Đọc và ghi tệp

Xem thử

Tình huống mở đầu

Cuối ca, chương trình ghi đơn của quán Cà Phê Gió ghi 500 đơn vào tệp orders.txt. Giữa lúc nó đang chạy, bạn mở tệp bằng trình soạn thảo và thấy tệp rỗng, dài 0 byte. Bạn nghĩ chắc chương trình chưa ghi gì. Nhưng sau khi chương trình tắt đột ngột vì mất điện, tệp lại thiếu đúng những đơn cuối cùng.

Chương trình không báo lỗi nào. Vậy 500 lệnh write đã đi đâu trong khoảng thời gian đó, và vì sao chỉ một phần dữ liệu đến được tệp? Hãy giữ câu hỏi này, mình sẽ chạy thật để bạn tự thấy.

Ôn nhanh bài trước: khi không dùng một ExecutorService nữa, bạn phải gọi phương thức nào, và nếu quên thì chuyện gì có thể xảy ra với chương trình?

Đáp án: gọi shutdown(). Nếu quên, các luồng của pool vẫn sống nên chương trình có thể không thoát. Đây cũng là bài học của hôm nay: tài nguyên đã mở thì phải được đóng đúng lúc.

Mục tiêu bài học

Sau bài này, bạn có thể:

  • Viết được chương trình ghi và đọc lại tệp văn bản theo từng dòng bằng BufferedWriter và BufferedReader trong try-with-resources.
  • Giải thích được dữ liệu của lệnh write đi qua đâu trước khi vào tệp, và khi nào độ dài tệp thật sự tăng.
  • Chọn đúng giữa ghi đè và ghi tiếp, và chỉ rõ bảng mã UTF-8 khi đọc và ghi.
  • Tìm và sửa được ba lỗi hay gặp: quên đóng luồng, gọi readLine hai lần mỗi vòng, mở tệp không tồn tại.

Bài này đo được bằng năm câu trong phần Tự kiểm tra, mỗi câu ghi rõ nó kiểm tra mục tiêu nào.

Bức tranh lớn (Big Picture)

Trước khi vào chi tiết, hãy xem một dòng chữ đi từ mã của bạn tới tệp và quay lại ra sao.

Cách đọc hình: hàng trên là chiều ghi, từ trái sang phải: mã của bạn gọi write, BufferedWriter giữ chữ trong bộ đệm, FileWriter đổi ký tự thành byte theo một bảng mã, rồi byte nằm trong tệp orders.txt ở ô vàng bên phải. Hàng dưới là chiều đọc, từ phải sang trái: FileReader đổi byte thành ký tự, BufferedReader đưa từng dòng cho mã của bạn. Màu chỉ là tín hiệu phụ, mỗi ô đều có tên chữ.

Hai điều cần nhớ từ hình. Một, giữa mã của bạn và tệp luôn có một lớp đệm, nên ghi xong chưa chắc đã vào tệp. Hai, byte trong tệp không tự biết mình là chữ gì: bảng mã quyết định.

Xuyên suốt bài, mình dùng một hình ảnh: BufferedWriter là tờ giấy nháp đặt trên bàn thư ký, còn tệp là cuốn sổ cái. Bạn viết vào giấy nháp rất nhanh, và chỉ khi thư ký chép cả tờ nháp vào sổ cái thì sổ mới có chữ. Dự án xuyên suốt là sổ ghi đơn orders.txt của quán Cà Phê Gió. Chỗ hình ảnh này không còn đúng: thư ký thật chép khi bạn bảo, còn BufferedWriter tự chép mỗi khi giấy nháp đầy, nên bạn không quyết định được đúng lúc nào.

Khái niệm cốt lõi

Bài này có bốn khái niệm. Mỗi khái niệm đi kèm một hình.

Khái niệm 1: bộ đệm, flush và close

Bộ đệm (buffer) là một vùng nhớ nhỏ mà BufferedWriter dùng để gom chữ trước khi ghi xuống tệp, giống tờ giấy nháp. flush là lệnh chép toàn bộ giấy nháp vào tệp ngay lập tức. close là lệnh đóng luồng: nó flush phần còn lại rồi trả tệp lại cho hệ điều hành.

Cách đọc hình: hàng trên là lệnh write("hello"): chữ đi vào giấy nháp (ô xanh dương), còn tệp lúc này vẫn dài 0 byte (ô đỏ, nét đứt nghĩa là chưa có gì đi qua). Hàng dưới là lệnh flush hoặc close: nó làm trống giấy nháp và ghi chữ vào tệp, nên tệp dài 5 byte (ô lục).

Đoạn thử nghiệm dưới đây đo độ dài tệp ở từng bước. Nó nằm trong file Buffering.java, dòng cuối nằm trong khối try để bắt lỗi và in thông báo.

java
BufferedWriter out = new BufferedWriter(new FileWriter(file, StandardCharsets.UTF_8));
out.write("hello");  // write chỉ đưa chữ vào bộ đệm trong bộ nhớ
System.out.println("after write: " + file.length());
out.flush();  // flush đẩy bộ đệm xuống tệp ngay
System.out.println("after flush: " + file.length());
out.write("!");
out.close();  // close tự flush phần còn lại rồi đóng luồng
System.out.println("after close: " + file.length());
out.write("more");  // luồng đã đóng nên dòng này ném IOException

Kết quả khi chạy [Đã chạy, JDK 17.0.15]:

