LÁMPARA: Cómo crear.Zip de archivos grandes para el usuario sobre la marcha, sin problemas de disco/CPU


A menudo un servicio web necesita comprimir varios archivos grandes para que el cliente los descargue. La forma más obvia de hacer esto es crear un archivo zip temporal, luego echo al usuario o guardarlo en el disco y redirigirlo (eliminarlo en algún momento en el futuro).

Sin embargo, hacer las cosas de esa manera tiene inconvenientes:

  • una fase inicial de la CPU intensiva y disco thrashing, lo que resulta en...
  • un considerable retraso inicial para el usuario mientras el archivo está preparado
  • huella de memoria muy alta por solicitud
  • uso de espacio temporal considerable en disco
  • si el usuario cancela la descarga a mitad de camino, todos los recursos utilizados en la fase inicial (CPU, memoria, disco) se habrán desperdiciado

Soluciones como ZipStream-PHP mejoran esto al palear los datos en Apache archivo por archivo. Sin embargo, el resultado sigue siendo un alto uso de memoria (los archivos se cargan por completo en la memoria), y grandes picos uso de disco y CPU.

Por el contrario, considere el siguiente fragmento de código bash:

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

Aquí, zip funciona en modo de transmisión, lo que resulta en una huella de memoria baja. Una tubería tiene un búfer integral: cuando el búfer está lleno, el sistema operativo suspende el programa de escritura (programa a la izquierda de la tubería). Esto asegura que zip funciona tan rápido como su salida puede ser escrita por cat.

La forma óptima, entonces, sería hacer lo mismo: reemplazar cat con un proceso de servidor web, transmitiendo el archivo zip al usuario con él creado sobre la marcha. Esto crearía poca sobrecarga en comparación con solo transmitir los archivos, y tendría un perfil de recursos no problemático y no puntiagudo.

¿Cómo se puede lograr esto en una pila de LÁMPARAS?

Author: Benji XVI, 2010-12-05

6 answers

Puede utilizar popen() (docs) o proc_open() (docs) para ejecutar un comando de unix (por ejemplo. zip o gzip), y volver stdout como un flujo php. flush() (docs) hará todo lo posible para enviar el contenido del búfer de salida de php al navegador.

Combinar todo esto te dará lo que quieres (siempre que nada más se interponga en el camino see ver esp. las advertencias en la página docs para flush()).

(Nota: no utilice flush(). Ver la actualización a continuación para detalles.)

Algo como lo siguiente puede hacer el truco:

<?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);

Usted preguntó acerca de "otras tecnologías": a lo que voy a decir, "cualquier cosa que soporte e/s sin bloqueo para todo el ciclo de vida de la solicitud". Podría construir un componente como un servidor independiente en Java o C/C++ (o cualquiera de muchos otros lenguajes disponibles), si estuviera dispuesto a entrar en el "down and dirty" de acceso a archivos sin bloqueo y demás.

Si quieres un implementación sin bloqueo, pero preferirías evitar el "down and dirty", la ruta más fácil (IMHO) sería usar NodeJS. Hay mucho soporte para todas las características que necesita en la versión existente de nodejs: use el módulo http (por supuesto) para el servidor http; y use el módulo child_process para generar la canalización tar/zip/whatever.

Finalmente, si (y solo si) está ejecutando un servidor multiprocesador (o multinúcleo) y desea aprovechar al máximo nodejs, puede usar Spark2 para ejecutar varias instancias en el mismo puerto. No ejecute más de una instancia de nodejs por procesador.


Actualización (de los excelentes comentarios de Benji en la sección de comentarios sobre esta respuesta)

1. Los documentos para fread() indican que la función solo leerá hasta 8192 bytes de datos a la vez de cualquier cosa que no sea un archivo regular. Por lo tanto, 8192 puede ser una buena opción de tamaño de búfer.

