Самый быстрый способ обслуживания файла с помощью PHP

98

Я пытаюсь создать функцию, которая получает путь к файлу, определяет, что это такое, устанавливает соответствующие заголовки и обслуживает его так же, как Apache.

Причина, по которой я делаю это, заключается в том, что мне нужно использовать PHP для обработки некоторой информации о запросе перед обслуживанием файла.

Скорость имеет решающее значение

virtual () не вариант

Должен работать в среде общего хостинга, где пользователь не может контролировать веб-сервер (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], '.');
    }
}
}
?>
Кирк Уимет
источник
10
Почему вы не позволяете Apache делать это? Это всегда будет значительно быстрее, чем запуск интерпретатора PHP ...
Билли Онил,
4
Мне нужно обработать запрос и сохранить некоторую информацию в базе данных перед выводом файла.
Kirk Ouimet
3
Могу ли я предложить способ получить расширение без более дорогих регулярных выражений: $extension = end(explode(".", $pathToFile)), или вы можете сделать это с подстрокой и strrpos: $extension = substr($pathToFile, strrpos($pathToFile, '.')). Кроме того, в качестве альтернативы mime_content_type()вы можете попробовать системный вызов:$mimetype = exec("file -bi '$pathToFile'", $output);
Фанис Хатзидакис,
Что вы имеете в виду самый быстрый ? Самое быстрое время загрузки?
Аликс Аксель

Ответы:

140

Мой предыдущий ответ был частичным и плохо документированным, вот обновление с кратким изложением решений, полученных из него и других участников обсуждения.

Решения отсортированы от лучшего решения к худшему, а также от решения, требующего наибольшего контроля над веб-сервером, до решения, требующего меньшего. Кажется, нет простого способа получить одно решение, которое было бы быстрым и работало бы везде.


Использование заголовка X-SendFile

Как утверждают другие, это действительно лучший способ. Основа заключается в том, что вы осуществляете контроль доступа на php, а затем вместо того, чтобы отправлять файл самостоятельно, вы указываете веб-серверу сделать это.

Базовый код php:

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

Где $file_nameполный путь к файловой системе.

