12.6 Die Klassen »BinaryReader« und »BinaryWriter«
Daten werden in einer Datei byteweise gespeichert. Dieses Grundprinzip macht sich die Klasse FileStream zunutze, indem sie Daten Byte für Byte in den Datenstrom schreibt oder aus einem solchen liest. Dazu werden Methoden angeboten, die entweder nur ein einzelnes Byte behandeln oder auf Basis eines Byte-Arrays operieren. Eine spezialisiertere Form der einfachen, byteweisen Vorgänge bieten uns die Klassen BinaryReader bzw. BinaryWriter. Mit BinaryReader lesen Sie aus dem Datenstrom, mit BinaryWriter schreiben Sie in einen solchen hinein. Das Besondere an den beiden Klassen ist die Behandlung der übergebenen oder ausgewerteten Daten.
Die Methoden der Klassen »BinaryReader« und »BinaryWriter«
Fast schon erwartungsgemäß veröffentlicht die Klasse BinaryWriter eine Write-Methode, die vielfach überladen ist. Sie können dieser Methode einen beliebigen primitiven Typ als Argument übergeben, der mit der ihm eigenen Anzahl von Bytes in der Datei gespeichert wird. Ein int schreibt sich demnach mit vier Bytes in eine Datei, ein long mit acht.
Ähnliches gilt auch für die Methode Read, der noch der Typ als Suffix angehängt wird, der gelesen wird, z. B. ReadByte, ReadInt32, ReadSingle usw.
Die Konstruktoren der Klassen »BinaryReader« und »BinaryWriter«
In den beiden Klassen BinaryReader und BinaryWriter stehen Ihnen nur jeweils zwei Konstruktoren zur Verfügung. Dem ersten können Sie die Referenz auf ein Objekt vom Typ Stream übergeben, dem zweiten zusätzlich noch eine Encoding-Referenz.
Binäre Datenströme auswerten
Aus einem Strom die Bytes auszulesen, ist kein Problem. Halten Sie aber nur die rohen Bytes in den Händen, werden diese in den meisten Fällen nur von geringem Nutzen sein. Das Problem ist, eine bestimmte Sequenz von Bytes in richtiger Weise zu interpretieren. Kennen Sie den Typ der Dateninformationen nicht, sind die Bytes praktisch wertlos. Betrachten Sie dazu das folgende Beispiel:
// Beispiel: ..\Kapitel 12\BinaryReaderSample_1
class Program {
static void Main(string[] args) {
// eine Datei erzeugen und einen Integer-Wert in die Datei schreiben
FileStream fileStr = new FileStream(@"D:\Binfile.mic", FileMode.Create);
BinaryWriter binWriter = new BinaryWriter(fileStr);
int intArr = 500;
binWriter.Write(intArr);
binWriter.Close();
// Datei öffnen und den Inhalt byteweise auslesen
FileInfo fi = new FileInfo(@"D:\Binfile.mic");
FileStream fs = new FileStream(@"D:\Binfile.mic", FileMode.Open);
byte[] byteArr = new byte[fi.Length];
// Datenstrom in ein Byte-Array einlesen
fs.Read(byteArr, 0, (int)fi.Length);
// Konsolenausgabe
Console.Write("Interpretation als Byte-Array: ");
for (int i = 0; i < fi.Length; i++)
Console.Write(byteArr[i] + " ");
Console.Write("\n\n");
fs.Close();
// Dateiinhalt textuell auswerten
StreamReader strReader = new StreamReader(@"D:\Binfile.mic");
Console.Write("Interpretation als Text: ");
Console.WriteLine(strReader.ReadToEnd());
strReader.Close();
Console.ReadLine();
}
}
Listing 12.10 Auswerten binärer Datenströme
Zuerst wird ein Objekt vom Typ FileStream erzeugt, um eine neue Datei anzulegen bzw. eine gleichnamige Datei zu überschreiben. Die Objektreferenz wird einem Konstruktor der Klasse BinaryWriter übergeben. Die Methode Write schreibt anschließend einen Integer mit dem Inhalt 500 in die Datei. Anschließend wird die Datei ausgelesen. Wir stellen uns dabei dumm und tun so, als wüssten wir nicht, von welchem Datentyp die in der Datei D:\Binfile.mic gespeicherte Zahl ist. Also testen wir den Dateiinhalt, zuerst byteweise und danach auch noch zeichenorientiert, in der Hoffnung, ein sinnvolles Ergebnis zu erhalten.
Zum byteweisen Lesen greifen wir auf die Klasse FileStream zurück, lesen den Datenstrom aus der Datei in das Byte-Array byteArr ein und geben dann die Elemente des Arrays an der Konsole aus:
Interpretation als Byte-Array: 244 1 0 0
Ein Unbedarfter wird vielleicht wegen der fehlerfreien Ausgabe in Verzückung geraten, wir wissen aber, dass es nicht das ist, was wir ursprünglich in die Datei geschrieben haben. Wie aber ist die Ausgabe zu interpretieren, die mit Sicherheit auf jedem Rechner genauso lauten wird?
Die vier Zahlen repräsentieren die vier Bytes aus der Datei. Dabei erfolgt die Anzeige vom Lower-Byte bis zum Higher-Byte. In die »richtige«, besser gesagt, gewohnte Reihenfolge gebracht müssten wir demnach
0 0 1 244
schreiben. Wir wissen, dass diese Bytes einen Integer beschreiben – und sie tun es auch, wenn wir uns nur die Bitfolge ansehen:
0000 0000 0000 0000 0000 0001 1111 0100
Die Kombination aller Bits ergibt tatsächlich die Dezimalzahl 500. Ein Benutzer, der nicht weiß, wie die vier Bytes zu interpretieren sind, hat die Qual der Wahl: Handelt es sich um vier einzelne Bytes oder um zwei Integer oder vielleicht um eine Zeichenfolge? Letzteres testet unser Code ebenfalls, das Ergebnis der Ausgabe ist ernüchternd: Uns grinst ein Smiley mit ausgestreckter Zunge an.
Ändern wir nun den Lesevorgang der Daten so ab, dass wir den Dateiinhalt tatsächlich als int auswerten:
FileStream fs = new FileStream(@"D:\Binfile.mic", FileMode.Open);
BinaryReader br = new BinaryReader(fs);
Console.WriteLine(br.ReadInt32());
Das Ergebnis wird diesmal mit der korrekten Ausgabe an der Konsole enden.
12.6.1 Komplexe binäre Dateien
Der Informationsgehalt binärer Dateien kann nur dann korrekt ausgewertet werden, wenn der Typ, den die Daten repräsentieren, bekannt ist. Im vorhergehenden Abschnitt haben Sie dazu ein kleines Beispiel gesehen. Binäre Dateien können aber mehr als nur einen einzigen Typ speichern, es können durchaus unterschiedliche Typen in beliebiger Reihenfolge sein. Um zu einem späteren Zeitpunkt auf die Daten zugreifen zu können, muss nur der strukturelle Aufbau der Datei – das sogenannte Dateiformat – der gespeicherten Informationen bekannt sein, ansonsten ist die Datei praktisch wertlos.
Dateien unterscheiden sich im Dateiformat: Eine Bitmap-Datei wird die Informationen zu den einzelnen Pixeln anders speichern, als Word den Inhalt eines Dokuments speichert; eine JPEG-Datei unterscheidet sich wiederum von einer MPEG-Datei. Die Dateierweiterung ist als Kennzeichnung einer bestimmten Spezifikation anzusehen, nämlich als Spezifikation der Datenstruktur in der Datei. Praktisch alle Binärdateien werden sich in ihrem Dateiformat unterscheiden.
Wir wollen uns nun in einem etwas aufwendigeren Beispiel dem Thema komplexer Binärdateien nähern, um das Arbeiten mit solchen Dateien zu verstehen, ohne zugleich in zu viel Programmcode die Übersicht zu verlieren. Sie können das Konzept, das sich hinter diesem Beispiel verbirgt, in ähnlicher Weise auch auf andere bekannte Dateiformate anwenden.
Dazu geben wir uns zunächst eine Struktur vor, die ein Point-Objekt beschreibt:
public struct Point {
public int XPos {get; set;}
public int YPos {get; set;}
public long Color {get; set;}
}
Listing 12.11 Definition der Struktur »Point« für das folgende Beispiel
Der Typ Point veröffentlicht drei Daten-Member: XPos und YPos jeweils vom Typ int sowie Color vom Typ long. Nun wollen wir eine Klasse entwickeln, die in der Lage ist, die Daten vieler Point-Objekte in einer Datei zu speichern und später auch wieder auszulesen. Außerdem soll eine Möglichkeit geschaffen werden, um auf die Daten eines beliebigen Point-Objekts in der Datei zugreifen zu können.
Die erste Überlegung ist, wie das Format einer Datei aussehen muss, um den gestellten Anforderungen zu entsprechen. Die Daten mehrerer Point-Objekte hintereinander zu speichern, ist kein Problem. Stellen Sie sich aber nun vor, Sie würden versuchen, die Informationen des zehnten Punkts aus einer Datei zu lesen, in der nur die Daten für fünf Punkte enthalten sind. Das kann zu keinem erfolgreichen Ergebnis führen.
Wir wollen daher eine Information in die Datei schreiben, der wir die gespeicherte Point-Anzahl entnehmen können. Der Typ dieser Information muss klar definiert sein, damit jedes Byte in der Datei eine klare Zuordnung erhält. Im Folgenden wird diese Information in einem int gespeichert, und zwar am Anfang der Datei.
Abbildung 12.5 Datei mit drei gespeicherten Point-Objekten
Damit haben wir die Spezifikation der binären Datei festgelegt. Die Auswertung der ersten vier Bytes liefert die Anzahl der gespeicherten Point-Objekte, und die folgenden insgesamt 16 Byte großen Blöcke beschreiben jeweils einen Punkt. Wir könnten jetzt noch festlegen, dass Dateien dieses Typs beispielsweise die Dateierweiterung .pot erhalten, aber eine solche Festlegung wird der Code des folgenden Beispiels nicht berücksichtigen.
Da wir uns nun auf ein Dateiformat geeinigt haben, wollen wir uns das weitere Vorgehen überlegen. Wir könnten die gesamte Programmlogik in Main implementieren mit dem Nachteil, dass etwaige spätere Änderungen zu Komplikationen führen könnten. Besser ist es, sich das objektorientierte Konzept der Modularisierung in Erinnerung zu rufen. Deshalb wird eine Klasse definiert, deren Methoden die Dienste zur Initialisierung der Point-Objekte, zum Speichern in einer Datei, zum Lesen der Datei und zur Ausgabe der Daten eines beliebigen Point-Objekts zur Verfügung stellen. Der Name der Klasse sei PointReader, die Bezeichner der Methoden lauten WriteToFile, GetFromFile und GetPoint.
Grundsätzlich können Methoden als Instanz- oder Klassenmethoden veröffentlicht werden. Instanzmethoden würden voraussetzen, dass die Klasse PointReader instanziiert wird. Das Objekt wäre dann an eine bestimmte Datei gebunden, die Point-Objekte enthält. Statische Methoden sind flexibler einsetzbar, verlangen allerdings auch bei jedem Aufruf die Pfadangabe zu der Datei. In diesem Beispiel sollen die Methoden statisch sein.
Widmen wir uns der Methode WriteToFile. Sie hat die Aufgabe, eine Datei zu generieren, die die Anforderungen unserer Spezifikation zur Speicherung von Point-Objekten erfüllt. Die Pfadangabe muss der Methode als Argument übergeben werden.
Wie wird der Code in dieser Methode arbeiten? Zunächst muss eine int-Zahl in die Datei geschrieben werden, die der Anzahl der Point-Objektdaten entspricht. Danach werden Point für Point alle Objektdaten übergeben, bis das Array durchlaufen ist.
public static void WriteToFile(string path, Point[] array) {
FileStream fileStr = new FileStream(path, FileMode.Create);
BinaryWriter binWriter = new BinaryWriter(fileStr);
// Anzahl der Punkte in die Datei schreiben
binWriter.Write(array.Length);
// die Point-Daten in die Datei schreiben
for(int i = 0; i < array.Length; i++) {
binWriter.Write(array[i].XPos);
binWriter.Write(array[i].YPos);
binWriter.Write(array[i].Color);
}
binWriter.Close();
}
Listing 12.12 »Point«-Daten in eine Datei schreiben
Die Daten der Point-Objekte sollen mit einer Instanz der Klasse BinaryWriter in die Datei geschrieben werden. Dazu benötigen wir auch ein Objekt vom Typ FileStream. Da alle Daten hintereinander in eine neue Datei geschrieben werden sollen, müssen wir FileStream im Modus Create öffnen.
Nachdem wir die Referenz auf den FileStream an den Konstruktor der BinaryWriter-Klasse übergeben haben, wird die Anzahl der Points in die Datei geschrieben. In einer Schleife greifen wir danach jedes Point-Objekt im Array ab und schreiben die Daten nacheinander in die Datei. Zum Schluss muss der Writer ordnungsgemäß geschlossen werden.
Unsere Datei ist erzeugt, und nur mit dem Kenntnisstand der Spezifikation, wie die einzelnen Bytes zu interpretieren sind, liefern die Daten die richtigen Werte. Die Methode GetFromFile zum Auswerten des Dateiinhalts muss sich an unsere Festlegung halten. Daher wird auch zuerst der Integer aus der Datei gelesen und daran anschließend die Daten der Point-Objekte. Der Rückgabewert der Methode ist die Referenz auf ein intern erzeugtes Point-Array.
public static Point[] GetFromFile(string path) {
FileStream fs = new FileStream(path, FileMode.Open);
BinaryReader br = new BinaryReader(fs);
// liest die ersten 4 Bytes aus der Datei, die die Anzahl der
// Point-Objekte enthält
int anzahl = br.ReadInt32();
// Lesen der Daten aus der Datei
Point[] arrPoint = new Point[anzahl];
for (int i = 0; i < anzahl; i++) {
arrPoint[i].XPos = br.ReadInt32();
arrPoint[i].YPos = br.ReadInt32();
arrPoint[i].Color = br.ReadInt64();
}
br.Close();
return arrPoint;
}
Listing 12.13 Lesen der »Point«-Daten aus einer Datei
Da wir die Kontrolle über jedes einzelne gespeicherte Byte der Datei haben und dieses richtig zuordnen können, muss es auch möglich sein, die Daten eines beliebigen Point-Objekts einzulesen. Dazu dient die Methode GetPoint. Bei deren Aufruf wird zunächst die Pfadangabe übergeben und als zweites Argument die Position des Point-Objekts in der Datei. Der Rückgabewert ist die Referenz auf das gefundene Objekt.
public static Point GetPoint(string path, int pointNo) {
FileStream fs = new FileStream(path, FileMode.Open);
int pos = 4 + (pointNo - 1) * 16;
BinaryReader br = new BinaryReader(fs);
// Prüfen, ob der user eine gültige Position angegeben hat
if (pointNo > br.ReadInt32() || pointNo == 0) {
string message = "Unter der angegebenen Position ist";
message += " kein \nPoint-Objekt gespeichert.";
throw new PositionException(message);
}
// den Zeiger positionieren
fs.Seek(pos, SeekOrigin.Begin);
// Daten des gewünschten Points einlesen
Point savedPoint = new Point();
savedPoint.XPos = br.ReadInt32();
savedPoint.YPos = br.ReadInt32();
savedPoint.Color = br.ReadInt64();
br.Close();
return savedPoint;
}
Listing 12.14 Einlesen eines bestimmten »Point«-Objekts
Die wesentliche Funktionalität der Methode steckt in der richtigen Positionierung der Zeigers, die aus der Angabe des Benutzers berechnet wird. Dabei muss berücksichtigt werden, dass am Dateianfang vier Bytes die Gesamtanzahl der Objekte in der Datei beschreiben und dass die Länge eines einzelnen Point-Objekts 16 Byte beträgt.
int pos = 4 + (pointNo - 1) * 16;
Die so ermittelte Position wird der Seek-Methode des BinaryReader-Objekts übergeben. Die Positionsnummer des ersten Bytes in der Datei ist 0, daher verweist der Zeiger mit der Übergabe der Zahl 4 auf das fünfte Byte. Wir setzen in diesem Fall natürlich den Ursprung origin des Zeigers auf den Anfang des Datenstroms.
fs.Seek(pos, SeekOrigin.Begin)
Da damit gerechnet werden muss, dass der Anwender eine Position angibt, die keinem Objekt in der Datei entspricht, sollte eine Ausnahme ausgelöst werden. Diese ist benutzerdefiniert und heißt PositionException.
public class PositionException : Exception {
public PositionException() {}
public PositionException(string message) : base(message) {}
public PositionException(string message, Exception inner):base(message, inner){}
}
Listing 12.15 Anwendungsspezifische Exception
Damit ist unsere Klassendefinition fertig, und wir können abschließend die Implementierung testen. Dazu schreiben wir entsprechenden Testcode in die Methode Main:
// Beispiel: ..\Kapitel 12\BinaryReaderSample_2
public class Program {
static void Main(string[] args) {
// Point-Array erzeugen
Point[] pArr = new Point[2];
pArr[0].XPos = 10;
pArr[0].YPos = 20;
pArr[0].Color = 310;
pArr[1].XPos = 40;
pArr[1].YPos = 50;
pArr[1].Color = 110;
// Point-Array speichern
PointReader.WriteToFile(@"D:\Test.pot",pArr);
// gespeicherte Informationen aus der Datei einlesen
Point[] x = PointReader.GetFromFile(@"D:\Test.pot");
// alle eingelesenen Point-Daten ausgeben
for(int i = 0; i < 2; i++) {
Console.WriteLine("Point-Objekt-Nr.{0}", i + 1);
Console.WriteLine();
Console.WriteLine("p[{0}].XPos = {1}", i, x[i].XPos);
Console.WriteLine("p[{0}].YPos = {1}", i, x[i].YPos);
Console.WriteLine("p[{0}].Color = {1}", i, x[i].Color);
Console.WriteLine(new string('=',30));
}
// einen bestimmten Point einlesen
Console.Write("\nWelchen Punkt möchten Sie einlesen? ");
int position = Convert.ToInt32(Console.ReadLine());
try {
Point myPoint = PointReader.GetPoint(@"D:\Test.pot", position);
Console.WriteLine("p.XPos = {0}", myPoint.XPos);
Console.WriteLine("p.YPos = {0}", myPoint.YPos);
Console.WriteLine("p.Color = {0}", myPoint.Color);
}
catch(PositionException e) {
Console.WriteLine(e.Message);
}
Console.ReadLine();
}
}
Listing 12.16 Komplettes Beispielprogramm
Weil die Main-Methode nur zum Testen der zuvor entwickelten Klasse dient, werden auch nur zwei Point-Objekte erzeugt, die uns als Testgrundlage für die weiteren Operationen dienen. Außerdem ist die Datei, in die gespeichert wird, immer dieselbe. Für unsere Zwecke ist das völlig ausreichend. Nach dem Speichern mit
PointReader.WriteToFile(@"D:\Test.pot", pArr);
wird die Datei sofort wieder eingelesen und die zurückgegebene Referenz einem neuen Array zugewiesen:
Point[] x = PointReader.GetFromFile(@"D:\Test.pot");
In einer Schleife werden danach alle eingelesenen Objektdaten an der Konsole ausgegeben.
Aufregender ist es hingegen, die Daten eines bestimmten Punktes zu erfahren. Dem Aufruf von GetPoint wird neben der Pfadangabe auch noch die Position des Point-Objekts in der Datei übergeben. Die Übergabe einer unzulässigen Position führt dazu, dass die spezifische Ausnahme PositionException mit einer entsprechenden Fehlermeldung ausgelöst wird, andernfalls werden die korrekten Werte angezeigt.
Ihre Meinung
Wie hat Ihnen das Openbook gefallen? Wir freuen uns immer über Ihre Rückmeldung. Schreiben Sie uns gerne Ihr Feedback als E-Mail an kommunikation@rheinwerk-verlag.de.