LaravelでCSVダウンロード(エクスポート)する

LaravelでCSVをエクスポートする方法

今回ですがCSVエクスポートの方法をお伝えします。

よく実装するものの、書き方を丸暗記するのは大変ですよね

そこで今回は記事にまとめて、サクッと実装できるようにしたいと思います。

目次

CSVをダウンロードするコードの完成系

まずはCSVダウンロードの完成系になります。

インターフェース

<?php

namespace App\Services\Account;

use Illuminate\Support\Collection;
use Symfony\Component\HttpFoundation\StreamedResponse;

interface ArticleCsvDownloadService
{
    /**
     * 記事一覧をCSVでダウンロードします
     *
     * @return StreamedResponse
     */
    public function handle(): StreamedResponse;
}

一つのpublicメソッドだけにしており、シンプルにしてます。

これでまずこのメソッドを読めばいんだとわかります。

命名で機能の内容がわかるため、メソッド名もhandle()とシンプルな命名にしています。

handle()という命名はよくみるので、このメソッドをまず見ればいいのだなとわかるようにしています。

実装クラス

続いて実装クラスです。詳しい内容は後で後述します。

まずはサクッと見てみましょう。

<?php

namespace App\Services\Account\Concrete;

use App\Models\Article;
use Illuminate\Support\Collection;
use Symfony\Component\HttpFoundation\StreamedResponse;

class ArticleCsvDownloadServiceImpl implements ArticleCsvDownloadService
{
    /**
     * {@inheritDoc}
     */
    public function download(): StreamedResponse
    {
        ini_set("max_execution_time", 0);

        $response = $this->generateCsv();
        $response = $this->setHeaders($response);

        return $response;
    }

    /**
     * CSVを生成
     * fputcsvでCSVに1行ずつ追加
     *
     * @return StreamedResponse
     */
    private function generateCsv(): StreamedResponse
    {

        return new StreamedResponse(function () {
            $stream = fopen('php://output', 'w');
            fwrite($stream, pack('C*', 0xEF, 0xBB, 0xBF));

            $this->fputHeader($stream);
            $this->fputArticles($stream);
            fclose($stream);
        });
    }

    /**
     * CSVのカラム名がわかるようにheaderを追加する。
     *
     * @param $stream
     * @return void
     */
    private function fputHeader($stream)
    {
        $header = array_keys($this->toRow());
        
        fputcsv(
            $stream,
            $header,
        );
    }

    /**
     * fputcsvで1行ずつ口座ステータスを書き込みます
     *
     * @param $stream
     * @return void
     */
    private function fputArticles($stream): void
    {
        Article::chunk(100)->map(function (Article $article) use ($stream){
            fputcsv(
                $stream,
                array_values($this->toRow($article))
            ); 
        });
    }

    /**
     * 口座ステータスのCSVを配列に変換する
     *
     * @param Article|null $article 記事
     * @return array
     */
    private function toRow(?Article $article = null): array
    {
        return [
            'id' => $article?->id,
            'タイトル' => $article?->title,
            '本文' => $article?->body,
        ];
    }



    /**
     * ファイル名などをheadersにset
     *
     * @param StreamedResponse $response
     * @return StreamedResponse
     */
    private function setHeaders(StreamedResponse $response): StreamedResponse
    {
        $fileName = 'account_list_' . now()->format('Y-m-d-H:i') . '.csv';
        $response->headers->set('Content-Type', 'application/octet-stream');
        $response->headers->set('Content-Disposition', "attachment; filename={$fileName}");

        return $response;
    }
}

これが一連の流れです。

解説

それでは処理の解説をしていきます。

目次をつくる

    /**
     * {@inheritDoc}
     */
    public function handle(): StreamedResponse
    {
        ini_set("max_execution_time", 0);

        $response = $this->generateCsv();
        $response = $this->setHeaders($response);

        return $response;
    }

handle()で意識しているのは、目次をつくることです。

  1. csvを生成
  2. csvダウンロードに必要なheadersをセット
  3. csvをダウンロードするレスポンスをreturn

というシンプルな目次構成で、処理の流れが掴みやすいです。

