Überblick

Die Verarbeitung von Ein- und Ausgaben ist eine häufige Aufgabe für Java-Programmierer. In diesem Tutorium werden wir uns die ursprünglichen java.io (IO)-Bibliotheken und die neueren java.nio (NIO)-Bibliotheken ansehen und wie sie sich bei der Kommunikation über ein Netzwerk unterscheiden.

Schlüsselmerkmale

Beginnen wir mit einem Blick auf die Schlüsselmerkmale beider Pakete.

2.1. IO – java.io

Das Paket java.io wurde in Java 1.0 eingeführt, der Reader in Java 1.1. Es bietet:

  • InputStream und OutputStream – die Daten Byte für Byte bereitstellen
  • Reader und Writer – Convenience Wrapper für die Streams
  • Blocking Mode – um auf eine vollständige Nachricht zu warten

2.2. NIO – java.nio

Das Paket java.nio wurde in Java 1.4 eingeführt und in Java 1.7 (NIO.2) mit verbesserten Dateioperationen und einem ASynchronousSocketChannel aktualisiert. Es bietet:

  • Buffer – um Datenpakete auf einmal zu lesen
  • CharsetDecoder – um rohe Bytes in/aus lesbaren Zeichen abzubilden
  • Channel – um mit der Außenwelt zu kommunizieren
  • Selector – um Multiplexing auf einem SelectableChannel zu aktivieren und Zugriff auf alle Channels zu ermöglichen, die für I/O bereit sind
  • non-blocking mode – um alles zu lesen, was bereit ist

Schauen wir uns nun an, wie wir jedes dieser Pakete verwenden, wenn wir Daten an einen Server senden oder seine Antwort lesen.

Konfigurieren Sie unseren Testserver

Hier werden wir WireMock verwenden, um einen anderen Server zu simulieren, damit wir unsere Tests unabhängig durchführen können.

Wir werden ihn so konfigurieren, dass er auf unsere Anfragen wartet und uns Antworten sendet, genau wie ein echter Webserver es tun würde. Wir werden auch einen dynamischen Port verwenden, damit wir nicht mit irgendwelchen Diensten auf unserem lokalen Rechner in Konflikt geraten.

Lassen Sie uns die Maven-Abhängigkeit für WireMock mit Testbereich hinzufügen:

<dependency> <groupId>com.github.tomakehurst</groupId> <artifactId>wiremock-jre8</artifactId> <version>2.26.3</version> <scope>test</scope></dependency>

In einer Testklasse lassen Sie uns eine JUnit @Rule definieren, um WireMock auf einem freien Port zu starten. Dann konfigurieren wir ihn so, dass er uns eine HTTP 200-Antwort zurückgibt, wenn wir eine vordefinierte Ressource anfordern, wobei der Nachrichtentext im JSON-Format vorliegt:

@Rule public WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicPort());private String REQUESTED_RESOURCE = "/test.json";@Beforepublic void setup() { stubFor(get(urlEqualTo(REQUESTED_RESOURCE)) .willReturn(aResponse() .withStatus(200) .withBody("{ \"response\" : \"It worked!\" }")));}

Nun, da wir unseren Mock-Server eingerichtet haben, können wir einige Tests durchführen.

Blocking IO – java.io

Schauen wir uns an, wie das ursprüngliche Blocking IO-Modell funktioniert, indem wir einige Daten von einer Website lesen. Wir werden ein java.net.Socket verwenden, um Zugang zu einem der Ports des Betriebssystems zu erhalten.

4.1. Senden einer Anfrage

In diesem Beispiel werden wir eine GET-Anfrage erstellen, um unsere Ressourcen abzurufen. Zunächst erstellen wir ein Socket, um auf den Port zuzugreifen, auf dem unser WireMock-Server lauscht:

Socket socket = new Socket("localhost", wireMockRule.port())

