JavaRush/Курси/JAVA 25 SELF/Асинхронна обробка текстових файлів

Асинхронна обробка текстових файлів

Відкрита

1. Читання файлу частинами: ByteBuffer і кодування

Сьогодні ми рідко маємо справу з маленькими текстовими файлами. Зазвичай це величезні журнали серверів, звіти, CSV-файли або гігабайтні дампи даних. Тому важливо не просто читати файл, а робити це ефективно й без «завмирання» застосунку.

Асинхронний підхід допомагає саме в цьому: він не блокує основний потік — чи то інтерфейс, чи серверна логіка, — дозволяє читати й записувати великі обсяги даних паралельно та робить застосунок масштабованим, коли потрібно працювати відразу з кількома файлами.

Головне — розуміти: асинхронне введення-виведення не пришвидшує сам диск — чудес не буває. Воно просто дозволяє вашій програмі не нудьгувати в очікуванні, поки диск виконує операцію, і займатися іншими справами в цей час.

Як працює асинхронне читання?

Асинхронний канал (AsynchronousFileChannel) читає не рядки, а блоки байтів в об’єкт ByteBuffer. Це наче ви переносите коробки з літерами, а не окремі слова. Після читання вам потрібно перетворити ці байти на рядки — з урахуванням кодування!

Приклад: асинхронне читання файлу блоками

Напишімо найпростіший приклад асинхронного читання файлу блоками по 4096 байт і виведення вмісту у консоль.

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);
            long position = 0;
            int bytesRead;
            Future<Integer> future = channel.read(buffer, position);

            while ((bytesRead = future.get()) > 0) {
                buffer.flip();
                // Перетворюємо байти на рядок (UTF-8)
                String chunk = StandardCharsets.UTF_8.decode(buffer).toString();
                System.out.print(chunk);
                buffer.clear();
                position += bytesRead;
                future = channel.read(buffer, position);
            }
        }
    }
}

Важливі моменти:

  • Ми читаємо файл частинами (буфером), а не цілком.
  • Після читання байти декодуються в рядок за допомогою Charset.
  • Після обробки звільняємо буфер: buffer.clear(), якщо всі байти вже декодовано, або buffer.compact(), якщо в кінці блоку могли залишитися байти неповного символу.
  • Цей приклад ламається на багатобайтних символах: Charset.decode() замінить «розірваний» на стику блоків символ на «�». Чому так і як це виправити — нижче.

Чому просто декодувати байти недостатньо?

Проблема в тому, що символ може «розірватися» між двома блоками, особливо якщо використовується багатобайтне кодування (наприклад, "UTF-8"). Якщо останній байт у буфері — це половина символу, то наступний блок почнеться з «залишку» символу. Без спеціальної обробки ви отримаєте «абракадабру» або навіть помилку декодування.

2. Перетворення байтів на рядки: обробка розривів

Проблема розриву рядків

Припустімо, у вас рядок "Привіт\nСвіт\n", а буфер закінчився на "Прив", а "іт\nСвіт\n" потрапило в наступний блок. Якщо просто склеювати рядки, можна втратити символи або отримати некоректний рядок.

Рішення: використовувати CharsetDecoder

Java надає клас CharsetDecoder, який уміє коректно обробляти такі випадки. Сам він байтів не зберігає: якщо в кінці буфера опинився початок багатобайтного символу, decode() залишить ці байти в ByteBuffer — позиція буфера зупиниться перед ними. Ваше завдання — їх не втратити: після декодування викликайте buffer.compact(), а не buffer.clear(). Метод compact() перенесе недекодовані байти на початок буфера, і наступне читання допише нові одразу за ними.

Приклад використання 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 = ... // ваші байти
CharBuffer charBuffer = CharBuffer.allocate(buffer.capacity());
decoder.decode(buffer, charBuffer, false);
// Тепер charBuffer містить коректно декодовані символи

Окремо зберігати «залишок» байтів не потрібно — його зберігає сам ByteBuffer після compact(). Коли вхідні дані закінчилися, востаннє викличте decode з endOfInput = true, а потім decoder.flush(charBuffer) — цього вимагає контракт CharsetDecoder. І перевіряйте результат decode: якщо байти пошкоджені, він поверне помилку, а не просунеться далі.

3. Асинхронний запис текстових файлів

Читання — це лише половина справи. Запис також виконується блоками байтів, які потрібно спочатку отримати з рядків (закодувати).

Приклад: асинхронний запис рядка у файл

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 = "Привіт, світ!\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);
            // Для демонстрації зачекаємо на завершення (зазвичай цього робити не слід!)
            future.get();
            System.out.println("Дані записано асинхронно.");
        }
    }
}

Коментар: У реальних асинхронних сценаріях не варто викликати future.get() в основному потоці — це перетворює асинхронний код на синхронний. Краще використовувати CompletionHandler (див. попередню лекцію).

4. Практика: асинхронне читання великого текстового файлу та підрахунок рядків

Реалізуймо практичне завдання: асинхронно прочитати великий текстовий файл і порахувати кількість рядків ("\n"). Результат — вивести кількість рядків у консоль.