text
after write: 0
after flush: 5
after close: 6
IOException: Stream closed
Dòng codeMáy làm gìVì sao viết như vậy
new BufferedWriter(new FileWriter(file, StandardCharsets.UTF_8))Mở tệp, rồi bọc một bộ đệm ngoài FileWriterFileWriter đổi ký tự thành byte, BufferedWriter gom chữ để không phải chạm tệp sau mỗi lệnh. Truyền UTF-8 để bảng mã rõ ràng
out.write("hello")Chép năm ký tự vào bộ đệm, trả về ngayNhanh vì chỉ làm việc trong bộ nhớ. Vì thế file.length() ngay sau đó vẫn là 0
out.flush()Đẩy toàn bộ bộ đệm xuống tệpDùng khi bạn cần chắc chắn chữ đã vào tệp ở một thời điểm cụ thể, ví dụ trước khi một chương trình khác đọc
out.write("!")Lại chỉ chép vào bộ đệmCho thấy sau flush, mọi lần ghi sau đều quay lại nằm trong bộ đệm
out.close()Flush phần còn lại rồi đóng luồngĐó là lý do tệp dài 6 byte chứ không dừng ở 5
out.write("more") sau closeNém IOException: Stream closedLuồng đã đóng không dùng lại được, phải mở luồng mới

Hãy xem từng bước máy thực hiện:

Đang tải Animation…

Bản chữ tương đương của animation:

BướcDòng chạyTrong bộ đệmĐộ dài tệp
1Mở BufferedWriter0 ký tự0 byte
2write("hello")5 ký tự0 byte
3flush()0 ký tự5 byte
4write("!")1 ký tự5 byte
5close()0 ký tự6 byte
6write("more")đã đóng6 byte, ném IOException

Concept Check 1. Bạn gọi out.write("hello") trên một BufferedWriter mới mở, chưa gọi gì thêm. Ngay sau đó, file.length() cho kết quả nào?

  • A. 5, vì lệnh write đã ghi năm ký tự xuống tệp.
  • B. 0, vì năm ký tự đang nằm trong bộ đệm chưa chép xuống tệp.
  • C. Ném IOException vì chưa gọi flush nên tệp chưa sẵn sàng.
  • D. 1, vì bộ đệm chép từng ký tự một xuống tệp.

Đáp án: B. A là hiểu lầm điển hình nhất: write trả về không có nghĩa dữ liệu đã nằm trong tệp, và hình mô hình sai ở phần Thử phá nó đi sẽ nói rõ hơn. C sai vì không ai bắt bạn gọi flush: thiếu flush chỉ làm tệp chậm có dữ liệu, không gây lỗi. D sai vì bộ đệm gom nhiều ký tự rồi mới ghi, đó là lý do nó tồn tại. Kết quả 0 đã đo thật trong Buffering.java [Đã chạy, JDK 17.0.15].

Khái niệm 2: try-with-resources đóng luồng giúp bạn

Tài nguyên (resource) là thứ phải trả lại sau khi dùng, như tệp đang mở. try-with-resources là cú pháp try (...) { ... } trong đó những gì khai báo trong ngoặc tròn sẽ được close() tự động khi khối kết thúc, dù kết thúc bình thường hay vì ngoại lệ.

Cách đọc hình: hàng trên là đường bình thường: mở luồng, chạy thân khối, rồi close() tự chạy (ô lục). Nét đứt đi xuống ô đỏ là đường có ngoại lệ: ngoại lệ được ném ra, nhưng mũi tên "close still runs" cho thấy close() vẫn chạy trước, sau đó ngoại lệ mới đi lên cho nơi gọi xử lý.

Nhờ vậy, chữ bạn đã write vẫn vào tệp ngay cả khi thân khối bị ngoại lệ cắt ngang. Trong lần thử riêng ghi Latte rồi ném IllegalStateException, tệp dài 5 byte sau khi ngoại lệ bay ra [Đã chạy, JDK 17.0.15]. Đây là lý do ví dụ nào trong bài cũng mở luồng trong ngoặc của try, và không bao giờ cần tự viết close().

Khái niệm 3: đọc từng dòng và dấu hiệu null

readLine là phương thức của BufferedReader trả về dòng tiếp theo, không kèm ký tự xuống dòng. Khi không còn dòng nào, nó trả về null, và đó là dấu hiệu duy nhất cho biết đã hết tệp.

Cách đọc hình: bốn ô nằm ngang là bốn lần gọi readLine trên tệp có ba dòng. Ba lần đầu trả về Latte, Mocha, Tea (ô lục), nhãn "next" là con trỏ đọc đi tiếp. Lần thứ tư trả về null (ô đỏ) và vòng lặp dừng ở ô lục bên dưới. Ô trắng ở dưới trái là dòng while mà bạn sẽ viết.

Quan trọng nhất: mỗi lần gọi readLine là con trỏ đi tiếp một dòng, và dòng đã lấy không quay lại. Nếu bạn gọi readLine hai lần trong một vòng, một dòng sẽ bị bỏ mất. Lỗi này nằm ở phần Thử phá nó đi.

Khái niệm 4: hai quyết định lúc mở tệp, bảng mã và chế độ ghi

Bảng mã (charset) là bảng quy ước đổi ký tự thành byte và ngược lại. UTF-8 là bảng mã phổ biến nhất: chữ không dấu chiếm 1 byte, chữ có dấu như é chiếm 2 byte. Chế độ ghi là chọn ghi đè hay ghi tiếp: new FileWriter(file, UTF_8) xoá sạch nội dung cũ ngay lúc mở, còn new FileWriter(file, UTF_8, true) giữ nội dung cũ và ghi nối vào cuối.

