CodeGym /Kurse /JAVA 25 SELF /Asynchrone Verarbeitung von Textdateien

Asynchrone Verarbeitung von Textdateien

JAVA 25 SELF
Level 56 , Lektion 2
Verfügbar

1. Datei stückweise lesen: ByteBuffer und Zeichenkodierung

Heutzutage haben wir selten mit kleinen Textdateien zu tun. Üblicher sind riesige Server-Logs, Reports, CSV-Dateien oder gigabytegroße Datendumps. Daher ist es wichtig, eine Datei nicht einfach nur zu lesen, sondern dies effizient und ohne „Einfrieren“ der Anwendung zu tun.

Der asynchrone Ansatz hilft genau dabei: Er blockiert den Haupt-Thread nicht – sei es UI oder Serverlogik –, ermöglicht das parallele Lesen und Schreiben großer Datenmengen und macht die Anwendung skalierbar, wenn gleichzeitig mit mehreren Dateien gearbeitet werden muss.

Wichtig ist: Asynchrone I/O macht das Laufwerk nicht schneller – Wunder gibt es nicht. Es sorgt lediglich dafür, dass Ihr Programm während der Wartezeit, in der das Laufwerk die Operation ausführt, nicht untätig ist und in der Zwischenzeit andere Aufgaben erledigen kann.

Wie funktioniert asynchrones Lesen?

Der asynchrone Kanal (AsynchronousFileChannel) liest keine Strings, sondern Byte-Blöcke in ein ByteBuffer-Objekt. Das ist, als würden Sie Kisten mit Buchstaben schleppen, nicht einzelne Wörter. Nach dem Lesen müssen Sie diese Bytes – unter Berücksichtigung der Kodierung – in Strings umwandeln!

Beispiel: Asynchrones Lesen einer Datei in Blöcken

Schreiben wir ein einfaches Beispiel für das asynchrone Lesen einer Datei in Blöcken zu 4096 Byte und die Ausgabe des Inhalts auf die Konsole.

import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
import java.nio.charset.StandardCharsets;
import java.io.IOException;

public class AsyncTextReadExample {
    public static void main(String[] args) throws Exception {
        Path path = Path.of("bigfile.txt");
        try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
            ByteBuffer buffer = ByteBuffer.allocate(4096);
            int position = 0;
            Future<Integer> future = channel.read(buffer, position);

            while (future.get() > 0) {
                buffer.flip();
                // Bytes in String umwandeln (UTF-8)
                String chunk = StandardCharsets.UTF_8.decode(buffer).toString();
                System.out.print(chunk);
                buffer.clear();
                position += chunk.getBytes(StandardCharsets.UTF_8).length;
                future = channel.read(buffer, position);
            }
        }
    }
}

Wichtige Punkte:

  • Wir lesen die Datei stückweise (pufferweise), nicht am Stück.
  • Nach dem Lesen werden die Bytes mit Charset in einen String dekodiert.
  • Vergessen Sie nicht buffer.clear() – sonst funktioniert der nächste read nicht!

Warum reicht es nicht, Bytes einfach zu dekodieren?

Das Problem: Eine Zeichenfolge kann zwischen zwei Blöcken „zerschnitten“ werden, besonders bei mehrbyteigen Kodierungen (zum Beispiel "UTF-8"). Ist das letzte Byte im Puffer nur die Hälfte eines Zeichens, beginnt der nächste Block mit dem „Rest“. Ohne spezielle Behandlung bekommen Sie Kauderwelsch oder sogar einen Dekodierfehler.

2. Bytes in Strings umwandeln: Trennungen korrekt behandeln

Das Problem zerschnittener Zeichen

Angenommen, Sie haben die Zeichenfolge "Hallo\nWelt\n", doch der Puffer endet bei "Hall", und "o\nWelt\n" gerät in den nächsten Block. Wenn Sie die Strings einfach zusammenkleben, können Zeichen verloren gehen oder die Zeichenfolge wird fehlerhaft.

Lösung: CharsetDecoder verwenden

Java stellt die Klasse CharsetDecoder bereit, die solche Fälle korrekt behandelt. Er „merkt“ sich nicht dekodierte Bytes und stellt Zeichen an Blockgrenzen korrekt wieder her.

Beispiel für die Verwendung von CharsetDecoder