Приклад із використанням 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.CharacterCodingException;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CoderResult;
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) {
                boolean endOfInput = (result == -1); // -1: файл прочитано до кінця
                buffer.flip();
                // +1 — запас під символ, який декодер міг «притримати» до кінця вхідних даних
                CharBuffer charBuffer = CharBuffer.allocate(buffer.remaining() + 1);
                CoderResult cr = decoder.decode(buffer, charBuffer, endOfInput);
                if (!cr.isError() && endOfInput) {
                    cr = decoder.flush(charBuffer); // після останнього decode(..., true)
                }
                if (cr.isError()) {
                    // Пошкоджені байти: без цієї перевірки цикл «compact → read → decode» не просунеться
                    try { cr.throwException(); } catch (CharacterCodingException e) { failed(e, attachment); }
                    return;
                }
                charBuffer.flip();
                String chunk = leftover.toString() + charBuffer.toString();
                leftover.setLength(0);

                // Рахуємо рядки
                int last = 0;
                int idx;
                while ((idx = chunk.indexOf('\n', last)) != -1) {
                    lines.incrementAndGet();
                    last = idx + 1;
                }
                // Залишок (частина рядка після останнього \n)
                if (last < chunk.length()) {
                    leftover.append(chunk.substring(last));
                }

                if (endOfInput) {
                    // Останній рядок без '\n' теж вважається рядком
                    if (leftover.length() > 0) lines.incrementAndGet();
                    System.out.println("Рядків у файлі: " + lines.get());
                    try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
                    return;
                }
                // Недекодовані байти (початок «розірваного» символу) залишилися в buffer.
                // compact() переносить їх на початок буфера, наступне читання допише байти одразу за ними.
                buffer.compact();
                position.addAndGet(result);
                channel.read(buffer, position.get(), null, this);
            }

            @Override
            public void failed(Throwable exc, Object attachment) {
                System.err.println("Помилка читання: " + exc.getMessage());
                try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
            }
        });

        // Щоб програма не завершилася завчасно (лише для прикладу!)
        try { Thread.sleep(2000); } catch (InterruptedException e) {}
    }
}
  • Ми використовуємо CompletionHandler для справді асинхронного коду.
  • Після кожного читання буфер декодується за допомогою CharsetDecoder.
  • Залишок рядка, що не закінчився на "\n", переноситься в наступний блок.
  • Недекодовані байти в кінці блоку залишаються в buffer, і compact() зберігає їх для наступного читання. А leftover — це хвіст рядка (уже символи): він потрібен лише для підрахунку рядків.
  • Коли read повернув -1, робимо останній виклик decode(..., true) і flush(). Якщо байти пошкоджені, decode поверне помилку — передаємо її у failed(), інакше цикл читання зациклиться.
  • Після завершення файлу, якщо щось залишилося в leftover, це також вважається рядком.
  • Для простоти приклад «засинає» на 2000 мс, щоб асинхронна операція завершилася (у реальних застосунках це не потрібно — зазвичай є головний цикл або UI).

5. Асинхронний запис результатів у файл

Припустімо, ми хочемо записати результат (наприклад, кількість рядків) у новий файл — також асинхронно.

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 = "Рядків у файлі: 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("Результат записано асинхронно!");
                try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
            }

            @Override
            public void failed(Throwable exc, Object attachment) {
                System.err.println("Помилка запису: " + exc.getMessage());
                try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
            }
        });

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

6. Поради щодо обробки часткових даних і кодувань

Часткові рядки між блоками

Якщо символ розбитий між двома блоками, не намагайтеся «склеїти» байти вручну! CharsetDecoder зупиниться перед неповним символом, а buffer.compact() збереже його байти до наступного читання.

Робота з різними кодуваннями

"UTF-8" — стандарт для сучасних застосунків, але якщо файл в іншому кодуванні (наприклад, "Windows-1251" або "UTF-16"), використовуйте відповідний Charset:

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

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

Використання CharsetDecoder та CharsetEncoder

Коли ви читаєте або записуєте дані частинами, важливо правильно працювати з кодуванням. Символ може «розірватися» між блоками, і тоді без додаткової обробки вийде каша з байтів.

Щоб цього уникнути, використовуються CharsetDecoder і CharsetEncoder.

Під час читання викликається decode(ByteBuffer, CharBuffer, endOfInput), а під час запису — encode(CharBuffer, ByteBuffer, endOfInput).

Декодер зупиняється перед неповним символом і залишає його байти у вхідному буфері. Зібрати символ вийде, лише якщо зберегти ці байти (compact()) і передати їх у наступний виклик decode.

7. Типові помилки під час асинхронної обробки текстових файлів

Помилка № 1: Втрата недекодованих байтів між блоками. Якщо після decode викликати buffer.clear(), початок «розірваного» багатобайтного символу пропаде: наступний блок почнеться із середини символу, і декодер поверне помилку або «абракадабру». Викликайте buffer.compact().

Помилка № 2: Неправильна робота з буфером. Забули викликати buffer.clear() або buffer.compact() після обробки — наступний read не спрацює або дані будуть некоректні.

Помилка № 3: Використання невідповідного кодування. Якщо байти декодуються не тим Charset, що був під час запису файлу, можливі «абракадабра» або навіть помилки.

Помилка № 4: Блокування основного потоку. Якщо ви викликаєте future.get() або Thread.sleep() в UI-потоці, ви втрачаєте сенс асинхронності. Використовуйте CompletionHandler і реактивні підходи.

Помилка № 5: Канал не закрито після завершення. Завжди закривайте канал (channel.close()) після завершення всіх операцій, навіть якщо була помилка.

1
Задача
JAVA 25 SELF,  56 рівень,  2 лекція
Недоступна
Секретар-скорохід: асинхронний підрахунок рядків у величезному звіті 📊
Секретар-скорохід: асинхронний підрахунок рядків у величезному звіті 📊
1
Задача
JAVA 25 SELF,  56 рівень,  2 лекція
Недоступна
Автоматизована фабрика даних: читання, обробка та асинхронне вивантаження 🤖
Автоматизована фабрика даних: читання, обробка та асинхронне вивантаження 🤖
Коментарі
  • популярні
  • нові
  • старі
Щоб залишити коментар, потрібно ввійти в систему
Для цієї сторінки немає коментарів.