Press "Enter" to skip to content

让PHP更快的提供文件下载

一般来说, 我们可以通过直接让URL指向一个位于Document Root下面的文件, 来引导用户下载文件.
但是, 这样做, 就没办法做一些统计, 权限检查, 等等的工作. 于是, 很多时候, 我们采用让PHP来做转发, 为用户提供文件下载.

<?php
    $file = "/tmp/dummy.tar.gz";
    header("Content-type: application/octet-stream");
    header('Content-Disposition: attachment; filename="' . basename($file) . '"');
    header("Content-Length: ". filesize($file));
    readfile($file);

但是这个有一个问题, 就是如果文件是中文名的话, 有的用户可能下载后的文件名是乱码.
于是, 我们做一下修改(参考: :

<?php
    $file = "/tmp/中文名.tar.gz";
    $filename = basename($file);
    header("Content-type: application/octet-stream");
    //处理中文文件名
    $ua = $_SERVER["HTTP_USER_AGENT"];
    $encoded_filename = rawurlencode($filename);
    if (preg_match("/MSIE/", $ua)) {
	header('Content-Disposition: attachment; filename="' . $encoded_filename . '"');
    } else if (preg_match("/Firefox/", $ua)) {
	header("Content-Disposition: attachment; filename*=\"utf8''" . $filename . '"');
    } else {
	header('Content-Disposition: attachment; filename="' . $filename . '"');
    }
    header("Content-Length: ". filesize($file));
    readfile($file);

恩, 现在看起来好多了, 不过还有一个问题, 那就是readfile, 虽然PHP的readfile尝试实现的尽量高效, 不占用PHP本身的内存, 但是实际上它还是需要采用MMAP(如果支持), 或者是一个固定的buffer去循环读取文件, 直接输出.
输出的时候, 如果是Apache + PHP mod, 那么还需要发送到Apache的输出缓冲区. 最后才发送给用户. 而对于Nginx + fpm如果他们分开部署的话, 那还会带来额外的网络IO.
那么, 能不能不经过PHP这层, 直接让Webserver直接把文件发送给用户呢?
今天, 我看到了一个有意思的文章: How I PHP: X-SendFile.
我们可以使用Apache的module mod_xsendfile, 让Apache直接发送这个文件给用户:

<?php
    $file = "/tmp/中文名.tar.gz";
    $filename = basename($file);
    header("Content-type: application/octet-stream");
    //处理中文文件名
    $ua = $_SERVER["HTTP_USER_AGENT"];
    $encoded_filename = rawurlencode($filename);
    if (preg_match("/MSIE/", $ua)) {
	header('Content-Disposition: attachment; filename="' . $encoded_filename . '"');
    } else if (preg_match("/Firefox/", $ua)) {
	header("Content-Disposition: attachment; filename*=\"utf8''" . $filename . '"');
    } else {
	header('Content-Disposition: attachment; filename="' . $filename . '"');
    }
    //让Xsendfile发送文件
    header("X-Sendfile: $file");

X-Sendfile头将被Apache处理, 并且把响应的文件直接发送给Client.
Lighttpd和Nginx也有类似的模块, 大家有兴趣的可以去找找看 🙂

81 Comments

  1. 飛天
    飛天 April 18, 2020

    大佬,请教个问题,为什么通过向360、迅雷等下载工具下载的时候,显示大小为0 ,但是并不影响下载,还是能正常下载,资料也完整的,我想请教下,文件大小怎么和下载工具检测的大小一致呢?尝试了好多方法,都没有成功!

  2. jsoma
    jsoma March 11, 2019

    下载下来文件为0字节

  3. Gonzalo
    Gonzalo October 18, 2018

    Thanks for finally writing about >让PHP更快的提供文件下载 | 风雪之隅 <Liked it!

  4. 徐吉武
    徐吉武 March 9, 2017

    $file = “/tmp/中文名.tar.gz”;
    $filename = basename($file);
    basename获取中文的文件名获取不到

  5. plansze szkolne
    plansze szkolne March 10, 2016

    If some one desires expert view about blogging afterward i propose him/her to pay a visit this web site, Keep up the nice work.

  6. winter outfits
    winter outfits December 15, 2015

    What’s up, for all time i used to check blog posts here in the early hours in the
    dawn, because i like to gain knowledge of more and more.

  7. Theda
    Theda January 11, 2015

    Very shortly this web site will be famous amid all blogging and site-building users,
    due too it’s fastidious articles or reviews

  8. […] 最近有点闲暇时间了,不小心看了@风雪之隅的一篇的文章《让PHP更快的提供文件下载》后,利用实际项目中的业务场景觉得有必须要去优化附件下载功能了。鸟哥的文章里面主要介绍的基于apache来做XSendfile讲解的,我有点强迫症,我对我的生产环境《顶岗实习管理系统》进行升级改造,我的webserver是nginx,所以基于nginx官方网站的XSendfile说明特做此改造笔记。 […]

  9. share112
    share112 May 20, 2014

    见到一些网站 采用 下载 实体文件加上 token的方式 来下载 如xxxx.mp3?uijsd2342ewijl234, 感觉这种处理方法也能统计 比 X-Sendfile 更直接 些,但也说不上有什么优势。。

  10. 2013 at 4:41 pm
    2013 at 4:41 pm June 22, 2013

    Hey! I’m at work surfing around your blog from my new apple iphone! Just wanted to say I love reading through your blog and look forward to all your posts! Keep up the great work!

  11. Luis
    Luis May 6, 2013

    Very nice article, just what I wanted to find.
    I am greatful that you taking a moment to see my information.
    You are free to consider a look at my very own web site also for
    more information and facts and effective suggestions: Luis

  12. I know this if off topic but I’m looking into starting my own weblog and was curious what all is required to get set up? I’m assuming having a blog like yours would cost a pretty
    penny? I’m not very web smart so I’m not 100% sure. Any suggestions or advice would be greatly appreciated. Appreciate it
    I appreciate you currently taking a time period to evaluation my account. You are free to take a glimpse at my personal webpage also for extra info and valuable ideas … Pest Control Charlotte

  13. test
    test September 11, 2012

    大家有试下nginx的X-Accel-Redirect,当文件不存的情况吗,是不是会请求很多次?

  14. test
    test September 5, 2012

    header(“Location: “.$uri);和该方法的区别何在?

  15. 0xFP
    0xFP August 22, 2012

    对于简单的字符串查找应避免使用正则表达式!
    ##################################################
    <?php
    $file = "/tmp/中文名.tar.gz";
    $filename = basename($file);
    header("Content-type: application/octet-stream");
    // 处理中文文件名
    $ua = $_SERVER["HTTP_USER_AGENT"];
    $encoded_filename = rawurlencode($filename);
    if (strpos($ua, 'MSIE') !== false) {
    header('Content-Disposition: attachment; filename="' . $encoded_filename . '"');
    } else if (strpos($ua, 'Firefox') !== false) {
    // 博主的代码有误,这里的文件名需要编码
    header("Content-Disposition: attachment; filename*=\"utf8''" . $encoded_filename . '"');
    } else {
    // 注意: 如果文件名包含双引号, 可能会丢失文件名中的第一个双引号开始的内容
    header('Content-Disposition: attachment; filename="' . $filename . '"');
    }
    // 博主的代码有误,这行代码已被上面那段代替了, 应该注释掉
    //header('Content-Disposition: attachment; filename="' . basename($file) . '"');


    ##################################################

  16. Anonymous
    Anonymous August 20, 2012

    $encoded_filename = rawurlencode($filename);
    这段更简洁,验证过没问题。

  17. mark35
    mark35 July 27, 2012

    $encoded_filename = urlencode($filename);
    $encoded_filename = str_replace(“+”, “%20”, $encoded_filename);
    ===========================
    可否直接用
    $encoded_filename = rawurlencode($filename);
    来替代呢

  18. 杨进春
    杨进春 July 16, 2012

    …不好意思哈哈。。。去掉if else 下面那条 ——–header(‘Content-Disposition: attachment; filename=”‘ . $filename . ‘”‘); —– 后就正常了。 谢谢博主了哈。

  19. 杨进春
    杨进春 July 16, 2012

    Laruence 您好,我用了你的这段代码,在IE下,中文文件名还是出问题了。IE版本是9.0。谷歌正确

  20. 天天
    天天 July 11, 2012

    以后就来这里学习了!博主很强大

  21. uffy
    uffy July 4, 2012

    好文,以后一定用的上

  22. adophper
    adophper June 29, 2012

    啊!看了回复原来还有不足的地方,前两天做excel导出的时候就遇到中文名了!

  23. 0xFP: 请问博主为啥提交内容 "{?PHP 1 ?} {?PHP 2 ?}" ("{"、 "}" 分别为小于号、大于号) 后没显示? 诡异的被过滤了
    0xFP: 请问博主为啥提交内容 "{?PHP 1 ?} {?PHP 2 ?}" ("{"、 "}" 分别为小于号、大于号) 后没显示? 诡异的被过滤了 June 11, 2012

    请问博主为啥提交内容 “{?PHP 1 ?} {?PHP 2 ?}” (“{“、 “}” 分别为小于号、大于号) 后没显示? 诡异的被过滤了

  24. 0xFP
    0xFP June 11, 2012

    Test

  25. 0xFP (Reply: kakalong)
    0xFP (Reply: kakalong) June 11, 2012

    晕,代码再次被过滤了
    测试代码(PHP 5.4.0-3):
    header(‘Content-Type: text/plain’);
    header(‘Content-Disposition: attachment; filename*=”UTF-8\’\’this%20is%20a%20filename”‘);

  26. 0xFP (Reply: kakalong)
    0xFP (Reply: kakalong) June 11, 2012

    Retry
    测试代码(PHP 5.4.0-3):
    header(‘Content-Type: text/plain’);
    header(‘Content-Disposition: attachment; filename*=”UTF-8\’\’this%20is%20a%20filename”‘);

  27. 0xFP (Reply: kakalong)
    0xFP (Reply: kakalong) June 11, 2012

    怪了,测试代码发不上
    测试代码(PHP 5.4.0-3):

  28. 0xFP (Reply: kakalong)
    0xFP (Reply: kakalong) June 11, 2012

    测试代码(PHP 5.4.0-3):
    Firefox 识别出的文件名(Firefox 14.0a2 for linux):
    this is a filename
    经测试文件名是需要经过 URL 编码
    ====================================================================
    修正代码:
    <?php
    $file = "/tmp/中文名.tar.gz";
    $filename = basename($file);
    header("Content-type: application/octet-stream");
    // 处理中文文件名
    $ua = $_SERVER["HTTP_USER_AGENT"];
    //$encoded_filename = urlencode($filename);
    //$encoded_filename = str_replace("+", "%20", $encoded_filename);
    $encoded_filename = rawurlencode($filename);
    //if (preg_match("/MSIE/", $ua)) {
    if (strpos($ua, 'MSIE') !== false) {
    header('Content-Disposition: attachment; filename="' . $encoded_filename . '"');
    //} else if (preg_match("/Firefox/", $ua)) {
    } else if (strpos($ua, 'Firefox') !== false) {
    //header("Content-Disposition: attachment; filename*=\"utf8''" . $filename . '"');
    header("Content-Disposition: attachment; filename*=\"utf8''" . $encoded_filename . '"');
    } else {
    header('Content-Disposition: attachment; filename="' . $filename . '"');
    }
    // 这行代码已被上面的代码代替了, 应该注释掉
    //header('Content-Disposition: attachment; filename="' . basename($file) . '"');
    //……

  29. d.p
    d.p June 8, 2012

    大婶你好,我是一个开发新手,刚开始拜读您的文章,你能不能做一篇关于php制作验证码的深度分析?我们网站历经几次验证码修改,到现在防刷效果还算可以,但是用户肉眼识别也变得困难。我看过腾讯的验证码,识别很简单,您能分析下他们这个验证码是怎么做的吗,既保证体验性又防刷?

  30. Chon81
    Chon81 June 4, 2012

    无法判断是否支持是个问题呀.

  31. kakalong
    kakalong June 4, 2012

    reply 0xFP:
    1.博主处理firefox部分代码没有错误,确实不要用url编码
    2.文件名是不允许有引号的,所以你的else里面多虑了
    我觉得
    $encoded_filename = urlencode($filename);
    $encoded_filename = str_replace(“+”, “%20”, $encoded_filename);
    可以换为rawurlencode

  32. 0xFP (Fix Bug)
    0xFP (Fix Bug) May 25, 2012

    if (preg_match(“/MSIE/”, $ua)) {
    header(‘Content-Disposition: attachment; filename=”‘ .$encoded_filename . ‘”‘);
    } else if (preg_match(“/Firefox/”, $ua)) {
    // 博主的代码有误,这里的文件名需经过 URL 编码
    header(“Content-Disposition: attachment; filename*=\”utf8”” . $encoded_filename . ‘”‘);
    } else {
    // 这里如何处理文件名包含双引号的情况?
    header(‘Content-Disposition: attachment; filename=”‘ . $filename . ‘”‘ );
    }

  33. 0xFP
    0xFP May 22, 2012

    if (preg_match(“/Firefox/”, $ua)) {
    // 这里的 $filename 也需要经过 RFC1738 的 URL Encoding
    header(“Content-Disposition: attachment; filename*=\”utf8”” . $filename . ‘”‘);
    } else {
    // 如果 $filename 包含 `”‘ 会导致文件名部分丢失
    header(‘Content-Disposition: attachment; filename=”‘ . $filename . ‘”‘);
    }

  34. wclssdn
    wclssdn May 9, 2012

    额.. 看到有人说通过PHP函数获取apache模块列表了.
    不知道其他服务端软件是不是这种方式..

  35. wclssdn
    wclssdn May 9, 2012

    如何判断apache是否支持那个X-Sendfile…

  36. yegle
    yegle May 9, 2012

    这个方案的好处是默认就支持断点续传了
    另一个好处是,可以实现对静态文件的ACL访问控制
    不过悲剧的是手头的虚拟主机不提供mod_xsendfile…

  37. leoliu
    leoliu May 9, 2012

    鸟哥
    foreach($arr AS $i){
    echo <<<HTML
    $i
    HTML;
    }
    syntax error, unexpected $end
    这个报错是什么原因

  38. stou
    stou May 4, 2012

    恩,非常不错的做法,刚刚尝试可以正常运行。

  39. 十一文
    十一文 May 4, 2012

    请问哈鸟哥,如果这个要求支持断点续传了?怎么改进

  40. tanglement
    tanglement May 4, 2012

    呃,不熟悉php,但是这样不会增加产品对环境的依赖么?如果做迁移,就会有代码修改的成本吧。

  41. wamper
    wamper May 3, 2012

    Apache的Sendfile模块好像是需要另外安装的,所以可能并不是很通用,我之前的框架里边就采用了这种方法,
    $contentType = $contentType ? $contentType : ‘application/octet-stream’;
    header(“Pragma:public”);
    header(“Expires:0”);
    header(“Content-type:” . $contentType . ‘;charset=UTF-8’);
    header(“Accept-Ranges:bytes”);
    $charset = Config::get(‘charset’);
    if ($charset != ‘UTF-8’) {
    $mbEncodings = array(‘GBK’=>’CP936’, ‘GB2312’=>’CP936’);
    if(isset($mbEncodings[$charset])) $charset = $mbEncodings[$charset];
    $fileName = mb_convert_encoding($fileName, $charset, ‘UTF-8’);
    }
    if (” != $fileCfg[‘contents’]) {
    ob_clean();
    $fileSize = strlen($fileCfg[‘contents’]);
    } else if (” != $fileCfg[‘filepath’]){
    ob_clean();
    $fileSize = filesize($fileCfg[‘filepath’]);
    }
    if($fileSize > 0)
    header(“Accept-Length:”.$fileSize);
    $ua = $_SERVER[‘HTTP_USER_AGENT’];
    if(preg_match(‘/firefox/i’, $ua)) {
    $fileName = str_replace(‘+’, ‘%20’, urlencode($fileName));
    $fileName = “utf8”” . $fileName;
    header(“Content-Disposition:attachment; filename*=\”{$fileName}\””);
    } else if(preg_match(‘/msie/i’, $ua)){
    $fileName = str_replace(‘+’, ‘%20’, urlencode($fileName));
    header(“Content-Disposition:attachment; filename=\”{$fileName}\””);
    } else {
    header(“Content-Disposition:attachment; filename=\”{$fileName}\””);
    }
    if (” != $fileCfg[‘contents’]) {
    echo $fileCfg[‘contents’];
    } else if(” != $fileCfg[‘filepath’]) {
    $serverSoft = $_SERVER[‘SERVER_SOFTWARE’];
    if(preg_match(‘/apache/i’, $serverSoft)) {
    readfile($fileCfg[‘filepath’]);
    } else if (preg_match(‘/lighttpd/i’, $serverSoft)) {
    header(‘X-LIGHTTPD-Send-file:’ . $fileCfg[‘filepath’]);
    } else if (preg_match(‘/nginx/i’, $serverSoft)) {
    $nginxSendfileMaps = Config::get(‘NGINX_SENDFILE_MAP’);
    if(false == $nginxSendfileMaps) {
    readfile($fileCfg[‘filepath’]);
    } else {
    $filePath = $fileCfg[‘filepath’];
    foreach($nginxSendfileMaps as $map) {
    if(0 === strpos($filePath, $map[0])) {
    $filePath = str_replace($map[0], $map[1], $filePath);
    break;
    }
    }
    header(‘X-Accel-Redirect:’ . $filePath);
    }
    }
    }

  42. Gang
    Gang May 2, 2012

    鸟哥写的很好,小弟在这里也班门弄斧一下,一个从Ruby On Rails中迁移过来的send_file()方法,具体使用参见 http://api.rubyonrails.org/classes/ActionController/DataStreaming.html#method-i-send_file
    [code=’php’]
    ‘application/octet-stream’,
    ‘disposition’ => ‘attachment’
    );
    $options = array_merge($defaults, $options);
    foreach (array(‘type’, ‘disposition’) as $arg) {
    if (is_null($options[$arg])) {
    throw new InvalidArgumentException(“{$arg} option required”);
    }
    }
    $disposition = $options[‘disposition’];
    if (isset($options[‘filename’])) {
    $disposition .= “; filename=\”{$options[‘filename’]}\””;
    }
    if (! headers_sent()) {
    header(“Content-Type: {$options[‘type’]}”);
    header(“Content-Disposition: {$disposition}”);
    header(“Content-Transfer-Encoding: binary”);
    }
    $x_sendfile_supported = $options[‘x_sendfile’] && in_array(‘mod_xsendfile’, apache_get_modules());
    if (! headers_sent() && $x_sendfile_supported) {
    header(“X-Sendfile: {$path}”);
    } else {
    @readfile($path);
    }
    }
    ?>
    [/code]

  43. 轩脉刃
    轩脉刃 May 2, 2012

    原来还有这么个好东西,查了下:
    nginx的模块是:http://wiki.nginx.org/XSendfile

  44. shirne
    shirne May 2, 2012

    nginx的是这个吧
    http://wiki.nginx.org/X-accel
    我试了下,可以用
    header(‘Content-Disposition: attachment; filename=”test.zip”‘);
    #http://wiki.nginx.org/X-accel
    header(‘X-Accel-Redirect:/test.zip’);

  45. 流氓
    流氓 May 2, 2012

    大牛哥, 你好
    最近遇到一个mysql索引的很离奇的问题
    create table t1(x char(10), y char(10), key hs using(x,y))
    describe select * from t1 where x>’dfd’
    显示查询使用了索引并且type为range
    我不明白为什么hash索引会有这样的行为, 去百度, google, 各大论坛仔细找了都没结果
    非常希望能得到你的帮助, 谢谢

  46. anylzer
    anylzer May 2, 2012

    android系统的浏览器上中文名的文件的下载多有问题

  47. GaRY
    GaRY May 2, 2012

    为啥PHP不调用系统底层sendfile调用?直接省掉open,read,write的内核上下文切换。相对会好一点, webserver就做了这些。

  48. treesky
    treesky May 2, 2012

    不错,以前readfile的时候就总害怕文件太大出问题。回家测试测试。

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.