import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.CharBuffer;
import java.nio.ByteBuffer;

CharsetDecoder decoder = Charset.forName("UTF-8").newDecoder();
ByteBuffer buffer = ... // Ihre Bytes
CharBuffer charBuffer = CharBuffer.allocate(buffer.capacity());
decoder.decode(buffer, charBuffer, false);
// Jetzt enthält charBuffer korrekt dekodierte Zeichen

In einer realen Aufgabe speichern Sie den „Rest“ zwischen den Lesevorgängen und dekodieren unter Berücksichtigung dieses Rests.

3. Asynchrones Schreiben von Textdateien

Lesen ist nur die halbe Miete. Auch das Schreiben erfolgt in Byte-Blöcken, die zunächst aus Strings gewonnen (kodiert) werden müssen.

Beispiel: Asynchrones Schreiben einer Zeichenkette in eine Datei

import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
import java.nio.charset.StandardCharsets;

public class AsyncTextWriteExample {
    public static void main(String[] args) throws Exception {
        Path path = Path.of("output.txt");
        String text = "Hallo, Welt!\n";
        ByteBuffer buffer = ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8));
        try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
            Future<Integer> future = channel.write(buffer, 0);
            // Zur Demonstration warten wir auf den Abschluss (normalerweise nicht nötig!)
            future.get();
            System.out.println("Die Daten wurden asynchron geschrieben.");
        }
    }
}

Kommentar: In echten asynchronen Szenarien sollten Sie future.get() nicht im Haupt-Thread aufrufen – das macht asynchronen Code wieder synchron. Verwenden Sie besser einen CompletionHandler (siehe vorherige Vorlesung).

4. Praxis: Asynchrones Lesen einer großen Textdatei und Zeilenzählung

Implementieren wir eine praktische Aufgabe: eine große Textdatei asynchron lesen und die Anzahl der Zeilen ("\n") zählen. Das Ergebnis – die Anzahl der Zeilen auf der Konsole ausgeben.

Beispiel mit Verwendung von CompletionHandler

import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.CharBuffer;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicLong;

public class AsyncLineCounter {
    public static void main(String[] args) throws IOException {
        Path path = Path.of("bigfile.txt");
        AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ);

        ByteBuffer buffer = ByteBuffer.allocate(4096);
        AtomicLong position = new AtomicLong(0);
        CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
        StringBuilder leftover = new StringBuilder();
        AtomicLong lines = new AtomicLong(0);

        channel.read(buffer, position.get(), null, new CompletionHandler<Integer, Object>() {
            @Override
            public void completed(Integer result, Object attachment) {
                if (result == -1) {
                    // Datei bis zum Ende gelesen
                    if (leftover.length() > 0) lines.incrementAndGet();
                    System.out.println("Zeilen in der Datei: " + lines.get());
                    try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
                    return;
                }
                buffer.flip();
                CharBuffer charBuffer = CharBuffer.allocate(buffer.remaining());
                decoder.decode(buffer, charBuffer, false);
                charBuffer.flip();
                String chunk = leftover.toString() + charBuffer.toString();
                leftover.setLength(0);

                // Zeilen zählen
                int last = 0;
                int idx;
                while ((idx = chunk.indexOf('\n', last)) != -1) {
                    lines.incrementAndGet();
                    last = idx + 1;
                }
                // Rest (Teil der Zeile nach dem letzten \n)
                if (last < chunk.length()) {
                    leftover.append(chunk.substring(last));
                }
                buffer.clear();
                position.addAndGet(result);
                channel.read(buffer, position.get(), null, this);
            }

            @Override
            public void failed(Throwable exc, Object attachment) {
                System.err.println("Lesefehler: " + exc.getMessage());
                try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
            }
        });

        // Damit das Programm nicht zu früh beendet wird (nur fürs Beispiel!)
        try { Thread.sleep(2000); } catch (InterruptedException e) {}
    }
}
  • Wir verwenden CompletionHandler für wirklich asynchronen Code.
  • Nach jedem Lesevorgang wird der Puffer mit CharsetDecoder dekodiert.
  • Der Rest einer Zeile, die nicht mit "\n" endet, wird in den nächsten Block übernommen.
  • Am Dateiende gilt: Wenn im leftover noch etwas steht, zählt das ebenfalls als Zeile.
  • Der Einfachheit halber „schläft“ das Beispiel 2000 ms, damit die asynchrone Operation abgeschlossen wird (in realen Anwendungen ist das nicht nötig – üblicherweise gibt es eine Event-Schleife oder ein UI).

