Hochladen vieler Dateien und großer Datenmengen als Stream-Prozess

Zweckmäßige Alternative zu Enctype multipart/form-data

Enctype multipart/form-data ist Schrott

Zum Hochladen sehr vieler Dateien und Datenmengen von über 1 GB bei 200 Dateien ist dieser Enctype multipart/form-data völlig ungeeignet. Selbst wenn der gesamte Request-Body temporär auf die Festplatte des Servers geschrieben und dann geparst wird, wäre dieser Prozess extrem CPU-lastig und würde das Zeitlimit des Webservers erreichen, was einen Abbruch zur Folge hätte. Außerdem bietet dieser Enctype keine Möglichkeit, die Last-Modified sowie Größen-Angaben der hochzuladenden Dateien zu erfassen und mitzusenden.

Stream aus STDIN blockweise lesen und schreiben

Streamen heißt, daß die Daten byte- oder blockweise aus STDIN gelesen und sofort auf die Festplatte geschrieben werden. Das hier vorgestellte Verfahren kodiert jede Datei als einen Tupel bzw. Tripel mit den Werten name, binary, lastmod für jede Einzeldatei und somit werden Tupel für Tupel sequentiell aus STDIN gelesen und immer dann wenn ein Tripel komplett ist, wird die Datei geschrieben.

Tupel bzw. Tripel mit JavaScript erzeugen

Als Alternative zu multipart/form-data bleibt die FileAPI moderner Browser. Damit stehen sämtliche Dateieigenschaften sowie die Dateien selbst zur Verfügung, so kommt es nur noch darauf an diese Informationen richtig zu verpacken für den POST. Betrachte untenstehenden JavaScript-Code:

// BufferEncode // raw ist ein Array mit strings oder Blobs (File) function BufferEncode( raw ){ this.result = []; for(let i = 0; i < raw.length; i++){ if( !raw[i] ) raw[i] = ''; let obj = typeof raw[i] == 'string' ? new Blob([raw[i]]) : raw[i]; let ab = new ArrayBuffer(4); let dv = new DataView(ab); dv.setUint32(0, obj.size); this.result.push( new Blob([ab,obj]) ); } } function upload(){ let files = document.getElementById('upspot').files; if(! files.length ) return; let sam = []; for(let i = 0; i < files.length; i++){ sam.push( files[i].name ); // Dateiname sam.push( ""+files[i].lastModified/1000 ); // 1617353322 sam.push( files[i] ); // Dateiinhalt } let up = new BufferEncode(sam); let xhr = new XHR(cb ); xhr.xhr.upload.onprogress = fuse; xhr.post('%url%?upload=1', new Blob(up.result)); }

Damit die Einzelwerte aus der Bytesequenz wiederhergestellt werden können, bekommt jeder Einzelwert seine Längenangabe als einen 32-Bit-Integer in Network-Order (Big Endian) vorangestellt, das sind genau 4 Bytes. Dieser Stream wird als HTTP-Message-Body per POST gesendet.

Stream lesen und Tupel wiederherstellen

Im Wechsel werden immer genau 4 Bytes gelesen und mit der sich daraus ergebenden Längenangabe der Wert selbst. Nach 3 Schleifendurchläufen ist ein Tupel komplett und die Datei wird weggeschrieben. Das Ganze wiederholt sich solange wie Daten aus STDIN zu lesen sind, die Anzahl der Bytes findet sich in $ENV{CONTENT_LENGTH} (Abbruchbedingung der Hauptschleife). Der gesamte Code untenstehend:

