лампа: Как творить.Zip-архив больших файлов для пользователя на лету, без перебоев с диском/процессором


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

Однако у такого подхода есть недостатки:

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

Такие решения, как Zipstream-PHP, улучшают это, загружая данные в Apache файл за файлом. Тем не менее, результатом по-прежнему является высокое использование памяти (файлы полностью загружаются в память) и большие, резкие скачки использование диска и процессора.

Напротив, рассмотрим следующий фрагмент bash:

ls -1 | zip -@ - | cat > file.zip
  # Note -@ is not supported on MacOS

Здесь zip работает в потоковом режиме, что приводит к малому объему памяти. Канал имеет встроенный буфер – когда буфер заполнен, ОС приостанавливает программу записи (программа слева от канала). Это здесь гарантирует, что zip работает только так быстро, как его вывод может быть записан cat.

Тогда оптимальным способом было бы сделать то же самое: заменить cat процессом веб-сервера, потоковая передача zip-файла пользователю с его созданием на лету. Это создало бы небольшие накладные расходы по сравнению с простой потоковой передачей файлов и имело бы непроблематичный профиль ресурсов без шипов.

Как вы можете добиться этого в стеке ЛАМП?

Author: Benji XVI, 2010-12-05

6 answers

Вы можете использовать popen() ( документы) или proc_open() ( документы) для выполнения команды unix (например, zip или gzip) и возврата стандартного вывода в виде потока php. flush() ( docs) сделает все возможное, чтобы передать содержимое выходного буфера php в браузер.

Объединение всего этого даст вам то, что вы хотите (при условии, что ничто другое не помешает - см. esp. предостережения на странице документов для flush()).

( Примечание: не используйте flush(). Смотрите обновление ниже для подробностей.)

Что-то вроде следующего может сделать свое дело:

<?php
// make sure to send all headers first
// Content-Type is the most important one (probably)
//
header('Content-Type: application/x-gzip');

// use popen to execute a unix command pipeline
// and grab the stdout as a php stream
// (you can use proc_open instead if you need to 
// control the input of the pipeline too)
//
$fp = popen('tar cf - file1 file2 file3 | gzip -c', 'r');

// pick a bufsize that makes you happy (64k may be a bit too big).
$bufsize = 65535;
$buff = '';
while( !feof($fp) ) {
   $buff = fread($fp, $bufsize);
   echo $buff;
}
pclose($fp);

Вы спросили о "других технологиях": на что я отвечу: "все, что поддерживает неблокирующий ввод-вывод на протяжении всего жизненного цикла запроса". Вы могли бы создать такой компонент, как автономный сервер на Java или C/C++ (или на любом из многих других доступных языков), , если бы вы были готовы погрузиться в "грязные" проблемы неблокирующего доступа к файлам и тому подобного.

Если вы хотите неблокирующая реализация, но вы предпочли бы избежать "грязного и грязного", самым простым путем (IMHO) было бы использовать NodeJS. В существующем выпуске nodejs есть много поддержки для всех функций, которые вам нужны: используйте модуль http (конечно) для http-сервера; и используйте модуль child_process для создания конвейера tar/zip/любого другого.

Наконец, если (и только если) вы используете многопроцессорный (или многоядерный) сервер и хотите получить максимальную отдачу от nodejs, вы можете использовать Spark2 для запуска нескольких экземпляров на одном порту. Не запускайте более одного экземпляра nodejs на ядро процессора.


Обновление (из отличного отзыва Бенджи в разделе комментариев к этому ответу)

1. Документы для fread() указывают, что функция будет считывать только до 8192 байт данных одновременно из всего, что не является обычным файлом. Поэтому 8192 может быть хорошим выбором размера буфера.

[редакционное примечание] 8192 является почти наверняка значение, зависящее от платформы - на большинстве платформ fread() будет считывать данные до тех пор, пока внутренний буфер операционной системы не опустеет, после чего он вернется, позволяя ОС снова асинхронно заполнять буфер. 8192 - это размер буфера по умолчанию во многих популярных операционных системах.

Существуют и другие обстоятельства, которые могут привести к тому, что fread вернет даже меньше 8192 байт - например, "удаленный" клиент (или процесс) медленно заполняет буфер - в большинстве в некоторых случаях fread() вернет содержимое входного буфера как есть, не дожидаясь его заполнения. Это может означать, что возвращается где угодно от 0..байт os_buffer_size.

Мораль такова: значение, которое вы передаете fread() как buffsize, следует считать "максимальным" размером - никогда не предполагайте, что вы получили запрошенное количество байтов (или любое другое число, если на то пошло).

2. Согласно комментариям к документам fread, несколько предостережений: волшебные цитаты могут вмешиваться и должен быть отключен.

3. Настройка mb_http_output('pass') ( документы) может быть хорошей идеей. Хотя 'pass' уже является настройкой по умолчанию, вам может потребоваться указать ее явно, если ваш код или конфигурация ранее изменили ее на что-то другое.

4. Если вы создаете zip-файл (в отличие от gzip), вам следует использовать заголовок типа содержимого:

