PHPで複数ファイル(DBにblob型として保存したファイルを含む)をZipダウンロードする方法


概要

複数のファイルを1つのZipファイルにまとめてダウンロードする機能を作成したので、手順をまとめました。
具体的には、次のような機能です。

  • これらのファイルをZipアーカイブ化しブラウザからダウンロードする。
    • 商品に関する情報を記載したExcelファイル
    • PDFやPSD、画像(pngやsvg)など、DB(Mysql)にバイナリ形式のBlobで保存されているファイル
  • インターネットに公開されない、管理画面上の機能なのでアクセス数は多くない。

この機能を作って得た学びは、

  • ファイル名が重複するときにファイル名に連番を振る方法
  • (Zipに限らず)IEでも日本語ファイル名のファイルをダウンロードする方法

です。

動作確認環境

EC-CUBE 4.0.3
Symfony 3.4
PHP 7.3
MySQL 5.7

方針

前提

ZIP拡張モジュールが有効になっていること。 今回は、EC-CUBE4の独自プラグインを作るにあたり、既に有効になっている環境でした。
そのため、本記事では拡張モジュールの導入方法は説明しません。
代わりに参考記事を貼っておきます。

サンプルコード

コードと合わせてひとつずつ説明します。

twigやHTMLは省略しますが、ボタンをクリックすると、
①注文した商品の情報が載ったExcel
②その商品の画像やPSD(画像なのかPSDなのか、PDFなのかは商品によって異なる)
が入っているZipファイルが1つダウンロードされる機能をイメージしてください。
サンプル用に書いたので、データを取得する部分には間違いがあるかもしれません。実際にはEC-CUBE4の商品テーブルに画像やPDFを入れるカラムは存在しません。

<?php

namespace Plugin\Xxx\Controller\Admin\Xxx;

class SampleController extends AbstractController
{
    private $orderRepository;
    private $orderItemRepository;

    public function __construct(
        OrderRepository $orderRepository,
        OrderItemRepository $orderItemRepository
    ) {
        $this->orderRepository = $orderRepository;
        $this->orderItemRepository = $orderItemRepository;
    }

    public function download(Request $request, OutputExcelService $outputExcelService): void
    {
        $orderId = $request->get('order_id');

        $order = $this->orderRepository->find($orderId);

        $orderItems = $this->orderItemRepository->findBy([
            'Order' => $order,
        ]);

        // uniqid()とmt_rand()でユニークな文字列を生成し、Excelとzipを一時保存するディレクトリを生成する。(絶対パス)
        // rand()よりmt_rand()のほうが高速。詳細はPHPマニュアルを参照。
        $temporaryDirectoryPath =  sprintf('/path/hoge/fuga/%s', uniqid(mt_rand(), true));
        if (! mkdir($temporaryDirectoryPath, 0777, true) && ! is_dir($temporaryDirectoryPath)) {
            throw new RuntimeException(sprintf('Directory "%s" was not created', $temporaryDirectoryPath));
        }

        // Excelを生成し、保存した絶対パスを配列に入れる。
        $excelSavedNames = [];
        foreach ($orderItems as $i => $orderItem) {
            $outputExcelService->make($orderItem);

            $excelSavedNames[] = $outputExcelService->saveForTemporaryFile($orderItem, $temporaryDirectoryPath, $i + 1);
        }

        // zipをダウンロードしたときのファイル名を指定する。
        $zipFileName = sprintf('test_%s_%s.zip', $order->getOrderNo(), date('YmdHis'));

        // ZipArchiveクラスのインスタンスを生成
        $zip = new ZipArchive();

        // 先ほどExcelを保存したのと同じディレクトリにzipファイルを新規作成。$zipにzipダウンロードしたいファイルを追加していく。
        // 第二引数を「ZIPARCHIVE::CREATE | ZIPARCHIVE::OVERWRITE」とすると、
        // 同名ファイルがあれば上書きし、なければ新規作成となる。
        $result = $zip->open($temporaryDirectoryPath.'/'.$zipFileName, ZIPARCHIVE::CREATE);

        // zipファイル生成に失敗したときの処理
        if ($result !== true) {
            log_error('zipファイルの生成に失敗しました');
            $this->addError('admin.common.system_error', 'admin');
        }

        // 先ほど作成したExcelをzipファイルに追加する。
        foreach ($excelSavedNames as $savedName) {
            // 第一引数は、zipに入れたいファイルを絶対パスで指定する。
            // 第二引数は、zip内でのディレクトリおよびファイル。
            // 例えば「var/hoge.php」と指定すると、zip内にvarディレクトリとその配下にhoge.phpが配置される。
            $zip->addFile(sprintf('%s/%s', $temporaryDirectoryPath, $savedName), $savedName);
        }

        // 同名のファイルが存在する場合は、ファイル名に連番を振る。
        // そのために、拡張子を含むファイル名をキーとしたカウント連想配列を用意する。
        $fileNameCounts = [];
        foreach ($orderItems as $orderItem) {
            $product = $orderItem->getProduct();

            // blobで格納されたファイルのファイル名を取得
            $fileName = $product->getFileName();
            if (isset($fileNameCounts[$fileName])) {
                $fileNameCounts[$fileName]++;
            } else {
                $fileNameCounts[$fileName] = 1;
            }

            // DBには拡張子ごとファイル名が入っている前提
            $fileInfo = pathinfo($fileName);

            // $product->getFileData()でblobで格納されたファイル情報を取得
            $zip->addFromString(
                sprintf('%s-%s.%s', $fileInfo['filename'], $fileNameCounts[$fileName], $fileInfo['extension']),
                stream_get_contents($product->getFileData(), -1, 0)
            );
        }

        // すべてのファイルをzipに入れたらclose
        $zip->close();

        // MIME Typeはzipを指定
        header('Content-Type: application/zip;');

        // attachment;とすると、ブラウザにダウンロードするように命令できる。
        // filenameには、ダウンロード時のファイル名を指定する。
        // filename=とfilename*=、2つあるのはIEでダウンロードすると、日本語ファイル名が文字化ける現象への対策。
        // filenameを併記すると、filename*=を優先し、filename*=に対応していないブラウザはfilename=を参照する。
        header("Content-Disposition: attachment; filename=\"{$zipFileName}\"; filename*=utf-8''" . rawurlencode($zipFileName));

        // readfile()によってメモリ不足になることを防ぐため、出力バッファリングを無効化
        // 「出力バッファリング」とは、
        // >出力内容を出力せずにメモリ内に溜め込んでおき、後から吐き出す方法のこと。
        // 下記記事より引用
        // https://qiita.com/fallout/items/3682e529d189693109eb
        while (ob_get_level()) {
            ob_end_clean();
        }

        // zipファイル
        $zipFullPath = sprintf('%s/%s', $temporaryDirectoryPath, $zipFileName);

        // zipの中身を標準出力
        readfile($zipFullPath);

        // zipをダウンロードした後は不要になるため、一時的に作成したExcelやzipを削除する。
        $this->deleteTemporaryFiles($temporaryDirectoryPath, $excelSavedNames, $zipFullPath);

        exit;
    }

