🙆

PHPで高速・省メモリ・確実に日本語CSVを扱う方法

2021/01/30に公開

結論

PHPで文字化けするCSV処理を文字化けしなくする方法を参照頂ければそのお悩みはすぐ解決します。

要点

  • composer require fw3/streamsとしてfw3/streamsを使えば、日本語CSV取り扱いにおける労苦から解放されるよ。
    • PHP5.3.3以降でもcomposer require fw3_for_old/streamsとしてfw3_for_old/streamsを使えば、日本語CSV取り扱いにおける労苦から解放されるよ。
    • 仮にcomposerが使用できない環境でもこの手順にしたがってZIPからファイルを展開すれば、日本語CSV取り扱いにおける労苦から解放されるよ。
    • ただし、5.3.3以降対応版についてはnamespace (useから始まる行)をfw3\からfw3_for_old\に書き換える必要があるよ。
      • 例:use fw3_for_old\streams\filters\ConvertEncodingFilter;
  • ラッパーメソッドもいいけど、\fgetcsv関数、\fputcsv関数、\SplFileObjectを使ってもシンプルに実現できるかもしれないよ。しかもより高速に動作する。
  • 取り急ぎ"PHPで高速・省メモリ・確実に日本語CSVを扱いたい"場合は使い方へどうぞ。

必要なもの

PHP 7.2.0以降のPHP + ext-mbstring + fw3/streams

または

PHP 5.3.3以降のPHP + ext-mbstring + みんなのPHP 現場で役立つ最新ノウハウ!のChapter 7.3 サポートコード

または

PHP 5.3.3以降のPHP + ext-mbstring + fw3_for_old/streams

お断り

  • この記事においてShift_JISとはWindows-31Jを指します。
  • この記事において入力元・出力先のCSVファイルは文字セット:Shift_JIS、改行コード:CRLFとして扱います。
  • この記事において入力先・出力元のデータは文字セット:UTF-8として扱います。

この記事はどういうお話なの?

様々なCSV入出力方法の課題を挙げ、それらの課題に対して一括で対処できるライブラリの紹介と使い方についてお話します。

既知の日本語CSV読み書き時の課題

既存の日本語CSV読み書きには次の課題がありました。

\fgetcsv関数、\fputcsv関数、\SplFileObject::fgetcsv\SplFileObject::fputcsvだと良くわからないタイミングで文字化けが発生する

PHP標準関数、標準クラスとして用意されている\fgetcsv関数、\fputcsv関数、\SplFileObject::fgetcsv\SplFileObject::fputcsv
ASCIIのみで構成されたCSVを扱うには必要十分でしたが、日本語での文字セットを持つCSVファイルを扱う場合、文字化けが発生したり意図しない箇所で解析が終わるなど、問題のある場合がありました。

具体的には\fgetcsv関数、\fputcsv関数、\SplFileObject::fgetcsv\SplFileObject::fputcsvはOS設定であるロカールの影響を受け、ロカールの文字セットと読み書きするCSVの文字セットが一致していない場合、文字化けが発生します。

ロカールの設定自体はPHPでも\setlocaleで実行時に任意に変更することが出来るのですが、同一リクエスト中の処理全体に影響を与えることと、期待するロカールがインストールされている保証がないため、大変扱いにくい状況にありました。

<?php
// 入力
$locale = \setlocale(LC_ALL, '0');

\setlocale(LC_ALL, 'Japanese_Japan.932'); // もしも'Japanese_Japan.932'がインストールされていれば。期待薄。なければ正常に動作しない。	

$fp = \fopen($path_to_csv, 'rb');
for (;($row = \fgetcsv($fp, 1024)) !== FALSE;) {
    \mb_convert_variables('UTF-8', 'SJIS-win', $row);
    $rows[] = $row;
}
\fclose($fp);

\setlocale(LC_ALL, $locale);

// または
$locale = \setlocale(LC_ALL, '0');

\setlocale(LC_ALL, 'Japanese_Japan.932'); // もしも'Japanese_Japan.932'がインストールされていれば。期待薄。なければ正常に動作しない。	

$fileObject = new \SplFileObject($path_to_csv, 'rb')
$fileObject->setFlags(\SplFileObject::READ_CSV);
foreacho ($fileObject as $row) {
    \mb_convert_variables('UTF-8', 'SJIS-win', $row);
    $rows[] = $row;
}