Cách đọc hình: chữ Café có 4 ký tự nhưng khi ghi bằng UTF-8 nó thành 5 byte (ô vàng) vì é cần 2 byte. Đọc lại bằng UTF-8 trả về đúng Café với 4 ký tự (ô lục). Đọc bằng ISO-8859-1, mỗi byte thành một ký tự riêng nên thành Café với 5 ký tự (ô đỏ).

Phần chính của chương trình thử nghiệm chỉ có ba dòng:

java
byte[] bytes = "Caf\u00e9".getBytes(StandardCharsets.UTF_8);  // chữ có dấu chiếm nhiều hơn một byte trong UTF-8
String right = new String(bytes, StandardCharsets.UTF_8);  // cùng dãy byte, đọc bằng bảng mã khác thì ra chữ khác
String wrong = new String(bytes, StandardCharsets.ISO_8859_1);

Kết quả khi chạy Charsets.java [Đã chạy, JDK 17.0.15, màn hình dùng UTF-8]:

text
4 characters took 5 bytes
UTF-8: Café (4 characters)
ISO-8859-1: Café (5 characters)

Dòng đầu cho thấy 4 ký tự mất 5 byte. Hai dòng sau cho thấy cùng 5 byte đó thành hai chuỗi khác nhau. Mình viết \u00e9 thay cho chữ é để kết quả không phụ thuộc vào cách bạn lưu file nguồn. Bài học: tệp không nhớ bảng mã của nó, nên bạn phải chỉ rõ cùng một bảng mã khi ghi và khi đọc.

Ví dụ đầu tiên (Worked Example) và "Xem từng bước"

Đây là chương trình đầu tiên của dự án: ghi ba đơn vào sổ orders.txt, đọc lại và đánh số từng dòng, rồi dọn tệp tạm.

OrderLog.java

java
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;

public class OrderLog {
    public static void main(String[] args) throws IOException {
        File dir = Files.createTempDirectory("cafe").toFile(); // thư mục tạm, không đụng tệp thật
        File file = new File(dir, "orders.txt"); // mới chỉ là một cái tên, chưa có tệp
        // try mở luồng trong ngoặc: hết khối thì close() tự chạy
        try (BufferedWriter out = new BufferedWriter(new FileWriter(file, StandardCharsets.UTF_8))) {
            out.write("Latte");
            out.newLine(); // xuống dòng để mỗi đơn một dòng
            out.write("Mocha");
            out.newLine();
            out.write("Tea");
            out.newLine();
        }
        System.out.println("exists " + file.exists()); // close đã đẩy chữ xuống tệp
        try (BufferedReader in = new BufferedReader(new FileReader(file, StandardCharsets.UTF_8))) {
            String line;
            int number = 1;
            while ((line = in.readLine()) != null) { // mỗi vòng gọi readLine đúng một lần
                System.out.println(number + ": " + line);
                number++;
            }
        }
        System.out.println("deleted " + (file.delete() && dir.delete())); // xoá tệp trước, rồi mới xoá thư mục
    }
}

Predict: hãy dừng lại, lấy giấy nháp và đoán: chương trình in ra những dòng nào, và readLine được gọi mấy lần tất cả? Đoán xong rồi hãy đọc tiếp.

Kết quả mong đợi [Đã chạy, JDK 17.0.15]:

text
exists true
1: Latte
2: Mocha
3: Tea
deleted true

Đọc code từng phần:

Dòng codeMáy làm gìVì sao viết như vậy
Files.createTempDirectory("cafe").toFile()Tạo một thư mục tạm mới trong thư mục tạm của hệ điều hành rồi đổi sang FileMọi ví dụ của bài làm việc trong thư mục tạm để không đụng tới tệp của bạn. Tên và vị trí thư mục thay đổi mỗi lần chạy [Có thể khác nhau]
new File(dir, "orders.txt")Tạo một đối tượng ghi nhớ đường dẫnFile chỉ là cái tên, chưa có tệp nào được tạo ở dòng này
try (BufferedWriter out = ...)Mở tệp để ghi và hứa sẽ đóng khi hết khốiĐây là khái niệm 2: mở trong ngoặc thì close() tự chạy, kể cả khi lỗi
out.write("Latte"); out.newLine();Chép chữ vào bộ đệm, rồi chép ký tự xuống dòng của hệ điều hànhwrite không tự xuống dòng, nên mỗi đơn cần một newLine() để nằm trên dòng riêng
file.exists()Hỏi hệ điều hành tệp có tồn tại khôngChạy sau khối ghi, nên chữ đã được close đưa vào tệp
while ((line = in.readLine()) != null)Gán dòng vừa đọc vào line rồi so với null trong một biểu thứcGọi readLine đúng một lần mỗi vòng, và null chính là lúc dừng
file.delete() && dir.delete()Xoá tệp trước, rồi xoá thư mục nếu tệp đã xoá đượcThư mục chỉ xoá được khi rỗng, nên phải xoá tệp trước. Cả hai trả về boolean thay vì ném lỗi

Bây giờ xem từng bước. Mỗi bước nêu dòng code đang chạy, trạng thái bộ đệm, tệp và con trỏ đọc:

Đang tải Animation…

Bảng trace (bản chữ tương đương của animation, [Suy luận] bằng cách đi từng dòng và khớp với kết quả chạy thật):

Dòng chạyTrong bộ đệmTệp trên đĩaIn ra màn hình
new File(dir, "orders.txt")không cóchưa có tệpkhông
mở BufferedWriterrỗngtệp rỗngkhông
ghi Latte, Mocha, Tea3 dòngtệp rỗngkhông
hết khối try, close() chạyrỗng3 dòngkhông
println("exists " + ...)rỗng3 dòngexists true
readLine lần 1 đến 3rỗng3 dòng1: Latte, 2: Mocha, 3: Tea
readLine lần 4 trả về nullrỗng3 dòngkhông
xoá tệp và thư mụcrỗngđã xoádeleted true