Bei normaler HTTP- oder HTTPS-Kommunikation wäre der Port 80 oder 443. In diesem Fall verwenden wir jedoch wireMockRule.port(), um auf den dynamischen Port zuzugreifen, den wir zuvor eingerichtet haben.

Nun öffnen wir einen OutputStream auf dem Socket, der in einen OutputStreamWriter eingewickelt ist, und übergeben ihn an einen PrintWriter, um unsere Nachricht zu schreiben. Und stellen wir sicher, dass wir den Puffer leeren, damit unsere Anfrage gesendet wird:

OutputStream clientOutput = socket.getOutputStream();PrintWriter writer = new PrintWriter(new OutputStreamWriter(clientOutput));writer.print("GET " + TEST_JSON + " HTTP/1.0\r\n\r\n");writer.flush();

4.2. Warten auf die Antwort

Öffnen wir einen InputStream auf dem Socket, um auf die Antwort zuzugreifen, lesen wir den Stream mit einem BufferedReader und speichern wir ihn in einem StringBuilder:

InputStream serverInput = socket.getInputStream();BufferedReader reader = new BufferedReader(new InputStreamReader(serverInput));StringBuilder ourStore = new StringBuilder();

Wir verwenden reader.readLine(), um zu blockieren und auf eine vollständige Zeile zu warten, und hängen die Zeile dann an unseren Speicher an. Wir lesen weiter, bis wir eine Null erhalten, die das Ende des Streams anzeigt:

for (String line; (line = reader.readLine()) != null;) { ourStore.append(line); ourStore.append(System.lineSeparator());}

Non-Blocking IO – java.nio

Schauen wir uns nun an, wie das Non-Blocking IO-Modell des nio-Pakets mit demselben Beispiel funktioniert.

Diesmal erstellen wir einen java.nio.channel.SocketChannel, um auf den Port unseres Servers zuzugreifen, anstatt eines java.net.Socket, und übergeben ihm eine InetSocketAddress.

5.1. Eine Anfrage senden

Zunächst öffnen wir unseren SocketChannel:

InetSocketAddress address = new InetSocketAddress("localhost", wireMockRule.port());SocketChannel socketChannel = SocketChannel.open(address);

Und jetzt nehmen wir einen Standard UTF-8 Zeichensatz, um unsere Nachricht zu kodieren und zu schreiben:

Charset charset = StandardCharsets.UTF_8;socket.write(charset.encode(CharBuffer.wrap("GET " + REQUESTED_RESOURCE + " HTTP/1.0\r\n\r\n")));

5.2. Lesen der Antwort

Nachdem wir die Anfrage gesendet haben, können wir die Antwort im nicht-blockierenden Modus lesen, indem wir Rohpuffer verwenden:

Da wir Text verarbeiten werden, brauchen wir einen ByteBuffer für die Rohbytes und einen CharBuffer für die konvertierten Zeichen (mit Hilfe eines CharsetDecoders):

ByteBuffer byteBuffer = ByteBuffer.allocate(8192);CharsetDecoder charsetDecoder = charset.newDecoder();CharBuffer charBuffer = CharBuffer.allocate(8192);

Unser CharBuffer wird Platz übrig haben, wenn die Daten in einem Multi-Byte-Zeichensatz gesendet werden.

Wenn wir eine besonders schnelle Leistung benötigen, können wir einen MappedByteBuffer im nativen Speicher mit ByteBuffer.allocateDirect() erstellen. In unserem Fall ist die Verwendung von allocate() aus dem Standard-Heap jedoch schnell genug.

Wenn wir mit Puffern arbeiten, müssen wir wissen, wie groß der Puffer ist (die Kapazität), wo wir uns im Puffer befinden (die aktuelle Position) und wie weit wir gehen können (das Limit).

Lesen wir also von unserem SocketChannel und übergeben ihm unseren ByteBuffer, um unsere Daten zu speichern. Unser Lesen aus dem SocketChannel endet mit der aktuellen Position unseres ByteBuffers, die auf das nächste zu schreibende Byte gesetzt ist (direkt nach dem letzten geschriebenen Byte), aber mit unverändertem Limit:

