先日、CSVダウンロードで盛大にコケまくったので、メモ。
わりとコードが長くなってしまったので、fopenが失敗した場合や文字コードの変換については書いていません。
経過なんてどうでもいい!結果だけくれ!という方は下から読むといいと思います。
CSVダウンロードの簡単な実装
頑張って手作りするとこんな感じ。
<?php public function downloadCSV() { $csv = [ ['id', 'name', 'age'], ['1', 'tanaka', '20'], ]; $csvContent = ''; foreach ($csv as $line) { $csvContent .= '"' . implode(',', $line) . '"'; $csvContent .= PHP_EOL; } return response( $csvContent, 200, [ 'Content-Type' => 'text/csv', 'Content-Disposition' => 'attachment; filename="users.csv"', ] ); }
初めて作ってみるとこんな感じになりますよね。
ちなみにこの実装は非常によろしくないです。
CSVのエスケープ処理が充分に考えられていません。(ダブルクォーテーションが値に入ってたら崩れます)
fputcsvを使う
自分でCSVのエスケープを書くのは面倒なので、 fputcsv
関数を使用するように変更します。
fputcsvはfopenの返り値に対して使用できるので、いちど適当にファイルに吐いてから取得するようにします。
<?php public function downloadCSV() { $csv = [ ['id', 'name', 'age'], ['1', 'tanaka', '20'], ]; $stream = fopen('/temp/users.csv', 'w'); foreach ($csv as $line) { fputcsv($stream, $line); } fclose($stream); rewind($stream); $csvContent = stream_get_contents($stream); return response( $csvContent, 200, [ 'Content-Type' => 'text/csv', 'Content-Disposition' => 'attachment; filename="users.csv"', ] ); }
これで正確なCSVを出力できるようになりました。
ですが、データが多いとファイルI/Oが頻繁に発生しそうです。
php://output を使う
いちどファイルに入れずに、直接出力してしまえば、ファイルI/Oは気にしなくて済みます。
ファイルのパスを php://output
に変更しましょう。
<?php public function downloadCSV() { $csv = [ ['id', 'name', 'age'], ['1', 'tanaka', '20'], ]; $stream = fopen('php://output', 'w'); foreach ($csv as $line) { fputcsv($stream, $line); } fclose($stream); return response( '', 200, [ 'Content-Type' => 'text/csv', 'Content-Disposition' => 'attachment; filename="users.csv"', ] ); }
これでファイルI/Oを気にしなくてよくなりました!
ですが、データが多いとヘッダーが無視され、ブラウザ上に出力されてしまいます!
理由はヘッダーを送る前にデータを送っているからのようです。
おそらく、ブラウザかサーバー(apacheとか)で「こんだけ待ってもヘッダー来ないから、もうデフォルトのヘッダーだと思って出力しちゃえ!」ってなってるんだと思います。
StreamedResponseを使う
先にヘッダーを送ってしまえばこの問題は解決しそうです。
response
関数は第一引数に出力内容を文字列で渡しますが、 StreamedResponse
クラスは出力内容をコールバックで渡すことができます。
<?php use Symfony\Component\HttpFoundation\StreamedResponse; public function downloadCSV() { return new StreamedResponse( function () { $csv = [ ['id', 'name', 'age'], ['1', 'tanaka', '20'], ]; $stream = fopen('php://output', 'w'); foreach ($csv as $line) { fputcsv($stream, $line); } fclose($stream); }, 200, [ 'Content-Type' => 'text/csv', 'Content-Disposition' => 'attachment; filename="users.csv"', ] ); }
データベースの内容を出力する
だいたいのケースにおいて、CSV出力したいものはあらかじめ定義した配列ではなく、データベースの中のデータではないでしょうか。
<?php use Symfony\Component\HttpFoundation\StreamedResponse; use App\User; public function downloadCSV() { return new StreamedResponse( function () { $csv = User::all(['id', 'name', 'age'])->toArray(); $stream = fopen('php://output', 'w'); foreach ($csv as $line) { fputcsv($stream, $line); } fclose($stream); }, 200, [ 'Content-Type' => 'text/csv', 'Content-Disposition' => 'attachment; filename="users.csv"', ] ); }
Eloquentは結果を Collection
クラスで返すので、忘れずに配列に変換しましょう。
ただ、データが多いとメモリが枯渇してしまいます。
chunkを使う
一度にすべてのデータを取得するのではなく、データを分割して取得し、その都度、出力すればよさそうです。
<?php use Symfony\Component\HttpFoundation\StreamedResponse; use App\User; public function downloadCSV() { return new StreamedResponse( function () { $stream = fopen('php://output', 'w'); User::chunk(100, function ($users) use ($stream) { foreach ($users as $user) { fputcsv($stream, [$user->id, $user->name, $user->age]); } }); fclose($stream); }, 200, [ 'Content-Type' => 'text/csv', 'Content-Disposition' => 'attachment; filename="users.csv"', ] ); }
これで問題なさそうです。
CSVダウンロードは考えることが多くて大変ですね。