在PHP中可靠地下载大文件

31

我在服务器上有一个PHP脚本,用于发送文件给接收者:他们会得到一个唯一的链接,然后就可以下载大文件。有时传输存在问题,导致文件损坏或无法完成。我想知道是否有更好的方法来发送大文件。

代码:

$f = fopen(DOWNLOAD_DIR.$database[$_REQUEST['fid']]['filePath'], 'r');
while(!feof($f)){
    print fgets($f, 1024);
}
fclose($f);

我看过一些函数,例如

http_send_file
http_send_data

但我不确定它们是否有效。

解决这个问题的最佳方法是什么?

问候
erwing


2
这个问题的一部分可能可以通过支持“范围”头来解决,这样浏览器就可以暂停和恢复下载。这里有一个处理这个问题的问题:https://dev59.com/o3VC5IYBdhLWcg3w4VRz - grossvogel
还请查看这些 Stack Overflow 的回答:这个这个 - trejder
请点击此处查看 -- https://stackoverflow.com/questions/47827768/how-to-download-large-files-with-php/47827769#47827769 - Oleg Uryutin
13个回答

17

如果您不想或者不能使用像cURLmod-xsendfile这样更专业的方法,那么在PHP中切分文件是最快/最简单的方法之一,也可以使用Apache上的一些专用脚本

$filename = $filePath.$filename;

$chunksize = 5 * (1024 * 1024); //5 MB (= 5 242 880 bytes) per one chunk of file.

if(file_exists($filename))
{
    set_time_limit(300);

    $size = intval(sprintf("%u", filesize($filename)));

    header('Content-Type: application/octet-stream');
    header('Content-Transfer-Encoding: binary');
    header('Content-Length: '.$size);
    header('Content-Disposition: attachment;filename="'.basename($filename).'"');

    if($size > $chunksize)
    { 
        $handle = fopen($filename, 'rb'); 

        while (!feof($handle))
        { 
          print(@fread($handle, $chunksize));

          ob_flush();
          flush();
        } 

        fclose($handle); 
    }
    else readfile($path);

    exit;
}
else echo 'File "'.$filename.'" does not exist!';

richnetapps.com / NeedBee移植而来。在200MB文件上经过测试,即使将允许的最大内存限制设置为1G(即下载文件大小的五倍),readfile()仍会死机。

顺便说一下:我还尝试在>2GB的文件上进行测试,但PHP只能写入文件的前2GB,然后断开连接。与文件相关的函数(如fopen、fread、fseek)使用INT,因此您最终会达到2GB的限制。在这种情况下,上述解决方案(即mod-xsendfile)似乎是唯一的选择。

编辑请确保您的文件以UTF-8保存。如果省略,下载的文件将损坏。这是因为该解决方案使用print将文件块推送到浏览器。


@user2864740,也许在你的PHP中是这样的,但在我的和官方的PHP中都不是这样的。告诉我,在readfile文档中你看到哪里提到了分块?哪里?只有关于用户自定义readfile_chunked函数的提及,其源代码看起来...几乎与上面的示例完全相同。在发表错误言论之前,请先检查! - trejder
我不会说,这个描述暗示或者甚至建议了内部的“分块”!而且,对于大文件使用纯粹的readfile()在客户端上会死掉——浏览器终止连接,因为它花费的时间太长,而不是出现内存问题。如果readfile()正在对文件进行分块,那么它很可能只是针对服务器上的读取文件进行分块,但它仍然将整个文件作为一个请求响应发送。这在大多数浏览器上都会失败。我的解决方案中的分块导致文件在多个响应中下载,并允许浏览器处理即使是大文件的下载。 - trejder
1
@GellieAnn,您提到的是7年前的答案。我已不再是PHP开发人员。 header('Content-Type:application / octet-stream');header('Content-Transfer-Encoding:binary');建议使用纯二进制连接,因此理论上以这种方式下载ZIP文件应该绝对没有问题。但我无法在实践中验证。还要注意,ZIP文件的大小不应超过2 GB,PHP库可能无法处理更大的文件(许多ZIP客户端也无法处理;其他使用一些奇怪的技巧来处理那么大的ZIP文件)。也许这就是问题所在。 - trejder
1
@GellieAnn 当我谈到UTF-8编码时,我最有可能是在谈论.php文件本身(带有上述代码),而不是你要通过这种方式下载的文件。下载的文件应该是二进制的。二进制文件不能使用文本编码(如UTF-8)进行编码。 - trejder
1
@trejder,我的 ZIP 文件小于 1GB,因此可能还有其他原因。我可能会再次查看它,但目前我最终没有压缩文件。你七年前的代码仍然有效。谢谢!另外,感谢你回答我的问题,非常感激。 - Gellie Ann
显示剩余6条评论