socketChannel.read(byteBuffer)

Unser SocketChannel.read() gibt die Anzahl der gelesenen Bytes zurück, die in unseren Puffer geschrieben werden konnten. Dies ist -1, wenn die Verbindung zum Socket unterbrochen wurde.

Wenn unser Puffer keinen Platz mehr hat, weil wir noch nicht alle Daten verarbeitet haben, dann gibt SocketChannel.read() null gelesene Bytes zurück, aber unsere buffer.position() ist immer noch größer als Null.

Um sicherzustellen, dass wir mit dem Lesen an der richtigen Stelle im Puffer beginnen, verwenden wir Buffer.flip(), um die aktuelle Position unseres ByteBuffers auf Null und seine Grenze auf das letzte Byte zu setzen, das vom SocketChannel geschrieben wurde. Dann speichern wir den Pufferinhalt mit unserer storeBufferContents-Methode, die wir uns später ansehen werden. Schließlich verwenden wir buffer.compact(), um den Puffer zu komprimieren und die aktuelle Position für das nächste Lesen aus dem SocketChannel bereitzustellen.

Da unsere Daten in Teilen ankommen können, verpacken wir unseren Puffer-Lesecode in eine Schleife mit Abbruchbedingungen, um zu prüfen, ob unser Socket noch verbunden ist oder ob wir getrennt wurden, aber noch Daten in unserem Puffer haben:

while (socketChannel.read(byteBuffer) != -1 || byteBuffer.position() > 0) { byteBuffer.flip(); storeBufferContents(byteBuffer, charBuffer, charsetDecoder, ourStore); byteBuffer.compact();}

Und vergessen wir nicht, unseren Socket zu schließen() (es sei denn, wir haben ihn in einem try-with-resources-Block geöffnet):

socketChannel.close();

5.3. Speichern von Daten aus unserem Puffer

Die Antwort des Servers wird Header enthalten, was dazu führen kann, dass die Datenmenge die Größe unseres Puffers überschreitet. Daher verwenden wir einen StringBuilder, um unsere vollständige Nachricht zu erstellen, sobald sie eintrifft.

Um unsere Nachricht zu speichern, dekodieren wir zunächst die Rohbytes in Zeichen in unserem CharBuffer. Dann drehen wir die Zeiger um, so dass wir unsere Zeichendaten lesen können, und hängen sie an unseren erweiterbaren StringBuilder an. Schließlich löschen wir den CharBuffer, um für den nächsten Schreib-/Lesezyklus bereit zu sein.

So, jetzt implementieren wir unsere vollständige storeBufferContents()-Methode, indem wir unsere Puffer, CharsetDecoder und StringBuilder übergeben:

void storeBufferContents(ByteBuffer byteBuffer, CharBuffer charBuffer, CharsetDecoder charsetDecoder, StringBuilder ourStore) { charsetDecoder.decode(byteBuffer, charBuffer, true); charBuffer.flip(); ourStore.append(charBuffer); charBuffer.clear();}

Abschluss

In diesem Artikel haben wir gesehen, wie das ursprüngliche java.io-Modell blockiert, auf eine Anfrage wartet und Streams verwendet, um die empfangenen Daten zu manipulieren.

Im Gegensatz dazu ermöglichen die java.nio-Bibliotheken eine nicht-blockierende Kommunikation mit Puffern und Kanälen und können einen direkten Speicherzugriff für eine schnellere Leistung bieten. Diese Geschwindigkeit geht jedoch mit der zusätzlichen Komplexität des Umgangs mit Puffern einher.

Wie üblich ist der Code für diesen Artikel auf GitHub verfügbar.

Lernen Sie Spring 5 und Spring Boot 2 mit dem Kurs „Spring lernen“:

>>CHECK OUT THE COURSE

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.