使用PHP提供文件的最快方法

109
我想编写一个函数,接收文件路径,识别文件类型,设置适当的头文件,并像Apache一样提供服务。

我这样做的原因是因为我需要使用PHP处理有关请求的某些信息,然后再提供文件服务。

速度至关重要

不考虑virtual()

必须在共享托管环境中工作,其中用户无法控制Web服务器(如Apache / nginx等)

以下是我目前的成果:
File::output($path);

<?php
class File {
static function output($path) {
    // Check if the file exists
    if(!File::exists($path)) {
        header('HTTP/1.0 404 Not Found');
        exit();
    }

    // Set the content-type header
    header('Content-Type: '.File::mimeType($path));

    // Handle caching
    $fileModificationTime = gmdate('D, d M Y H:i:s', File::modificationTime($path)).' GMT';
    $headers = getallheaders();
    if(isset($headers['If-Modified-Since']) && $headers['If-Modified-Since'] == $fileModificationTime) {
        header('HTTP/1.1 304 Not Modified');
        exit();
    }
    header('Last-Modified: '.$fileModificationTime);

    // Read the file
    readfile($path);

    exit();
}

static function mimeType($path) {
    preg_match("|\.([a-z0-9]{2,4})$|i", $path, $fileSuffix);

    switch(strtolower($fileSuffix[1])) {
        case 'js' :
            return 'application/x-javascript';
        case 'json' :
            return 'application/json';
        case 'jpg' :
        case 'jpeg' :
        case 'jpe' :
            return 'image/jpg';
        case 'png' :
        case 'gif' :
        case 'bmp' :
        case 'tiff' :
            return 'image/'.strtolower($fileSuffix[1]);
        case 'css' :
            return 'text/css';
        case 'xml' :
            return 'application/xml';
        case 'doc' :
        case 'docx' :
            return 'application/msword';
        case 'xls' :
        case 'xlt' :
        case 'xlm' :
        case 'xld' :
        case 'xla' :
        case 'xlc' :
        case 'xlw' :
        case 'xll' :
            return 'application/vnd.ms-excel';
        case 'ppt' :
        case 'pps' :
            return 'application/vnd.ms-powerpoint';
        case 'rtf' :
            return 'application/rtf';
        case 'pdf' :
            return 'application/pdf';
        case 'html' :
        case 'htm' :
        case 'php' :
            return 'text/html';
        case 'txt' :
            return 'text/plain';
        case 'mpeg' :
        case 'mpg' :
        case 'mpe' :
            return 'video/mpeg';
        case 'mp3' :
            return 'audio/mpeg3';
        case 'wav' :
            return 'audio/wav';
        case 'aiff' :
        case 'aif' :
            return 'audio/aiff';
        case 'avi' :
            return 'video/msvideo';
        case 'wmv' :
            return 'video/x-ms-wmv';
        case 'mov' :
            return 'video/quicktime';
        case 'zip' :
            return 'application/zip';
        case 'tar' :
            return 'application/x-tar';
        case 'swf' :
            return 'application/x-shockwave-flash';
        default :
            if(function_exists('mime_content_type')) {
                $fileSuffix = mime_content_type($path);
            }
            return 'unknown/' . trim($fileSuffix[0], '.');
    }
}
}
?>

11
为什么不让Apache来做这件事呢?它总是比启动PHP解释器要快得多... - Billy ONeal
4
在输出文件之前,我需要处理请求并将某些信息存储到数据库中。 - Kirk Ouimet
3
我可以建议一种获取文件扩展名的方法,而不需要使用更昂贵的正则表达式: $extension = end(explode(".", $pathToFile)),或者您也可以使用substr和strrpos:$extension = substr($pathToFile, strrpos($pathToFile, '.'))。此外,作为对mime_content_type()的备用方案,您可以尝试一个系统调用:$mimetype = exec("file -bi '$pathToFile'", $output) - Fanis Hatzidakis
“fastest”是什么意思?最快的下载时间吗? - Alix Axel
8个回答

151

我的上一个回答只是部分回答,并且没有很好地记录,这里更新了一下,总结了来自我的解决方案和讨论中其他人的解决方案。

这些解决方案按照最佳解决方案到最差解决方案的顺序排列,但也按照需要对Web服务器进行最多控制的解决方案到需要最少控制的解决方案的顺序排列。似乎没有一种简单的方法可以实现既快速又在任何地方都有效的解决方案。


使用 X-SendFile 标头

正如其他人所记录的那样,这实际上是最好的方法。基础是您在 php 中进行访问控制,然后告诉 web 服务器执行而不是自己发送文件。

基本的 php 代码如下:

header("X-Sendfile: $file_name");
header("Content-type: application/octet-stream");
header('Content-Disposition: attachment; filename="' . basename($file_name) . '"');

其中$file_name是文件系统上的完整路径。

