Найшвидший спосіб подати файл за допомогою 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
Чому ти не дозволиш Апачу зробити це? Завжди це буде значно швидше, ніж запуск інтерпретатора PHP ...
Billy ONeal

4
Мені потрібно обробити запит і зберегти деяку інформацію в базі даних перед тим, як вивести файл.
Кірк Оуімет,

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, якщо ви використовуєте mod_php, вам потрібно встановити модуль з назвою mod_xsendfile, а потім налаштуйте його (або в apache config, або .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-файл файлу та переходили безпосередньо до нього, їх вікі містить добре пояснення цього.

Символьні посилання та заголовок розташування

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

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, можуть потенційно отримати доступ до файлу.

Коли все інше не вдається

Якщо у вас насправді немає способу отримати веб-сервер, щоб допомогти вам, єдиним рішенням залишається readfile , який доступний у всіх версіях php, що використовуються в даний час, і працює досить добре (але насправді не ефективно).


Поєднання рішень

Інакше кажучи, найкращий спосіб надсилати файл дуже швидко, якщо ви хочете, щоб ваш PHP-код був застосований скрізь, - це мати десь налаштований параметр з інструкціями, як його активувати залежно від веб-сервера та, можливо, автоматичного виявлення у вашій установці сценарій.

Це дуже схоже на те, що робиться у багатьох програмних продуктах для

  • Чисті URL-адреси ( mod_rewriteна apache)
  • Крипто-функції ( mcryptphp-модуль)
  • Підтримка багатобайтових рядків ( mbstringphp-модуль)

Чи є якісь проблеми з виконанням деяких PHP-робіт (перевірка файлів cookie / інших параметрів GET / POST щодо бази даних) перед виконанням header("Location: " . $path);?
Афріза Н. Коротка

2
Немає проблем для таких дій, з чим потрібно бути обережним - це надсилання вмісту (друк, ехо), оскільки заголовок повинен стояти перед будь-яким вмістом і виконувати дії після надсилання цього заголовка, це не негайне перенаправлення та код після того, як він буде виконується більшу частину часу, але ви не маєте гарантій, що браузер не перерве з'єднання.
Жульєн Ронкалья

Jords: Я не знав, що apache також це підтримує, я додаю це до своєї відповіді, коли встигну. Єдина проблема з ним полягає в тому, що я не є уніфікованим (наприклад, X-Accel-Redirect nginx), тому потрібне друге рішення, якщо сервер не підтримує його. Але я повинен додати це до своєї відповіді.
Julien Roncaglia

Де я можу дозволити .htaccess контролювати XSendFilePath?
Кейн Віана

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: І за допомогою цього методу ви все ще можете виконувати всі необхідні функції контролю доступу та баз даних.


4
Дозвольте лише додати, що це також існує для Apache: jasny.net/articles/how-i-php-x-sendfile . Ви можете змусити скрипт винюхати сервер і надіслати відповідні заголовки. Якщо жодного не існує (і користувач не має контролю над сервером відповідно до запитання), поверніться до нормального стануreadfile()
Fanis Hatzidakis

Зараз це просто приголомшливо - я завжди ненавидів піднімати обмеження пам'яті у своїх віртуальних хостах лише для того, щоб 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. =)
Алікс Аксель

Чи було б корисно підтримувати кодування передачі: chunked та кодування вмісту: gzip?
skibulk

Чому $size = sprintf('%u', filesize($path))?
Свиш

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

Нехай Apache зробить роботу за вас.


12
Це простіше, ніж метод x-sendfile, але не допоможе обмежити доступ до файлу, скажімо, лише зареєстровані люди. Якщо вам не потрібно це робити, тоді це здорово!
Jords

Також додайте перевірку переходу за допомогою mod_rewrite.
санмай

1
Ви можете авторизуватися, перш ніж передавати заголовок. Таким чином, ви також не прокачуєте багато речей через пам'ять PHP.
Brent

7
@UltimateBrent Місцеположення все ще повинно бути доступним для всіх .. А перевірка перенаправлення - це зовсім не безпека, оскільки вона надходить від клієнта
Øyvind Skaar

@Jimbo Маркер користувача, який ви збираєтесь перевірити як? З PHP? Раптом ваше рішення повторюється.
Mark Amery

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. Зараз це працює без проблем, так само швидко, як пекло. Можливо, ви могли б також змінити цю функцію, бо я бачив, що вона використовується в Інтернеті.


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");
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.