laravel5.3 CSVダウンロードを実装する

先日、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ダウンロードは考えることが多くて大変ですね。