Bạn vừa làm được: đọc một chương trình ghi và đọc tệp và nói đúng lúc nào tệp có dữ liệu, lúc nào readLine trả về null.

Concept Check xen kẽ

Chọn đáp án của bạn trước, rồi đọc phần đáp án ngay bên dưới mỗi câu.

Concept Check 2. Trong khối try (BufferedWriter out = ...), bạn gọi out.write("Latte") rồi một dòng khác ném IllegalStateException. Điều gì xảy ra với out?

  • A. out vẫn mở vì ngoại lệ bỏ qua mọi lệnh còn lại, kể cả close.
  • B. out bị đóng nhưng chữ Latte mất vì chưa kịp flush.
  • C. close() chỉ chạy nếu bạn viết thêm khối catch bắt ngoại lệ đó.
  • D. close() chạy trước, flush Latte vào tệp, rồi ngoại lệ mới đi lên.

Đáp án: D. Cú pháp try (...) bảo đảm close() chạy trên mọi đường thoát, và close() của BufferedWriter flush trước khi đóng. A là cách nghĩ về try thường, không có ngoặc tròn. B sai vì chính close() mới là nơi flush. C sai vì close() không phụ thuộc vào catch. Kết quả tệp dài 5 byte đã đo trong lần thử [Đã chạy, JDK 17.0.15].

Concept Check 3. Tệp có đúng ba dòng. Một vòng lặp gọi in.readLine() liên tiếp. Lần gọi thứ tư trả về gì?

  • A. null, vì không còn dòng nào để đọc.
  • B. Chuỗi rỗng "", vì cuối tệp có ký tự xuống dòng.
  • C. Ném EOFException để báo hết tệp.
  • D. Ném IOException vì con trỏ đã vượt quá tệp.

Đáp án: A. readLine dùng giá trị null làm dấu hiệu hết tệp. B nhầm với dòng rỗng thật: một dòng không có chữ vẫn là một dòng và cho "", còn hết tệp thì cho null. C và D nghe có lý nhưng readLine chọn cách trả null để vòng while viết gọn, và không có ngoại lệ nào ở đây.

Làm cùng mình (Guided Practice)

Bạn sẽ dựng dần class ShiftLog qua năm bước nhỏ, mỗi bước thêm vào chương trình của bước trước, hỗ trợ giảm dần: bước 1 gần như làm sẵn, bước 5 chỉ còn đề. Tạo file ShiftLog.java có hàm main khai báo throws IOException, với các dòng import java.io.*;, import java.nio.charset.StandardCharsets;, import java.nio.file.Files;, import java.util.ArrayList; và import java.util.List;. Chạy bằng java ShiftLog.java.

Bước 1: ghi ba đơn vào tệp.

java
File dir = Files.createTempDirectory("cafe").toFile();  // thư mục tạm
File file = new File(dir, "shift.txt");
try (BufferedWriter out = new BufferedWriter(new FileWriter(file, StandardCharsets.UTF_8))) {
    out.write("Latte");
    out.newLine();
    // TODO 1: ghi thêm "Mocha" rồi "Tea", mỗi đơn một dòng
}
System.out.println("step 1: " + file.length());

Chạy xong bạn sẽ thấy: step 1: 16 trên Linux và macOS, còn Windows cho 19 vì newLine() ghi hai byte [Có thể khác nhau].

Bước 2: đọc lại tất cả các dòng. Thêm phương thức này vào class, ngoài hàm main, rồi gọi nó:

java
static List<String> read(File file) throws IOException {
    List<String> lines = new ArrayList<>();
    try (BufferedReader in = new BufferedReader(new FileReader(file, StandardCharsets.UTF_8))) {
        // TODO 2: đọc từng dòng cho tới khi readLine trả về null, thêm vào lines
    }
    return lines;
}

Trong main, thêm dòng System.out.println("step 2: " + read(file));. Chạy xong bạn sẽ thấy: step 2: [Latte, Mocha, Tea].

Vẽ ra giấy: kẻ một cột bốn ô ghi kết quả của bốn lần readLine, rồi điền. Đối chiếu: lần 1 Latte, lần 2 Mocha, lần 3 Tea, lần 4 null. Hình readLine ở khái niệm 3 là cùng ý.

Bước 3: ghi tiếp một đơn Latte vào cuối tệp.

java
// TODO 3: mở FileWriter ở chế độ ghi tiếp và ghi thêm "Latte" thành một dòng
System.out.println("step 3: " + read(file));

Chạy xong bạn sẽ thấy: step 3: [Latte, Mocha, Tea, Latte]. Gợi ý: chế độ ghi tiếp là tham số thứ ba của FileWriter, và nhớ bọc trong try-with-resources.

Bước 4: đếm số đơn Latte. Bạn tự quyết cách viết.

java
int lattes = 0;
// TODO 4: duyệt read(file) và đếm số dòng bằng "Latte"
System.out.println("step 4: " + lattes);

Chạy xong bạn sẽ thấy: step 4: 2. Gợi ý: vòng for-each với equals là đủ.

Bước 5: bỏ mọi đơn Latte và ghi lại cả tệp.

java
// TODO 5: bỏ mọi dòng "Latte" rồi ghi lại cả tệp, ghi đè
System.out.println("step 5: " + read(file));

Chạy xong bạn sẽ thấy: step 5: [Mocha, Tea].