package Admin::FotoUp; # Änderung am 9.1.2023 # die eingespielten Bilder werden nach Jahr und Monat verteilt # z.B. nach c:/fotoarchiv/2022/11 # wenn die Bilder von der Olympus kommen, beginnen die Dateinamen mit P # das 2. Zeichen ist der Monat in HEX # in diesem Fall ersetzen wir das P einfach durch das Jahr use base main; use strict; use warnings; use IO::File; sub init{ my $self = shift; $self->eav('title', $self->eav('title')." $ENV{SERVER_NAME}"); $self->{xfiles} = 0; $self->{log} = []; # dignostics } # Loggen diagnostics sub log{ my $self = shift; my $value = shift; push @{$self->{log}}, $value; } # die Datei wird blockweise geschrieben sub bwrite{ my $self = shift; #$self->log("@_"); my $name = shift; my $utime = shift; my $binlen = shift; # Länge aus STDIN zu lesen my $path = "$self->{FILEDIR}/".$self->eav('dir'); unless( -d $path ){ mkdir $path or die $!; } $path = "c:/fotoarchiv" if $ENV{SERVER_NAME} eq "rolfrost"; my $timehunt = $self->localtime($utime); my $year = sprintf("%04d", $timehunt->{year}); my $month = sprintf("%02d", $timehunt->{mon}); my $mday = sprintf("%02d", $timehunt->{mday}); if(! -d "$path/$year"){ mkdir "$path/$year" or die $! } if(! -d "$path/$year/$month"){ mkdir "$path/$year/$month" or die $! } # Name anpassen, Bilder von Olympus beginnen mit P if( $name =~ /^p/i ){ $name =~ s/.+(\d\d\d\d)\.jpg/$mday\.$month\.$year\_$1\.jpg/i; } #die $name; # Die Datei wird blockweise aus STDIN gelesen und geschrieben my $fh = IO::File->new(); $fh->open( "$path//$year/$month/$name", O_CREAT|O_TRUNC|O_BINARY|O_RDWR ) or die $!; #$self->log('tell vor bwrite: '.tell STDIN); # hier wird gepuffert my $buflen = 1000000; my $int = int($binlen/$buflen); my $rest = $binlen - $int * $buflen; for(1..$int){ read(STDIN, my $buffer, $buflen); $fh->print($buffer); } read(STDIN, my $buffer, $rest); $fh->print($buffer); #$self->log('tell nach bwrite: '.tell STDIN); $fh->close; utime($utime, $utime, "$path/$year/$month/$name") or die $!; $self->{xfiles}++; } # Reihenfolge in Tupel: dateiname, utime, binary # Datei blockweise sub control{ my $self = shift; my $i = 0; # Index Tupel my @tupel = (); # name, utime, content while( tell(STDIN) < $ENV{CONTENT_LENGTH} ){ read(STDIN, my $bin, 4); my $binlen = unpack "N", $bin; read(STDIN, $tupel[$i], $binlen); $i++; if( $i == 2 ){ read(STDIN, my $clen, 4); $self->bwrite(@tupel, unpack("N", $clen)); @tupel = (); $i = 0; } } $self->{CONTENT} = "UpFiles: $self->{xfiles} OK "; # $self->dumper($self->{log}); } 1;######################################################################### __DATA__ <script src="/jquery.min.js"></script> <script src="/request.js"></script> <script> function cb(){ cleanup(); pretext(this.response); } function cleanup(){ let pro = document.getElementsByTagName('progress'); for(let i = 0; i < pro.length; i++){ pro[i].remove(); } } function upload(){ let files = document.getElementById('upspot').files; if(! files.length ) return; let sam = []; for(let i = 0; i < files.length; i++){ sam.push( files[i].name ); sam.push( ""+files[i].lastModified/1000 ); // 1617353322 sam.push( files[i] ); } let up = new BufferEncode(sam); let xhr = new XHR(cb ); xhr.xhr.upload.onprogress = fuse; xhr.post('%url%?upload=1', new Blob(up.result)); } </script> <p style="margin:3em"><input type="file" multiple id="upspot"></p> <p style="margin:3em"><button onClick="upload()">UpLoad!</button></p>

Erfahrungen

Diese Anwendung habe ich entwickelt um Fotos vom Smartphone auf meinen Server im LAN bzw. WLAN hochzuladen. In der Ersten Version gab es bei mehr als 100 Dateien (ca. 500 MB) Abbrüche was daran lag daß der gesamte Stream zunächst in den Hauptspeicher kopiert wurde. Es war genau dieses Problem welches infolge der Umstellung auf Streaming gelöst werden konnte. Ein Upload von mehr als 200 Dateien (Fotos, Videos), Datenmenge 1 GB, läuft damit unbeaufsichtigt durch und dauert ca. 4 Minuten.


Datenschutzerklärung: Diese Seite dient rein privaten Zwecken. Auf den für diese Domäne installierten Seiten werden grundsätzlich keine personenbezogenen Daten erhoben. Das Loggen der Zugriffe mit Ihrer Remote Adresse erfolgt beim Provider soweit das technisch erforderlich ist. s​os­@rolf­rost.de.