Laravelで複数ファイルをZIPでまとめてストリーミングダウンロードさせる方法

はじめに

ファイルを大量にまとめてダウンロードさせたかった時の話。

高スペックなサーバなら普通にZipArchiveを使ってZIPファイルを作成してからダウンロードさせればいいけど、低スペックなサーバで大量のファイルをZIPにしようとすると普通にファイルサイズ分のメモリを消費し、メモリー不足で止まってしまう。ZIP格納だけでなく圧縮もしようとするとCPUの消費も半端ない。

そこで今回は、「stechstudio/laravel-zipstream」というライブラリを使ってメモリとCPU消費を抑えた、ZIPのストリーミングダウンロードを実装してみたいと思います。

このライブラリは、ZIPファイルを作成しながら出来たところから同時にストリーミング形式でダウンロードさせるというもので、メモリとCPUの消費をおさえられます。(当社比80%OFF)「maennchen/ZipStream-PHP」というライブラリをLaravel用に使いやすくしてくれたものらしいです。

使い方

まずはライブラリをインストール。

composer require stechstudio/laravel-zipstream

あとは以下のようにするだけで簡単に実装できる。(公式から引用)

use Zip;

class ZipController {

    public function build()
    {
        return Zip::create("package.zip", [
            "/path/to/Some File.pdf",
            "/path/to/Export.xlsx"       
        ]);
    }
}

ポイントは、create()を直接returnしているところ。これによってストリーミングされるっぽい。

一度作成されたZIPに以下のようにadd()を使ってファイルを追加することもできるが、この場合はストリーミングされなかった。

$zipfile = Zip::create("package.zip", [
    "/path/to/Some File.pdf",
    "/path/to/Export.xlsx"
]);

// ファイル追加
$zipfile->add("/path/to/Some File.pdf");

return $zipfile;

サンプル

自分は以下のように実装してみました。

use Zip;

class ZipController
{
    public function build()
    {
        // ダウンロードさせたいファイルのパスが入った配列
        $filePaths = [
            "/path/to/File1.pdf",
            "/path/to/File2.pdf",
            "/path/to/File3.pdf"
        ];

        // 完成時のzipファイルの名前
        $zipname = 'download.zip';

        // zipファイルストリーミング
        return Zip::create($zipname, $filePaths);
    }
}

ZIPに格納するファイルの名前を個別に設定したい場合は、$filePathsを連想配列にして、keyにパス、valueにファイル名を入れて渡す。

$filePaths = [
    "/path/to/data1.xlsx" => 'Export1.xlsx',
    "/path/to/data2.xlsx" => 'Export2.xlsx',
    "/path/to/data3.xlsx" => 'dir/Export3.xlsx', // ディレクトリから書くとディレクトリも作成される
];

ファイルパスは、「https://~」のURLでも可で、AWSのS3にも対応している(AWSのSDKが必要)。また、生のファイルデータを直接渡すこともできるらしい。

補足

デフォルトでは、ファイルを圧縮せずにそのままZIPに格納される。圧縮するようにもできるがその場合はCPUの消費が大きくなるので今回はしない。他にオプションとして以下の機能がある。

作成したZIPファイルをサーバ内に保存

Zip::create("package.zip")
    // ... add files ...
    ->saveTo("/path/to/folder");

キャッシュファイルを残してレジュームダウンロードさせる

Zip::create("package.zip")
    // ... add files ...
    ->cache("/path/to/folder/some-unique-cache-name.zip");

その他の機能

HTTPヘッダーを自動で付けてくれるので自分で作って付けなくても良い。さらに、ZIP完成後のファイルサイズを自動計算してくれてヘッダーに付けてくれるので、ユーザーはダウンロード時にブラウザで進捗状況を確認できる。圧縮オプションを有効にした場合は確認できない。

あと、ダウンロード開始イベントや完了イベントも受け取れるらしい。今回は実装しないので必要な方は公式マニュアルを参照してください。

ファイル名に日本語を使いたい場合

とても良いライブラリなのですが、格納するファイルの名前に日本語が含まれている場合、ファイル名から削除されてしまいます。どうしても日本語に対応させたかったので、ファイル名を扱っている部分を探して修正してみました。

ファイル名を扱っている箇所は、「vendor/stechstudio/laravel-zipstream/src/Models/File.php」でした。以下の箇所を以下のように修正してみました。(93行目あたり)

/**
 * @return string
 */
public function getZipPath(): string
{
    /* 元の部分
    return Str::ascii(
        ltrim(
            preg_replace('|/{2,}|', '/',
                $this->zipPath
            ),
            '/')
    );
    */

    // こう修正
    return $this->zipPath;
}

おそらくファイル名にディレクトリを含んでいる場合の処理などが入っているが、今回は使わないのでそのまま返すようにしたら日本語が消されなくなった。文字コードを変換する必要がある場合や、何か処理をしたい場合はここに書いたら良さそう。必要に応じて書き換えてみてください。

vendor内ファイルのオーバーライド

上記の修正をすると一応日本語対応になるのですが、修正ファイルがvendor内なので本番環境へデプロイした時などは反映されません。そこで、今回のライブラリディレクトリをすべてappディレクトリ以下にコピーしてから修正を行います。(「Override」というディレクトリを作ってそこへ入れました)

そして、File.phpをLaravelが見に行く時に、vendor内ではなくapp内のFile.phpを見に行くように「composer.json」のautoloadに以下のように追記します。

"autoload": {
    "psr-4": {
        "App\\": "app/"
    },
    "classmap": [
        "database/seeds",
        "database/factories"
    ],
    // ここから 
    "exclude-from-classmap": [
        "vendor\\stechstudio\\laravel-zipstream\\src\\Models\\File.php"
    ],
    "files": [
        "app\\Override\\stechstudio\\laravel-zipstream\\src\\Models\\File.php"
    ]
    // ここまで追記
},

「composer.json」修正後は、以下のコマンドで反映させます。

composer dump-autoload

以上で完了です。