LoginSignup
50
56

More than 5 years have passed since last update.

PHPで容量の大きいデータをCSV出力するときに工夫したこと

Posted at

前にもPHPでSJISのデカイCSVデータを扱った時に困ったことという記事を書いたけど、やっぱりCSVを扱うのって少し難しい。
今回は 「ログのデータをCSV出力してほしい」 という依頼があったときの話です。

検索をかければ、スニペットコードはたくさん見つかるのでなんとなく組み合わせて動くコードを書くところまではすんなりいったけれど、それだと容量の大きいデータを出力するときにうまくいかなかったりと手こずりました。

この記事では「容量の大きいデータだとCSV出力できないコード」をどうやって「最大20000件のデータまで出力できるように修正」したときのポイントなどについてまとめます。

備考

自分なりに調べて書いた記事なので、解釈が間違っている箇所もあるかもしれません。
間違っているところがあればコメントでご指摘いただけると幸いです!

仕様

  • Laravel5.4
  • t_logs というテーブルに入ってくるログをCSV出力してほしいとのこと
  • アプリケーションは Heroku にデプロイされている

いちばん最初に書いたコード(容量が大きくなければ動くコード)

Route::get('/log', function(){
    $stream = fopen('php://temp', 'w'); //1.一時的なファイルポインタを作成
    $result = DB::table("t_logs")->get();
    $data = [];
    foreach ($result as $log) {
        fputcsv($stream, array_values((array)$log))); //2.ファイルポインタに書き込む
    }
    rewind($stream); //3.ファイルポインタの位置を先頭に戻す
    $csv = str_replace(PHP_EOL, "\r\n", stream_get_contents($stream)); //4.改行コードの置き換え

    return Response::make($csv, 200, [ //5.CSV出力
        'Content-Type' => 'text/csv',
        'Content-Disposition' => "attachment; filename=t_logs.csv"
    ]);
});

1. 一時的なファイルポインタを作成

fopen() はファイルまたは URL をオープンする関数で、これを利用して一時的なファイルポインタを作成できる。
fopen() は第二引数にモードを指定することができ、これを変えれば「読み出しのみ」や「書き込み&読み出し可」のような指定が可能。

今回は

書き出しのみでオープンします。ファイルポインタをファイルの先頭に置き、 ファイルサイズをゼロにします。ファイルが存在しない場合には、 作成を試みます。

というモード w を使ってファイルポインタを作成した。

補足:ファイルポインタってなに?

ファイルの操作でよく出てくる「ファイルポインタ」というものは、サーバーがファイルを扱うための専用の"しおり"のようなもの。(どこまで読み取ったか、など)

補足: php://temp って?

php://memory および php://temp は読み書き可能なストリームで、一時データをファイルのように保存できるラッパーです。
両者の唯一の違いは、php://memory が常にデータをメモリに格納するのに対して php://temp は定義済みの上限 (デフォルトは 2 MB) に達するとテンポラリファイルを使うという点です。
このテンポラリファイルの場所は、 sys_get_temp_dir() 関数と同じ方法で決めます。

どちらもメモリ上に領域を確保するという点では同じだが、php://memory だとメモリリークしてしまうことがあるため、
2MBを超えた場合はテンポラリファイル(自動削除される一時ファイル)を作ってくれる php://temp を使うことにした。

補足:メモリリークってなに?

プログラムが確保したメモリの一部、または全部を解放するのを忘れ、確保したままになってしまうこと。

2. ファイルポインタに書き込む

fputcsv() を使って、列をCSV 形式にフォーマットしてファイルポインタに書き込んでいく。
このとき、 いちばん最後に改行が追加 される。

3. ファイルポインタを先頭に戻す

fputcsv() ファイルの書き込みが終わると、ファイルポインタの位置は最後に書き込みが終えた時点になっている。
次に行う処理は書き込んだテンポラリファイルの先頭から開始する必要があるため、 rewind() を使ってファイルポインタを先頭に戻す。

4. 改行コードを置き換え

fputcsv でファイルポインタを書き込んでいくときに改行コード(PHP_EOL)が追加されるが、この改行コードはOSに依存する。