12

3
这里有一个PHP脚本可以下载各种文件类型,如exe、mp3、mp4、pdf等,支持下载大约2GB大小的文件。该脚本适用于通过PHP脚本下载小到中等文件大小的文件。另外,它还介绍了一种名为“X-sendfile”的技术用于下载超过5GB的大型文件。请查看链接。 - webblover
@webblover - 你的链接已经失效了。你能重新发一遍吗? - Ben
3
@Ben 抱歉,'phpsnips'网站已删除了那个代码片段。你可以直接从Github下载它:https://github.com/saleemkce/downloadable/blob/master/download.php - webblover
刚才在这里上面重新发布了“大文件下载”脚本。 - webblover

7

最好的解决方案是依赖于lighty或apache,但如果使用PHP,我会使用PEAR的HTTP_Download(无需重新发明轮子等),具有以下一些不错的特性:

  • 基本限流机制
  • 范围(部分下载和续传)

请参见intro/usage docs


1
感谢您指出HTTP_Download,这正是我所寻找的,并且在下载CD映像时完美运行。 - SaschaM78
1
我不得不修复一些已弃用的引用错误和非静态方法的使用,但是在5分钟内我就让它正常运行了,所以这并不太难。可能是因为我有一个过时的pear库,但值得注意。 - STT LCU
1
这个已经过时了。HTTP_Download在2020年已经过时,甚至不能在php7中运行,因为它使用了&new对象实例创建方法,在PHP 7中会出现错误。我浪费了很多时间来让pear工作并安装HTTP_Download扩展,只是发现这个包已经被弃用了。 - brett

6

我们在几个项目中使用了这个,目前效果非常好:

/**
 * Copy a file's content to php://output.
 *
 * @param string $filename
 * @return void
 */
protected function _output($filename)
{
    $filesize = filesize($filename);

    $chunksize = 4096;
    if($filesize > $chunksize)
    {
        $srcStream = fopen($filename, 'rb');
        $dstStream = fopen('php://output', 'wb');

        $offset = 0;
        while(!feof($srcStream)) {
            $offset += stream_copy_to_stream($srcStream, $dstStream, $chunksize, $offset);
        }

        fclose($dstStream);
        fclose($srcStream);   
    }
    else 
    {
        // stream_copy_to_stream behaves() strange when filesize > chunksize.
        // Seems to never hit the EOF.
        // On the other handside file_get_contents() is not scalable. 
        // Therefore we only use file_get_contents() on small files.
        echo file_get_contents($filename);
    }
}

1
我尝试过这个,但它似乎只是将文本输出到屏幕上。我该如何将其转换为可下载的文件? - Matt
这是一个不同的话题。您需要设置适当的内容头:Content-Type、Content-Length 和可能的 Content-Disposition。 - Andreas Baumgart
4KB的块不会效率低吗?我至少会选择0.5MB。 - Parsa Yazdani

4

下载文件的最简单方法是将文件放在临时位置,并提供一个唯一的URL,用户可以通过常规HTTP下载。

在生成这些链接的同时,您还可以删除X小时以上的文件。


我不喜欢这个答案,因为它需要额外使用cron或类似的工具来删除旧文件,这增加了系统的复杂性和故障点,但我不会投反对票,因为它是一个有效的答案。 - UnkwnTech
@Unkwntech - 无需使用cron,如在生成新文件时所述,您也可以丢弃旧文件。许多网站会将类似cron的任务作为另一个调用的一部分执行。 - Andrew Grant
@Andrew Grant,你说得对,它可以在没有CRON的情况下完成,但我仍然觉得它增加了一个额外的故障点,而我的答案则不会增加额外的故障点。 - UnkwnTech
3
但你从中得到的好处是将实际HTTP通信的复杂性推给服务器,让它去处理(例如过期头,范围下载,缓存等)。我宁愿用一个脚本清理几个文件,也不想在我的应用程序中重新实现HTTP。 - Will Hartung
1
我同意威尔的观点。现今的浏览器具有先进的下载器,可以使用http的内置功能暂停和恢复下载。 - grossvogel