\setlocale(LC_ALL, $locale);
<?php
// 出力
$fp = \fopen($path_to_csv, 'wb');
foreacho ($rows as $row) {
    \mb_convert_variables('SJIS-win', 'UTF-8', $row);
    \fputcsv($fp, $row);
}
\fclose($fp);

// または
$fileObject = new \SplFileObject($path_to_csv, 'wb')
foreacho ($rows as $row) {
    \mb_convert_variables('SJIS-win', 'UTF-8', $row);
    $fileObject->fputcsv($row);
}

\file_get_contents関数、\file_put_contents関数で実装するとプロセスあたりのメモリが足りなくなる

ロカールに頼らず文字セットを変更しようとする場合、\mb_convert_encoding関数を用いることが定石です。

この場合、出力時は逐次変換を行う選択も取れるのですが、入力時はどこまでが文字セット変換の対象文字列かがわからないため、一度すべてを変数に乗せる必要がありました。

そのため、対象となるファイルが巨大な場合、メモリーリミットに到達しfatalエラーを誘発し、UXが最悪となる、いわゆる白画面が表示されることとなりました。

<?php
// 入力
$csv_string = \mb_convert_encoding(\file_get_contents($path_to_csv), 'UTF-8', 'SJIS-win');

$locale = \setlocale(LC_ALL, '0');
\setlocale(LC_ALL, 'ja_JP.utf8'); // もしも'ja_JP.utf8'がインストールされていれば。なければ正常に動作しない。

$fp = \fopen('php://memory/maxmemory:100', 'wb+');
\fwrite($fp, $csv_string);
\rewind(0);
for (;($row = \fgetcsv($fp, 1024)) !== FALSE;) {
    $rows[] = $row;
}
\fclose($fp);

\setlocale(LC_ALL, $locale);

一時ファイルを使用するとシステム全体の実行効率が悪化する

\fopen関数 + \fread関数で読み込めたところまでで文字セット変換し、そのままファイルに出力する」という手段が用いられることもあります。

ただしこれは大量のファイルI/Oが発生するため、実行速度が低下したり、システム全体の負荷として他のリクエストや処理にまで悪影響を与えます。
また、一時ファイルの出力先や一時ファイルそのものなど、関心ごとの外まで管理しなければならないため、使い勝手がよくありませんでした。

<?php
// 入力
$locale = \setlocale(LC_ALL, '0');

\setlocale(LC_ALL, 'Japanese_Japan.932'); // もしも'Japanese_Japan.932'がインストールされていれば。期待薄。なければ正常に動作しない。	

$fp         = \fopen($path_to_csv, 'rb');
$temp_fp    = \fopen('php://temp', 'wb+');
for (;($row = \fgets($fp, 1024)) !== FALSE;) {
    \fwrite($temp_fp, \mb_convert_encoding('UTF-8', 'SJIS-win', $row));
}
\rewind(0);
for (;($row = \fgetcsv($temp_fp, 1024)) !== FALSE;) {
    $rows[] = $row;
}
\fclose($temp_fp);
\fclose($fp);

\setlocale(LC_ALL, $locale);

\popen関数で実装すると物理メモリが足りなくなっても気づけない

PHPで600MBほどのSJISのCSVを読み込んでUTF-8として処理する場合のサンプルコード - Qiitaにサンプルコードがありますが、\popen関数を用いて現在のプロセスとは別のプロセスでファイル全体に対して一括で文字セット変換を行う手段もあります。

ですが、これは意図して設定していたはずのmemory_limitを解除して動作させてしまうため、誰も意図していないサイズのメモリを確保される恐れが高いです。
また、\popen関数で開いたプロセスを正しく閉じなかった場合に意図しない状況が発生する恐れが高いです。

iconvストリームフィルタを使用すると文字セット変換に失敗した行が消える

iconvストリームフィルタを使うことで透過的に文字セット変換を行うことができます。

ですが、iconvストリームフィルタでは変換できない文字を含んだ行は0バイトとして扱われ削除されます。
出力されたCSV上では正しく1行が消えただけにしか見えません。
そのため、運用中に不具合があったことに気づくことが難しいです。

