ストリームフィルタで文字コード変換してみる

PHPでもJavaやその他の言語のようにストリームにフィルタを挟むことが出来るようだ。
これを使うと透過的に圧縮処理を行ったり、Base64変換を行ったり、文字エンコーディング変換を行ったりできるとのこと。


公式マニュアルのストリーム関数をざっと眺めてみたところ、どうやらstream_filter_prepend(), stream_filter_append()がフィルタを追加する関数らしい。
が、マニュアルの情報が明らかに他の関数よりも劣っていて読んでもいまいちわからん。
使い方はサンプルコードでなんとなくわかるものの、肝心のフィルタにどんなものがあるのかわからん。
stream_get_filters()で利用可能なフィルタの一覧が取得できるというのでやってみると……

<?php
print_r(stream_get_filters());
?>
Array
(
    [0] => string.rot13
    [1] => string.toupper
    [2] => string.tolower
    [3] => string.strip_tags
    [4] => convert.*
    [5] => consumed
    [6] => convert.iconv.*
    [7] => bzip2.*
    [8] => zlib.*
)

string.toupperとかはまだわかるけど、〜.*ってなんだよ。何指定すればいいんだよ。

ソースコードを読んでみる

こりゃあ実装を見るしかあるめぇ。つーわけで、上記のフィルタ名を頼りにソースを紐解いてみた。
基本となるのはext/standard/filters.cで、convert.iconv.*やzlib.*なんかはext/iconv/iconv.cやext/zlib/zlib.cなど当該ライブラリの方で実装されているようだ。
ちなみにconvert.*はconvert.base64-encode(decode)とconvert.quoted-printable-encode(decode)の4種類が実装されていた。


実は透過的にエンコーディングを変換したかったので、convert.iconv.*のほうを見てみると、convert.iconv.from_charset/to_charsetという指定をすることが分かった。

試してみる

<?php
mb_language('Japanese');
mb_internal_encoding('UTF-8');

$file = $argv[1];
$fh = fopen($file, 'r');
if ($fh === false) {
    die('Could not open file.');
}
$sh = stream_filter_prepend($fh, 'convert.iconv.cp932/utf-8', STREAM_FILTER_READ);
if ($sh === false) {
    fclose($fh);
    die('Counld not apply stream filter.');
}

while (($line = fgets($fh)) !== false) {
    echo $line;
}

fclose($fh);
?>

って感じでShift JISのファイルを読み込ませてみたらちゃんとUTF-8で出てきた。おお、いいんじゃない?

何が嬉しいかというと

fgetcsv()が使いやすくなるってこと。
PHPCSVファイルを読み込む場合、まず最初にこいつが出てくるんだが、この関数はロケールの影響をもろに受けるので、異なるエンコーディングのファイルを読み込ませると文字化けしたりパースが狂って壊れたりという残念な結果になる。
じゃあロケール設定すればいいじゃんってことになるんだけど、最近は*.UTF-8しか標準で入ってなくて、fgetcsv()のためだけに追加すんのかよ! とか、ロケール設定はプロセス全体に影響を及ぼすからやばいだろ! とか色々あって採用できない。


で、なんでエンコーディング指定ができないんだよと愚痴っても仕方がないので、どっかから自作の関数とかを拾ってきて使うんだけど、エスケープが微妙に処理し切れてなかったり、改行が入ってるとダメだったりと微妙なのが多い*1ので、やっぱり出来ることなら標準の関数を使いたいよねーと。


そんで真っ先に思いつくのが、読み込むCSVファイルのエンコーディングを事前に合わせておくことなんだけど、ものすごく無駄な感が否めないので、どうせなら読みながら変換できねーのかよ、というところで上記の話に繋がる訳だ。
fgetcsv()の中身はどうしようもないので、ファイルハンドルにiconvのストリームフィルタをかましてから渡してやればfgetcsv()が読み出す頃には既に任意の(というかロケールに合わせた)エンコーディングで読み出されて万事解決、と。



2009年6月3日追記
この解決方法はロケールに合わせたエンコーディングに変換するというアプローチなので、ロケールがen_US.ISO-8859-1とかだったらやっぱり日本語ダメじゃね?
この場合、ストリームでUTF-8に変換しつつロケールもja_JP.UTF-8とかに設定しないといけない。
しかしja_JP.UTF-8が指定できない環境とかあるかもしれないし(Windowsとか)、汎用に解決するにはどうしたらいいんだ!?


残る問題点はストリームフィルタなんてほとんど使ってる人がいなさそうだからバグありそうだなぁってことと、fgetcsv()もまだ腐ってんじゃないの? ってところだな。
まぁでも、ざっと試した感じではうまく動いてる。

2009年5月29日追記

id:hnw氏によると、iconvフィルタは不正なバイトが含まれていると全体を0バイトに変換してしまうそうなので、同氏作のStream_Filter_Mbstringまたは、rsk氏作のStream_Filter_MBStringを使うのがよいみたいです。

フィルタの自作もできる

特定のメソッドを持ったクラスを作って、stream_filter_register()すれば任意のフィルタを追加することもできる模様。

*1:fgetcsv()自体も5.2系まではかなり怪しい動きだったけどw