web-dev-qa-db-fra.com

LAMP: Comment créer .Zip de gros fichiers pour l'utilisateur à la volée, sans thrash disque / CPU

Souvent, un service Web doit compresser plusieurs fichiers volumineux à télécharger par le client. La façon la plus évidente de le faire est de créer un fichier Zip temporaire, puis de le echo à l'utilisateur ou de l'enregistrer sur le disque et de le rediriger (en le supprimant dans le futur).

Cependant, faire les choses de cette façon présente des inconvénients:

  • une phase initiale de thrashing intensif du processeur et du disque, résultant en ...
  • un retard initial considérable pour l'utilisateur pendant la préparation des archives
  • encombrement mémoire très élevé par requête
  • utilisation d'un espace disque temporaire important
  • si l'utilisateur annule le téléchargement à mi-parcours, toutes les ressources utilisées dans la phase initiale (CPU, mémoire, disque) auront été gaspillées

Des solutions telles que ZipStream-PHP améliorent cela en pelletant les données dans Apache fichier par fichier. Cependant, le résultat est toujours une utilisation élevée de la mémoire (les fichiers sont entièrement chargés dans la mémoire) et des pics importants et thrashy dans l'utilisation du disque et du processeur.

En revanche, considérez l'extrait de bash suivant:

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

Ici, Zip fonctionne en mode streaming, ce qui entraîne une faible empreinte mémoire. Un tube a un tampon intégré - lorsque le tampon est plein, le système d'exploitation suspend le programme d'écriture (programme à gauche du tube). Ceci garantit ici que Zip fonctionne seulement aussi vite que sa sortie peut être écrite par cat.

La meilleure façon serait alors de faire de même: remplacer cat par un processus de serveur Web, diffuser le fichier Zip sur le utilisateur avec elle créée à la volée. Cela créerait peu de frais généraux par rapport à la simple diffusion en continu des fichiers et aurait un profil de ressource non problématique et non hérissé.

Comment pouvez-vous y parvenir sur une pile LAMP?

43
Benji XVI

Vous pouvez utiliser popen()(docs) ou proc_open()(docs) pour exécuter une commande unix (par exemple. Zip ou gzip) , et récupérez stdout en tant que flux php. flush()(docs) fera de son mieux pour pousser le contenu du tampon de sortie de php vers le navigateur.

La combinaison de tout cela vous donnera ce que vous voulez (à condition que rien d'autre ne gêne - voir en particulier les mises en garde sur la page de documentation pour flush()).

( Remarque : n'utilisez pas flush(). Voir la mise à jour ci-dessous pour plus de détails.)

Quelque chose comme ce qui suit peut faire l'affaire:

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

Vous avez posé des questions sur "d'autres technologies": auxquelles je dirai, "tout ce qui prend en charge les E/S non bloquantes pendant tout le cycle de vie de la demande". Vous pouvez créer un tel composant en tant que serveur autonome dans Java ou C/C++ (ou l'un des nombreux autres langages disponibles), si dans lequel vous souhaitiez entrer le "bas et sale" de l'accès aux fichiers non bloquant et ainsi de suite.

Si vous voulez une implémentation non bloquante, mais que vous préférez éviter le "bas et le sale", le chemin le plus simple (à mon humble avis) serait d'utiliser nodeJS . Il y a beaucoup de support pour toutes les fonctionnalités dont vous avez besoin dans la version existante de nodejs: utilisez le module http (bien sûr) pour le serveur http; et utilisez le module child_process pour générer le pipeline tar/Zip/quel que soit le pipeline.

Enfin, si (et seulement si) vous exécutez un serveur multiprocesseur (ou multicœur) et que vous souhaitez tirer le meilleur parti de nodejs, vous pouvez utiliser Spark2 pour exécuter plusieurs instances sur le même Port. N'exécutez pas plus d'une instance nodejs par processeur-core.


Mise à jour (d'après les excellents commentaires de Benji dans la section des commentaires sur cette réponse)

1. Les documents pour fread() indiquent que la fonction ne lira que jusqu'à 8192 octets de données à la fois à partir de tout ce qui est pas un fichier ordinaire. Par conséquent, 8192 peut être un bon choix de taille de tampon.

[note éditoriale] 8192 est presque certainement une valeur dépendante de la plateforme - sur la plupart des plateformes, fread() lira les données jusqu'à ce que le tampon interne du système d'exploitation soit vide, auquel cas il reviendra, permettant à l'os de remplir le tampon à nouveau de manière asynchrone. 8192 est la taille du tampon par défaut sur de nombreux systèmes d'exploitation populaires.

Il y a d'autres circonstances qui peuvent amener fread à retourner encore moins de 8192 octets - par exemple, le client (ou processus) "distant" est lent à remplir le tampon - dans la plupart des cas, fread() renverra le le contenu du tampon d'entrée tel quel sans attendre qu'il soit plein. Cela pourrait signifier n'importe où de 0..os_buffer_size octets sont retournés.

La morale est la suivante: la valeur que vous passez à fread() comme buffsize doit être considérée comme une taille "maximale" - ne supposez jamais que vous avez reçu le nombre d'octets que vous avez demandé (ou autre numéro d'ailleurs).

2. Selon les commentaires sur les docs fread, quelques mises en garde: citations magiques peut interférer et doit être tourné off .

3. La configuration de mb_http_output('pass')(docs) peut être une bonne idée. Bien que 'pass' Soit déjà le paramètre par défaut, vous devrez peut-être le spécifier explicitement si votre code ou votre configuration l'a précédemment changé en autre chose.

4. Si vous créez un Zip (par opposition à gzip), vous voudriez utiliser l'en-tête du type de contenu:

Content-type: application/Zip

ou ... 'application/octet-stream' peut être utilisé à la place. (c'est un type de contenu générique utilisé pour les téléchargements binaires de toutes sortes):

Content-type: application/octet-stream

et si vous souhaitez que l'utilisateur soit invité à télécharger et enregistrer le fichier sur le disque (plutôt que de laisser le navigateur essayer d'afficher le fichier sous forme de texte), vous aurez besoin de l'en-tête de disposition de contenu. (où filename indique le nom qui devrait être suggéré dans la boîte de dialogue d'enregistrement):

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

Il faut également envoyer l'en-tête Content-length, mais c'est difficile avec cette technique car vous ne connaissez pas à l'avance la taille exacte du Zip. Existe-t-il un en-tête qui peut être défini pour indiquer que le contenu est "en streaming" ou est de longueur inconnue? Quelqu'un le sait-il?


Enfin, voici un exemple révisé qui utilise toutes les suggestions @ de Benji (et qui crée un fichier Zip au lieu d'un fichier 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);

Mise à jour : (2012-11-23) J'ai découvert que l'appel de flush() dans la boucle de lecture/écho peut causer des problèmes lors du travail avec de très gros fichiers et/ou des réseaux très lents. Au moins, cela est vrai lors de l'exécution de PHP en tant que cgi/fastcgi derrière Apache, et il semble probable que le même problème se produise également lors de l'exécution dans d'autres configurations. Le problème semble se produire lorsque PHP vide la sortie vers Apache plus rapidement qu'Apache ne peut réellement l'envoyer via le socket. Pour les fichiers très volumineux (ou les connexions lentes), cela entraîne éventuellement un dépassement du tampon de sortie interne d'Apache. Cela provoque Apache à tuer le processus PHP, ce qui entraîne bien entendu le téléchargement à se bloquer ou à se terminer prématurément, avec seulement un transfert partiel ayant eu lieu.

La solution est pas pour appeler flush() du tout. J'ai mis à jour les exemples de code ci-dessus pour refléter cela, et j'ai placé une note dans le texte en haut de la réponse.

49
Lee

Une autre solution est mon module mod_Zip pour Nginx, écrit spécifiquement à cet effet:

https://github.com/evanmiller/mod_Zip

Il est extrêmement léger et n'invoque pas de processus "Zip" séparé ni ne communique via des canaux. Vous pointez simplement vers un script qui répertorie les emplacements des fichiers à inclure, et mod_Zip fait le reste.

3
Emiller

J'ai écrit ce microservice de fermeture à glissière de fichier s3 le week-end dernier - pourrait être utile: http://engineroom.teamwork.com/how-to-securely-provide-a-Zip-download-of-a-s3-file -bundle /

2
user3665185

En essayant d'implémenter un téléchargement généré dynamiquement avec beaucoup de fichiers de différentes tailles, je suis tombé sur cette solution mais j'ai rencontré diverses erreurs de mémoire comme "Taille de mémoire autorisée de 134217728 octets épuisés à ...".

Après avoir ajouté ob_flush(); juste avant la flush(); les erreurs de mémoire disparaissent.

Avec l'envoi des en-têtes, ma solution finale ressemble à ceci (il suffit de stocker les fichiers à l'intérieur du Zip sans structure de répertoire):

<?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
Rico Sonntag

Selon le PHP manual , l'extension Zip fournit un wrapper Zip:).

Je ne l'ai jamais utilisé et je ne connais pas ses propriétés internes, mais il devrait logiquement pouvoir faire ce que vous cherchez, en supposant que les archives Zip peuvent être diffusées, ce dont je ne suis pas entièrement sûr.

Quant à votre question sur la "pile LAMP", cela ne devrait pas poser de problème tant que PHP is notconfiguré pour tamponner la sortie .


Edit: J'essaie de mettre en place une preuve de concept, mais cela ne semble pas anodin. Si vous n'êtes pas expérimenté avec les flux PHP, cela pourrait s'avérer trop compliqué, si c'est même possible.


Edit (2): relisant votre question après avoir jeté un œil à ZipStream, j'ai trouvé ce qui va être votre principal problème ici quand vous dites (c'est moi qui souligne)

le Zipping opérationnel devrait fonctionner en mode streaming, c'est-à-dire traiter des fichiers et fournir des données au rythme du téléchargement.

Cette partie sera extrêmement difficile à implémenter car je ne pense pas que PHP fournit un moyen de déterminer le niveau de tampon d'Apache. Donc, la réponse à votre question est non, vous ne le ferez probablement pas être en mesure de le faire en PHP.

1
Josh Davis

Il semble que vous puissiez éliminer tous les problèmes liés au tampon de sortie en utilisant fpassthru () . J'utilise également -0 pour gagner du temps CPU car mes données sont déjà compactes. J'utilise ce code pour servir un dossier entier, zippé à la volée:

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
Hermann