    private function deleteTemporaryFiles(string $temporaryDirectoryPath, array $excelSavedNames, string $zipFullPath): void
    {
        foreach ($excelSavedNames as $fileName) {
            unlink(sprintf('%s/%s', $temporaryDirectoryPath, $fileName));
        }

        unlink($zipFullPath);

        rmdir($temporaryDirectoryPath);
    }
}

Excelを生成する

Excelを読み書きしてサーバ上に保存する方法については、別の記事で説明しました。
PhpSpreadsheetでExcelを読み書きしてExcelとしてダウンロードする

同名ファイルが存在するときに連番を振る

同名のファイルをzipに追加すると上書きされてしまう。
これを回避するため、拡張子を含むファイル名をキーとしたカウント連想配列を用意した。
この方法は人に教わりました。結構出番のある処理らしい。

    $fileNameCounts = [];
    foreach ($orderItems as $orderItem) {
        // 省略

        if (isset($fileNameCounts[$fileName])) {
            $fileNameCounts[$fileName]++;
        } else {
            $fileNameCounts[$fileName] = 1;
        }

        // 省略
    }

この処理によって、同名ファイルがあった場合は、aaa-1.png, aaa-2.png, aaa-3.png, と重複が無いようにリネームされてzipファイルに追加される。

$fileNameCountsの中身のイメージ↓

$fileNameCounts = [
    'aaa.png' => 2,
    'aaa.svg' => 1,
    'bbb.pdf' => 1,
    'ccc.ai' => 3,
];

エラーと対処法

事象① ファイルサイズ0バイトのZipがダウンロードされる

readfile()でzipのファイル名だけを渡していたため。エラーログにも、「そんなファイルまたはディレクトリは存在しないよ」と出ていました。
zipファイルの絶対パスを渡すと、正常にダウンロードできました。

message: 'Warning: readfile(test_1_20200428114851.zip): failed to open stream: No such file or directory'
class: Symfony\Component\Debug\Exception\ContextErrorException

事象② Zipを展開するときに「ファイルが破損している」と警告が出る

そんなときはダウンロードしたzipファイルをサクラエディタなどで開いてみましょう。
高確率でPHPのエラーが吐きだされており、これがファイル破損の原因です。バイナリなファイルのはずなのにエラー文などの文言が出力されていることが問題なので、 出ているエラーを解消すれば正常にダウンロード・展開できます。

現象としては、この記事で書いたことと同じです。

事象③ 日本語がファイル名に含まれるzipをダウンロードすると文字化ける

サンプルコード中にも書きましたが、
(Zipファイルに限らず)ヘッダーのContent-Dispositionでファイル名を指定するとき、filename=とは別に、filename*=というパラメータを併記するとIEでも名前に日本語が含まれるファイルがダウンロードできます。
sprintf()とか変数展開でもう少し分かりやすく書いたほうがいいけどここで力尽きました。

        // IEでダウンロードすると、日本語ファイル名が文字化ける現象への対策。
        // filenameを併記すると、filename*=を優先し、filename*=に対応していないブラウザはfilename=を参照する。
        header("Content-Disposition: attachment; filename=\"{$fileName}\"; filename*=utf-8''" . rawurlencode($fileName));

実装終わった~!とおもったらIEで動確すると文字化けたり挙動がおかしいときの悲しさといったらない。
このサイトの存在を後輩から聞いたときは笑ったけど、まだ5年もあるの・・・?

詳しくは、下記記事も参照してください。

おまけ

今回はblob型でデータベースに保存した画像をZipに入れてダウンロードしましたが、HTML(twig)に出力する方法については、別の記事を書きました。
よければ参考にしてください。
PHPでblob型でデータベースに保存した画像を出力する

参考記事まとめ

以下は、Zipダウンロード機能を実装するために参考にした記事です。

以下は、ファイルダウンロード時にファイル名の日本語が文字化ける現象を解消するのに参考にしました。