CodeGym /Các khóa học /JAVA 25 SELF /Xử lý tệp văn bản bất đồng bộ

Xử lý tệp văn bản bất đồng bộ

JAVA 25 SELF
Mức độ , Bài học
Có sẵn

1. Đọc tệp theo từng phần: ByteBuffer và mã hóa ký tự

Ngày nay, chúng ta hiếm khi làm việc với các tệp văn bản nhỏ. Thông thường đó là log máy chủ khổng lồ, báo cáo, tệp CSV hoặc dump dữ liệu hàng gigabyte. Vì vậy, điều quan trọng không chỉ là đọc tệp, mà còn phải làm điều đó hiệu quả và không làm ứng dụng bị “đơ”.

Cách tiếp cận bất đồng bộ giúp chính xác điều này: nó không chặn luồng chính — cho dù là giao diện người dùng hay logic phía máy chủ — cho phép đọc và ghi lượng dữ liệu lớn song song và làm cho ứng dụng có khả năng mở rộng khi cần làm việc với nhiều tệp cùng lúc.

Điều quan trọng cần hiểu: I/O bất đồng bộ không làm cho đĩa nhanh hơn — không có phép màu nào cả. Nó chỉ cho phép chương trình của bạn không “ngồi chờ” trong khi đĩa thực hiện thao tác, mà có thể làm những việc khác trong thời gian đó.

Cơ chế đọc bất đồng bộ hoạt động thế nào?

Kênh bất đồng bộ (AsynchronousFileChannel) không đọc từng dòng, mà đọc các khối byte vào đối tượng ByteBuffer. Điều này giống như bạn khuân các thùng chữ cái, chứ không phải từng từ. Sau khi đọc, bạn cần biến các byte này thành chuỗi — có tính đến mã hóa ký tự!

Ví dụ: đọc tệp bất đồng bộ theo khối

Hãy viết một ví dụ đơn giản đọc tệp bất đồng bộ theo từng khối 4096 byte và in nội dung ra console.

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();
                // Chuyển byte thành chuỗi (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);
            }
        }
    }
}

Những điểm quan trọng:

  • Chúng ta đọc tệp theo từng phần (theo buffer), không phải toàn bộ một lần.
  • Sau khi đọc, các byte được giải mã thành chuỗi bằng Charset.
  • Đừng quên gọi buffer.clear() — nếu không lần đọc tiếp theo sẽ không hoạt động!

Vì sao chỉ giải mã byte là chưa đủ?

Vấn đề là ký tự có thể bị “đứt đoạn” giữa hai khối, đặc biệt nếu dùng mã hóa đa byte (ví dụ, "UTF-8"). Nếu byte cuối trong buffer là một nửa của ký tự, thì khối tiếp theo sẽ bắt đầu bằng “phần còn lại” của ký tự đó. Nếu không xử lý đặc biệt, bạn sẽ nhận được “ký tự rác” hoặc thậm chí lỗi giải mã.

2. Chuyển đổi byte thành chuỗi: xử lý các đoạn bị đứt

Vấn đề đứt đoạn chuỗi

Giả sử bạn có chuỗi "Xin chào\nThế giới\n", và buffer kết thúc ở "Xin ch", còn "ào\nThế giới\n" rơi vào khối tiếp theo. Nếu chỉ nối chuỗi lại, bạn có thể mất ký tự hoặc thu được chuỗi không hợp lệ.

Giải pháp: dùng CharsetDecoder

Java cung cấp lớp CharsetDecoder, có thể xử lý chính xác các trường hợp như vậy. Nó “ghi nhớ” những byte chưa được giải mã và khôi phục chính xác các ký tự ở ranh giới giữa các khối.

Ví dụ sử dụng 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 = ... // byte của bạn
CharBuffer charBuffer = CharBuffer.allocate(buffer.capacity());
decoder.decode(buffer, charBuffer, false);
// Bây giờ charBuffer chứa các ký tự đã được giải mã chính xác

Trong bài toán thực tế, bạn sẽ lưu “phần còn lại” giữa các lần đọc và giải mã có tính đến phần còn lại đó.

3. Ghi tệp văn bản bất đồng bộ

Đọc chỉ là một nửa câu chuyện. Ghi cũng được thực hiện theo các khối byte, mà trước đó cần lấy ra từ chuỗi (mã hóa).

Ví dụ: ghi chuỗi vào tệp một cách bất đồng bộ

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 = "Xin chào, thế giới!\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);
            // Để minh họa, chờ đến khi hoàn tất (thường không nên làm!)
            future.get();
            System.out.println("Dữ liệu đã được ghi bất đồng bộ.");
        }
    }
}

Ghi chú: Trong các kịch bản bất đồng bộ thực tế, không nên gọi future.get() trong luồng chính — việc này biến mã bất đồng bộ thành đồng bộ. Tốt hơn là sử dụng CompletionHandler (xem bài giảng trước).