这种解决方案的主要问题在于它需要被Web服务器允许,并且默认情况下未安装(Apache),默认情况下未激活(Lighttpd)或需要特定配置(Nginx)。

Apache

在Apache下,如果使用mod_php,则需要安装一个名为mod_xsendfile的模块,然后对其进行配置(可以在Apache配置文件或允许的.htaccess文件中进行配置)。

XSendFile on
XSendFilePath /home/www/example.com/htdocs/files/

使用此模块,文件路径可以是绝对路径或相对于指定的XSendFilePath

Lighttpd

当配置了mod_fastcgi支持时,它也支持此功能。
"allow-x-send-file" => "enable" 

该功能的文档在lighttpd wiki上,他们记录了X-LIGHTTPD-send-file头部,但X-Sendfile也可以使用

Nginx

在Nginx上,你不能使用X-Sendfile头部,必须使用他们自己的头部,名为X-Accel-Redirect。它默认启用,唯一的区别是它的参数应该是一个URI而不是文件系统。因此,您必须在配置中定义一个标记为内部的位置,以避免客户端找到真实的文件URL并直接访问它,他们的wiki包含a good explanation

符号链接和位置头

您可以使用symlinks并将其重定向到它们,只需在用户被授权访问文件时使用随机名称创建指向文件的符号链接,并将用户重定向到它,使用:

header("Location: " . $url_of_symlink);

显然,您需要一种方式在调用创建它们的脚本时或通过cron(在机器上如果您有访问权限或通过某些webcron服务)来修剪它们。

在apache下,您需要能够在.htaccess或apache配置中启用FollowSymLinks

通过IP和位置标头进行访问控制

另一个方法是从php生成apache访问文件,允许显式用户IP。在apache下,这意味着使用mod_authz_hostmod_accessAllow from命令。

问题在于锁定对文件的访问(因为可能有多个用户同时想要这样做)并不容易,并且可能导致某些用户等待很长时间。而且,您仍然需要修剪该文件。

显然,另一个问题是多个人在同一IP后面可能会访问该文件。

当所有其他方法都失败时

如果您真的没有任何办法让您的Web服务器帮助您,那么唯一剩下的解决方案就是readfile。它在当前所有正在使用的php版本中都可用,并且工作得相当不错(但效率不高)。


合并解决方案

总之,如果您希望您的php代码可在任何地方使用,并且想要快速发送文件,则最好的方法是在某个可配置的选项中提供说明,以便根据Web服务器激活它,并可能在安装脚本中进行自动检测。

