クラウド時代のWebアプリケーション・スマートフォンアプリを開発・運用する会社です。 03-4577-8680 03-6673-4950

PHPでCSVを生成する (ただしメモリ・CPUを抑えて)

2019-07-12

2017/6 追記:
この記事が意図に違った紹介のされ方をしてしまっているようなので、念のため注意喚起させていただきます。

  1. 「CSVを生成するときにはfputcsv()を使う」ということを紹介している記事ではありません。
  2. 教科書通りの方法では、現実にはサーバ障害を発生させかねない危険性を説明した記事です。
  3. この記事では、結論に至るまで順を追って記載しています。つまり結論は最後に書いてあります。

単純にCSVを作ることは簡単です。
しかし単純な方法で実装すると、次のような問題でやられたりします。

  • CSVダウンロードボタンを押してしばらくしたら画面が真っ白に
  • CSVがダウンロードされている間サーバに誰もアクセスできない
  • CSVダウンロードボタンを押した瞬間にサーバがハングする

要するに、行数が少なければどのような方法でも変わりありませんが、単純な方法ではサーバリソース上の問題が出る場合がある、ということです。

だいたいブラウザ画面からCSVをダウンロードするということは、管理画面的なものがあって、条件検索してデータを絞り込んで出力するということです。
たとえばこれが年月日単位で検索するデータであれば、日次バッチで生成しておいたものを合体してダウンロードさせるというのが鉄板ですね。
ですので、そういう検索の場合は除外して、いわゆるマスタデータに対する検索のようなものを想定することとします。

対象レコード数が10万、テーブルのカラム数が50、検索条件対象カラム数が30として、考えてみましょう。

話がややこしくなるので、ここではタイムアウト問題( max_execution_time の影響など)は横に置きます。また、DBの検索対象テーブルにINDEXが正しく設定されているか否かというのも当然大きい要素ではありますが、これもここでは除外します。

では、想定されるいくつかの方法と、その問題点をそれぞれ追ってみます。

生成方法

1.変数に出力テキストを追加していく方式

たとえば入門書やブログなどで紹介されている最もシンプルな方法は次のようなものだと思います。


$buf = \’\’;
$data = array(
array(…),

);
foreach($data as $datum){
$buf .= \’\”\’.implode(\’\”,\”\’, $datum).\’\”\’.PHP_EOL;
}
header(\’Content-Type: application/octet-stream\’);
header(\’Content-Disposition: attachment; filename=hoge.csv\’);
print $buf;

これ単体でダメだという話ではありません。
しかし、$dataが100件ならいいですが、数千件になるようであればCPU使用率を振り切るかメモリを使い切って落ちたりしだします。
ですので、上記コード例は絶対にマネしてはいけない、ということになりますね。

特定の入門書やブログをdisっているわけではありません。実装として正しくないだけで概念の説明としては正しいと思うので。

2.いったんサーバ内にファイル保存する方式

これはどういうことかというと、サーバローカルにfopenしたファイルに対して、1行づつCSVを追記していき、全部終わったところでreadfileなりして返却する、という方式です。


$tmp_file = \’tmp.csv\’;
$fp = fopen($tmp_file,\’a\’);
$data = array(
array(…),

);
foreach($data as $datum){
fputcsv($fp, $datum, \’,\’,\’\”\’);
}
fclose($fp);
header(\’Content-Type: application/octet-stream\’);
header(\’Content-Disposition: attachment; filename=hoge.csv\’);
readfile($tmp_file);
unlink($tmp_file);

この方式ではメモリは抑えられますが、ファイルI/Oが10万回発生するということになると、スペック低めのサーバではCPU使用率が振り切れます。

3.メモリ内に一時保存する方式

php://memory や php://temp などのストリームラッパーを使用して、データをメモリに保存していくという方法です。
両者の違いは、php://memory が全てをメモリ上に保持するのに対して php://temp は一定以上の容量に達した場合自動的にテンポラリファイルに逃がされる、という点です。
php://temp がファイル保存になる閾値はデフォルトで2MBということなので、php://temp/maxmemory:5242880 などとすれば、「5MBを超えるまではメモリ上に保持」ということになります。

