Bytesemantics zum Lesen und Schreiben von Daten in Dateien bzw. Handle, binary safe read and write
Der vorliegende Artikel beschreibt, wie Serialize-Algorithmen in Perl
entwickelt werden können. Derartige Algorithmen sind jedoch keineswegs auf die Programmiersprache Perl
beschränkt, sie gewinnen an Bedeutung für einen Bytesicheren (8Bit) Transport zum Austausch von Daten innerhalb einer Anwendung und zum Austausch von Daten von einer Anwendung zur Anderen, beispielsweise von JavaScript
nach Perl
, Perl
nach PHP
usw. und umgekehrt.
Der Dateibegriff nach Niklaus Wirth beschreibt eine Byte-Sequenz. Eine Datei wird sequentiell geschrieben und auch gelesen. In jeder Programmiersprache, außer JavaScript
, heißt das Schreiben einer Datei: Es werden Bytes in einem Handle
abgelegt. Dabei ist es unerheblich, ob das Handle
auf eine im Dateisystem physikalisch vorliegende Datei zeigt oder auf einen Bereich im Hauptspeicher.
IO::String
use IO::String; my $FH = IO::String->new;
Das erstellt ein Handle
im Hauptspeicher. $FH
wird genauso verwendet wie jedes andere FileHandle oder STDIN
, STDOUT
. Sollen STDIN, STDOUT
in Subfunktionen übergeben werden, verwende einen Typeglob, z.B. $eav = handle2eav(*STDIN);
Für im Dateisystem physikalisch vorliegende Dateien, verwende IO::File
. Das importiert Konstanten, welche den Modus beim Öffnen einer Datei bestimmen, z.B.:
use IO::File; my $FH = IO::File->new; $FH->open('/path/file', O_CREAT|O_RDWR|O_BINARY) or die $!;
Beachte: Mit O_BINARY
wird bereits beim Öffnen der Datei der Binärmodus eingeschaltet. Das ist insofern wichtig, als dass beim Lesen und Schreiben in das Handle seitens Betriebssystem keine eigenmächtigen Änderungen an der Datei vorgenomen werden, beispielsweise eine Sonderbehandlung von Zeilenumbrüchen. Merke, dass es in Binärdateien gar keine Zeilenumbrüche gibt, sondern einfach nur Bytes.
Ein Byte verkörpert eine Wertigkeit von 0..255. Eine Folge von mehreren Bytes können wir als ein Array
bzw. eine Liste von Zahlen betrachten. Somit sähe ein einfacher Algorithmus zum Schreiben einer Liste von Zahlen so aus:
use strict; use warnings; use IO::String; use bytes; my $FH = IO::String->new; # Erstelle ein Handle im Hauptspeicher foreach my $nr(1,2,3,4,55,255){ $FH->print( pack "C", $nr ); } # Handle wieder auslesen $FH->seek(0,0); # zurück zum Anfang while( read($FH, my $buffer, 1) ){ print unpack("C", $buffer), "\n"; } # Die Zahlen 1 2 3 4 55 255 werden untereinander ausgegeben
Gut zu sehen: Egal wieviel Platz an Bytes, also wieviele Stellen eine Zahl von (0..255) selbst belegt, in der Datei belegt diese Zahl immer genau ein Byte. Dieser Fakt ist wichtig, denn ein Serialize-Algorithmus muss sicherstellen, dass die Daten auch wieder hergestellt werden können. So wird in obenstehendem Beispiel das Handle in Schritten von genau einem Byte gelesen, womit sich das gewünschte Ergebnis zeigt.
Zum Kodieren größerer Zahlen als 255 werden mehrere Bytes benötigt. Vier Bytes aneinandergereiht ermöglichen so die Kodierung einer Zahl 0xFFFFffff
, was den größten Wert für einen vorzeichenlosen Integer
mit 32 Bit darstellt. Beim Aneinanderreihen mehrerer Bytes ist die sogenannte Byte-Order zu beachten, je nachdem, ob diese Order absteigend oder ansteigend erfolgt, liegt entweder ein Big-Endian oder ein Little-Endian vor. In Perl gibt es für den Big-Endian die pack-Schablone "N", diese Byte-Order wird auch als Network-Order bezeichnet. Obenstehendes Beispiel nun geändert für die Verwendung der pack-Schablone "N":
use strict; use warnings; use IO::String; use bytes; my $FH = IO::String->new; # Erstelle ein Handle im Hauptspeicher foreach my $nr(1,2,3,4,55,255,3299123){ $FH->print( pack "N", $nr ); } # Handle wieder auslesen $FH->seek(0,0); # zurück zum Anfang while( read($FH, my $buffer, 4) ){ print unpack("N", $buffer), "\n"; } # Die Zahlen 1 2 3 4 55 255 3299123 werden untereinander ausgegeben
Nunmehr wird zum Lesen einer jeden einzelnen Zahl das Handle in Schritten von vier Bytes durchgegangen und das Speichern sehr großer Zahlen wird möglich.
pack()
und unpack()
Die beiden Built-In-Funktionen benötigen eine Schablone, welche zum Einen die Anzahl der Bytes bestimmt und zum Anderen die Größe der Byte-Wertigkeit, den numerischen Wert. Beispiele in Kürze mit einer bildlichen Veranschaulichung der Bytes:
□ = pack "C", 255; # ein Byte für Integer von 0..255, ergibt ein Byte □□□□ = pack "N", 0xFFFFffff; # BigEndian für Integer von 0..0xFFFFffff, ergibt vier Bytes □□□□ = pack "V", 0xFFFFffff; # LittleEndian für Integer von 0..0xFFFFffff, ergibt vier Bytes # Mehrere Zahlen kombiniert □□□□ = pack ("CCCC", 1,2,3,4); □□□□ = pack ("C4", 1,2,3,4); # dasselbe wie eine Zeile weiter oben # und wieder auspacken @numbers = unpack "CCCC", □□□□); # ergibt 1,2,3,4 # ein vorzeichenloser Integer mit 32 Bit, 0xFFFFffff, 4294967295 □□□□ = pack "N", 0xFFFFffff; □□□□ = pack "N", 4294967295; 4294967295 <= unpack "N", □□□□ # scalarer Kontext: eine Zahl (255,255,255,255) <= unpack "CCCC", □□□□ # vier Zahlen im Listenkontext
Merke: Die Schablone bestimmt die Anzahl der Bytes UND die Anzahl der vorzeichenlosen Integer-Werte. Beim Serialisieren bietet sich die Schablone "N" an zum Verpacken einer Längenangabe (unsigned integer mit 32 Bit) in eine Sequenz von vier Bytes (□□□□
) als BigEndian.
Mit use bytes;
wird in Perl ein Pragma gesetzt, womit alle in Scalaren
vorliegenden Strings nicht als Zeichenfolgen, sondern als Folge von Bytes betrachtet werden. Eine UTF-8-kodierte Zeichenfolge 'äöü' verkörpert nach dem Einschalten des o.g. Pragma eine Bytesequenz mit der Länge von 6 Bytes, obwohl es 3 Zeichen sind.
Beachte: Das Einschalten der Bytesemantics mit use bytes;
ist wichtig zum Ermittlen der Längenangaben als Anzahl der Bytes und nicht als Anzahl der Zeichen.
Nach dem Einschalten der Bytesemantics können wir nun eine Liste von beliebigen Literalen in ein Handle schreiben und daraus unzerknittert wieder hervorzaubern.
use strict; use warnings; use IO::String; use bytes; my $FH = IO::String->new; # Erstelle ein Handle im Hauptspeicher foreach my $str( qw(foo bar baz dogs) ){ $FH->print( pack("N", length $str).$str ); } # Daten wiederherstellen $FH->seek(0,0); while( read($FH, my $buffer,4) ){ my $len = unpack "N", $buffer; read($FH, $buffer, $len); print "$buffer\n"; } # ausgegeben werden untereinander foo bar baz dogs
Der Algorithmus ist leicht verständlich: Im Schleifenkörper lesen wir anfangs immer genau 4 Bytes zum Ermitteln der Längenangabe für den nachfolgenden Wert, d.h. als Nächstes wird mit dieser Längenangabe der Wert selbst aus dem Handle gelesen.
Der Algorithmus arbeitet binary safe, ein Wert selbst kann eine beliebige Bytefolge mit Oktettenwertigkeiten von 0..255 enthalten.
Mit dem bisher gezeigten Algorithmus haben wir als kleinste Logische Speichereinheit auf dem Low-Level (Byte-Ebene) ein Paar, bestehend aus einer Längenangabe und einem gespeicherten Wert. Wobei: Die Längenangabe selbst hat stets eine Länge von genau 4 Bytes als Big-Endian. Damit ist sichergestellt, dass die gewünschte Datenstuktur, in diesem Fall ein Array
, aus der Sequenz wiederhergestellt werden kann. Wenn wir mit einem Textbetrachter in die Datei schauen, sehen wir in etwa so etwas:
□□□□foo□□□□bar□□□□baz□□□□dogs
Wobei z.B. □□□□foo
eine logische Speichereinheit darstellt, für genau einen Wert.
Ein Array
mit einer geradzahligen Anzahl von Array-Elementen kann auf Programmcode-Ebene auch als assoziatives Array
aufgefasst werden, was in Perl einen einfachen Hash
verkörpert. Somit können wir auch einen Hash
mit dem oben gezeigten Algorithmus in einer Datei speichern. Dieser Hash
entspricht dem Aufbau-Muster Attribute => Value
(im Beispiel: foo => 'bar', bar => 'dogs'
).
Am Serialize-Algorithmus ändert sich nichts, allein der Programmcode bestimmt die aus der Sequenz zu gewinnende Datenstruktur, ein Array
lässt sich in einen Hash
transformieren und umgekehrt, vorausgesetzt, die Anzahl der Array-Elemente ist restlos teilbar durch zwei. Aufgrund dieser Vielgestaltigkeit, lassen sich als weitere Möglichkeit jeweils immer drei Werte zusammenfassen und ergeben eine Datenstruktur nach dem Muster Entity, Attribute, Value
, in Perl bekannt als Hash-of-Hashes
(HoH). Das heißt, dass es mit dem oben dargestellten Algorithmus und der damit verbundenen kleinsten Low-Level-Speichereinheit auch möglich ist, Perl HoH's
in Dateien speichern zu können.
Visuell ist eine Tabelle ein zweidimensionales Array
, bestehend aus Zeilen und Spalten. Jeder Schnittpunkt einer Zeile mit einer Spalte definiert eine Zelle, in welcher sich der Wert befindet. Aber auch zum Serialisieren einer solchen Matrix lässt sich der bisher beschriebene Algorithmus anwenden, nämlich dadurch, dass die Matrix in einen %HoH
transformiert wird:
Liegt die Matrix als Array-of-Arrays
vor, haben wir sowohl den spaltenindex
als auch den zeilenindex
.
$hoh{$spaltenindex}{$zeilenindex} = $value; # Komplettbeispiel, 2 Zeilen, 3 Spalten my $AoA = [ [1,2,3], [4,5,6] ]; my $HoH = {}; foreach my $zeilenindex( 0 .. scalar @$AoA - 1){ foreach my $spaltenindex( 0 .. scalar @{$AoA->[$zeilenindex]} - 1 ){ $HoH->{$zeilenindex}{$spaltenindex} = $AoA->[$zeilenindex][$spaltenindex]; } }
Der Hash-of-Hashes
beinhaltet genau dieselben Informationen wie das Array-of-Array
nunmehr mit dem Vorteil, dass die Werte über $zeilenindex
und $spaltenindex
direkt adressierbar sind..
Merke: Der wahlfreie Zugriff (Random Access, direkte Adressierbarkeit) wird nicht etwa in der Sequenz (auf Dateiebene) abgebildet sondern auf der in der Sequenz enthaltenen Datenstruktur. Der Aufbau einer Datei auf Byte-Ebene ist von der Datenstruktur unabhängig. Ein wahlfreier Zugriff erfolgt über die im Code vorliegende Datenstruktur, das ist ein Array
oder ein Hash
oder ein Scalar
.
Beim Entwicklen von Serialize-Algorithmen ist zu beachten, wie mit undefinierten Werten umzugehen ist, z.B. mit sowas:
my %h = ( foo => 'bar', baz => undef);
Ein undef
lässt sich nämlich nicht so ohne Weiteres in einer Datei abbilden und obenstehender Algorithmus erzeugt eine Fehlermeldung:
my $FH = IO::String->new; # Erstelle ein Handle im Hauptspeicher foreach my $nr(1,2,3,4,55,255,3299123, undef){ $FH->print( pack "N", $nr ); } # Use of uninitialized value $nr in pack
Es wäre unklug, die Warnmeldungen für solche Fälle einfach abzuschalten, vielmehr sollte sich der Programmierer Gedanken darüber machen, wie mit undefinierten Werten in seinem Programm umzugehen ist. In Kürze zwei Möglichkeiten:
undef
oder Leerstring wird mit einem zusätzlichen Byte gekennzeichnetundef
)undef
wird einfach zu einem Leerstring gemachtIn beiden Fällen liegt beim Serialisieren ein definierter Wert vor, und die zweite Variante hat sogar den Vorzug, dass bei der Weiterverarbeitung der deserialisierten Daten nicht erneut auf undef
geprüft werden muss, was freilich auch von der Endanwendung abhängt. In Fakt ist zu überlegen, ob undef
über den Transport-Layer (Datei, Sequenz) zu transportieren ist oder nicht oder in welcher Form.
Es lassen sich weitere Algorithmen finden, womit sich die IO
-Performance verbessern lässt. Nun, was heißt das? Bei einer gegebenen Sequenz:
□□□□foo□□□□bar□□□□baz□□□□dogs
müsste die Perl-Funktion unpack("N", $buffer);
viermal aufgerufen werden zum Ermitteln der jeweiligen Längenangaben, so hätten wir vier Schleifendurchläufe. Aufgrund der Tatsache, dass die Perl-Funktion unpack()
ein Array
liefert, können wir die Längenangaben auch zusammenfasssen, betrachte die Sequenz:
□□□□□□□□□□□□□□□□foobarbazdogs
Der Aufruf der Perl-Funktion unpack()
sieht dann so aus, nachdem 4*4 Bytes auf den Buffer gelesen wurden:
read($FH, $buffer, 12); # 4x4 Bytes my($len_foo, $len_bar, $len_baz, $len_dogs) = unpack "NNNN", $buffer;
Erforderlich ist jedoch nun, dass die Anzahl der Array-Elemente am Anfang der Sequenz zu finden ist, kein Problem, dafür genügt ein Big-Endian:
□□□□ read($FH, $buffer, 4); my $el_count = unpack "N", $buffer;
Aus der Anzahl der Array-Elemente ergibt sich die Anzahl der zu lesenden Bytes, die Schablone für den nächsten Aufruf der Funktion unpack()
und das Array mit den Offset-Werten für den Rest der Datei:
read($FH, $buffer, $el_count * 4); my @offsets = unpack "N$el_count", $buffer;
Schließlich müssen wir nur noch das Array mit den Offset-Angaben abarbeiten und bekommen alle Array-Elemente aus der Datei:
@result = (); while( my $offset = shift @offsets){ read($FH, $buffer, $offset); push @result, $buffer; }
Und hier mal alles zusammen:
use strict; use warnings; use IO::String; use bytes; my $FH = IO::String->new; my @array = qw(foo bar boo dogs); # serialize my $el_count = scalar @array; $FH->print( pack "N", $el_count); # Anzahl der Array-Elemente my $packs = ''; foreach my $el(@array){ $packs .= pack "N", length $el; } $FH->print($packs); # byte-kodierte Längenangaben $FH->print(join '', @array); # Alle Elements mit einmal # deserialize $FH->seek(0,0); read($FH, my $buffer, 4); $el_count = unpack "N", $buffer; read($FH, $buffer, 4 * $el_count); my @offs = unpack "N$el_count", $buffer; while(my $offset = shift @offs){ read($FH, $buffer, $offset); print "$buffer\n"; } # Ausgabe untereinander: foo bar baz dogs
Mit dem Verständnis der bisher gezeigten Algorithmen ist es nicht weiter schwierig, einen Serializer für Hash-of-Hashes
(Entity, Attribute, Value, EAV) zu entwickeln. Gerade das EAV-Model eröffnet viele Perspektiven für den Datenaustausch zwischen Perl und JavaScript (AJAX). Zum Beispiel die Übertragung der Daten einer kompletten Tabelle, welche sich im Browser befindet in einem PUT-Request, Anwendung siehe hier. Abstrakt gesehen werden in dieser Anwendung mehrere Objekte übertragen. Im Prinzip ist das auch nichts Neues, neu ist lediglich, dass anstelle JSON
ein eigener Serialize-Algorithmus zum Einsatz kommt, der ohnehin serverseitig zur Verfügung steht, weil die Daten mit demselben Algorithmus in einer Datei persistent gespeichert werden, genauso wie die lokale Replik für JavaScript.
Gegenüber JSON
ist ein Low-Level-Serializer
jedoch binary safe und jegliche Sonderbehandlungen von Zeichen (etwa das Maskieren von Zeilenumbrüchen) entfallen komplett. Ja, sogar die Zeichenkodierung ist für einen Low-Level-Serializer
völlig uninteressant, der interessiert sich nur für Bytes.
JavaScript
kennt kein IO
und demzufolge auch keine DateiHandler. Wohl aber gibt es in JS den ArrayBuffer
und das Uint8Array
. Darüber hinaus lassen sich in einem Uin8Array
vorliegende mehrere Array-Elemente zusammenfassen, so dass über das Objekt DataView
beispielsweise ein Big- oder Little-Endian erzeugt werden kann, hierzu werden 4 Array-Elemente zusammengefasst.
Somit ist es möglich, den oben gezeigten Algorithmus auf die Verarbeitung von Binärdaten bzw. Byte-Sequenzen auch in JS anzuwenden, woraus sich eine Vielzahl an Möglichkeiten zum Datenaustausch und Datentransport innerhalb einer Webanwendung ergeben (AJAX).
Es ist bemerkenswert: Mit XML
oder JSON
wird versucht, den wahlfreien Zugriff auf Dateiebene abzubilden. Bemerkenswert insofern, als dass daraus gewisse Standards hervorgegangen sind zum maschinenlesbaren Transport von Daten Programmübergreifend. Letzteres bedeutet, dass Daten ein Programm verlassen und über Dateien, Sockets (HTTP, FTP
) oder andere Handler (STDIN, STDOUT
) transportiert werden.
Den menschlichen Algorithmus zum Lesen einer Datei auf Maschinen abzubilden ist nicht immer sinnvoll. Eine Maschine, ein Programm liest Dateien nämlich ganz anders als ein Mensch: In Schritten von Bytes oder in Schritten von mehreren Bytes. Für eine Maschine ist es beispielsweise auch unbedeutend, welche Zeichenkodierung in einer Datei vorliegt oder wo Zeilenumbrüche sind, für eine Maschine sind das einfach nur Folgen von Bytes. Wenn beispielsweise mit XML
eine Datenstruktur auf Dateiebene (Text-Datei) abgebildet wurde, muss die Maschine einen Parser verwenden, womit die beabsichtigte Struktur so erkannt wird, wie ein Mensch diese erkennt.
Es liegt auf der Hand, dass ein Parser dazu erst die gesamte Datei in den Hauptspeicher lesen muss. Ein Serializer hingegen beginnt seine Arbeit sofort mit dem Lesen des ersten Bytes, er sammelt sozusagen die Daten auf und legt diese sofort (je nach Algorithmus) im Hauptspeicher ab.
Serialize ist nicht auf Sequenzen (Dateien) beschränkt. Mit einem entsprechenden Algorithmus lassen sich Daten auch in eine Tabelle einer Datenbank serialisieren, wie dieser Artikel hier zeigt:
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. sos@rolfrost.de.