Подтвердить что ты не робот

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

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

Однако, делать то, что имеет недостатки:

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

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

В отличие от этого, рассмотрите следующий bash фрагмент:

ls -1 | zip [email protected] - | cat > file.zip
  # Note [email protected] is not supported on MacOS

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

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

Как вы можете добиться этого в стеке LAMP?

4b9b3361

Ответ 1

Вы можете использовать popen() (docs) или proc_open() (docs), чтобы выполнить команду unix (например, zip или gzip) и вернуть stdout как поток 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/С++ (или любой другой доступный язык), если бы вы захотели войти в "нисходящее и грязное" неблокирующее доступ к файлам и многое другое.

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

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


Обновить (от Benji отличная обратная связь в разделе комментариев по этому вопросу)

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

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

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

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

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

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

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

Content-type: application/zip
Вместо этого можно использовать

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

Content-type: application/octet-stream

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

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

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


Наконец, здесь приведен пересмотренный пример, в котором используются все предложения @Benji (и который создает 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() внутри цикла read/echo может вызвать проблемы при работе с очень большими файлами и/или очень медленными сетями. По крайней мере, это верно при запуске PHP как cgi/fastcgi за Apache, и кажется вероятным, что такая же проблема возникнет и при работе в других конфигурациях. Проблема возникает, когда PHP сбрасывает вывод в Apache быстрее, чем Apache может фактически отправить его через сокет. Для очень больших файлов (или медленных подключений) это в конечном итоге приводит к переполнению внутреннего выходного буфера Apache. Это заставляет Apache убивать процесс PHP, что, конечно, заставляет загрузку зависать или заканчиваться преждевременно, только с частичной передачей.

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

Ответ 2

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

https://github.com/evanmiller/mod_zip

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

Ответ 3

Пытаясь реализовать динамически сгенерированную загрузку с большим количеством файлов с разными размерами, я столкнулся с этим решением, но я столкнулся с различными ошибками памяти, такими как "Разрешенный размер памяти 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);

Ответ 5

Согласно руководство по PHP, расширение ZIP предоставляет zip: wrapper.

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

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


Изменить: я пытаюсь объединить доказательство концепции, но, похоже, это не тривиально. Если вы не знакомы с потоками PHP, это может оказаться слишком сложным, если это возможно.


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

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

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

Ответ 6

Кажется, вы можете устранить любые проблемы, связанные с выходным буфером, используя 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);