[nota editorial] 8192 es es casi seguro que es un valor dependiente de la plataforma {en la mayoría de las plataformas, fread() leerá los datos hasta que el búfer interno del sistema operativo esté vacío, momento en el que volverá, permitiendo que el sistema operativo llene el búfer de nuevo de forma asíncrona. 8192 es el tamaño del búfer predeterminado en muchos sistemas operativos populares.

Hay otras circunstancias que pueden hacer que fread devuelva incluso menos de 8192 bytes for por ejemplo, el cliente "remoto" (o proceso) es lento para llenar el búfer - en la mayoría cases, fread() devolverá el contenido del búfer de entrada tal cual sin esperar a que se llene. Esto podría significar en cualquier lugar de 0..se devuelven los bytes os_buffer_size.

La moraleja es: el valor que pasa a fread() como buffsize debe considerarse un tamaño "máximo" never nunca asuma que ha recibido el número de bytes que pidió (o cualquier otro número para el caso).

2. De acuerdo con los comentarios en fread docs, algunas advertencias: citas mágicas mayo interfiere y debe estar desactivado.

3. Configuración mb_http_output('pass') (docs) puede ser una buena idea. Aunque 'pass' ya es la configuración predeterminada, es posible que deba especificarla explícitamente si su código o configuración la ha cambiado previamente a otra cosa.

4. Si estás creando un zip (en lugar de gzip), querrás usar el encabezado content type:

Content-type: application/zip

Or... se puede usar' application/octet-stream ' en su lugar. (es un contenido genérico tipo utilizado para descargas binarias de todo tipo):

Content-type: application/octet-stream

Y si desea que se le pida al usuario que descargue y guarde el archivo en el disco (en lugar de que el navegador intente mostrar el archivo como texto), necesitará el encabezado content-disposition. (donde filename indica el nombre que debe sugerirse en el diálogo guardar):

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

También se debe enviar el encabezado Content-length, pero esto es difícil con esta técnica, ya que no conoces el zip tamaño exacto por adelantado. ¿Hay un encabezado que se pueda configurar para indicar que el contenido está "transmitiendo" o es de longitud desconocida? ¿Alguien lo sabe?


Finalmente, aquí hay un ejemplo revisado que usa todas las sugerencias @ de Benji (y que crea un archivo ZIP en lugar de un TAR.Archivo 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);

Update : (2012-11-23) He descubierto que llamar a flush() dentro del bucle read/echo puede causar problemas cuando se trabaja con archivos muy grandes y / o redes muy lentas. Al menos, esto es cierto cuando se ejecuta PHP como cgi/fastcgi detrás de Apache, y parece probable que el mismo problema ocurriría cuando se ejecuta en otras configuraciones también. El problema parece ser el resultado cuando PHP descarga la salida a Apache más rápido de lo que Apache realmente puede enviarla a través del socket. Para archivos muy grandes (o conexiones lentas), esto eventualmente causa un desbordamiento del búfer de salida interno de Apache. Esto hace que Apache para matar el proceso de PHP, que por supuesto hace que la descarga se cuelgue, o se complete prematuramente, con solo una transferencia parcial que ha tenido lugar.

La solución es no para llamar a flush() en absoluto. He actualizado los ejemplos de código anteriores para reflejar esto, y he colocado una nota en el texto en la parte superior de la respuesta.

 47
Author: Lee,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-05-23 12:18:19

Otra solución es mi módulo mod_zip para Nginx, escrito específicamente para este propósito:

Https://github.com/evanmiller/mod_zip

Es extremadamente ligero y no invoca un proceso "zip" separado ni se comunica a través de tuberías. Simplemente apunta a un script que enumera las ubicaciones de los archivos que se incluirán, y mod_zip hace el resto.

 3
Author: Emiller,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2011-05-29 23:57:04

Tratando de implementar una descarga generada dinámica con un montón de archivos con diferentes tamaños me encontré con esta solución, pero me encuentro con varios errores de memoria como "Tamaño de memoria permitido de 134217728 bytes agotado en ...".

Después de agregar ob_flush(); justo antes de flush(); los errores de memoria desaparecen.

Junto con el envío de los encabezados, mi solución final se ve así (Simplemente almacenando los archivos dentro del zip sin estructura de directorios):

<?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,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2012-02-13 13:23:39

Escribí este archivo s3 steaming zipper microservice el fin de semana pasado - podría ser útil: http://engineroom.teamwork.com/how-to-securely-provide-a-zip-download-of-a-s3-file-bundle/

 2
Author: user3665185,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2015-08-04 23:52:02

De acuerdo con el manual de PHP, la extensión ZIP proporciona un zip: wrapper.

Nunca lo he usado y no conozco sus componentes internos, pero lógicamente debería ser capaz de hacer lo que estás buscando, asumiendo que los archivos ZIP se pueden transmitir, de lo cual no estoy del todo seguro.

En cuanto a su pregunta sobre la "pila de LÁMPARAS", no debería ser un problema mientras PHP no sea configurado para buffer de salida .


Editar: Estoy tratando de poner una prueba de concepto juntos, pero no parece trivial. Si no tiene experiencia con las transmisiones de PHP, podría resultar demasiado complicado, si es posible.


Edit (2): releer su pregunta después de echar un vistazo a ZipStream, he encontrado lo que va a ser su principal problema aquí cuando usted dice (énfasis añadido)

El Zipping operativo debe funcionar en modo streaming, es decir, procesar archivos y proporcionar datos al ritmo de la descargar.

Esa parte será extremadamente difícil de implementar porque no creo que PHP proporcione una forma de determinar cuán lleno está el búfer de Apache. Por lo tanto, la respuesta a su pregunta es no, probablemente no podrá hacerlo en PHP.

 1
Author: Josh Davis,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2010-12-05 04:29:13

Parece que puede eliminar cualquier problema relacionado con el búfer de salida usando fpassthru(). También uso -0 para ahorrar tiempo de CPU, ya que mis datos ya son compactos. Utilizo este código para servir una carpeta completa, comprimida sobre la marcha:

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,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-05-16 19:39:25