PHP: fgetcsvでもSJISのCSVをUTF-8として《安全》に読む方法(ストリームフィルタ使用)

`fgetcsv`でShift-JIS文字コードのCSVを読むと、文字列がShift-JISのままの配列が返ってくるため、UTF-8を基本的な文字コードに据えたシステムでただ不便というだけにとどまらず、SJISのCSVを`fgetcsv`で直接読み取るのは「5C問題」のため危険である。

本稿では、ストリームフィルタを使ったSJIS CSVの安全な読み込み方法を紹介する。なお、本稿で作るストリームフィルタの完成形は[GitHub](https://github.com/suin/php-playground/tree/master/ReadingSjisCsvWithStreamFilter)で公開する。

## 5C問題とは

よく見かける次のような実装は5C問題のため、データによっては読めないものが出てくる危険性がある:

```php
$fp = fopen('sjis.csv', 'r');
while ($row = fgetcsv($fp) !== false) {
	// fgetcsvで読み取った後に、文字コードを変換している
	mb_convert_variables('UTF-8', 'SJIS-win', $row);
}
fclose($fp);
```

次のコードを実行すると5C問題を簡単に再現できる。

```php
$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`するような実装が考えられる。

```php
$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`は内部で次の処理をしている:

1. ファイルから文字列をN文字読み込む
2. 読み込んだ文字をパースして、CSVの行の配列にする

ストリームフィルタはこの1と2の間に割って入ることができる。したがって、次のような処理ができる:

1. ファイルから文字列をN文字読み込む
2. __その文字列をUTF-8に変換する (ストリームフィルタ)__
2. 読み込んだ文字をパースして、CSVの行の配列にする


## ストリームフィルタの作り方

ストリームフィルタは`php_user_filter`クラスを継承して作る。実装する必要があるのは、`filter`メソッドだけだ。ファイルのデータが8192バイト[^デフォルトで8192バイト]ごとに分割されて`$in`に渡ってくるので、`filter`メソッドではそのデータをSJISからUTF-8に変換し、`$out`に渡してやれば良い。

ただし、8192バイト目がSJIS的に切りの悪い場所かもしれないので、無頓着に8192バイトごとに文字コードを変換するのではなく、SJIS的に切りの良いところでデータを分けた上で文字コード変換するような処理にする必要がある。

[^デフォルトで8192バイト]: ストリームはデフォルトで8192バイトごとのチャンクになるが、`stream_set_chunk_size`関数と`stream_set_read_buffer`関数で実行時にチャンクサイズを変更することもできる。

```php:SjisToUtf8EncodingFilter.php
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`でファイル読み込み時に使うストリームフィルタを指定すると、このフィルタクラスが使われるようになる:

```php
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);
```