Daten-Ströme
von Charles L. Perkins
Heute lernen Sie alles über Java-Ströme mit folgenden Schwerpunkten:
Sie lernen auch zwei Stromschnittstellen, die das Lesen und Schreiben getippter Ströme vereinfachen. Ferner lernen Sie mehrere Utility-Klassen kennen, die benutzt werden, um auf das Dateisystem zuzugreifen. Wie beginnen mit einer kurzen Geschichte über Ströme.
Eine der ersten Versionen des Unix-Betriebssystems war die Pipe. Durch Vereinheitlichung aller möglichen Kommunikationsarten in einer einzigen Metapher ebnete Unix den Weg für eine ganze Reihe ähnlicher Neuerungen, die schließlich in der Abstraktion namens Streams oder Ströme gipfelten.
Dieser Informationsblock, d. h. ein nicht interpretierter Bytestrom, kann von jeder »Pipe-Quelle«, dem Rechnerspeicher oder auch vom Internet kommen. Quelle und Ziel eines Stroms sind völlig arbiträre Erzeuger bzw. Verbraucher von Bytes. Darin liegt die Leistung der Abstraktion. Sie müssen beim Lesen nichts über die Quelle und beim Schreiben nichts über das Ziel des Informationsstroms wissen.
Allgemeine Methoden, die von jeder beliebigen Quelle lesen können, akzeptieren ein Stromargument, das die Quelle bezeichnet. Allgemeine Methoden zum Schreiben akzeptieren einen Strom, um das Ziel zu bestimmen. Arbiträre Prozessoren (oder Filter) haben zwei Stromargumente. Sie lesen vom ersten, verarbeiten die Daten und schreiben die Ergebnisse in den zweiten. Diese Prozessoren kennen weder Quelle noch Ziel der Daten, die sie verarbeiten. Quelle und Ziel können sehr unterschiedlich sein: von zwei Speicherpuffern auf dem gleichen lokalen Rechner über ELF-Übertragungen von und zu einer Unterwasserstation bis zu Echtzeit-Datenströmen einer NASA-Sonde im Weltraum.
Durch Abkoppeln des Verbrauchs, der Verarbeitung und der Produktion der Daten von Quelle und Ziel dieser Daten können Sie jede beliebige Kombination mischen, während Sie Ihr Programm schreiben. Künftig, wenn neue bisher nicht bekannte Formen von Quelle oder Ziel (oder Verbraucher, Verarbeiter und Erzeuger) erscheinen, können sie im gleichen Rahmen ohne Änderung von Klassen benutzt werden. Neue Stromabstraktionen, die höhere Interpretationsebenen »oberhalb« der Bytes unterstützen, können völlig unabhängig von den zugrundeliegenden Mechanismen für den Transport der Bytes geschrieben werden.
An der Spitze dieses Stromrahmens stehen die zwei abstract-Klassen InputStream und OutputStream. Wenn Sie sich das Diagramm von java.io in Anhang B ansehen, erkennen Sie, daß unter diesen Klassen eine virtuelle Fülle kategorisierter Klassen steht, die den breiten Bereich von Strömen im System, aber auch eine äußerst gut ausgelegte Hierarchie von Beziehungen zwischen diesen Strömen aufzeigt. Wir beginnen mit den Eltern und arbeiten uns durch diesen buschigen Baum.
Alle Methoden, die wir heute untersuchen, werden mit throw IOExceptions deklariert. Diese neue Subklasse von Exception verkörpert konzeptionell alle möglichen I/O-Fehler, die bei der Verwendung von Strömen auftreten können. Mehrere Subklassen davon definieren ein paar spezifischere Ausnahmen, die ebenfalls ausgeworfen werden können. Vorläufig genügt zu wissen, daß Sie eine IOException entweder mit catch auffangen oder in eine Methode stellen müssen, die sie weitergeben kann.
InputStream ist eine abstract-Klasse, die die Grundlagen für das Lesen eines Bytestroms durch den Verbraucher (das Ziel) von einer Quelle definiert. Die Identität der Quelle und die Art des Erstellens und Beförderns der Bytes ist nicht relevant. Bei der Verwendung eines Eingabestroms sind Sie das Ziel dieser Bytes. Das ist alles, was Sie wissen müssen.
Die wichtigste Methode für den Verbraucher eines Eingabestroms ist die, die die Bytes von der Quelle liest. Diese Methode ist read(). Sie existiert in vielen Varianten, von denen in der heutigen Lektion je ein Beispiel aufgezeigt wird.
Jede dieser read()-Methoden ist so definiert, daß sie warten muß, bis alle angeforderten Eingaben verfügbar sind. Sorgen Sie sich nicht wegen dieser Einschränkung. Dank Multithreading können Sie viele andere Dinge realisieren, während ein Thread auf eine Eingabe wartet. Üblicherweise wird ein Thread je einem Eingabestrom (und je einem Ausgabestrom) zugewiesen, der allein für das Lesen (oder Schreiben) vom jeweiligen Strom zuständig ist. Die Eingabe-Threads können dann die Informationen zur Verarbeitung an andere Threads abgeben. Dadurch überlappt natürlich die I/O-Zeit Ihres Programms mit seiner Berechnungszeit.
Hier die erste Form von read():
InputStream s = getAnInputStreamFromSomewhere(); byte[] buffer = new byte[1024]; // Kann beliebige Größe sein if (s.read(buffer) != buffer.length) System.out.println("I got less than I expected.");
Diese Form von read() versucht, den gesamten zugeteilten Puffer zu füllen. Gelingt es ihr nicht (normalerweise, weil das Ende des Eingabestroms vorher erreicht wird), gibt sie die tatsächliche Anzahl von Bytes aus, die in den Puffer eingelesen wurden. Danach gibt ein eventueller weiterer Aufruf von read() den Wert -1 aus, was anzeigt, daß Sie sich am Ende des Stroms befinden. Die if-Anweisung funktioniert auch in diesem Fall, weil -1 != 1024 ist (entspricht einem Eingabestrom ohne Bytes).
Sie können auch in einen Bereich Ihres Puffers einlesen, indem Sie den Versatz und die gewünschte als Argumente von read() angeben:
s.read(buffer, 100, 300);
Bei diesem Beispiel werden Bytes 100 bis 399 aufgefüllt. Andernfalls verhält es sich genauso wie mit der vorherigen read()-Methode. In der aktuellen Version benutzt die Standardimplementierung der ersten Form von read() die zweite Alternative:
public int read(byte[] buffer) throws IOException { return read(buffer, 0, buffer.length); }
Außerdem können die Bytes auch einzeln eingelesen werden:
InputStream s = getAnInputStreamFromSomewhere(); byte b; int byteOrMinus1; while ((byteOrMinus1 = s.read()) != -1) { b = (byte) byteOrMinus1; ... // Verarbeitung von Byte b } ... // Lesen des Stromendes
Für den Fall, daß Sie einige Bytes in einem Strom überspringen oder von einer anderen Stelle mit dem Lesen des Stroms beginnen wollen, gibt es eine mit read() vergleichbare Methode:
if (s.skip(1024) != 1024) System.out.println("I skipped less than I expected.");
Dadurch werden die nächsten 1024 Bytes des Eingabestroms übersprungen. skip() nimmt und gibt eine lange Ganzzahl, weil Ströme nicht auf eine bestimmte Größe begrenzt werden müssen. Die Standardimplementierung von skip benutzt in diesem Release einfach read():
public long skip(long n) throws IOException { byte[] buffer = new byte[(int) n]; return read(buffer); }
Wenn Sie wissen wollen, wie viele Bytes ein Strom momentan umfaßt, können Sie so fragen:
if (s.available() < 1024) System.out.println("Too little is available right now.");
Dadurch wird Ihnen die Anzahl von Bytes mitgeteilt, die ohne Blockierung mit read() gelesen werden kann. Aufgrund der abstrakten Natur der Quelle dieser Bytes sind Ströme eventuell nicht in der Lage, Ihnen auf diese Frage eine Antwort zu geben. Einige Ströme geben beispielsweise immer 0 aus. Sofern Sie keine spezifischen Subklassen von InputStream verwenden, die Ihnen eine vernünftige Antwort auf diese Frage geben, sollten Sie sich nicht auf diese Methode verlassen. Multithreading schließt ohnehin viele Probleme in Verbindung mit der Blokkierung während der Wartezeit auf einen Strom aus. Damit schwindet einer der vorrangigen Nutzen von available() dahin.
Einige Ströme unterstützen die Markierung einer Position im Strom und das spätere Zurücksetzen des Stroms auf diese Position. Der Strom müßte sich dabei an alle Bytes erinnern, deshalb gibt es eine Einschränkung, in welchem Abstand in einem Strom markiert und zurückgesetzt werden kann. Ferner gibt es eine Methode, die fragt, ob der Strom dies überhaupt unterstützt. Hier ein Beispiel:
InputStream s = getAnInputStreamFromSomewhere(); if (s.markSupported()) { // Unterstützt s dieses Konzept? ... // Ein Teil des Stroms wird gelesen s.mark(1024); ... // Weniger als 1024 weitere Bytes lesen s.reset(); ... // Diese Bytes erneut lesen } else { ... // Nein, irgendeine Alternative ausführen }
Durch Markieren eines Stroms wird die Höchstzahl der Bytes bestimmt, die vor dem Zurücksetzen weitergegeben werden soll. Dadurch kann der Strom den Umfang seines »Bytespeichers« eingrenzen. Läuft diese Zahl durch, ohne daß ein reset() erfolgt, wird die Markierung ungültig und der Versuch, zurückzusetzen, wirft eine Ausnahme aus.
Markieren und Zurücksetzen eines Stroms ist nützlich, wenn der Stromtyp (oder der nächste Stromteil) identifiziert werden soll.
Da Sie nicht wissen, welche Ressourcen ein offener Strom darstellt und wie diese Ressourcen zu behandeln sind, nachdem der Strom gelesen wurde, müssen Sie einen Strom normalerweise explizit schließen, damit er diese Ressourcen freigeben kann. Selbstverständlich kann das der Papierkorb und eine finalize-Methode ebenfalls erledigen. Es könnte aber sein, daß Sie den Strom erneut öffnen müssen, bevor die Ressourcen freigegeben werden. Da Sie hierbei mit der Außenwelt, d. h. externen Ressourcen zu tun haben, ist es ratsam, genau anzugeben, wann deren Benutzung enden soll:
InputStream s = alwaysMakesANewInputStream(); try { ... // s nach Herzenslust verwenden } finally { s.close(); }
Gewöhnen Sie sich an die Verwendung von finally; Sie stellen damit sicher, daß Aktionen (z. B. das Schließen eines Stroms) auf jeden Fall ausgeführt werden. Selbstverständlich gehen Sie davon aus, daß der Strom immer erfolgreich erzeugt wird. Ist das nicht stets der Fall und wird zuweilen Null ausgegeben, gehen Sie so vor:
InputStream s = tryToMakeANewInputStream(); if (s != null) { try { ... } finally { s.close(); } }
Alle Eingabeströme stammen von der abstract-Klasse InputStream ab. Alle haben die bisher beschriebenen Methoden. Somit könnte Strom s im vorherigen Beispiel auch einen der komplexeren Eingabeströme haben, die im folgenden beschrieben werden.
Durch »Umkehr»« einiger der vorherigen Beispiele würde man einen Eingabestrom aus einer Reihe von Bytes erstellen. Genau das besorgt ByteArrayInputStream:
byte[] buffer = new byte[1024], fillWithUsefulData(buffer); InputStream s = new ByteArrayInputStream(buffer);
Leser des neuen Stroms s sehen einen Strom mit einer Länge von 1024 Bytes, d. h. die Bytes im Array buffer. Der Constructor dieser Klasse hat wie read() einen Versatz und eine Länge:
InputStream s = new ByteArrayInputStream(buffer, 100, 300);
Hier ist der Strom 300 Bytes lang und enthält Bytes 100-399 aus dem Array buffer.
ByteArrayInputStreams implementiert nur die Standardmethoden, wie alle Eingabeströme. Hier hat die available()-Methode aber eine bestimmte Aufgabe: Sie gibt 1024 bzw. 300 für die zwei Instanzen von ByteArrayInputStream aus, die Sie zuvor erstellt haben, weil sie genau weiß, wie viele Bytes verfügbar sind. Schließlich wird ByteArrayInputStream durch Aufrufen von reset() an den Strom- bzw. Pufferanfang zurückgesetzt, ungeachtet dessen, wo sich die Markierung befindet.
Eine der häufigsten Verwendungen von Strömen und historisch die älteste ist das Anhängen von Strömen an Dateien im Dateisystem. Hier wird beispielsweise ein solcher Eingabestrom in einem Unix-System erstellt:
InputStream s = new FileInputStream("/some/path/and/fileName");
Sie können den Strom aus einem zuvor geöffneten Dateibezeichner erstellen:
int fd = openInputFileInTraditionalUNIXWays(); InputStream s = new FileInputStream(fd);
Da dies auf einer tatsächlichen Datei mit bestimmter Länge basiert, kann der erzeugte Eingabestrom in beiden Fällen available() und skip() implementieren (wie übrigens auch ByteArrayInputStream). FileInputStream kennt darüber hinaus noch ein paar Tricks:
FileInputStream aFIS = new FileInputStream("aFileName"); int myFD = aFIS.getFD(); /* aFIS.finalize(); */ // Ruft close() auf, wenn GC automatisch aufgerufen wird
getFD() gibt den Bezeichner der Datei aus, auf der der Strom basiert. Die Implementierung von finalize() (einer geschützten Methode) durch FileInputStream schließt den Strom. Im Gegensatz zum vorherigen Beispiel sollten Sie eine finalize()-Methode nie direkt aufrufen. Der Papierkorb ruft sie auf, nachdem er festgestellt hat, daß der Strom nicht mehr verwendet wird. Das System schließt den Strom (irgendwann).
Sie können sich diese Lässigkeit leisten, weil Ströme, die auf Dateien basieren, nur sehr wenige Ressourcen binden. Diese Ressourcen können nicht versehentlich vor der Reinigung wiederverwendet werden. Wenn Sie auch in die Datei schreiben, müssen Sie sorgfältiger vorgehen. Durch frühzeitiges erneutes Öffnen der Datei nach dem Schreiben kann sich ein inkonsistenter Zustand ergeben, weil finalize() und damit close() noch nicht ausgeführt wurden. Wenn Sie den Typ von InputStream nicht genau kennen, rufen Sie am besten close() selbst auf.
Diese abstrakte Klasse bietet einen »Durchlauf« für alle Standardmethoden von InputStream. Sie selbst enthält einen anderen Strom weiter unten in der Filterkette, an den sie alle Methodenaufrufe abgibt. Sie implementiert nichts Neues, gestattet aber ihr eigenes Verschachteln:
InputStream s = getAnInputStreamFromSomewhere(); FilterInputStream s1 = new FilterInputStream(s); FilterInputStream s2 = new FilterInputStream(s1); FilterInputStream s3 = new FilterInputStream(s2); ... s3.read() ...
Wenn read auf den gefilterten Strom s3 ausgeführt wird, wird die Anfrage an s2 weitergegeben. Dann reagiert s2 genau so wie s1, und schließlich wird s aufgefordert, die Bytes bereitzustellen. Subklassen von FilterInputStream führen eine gewisse Verarbeitung der durchfließenden Bytes aus. Die Kette im vorherigen Beispiel kann eleganter geschrieben werden:
s3 = new FilterInputStream(new FilterInputStream(new FilterInputStream(s)));
Sie sollten diese Form soweit möglich immer in Ihrem Code verwenden. Sie drückt die Verschachtelung verketteter Filter deutlich aus.
Im nächsten Abschnitt betrachten wir die Subklassen von FilterInputStream.
Das ist einer der nützlichsten Ströme. Er implementiert die vollen Fähigkeiten der InputStream-Methoden, jedoch durch Verwendung eines gepufferten Byte-Arrays, der sich als Cache für weitere Leseoperationen verhält. Dadurch werden die gelesenen »Stückchen« von den größeren Blöcken, in denen Ströme am effizientesten gelesen werden (z. B. von Peripheriegeräten, Dateien oder im Netz), abgekoppelt. Ferner ermöglicht es den Strömen, Daten vorauszulesen.
Da das Puffern von BufferedInputStream so hilfreich, und das auch die einzige Klasse ist, die mark() und reset() richtig abarbeitet, möchte man sich wünschen, daß jeder Eingabestrom diese wertvollen Fähigkeiten irgendwie nutzt. Sie haben bereits eine Möglichkeit gesehen, durch die sich Filterströme um andere Ströme »herumwickeln« können. Wenn ein gepufferter FileInputStream korrekt markieren und zurücksetzen soll, schreiben Sie folgendes:
InputStream s = new BufferedInputStream(new FileInputStream("foo"));
Damit haben Sie einen gepufferten Eingabestrom auf der Grundlage der Datei »foo«, die mark() und reset() unterstützt.
Jetzt wird die Leistung verschachtelter Ströme langsam klar. Jede von einem gefilterten Eingabestrom (und Ausgabestrom, wie Sie bald sehen werden) bereitgestellte Fähigkeit kann durch Verschachtelung von einem anderen Strom genutzt werden. Durch Verschachtelung der Filterströme ist jede Kombination dieser Fähigkeiten in jeder Reihenfolge möglich.
Alle Methoden dieser Klasse sind in einer separaten Schnittstelle definiert, die von DataInputStream und RandomAccessFile (einer weiteren Klasse in java.io) implementiert wird. Diese Schnittstelle ist allgemein, so daß Sie sie in Ihren eigenen Klassen benutzen können. Sie heißt DataInput.
Wenn Sie häufigeren Gebrauch von Strömen machen, werden Sie bald feststellen, daß Byteströme kein Format bieten, in das alle Daten eingezwängt werden können. Vor allem die Primitivtypen der Java-Sprache können in den bisher behandelten Strömen nicht gelesen werden. Die DataInput-Schnittstelle spezifiziert höhere Methoden zum Lesen und Schreiben, die komplexere Datenströme unterstützen. Diese Schnittstelle definiert folgende Methoden:
void readFully(byte[] buffer) throws IOException; void readFully(byte[] buffer, int offset, int length) throws IOException; int skipBytes(int n) throws IOException; boolean readBoolean() throws IOException; byte readByte() throws IOException; int readUnsignedByte() throws IOException; short readShort() throws IOException; int readUnsignedShort() throws IOException; char readChar() throws IOException; int readInt() throws IOException; long readLong() throws IOException; float readFloat() throws IOException; double readDouble() throws IOException; String readLine() throws IOException; String readUTF() throws IOException;
Die ersten drei Methoden sind lediglich neue Bezeichnungen für skip() und die zwei vorher behandelten Formen von read(). Die nächsten zehn Methoden lesen einen Primitivtyp bzw. dessen vorzeichenloses Gegenstück (nützlich für die effiziente Verwendung aller Bits in einem Binärstrom). Diese Methoden müssen eine Ganzzahl mit einer breiteren Größe ausgeben. Da Ganzzahlen in Java Vorzeichen haben, passen die vorzeichenlosen Werte nicht in etwas, das kleiner ist. Die letzten zwei Methoden lesen eine neue Zeile ('\r', '\n' oder "\r\n") aus dem Strom - beendete Zeichenketten (die erste in ASCII und die zweite in Unicode).
Da Sie nun wissen, wie die Schnittstelle, die DataInputStream implementiert, aussieht, betrachten wir sie in Aktion:
DataInputStream s = new DataInputStream(getNumericInputStream()); long size = s.readLong(); // Anzahl der Elemente im Strom while (size -- > 0) { if (s.readBoolean()) { // Soll ich dieses Element verarbeiten? int anInteger = s.readInt(); int magicBitFlags = s.readUnsignedShort(); double aDouble = s.readDouble(); if (magicBitFlags & 0100000) != 0) { ... // Hohes Bitset, etwas Besonderes treiben } ... // anInteger und aDouble verarbeiten } }
Die Klasse implementiert eine Schnittstelle für alle ihre Methoden, deshalb können Sie auch folgende Schnittstelle verwenden:
DataInput d = new DataInputStream(new FileInputStream("anything")); String line; while ((line = d.readLine()) != null) { ... // Zeile verarbeiten }
Auf die meisten Methoden von DataInputStream trifft folgendes zu: Wird das Ende des Stroms erreicht, werfen sie durch throw EOFException aus. Das ist sehr hilfreich, denn es ermöglicht Ihnen, alle Verwendungen von -1 in den bisherigen Beispielen besser zu schreiben:
DataInputStream s = new DataInputStream(getAnInputStreamFromSomewhere()); try { while (true) { byte b = (byte) s.readByte(); ... // Byte b verarbeiten } } catch (EOFException e) { ... // Stromende erreicht }
Das funktioniert bei allen read-Methoden von DataInputStream, außer den letzten zwei.
In einem Editor oder Debugger ist die Zeilennumerierung wichtig. Um diese Fähigkeit in Ihrem Programm anzuwenden, benutzen Sie den Filterstrom LineNumberInputStream. Er kann sich sogar eine Zeilennummer merken und später in mark() und reset() zurückschreiben. Sie können diese Klasse wie folgt verwenden:
LineNumberInputStream aLNIS; aLNIS = new LineNumberInputStream(new FileInputStream("source")); DataInputStream s = new DataInputStream(aLNIS); String line; while ((line = s.readLine()) != null) { ... // Zeile verarbeiten System.out.println("Did line number: " + aLNIS.getLineNumber()); }
Hier werden zwei Filterströme in FileInputStream verschachtelt. Der erste liest die Zeilen nacheinander und der zweite verfolgt die Nummern dieser Zeilen. Der zwischengeschaltete Filterstrom aLNIS muß explizit benannt werden. Andernfalls können Sie später getLineNumber() nicht aufrufen. Wenn Sie die Reihenfolge der verschachtelten Ströme umkehren, kann LineNumberInputStream beim Lesen von DataInputStream die Zeilen nicht »sehen«.
Filterströme müssen quasi als »Überwacher« in der Mitte der Kette stehen und die Daten aus dem äußersten Filterstrom herausziehen, damit die Daten alle Überwacher nacheinander durchlaufen. Auch das Puffern sollte möglichst im Zentrum der Kette stattfinden. Im folgenden Beispiel sehen Sie eine unsinnige Reihenfolge:
new BufferedInputStream(new LineNumberInputStream( new DataInputStream(new FileInputStream("foo"));
Im nächsten Beispiel sehen Sie eine viel bessere Reihenfolge:
new DataInputStream(new LineNumberInputStream( new BufferedInputStream(new FileInputStream("foo"));
LineNumberInputStream kann auch angewiesen werden, setLineNumber() für die seltenen Fälle zu setzen, wenn Sie mehr darüber wissen wollen.
Die Filterstromklasse PushbackInputStream wird üblicherweise in Parsern benutzt, um ein Zeichen der Eingabe (nach dem Lesen) »zurückzuschieben«, während versucht wird, die nächste Aktion zu ermitteln. Das ist eine vereinfachte Version von mark() und reset(). Sie erweitert die InputStream-Standardmethoden um unread(). Wie Sie sich denken können, gibt diese Methode vor, das in ihr durchgereichte Byte nie gelesen zu haben. Dann gibt sie dieses Byte als Ausgabewert an das nächste read() weiter.
Das folgende Beispiel ist eine einfache Implementierung von readLine() anhand dieser Klasse:
public class SimpleLineReader { private FilterInputStream s; public SimpleLineReader(InputStream anIS) { s = new DataInputStream(anIS); } ... // Andere read()-Methoden, die Strom s verwenden public String readLine() throws IOException { char[] buffer = new char[100]; int offset = 0; byte thisByte; try { loop: while (offset < buffer.length) { switch (thisByte = (byte) s.read()) { case '\n': break loop; case '\r': byte nextByte = (byte) s.read(); if (nextByte != '\n') { if (!(s instanceof PushbackInputStream)) { s = new PushbackInputStream(s); } ((PushbackInputStream) s).unread(nextByte); } break loop; default: buffer[offset++] = (char) thisByte; break; } } } catch (EOFException e) { if (offset == 0) return null; } return String.copyValueOf(buffer, 0, offset); } }
Das zeigt verschiedene Dinge auf. In diesem Beispiel ist readLine() auf das Lesen der ersten 100 Zeichen der Zeilen begrenzt. In dieser Hinsicht wird aufgezeigt, wie ein allgemeiner Zeilenverarbeiter nicht geschrieben werden sollte (wir wollen ja Zeilen in jeder Größe lesen). Außerdem werden wir daran erinnert, daß wir mit break aus einer äußeren Schleife ausbrechen können und wie ein String von einem Zeichen-Array erzeugt wird. In diesem Beispiel wird auch read() von InputStream zum Lesen der einzelnen Bytes verwendet. Das Stromende wird durch Einbinden in DataInputStream und catch EOFException festgelegt.
Ein ungewöhnlicher Aspekt ist bei diesem Beispiel die Art, wie PushbackInputStream verwendet wird. Um sicher zu sein, daß '\n' nach '\r' ignoriert wird, muß ein Zeichen vorausgelesen werden. Ist das kein '\n', muß dieses Zeichen zurückgeschoben werden. Die nächsten zwei Zeilen betrachten wir, als ob wir nicht viel über Strom s wüßten. Die allgemein angewandte Technik ist instruktiv. Erstens sehen wir, ob s bereits eine instanceof einer Art PushbackInputStream ist. Trifft das zu, können wir ihn direkt verwenden. Andernfalls wird der aktuelle Strom (egal welcher) in einen neuen PushbackInputStream gesetzt, und dieser Strom wird verwendet.
Die nachfolgende Zeile möchte die Methode unread() aufrufen. FilterInputStream von s ist aber ein Kompilierzeittyp und versteht somit diese Methode nicht. Die zwei vorherigen Zeilen gewährleisten jedoch, daß der Laufzeittyp des Stroms in s PushbackInputStream ist, so daß Sie ihn problemlos in diesen Typ umwandeln und unread() aufrufen können.
Bisher wurden noch nicht alle Subklassen von FilterInputStream beschrieben. Nun ist es an der Zeit, zu den direkten Subklassen von InputStream zurückzukehren.
Diese Klasse und ihre Schwester PipedOutputStream werden später in der heutigen Lektion behandelt. Vorläufig genügt zu wissen, daß sie zusammen eine einfache zweiwegige Kommunikation zwischen Threads ermöglichen.
Soll aus zwei Strömen ein zusammengesetzter Strom gebildet werden, wird SequenceInputStream verwendet:
InputStream s1 = new FileInputStream("theFirstPart"); InputStream s2 = new FileInputStream("theRest"); InputStream s = new SequenceInputStream(s1, s2); ... s.read() ... // Liest abwechselnd von beiden Strömen
Wir hätten das gleiche Ergebnis durch abwechselndes Lesen der Dateien auch »simulieren« können. Was aber, wenn wir den zusammengesetzten Strom s an eine andere Methode abgeben wollen, die nur einen InputStream erwartet?
Hier ein Beispiel (mit s), bei dem die Zeilen der zwei vorherigen Dateien durch ein übliches Numerierungsschema numeriert werden:
LineNumberInputStream aLNIS = new LineNumberInputStream(s); ... aLNIS.getLineNumber() ...
Wenn Sie mehr als zwei Ströme verketten wollen, versuchen Sie es so:
Vector v = new Vector(); ... // Alle Ströme einrichten und jeden zum Vektor hinzufügen InputStream s1 = new SequenceInputStream(v.elementAt(0), v.elementAt(1)); InputStream s2 = new SequenceInputStream(s1, v.elementAt(2)); InputStream s3 = new SequenceInputStream(s2, v.elementAt(3));
Viel einfacher ist aber die Verwendung eines anderen Constructors, der SequenceInputStream bietet:
InputStream s = new SequenceInputStream(v.elements());
Sie müssen alle Sequenzen, die kombiniert werden sollen, auflisten, so daß ein einzelner Strom ausgegeben wird, der die Daten aller Sequenzen nacheinander liest.
StringBufferInputStream ist genau wie ByteArrayInputStream, basiert aber nicht auf einem Byte-Array, sondern auf einer Zeichenkette:
String buffer = "Now is the time for all good men to come..."; InputStream s = new StringBufferInputStream(buffer);
Alle Kommentare zu ByteArrayInputStream treffen auch hier zu (siehe ersten Abschnitt über diese Klasse).
Ausgabeströme werden fast ausnahmslos mit einem brüderlichen InputStream gepaart. Führt ein InputStream eine bestimmte Operation aus, wird die umgekehrte Operation vom OutputStream ausgeführt. Was das bedeuten soll, sehen Sie in Kürze.
OutputStream ist die abstract-Klasse, die die grundlegenden Arten definiert, in der eine Quelle (Erzeuger) einen Bytestrom in ein Ziel schreiben kann. Die Identität des Ziels und die Art der Beförderung und Speicherung der Bytes sind nicht relevant. Bei der Verwendung eines Ausgabestroms sind Sie die Quelle der Bytes. Das ist alles, was Sie wissen müssen.
Die wichtigste Methode für den Erzeuger eines Ausgabestroms ist diejenige, die Bytes in das Ziel schreibt. Diese Methode ist write(), die es in verschiedenen Varianten gibt, wie Sie in den folgenden Beispielen sehen werden.
OutputStream s = getAnOutputStreamFromSomewhere(); byte[] buffer = new byte[1024]; // Jede Größe ist zulässig fillInData(buffer); // Die Daten, die ausgegeben werden sollen s.write(buffer);
Sie können auch ein »Scheibchen« Ihres Puffers schreiben, indem Sie den Versatz und die gewünschte Länge als Argumente für write() angeben:
s.write(buffer, 100, 300);
Dadurch werden Bytes 100 bis 399 ausgegeben. Ansonsten ist das Verhalten genau so wie bei der vorherigen write()-Methode. Im derzeitigen Release benutzt die Standardimplementierung der ersten Form von write() die zweite Alternative:
public void write[byte[] buffer) throws IOException { write(buffer, 0, buffer.length); }
Außerdem können Sie Bytes auch einzeln ausgeben:
while (thereAreMoreBytesToOutput()) { byte b = getNextByteForOutput(); s.write(b); }
Da wir nicht wissen, mit was ein Ausgabestrom verbunden ist, können wir mit flush() die »Spülung« der Ausgabe durch einen gepufferten Cache anfordern, um sie (zeitgerecht oder überhaupt) zu erhalten. Die OutputStream-Version dieser Methode bewirkt nichts. Von ihr wird lediglich erwartet, diese Version durch Subklassen, die flush voraussetzen (z. B. BufferedOutputStream und PrintStream) mit nichttrivialen Aktionen zu überschreiben.
Wie bei InputStream sollte ein OutputStream normalerweise explizit geschlossen werden, damit die von ihm beanspruchten Ressourcen freigegeben werden. Im übrigen trifft alles zu, was über InputStream gesagt wurde.
Alle Ausgabeströme stammen von der abstract-Klasse OutputStream ab und haben die oben beschriebenen Methoden.
Das Gegenstück von ByteArrayInputStream, das einen Eingabestrom für ein Byte-Array erzeugt, ist ByteArrayOutputStream, der einen Ausgabestrom an ein Byte-Array weitergibt:
OutputStream s = new ByteArrayOutputStream(); s.write(123); ...
Die Größe des (internen) Byte-Arrays wächst nach Bedarf, um einen Strom jeder beliebigen Länge zu speichern. Sie können auf Wunsch eine Anfangskapazität festlegen:
OutputStream s = new ByteArrayOutputStream(1024 * 1024); // 1 MByte
Nachdem ByteArrayOutputStream s gefüllt wurde, kann er Daten an einen anderen Ausgabestrom ausgeben:
OutputStream anotherOutputStream = getTheOtherOutputStream(); ByteArrayOutputStream s = new ByteArrayOutputStream(); fillWithUsefulData(s); s.writeTo(anotherOutputStream);
Außerdem kann er als Byte-Array herausgezogen oder in eine Zeichenkette konvertiert werden:
byte[] buffer = s.toByteArray(); String bufferString = s.toString(); String bufferUnicodeString = s.toString(upperByteValue);
ByteArrayOutputStream hat zwei Utility-Methoden: Eine gibt die aktuelle Anzahl der im internen Byte-Array gespeicherten Bytes aus, die andere setzt den Array zurück, so daß der Strom von Anfang an erneut geschrieben werden kann:
int sizeOfMyByteArray = s.size(); s.reset(); // s.size() würde jetzt 0 ausgeben s.write(123); ...
Eine der häufigsten Verwendungen von Strömen und historisch die älteste ist das Anhängen von Strömen an Dateien im Dateisystem. Hier wird beispielsweise ein solcher Ausgabestrom in einem Unix-System erstellt:
OutputStream s = new FileOutputStream("/some/path/and/fileName");
Sie können den Strom aus einem zuvor geöffneten Dateibezeichner erstellen:
int fd = openOutputFileInTraditionalUNIXWays(); OutputStream s = new FileOutputStream(fd);
FileOutputStream ist das Gegenstück von FileInputStream und kennt die gleichen Tricks:
FileOutputStream aFOS = new FileOutputStream("aFileName"); int myFD = aFOS.getFD(); /* aFOS.finalize(); */ // Ruft close() auf, wenn GC automatisch aufgerufen wird
getFD() gibt den Bezeichner der Datei aus, auf der der Strom basiert. Die Implementierung von finalize() (einer geschützten Methode) in FileOutputStream schließt den Strom (siehe weitere Anmerkungen unter FileInputStream).
Diese abstrakte Klasse bietet einen »Durchlauf« für alle Standardmethoden von OutputStream. Sie selbst enthält einen anderen Strom weiter unten in der Filterkette, an den sie alle Methodenaufrufe abgibt. Sie implementiert nichts Neues, gestattet aber ihr eigenes Verschachteln:
OutputStream s = getAnOutputStreamFromSomewhere(); FilterOutputStream s1 = new FilterOutputStream(s); FilterOutputStream s2 = new FilterOutputStream(s1); FilterOutputStream s3 = new FilterOutputStream(s2); ... s3.write(123) ...
Wenn write auf den gefilterten Strom s3 angewandt wird, wird die Anfrage an s2 weitergegeben. Dann reagiert s2 genau so wie s1, und schließlich wird s aufgefordert, die Bytes auszugeben. Subklassen von FilterOutputStream führen eine gewisse Verarbeitung der durchfließenden Bytes aus. Die Kette kann noch straffer verschachtelt werden (siehe unter FilterInputStream).
Im nächsten Abschnitt betrachten wir die Subklassen von FilterOutputStream.
Das ist einer der nützlichsten Ströme. Er implementiert die vollen Fähigkeiten der OutputStream-Methoden, jedoch durch Verwendung eines gepufferten Byte-Arrays, der sich als Cache für Schreiboperationen verhält. Dadurch werden die geschriebenen Teile von den größeren Blöcken, in denen Ströme am effizientesten geschrieben werden (z. B. Peripheriegeräte, Dateien oder im Netz), abgekoppelt.
BufferedOutputStream ist eine von zwei Klassen der Java-Bibliothek, die flush() implementieren. Sie können jeden Ausgabestrom so umgeben, daß folgendes erreicht wird:
OutputStream s = new BufferedOutputStream(new FileOutputStream("foo"));
Damit haben Sie einen gepufferten Ausgabestrom auf der Grundlage der Datei »Foo«, die flush() unterstützt.
Jede von einem gefilterten Ausgabestrom bereitgestellte Fähigkeit kann durch Verschachteln von einem anderen Strom genutzt werden. Durch Verschachteln der Filterströme ist jede Kombination dieser Fähigkeiten in jeder Reihenfolge möglich.
Alle Methoden dieser Klasse sind in einer separaten Schnittstelle definiert, die von DataOutputStream und RandomAccessFile (einer weiteren Klasse in java.io) implementiert wird. Diese Schnittstelle ist allgemein, so daß Sie sie in Ihren eigenen Klassen benutzen können. Sie heißt DataOutput.
In Zusammenhang mit dem Gegenstück DataInput bietet DataOutput höhere Methoden zum Lesen und Schreiben von Daten. Diese Schnittstelle definiert folgende Methoden:
void write(int i) throws IOException; void write(byte[] buffer, int offset, int length) throws IOException; void write(byte[] buffer, int offset, int length) throws IOException; void writeBoolean(boolean b) throws IOException; void writeByte(int i) throws IOException; void writeShort(int i) throws IOException; void writeChar(int i) throws IOException; void writeInt(int i) throws IOException; void writeLong(long l) throws IOException; void writeFloat(float f) throws IOException; void writeDouble(double d) throws IOException; void writeBytes(String s) throws IOException; void writeChars(String s) throws IOException; void writeUTF(String s) throws IOException;
Zu den meisten dieser Methoden gibt es DataInput-Gegenstücke.
Die ersten drei Methoden entsprechen den drei vorher aufgezeigten write()-Methoden. Die nächsten acht Methoden geben einen Primitivtyp aus. Die letzten drei Methoden schreiben eine Zeichenkette in den Strom - die erste als 8-Bit-Bytes, die zweite als Unicode-Zeichen (16 Bit) und die dritte als speziellen Unicode-Strom (der von readUTF() in DataInput gelesen werden kann).
Da Sie nun wissen, wie die Schnittstelle, die DataOutputStream implementiert, aussieht, betrachten wir sie in Aktion:
DataOutputStream s = new DataOutputStream(getNumericOutputStream()); long size = getNumberOfItemsInNumericStream(); s.writeLong(size); for (int i = 0; i < size; ++i) { if (shouldProcessNumber(i)) { s.writeBoolean(true); // Sollte dieses Element verarbeiten s.writeInt(theIntegerForItemNumber(i)); s.writeShort(theMagicBitFlagsForItemNumber(i)); s.writeDouble(theDoubleForitemNumber(i)); } else s.writeBoolean(false); }
Das ist das genaue Gegenstück des mit DataInput aufgeführten Beispiels. Zusammen bilden sie ein Paar, das ein bestimmtes strukturiertes Primitivtypen-Array über jeden Strom (bzw. die Transportschicht) austauschen kann. Verwenden Sie dieses Paar als Sprungbrett für ähnliche Aktionen.
Zusätzlich zur obigen Schnittstelle implementiert die Klasse eine (selbsterklärende) Utility-Methode:
int theNumberOfBytesWrittenSoFar = s.size();
Zu den häufigsten E/A-Operationen zählen das Öffnen einer Datei, das zeilenweise Lesen und Verarbeiten und das Ausgeben dieser Daten in eine andere Datei. Das folgende Beispiel ist ein Prototyp dessen, wie dies in Java realisiert wird:
DataInput aDI = new DataInputStream(new FileInputStream("source")); DataOutput aDO = new DataOutputStream(new FileOutputStream("dest")); String line; while ((line = aDI.readLine()) != null) { StringBuffer modifiedLine = new StringBuffer(line); ... // modifiedLine vor Ort verarbeiten aDO.writeBytes(modifiedLine.toString()); } aDI.close(); aDO.close();
Möchten Sie das byteweise verarbeiten, schreiben Sie folgendes:
try { while (true) { byte b = (byte) aDI.readByte(); ... // b vor Ort verarbeiten aDO.writeByte(b); } } finally { aDI.close(); aDO.close(); }
Der folgende nette Zweizeiler kopiert die Datei:
try { while (true) aDO.writeByte(aDI.readByte()); } finally { aDI.close(); aDO.close(); }
Ohne sich eventuell dessen bewußt zu sein, sind Sie bereits mit den zwei Methoden der PrintStream-Klasse vertraut. Wenn Sie die Methodenaufrufe
System.out.print(...) System.out-println(...)
verwenden, benutzen Sie eigentlich eine PrintStream-Instanz, die sich in der Variablen out der System-Klasse befindet, um die Ausgabe auszuführen. System.err gehört ebenfalls zu PrintStream und System.in ist ein InputStream.
PrintStream ist ein Ausgabestrom ohne brüderliches Gegenstück. Da er normalerweise mit einer Bildschirmausgabe zusammenhängt, implementiert er flush(). Ferner bietet er die bekannten Methoden close() und write() sowie eine Fülle von Möglichkeiten zur Ausgabe der Primitivtypen und Zeichenketten von Java:
public void write(int b); public void write(byte[] buffer, int offset, int length); public void flush(); public void close(); public void print(Object o); public void print(String s); public void print(char[] buffer); public void print(char c); public void print(int i); public void print(long l); public void print(float f); public void print(double d); public void print(boolean b); public void println(Object o); public void println(String s); public void println(char[] buffer); public void println(char c); public void println(int i); public void println(long l); public void println(float f); public void println(double d); public void println(boolean b); public void println(); // Eine Leerzeile ausgeben
PrintStream kann auch um jeden Ausgabestrom angeordnet werden, wie eine Filterklasse:
PrintStream s = PrintStream(new FileOutputStream("foo")); s.println("Here's the first ine of text in the file foo.");
Ein zweites Argument für den Constructor von PrintStream ist boolesch und bestimmt, ob der Strom automatisch »flushen« soll. Im Fall von true wird nach jedem geschriebenen Zeichen (bzw. nach dem Schreiben einer Zeichengruppe bei der write()-Form mit drei Argumenten) flush() gesendet.
Das folgende kleine Programm arbeitet wie der Unix-Befehl cat. Es nimmt die Standardeingabe zeilenweise entgegen und gibt sie auf der Standardausgabe aus:
import java.io.*; // Das einzige Mal in dieser Lektion schreiben wir das public class Cat { public static void main(String args[]) { DataInput d = new DataInputStream(System.in); String line; try { while ((line = d.readLine()) != null) System.out.println(line); } catch (IOException ignored) { } } }
Diese und die Klasse PipedInputStream bilden zusammen das Paar, das eine unixartige Pipe-Verbindung zwischen zwei Threads herstellt und sorgfältig die gesamte Synchronisation implementiert, die die sichere Operation dieser Art von gemeinsamer Warteschlange ermöglicht. Die Verbindung wird so eingerichtet:
PipedInputStream sIn = PipedInputStream();
PipedOutputStream sOut = PipedOutputStream(sIn);
Ein Thread schreibt in sOut und der andere liest von sIn. Durch Einrichten solcher Paare können die Threads in beiden Richtungen problemlos kommunizieren.
Die übrigen Klassen und Schnittstellen in java.io ergänzen die Ströme, so daß ein komplettes E/A-System bereitgestellt wird.
Die File-Klasse abstrahiert »File« auf plattformunabhängige Weise. Mit einem Dateinamen kann sie auf Anfragen über Typ, Status und Eigenschaften einer Datei oder eines Verzeichnisses im Dateisystem reagieren.
Anhand einer Datei, eines Dateinamens oder eines Dateibezeichners wird eine RandomAccessFile erzeugt. Sie umfaßt Implementierungen von DataInput und DataOutput in einer Klasse. Zusätzlich zu diesen Schnittstellen bietet RandomAccessFile bestimmte traditionelle Einrichtungen nach Unix-Art, z. B. seek() zum Suchen eines Zufallspunkts in einer Datei.
Die StreamTokenizer-Klasse greift einen Eingabestrom und erzeugt daraus eine Folge von Token. Durch Überschreiben verschiedener darin enthaltener Methoden in den Subklassen können Sie starke lexikale Parser erstellen.
In der API-Beschreibung Ihres Java-Releases finden Sie weitere Informationen über diese Klassen.
Heute haben Sie das allgemeine Konzept von Strömen gelernt und Beispiele mit Eingabeströmen auf der Grundlage von Byte-Arrays, Dateien, Pipes, anderen Stromfolgen und Zeichenkettenpuffern sowie Eingabefiltern, Dateneingaben, Zeilennumerierung und Zurückschieben von Zeichen durchgearbeitet.
Sie haben auch die Gegenstücke dazu - die Ausgabeströme für Byte-Arrays, Dateien, Pipes und Ausgabefilter - kennengelernt.
Sie haben sich in dieser Lektion Kenntnisse über die grundlegenden Methoden aller Ströme (z. B. read() und write()) und einige spezielle Methoden angeeignet. Sie haben das Auffangen von IOExceptions, insbesondere EOFException, gelernt.
Sie haben gelernt, mit den doppelt nützlichen DataInput- und DataOutput-Schnittstellen umzugehen, die den Kern von RandomAccessFile bilden.
Java-Ströme bieten eine starke Grundlage, auf der Sie Multithreading/Streaming-Schnittstellen der komplexesten Art entwickeln können, die in Browsern (z. B. HotJava) interpretiert werden. Die höheren Internet-Protokolle und -Dienste, für die Sie künftig Ihre Applets schreiben können, sind im Prinzip nur durch Ihre Vorstellungskraft beschränkt.
F: In einem früheren read()-Beispiel haben Sie meiner Meinung nach mit der Variablen byteOrMinus1 etwas Plumpes angestellt. Gibt es dafür keine bessere Art? Und falls nicht, warum haben Sie in einem späteren Abschnitt die Umwandlung empfohlen?
A: Stimmt, diese Anweisungen haben wirklich etwas Schwerfälliges an sich. Man ist versucht, statt dessen etwa folgenden Code zu schreiben:
while ((b = (byte) s.read()) != -1) { ... // Byte b verarbeiten }
Das Problem bei dieser Kurzfassung entsteht, wenn read() den Wert 0xFF (0377) ausgibt. Da dieser Wert vor der Ausgabe ein verlängertes Vorzeichen erhält, erscheint er genau so wie der ganzzahlige Wert -1, der das Stromende bezeichnet. Nur durch Speichern dieses Wertes in einer getrennten ganzzahligen Variablen und späteres Umwandeln erreicht man das gewünschte Ergebnis. Ich habe die Umwandlung in byte aus orthogonalen Gründen empfohlen. Das Speichern ganzzahliger Werte in Variablen mit korrekter Größe entspricht immer einem guten Stil (abgesehen davon sollte read() hier eine byte-Größe ausgeben und für das Stromende eine Ausnahme auswerfen).
F: Welche Eingabeströme in java.io implementieren nun eigentlich mark(), reset() und markSupported()?
A: InputStream und seine Standardimplementierungen geben für markSupported() false aus, machen bei mark() nichts und werfen durch reset() eine Ausnahme aus. Der einzige Eingabestrom, der in der derzeitigen Version die Kennzeichnung korrekt unterstützt, ist BufferedInputStream, der diese Standards übergeht. LineNumberInputStream implementiert mark() und reset(), jedoch erfolgt im derzeitigen Release auf markSupported() keine richtige Antwort.
F: Warum sollte available() nützlich sein, wenn es manchmal die falsche Antwort ausgibt?
A: Erstens muß man zugeben, daß es bei vielen Strömen richtig reagiert. Zweitens kann seine Implementierung bei manchen Netzströmen eine spezielle Anfrage senden, um bestimmte Informationen aufzudecken, die Sie andernfalls nicht einholen können (z. B. die Größe einer durch ftp übertragenen Datei). Würden Sie einen Verlaufsbalken für das Downloading oder die Übertragung von Dateien anzeigen, gäbe available() beispielsweise die Gesamtgröße der Übertragung aus bzw. andernfalls 0, was für sie und Ihre Benutzer sichtbar wäre.
F: Können Sie mir ein gutes Beispiel für die Verwendung des DataInput/DataOutput-Paars geben?
A: Eine übliche Verwendung eines solchen Schnittstellenpaars ist, wenn sich Objekte selbst zum Speichern oder Befördern über das Netz »einpökeln«. Jedes Objekt implementiert Lese- und Schreibmethoden anhand dieser Schnittstellen, so daß sie sich selbst effektiv in einen Strom umwandeln, der später am anderen Ende als Kopie des Originalobjekts wiederhergestellt werden kann.
Copyright ©1996 Markt&Technik