CodeGym /Kursy /JAVA 25 SELF /Asynchroniczne przetwarzanie plików tekstowych

Asynchroniczne przetwarzanie plików tekstowych

JAVA 25 SELF
Poziom 56 , Lekcja 2
Dostępny

1. Odczyt pliku partiami: ByteBuffer i kodowanie

Dziś rzadko mamy do czynienia z małymi plikami tekstowymi. Zazwyczaj to ogromne logi serwerów, raporty, pliki CSV lub gigabajtowe zrzuty danych. Dlatego ważne jest nie tylko czytać plik, ale robić to wydajnie i bez „zawieszania” aplikacji.

Podejście asynchroniczne właśnie w tym pomaga: nie blokuje głównego wątku — czy to interfejs użytkownika, czy logika serwerowa — pozwala równolegle czytać i zapisywać duże ilości danych i sprawia, że aplikacja jest skalowalna, gdy trzeba pracować jednocześnie z kilkoma plikami.

Najważniejsze: asynchroniczny I/O nie przyspiesza samego dysku — cudów nie ma. Po prostu pozwala Twojemu programowi nie bezczynnie czekać, gdy dysk wykonuje operację, lecz w tym czasie zajmować się innymi zadaniami.

Jak działa asynchroniczny odczyt?

Asynchroniczny kanał (AsynchronousFileChannel) czyta nie wiersze, lecz bloki bajtów do obiektu ByteBuffer. To tak, jakbyś przenosił pudła z literami, a nie pojedyncze słowa. Po odczycie musisz zamienić te bajty na ciągi znaków — z uwzględnieniem kodowania!

Przykład: asynchroniczny odczyt pliku blokami

Napiszmy prosty przykład odczytu asynchronicznego pliku blokami po 4096 bajtów i wypisania zawartości na konsolę.

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();
                // Przekształcamy bajty w łańcuch znaków (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);
            }
        }
    }
}

Ważne punkty:

  • Czytamy plik partiami (do bufora), a nie w całości.
  • Po odczycie bajty są dekodowane do łańcucha znaków przy użyciu Charset.
  • Pamiętaj o buffer.clear() — w przeciwnym razie następny read nie zadziała!

Dlaczego samo dekodowanie bajtów nie wystarcza?

Problem w tym, że znak może „rozpaść się” między dwoma blokami, zwłaszcza jeśli używane jest wielobajtowe kodowanie (np. "UTF-8"). Jeśli ostatni bajt w buforze to połowa znaku, to następny blok zacznie się od „reszty” znaku. Bez specjalnej obsługi otrzymasz „krzaczki” albo nawet błąd dekodowania.

2. Przekształcanie bajtów w znaki: obsługa rozcięć

Problem rozcięcia wierszy

Załóżmy, że masz łańcuch "Witaj\nŚwiat\n", a bufor skończył się na "Wit", a "aj\nŚwiat\n" trafiło do kolejnego bloku. Jeśli po prostu skleisz łańcuchy, możesz zgubić znaki lub otrzymać niepoprawny łańcuch.

Rozwiązanie: użyć CharsetDecoder

Java udostępnia klasę CharsetDecoder, która potrafi poprawnie obsługiwać takie przypadki. „Zapamiętuje” niedekodowane bajty i poprawnie odtwarza znaki na styku bloków.

Przykład użycia 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 = ... // twoje bajty
CharBuffer charBuffer = CharBuffer.allocate(buffer.capacity());
decoder.decode(buffer, charBuffer, false);
// Teraz charBuffer zawiera poprawnie zdekodowane znaki

W realnym zadaniu będziesz przechowywać „resztę” między odczytami i dekodować z jej uwzględnieniem.

3. Asynchroniczny zapis plików tekstowych

Odczyt to tylko połowa pracy. Zapis również odbywa się blokami bajtów, które najpierw trzeba uzyskać z łańcuchów znaków (zakodować).

Przykład: asynchroniczny zapis łańcucha do pliku

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 = "Witaj, świecie!\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);
            // Dla demonstracji poczekamy na zakończenie (zwykle nie trzeba tego robić!)
            future.get();
            System.out.println("Dane zapisano asynchronicznie.");
        }
    }
}

Komentarz: W rzeczywistych scenariuszach asynchronicznych nie należy wywoływać future.get() w wątku głównym — zamienia to kod asynchroniczny w synchroniczny. Lepiej użyć CompletionHandler (zob. poprzedni wykład).