这与许多软件中所做的非常相似,例如:

  • 清晰的URL(Apache上的 mod_rewrite
  • 加密功能(mcrypt php模块)
  • 多字节字符串支持(mbstring php模块)

2
这样的操作没有问题,但需要注意的是发送内容(print,echo)时,标头必须在任何内容之前发送,并且在发送此标头后执行操作不会立即重定向,大多数情况下将执行其后的代码,但无法保证浏览器不会中断连接。 - Julien Roncaglia
我在哪里可以允许.htaccess控制XSendFilePath? - Keyne Viana
1
@Keyne 我认为你不能这样做。https://tn123.org/mod_xsendfile/没有在XSendFilePath选项的上下文中列出.htaccess。 - cheshirekow
@cheshirekow - 没错。XSendFilePath不能放在.htaccess文件中。如果你是唯一控制服务器的人,省略它也可以正常工作。 - Anachronist
当文件托管在不同的服务器上时,我能做到这些吗?我的问题是,我需要以最有效的方式从第三方服务器提供文件,而不泄露URL。 - Nicola Peluchetti
显示剩余2条评论

34

最快的方式:不要在php中读取和发送文件,而是查看nginx的x-sendfile头,其他Web服务器也有类似的东西。这意味着你仍然可以在php中进行访问控制等操作,但将实际发送文件的任务委托给专为此设计的Web服务器。

P.S: 一想到与使用Apache和PHP读取和发送文件相比,使用Nginx会更加高效,我就不禁感到寒意涌上心头。想象一下如果有100个人在下载文件:使用Apache和PHP大概需要1.5GB(约)的内存。Nginx会把发送文件的任务交给内核,然后直接从磁盘加载到网络缓冲区中。速度极快!

P.P.S:而且,通过这种方法,你仍然可以进行所有访问控制和数据库操作。


4
让我补充一点,这也适用于Apache:http://www.jasny.net/articles/how-i-php-x-sendfile/。您可以让脚本嗅探服务器并发送适当的标头。如果没有存在(且用户无法控制服务器,就像问题描述中一样),则回退到普通的`readfile()`。 - Fanis Hatzidakis
现在这真是太棒了 - 我总是讨厌在我的虚拟主机中提高内存限制,只是为了让PHP提供一个文件,而有了这个,我就不必这样做了。我很快就会试一下它。 - Greg W
1
值得赞扬的是,Lighttpd 是第一个实现这个功能的 Web 服务器(后来的都是抄袭的,不过没关系,因为这是个好主意。但是应该要给出应有的赞扬)。 - ircmaxell
1
这个答案一直被点赞,但在用户无法控制Web服务器及其设置的环境中,它将无法工作。 - Kirk Ouimet
你在我回答后才添加了这个问题。如果性能是一个问题,那么Web服务器必须在你的控制范围内。 - Jords

26

这里提供了一个纯 PHP 解决方案。我改编了以下函数从我的个人框架中:

function Download($path, $speed = null, $multipart = true)
{
    while (ob_get_level() > 0)
    {
        ob_end_clean();
    }

    if (is_file($path = realpath($path)) === true)
    {
        $file = @fopen($path, 'rb');
        $size = sprintf('%u', filesize($path));
        $speed = (empty($speed) === true) ? 1024 : floatval($speed);

        if (is_resource($file) === true)
        {
            set_time_limit(0);

            if (strlen(session_id()) > 0)
            {
                session_write_close();
            }

            if ($multipart === true)
            {
                $range = array(0, $size - 1);

                if (array_key_exists('HTTP_RANGE', $_SERVER) === true)
                {
                    $range = array_map('intval', explode('-', preg_replace('~.*=([^,]*).*~', '$1', $_SERVER['HTTP_RANGE'])));

                    if (empty($range[1]) === true)
                    {
                        $range[1] = $size - 1;
                    }

                    foreach ($range as $key => $value)
                    {
                        $range[$key] = max(0, min($value, $size - 1));
                    }

                    if (($range[0] > 0) || ($range[1] < ($size - 1)))
                    {
                        header(sprintf('%s %03u %s', 'HTTP/1.1', 206, 'Partial Content'), true, 206);
                    }
                }

                header('Accept-Ranges: bytes');
                header('Content-Range: bytes ' . sprintf('%u-%u/%u', $range[0], $range[1], $size));
            }

            else
            {
                $range = array(0, $size - 1);
            }

            header('Pragma: public');
            header('Cache-Control: public, no-cache');
            header('Content-Type: application/octet-stream');
            header('Content-Length: ' . sprintf('%u', $range[1] - $range[0] + 1));
            header('Content-Disposition: attachment; filename="' . basename($path) . '"');
            header('Content-Transfer-Encoding: binary');

            if ($range[0] > 0)
            {
                fseek($file, $range[0]);
            }

            while ((feof($file) !== true) && (connection_status() === CONNECTION_NORMAL))
            {
                echo fread($file, round($speed * 1024)); flush(); sleep(1);
            }

            fclose($file);
        }

        exit();
    }

    else
    {
        header(sprintf('%s %03u %s', 'HTTP/1.1', 404, 'Not Found'), true, 404);
    }

    return false;
}

这段代码已经达到了最高效的程度,它关闭了会话处理程序,以便其他PHP脚本可以同时为同一用户/会话运行。它还支持按范围提供下载(我认为这也是Apache默认使用的方式),这样人们就可以暂停/恢复下载,并且通过下载加速器获得更高的下载速度。它还允许您通过 $speed 参数指定下载(部分)应该提供的最大速度(以kbps为单位)。


2
显然,只有在无法使用X-Sendfile或其变体让内核发送文件时,这才是一个好主意。您应该能够用http://php.net/manual/en/function.eio-sendfile.php循环,它可以在PHP中完成相同的事情。 虽然这不如直接在内核中执行快,因为在PHP中生成的任何输出仍然必须通过Web服务器进程返回,但它比在PHP代码中执行要快得多。 - Brian C
@BrianC:当然,但是你不能使用X-Sendfile(可能不可用)限制速度或多部分能力,而eio也并非总是可用。还是+1,我不知道那个pecl扩展。=) - Alix Axel
支持传输编码:分块和内容编码:gzip是否有用? - skibulk
为什么要使用 $size = sprintf('%u', filesize($path)) - Svish
@Alix Axel 谢谢你 ;) - Ajmal PraveeN

14
header('Location: ' . $path);
exit(0);

让Apache为您完成工作。


12
这种方法比x-sendfile方法更简单,但不能限制对文件的访问,比如只允许已登录的用户访问。如果您不需要这样做,那就很好! - Jords
同时使用mod_rewrite添加一个引荐者检查。 - sanmai
1
你可以在传递头信息之前进行身份验证。这样,你也不会将大量内容通过PHP的内存传输。 - Brent
7
地点仍须对所有人可达。参考检查并不能保证安全,因为它是由客户提供的。 - Øyvind Skaar
@Jimbo 你要如何检查用户令牌?使用PHP吗?突然间你的解决方案变成了递归。 - Mark Amery
这是流媒体问题最简单的解决方案。在PHP中流式传输文件时,没有任何常见方法比使用file_get_contents($file_path)将文件加载到内存中,然后使用标头进行回显更有效率(显然,对于大文件(大于允许的内存)不起作用,比如300-400GB,我尝试了所有方法,没有一个接近这个)。此时,我会说处理权限问题比尝试使用PHP进行流式传输更容易且更好(这实在太可怕了)。 - Daniel Andres Acevedo

