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
```

# 方針
- blob型でDBに格納されているデータは、`ZipAchive`クラスの`addFromString()`メソッドを使う。
  - [【PHPサンプルコード】Zipファイルを作成し、ブラウザからダウンロードさせる方法](https://estpolis.com/2012/07/4361.html)
  - [PHP マニュアル ZipArchiveクラス](https://www.php.net/manual/ja/class.ziparchive.php)

- 一方、Excelファイルは一時的にサーバ上に生成する。サーバ上のファイルは、`ZipAchive`クラスの`addFile()`メソッドでZipに追加する。
  - [【PHP】PHPでZipを作成しダウンロードするやり方](https://gootablog.com/php-zip-download)
  - 以前、[テンプレート用のExcelを読み込みExcelとしてブラウザでダウンロードする機能を作ったとき](https://qiita.com/haruna-nagayoshi/items/bccc4b844e909608f514)、phpspreadsheetライブラリについて調べていますが、Excelをバイナリで出力する方法はわかりませんでした(あるいは、存在しない)。
    そのため、Excelは一時的にサーバ上にファイルとして保存する方法をとりました。
- Zipダウンロード後、一時的に生成したファイルとダウンロードされたZipを削除する。
  複数人が同時にダウンロード操作したとき、他の人が生成したファイルを削除しないようにするため、パスにランダムな文字列を指定する。

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

- [phpで複数のファイルをZIPに圧縮してダウンロードする方法](https://ex1.m-yabe.com/archives/4215)
- [【PHP】PHPでZipを作成しダウンロードするやり方](https://gootablog.com/php-zip-download)

# サンプルコード
コードと合わせてひとつずつ説明します。

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

```php
<?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としてダウンロードする](https://qiita.com/haruna-nagayoshi/items/bccc4b844e909608f514)

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

```php
    $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`の中身のイメージ↓
    

```php
$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のエラーが吐きだされており、これがファイル破損の原因です。バイナリなファイルのはずなのにエラー文などの文言が出力されていることが問題なので、 出ているエラーを解消すれば正常にダウンロード・展開できます。

現象としては、[この記事](https://qiita.com/haruna-nagayoshi/items/bccc4b844e909608f514#事象ファイルが破損している)で書いたことと同じです。

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

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

実装終わった～！とおもったらIEで動確すると文字化けたり挙動がおかしいときの悲しさといったらない。
[このサイト](https://death-to-ie11.com/)の存在を後輩から聞いたときは笑ったけど、まだ5年もあるの・・・？

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

- [Content-Disposition: attachment; filenameのrfc 6266形式](https://qiita.com/khsk/items/d541b8dc40bd2c6128d2)
- [PHPでダウンロードさせるファイル名がIEで文字化けする件](https://qiita.com/takehironet/items/79c025e4140e29c57abe)

# おまけ
今回はblob型でデータベースに保存した画像をZipに入れてダウンロードしましたが、HTML(twig)に出力する方法については、別の記事を書きました。
よければ参考にしてください。
[PHPでblob型でデータベースに保存した画像を出力する](https://qiita.com/haruna-nagayoshi/items/1a4884273b0ac437a09a)

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

- [【PHPサンプルコード】Zipファイルを作成し、ブラウザからダウンロードさせる方法](https://estpolis.com/2012/07/4361.html)
  - blob形式のファイルをZipにする方法が参考になりました。
- [【PHP】PHPでZipを作成しダウンロードするやり方](https://gootablog.com/php-zip-download)
- [phpで複数のファイルをZIPに圧縮してダウンロードする方法](https://ex1.m-yabe.com/archives/4215)
- [PHPでファイルをzipにまとめて一括ダウンロードする](https://qiita.com/marien/items/2b933b5e2a8ff0189d28)
- [PHP マニュアル ZipArchiveクラス](https://www.php.net/manual/ja/class.ziparchive.php)
- [[PHP] 複数ファイルをZipにしてダウンロートする2つの方法](https://agohack.com/download-zip-file-with-php/)
  - 本記事では、1つのフォルダに存在する複数のファイル( + blob型のファイル)をZipにする方法を書きました。
    こちらの記事では、複数のフォルダにまたがった複数のファイルをZipにしてダウンロードする方法も載っています。
- [【PHP】正しいダウンロード処理の書き方](https://qiita.com/fallout/items/3682e529d189693109eb)
  - `readfile()`の正しい使い方が参考になりました。

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

- [Content-Disposition: attachment; filenameのrfc 6266形式](https://qiita.com/khsk/items/d541b8dc40bd2c6128d2)
  - 各ブラウザがどういう挙動なのか、各ブラウザに対応するには`Content-Disposition`の`filename`を併記すればよい、と対処方法まで例を含めて書いてあり、大変参考になりました。
- [PHPでダウンロードさせるファイル名がIEで文字化けする件](https://qiita.com/takehironet/items/79c025e4140e29c57abe)
  - こちらもコードを交えて説明されています。URLエンコードする方法が参考になりました。