また、変換できない文字が存在した場合、強制的にNoticeが表示され、これを阻止する事は出来ません。
そのため、開発環境モードのLaravelなど、Noticeをトラップして例外としてスローする環境では必ずそこで処理が停止するため、作業の手が止まることになります。

<?php
// 入力
$fp = \fopen(\sprintf('php://filter/read=convert.iconv.Windows-31J%2FUTF-8/resource=%s', $path_to_csv), 'rb');
for (;($row = \fgetcsv($fp, 1024)) !== FALSE;) {
    $rows[] = $row;
}
\fclose($fp);
<?php
// 出力
$fp = \fopen(\sprintf('php://filter/write=convert.iconv.UTF-8%2FWindows-31J/resource=%s', $path_to_csv), 'rb');
foreacho ($rows as $row) {
    \mb_convert_variables('SJIS-win', 'UTF-8', $row);
    \fputcsv($fp, $row);
}
\fclose($fp);

既知の日本語CSV読み書き時の課題のまとめ

以上が既知の日本語CSV読み書き時の課題となります。
これらを問題単位で切り分けると次になります。

  • 正しいロカールがインストールされていないとCSVとして正しく読み込めない
  • 実行時に過大なメモリを消費する
  • 一時ファイルで回避した場合、システム全体のリソースを圧迫する

課題解決:このライブラリを使うことで得られる効果や副次的な効果

このライブラリを使うことで得られる効果

次の通り、既知の課題を全て解決できます。

  • ロカールのインストール状況問わず、正しくCSVとして読み込める
  • 行単位でメモリ展開、文字セット変換を行うため省メモリ
  • \fopen関数や\SplFileObject使用箇所などの既存実装に組み込みやすい
  • PHP nativeに近い箇所で動作するため、PHP標準関数の支援を受けやすい
  • Tempファイルなどの一時出力先が不要

副次的な効果

また、ライブラリ実装者の知見が反映されているため、次の副次的な効果を得られます。

  • エンコード自動判定時に正確な判定が期待できる
  • 変換できない文字があった場合のふるまいを決められる
  • 意図した行末改行文字で出力を行える
    • \fputcsv関数はシステムのOSに依存して出力時の改行コードが変わります
  • Specクラス経由で設定を注入することもできるため、直感的かつ確実に、経験が薄くても高速に設定を構築できます

使い方

単純に使うだけならば、次の3パターンのみで問題ありません。

デリミタやエンクロージャ、エスケープを変更したい場合は\fgetcsv関数、\fputcsv関数の引数や、\SplFileObject::setCsvControlによる設定を使用してください。

5.3.3以降対応版についてはnamespace (useから始まる行)をfw3\からfw3_for_old\に書き換えて実行してください。
例:use fw3_for_old\streams\filters\ConvertEncodingFilter;

読み込み

<?php

use fw3\streams\filters\ConvertEncodingFilter;
use fw3\streams\filters\utilitys\StreamFilterSpec;
use fw3\streams\filters\utilitys\specs\StreamFilterConvertEncodingSpec;

// CSVファイルのパスを設定して下さい
$path_to_csv    = '';

// フィルタ登録
StreamFilterSpec::registerConvertEncodingFilter();

// デリミタ、エンクロージャ、エスケープの設定
$delimiter  = ','; // TSVにしたい場合にはここにタブを設定する
$enclosure  = '"';
$escape     = "\\";

// フィルタの設定
$spec   = StreamFilterSpec::resource($path_to_csv)->read([
    StreamFilterConvertEncodingSpec::toUtf8()->fromSjisWin(),
]);

// \fopenでの実装
// Shift_JIS => UTF-8としてCSV読み込みを行う
$rows   = [];

$fp     = \fopen($spec->build(), 'rb');

for (;($row = \fgetcsv($fp, 1024, $delimiter, $enclosure, $escape)) !== FALSE;) {
    $rows[] = $row;
}
\fclose($fp);

// \SplFileObjectでの実装
// Shift_JIS => UTF-8としてCSV読み込みを行う
$rows   = [];

$fileObject = \SplFileObject($spec->build(), 'rb');
$fileObject->setFlags(\SplFileObject::READ_CSV);
$fileObject->setCsvControl($delimiter, $enclosure, $escape);

foreach ($fileObject as $row) {
    $rows[] = $row;
}

書き込み

<?php

