fgetcsv
でShift-JIS文字コードのCSVを読むと、文字列がShift-JISのままの配列が返ってくるため、UTF-8を基本的な文字コードに据えたシステムでただ不便というだけにとどまらず、SJISのCSVをfgetcsv
で直接読み取るのは「5C問題」のため危険である。
本稿では、ストリームフィルタを使ったSJIS CSVの安全な読み込み方法を紹介する。なお、本稿で作るストリームフィルタの完成形はGitHubで公開する。
5C問題とは
よく見かける次のような実装は5C問題のため、データによっては読めないものが出てくる危険性がある:
$fp = fopen('sjis.csv', 'r');
while ($row = fgetcsv($fp) !== false) {
// fgetcsvで読み取った後に、文字コードを変換している
mb_convert_variables('UTF-8', 'SJIS-win', $row);
}
fclose($fp);
次のコードを実行すると5C問題を簡単に再現できる。
$data = mb_convert_encoding('"表"', 'SJIS-win', 'UTF-8');
$csv = tmpfile();
fwrite($csv, $data);
rewind($csv);
var_dump(fgetcsv($csv)); //=> bool(false)
このコードは、「表」という単語をダブルクオーテーションで囲った"表"
というデータを持つSJISのCSVをfgetcsv
で読み込むデモである。期待値は、次のような2バイトの文字列を含んだ配列が返されることだが、5C問題のためfalse
が返ってくる。
array(1) {
[0] =>
string(2) "��"
}
5C問題とは、「ソ」「表」「十」「予」などSJISの文字の2バイト目が「5C」になる文字が、fgetcsvなどの処理機に与えられたとき、それが文字の一部としてでなく、ASCIIのバックスラッシュ(5C)と判断してしまい、エスケープ文字として解釈される問題を言う。
したがって、fgetcsv
は"表"
を(ダブルクオーテーション) (謎の文字) (エスケープ記号) (ダブルクオーテーション)
と解釈し、閉じ側のダブルクオーテーションがエスケープされた結果、カラムの終わりが無い不正なCSVの行と判断され、false
を返す。
fgetcsvのCSVパース処理以前に文字コードをUTF-8にしておく
fgetcsvで5C問題を起こさないためには、fgetcsvでパースするデータをUTF-8にしておくと良い。UTF-8はSJISとは設計が異なり、5Cが1文字の途中のバイトに出てくることがないため、理論上5C問題は存在しない。
事前に文字コードを変換しておく方法として、file_get_contents
などでSJISのCSVの中身を文字列で取り出し、mb_convert_encoding
でUTF-8に変換し、それをfile_put_contents
でファイルに書き出し、UTF-8のCSVを作り、それをfopen
するような実装が考えられる。
$sjis = file_get_contents('sjis.csv');
$utf8 = mb_convert_encoding($sjis, 'UTF-8', 'SJIS-win');
file_put_contents('utf8.csv', $utf8);
$fp = fopen('utf8.csv', 'r');
この方法は、容量が小さなCSVでは上手くいくが、CSVのデータが多く何十メガバイトもあったりすると、PHPのメモリが足りなくなり処理できないといった性能上の限界がある実装だ。
ストリームフィルタでCSVパースする直前に文字コードをUTF-8にする
PHPのファイル関数には、書き込み前や読み込み後に何らかの変換処理を加えられる仕組みがある。これはストリームフィルタと呼ばれる。
fgetcsv
は内部で次の処理をしている:
- ファイルから文字列をN文字読み込む
- 読み込んだ文字をパースして、CSVの行の配列にする
ストリームフィルタはこの1と2の間に割って入ることができる。したがって、次のような処理ができる:
- ファイルから文字列をN文字読み込む
- その文字列をUTF-8に変換する (ストリームフィルタ)
- 読み込んだ文字をパースして、CSVの行の配列にする
ストリームフィルタの作り方
ストリームフィルタはphp_user_filter
クラスを継承して作る。実装する必要があるのは、filter
メソッドだけだ。ファイルのデータが8192バイト1ごとに分割されて$in
に渡ってくるので、filter
メソッドではそのデータをSJISからUTF-8に変換し、$out
に渡してやれば良い。
ただし、8192バイト目がSJIS的に切りの悪い場所かもしれないので、無頓着に8192バイトごとに文字コードを変換するのではなく、SJIS的に切りの良いところでデータを分けた上で文字コード変換するような処理にする必要がある。
final class SjisToUtf8EncodingFilter extends \php_user_filter
{
/**
* Buffer size limit (bytes)
*
* @var int
*/
private static $bufferSizeLimit = 1024;
/**
* @var string
*/
private $buffer = '';
public static function setBufferSizeLimit(int $bufferSizeLimit): void
{
self::$bufferSizeLimit = $bufferSizeLimit;
}
/**
* @param resource $in
* @param resource $out
* @param int $consumed
* @param bool $closing
*/
public function filter($in, $out, &$consumed, $closing): int
{
$isBucketAppended = false;
$previousData = $this->buffer;
$deferredData = '';
while ($bucket = \stream_bucket_make_writeable($in)) {
$data = $previousData . $bucket->data; // 前回後回しにしたデータと今回のチャンクデータを繋げる
$consumed += $bucket->datalen;
// 受け取ったチャンクデータの最後から1文字ずつ削っていって、SJIS的に区切れがいいところまでデータを減らす
while ($this->needsToNarrowEncodingDataScope($data)) {
$deferredData = \substr($data, -1) . $deferredData; // 削ったデータは後回しデータに付け加える
$data = \substr($data, 0, -1);
}
if ($data) { // ここに来た段階で $data は区切りが良いSJIS文字列になっている
$bucket->data = $this->encode($data);
\stream_bucket_append($out, $bucket);
$isBucketAppended = true;
}
}
$this->buffer = $deferredData; // 後回しデータ: チャンクデータの句切れが悪くエンコードできなかった残りを次回の処理に回す
$this->assertBufferSizeIsSmallEnough(); // メモリ不足回避策: バッファを使いすぎてないことを保証する
return $isBucketAppended ? \PSFS_PASS_ON : \PSFS_FEED_ME;
}
private function needsToNarrowEncodingDataScope(string $string): bool
{
return !($string === '' || $this->isValidEncoding($string));
}
private function isValidEncoding(string $string): bool
{
return \mb_check_encoding($string, 'SJIS-win');
}
private function encode(string $string): string
{
return \mb_convert_encoding($string, 'UTF-8', 'SJIS-win');
}
private function assertBufferSizeIsSmallEnough(): void
{
\assert(
\strlen($this->buffer) <= self::$bufferSizeLimit,
\sprintf(
'Streaming buffer size must less than or equal to %u bytes, but %u bytes allocated',
self::$bufferSizeLimit,
\strlen($this->buffer)
)
);
}
}
これのクラスを作ったら、stream_filter_register
でストリームフィルタとして登録し、stream_filter_append
でファイル読み込み時に使うストリームフィルタを指定すると、このフィルタクラスが使われるようになる:
stream_filter_register(
'sjis_to_utf8_encoding_filter',
SjisToUtf8EncodingFilter::class
);
$fp = fopen('sjis.csv', 'r');
stream_filter_append($fp, 'sjis_to_utf8_encoding_filter');
while ($row = fgetcsv($fp) !== false) {
// $rowはすでにUTF-8になっている
}
fclose($fp);
-
ストリームはデフォルトで8192バイトごとのチャンクになるが、
stream_set_chunk_size
関数とstream_set_read_buffer
関数で実行時にチャンクサイズを変更することもできる。 ↩