Đọ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
BufferedWritervàBufferedReadertrongtry-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
readLinehai 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.
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]:
after write: 0
after flush: 5
after close: 6
IOException: Stream closed
| Dòng code | Má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 FileWriter | FileWriter đổ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ề ngay | Nhanh 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ệp | Dù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ộ đệm | Cho 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 close | Ném IOException: Stream closed | Luồ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ước | Dòng chạy | Trong bộ đệm | Độ dài tệp |
|---|---|---|---|
| 1 | Mở BufferedWriter | 0 ký tự | 0 byte |
| 2 | write("hello") | 5 ký tự | 0 byte |
| 3 | flush() | 0 ký tự | 5 byte |
| 4 | write("!") | 1 ký tự | 5 byte |
| 5 | close() | 0 ký tự | 6 byte |
| 6 | write("more") | đã đóng | 6 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
IOExceptionvì chưa gọiflushnê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:
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]:
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
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]:
exists true
1: Latte
2: Mocha
3: Tea
deleted true
Đọc code từng phần:
| Dòng code | Má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 File | Mọ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ẫn | File 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ành | write 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ông | Chạ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ức | Gọ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á được | Thư 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ạy | Trong bộ đệm | Tệp trên đĩa | In ra màn hình |
|---|---|---|---|
new File(dir, "orders.txt") | không có | chưa có tệp | không |
mở BufferedWriter | rỗng | tệp rỗng | không |
| ghi Latte, Mocha, Tea | 3 dòng | tệp rỗng | không |
hết khối try, close() chạy | rỗng | 3 dòng | không |
println("exists " + ...) | rỗng | 3 dòng | exists true |
readLine lần 1 đến 3 | rỗng | 3 dòng | 1: Latte, 2: Mocha, 3: Tea |
readLine lần 4 trả về null | rỗng | 3 dòng | không |
| xoá tệp và thư mục | rỗ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.
outvẫn mở vì ngoại lệ bỏ qua mọi lệnh còn lại, kể cảclose. - B.
outbị đó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ốicatchbắ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
IOExceptionvì 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.
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ó:
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.
// 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.
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.
// 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ỗi | Thông báo lỗi | Nguyên nhân bằng lời thường | Cá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 0 | Chữ 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ại | Kiểm tra đường dẫn, hoặc file.exists() trước khi mở |
Mở new FileWriter(file, UTF_8) lần hai để ghi three | Khô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òng | In ra Mocha rồi null | Mỗ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ề null | Lư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.
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ức | Việ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):
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:
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ở trongtry-with-resourcesvà dừng vòng đọc khireadLinetrả vềnull(mục tiêu 1). writechỉ đưa chữ vào bộ đệm, tệp chỉ dài ra sauflushhoặcclose(mục tiêu 2).- Không có tham số
truethìFileWriterghi đè 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
readLinehai 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àm | Cú pháp | Ghi chú |
|---|---|---|
| Ghi đè tệp | new FileWriter(file, UTF_8) | Xoá nội dung cũ ngay lúc mở |
| Ghi tiếp | new FileWriter(file, UTF_8, true) | Giữ nội dung cũ |
| Thêm bộ đệm | new BufferedWriter(writer) | Nhanh hơn, nhưng tệp có thể chậm hơn code |
| Xuống dòng | out.newLine() | Một byte trên Linux và macOS, hai byte trên Windows |
| Đẩy xuống tệp ngay | out.flush() | close() đã làm việc này |
| Đọc từng dòng | in.readLine() | null khi hết tệp, không kèm ký tự xuống dòng |
| Đóng luồng chắc chắn | try (... ) { ... } | 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 |
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):
- The Java Tutorials: I/O Streams
- Javadoc: java.io.BufferedWriter
- Javadoc: java.io.BufferedReader
- Javadoc: java.io.FileWriter
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]:
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 code | Má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ộ đệm | Mỗ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 null | Gọi readLine đúng một lần mỗi vòng |
new FileWriter(file, UTF_8, true) | Mở tệp ở chế độ ghi tiếp | Tham 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 Latte | Chỉ đọc, nên an toàn |
kept.removeIf(...) rồi ghi lại | Lọ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ối | Cá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:
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:
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 code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
List<String> orders = load(); | Đọc cả tệp ra danh sách | Phả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ó true | Ghi đè tệp bằng danh sách đã lọc | Tệp mới đúng bằng danh sách mới |
return before - orders.size() | Lấy chênh lệch hai lần đếm | removeIf 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:
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.
- 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
closenên luồng không bao giờ đẩy nốt phần còn lại. Cách chắc ăn là dùngtry-with-resources. Kiểm tra mục tiêu 2. - In ra
A,B, rồinull[Đã chạy, trongSelfcheck.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. - Sau bước một, tệp dài 0 byte vì
FileWriterkhông cótruexoá nội dung ngay lúc mở dù chưa ghi gì. Sau bước hai, các dòng là[x][Đã chạy, trongSelfcheck.java]. Kiểm tra mục tiêu 3. - 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ằngnew FileReader(file, StandardCharsets.UTF_8)để đọc bằng đúng bảng mã đã ghi [Suy luận, khớp vớiCharsets.java]. Kiểm tra mục tiêu 3 và 4. - 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ặcflush()để chép từ nháp vào sổ, và cách đơn giản nhất là mở luồng trongtry-with-resourcesđểclose()tự chạy. Kiểm tra mục tiêu 2.