Laravelで大容量のファイルをダウンロードさせる方法 | A Day In The Boy's Life

A Day In The Boy's Life

とあるエンジニアのとある1日のつぶやき。

ファイルダウンロード処理とかよくあるものですが、Laravel標準での実装をすると一部問題が出たりもしたので、その辺の対策などのまとめです。

環境はLaravel4.2です。



Laravel標準実装のお作法


Laravelでの標準的なファイルダウンロードの実装方法は、Response::download()を利用する方法です。


public function anyIndex()
{
    $path = "/path/to/download/file.doc";
    $name = "ダウンロード.doc";
    $mimeType = "application/msword";
    $headers = array(
        'Content-Type' => $mimeType,
        'Content-Length' => filesize($path)
    );
    return Response::download($path, $name, $headers);
}

ただ、マニュアルの注記 にあるように、ファイル名はASCIIにする必要があると書いてたりします。

実際に上記のようにダウンロードファイル名にマルチバイトを渡すと、バージョンによってはInternet Explorerでは文字化けが発生します(見た限り FireFoxやChromeだと問題ない)。


ファイル名をASCIIにする必要があるって注記があるのにASCII以外を渡しても問題ないケースがあるのが変ではあるんですけど根本的な原因は、ダウンロード時のレスポンスヘッダにある下記のヘッダがブラウザによってうまく解釈できないことにあるようです。


Content-Disposition
attachment; filename="ダウンロード.doc"; filename*=utf-8''%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89.doc

一応、Response::download()の処理の流れもざっと追ってみると


public static function download($file, $name = null, array $headers = array(), $disposition = 'attachment')
{
    $response = new BinaryFileResponse($file, 200, $headers, true, $disposition);
    if ( ! is_null($name))
    {
        return $response->setContentDisposition($disposition, $name, str_replace('%', '', Str::ascii($name)));
    }
    return $response;
}

にあるように、ファイル名を指定した場合はBinaryFileResponse/setContentDisposition()が呼び出されます。

その際に、


laravel/vendor/laravel/framework/src/Illuminate/Support/Str.php

のascii()を通っていて(実態としてはlaravel/vendor/patchwork/utf8/class/Patchwork/Utf8.phpのtoAscii()メソッド)、この際に ファイル名をUTF-8からASCIIに変換かけてそれ以外の文字は切り捨てる処理をしてたりします。

setContentDispositionの実態を見てみると


public function setContentDisposition($disposition, $filename = '', $filenameFallback = '')
{
    if ($filename === '') {
        $filename = $this->file->getFilename();
    }

    $dispositionHeader = $this->headers->makeDisposition($disposition, $filename, $filenameFallback);
    $this->headers->set('Content-Disposition', $dispositionHeader);
    return $this;
}

となっていて、第3引数で渡したファイル名は実際にはfallback filenameとあるので代替のファイル名のようです。

さらに実際にDispositionヘッダを生成しているmakeDisposition()を追うと


if ($filename !== $filenameFallback) {
    $output .= sprintf("; filename*=utf-8''%s", rawurlencode($filename));
}

のようにあるので、ここで実際のマルチバイトのファイル名と、ASCII変換によって切り捨てられた代替ファイル名が一致しないため、最初に書いたようなヘッダが作成されるようです。

何れにせよこのヘッダはRFC6266でちゃんと定義されているそうなので、古いブラウザ側がクソ仕様で動いているせいっぽいです。


PHPでダウンロードさせるファイル名がIEで文字化けする件 @Qiita


一応、その他の方法でもLaravelでファイルダウンロードを実装する方法があります。

それは、Response::make()を利用する方法です。


public function anyIndex()
{
    // ダウンロード対象ファイル
    $path = "/path/to/download/file.doc";
    $name = "ダウンロード.doc";
    $mimeType = "application/msword";
    // レスポンスヘッダ
    $header = array(
        'Content-Type' => $mimeType,
        'Content-Length' => filesize($path),
        'Content-Disposition' => attachment; filename=\"{$name}\""
    );

    $handle = fopen($path, 'r');
    $contents = "";
    while (!feof($handle)){
        $contents .= fread($handle, 4096);
    }
    fclose($handle);

    return Response::make($contents, 200, $header);
}

この方法は、Dispositionヘッダを自分で生成する分、ダウンロードファイル名が文字化けするといったことは回避できるのですが、これはこれで問題があるようで、Response::make()の場合はファイルの中身を引数に渡しているんですが、呼び出した際に通るsetContent()の中で


public function setContent($content)
{
    if (null !== $content && !is_string($content) && !is_numeric($content) && !is_callable(array($content, 

'__toString'))) {
        throw new \UnexpectedValueException(sprintf('The Response content must be a string or object implementing 

__toString(), "%s" given.', gettype($content)));
    }

    $this->content = (string) $content;

    return $this;
}

データ自体をキャストしてコピーしているため、倍のメモリを食ったりします。

ですので、かなり大容量のファイルをダウンロードさせようとした場合に、そもそもその中身を取り出すのにメモリを消費し、さらに コピーされることでメモリを食ってといったことでメモリ枯渇のエラーとかを起こす可能性があります(実際に起きました)。



大容量のファイルをダウンロードさせる


ってことで話がかなり長くなりましたが、結論としては(元も子もないですけど)Laravelの作法を無視してPHP標準のやり方で実装しました。


// ダウンロード対象ファイル
$path = "/path/to/download/file.doc";
$name = "ダウンロード.doc";
$mimeType = "application/msword";

header("Content-Length: " . filesize($path));
header("Content-Disposition: attachment; filename=\"{$fileName}\"");
header("Accept-Ranges: bytes");
header("Content-type: {$mimeType}");

// 実ファイル呼び出し
$handle = fopen($path, 'rb');
while (!feof($handle)){
    echo fread($handle, 4096);
    ob_flush();
    flush();
}
fclose($handle);

4096Byteごとにflush()するので、メモリを大量に消費するということもありません。

まぁ、フレームワークを使う以上はその作法に則るのがベストだとは思いますが、ここで書いた諸々の事情により、一部のブラウザに配慮するのであれば今のところはこの ようにするのが良さそうな気がします。