ぎゅう

処理の流れがわかりやすい
仕様が掴めてよきです

この目次構成のおかげで、他のエンジニアがコードを読んでも理解できます。
エンジニアはコードを読む時間が長いので、こういった可読性を上げる工夫は重要だと考えています。

ini_set("max_execution_time", 0); // タイムアウトの時間設定を無限

上記では処理時間を無制限にしています。
ダウンロードする量が多いと360秒でもタイムアウトすることがあるので、いったんは0にしています。
この辺りは実装する機能に合わせて設定すると良いでしょう。

CSV生成処理

handle()では$this->generateCsv()が記載されていました。

    /**
     * {@inheritDoc}
     */
    public function handle(): StreamedResponse
    {
        ini_set("max_execution_time", 0);

        $response = $this->generateCsv();
        $response = $this->setHeaders($response);

        return $response;
    }

この中身を確認していきましょう。

    /**
     * CSVを生成
     * fputcsvでCSVに1行ずつ追加
     *
     * @return StreamedResponse
     */
    private function generateCsv(): StreamedResponse
    {

        return new StreamedResponse(function () {
            $stream = fopen('php://output', 'w');          // ファイルを開く
            fwrite($stream, pack('C*', 0xEF, 0xBB, 0xBF)); // BOMで文字化け防止

            $this->fputHeader($stream);                    // 1行目にカラム名がわかるテーブルのヘッダーを追加
            $this->fputArticles($stream);                  // 1行ずつ記事情報を記載する。

            fclose($stream);
        });
    }

まずはStreamedResponseのインスタンスを生成します。

この中でcsvを作成していきます。

symfonyのStreamedResponseを利用します。

クラスStreamedResponseResponseを拡張します。
StreamedResponse は、ストリーミングされた HTTP レスポンスを表します。
StreamedResponseはその内容に対してコールバックを使用します。
コールバックでは、 echo のような標準的な PHP 関数を使用してレスポンスをクライアントに返す必要があります。必要に応じて flush()関数を使用することもできます。

CSVはfopenからfcloseまでになります。

$stream = fopen('php://output', 'w');

何かcsvファイルがあればfopenで開きますが、今回は元となるcsvがないためphp://outputを開いています。
'w'は書き込み権限になります。

php:// — さまざまな入出力ストリームへのアクセス
php://output は書き込み専用のストリームで、 print および echo と同じ方法での出力バッファへの書き込みを許可します。

phpマニュアル

BOMで文字化け防止する

fwiteでbomをつけて文字化け防止をします。

fwrite($stream, pack('C*', 0xEF, 0xBB, 0xBF)); // BOMで文字化け防止
ぎゅう

誰もが一度は文字化けさせたはず

BOMをつけないとWindowsで文字化けするなど頻繁になるので追加しています。
この辺りは下記の記事が参考になりそうなので、添付しておきます。

あとは書き出したい情報をfputで1行ずつ追加していきます。

ここでは処理の流れをわかりやすくするためにメソッド分けしています。

$this->fputHeader($stream);   // 1行目にカラム名がわかるテーブルのヘッダーを追加
$this->fputArticles($stream); // 1行ずつ記事情報を記載する。

1行目にカラム名を追加する

まずはカラム名をわかるようにします。

挿入するデータはtoRow()で定義をしています。

    /**
     * 口座ステータスのCSVを配列に変換する
     *
     * @param Article|null $article 記事
     * @return array
     */
    private function toRow(?Article $article = null): array
    {
        return [
            'id' => $article?->id,
            'タイトル' => $article?->title,
            '本文' => $article?->body,
        ];
    }
ぎゅう

配列だとどの値を書いているのか
わかりやすいね

この配列のkeyとなるidタイトル本文が今回必要なカラム名です。

このkeyを取得するために、array_keys()を利用します。

array_keys($this->toRow());
ぎゅう

引数の$articleいらないの?

$articleがないとエラーになってしまうので、null安全オペレータを利用しています。

'タイトル' => $article->title,  // ->だとエラー
'タイトル' => $article?->title, // ?->だとnullを返すので、エラーが発生しない