Основная проблема с этим решением заключается в том, что оно должно быть разрешено веб-сервером и либо не установлено по умолчанию (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, они документируют X-LIGHTTPD-send-fileзаголовок, но X-Sendfileимя также работает

Nginx

В Nginx вы не можете использовать X-Sendfileзаголовок, вы должны использовать свой собственный заголовок с именем X-Accel-Redirect. Он включен по умолчанию, и единственное реальное отличие состоит в том, что его аргумент должен быть URI, а не файловой системой. Следствием этого является то, что вы должны определить местоположение, помеченное как внутреннее в вашей конфигурации, чтобы клиенты не находили реальный URL-адрес файла и не переходили непосредственно к нему, их вики содержит хорошее объяснение этого.

Символические ссылки и заголовок Location

Вы можете использовать символические ссылки и перенаправлять на них, просто создавайте символические ссылки на свой файл со случайными именами, когда пользователь авторизован для доступа к файлу, и перенаправляйте пользователя к нему, используя:

header("Location: " . $url_of_symlink);

Очевидно, вам понадобится способ обрезать их либо при вызове скрипта для их создания, либо через cron (на машине, если у вас есть доступ, или через какую-либо службу webcron в противном случае)

Под apache вы должны иметь возможность включить FollowSymLinksв .htaccessили в конфигурации apache.

Контроль доступа по IP и заголовку Location

Другой способ - сгенерировать файлы доступа к apache из php, разрешив явный IP-адрес пользователя. Под apache это означает использование команд mod_authz_host( mod_access) Allow from.

Проблема в том, что блокировка доступа к файлу (поскольку несколько пользователей могут захотеть сделать это одновременно) нетривиальна и может привести к тому, что некоторые пользователи будут ждать долгое время. И вам все равно нужно обрезать файл.

Очевидно, другая проблема заключается в том, что несколько человек с одним и тем же IP-адресом потенциально могут получить доступ к файлу.

Когда все остальное терпит неудачу

Если у вас действительно нет никакого способа заставить ваш веб-сервер помочь вам, единственное оставшееся решение - это файл для чтения , который доступен во всех используемых в настоящее время версиях php и работает довольно хорошо (но не очень эффективен).


Комбинируя решения

В общем, лучший способ отправить файл очень быстро, если вы хотите, чтобы ваш php-код можно было использовать повсюду, - это иметь где-нибудь настраиваемую опцию с инструкциями о том, как активировать ее в зависимости от веб-сервера и, возможно, автоматическое обнаружение в вашей установке сценарий.

Это очень похоже на то, что делается во многих программах для

  • Чистые URL-адреса ( mod_rewriteна apache)
  • Крипто-функции ( mcryptмодуль php)
  • Поддержка многобайтовых строк ( mbstringмодуль php)
Жюльен Ронкалья
источник
Есть ли какие-либо проблемы с выполнением некоторых работ PHP (проверьте файлы cookie / другие параметры GET / POST для базы данных), прежде чем делать это header("Location: " . $path);?
Afriza N. Arief
2
Нет проблем для такого действия, вам нужно быть осторожным с отправкой содержимого (печать, эхо), поскольку заголовок должен стоять перед любым содержимым и делать что-то после отправки этого заголовка, это не немедленное перенаправление, и код после него будет выполняется большую часть времени, но у вас нет гарантий, что браузер не прервет соединение.
Жюльен Ронкалья,
Jords: Я не знал, что apache также поддерживает это, я добавлю это к своему ответу, когда у меня будет время. Единственная проблема заключается в том, что я не унифицирован (например, X-Accel-Redirect nginx), поэтому требуется второе решение, если сервер либо его не поддерживает. Но я должен добавить это к своему ответу.
Жюльен Ронкалья,
Где я могу разрешить .htaccess управлять XSendFilePath?
Keyne Viana
1
@Keyne Я не думаю, что ты сможешь. tn123.org/mod_xsendfile не перечисляет .htaccess в контексте для параметра XSendFilePath
cheshirekow
33

Самый быстрый способ: не надо. Загляните в заголовок x-sendfile для nginx , есть аналогичные вещи и для других веб-серверов. Это означает, что вы все еще можете управлять доступом и т. Д. В php, но делегировать фактическую отправку файла на веб-сервер, предназначенный для этого.

PS: Меня охватывает мурашки, когда я думаю о том, насколько эффективнее использовать это с nginx по сравнению с чтением и отправкой файла на php. Подумайте только, если 100 человек загружают файл: с php + apache, если быть щедрым, это, вероятно, 100 * 15 МБ = 1,5 ГБ (примерно, стреляйте в меня), оперативной памяти прямо здесь. Nginx просто передаст файл ядру, а затем он будет загружен прямо с диска в сетевые буферы. Быстро!

PPS: И с помощью этого метода вы по-прежнему можете выполнять любой контроль доступа и работу с базой данных, которую хотите.

Jords
источник
4
Позвольте мне просто добавить, что это также существует для Apache: jasny.net/articles/how-i-php-x-sendfile . Вы можете заставить скрипт обнюхивать сервер и отправлять соответствующие заголовки. Если ничего не существует (и пользователь не контролирует сервер согласно вопросу), вернитесь к нормальному состояниюreadfile()
Фанис Хатзидакис
Это просто потрясающе - я всегда ненавидел увеличивать лимит памяти на моих виртуальных хостах только для того, чтобы PHP обслуживал файл, а с этим мне не следовало бы делать это. Я очень скоро попробую.
Greg W
1
И, что касается похвалы, Lighttpd был первым веб-сервером, реализовавшим это (а остальные скопировали его, что нормально, поскольку это отличная идея. Но отдайте должное, если это необходимо) ...
ircmaxell
1
За этот ответ продолжают набирать голоса, но он не будет работать в среде, где веб-сервер и его настройки находятся вне контроля пользователя.
Kirk Ouimet
Вы действительно добавили это к своему вопросу после того, как я опубликовал этот ответ. И если производительность является проблемой, тогда веб-сервер должен быть под вашим контролем.
Jords
23

Вот чистое решение 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аргумента.

Аликс Аксель
источник
2
Очевидно, что это хорошая идея, только если вы не можете использовать X-Sendfile или один из его вариантов, чтобы ядро ​​отправило файл. Вы должны иметь возможность заменить цикл feof () / fread () выше на вызов [ php.net/manual/en/function.eio-sendfile.php ]( PHP eio_sendfile ()], который выполняет то же самое в PHP. Это не так быстро, как делать это непосредственно в ядре, поскольку любой вывод, сгенерированный в PHP, по-прежнему должен возвращаться через процесс веб-сервера, но это будет чертовски быстрее, чем делать это в коде PHP.
Брайан С.
@BrianC: Конечно, но вы не можете ограничить скорость или возможность составления нескольких частей с помощью X-Sendfile (который может быть недоступен) и eioтакже не всегда доступен. Тем не менее, +1 не знал об этом расширении pecl. =)
Аликс Аксель
Было бы полезно поддерживать кодирование передачи: фрагментированное и кодирование содержимого: gzip?
skibulk
Почему $size = sprintf('%u', filesize($path))?
Svish
14
header('Location: ' . $path);
exit(0);

Пусть Apache сделает всю работу за вас.

амфетамашина
источник
12
Это проще, чем метод x-sendfile, но не сработает для ограничения доступа к файлу, то есть только для зарегистрированных людей. Если вам это не нужно, тогда отлично!
Jords,
Также добавьте проверку реферера с помощью mod_rewrite.
sanmai
1
Вы можете авторизоваться перед передачей заголовка. Таким образом, вы также не перекачиваете тонны вещей через память PHP.
Brent
7
@UltimateBrent Местоположение по-прежнему должно быть доступно для всех .. И проверка ссылки не является безопасностью, поскольку она исходит от клиента
Ойвинд Скаар
@Jimbo Токен пользователя, который вы собираетесь проверить как? С PHP? Вдруг ваше решение повторяется.
Марк Эмери
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

если у вас есть возможность добавить расширения PECL в свой php, вы можете просто использовать функции из пакета Fileinfo для определения типа содержимого, а затем отправить правильные заголовки ...

Андреас Линден
источник
/ bump, вы упомянули такую ​​возможность? :)
Андреас Линден
0

DownloadУпомянутая здесь функция PHP вызвала некоторую задержку перед фактической загрузкой файла. Я не знаю, было ли это вызвано использованием кеша лака или чем-то еще, но мне помогло sleep(1);полностью удалить его и установить $speedна 1024. Теперь он работает без проблем, чертовски быстро. Возможно, вы могли бы изменить и эту функцию, потому что я видел, как она используется во всем Интернете.

user1601422
источник
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");
Самуэль Даузон
источник