1
一个更好的实现,支持缓存,自定义http头。
serveStaticFile($fn, array(
        'headers'=>array(
            'Content-Type' => 'image/x-icon',
            'Cache-Control' =>  'public, max-age=604800',
            'Expires' => gmdate("D, d M Y H:i:s", time() + 30 * 86400) . " GMT",
        )
    ));

function serveStaticFile($path, $options = array()) {
    $path = realpath($path);
    if (is_file($path)) {
        if(session_id())
            session_write_close();

        header_remove();
        set_time_limit(0);
        $size = filesize($path);
        $lastModifiedTime = filemtime($path);
        $fp = @fopen($path, 'rb');
        $range = array(0, $size - 1);

        header('Last-Modified: ' . gmdate("D, d M Y H:i:s", $lastModifiedTime)." GMT");
        if (( ! empty($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) == $lastModifiedTime ) ) {
            header("HTTP/1.1 304 Not Modified", true, 304);
            return true;
        }

        if (isset($_SERVER['HTTP_RANGE'])) {
            //$valid = preg_match('^bytes=\d*-\d*(,\d*-\d*)*$', $_SERVER['HTTP_RANGE']);
            if(substr($_SERVER['HTTP_RANGE'], 0, 6) != 'bytes=') {
                header('HTTP/1.1 416 Requested Range Not Satisfiable', true, 416);
                header('Content-Range: bytes */' . $size); // Required in 416.
                return false;
            }

            $ranges = explode(',', substr($_SERVER['HTTP_RANGE'], 6));
            $range = explode('-', $ranges[0]); // to do: only support the first range now.

            if ($range[0] === '') $range[0] = 0;
            if ($range[1] === '') $range[1] = $size - 1;

            if (($range[0] >= 0) && ($range[1] <= $size - 1) && ($range[0] <= $range[1])) {
                header('HTTP/1.1 206 Partial Content', true, 206);
                header('Content-Range: bytes ' . sprintf('%u-%u/%u', $range[0], $range[1], $size));
            }
            else {
                header('HTTP/1.1 416 Requested Range Not Satisfiable', true, 416);
                header('Content-Range: bytes */' . $size);
                return false;
            }
        }

        $contentLength = $range[1] - $range[0] + 1;

        //header('Content-Disposition: attachment; filename="xxxxx"');
        $headers = array(
            'Accept-Ranges' => 'bytes',
            'Content-Length' => $contentLength,
            'Content-Type' => 'application/octet-stream',
        );

        if(!empty($options['headers'])) {
            $headers = array_merge($headers, $options['headers']);
        }
        foreach($headers as $k=>$v) {
            header("$k: $v", true);
        }

        if ($range[0] > 0) {
            fseek($fp, $range[0]);
        }
        $sentSize = 0;
        while (!feof($fp) && (connection_status() === CONNECTION_NORMAL)) {
            $readingSize = $contentLength - $sentSize;
            $readingSize = min($readingSize, 512 * 1024);
            if($readingSize <= 0) break;

            $data = fread($fp, $readingSize);
            if(!$data) break;
            $sentSize += strlen($data);
            echo $data;
            flush();
        }

        fclose($fp);
        return true;
    }
    else {
        header('HTTP/1.1 404 Not Found', true, 404);
        return false;
    }
}

0
这里提到的PHP Download函数在文件实际开始下载之前会导致一些延迟。我不知道这是否是由于使用了varnish缓存引起的,但对我来说,完全删除sleep(1);并将$speed设置为1024有所帮助。现在它可以快得像地狱一样,没有任何问题。也许你也可以修改那个函数,因为我在互联网上看到它被广泛使用。

0
如果您有可能将PECL扩展添加到您的PHP中,您可以简单地使用Fileinfo package中的函数来确定内容类型,然后发送适当的标头...

/顶一下,你提到过这个可能性吗? :) - Andreas Linden

0
我编写了一个非常简单的函数,用于使用PHP并自动检测MIME类型服务文件:
function serve_file($filepath, $new_filename=null) {
    $filename = basename($filepath);
    if (!$new_filename) {
        $new_filename = $filename;
    }
    $mime_type = mime_content_type($filepath);
    header('Content-type: '.$mime_type);
    header('Content-Disposition: attachment; filename="downloaded.pdf"');
    readfile($filepath);
}

使用方法

serve_file("/no_apache/invoice243.pdf");

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