4. Praktyka: asynchroniczny odczyt dużego pliku tekstowego i zliczanie wierszy

Zaimplementujmy praktyczne zadanie: asynchronicznie odczytać duży plik tekstowy i policzyć liczbę wierszy ("\n"). Wynik — wypisać liczbę wierszy na konsolę.

Przykład z użyciem 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) {
                    // Plik odczytany do końca
                    if (leftover.length() > 0) lines.incrementAndGet();
                    System.out.println("Liczba wierszy w pliku: " + 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);

                // Liczymy wiersze
                int last = 0;
                int idx;
                while ((idx = chunk.indexOf('\n', last)) != -1) {
                    lines.incrementAndGet();
                    last = idx + 1;
                }
                // Reszta (część wiersza po ostatnim \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("Błąd odczytu: " + exc.getMessage());
                try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
            }
        });

        // Żeby program nie zakończył się zbyt wcześnie (tylko do przykładu!)
        try { Thread.sleep(2000); } catch (InterruptedException e) {}
    }
}
  • Używamy CompletionHandler do naprawdę asynchronicznego kodu.
  • Po każdym odczycie bufor jest dekodowany przy użyciu CharsetDecoder.
  • Pozostała część wiersza, który nie kończy się na "\n", jest przenoszona do następnego bloku.
  • Po zakończeniu pliku, jeśli coś zostało w leftover, to również liczymy jako wiersz.
  • Dla prostoty przykład „usypia” na 2000 ms, aby operacja asynchroniczna się zakończyła (w rzeczywistych aplikacjach nie jest to potrzebne — zwykle istnieje pętla główna lub UI).

5. Asynchroniczny zapis wyników do pliku

Załóżmy, że chcemy zapisać wynik (np. liczbę wierszy) do nowego pliku — również asynchronicznie.

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 = "Liczba wierszy w pliku: 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("Wynik zapisano asynchronicznie!");
                try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
            }

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

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

6. Wskazówki dotyczące obsługi danych częściowych i kodowań

Częściowe wiersze między blokami

Jeśli wiersz jest podzielony między dwa bloki, nie próbuj „sklejać” bajtów ręcznie! Użyj CharsetDecoder, który starannie obsłuży brakujące bajty i nie zgubi żadnego znaku.

Praca z różnymi kodowaniami

"UTF-8" to standard dla nowoczesnych aplikacji, ale jeśli plik ma inne kodowanie (np. "Windows-1251" lub "UTF-16"), użyj odpowiedniego Charset:

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

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

Użycie CharsetDecoder i CharsetEncoder

Gdy czytasz lub zapisujesz dane partiami, ważna jest poprawna obsługa kodowania. Znak może „rozpaść się” między blokami i bez dodatkowej obsługi powstanie „sałatka” z bajtów.

Aby temu zapobiec, używa się CharsetDecoder i CharsetEncoder.

Przy odczycie wywołuje się decode(ByteBuffer, CharBuffer, endOfInput), a przy zapisie — encode(CharBuffer, ByteBuffer, endOfInput).

Dbają o to, by nawet jeśli znak został rozdzielony między dwa bloki, został mimo to złożony i poprawnie przetworzony.

7. Typowe błędy przy asynchronicznym przetwarzaniu plików tekstowych

Błąd nr 1: Ignorowanie pozostałości wiersza. Jeśli nie przechowujesz „ogona” wiersza między blokami, część wierszy może zginąć albo zostać niepoprawnie zdekodowana.

Błąd nr 2: Nieprawidłowa obsługa bufora. Zapomniano wywołać buffer.clear() po przetworzeniu — następny read nie zadziała lub dane będą niepoprawne.

Błąd nr 3: Użycie niewłaściwego kodowania. Jeśli bajty są dekodowane innym Charset niż ten, który był użyty przy zapisie pliku, możliwe są „krzaczki” lub nawet błędy.

Błąd nr 4: Blokowanie wątku głównego. Jeśli wywołujesz future.get() lub Thread.sleep() w wątku UI, tracisz sens asynchroniczności. Używaj CompletionHandler i podejść reaktywnych.

Błąd nr 5: Niezamknięty kanał po zakończeniu. Zawsze zamykaj kanał (channel.close()) po zakończeniu wszystkich operacji, nawet jeśli wystąpił błąd.

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