これでkeyを取得できますね。
では実際に利用しているfputHeader()をみていきましょう。

    /**
     * CSVのカラム名がわかるようにheaderを追加する。
     *
     * @param $stream
     * @return void
     */
    private function fputHeader($stream)
    {
        $header = array_keys($this->toRow()); // カラム名を取得
        
        fputcsv(
            $stream,
            $header,
        );
    }

説明した通り、array_keysでカラム名を取得し、それをfputcsv()で入力しています。

これで1行目にカラム名を入力できましたね。

1行ずつ記事情報を入力する。

map()処理でfputcsv()を実行し、1行ずつ記事情報を記載していきます。

    /**
     * fputcsvで1行ずつ口座ステータスを書き込みます
     *
     * @param $stream
     * @return void
     */
    private function fputArticles($stream): void
    {
        Article::chunk(100)->map(function (Article $article) use ($stream){
            fputcsv(
                $stream,
                array_values($this->toRow($article))
            ); 
        });
    }

csvを閉じる

これで入力したい情報を記載終えので、fclose()で閉じます。

fclose($stream);

csv生成処理の振り返り

これでcsvの生成箇所は理解できたはずです。

    /**
     * CSVを生成
     * fputcsvでCSVに1行ずつ追加
     *
     * @return StreamedResponse
     */
    private function generateCsv(): StreamedResponse
    {

        return new StreamedResponse(function () {
            $stream = fopen('php://output', 'w');
            fwrite($stream, pack('C*', 0xEF, 0xBB, 0xBF));

            $this->fputHeader($stream);
            $this->fputArticles($stream);
            fclose($stream);
        });
    }

ダウンロードするためにheadersをセット

さてcsvを作れたところで、ダウンロードするための準備に取り掛かります。

    /**
     * {@inheritDoc}
     */
    public function download(): StreamedResponse
    {
        ini_set("max_execution_time", 0);

        $response = $this->generateCsv();
        $response = $this->setHeaders($response); // ダウンロードするためにheadersを操作

        return $response;
    }

headersをセットしている、$this->setHeaders($response)をみていきましょう。

    /**
     * ファイル名などをheadersにset
     *
     * @param StreamedResponse $response
     * @return StreamedResponse
     */
    private function setHeaders(StreamedResponse $response): StreamedResponse
    {
        $fileName = 'account_list_' . now()->format('Y-m-d-H:i') . '.csv'; //ファイル名
        $response->headers->set('Content-Type', 'application/octet-stream'); //MIMEを設定
        $response->headers->set('Content-Disposition', "attachment; filename=${fileName}"); // ダウンロード方式とファイル名を設定

        return $response;
    }

まずはMIMEを設定します。

$response->headers->set('Content-Type', 'application/octet-stream'); //MIMEを設定

そして、実際にダウンロード設定をします。

ファイル名を決めて、setします。

$fileName = 'account_list_' . now()->format('Y-m-d-H:i') . '.csv'; //ファイル名を決める
$response->headers->set('Content-Disposition', "attachment; filename=${fileName}");

attachmentは即座にダウンロードします。inlineの場合プレビューが表示されますが、PDFでないとできません。

これでheader情報もセットできましたので以上となります。

$responseをreturn

    /**
     * {@inheritDoc}
     */
    public function handle(): StreamedResponse
    {
        ini_set("max_execution_time", 0);

        $response = $this->generateCsv();
        $response = $this->setHeaders($response);

        return $response;
    }

responseを返してcsvをダウンロードしましょう

終わり

リファクタもしてあるので、読みやすく書いたつもりですが、いかがだったでしょうか?

この辺りは本当によく書くので、見本としておいておくと実装効率早くなるので、よくなると思います。

import処理も書いていきたいと思いますので、今後の記事も楽しみにしておいてくださいね

ぎゅう
WEBエンジニア
渋谷でWEBエンジニアとして働く。
LaravelとVue.jsをよく取り扱い、誰でも仕様が伝わるコードを書くことを得意とする。
先輩だろうがプルリクにコメントをして、リファクタしまくる仕様伝わるコード書くマン
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

目次
閉じる