OS 改行コード 改行コード文字
Windows CRLF \r\n
Linux,Unix,MACOS10 LF \n

(引用: https://qiita.com/kazu56/items/bc77582313918fe2a3b1

そのため、CSV出力したサーバーのOSと、実際にCSVを使うOSが異なる場合(LinuxとWindows)にうまいこと改行してくれない...ということが起こってしまう。

これを防ぐため、str_replace() を使って \r\n に置き換えた。(今回CSVを使うOSはWindows)

補足: stream_get_contents

file_get_contents() と似ていますが、 stream_get_contents() は既にオープンしているストリームリソースに対して操作を行います。
そして、指定した offset から始まる最大 maxlength バイトのデータを取得して文字列に 保存します。

今回はすでに fopen() でファイルはオープンしているため stream_get_contents() を使用し、文字列として読み込んだ。

5. CSV出力

文字列にしたデータを Response::make() を使ってCSV出力。

容量が大きすぎてエラーになった

実際にテスト環境で出力しようとしたところ、エラーとなってしまった。

Fatal error: Allowed memory size of ...

データが大きい時はどうやらメモリのことを考えないといけないらしい...。

原因となった部分

想定していたよりもデータの容量が大きく(Maxで20000件)、 stream_get_contents で文字列にするときにメモリに乗り切らなかった。

修正後

Route::get('/log', function(){
    $stream = fopen('php://temp', 'w');
    $result = DB::table("t_logs")->get();
    $data = [];
    foreach ($result as $log) {
        fputcsv($stream, str_replace(PHP_EOL, "\r\n", array_values((array)$log))); //修正 1.改行コードの置き換え時に文字列変換しない
    }
    rewind($stream); //注意:fpassthru() する前にもファイルポインタは戻しておく

    return response()->stream(function () use ($stream) { //修正 2. ストリームのままCSV出力できるようにする
        fpassthru($stream);
        fclose($stream);
    }, 200, [
        'Content-Type' => 'text/csv',
        'Content-Disposition' => "attachment; filename=t_logs.csv"
    ]);
});

修正 1. 改行コードの置き換え時に文字列変換しない

stream_get_contents() でストリームが文字列に変換されてしまうと、メモリが必要となってしまうため
fputcsv() で書き出すと同時に改行コードの処理をしてしまうことにした。

修正 2. ストリームのままCSV出力できるようにする

文字列に変換しないことになったので、出力方法にも工夫が必要。
Response::make() では文字列のみを許容するため、ストリームのまま出力できるるよう response()->stream() を使う。

この時、fpassthru() を使って、ファイルポインタ上に残っているすべてのデータを出力する。
テンポラリファイルはスクリプト終了時にも自動削除されるようだが、念のため fclose() で終了を明示した。

注意: fpassthru() する前にもファイルポインタは戻しておく

「改行の置き換えが済んだなら、ファイルポインタを戻す必要はないんじゃないの?」と思いきや、
ポインタが一番後ろに位置したままだと fpassthru() しても読み取るものがなく、空っぽのCSVが出力されてしまうので注意。

まとめ

容量の大きくないデータをCSV出力するのであれば最初のコードでも問題はないけれど、

  • どの関数がなんの役割をしているのか
  • どのような形式でデータを保持しているのか
  • ファイルポインタの挙動 etc...

といったことを理解していないと、今回のログデータのように容量の大きいデータを出力する際にはうまくいきませんでした。

CSV出力に限らず、何かを実現するために手法は何通りもあることが多いけれど、
状況に応じて工夫する必要は必ずでてくるので、関数を使ったりするときは公式ドキュメントなどもよんで理解することは大事だなぁと思いました。

参考

PHP関数・クラス

fopen
fputcsv
rewind
file_get_contents
stream_get_contents
fclose
fpassthru

その他

http://php-beginner.com/practice/file_ope/file_ope6.html
https://www.muchacolla.com/work/php/416/
https://qiita.com/mpyw/items/f24d3764fe3eedf132ff
http://www.standpower.com/php_analyz.html
http://php.net/manual/ja/wrappers.php.php
https://lab.flama.co.jp/archives/1139/
https://qiita.com/ma_me/items/fae63108dbce03290efb

50
56
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
50
56