Vẽ ra giấy: vẽ tệp trước và sau bước 5. Đối chiếu: chế độ ghi đè xoá sạch nội dung cũ ngay lúc mở, nên bạn phải đọc nội dung ra danh sách trước, lọc, rồi mới mở luồng ghi. Nếu mở luồng ghi trước, bạn đã tự xoá dữ liệu cần đọc. Gợi ý: removeIf quen thuộc từ bài List. Lời giải đầy đủ nằm ở cuối bài.

Bạn vừa làm được: tự dựng một chương trình ghi, ghi tiếp, đọc và ghi đè tệp qua năm bước. Toàn bộ mã nguồn của bài cũng nằm trong thư mục 16-tep/01-java-io của kho GitHub, nhưng bạn không cần mở nó để học.

Thử phá nó đi (Lỗi thường gặp)

Bốn lỗi dưới đây là những lỗi người mới gặp nhiều nhất với java.io. Mỗi dòng trong bảng đã được chạy thật trong Breakages.java [Đã chạy, JDK 17.0.15; chữ trong ngoặc ở lỗi thứ hai phụ thuộc hệ điều hành, nên có thể khác].

Code lỗiThông báo lỗiNguyên nhân bằng lời thườngCách sửa
out.write("hello"); rồi không close, rồi hỏi file.length()Không báo lỗi, file.length() cho 0Chữ còn nằm trong giấy nháp, chưa ai chép vào sổMở luồng trong try (...) để close() tự chạy
new FileReader(new File(dir, "missing.txt"), UTF_8)FileNotFoundException: <dir>/missing.txt (No such file or directory)Đường dẫn trỏ tới tệp không tồn tạiKiểm tra đường dẫn, hoặc file.exists() trước khi mở
Mở new FileWriter(file, UTF_8) lần hai để ghi threeKhông báo lỗi, tệp chỉ còn [three]Không có tham số true nên chế độ mặc định là ghi đè và xoá sạch nội dung cũnew FileWriter(file, UTF_8, true) để ghi tiếp
while (in.readLine() != null) { println(in.readLine()); } trên ba dòngIn ra Mocha rồi nullMỗi vòng gọi readLine hai lần nên Latte và Tea bị nuốt, và lần gọi thứ hai có thể trả về nullLưu kết quả vào biến: while ((line = in.readLine()) != null)

Lỗi đầu tiên là lỗi lớn nhất vì nó im lặng: chương trình chạy trọn vẹn nhưng tệp thiếu chữ. Hình dưới đây gom mô hình sai và mô hình đúng về lệnh write lại một chỗ.

Cách đọc hình: hàng trên màu đỏ nhạt là mô hình sai: nhiều người tưởng write ghi thẳng vào tệp nên file.length() cho 5, kể cả khi chương trình sập. Hàng dưới màu lục là mô hình đúng: chữ nằm trong bộ đệm trong bộ nhớ, file.length() cho 0 cho tới khi flush hoặc close.

Mô hình đúng này giải thích luôn tình huống mở đầu: tệp rỗng giữa chừng và thiếu đơn cuối là hai mặt của cùng một chuyện, những đơn cuối còn nằm trong giấy nháp khi điện tắt.

Bạn vừa làm được: đọc bốn lỗi phổ biến của java.io và nói được nguyên nhân bằng lời thường.

Tự tay làm (Bài tập)

Làm lần lượt từng mức. Mỗi bài có đề, dữ liệu vào, kết quả mong đợi, tiêu chí nghiệm thu và gợi ý. Lời giải nằm riêng ở cuối bài, hãy thử trước rồi mới so.

Mức Cơ bản

Bài 1. Vẽ trạng thái của tệp. Dữ liệu vào: một tệp t.txt chưa tồn tại, rồi ba khối chạy lần lượt. Khối 1 mở new FileWriter(f, UTF_8), ghi "A\n" rồi "B\n", đóng. Khối 2 mở new FileWriter(f, UTF_8, true), ghi "C\n", đóng. Khối 3 mở new FileWriter(f, UTF_8), ghi "D\n", đóng. Điền bảng ba cột: sau khối nào, các dòng trong tệp, độ dài tệp tính bằng byte. Kết quả mong đợi: một bảng ba dòng. Tiêu chí nghiệm thu: bạn nói được khối nào làm mất dữ liệu cũ và vì sao. Gợi ý: mỗi "X\n" chiếm 2 byte, và chỉ khối có tham số true mới giữ nội dung cũ.

Bài 2. Đếm dòng chứa một từ. Dữ liệu vào: tệp sales.txt có bốn dòng Latte 2, Mocha 1, Latte 3, Tea 1. Viết phương thức static int countLines(File file, String word) trả về số dòng chứa word. Kết quả mong đợi: countLines(file, "Latte") cho 2 và countLines(file, "Cocoa") cho 0. Tiêu chí nghiệm thu: dùng try-with-resources, gọi readLine đúng một lần mỗi vòng. Gợi ý: phương thức contains của String kiểm tra một dòng có chứa chữ cần tìm không.

Bài 3. Sửa tệp cứ rỗng. Dữ liệu vào: đoạn code sau, chạy xong file.length() cho 0.

java
BufferedWriter out = new BufferedWriter(new FileWriter(file, StandardCharsets.UTF_8));
out.write("Latte\n");
out.write("Mocha\n");
System.out.println("length " + file.length());  // hai dòng trên còn nằm trong bộ đệm khi dòng này chạy

Kết quả mong đợi sau khi sửa: in ra length 12. Tiêu chí nghiệm thu: bạn giải thích được vì sao bản gốc cho 0, và bản sửa không gọi flush bằng tay. Gợi ý: để dòng println ở ngoài khối try, sau khi khối kết thúc.

