CodeGym /課程 /JAVA 25 SELF /非同步處理文字檔案

非同步處理文字檔案

JAVA 25 SELF
等級 56 , 課堂 2
開放

1. 分段讀取檔案:ByteBuffer 與編碼

如今我們很少處理小型的文字檔。通常面對的是龐大的伺服器日誌、報表、CSV 檔,或是數 GB 的資料傾印。因此,重點不只是在於讀取檔案,而是要有效率地讀,且不讓應用程式「卡住」。

非同步作法正是為此而生: 它不會阻塞主執行緒 — 無論是介面或伺服端邏輯, — 允許平行讀寫大量資料,並在需要同時處理多個檔案時,讓應用程式更具延展性。

要弄清的一點是: 非同步 I/O 並不會讓磁碟本身更快 — 沒有奇蹟。它只是讓你的程式在等待磁碟完成操作時,不必發呆,可以去做其他事情。

非同步讀取如何運作?

非同步通道 (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);
            int position = 0;
            Future<Integer> future = channel.read(buffer, position);

            while (future.get() > 0) {
                buffer.flip();
                // 將位元組解碼為字串 (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);
            }
        }
    }
}

重點:

  • 以分段(緩衝區)方式讀取檔案,而非一次讀完整個檔案。
  • 讀取後,使用 Charset 將位元組解碼為字串。
  • 別忘了呼叫 buffer.clear() —— 否則下一次 read 不會生效!

為什麼僅僅解碼位元組還不夠?

問題在於,字元可能會跨兩個區塊「被切開」,特別是使用多位元組編碼(例如,"UTF-8")時。如果緩衝區中的最後一個位元組只是某個字元的一半,下一個區塊就會以該字元的「剩餘部分」開始。若沒有特別處理,你會得到亂碼,甚至發生解碼錯誤。

2. 位元組轉字串: 處理斷裂

行被分割的問題

假設你有字串 "你好\n世界\n",而緩衝區剛好結束在 "你",而 "好\n世界\n" 落在下一個區塊。若只是把字串硬拼起來,可能會遺失字元或得到不正確的字串。

解法: 使用 CharsetDecoder

Java 提供了 CharsetDecoder 類別,能正確處理此類情形。它會「記住」尚未解碼的位元組,並在區塊邊界正確還原字元。

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 已含有正確解碼的字元

在實務中,你會在每次讀取之間保留「剩餘」資料,並在解碼時一併考量。

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.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) {
                    // 檔案已讀到結尾
                    if (leftover.length() > 0) lines.incrementAndGet();
                    System.out.println("檔案中的行數: " + 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);

                // 計算行數
                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));
                }
                buffer.clear();
                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" 結尾的行之剩餘部分會帶到下一個區塊。
  • 讀到檔案結尾後,若 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,它會妥善處理缺少的位元組,不遺漏任何字元。

處理不同編碼

"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

當你以分段方式讀寫資料時,正確處理編碼非常重要。字元可能會被拆在兩個區塊之間,若沒有額外處理,就會變成一團亂碼。

為了避免這種情況,請使用 CharsetDecoderCharsetEncoder

讀取時呼叫 decode(ByteBuffer, CharBuffer, endOfInput),寫入時呼叫 encode(CharBuffer, ByteBuffer, endOfInput)。

它們會確保即使某個字元被分割在兩個區塊之間,也依然能被正確組裝與處理。

7. 非同步處理文字檔的常見錯誤

錯誤 №1: 忽略行的剩餘部分。 如果不在區塊之間保存行尾的「尾巴」,部分行可能會遺失或被錯誤解碼。

錯誤 №2: 不正確的緩衝區操作. 忘了呼叫 buffer.clear() 之後再處理 —— 下一次 read 可能無法運作或資料會不正確。

錯誤 №3: 使用了不相符的編碼. 若以與檔案實際編碼不一致的 Charset 來解碼位元組,可能會出現亂碼,甚至錯誤。

錯誤 №4: 阻塞主執行緒. 如果你在 UI 執行緒呼叫 future.get()Thread.sleep(),就失去了非同步的意義。請使用 CompletionHandler 與反應式作法。

錯誤 №5: 操作完成後未關閉通道. 無論是否發生錯誤,所有操作完成後務必關閉通道(channel.close())。

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