Content-type: application/zip

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

Content-type: application/octet-stream

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

Content-disposition: attachment; filename="file.zip"

Также следует отправить заголовок длины содержимого, но с этим методом это сложно, так как вы не знаете, что такое почтовый индекс. точный размер заранее. Есть ли заголовок, который можно настроить так, чтобы он указывал, что содержимое "потоковое" или имеет неизвестную длину? Кто-нибудь знает?


Наконец, вот пересмотренный пример, в котором используются все предложения @Бенджи (и который создает ZIP-файл вместо файла TAR.GZIP):

<?php
// make sure to send all headers first
// Content-Type is the most important one (probably)
//
header('Content-Type: application/octet-stream');
header('Content-disposition: attachment; filename="file.zip"');

// use popen to execute a unix command pipeline
// and grab the stdout as a php stream
// (you can use proc_open instead if you need to 
// control the input of the pipeline too)
//
$fp = popen('zip -r - file1 file2 file3', 'r');

// pick a bufsize that makes you happy (8192 has been suggested).
$bufsize = 8192;
$buff = '';
while( !feof($fp) ) {
   $buff = fread($fp, $bufsize);
   echo $buff;
}
pclose($fp);

Обновление: (2012-11-23) Я обнаружил, что вызов flush() в цикле чтения/эха может вызвать проблемы при работе с очень большими файлами и/или очень медленные сети. По крайней мере, это верно при запуске PHP как cgi/fastcgi за Apache, и кажется вероятным, что та же проблема возникнет и при запуске в других конфигурациях. Проблема, по-видимому, возникает, когда PHP сбрасывает вывод в Apache быстрее, чем Apache может фактически отправить его через сокет. Для очень больших файлов (или медленных подключений) это в конечном итоге приводит к переполнению внутреннего выходного буфера Apache. Это приводит к тому, что Apache убивает процесс PHP, что, конечно же, приводит к зависанию загрузки или преждевременному завершению, при этом выполняется только частичная передача.

Решение состоит в том, чтобы вообще не вызывать flush(). Я обновил приведенные выше примеры кода, чтобы отразить это, и я поместил примечание в текст вверху ответа.

 47
Author: Lee, 2017-05-23 12:18:19

Другим решением является мой модуль mod_zip для Nginx, написанный специально для этой цели:

Https://github.com/evanmiller/mod_zip

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

 3
Author: Emiller, 2011-05-29 23:57:04

Пытаясь реализовать динамическую загрузку с большим количеством файлов разных размеров, я столкнулся с этим решением, но столкнулся с различными ошибками памяти, такими как "Допустимый размер памяти 134217728 байт, исчерпанный в...".

После добавления ob_flush(); непосредственно перед flush(); ошибки памяти исчезают.

Вместе с отправкой заголовков мое окончательное решение выглядит следующим образом (просто хранение файлов внутри zip без структуры каталогов):

<?php

// Sending headers
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="download.zip"');
header('Content-Transfer-Encoding: binary');
ob_clean();
flush();

// On the fly zip creation
$fp = popen('zip -0 -j -q -r - file1 file2 file3', 'r');

while (!feof($fp)) {
    echo fread($fp, 8192);
    ob_flush();
    flush();
}

pclose($fp);
 2
Author: Rico Sonntag, 2012-02-13 13:23:39

В прошлые выходные я написал этот микросервисный файл s3 с молнией на молнии - может быть полезно: http://engineroom.teamwork.com/how-to-securely-provide-a-zip-download-of-a-s3-file-bundle/

 2
Author: user3665185, 2015-08-04 23:52:02

В соответствии с руководством по PHP, расширение ZIP предоставляет zip: оболочку.

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

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


Редактировать: Я пытаюсь чтобы собрать воедино доказательство концепции, но это кажется нетривиальным. Если у вас нет опыта работы с потоками PHP, это может оказаться слишком сложным, если это вообще возможно.


Редактировать(2): перечитав ваш вопрос после просмотра ZipStream, я обнаружил, что здесь будет вашей главной проблемой, когда вы скажете (курсив добавлен)

Оперативное сжатие должно работать в потоковом режиме, т.е. обрабатывать файлы и предоставлять данные в размере скачать.

Эту часть будет чрезвычайно сложно реализовать, потому что я не думаю, что PHP предоставляет способ определить, насколько полон буфер Apache. Итак, ответ на ваш вопрос - нет, вы, вероятно, не сможете сделать это в PHP.

 1
Author: Josh Davis, 2010-12-05 04:29:13

Похоже, вы можете устранить любые проблемы, связанные с буфером вывода, с помощью fpassthru(). Я также использую -0 для экономии процессорного времени, так как мои данные уже компактны. Я использую этот код для обслуживания целой папки, застегнутой на лету:

chdir($folder);
$fp = popen('zip -0 -r - .', 'r');
header('Content-Type: application/octet-stream');
header('Content-disposition: attachment; filename="'.basename($folder).'.zip"');
fpassthru($fp);
 0
Author: Hermann, 2017-05-16 19:39:25