php://memory 一択のように思えますが、今回の想定のように、結果データの容量が読めない場合は php://temp の方が明らかに安全ですね。

メモリ上に保持されている限りは問題がありませんが、テンポラリファイル扱いになった瞬間からファイルI/Oが大量に発生しCPU使用率が激増することになるので注意が必要です。

#この機能は/tmpなどのベースとなる仕組みを知っている前提になっているので、
#書けば使える式に考えられると危険な部類のものですよね


$fp = fopen(\’php://temp/maxmemory:5242880\’,\’a\’);
$data = array(
array(…),

);
foreach($data as $datum){
fputcsv($fp, $datum, \’,\’,\’\”\’);
}
header(\”Content-Type: application/octet-stream\”);
header(\”Content-Disposition: attachment; filename=hoge.csv\”);
rewind($fp);
print stream_get_contents($fp);
fclose($fp);

4.ブラウザに直接返却する方式

ここまで長々と書いておきながらそれを覆すような話ですが、そもそもブラウザにCSVが返せればよいわけで、ファイル保存の必要が別途存在するのでない限り、php://output で直接ブラウザに返却するのが最短の手段です。


header(\”Content-Type: application/octet-stream\”);
header(\”Content-Disposition: attachment; filename=hoge.csv\”);
$fp = fopen(\’php://output\’,\’w\’);
$data = array(
array(…),

);
foreach($data as $datum){
fputcsv($fp, $datum, \’,\’,\’\”\’);
}
fclose($fp);

この方式の場合、出力がHTTPレスポンスそのものになるため、ブラウザに対してレスポンスデータを「ダウンロード」として開始するよう指定しなければなりません。
ですので、header()の位置は重要ですね。

ただContent-lengthヘッダを送出することができないため、ブラウザ側の「ダウンロード残り時間」や「ダウンロードゲージ」は表示されなくなります。この点は一応注意が必要です。

文字コード

ここまでの説明で意図的に無視してきた要素がもう一つあります。
それは「Microsoft Excelが想定している日本語CSVのデフォルトエンコードは\”Shift_JIS\”である」(正確にはShift_JISではありませんが)という、おなじみの件です。

上記に挙げたコードではおそらくUTF-8のCSVができ上がってしまうでしょう。
「どうせExcelで開いた時コンバートするんだから別にいいじゃん」と言える世界の住人とはここでお別れです。

さてそれでは続きます。

UTF-8をSJIS-winに変換するということは、ごく単純に考えると、
・fputcsvの引数に指定する前に配列要素に対して再帰的に文字コード変換をかける
・fputcsvの使用をあきらめていったん変換した文字列をfwriteする
という判断になるかと思います。

しかしストリームフィルタを使えば、fputcsvをあきらめる必要はありません。
stream_filter_append
http://www.php.net/manual/ja/function.stream-filter-append.php
先ほどのコードで言うと、fopenの後、出力が始まる前に追加します。


header(\”Content-Type: application/octet-stream\”);
header(\”Content-Disposition: attachment; filename=hoge.csv\”);
$fp = fopen(\’php://output\’,\’w\’);
stream_filter_append($fp, \’convert.iconv.UTF-8/CP932\’, STREAM_FILTER_WRITE);
$data = array(
array(…),

);
foreach($data as $datum){
fputcsv($fp, $datum, \’,\’,\’\”\’);
}
fclose($fp);

stream_get_filters()のの結果に\”convert.iconv.*\”が存在しない場合はもちろん動作しません。
もっと細やかな変換を要する場合にはフィルタクラスを作って stream_filter_register で登録することになりますが、文字コード変換だけであればこれで十分です。

#もちろん、UTF-8からShift_JISへの変換にともなう問題(「ハシゴ高」など)はあります。

こうした処置がどれだけサーバリソースを抑えられるか、というのは、もちろん環境に依存するところは多々あります。
しかし経験上も、あまり真っ先に疑う個所ではなかったりすると思うので、ハマりポイントとして挙げておくと何かの役に立つかもしれないと思う次第です。