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

前にも[PHPでSJISのデカイCSVデータを扱った時に困ったこと](https://qiita.com/shosho/items/b38d653a960bbbc010d6)という記事を書いたけど、やっぱりCSVを扱うのって少し難しい。
今回は **「ログのデータをCSV出力してほしい」** という依頼があったときの話です。

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

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

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

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

## いちばん最初に書いたコード（容量が大きくなければ動くコード）
```php
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` で文字列にするときにメモリに乗り切らなかった。

## 修正後
```php
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](http://php.net/manual/ja/function.fopen.php)
[fputcsv](http://php.net/manual/ja/function.fputcsv.php)
[rewind](http://php.net/manual/ja/function.rewind.php)
[file_get_contents](http://php.net/manual/ja/function.file-get-contents.php)
[stream_get_contents](http://php.net/manual/ja/function.stream-get-contents.php)
[fclose](http://php.net/manual/ja/function.fclose.php)
[fpassthru](http://php.net/manual/ja/function.fpassthru.php)

### その他
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