4. Thực hành: đọc bất đồng bộ tệp văn bản lớn và đếm dòng

Hãy hiện thực một bài toán thực tế: đọc bất đồng bộ một tệp văn bản lớn và đếm số dòng ("\n"). Kết quả — in số dòng ra console.

Ví dụ sử dụng 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) {
                    // Tệp đã được đọc đến hết
                    if (leftover.length() > 0) lines.incrementAndGet();
                    System.out.println("Số dòng trong tệp: " + 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);

                // Đếm số dòng
                int last = 0;
                int idx;
                while ((idx = chunk.indexOf('\n', last)) != -1) {
                    lines.incrementAndGet();
                    last = idx + 1;
                }
                // Phần còn lại (phần chuỗi sau ký tự \n cuối cùng)
                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("Lỗi đọc: " + exc.getMessage());
                try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
            }
        });

        // Để chương trình không kết thúc quá sớm (chỉ cho ví dụ!)
        try { Thread.sleep(2000); } catch (InterruptedException e) {}
    }
}
  • Chúng ta dùng CompletionHandler cho mã truly async.
  • Sau mỗi lần đọc, buffer được giải mã bằng CharsetDecoder.
  • Phần còn lại của dòng chưa kết thúc bằng "\n" sẽ được chuyển sang khối kế tiếp.
  • Sau khi đọc hết tệp, nếu còn gì trong leftover, thì phần đó cũng được tính là một dòng.
  • Để đơn giản, ví dụ “ngủ” 2000 ms để thao tác bất đồng bộ hoàn tất (trong ứng dụng thực tế không cần — thường có vòng lặp chính hoặc UI).

5. Ghi kết quả ra tệp một cách bất đồng bộ

Giả sử chúng ta muốn ghi kết quả (ví dụ, số dòng) vào một tệp mới — cũng theo kiểu bất đồng bộ.

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 = "Số dòng trong tệp: 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("Kết quả đã được ghi bất đồng bộ!");
                try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
            }

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

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

6. Mẹo xử lý dữ liệu bị cắt khúc và bộ mã hóa ký tự

Chuỗi bị cắt giữa các khối

Nếu một chuỗi bị chia giữa hai khối, đừng cố “nối” các byte thủ công! Hãy dùng CharsetDecoder, công cụ sẽ xử lý phần byte thiếu một cách cẩn thận và không làm mất bất kỳ ký tự nào.

Làm việc với các bộ mã hóa ký tự khác nhau

"UTF-8" là tiêu chuẩn cho các ứng dụng hiện đại, nhưng nếu tệp ở bộ mã khác (ví dụ, "Windows-1251" hoặc "UTF-16"), hãy dùng Charset tương ứng:

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

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

Sử dụng CharsetDecoder và CharsetEncoder

Khi bạn đọc hoặc ghi dữ liệu theo từng phần, việc xử lý đúng mã hóa là rất quan trọng. Một ký tự có thể bị “đứt” giữa hai khối, và nếu không có xử lý bổ sung thì sẽ thành mớ byte lộn xộn.

Để tránh điều đó, hãy dùng CharsetDecoderCharsetEncoder.

Khi đọc, gọi decode(ByteBuffer, CharBuffer, endOfInput), còn khi ghi — encode(CharBuffer, ByteBuffer, endOfInput).

Chúng đảm bảo rằng ngay cả khi một ký tự bị chia giữa hai khối, nó vẫn được ghép lại và xử lý đúng.

7. Những lỗi thường gặp khi xử lý bất đồng bộ tệp văn bản

Lỗi số 1: Bỏ qua phần còn lại của dòng. Nếu không lưu “đuôi” của dòng giữa các khối, một số dòng có thể bị mất hoặc giải mã không chính xác.

Lỗi số 2: Làm việc sai với buffer. Quên gọi buffer.clear() sau khi xử lý — lần đọc tiếp theo sẽ không hoạt động hoặc dữ liệu sẽ không đúng.

Lỗi số 3: Dùng sai bộ mã hóa ký tự. Nếu byte được giải mã bằng Charset khác với bộ dùng khi ghi tệp, có thể xuất hiện “ký tự rác” hoặc thậm chí lỗi.

Lỗi số 4: Chặn luồng chính. Nếu bạn gọi future.get() hoặc Thread.sleep() trong luồng UI, bạn đánh mất ý nghĩa của bất đồng bộ. Hãy dùng CompletionHandler và các phương pháp tiếp cận reactive.

Lỗi số 5: Không đóng kênh sau khi hoàn tất. Luôn đóng kênh (channel.close()) sau khi kết thúc mọi thao tác, ngay cả khi có lỗi.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION