PHPGangsta - Der praktische PHP Blog

PHP Blog von PHPGangsta


Archive for the ‘Zip Download’ tag

Große Dateien komprimiert als Download streamen

with 24 comments

Bei großen Dateien gibt es mehrere Probleme. Sie belegen viel Speicherplatz auf der Festplatte, bei der Komprimierung vergeht viel Zeit, die fertig komprimierte Datei belegt wiederum viel Speicherplatz, und eventuell wird auch viel Arbeitsspeicher benötigt beim Komprimieren oder ausliefern via PHP.

Viele dieser Probleme lassen sich lösen, wie ich gleich zeigen möchte. Wie vieles auf dieser Welt ist das jedoch auch mit kleinen Nachteilen versehen, sodass man abwägen muss, was einem wichtig ist.

Mein kleines Beispiel hier geht davon aus dass es eine oder mehrere große Dateien gibt, die gezippt heruntergeladen werden sollen. Die zu komprimierenden Dateien liegen auf einem anderen Server (hier: Netzlaufwerk), es könnte aber auch ein FTP-Server, ein Upload oder sonst irgendeine Quelle sein. Natürlich funktioniert das ganze auch mit lokalen Dateien oder gerade erst erzeugten Daten (beispielsweise MySQL Dump).

Die erste Lösung wäre diese:

<?php
set_time_limit(0);

copy('\\\\192.168.1.33\\Dateien\\seinfeld.avi', 'seinfeld1.avi');

$zip = new ZipArchive();
$filename = "seinfeld1.zip";

if ($zip->open($filename, ZIPARCHIVE::CREATE)!==TRUE) {
    exit("cannot open <$filename>\n");
}
$zip->addFile('seinfeld1.avi');
$zip->close();

header('Content-type: application/octetstream');
header('Content-Disposition: attachment; filename="' . $filename . '"');
readfile($filename);

Hier haben wir ein 700MB Video als Quelle, das auf einem Netzlaufwerk liegt. Wir kopieren es zuerst auf unsere lokale Festplatte (mit 30MB/s dank Gigabit-Netzwerk) und beginnen dann mit der Komprimierung. Durch die Komprimierung werden temporär weitere 700MB belegt, und danach starten wir direkt mit der Ausgabe der Headern und dem Inhalt der Datei.

Findige Programmierer sehen sofort: Wir können uns das Kopieren der Datei sparen, und die Datei direkt vom Netzlaufwerk an addFile() übergeben. Dadurch sparen wir die 20 Sekunden für die Kopieraktion und beginnen direkt mit der Komprimierung. Das funktioniert auch dank Wrappern mit einigen anderen Protokollen, aber bei einer „unüblichen Quelle“ wie beispielsweise dem MySQL-Dump nicht.

Lösung 2:

<?php
set_time_limit(0);

$zip = new ZipArchive();
$filename = "seinfeld2.zip";

if ($zip->open($filename, ZIPARCHIVE::CREATE)!==TRUE) {
    exit("cannot open <$filename>\n");
}
$zip->addFile('\\\\192.168.1.33\\Dateien\\seinfeld.avi');
$zip->close();

header('Content-type: application/octetstream');
header('Content-Disposition: attachment; filename="' . $filename . '"');
readfile($filename);

Garnicht schlecht, wir haben den Festplattenbedarf halbiert, und auch der Download startet früher. Aber wir haben nach wie vor das Problem dass der Download erst nach 58 Sekunden beginnt, so lange dauert nämlich die Komprimierung. Das ist eigentlich nicht zumutbar. Das wollen wir verbessern.

Wie kann das gehen? Wir komprimieren nicht die ganze Datei und beginnen mit dem Download wenn die ganze Datei komprimiert wurde, sondern wir komprimieren in 100KB Häppchen und senden permanent die Zwischenergebnisse zum Download. Leider hat das ZIP Format den Nachteil dass es direkt am Anfang die CRC-Prüfsumme der Datei wissen will. Das bedeutet wiederum dass wir nicht darum herum kommen, diese anfangs zu berechnen. Dies dauert 20 Sekunden, der Download startet also nach 20 Sekunden. Es werden dann 100KB vom Netzlaufwerk gelesen, komprimiert und an den Browser ausgeliefert. Das geht solange bis die komplette Datei komprimiert wurde.

Da ZipArchive diese Streaming Technik nicht unterstützt muss manuell Hand angelegt werden und die ZIP-Datei erstellt werden. Dazu gehört der Gesamtheader der ZIP Datei sowie zu jeder Datei innerhalb des Archivs die Dateiheader. Das Projekt ZipStream-PHP von Paul Duncan kann das ganz gut. Leider treten bei der Benutzung einige Notices auf, die aber schnell behoben sind, sie treten auch nur auf wenn man nicht alle optionalen Parameter nutzt. Außerdem gibt es ein Problem mit der crc32b Berechnung, die aber auch leicht zu lösen war. Eine funktionierende Version befindet sich hier.

Der Ablauf:

Nutzung von hash_file() um die CRC32-Prüfsumme zu berechnen und den ZIP-Datei-Header zu generieren. Dann werden jeweils 100KB gelesen, komprimiert und ausgegeben. Dies wird wiederholt bis die Datei vollständig komprimiert und ausgegeben wurde.

In der ZipStream Klasse können natürlich noch einige Parameter angepasst werden, beispielsweise die Größe der Päckchen (hier 100KB), man kann aber auch ein Limit festlegen, ab dem dieses Zerhackstückeln passieren soll, unterhalb dieser Grenze wird die Datei als ganze gelesen und komprimiert.

Und hier nun das Script, welches ZipStream nutzt (Lösung 3):

<?php
set_time_limit(0);

require 'zipstream.php';
$zip = new ZipStream('seinfeld3.zip');

$zip->add_file_from_path('seinfeld.avi', '\\\\192.168.1.33\\Dateien\\seinfeld.avi');

$zip->finish();

So einfach kann es sein. Es gibt keine temporäre Datei, der Download beginnt früh, und der Arbeitsspeicher wird auch nicht beansprucht.

Ich habe auch nach einer Lösung gesucht, um „richtiges“ Streaming zu machen und dann mit stream_filter_append() und zlib.deflate zu arbeiten, aber da habe ich nichts gefunden. Vielleicht könnte man ZipStream-PHP so erweitern dass es damit funktioniert.

Aber wie bereits gesagt haben diese Streaming Lösungen auch Nachteile. Ein Nachteil ist beispielsweise die fehlende Möglichkeit, den Download zu pausieren und später fortzufahren (HTTP Ranges). Würde man die Datei nicht streamen sondern wie in den ersten Beispielen erstmal als Datei abspeichern würde das funktionieren, ebenso könnte man dann den eigentlichen Download an einen leichtgewichtigen Webserver wie nginx oder lighttpd übergeben oder mittels Sendfile die Datei vom Apache ausliefern lassen. Dann würde auch HTTP Range unterstützt und mehrere Personen könnten die Datei downloaden. Je nach Anwendungsfall ist also manchmal auch die Nicht-Streaming-Methode besser.

Hier nochmal eine Übersicht der 3 Lösungen:

Lösung 1Lösung 2Lösung 3
Download beginnt nach75 Sekunden58 Sekunden20 Sekunden
Download beendet nach (in Sekunden)141 Sekunden109 Sekunden98 Sekunden
zusätzlicher Festplattenverbrauch1400 MB700 MB0 MB
memory_get_peak_usage(true)500 KB500 KB1 MB

Written by Michael Kliewe

Dezember 2nd, 2010 at 9:10 am

Posted in PHP

Tagged with , , ,