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. 雪候鸟
    雪候鸟 August 20, 2012

    @Anonymous 已更正, thanks

  17. Anonymous
    Anonymous August 20, 2012

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

  18. mark35
    mark35 July 27, 2012

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

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

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

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

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

  21. 天天
    天天 July 11, 2012

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

  22. uffy
    uffy July 4, 2012

    好文,以后一定用的上

  23. adophper
    adophper June 29, 2012

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

  24. dtbsky
    dtbsky June 12, 2012

    额,用上了!

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

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

  26. 0xFP
    0xFP June 11, 2012

    Test

  27. 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”‘);

  28. 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”‘);

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

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

  30. 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) . '"');
    //……

  31. d.p
    d.p June 8, 2012

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

  32. Chon81
    Chon81 June 4, 2012

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

  33. kakalong
    kakalong June 4, 2012

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

  34. 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 . ‘”‘ );
    }

  35. 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 . ‘”‘);
    }

  36. wclssdn
    wclssdn May 9, 2012

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

  37. wclssdn
    wclssdn May 9, 2012

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

  38. lly
    lly May 9, 2012

    学习了。。

  39. yegle
    yegle May 9, 2012

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

  40. leoliu
    leoliu May 9, 2012

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

  41. stou
    stou May 4, 2012

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

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

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

  43. tanglement
    tanglement May 4, 2012

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

  44. 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);
    }
    }
    }

  45. 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]

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

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

  47. 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’);

  48. 流氓
    流氓 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, 各大论坛仔细找了都没结果
    非常希望能得到你的帮助, 谢谢

  49. anylzer
    anylzer May 2, 2012

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

  50. GaRY
    GaRY May 2, 2012

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

  51. treesky
    treesky May 2, 2012

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

Comments are closed.