Mức Vận dụng

Bài 4. Class OrderStore cho sổ ghi đơn. Viết class OrderStore giữ một File bên trong với ba phương thức:

Phương thứcViệc phải làm
void append(String order)Ghi tiếp một đơn thành một dòng vào cuối tệp
List<String> load()Đọc mọi dòng, trả về danh sách rỗng nếu tệp chưa tồn tại
int remove(String order)Bỏ mọi dòng bằng order, ghi lại tệp, trả về số dòng đã bỏ

Dữ liệu vào là hàm main sau (thêm vào class của bạn):

java
OrderStore store = new OrderStore(new File(dir, "orders.txt"));
System.out.println("empty: " + store.load());  // tệp chưa tồn tại, load phải trả về danh sách rỗng
store.append("Latte");
store.append("Mocha");
store.append("Latte");
System.out.println("loaded: " + store.load());
System.out.println("removed Latte: " + store.remove("Latte") + " " + store.load());
System.out.println("removed Cocoa: " + store.remove("Cocoa") + " " + store.load());

Kết quả mong đợi:

text
empty: []
loaded: [Latte, Mocha, Latte]
removed Latte: 2 [Mocha]
removed Cocoa: 0 [Mocha]

Tiêu chí nghiệm thu: bốn dòng in ra khớp từng ký tự, mọi luồng mở trong try-with-resources, và load() không ném lỗi khi tệp chưa có. Gợi ý: remove nên đọc cả tệp ra danh sách trước rồi mới mở luồng ghi, vì mở luồng ghi sẽ xoá nội dung.

Mức Thử thách (tuỳ chọn)

Bài 5. Đếm số lần đọc khi sao chép tệp nhị phân. Dữ liệu vào: một tệp data.bin có 10000 byte. Viết hai phương thức sao chép dùng FileInputStream và FileOutputStream: một cái đọc từng byte bằng in.read(), một cái đọc theo khối bằng mảng byte[4096]. Đếm số lần gọi read, kể cả lần cuối trả về -1. Kết quả mong đợi: 10001 lần cho cách đọc từng byte và 4 lần cho cách đọc theo khối, cả hai bản sao giống hệt tệp gốc. Tiêu chí nghiệm thu: bạn giải thích được vì sao con số là 4 chứ không phải 3. Gợi ý: 10000 chia cho 4096 được hai khối đầy và một khối lẻ, và phải có thêm một lần đọc để biết đã hết dữ liệu.

Tự kiểm tra (Retrieval Practice)

Trả lời bằng trí nhớ, không xem lại bài. Mỗi câu ghi rõ nó kiểm tra mục tiêu nào trong bốn mục tiêu ở đầu bài.

Câu 1 (nhớ lại, mục tiêu 2). Một chương trình ghi 10 dòng bằng BufferedWriter rồi kết thúc mà không gọi close. Kể ra hai lý do vì sao tệp có thể thiếu dòng.

Câu 2 (dự đoán kết quả code, mục tiêu 1). Tệp có hai dòng A và B. Ba lần gọi liên tiếp System.out.println(in.readLine()) in ra gì?

Câu 3 (dự đoán trạng thái sau nhiều bước, mục tiêu 3). Tệp đang có hai dòng one và two. Bạn chạy lần lượt: mở new FileWriter(f, UTF_8) rồi đóng ngay mà không ghi gì, sau đó mở new FileWriter(f, UTF_8, true) ghi "x\n". Độ dài tệp sau bước một là bao nhiêu, và các dòng cuối cùng là gì?

Câu 4 (tìm lỗi, mục tiêu 3 và 4). Người dùng ghi tệp bằng UTF-8 nhưng đọc lại bằng new FileReader(file, StandardCharsets.ISO_8859_1), và chữ Café hiện thành Café. Chỉ ra lỗi và sửa.

Câu 5 (giải thích cho bạn chưa học, mục tiêu 2). Bạn của bạn hỏi: "Mình gọi lệnh ghi rồi mà mở tệp thấy trống, máy hỏng hả?" Hãy giải thích bằng hình ảnh giấy nháp và sổ cái, và nói bạn ấy nên sửa code thế nào.

Điều cần nhớ

  • Ghi và đọc văn bản theo dòng bằng BufferedWriter, BufferedReader, luôn mở trong try-with-resources và dừng vòng đọc khi readLine trả về null (mục tiêu 1).
  • write chỉ đưa chữ vào bộ đệm, tệp chỉ dài ra sau flush hoặc close (mục tiêu 2).
  • Không có tham số true thì FileWriter ghi đè và xoá nội dung cũ ngay lúc mở, và luôn chỉ rõ UTF-8 khi ghi và đọc (mục tiêu 3).
  • Ba lỗi hay gặp là quên đóng luồng, gọi readLine hai lần mỗi vòng, và mở tệp không tồn tại (mục tiêu 4).

Cheat sheet một trang.

Việc cần làmCú phápGhi chú
Ghi đè tệpnew FileWriter(file, UTF_8)Xoá nội dung cũ ngay lúc mở
Ghi tiếpnew FileWriter(file, UTF_8, true)Giữ nội dung cũ
Thêm bộ đệmnew BufferedWriter(writer)Nhanh hơn, nhưng tệp có thể chậm hơn code
Xuống dòngout.newLine()Một byte trên Linux và macOS, hai byte trên Windows
Đẩy xuống tệp ngayout.flush()close() đã làm việc này
Đọc từng dòngin.readLine()null khi hết tệp, không kèm ký tự xuống dòng
Đóng luồng chắc chắntry (... ) { ... }close() chạy cả khi có ngoại lệ
Tạo tệp tạm để thửFiles.createTempDirectory("cafe")Không đụng vào tệp thật của bạn
Hình: tóm tắt chọn công cụ nào cho việc nào với tệp văn bản