use fw3\streams\filters\ConvertEncodingFilter;
use fw3\streams\filters\ConvertLinefeedFilter;
use fw3\streams\filters\utilitys\StreamFilterSpec;
use fw3\streams\filters\utilitys\specs\StreamFilterConvertEncodingSpec;
use fw3\streams\filters\utilitys\specs\StreamFilterConvertLinefeedSpec;

// CSVファイルのパスを設定して下さい
$path_to_csv    = '';

// フィルタ登録
StreamFilterSpec::registerConvertEncodingFilter();
StreamFilterSpec::registerConvertLinefeedFilter();

// デリミタ、エンクロージャ、エスケープの設定
$delimiter  = ','; // TSVにしたい場合にはここにタブを設定する
$enclosure  = '"';
$escape     = "\\";

// フィルタの設定
$spec   = StreamFilterSpec::resource($path_to_csv)->write([
    StreamFilterConvertEncodingSpec::toSjisWin()->fromUtf8(),
    StreamFilterConvertLinefeedSpec::toCrLf()->fromAll(),
]);

// \fopenでの実装
// UTF-8 => Shift_JISとしてCSV書き込みを行う
$fp     = \fopen($spec->build(), 'wb');

foreach ($rows as $row) {
    \fputcsv($fp, $row);
}
\fclose($fp);

// \SplFileObjectでの実装
// UTF-8 => Shift_JISとしてCSV書き込みを行う
$fileObject = \SplFileObject($spec->build(), 'wb');
$fileObject->setCsvControl($delimiter, $enclosure, $escape);

foreach ($rows as $row) {
    $fileObject->fputcsv($row);
}

直接ダウンロードさせる場合

<?php

use fw3\streams\filters\ConvertEncodingFilter;
use fw3\streams\filters\ConvertLinefeedFilter;
use fw3\streams\filters\utilitys\StreamFilterSpec;
use fw3\streams\filters\utilitys\specs\StreamFilterConvertEncodingSpec;
use fw3\streams\filters\utilitys\specs\StreamFilterConvertLinefeedSpec;

// CSVファイルのパスを設定して下さい
$path_to_csv    = '';

// フィルタ登録
StreamFilterSpec::registerConvertEncodingFilter();
StreamFilterSpec::registerConvertLinefeedFilter();

// デリミタ、エンクロージャ、エスケープの設定
$delimiter  = ','; // TSVにしたい場合にはここにタブを設定する
$enclosure  = '"';
$escape     = "\\";

// フィルタの設定
$spec   = StreamFilterSpec::resourceOutput()->write([
    StreamFilterConvertEncodingSpec::toSjisWin()->fromUtf8(),
    StreamFilterConvertLinefeedSpec::toCrLf()->fromAll(),
]);

// DL用のヘッダ:簡易版
\header('Content-Type: application/octet-stream');
\header('Content-Disposition: attachment; filename=sample.csv');

// \fopenでの実装
// UTF-8 => Shift_JISとしてCSV書き込みを行う
$fp     = \fopen($spec->build(), 'wb');

foreach ($rows as $row) {
    \fputcsv($fp, $row);
}
\fclose($fp);

使用上の注意点

[必須] 日本語CSVの読み込みを行う場合はConvertEncodingFilter::startChangeLocale()を実行してください

適切なロカールが設定できていないと、\fgetcsv関数、\SplFileObject::fgetcsvによるCSV読み込み時に意図しない動作となります。

[必須] フィルタの使用を終えた場合はConvertEncodingFilter::endChangeLocale()を実行してください

ConvertEncodingFilter::endChangeLocale()を実行することで、ConvertEncodingFilter::startChangeLocale()を実行する前の状態に戻すことができます。

ロカールの設定は同一プロセス中(HTTPアクセスの場合は一般的に一リクエスト中)維持されます。
その為、ロカールの設定を元に戻さないと、意図しない箇所で意図しないロカールでの処理が実行されるリスクがあります。

[必須] フィルタを使う前にStreamFilterSpec::registerConvertEncodingFilter()StreamFilterSpec::registerConvertLinefeedFilter()を使用しフィルタの登録を行ってください

フィルタが登録されていない場合、当然のことながらフィルタを使用する事はできません。
StreamFilterSpec::registerConvertEncodingFilter()StreamFilterSpec::registerConvertLinefeedFilter()を使用してストリームフィルタを設定してください。

Discussion