3
创建一个符号链接指向实际文件,并使下载链接指向该符号链接。这样,当用户单击DL链接时,他们将从真实文件下载文件,但文件名来自符号链接。创建符号链接只需几毫秒,并且比尝试复制文件到新名称并从那里下载更好。
例如:
<?php

// validation code here

$realFile = "Hidden_Zip_File.zip";
$id = "UserID1234";

if ($_COOKIE['authvalid'] == "true") {
    $newFile = sprintf("myzipfile_%s.zip", $id); //creates: myzipfile_UserID1234.zip

    system(sprintf('ln -s %s %s', $realFile, $newFile), $retval);

    if ($retval != 0) {
        die("Error getting download file.");
    }

    $dlLink = "/downloads/hiddenfiles/".$newFile;
}

// rest of code

?>

<a href="<?php echo $dlLink; ?>Download File</a>

这是我所做的,因为Go Daddy会在大约2分30秒后停止运行脚本...这可以防止该问题并隐藏实际文件。
然后,您可以设置CRON作业以定期删除符号链接...
整个过程将向浏览器发送文件,并且由于它不是脚本,因此无论运行多长时间都没有关系。

1
如果您正在使用lighttpd作为Web服务器,安全下载的替代方法是使用ModSecDownload。它需要服务器配置,但您将让Web服务器处理下载本身,而不是PHP脚本。
生成下载URL看起来像这样(摘自文档),当然只能为授权用户生成:
<?php

  $secret = "verysecret";
  $uri_prefix = "/dl/";

  # filename
  # please note file name starts with "/" 
  $f = "/secret-file.txt";

  # current timestamp
  $t = time();

  $t_hex = sprintf("%08x", $t);
  $m = md5($secret.$f.$t_hex);

  # generate link
  printf('<a href="%s%s/%s%s">%s</a>',
         $uri_prefix, $m, $t_hex, $f, $f);
?>

当然,根据文件的大小,使用像Unkwntech提出的readfile()这样的方法是很不错的。而像garrow提出的使用xsendfile也是一个很好的主意,而且也得到了Apache的支持。

1

过去我做这个的时候,我用了这个:

set_time_limit(0); //Set the execution time to infinite.
header('Content-Type: application/exe'); //This was for a LARGE exe (680MB) so the content type was application/exe
readfile($fileName); //readfile will stream the file.

这3行代码将完成下载的所有工作。readfile()将把指定的整个文件流式传输到客户端,并确保设置无限时间限制,否则在文件完成流式传输之前可能会超时。

2
此外,千万不要将set_time_limit(0)与ignore_user_abort()结合使用,否则脚本可能会一直运行下去。 - Pim Jager
2
非常正确,除非您希望在用户死亡后继续运行脚本。 - UnkwnTech
6
这会留下证据表明你杀了用户。这并不是一个好主意。 - Matt
5
这并不适用于大文件。问题特别涉及大文件。点踩。 - Mike Starov
正如其他人已经提到的那样,这当然不是真的。readfile函数不会流式传输任何内容。它只是尝试将整个文件读入内存,并将其全部抛出到浏览器中。对于大于100 MB的任何内容,这都会失败。它会“残酷地”死掉,即没有错误消息,只有您的用户下载带有关于耗尽内存的错误的HTML页面,而不是真正的文件。要实现真正的大文件流式传输,您必须实现比这三行更复杂的东西。例如,文件分块。请参见下面的我的示例 - trejder
显示剩余4条评论

1
header("Content-length:".filesize($filename));
header('Content-Type: application/zip'); // ZIP file
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="downloadpackage.zip"');
header('Content-Transfer-Encoding: binary');
ob_end_clean();
readfile($filename);
exit();

1
无法处理大文件。几秒钟内就会耗尽内存。 - Scottymeuk
@Scottymeuk 为什么?请提供一个资源。 - user2864740
1
这就是你需要做的全部。真的。不确定为什么这不被接受。"关键"是使用ob_end_clean(可以包装在ob_get_level中)来禁用输出缓冲区。 - user2864740

0

这是在内存限制为256MB的服务器上测试了大小为200 MB+文件的结果。

header('Content-Type: application/zip');
header("Content-Disposition: attachment; filename=\"$file_name\"");
set_time_limit(0);
$file = @fopen($filePath, "rb");
while(!feof($file)) {
  print(@fread($file, 1024*8));
  ob_flush();
  flush();
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接