Cách đọc hình: mỗi hàng là một việc bên trái và công cụ nên dùng bên phải. Đọc theo hàng ngang: ghi đè thì FileWriter không có true, ghi tiếp thì có true, đọc theo dòng thì readLine và dừng khi null. Mỗi ô đều có nhãn chữ, màu chỉ giúp bạn tìm nhanh.

Câu hỏi để dành cho ngày mai: để ghi một chuỗi ra tệp, bạn vừa phải viết FileWriter, BufferedWriter, try, newLine. Nếu có một lệnh duy nhất làm hết việc đó trong một dòng, bạn đoán nó trông thế nào và nó phải xử lý những gì thay bạn?

Đào sâu

Phần này dành cho bạn tò mò. Bỏ qua cũng không thiếu gì ở trên.

Bên trong bộ đệm. BufferedWriter giữ một mảng ký tự, và FileWriter bên dưới còn có một bộ đệm byte riêng, nên thực tế có hai tầng giấy nháp. Trong lần chạy Deeper.java của người biên soạn [Đã chạy, JDK 17.0.15], ghi 8193 ký tự vẫn chưa làm tệp dài thêm byte nào trước khi đóng, còn ghi 16384 ký tự thì tệp đã dài 8192 byte trước khi đóng. Các ngưỡng này là chi tiết cài đặt của OpenJDK, không nằm trong hợp đồng, nên đừng viết code dựa vào chúng.

So với lựa chọn khác. Bài sau giới thiệu java.nio.file.Files, nơi một dòng như Files.writeString làm hết việc mở, ghi, đóng. Cách viết trong bài này vẫn đáng biết vì nhiều code cũ dùng nó, và vì nó cho thấy từng tầng của việc ghi tệp.

Hiểu lầm nâng cao. Nhiều người tưởng flush() bảo đảm dữ liệu đã nằm trên đĩa vật lý. Không hẳn: flush() đưa dữ liệu từ Java xuống hệ điều hành, còn hệ điều hành có thể giữ nó thêm một lúc trong bộ nhớ của nó. Với dữ liệu cực kỳ quan trọng cần công cụ riêng, và bài này chưa nói đến.

Luồng byte và byte thành số nguyên. Với dữ liệu nhị phân, FileInputStream.read() trả về byte dưới dạng số từ 0 đến 255. Lần thử ghi byte 200 cho thấy Java thấy giá trị lưu là -56, read() trả 200, và hết dữ liệu trả -1 [Đã chạy, JDK 17.0.15]. Lý do: để dành -1 làm dấu hiệu hết dữ liệu mà không nhầm với một byte thật.

Giới hạn của File. File.delete(), mkdir() báo thất bại bằng false chứ không ném lỗi. Trong lần chạy riêng, mkdir trên đường dẫn thiếu thư mục cha cho false, mkdirs cho true, và delete trên thư mục còn tệp cho false mà không có ngoại lệ [Đã chạy, JDK 17.0.15]. Vì vậy không kiểm tra giá trị trả về thì lỗi sẽ im lặng.

Giới hạn của bài. Bài này chưa nói về Scanner đọc tệp, tuần tự hoá đối tượng, hay tệp rất lớn cần đọc theo khối.

Đọc thêm (tài liệu chính thống, Java SE 17):

Bài tiếp theo

Bài sau là NIO và java.nio.file: cách hiện đại để đọc, ghi, sao chép và duyệt thư mục chỉ với Path và Files, thường chỉ một dòng cho mỗi việc, và những lỗi báo rõ hơn.

Mini-dự án nối tiếp: ở bài sau, sổ ghi đơn của quán sẽ được chuyển thành báo cáo ngày nằm trong một thư mục theo tháng, có bản sao lưu. Hãy giữ lại class OrderStore bạn vừa viết để so sánh độ dài code hai cách.

Lời giải và đáp án

Hãy thử xong rồi mới đọc phần này.

Lời giải Làm cùng mình. Thay các dòng // TODO bằng những dòng dưới đây, ghép lại chạy ra đúng năm dòng step ở trên [Đã chạy, JDK 17.0.15]:

java
out.write("Mocha");
out.newLine();
out.write("Tea");
out.newLine();

String line;
while ((line = in.readLine()) != null) {  // readLine trả về null khi hết tệp
    lines.add(line);
}

try (BufferedWriter out = new BufferedWriter(new FileWriter(file, StandardCharsets.UTF_8, true))) {  // true: ghi tiếp
    out.write("Latte");
    out.newLine();
}
for (String line : read(file)) {
    if (line.equals("Latte")) {
        lattes++;
    }
}

List<String> kept = read(file);  // đọc và lọc trong bộ nhớ trước, rồi mới mở luồng ghi đè
kept.removeIf(line -> line.equals("Latte"));
try (BufferedWriter out = new BufferedWriter(new FileWriter(file, StandardCharsets.UTF_8))) {
    for (String line : kept) {
        out.write(line);
        out.newLine();
    }
}
Dòng codeMáy làm gìLý do chọn cách này
out.write("Mocha"); out.newLine();Chép chữ rồi xuống dòng vào bộ đệmMỗi đơn một dòng riêng để readLine tách được
while ((line = in.readLine()) != null)Đọc một dòng, gán vào line, dừng khi nullGọi readLine đúng một lần mỗi vòng
new FileWriter(file, UTF_8, true)Mở tệp ở chế độ ghi tiếpTham số true giữ ba dòng cũ
vòng for-each với lattes++Duyệt danh sách đọc từ tệp, đếm dòng bằng LatteChỉ đọc, nên an toàn
kept.removeIf(...) rồi ghi lạiLọc trong bộ nhớ, rồi mở luồng ghi đèĐọc trước, ghi sau, vì ghi đè xoá nội dung cũ ngay lúc mở