5. Asynchrones Schreiben der Ergebnisse in eine Datei

Nehmen wir an, wir möchten das Ergebnis (zum Beispiel die Anzahl der Zeilen) in eine neue Datei schreiben – ebenfalls asynchron.

import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.StandardCharsets;
import java.io.IOException;

public class AsyncWriteResult {
    public static void main(String[] args) throws IOException {
        String result = "Zeilen in der Datei: 12345\n";
        ByteBuffer buffer = ByteBuffer.wrap(result.getBytes(StandardCharsets.UTF_8));
        Path path = Path.of("result.txt");

        AsynchronousFileChannel channel = AsynchronousFileChannel.open(
            path, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);

        channel.write(buffer, 0, null, new CompletionHandler<Integer, Object>() {
            @Override
            public void completed(Integer written, Object attachment) {
                System.out.println("Ergebnis asynchron geschrieben!");
                try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
            }

            @Override
            public void failed(Throwable exc, Object attachment) {
                System.err.println("Schreibfehler: " + exc.getMessage());
                try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
            }
        });

        try { Thread.sleep(500); } catch (InterruptedException e) {}
    }
}

6. Tipps zum Umgang mit Teilfragmenten und Kodierungen

Teilweise Zeilen zwischen Blöcken

Wenn eine Zeile zwischen zwei Blöcken aufgeteilt ist, versuchen Sie nicht, die Bytes manuell zu „verkleben“! Verwenden Sie CharsetDecoder, der fehlende Bytes sauber behandelt und kein Zeichen verliert.

Arbeiten mit verschiedenen Kodierungen

"UTF-8" ist der Standard für moderne Anwendungen, aber wenn die Datei in einer anderen Kodierung vorliegt (zum Beispiel "Windows-1251" oder "UTF-16"), verwenden Sie das entsprechende Charset:

import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;

Charset charset = Charset.forName("Windows-1251");
CharsetDecoder decoder = charset.newDecoder();

Verwendung von CharsetDecoder und CharsetEncoder

Wenn Sie Daten stückweise lesen oder schreiben, ist der korrekte Umgang mit der Kodierung entscheidend. Ein Zeichen kann zwischen Blöcken „zerschnitten“ werden; ohne zusätzliche Behandlung entsteht dann ein Byte-Salat.

Um dies zu vermeiden, verwendet man CharsetDecoder und CharsetEncoder.

Beim Lesen rufen Sie decode(ByteBuffer, CharBuffer, endOfInput) auf, beim Schreiben – encode(CharBuffer, ByteBuffer, endOfInput).

Sie sorgen dafür, dass ein Zeichen, das zwischen zwei Blöcken geteilt wurde, trotzdem korrekt zusammengesetzt und verarbeitet wird.

7. Typische Fehler bei der asynchronen Verarbeitung von Textdateien

Fehler Nr. 1: Ignorieren von Zeilenresten. Wenn der „Rest“ einer Zeile nicht zwischen den Blöcken vorgehalten wird, können Teile von Zeilen verloren gehen oder falsch dekodiert werden.

Fehler Nr. 2: Falscher Umgang mit dem Puffer. Wenn Sie nach der Verarbeitung buffer.clear() nicht aufrufen, funktioniert der nächste read nicht oder die Daten sind fehlerhaft.

Fehler Nr. 3: Verwendung einer ungeeigneten Kodierung. Wenn die Bytes nicht mit dem Charset dekodiert werden, das beim Schreiben der Datei verwendet wurde, sind „Kauderwelsch“ oder sogar Fehler möglich.

Fehler Nr. 4: Blockieren des Haupt-Threads. Wenn Sie future.get() oder Thread.sleep() im UI-Thread aufrufen, verlieren Sie den Sinn der Asynchronität. Verwenden Sie CompletionHandler und reaktive Ansätze.

Fehler Nr. 5: Kanal nach Abschluss nicht geschlossen. Schließen Sie den Kanal (channel.close()) immer nach dem Ende aller Operationen – auch im Fehlerfall.

Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION