从Laravel存储中下载而不将整个文件加载到内存中

时间:2016-04-21 18:54:13

标签: php laravel

我正在使用Laravel Storage,我想为用户提供一些(大于内存限制)文件。我的代码的灵感来自SO中的post,它是这样的:

$fs = Storage::getDriver();
$stream = $fs->readStream($file->path);

return response()->stream(
    function() use($stream) {
        fpassthru($stream);
    }, 
    200,
    [
        'Content-Type' => $file->mime,
        'Content-disposition' => 'attachment; filename="'.$file->original_name.'"',
    ]);

不幸的是,我遇到了大文件的错误:

[2016-04-21 13:37:13] production.ERROR: exception 'Symfony\Component\Debug\Exception\FatalErrorException' with message 'Allowed memory size of 134217728 bytes exhausted (tried to allocate 201740288 bytes)' in /path/app/Http/Controllers/FileController.php:131
Stack trace:
#0 /path/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php(133): Symfony\Component\Debug\Exception\FatalErrorException->__construct()
#1 /path/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php(118): Illuminate\Foundation\Bootstrap\HandleExceptions->fatalExceptionFromError()
#2 /path/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php(0): Illuminate\Foundation\Bootstrap\HandleExceptions->handleShutdown()
#3 /path/app/Http/Controllers/FileController.php(131): fpassthru()
#4 /path/vendor/symfony/http-foundation/StreamedResponse.php(95): App\Http\Controllers\FileController->App\Http\Controllers\{closure}()
#5 /path/vendor/symfony/http-foundation/StreamedResponse.php(95): call_user_func:{/path/vendor/symfony/http-foundation/StreamedResponse.php:95}()
#6 /path/vendor/symfony/http-foundation/Response.php(370): Symfony\Component\HttpFoundation\StreamedResponse->sendContent()
#7 /path/public/index.php(56): Symfony\Component\HttpFoundation\Response->send()
#8 /path/public/index.php(0): {main}()
#9 {main}  

它似乎试图将所有文件加载到内存中。我期待使用stream和passthru不会这样做...我的代码中是否缺少某些内容?我是否必须以某种方式指定块大小或什么?

我使用的版本是Laravel 5.1和PHP 5.6。

6 个答案:

答案 0 :(得分:16)

似乎输出缓冲仍在内存中积累很多。

尝试在执行fpassthru之前禁用ob:

function() use($stream) {
    while(ob_get_level() > 0) ob_end_flush();
    fpassthru($stream);
},

可能有多个输出缓冲区处于活动状态,这就是为什么需要while。

答案 1 :(得分:13)

不要一次将整个文件加载到内存中,而是尝试使用fread来读取并按块发送它。

这是一篇非常好的文章:http://zinoui.com/blog/download-large-files-with-php

<?php

//disable execution time limit when downloading a big file.
set_time_limit(0);

/** @var \League\Flysystem\Filesystem $fs */
$fs = Storage::disk('local')->getDriver();

$fileName = 'bigfile';

$metaData = $fs->getMetadata($fileName);
$handle = $fs->readStream($fileName);

header('Pragma: public');
header('Expires: 0');
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
header('Cache-Control: private', false);
header('Content-Transfer-Encoding: binary');
header('Content-Disposition: attachment; filename="' . $metaData['path'] . '";');
header('Content-Type: ' . $metaData['type']);

/*
    I've commented the following line out.
    Because \League\Flysystem\Filesystem uses int for file size
    For file size larger than PHP_INT_MAX (2147483647) bytes
    It may return 0, which results in:

        Content-Length: 0

    and it stops the browser from downloading the file.

    Try to figure out a way to get the file size represented by a string.
    (e.g. using shell command/3rd party plugin?)
*/

//header('Content-Length: ' . $metaData['size']);


$chunkSize = 1024 * 1024;

while (!feof($handle)) {
    $buffer = fread($handle, $chunkSize);
    echo $buffer;
    ob_flush();
    flush();
}

fclose($handle);
exit;
?>

更新

更简单的方法:只需调用

if (ob_get_level()) ob_end_clean();

返回回复之前。

归功于@Christiaan

//disable execution time limit when downloading a big file.
set_time_limit(0);

/** @var \League\Flysystem\Filesystem $fs */
$fs = Storage::disk('local')->getDriver();

$fileName = 'bigfile';

$metaData = $fs->getMetadata($fileName);
$stream = $fs->readStream($fileName);

if (ob_get_level()) ob_end_clean();

return response()->stream(
    function () use ($stream) {
        fpassthru($stream);
    },
    200,
    [
        'Content-Type' => $metaData['type'],
        'Content-disposition' => 'attachment; filename="' . $metaData['path'] . '"',
    ]);

答案 2 :(得分:5)

X-Send-File

X-Send-File是一个内部指令,包含Apache,nginx和lighthttpd的变体。它允许您完全跳过通过PHP分发文件,这是一条告诉网络服务器发送什么作为响应而不是来自FastCGI的实际响应的指令。

我之前在个人项目上处理了这个问题,如果你想查看我的工作总和,你可以在这里访问:
https://github.com/infinity-next/infinity-next/blob/master/app/Http/Controllers/Content/ImageController.php#L250-L450

这不仅涉及分发文件,还涉及处理流媒体搜索。您可以自由使用该代码。

以下是关于X-Send-File的官方nginx文档 https://www.nginx.com/resources/wiki/start/topics/examples/xsendfile/

执行必须编辑您的网络服务器,并将特定目录标记为nginx的内部目录,以符合X-Send-File指令。

我在上面的代码中有Apache和nginx的示例配置。
https://github.com/infinity-next/infinity-next/wiki/Installation

这已在高流量网站上测试过。通过PHP守护进程缓冲媒体,除非您的网站没有任何流量,或者您正在流失资源。

答案 3 :(得分:4)

您可以尝试直接使用StreamedResponse组件,而不是Laravel包装器。 StreamedResponse

答案 4 :(得分:0)

enter image description here

<?php
$file = 'monkey.gif';

if (file_exists($file)) {
    header('Content-Description: File Transfer');
    header('Content-Type: application/octet-stream');
    header('Content-Disposition: attachment; filename="'.basename($file).'"');
    header('Expires: 0');
    header('Cache-Control: must-revalidate');
    header('Pragma: public');
    header('Content-Length: ' . filesize($file));
    readfile($file);
    exit;
}
?>

答案 5 :(得分:0)

2020 Laravel 7有更好的方法:

return response()->download($pathToFile);

来自package file“下载方法可用于生成强制用户浏览器在给定路径上下载文件的响应。下载方法接受文件名作为该方法的第二个参数,该参数将确定可以看到用户正在下载文件。最后,您可以将HTTP标头数组作为方法的第三个参数传递:

return response()->download($pathToFile);

return response()->download($pathToFile, $name, $headers);

return response()->download($pathToFile)->deleteFileAfterSend();

我们还流式传输了可能更适合的下载内容:Laravel docs