Lời giải Bài 1. [Suy luận, khớp với kết quả chạy thật của Trace.java]

Sau khốiCác dòng trong tệpĐộ dài
Khối 1[A, B]4 byte
Khối 2[A, B, C]6 byte
Khối 3[D]2 byte

Khối 3 làm mất dữ liệu cũ vì không có tham số true, nên tệp bị xoá sạch ngay lúc mở rồi mới ghi D.

Lời giải Bài 2. [Đã chạy, in ra Latte 2 và Cocoa 0] Vòng đọc là cùng khuôn với read ở bước 2 của Làm cùng mình, chỉ thêm điều kiện if (line.contains(word)) { count++; } và trả về count. Mở luồng trong try-with-resources để luồng đóng cả khi tệp có lỗi.

Lời giải Bài 3. [Đã chạy, in ra length 12] Bản gốc cho 0 vì hai dòng còn nằm trong bộ đệm. Sửa bằng cách mở luồng trong try (...), rồi đưa dòng println ra sau khối:

java
try (BufferedWriter out = new BufferedWriter(new FileWriter(file, StandardCharsets.UTF_8))) {
    out.write("Latte\n");
    out.write("Mocha\n");
}
System.out.println("length " + file.length());  // hết khối try thì close đã flush hai dòng xuống tệp

Khi khối try kết thúc, close() flush hai dòng xuống tệp, nên độ dài là 6 cộng 6 bằng 12 byte.

Lời giải Bài 4. [Đã chạy, bốn dòng khớp với kết quả mong đợi] Phương thức đáng đọc kỹ là remove:

java
public int remove(String order) throws IOException {
    List<String> orders = load();  // đọc trước, vì ghi đè xoá tệp ngay
    int before = orders.size();
    orders.removeIf(o -> o.equals(order));
    try (BufferedWriter out = new BufferedWriter(new FileWriter(file, StandardCharsets.UTF_8))) {
        for (String o : orders) {
            out.write(o);
            out.newLine();
        }
    }
    return before - orders.size();  // chênh lệch hai lần đếm
}
Dòng codeMáy làm gìLý do chọn cách này
List<String> orders = load();Đọc cả tệp ra danh sáchPhải đọc trước, vì lát nữa mở luồng ghi đè sẽ xoá tệp
orders.removeIf(...)Bỏ mọi đơn bằng order trong bộ nhớMột lần duyệt an toàn, không cần vòng for theo chỉ số
mở FileWriter không có trueGhi đè tệp bằng danh sách đã lọcTệp mới đúng bằng danh sách mới
return before - orders.size()Lấy chênh lệch hai lần đếmremoveIf không cho biết đã bỏ bao nhiêu

append mở FileWriter với tham số true rồi ghi order và newLine(). load kiểm tra file.exists() trước, nếu chưa có thì trả về danh sách rỗng, nếu có thì dùng cùng vòng readLine như bước 2.

Lời giải Bài 5. [Đã chạy, in ra byte by byte: 10001 read calls và 4096 block: 4 read calls] Cách đọc theo khối:

java
byte[] buffer = new byte[4096];
int n;
do {
    n = in.read(buffer);  // mỗi lần read lấy tới 4096 byte và trả về -1 khi hết
    calls++;
    if (n != -1) {
        out.write(buffer, 0, n);
    }
} while (n != -1);

read(buffer) trả về số byte đã đọc, nên ghi lại đúng n byte đầu của mảng. Ba lần đầu trả về 4096, 4096, 1808 và lần thứ tư trả về -1, nên có 4 lần gọi. Cách đọc từng byte có 10000 lần có dữ liệu cộng một lần -1 là 10001. Cả hai bản sao giống hệt tệp gốc.

Đáp án Tự kiểm tra.

  1. Lý do một: chữ còn nằm trong bộ đệm chưa flush nên chưa ra tệp. Lý do hai: không gọi close nên luồng không bao giờ đẩy nốt phần còn lại. Cách chắc ăn là dùng try-with-resources. Kiểm tra mục tiêu 2.
  2. In ra A, B, rồi null [Đã chạy, trong Selfcheck.java]. Hai lần đầu lấy hai dòng, lần ba hết tệp nên trả về null. Kiểm tra mục tiêu 1.
  3. Sau bước một, tệp dài 0 byte vì FileWriter không có true xoá nội dung ngay lúc mở dù chưa ghi gì. Sau bước hai, các dòng là [x] [Đã chạy, trong Selfcheck.java]. Kiểm tra mục tiêu 3.
  4. Lỗi nằm ở bảng mã khi đọc: tệp ghi bằng UTF-8 nhưng đọc bằng ISO-8859-1, nên é gồm hai byte bị đọc thành hai ký tự. Sửa bằng new FileReader(file, StandardCharsets.UTF_8) để đọc bằng đúng bảng mã đã ghi [Suy luận, khớp với Charsets.java]. Kiểm tra mục tiêu 3 và 4.
  5. Lệnh ghi chỉ chép chữ vào giấy nháp, còn sổ cái là tệp chưa có chữ. Máy không hỏng. Cần gọi close() hoặc flush() để chép từ nháp vào sổ, và cách đơn giản nhất là mở luồng trong try-with-resources để close() tự chạy. Kiểm tra mục tiêu 2.