Mở đầu chặng I/O và dữ liệu. Và câu đầu tiên là một lời khuyên dứt khoát: đừng dùng java.io.File nữa.
Không phải vì nó cũ, mà vì nó im lặng khi có lỗi.
Ba chỗ API cũ nuốt lỗi
File.listFiles() -> null (im lặng)
Files.list() -> NoSuchFileException: /khong/co/that
File.delete() trả về : false (không rõ vì sao)
Files.delete() -> NoSuchFileException
File.listFiles() trả null khi thư mục không tồn tại — và cũng trả null khi bạn không có quyền đọc, khi đường dẫn trỏ vào một tệp thay vì thư mục, hay khi có lỗi I/O. Bốn nguyên nhân, một giá trị trả về.
Kết quả là mã kiểu này ở khắp nơi:
File[] ds = thuMuc.listFiles();
for (File f : ds) { ... } // NullPointerException, và bạn không biết vì sao
File.delete() trả false với đủ mọi lý do: tệp không tồn tại, không có quyền, thư mục không rỗng, tệp đang bị khoá. Bạn không bao giờ biết nguyên nhân nào.
java.nio.file thay bằng ngoại lệ có tên và có thông tin: NoSuchFileException, AccessDeniedException, DirectoryNotEmptyException — và mỗi cái đều mang đường dẫn gây lỗi.
Riêng điều này đã đủ lý do để chuyển hẳn.
Path là thao tác chuỗi, không đụng đĩa
Path p = Path.of("/du/an", "src/main", "App.java");
toàn bộ : /du/an/src/main/App.java
tên tệp : App.java
thư mục cha : /du/an/src/main
số phần : 5
resolve : /du/an/src/main/Khac.java
relativize : src/main/App.java
normalize : /a/c/d
tồn tại? : false (Path không cần tệp có thật)
Điểm cần nắm: Path chỉ là một đường dẫn, hoàn toàn không liên quan tới việc tệp có tồn tại hay không. Mọi thao tác trên nó là xử lý chuỗi, và chúng an toàn với mọi hệ điều hành.
Bốn phương thức đáng thuộc:
resolve(x) — nối thêm vào cuối. Path.of("/a").resolve("b") cho /a/b. Nếu x là đường dẫn tuyệt đối thì nó thay thế hẳn — hành vi đúng nhưng dễ bất ngờ.
relativize(x) — tính đường đi từ đây tới đó. Dùng khi cần đường dẫn tương đối để hiển thị hay lưu.
normalize() — rút gọn . và ... Rất quan trọng cho bảo mật, xem mục dưới.
getFileName(), getParent() — lấy phần cuối và phần đầu.
Path.of(...) nhận nhiều mảnh và tự nối bằng dấu phân cách đúng của hệ điều hành. Đừng bao giờ tự nối bằng "/" — mã sẽ hỏng trên Windows.
Files là thao tác đĩa
Files.readString(p); // đọc cả tệp thành chuỗi
Files.writeString(p, "nội dung");
Files.size(p);
Files.exists(p);
Files.createDirectories(p);
Files.copy(nguon, dich);
Files.move(nguon, dich);
Files.delete(p);
readString và writeString (Java 11) là hai phương thức tôi dùng nhiều nhất — chúng thay cả một khối try-with-resources với BufferedReader cho những tệp nhỏ.
Nhớ giới hạn: readString nạp cả tệp vào bộ nhớ. Với tệp lớn thì Files.lines như bài hôm trước.
Vài tuỳ chọn hữu ích:
Files.writeString(p, "x", StandardOpenOption.APPEND); // ghi thêm
Files.copy(nguon, dich, StandardCopyOption.REPLACE_EXISTING);
Files.move(a, b, StandardCopyOption.ATOMIC_MOVE); // đổi tên nguyên tử
ATOMIC_MOVE đáng nhớ: nó là cách chuẩn để ghi tệp an toàn — ghi vào tệp tạm, rồi move nguyên tử sang tên thật. Nhờ đó không bao giờ có ai đọc được tệp viết dở.
Duyệt cây thư mục
list (1 tầng) : [a, moi.txt, mot.txt]
walk (đệ quy) : [a, a/b, a/b/ba.log, a/hai.txt, moi.txt, mot.txt]
find *.txt : [hai.txt, moi.txt, mot.txt]
Ba phương thức, đều trả về Stream<Path> lười:
Files.list(dir) — một tầng.
Files.walk(dir) — đệ quy. Nhận thêm tham số độ sâu tối đa.
Files.find(dir, depth, matcher) — đệ quy có lọc, và matcher nhận cả BasicFileAttributes nên lọc theo kích thước hay thời gian sửa được mà không phải gọi thêm.
Cả ba đều phải đóng — chúng giữ file handle, đúng như Files.lines. Đây là chỗ rất hay quên vì người ta không nghĩ "liệt kê thư mục" lại là một tài nguyên.
Xoá cả cây thì có một mẹo gọn:
try (var s = Files.walk(goc)) {
s.sorted(Comparator.reverseOrder()).forEach(x -> Files.delete(x));
}
reverseOrder() bảo đảm tệp con đứng trước thư mục cha, nên khi xoá tới thư mục thì nó đã rỗng.
walkFileTree khi cần kiểm soát
Files.walk đơn giản nhưng có một nhược điểm: gặp thư mục không đọc được là ném ngoại lệ và dừng cả stream.
Cần bỏ qua lỗi và đi tiếp thì dùng Files.walkFileTree với một FileVisitor:
Files.walkFileTree(goc, new SimpleFileVisitor<>() {
@Override public FileVisitResult visitFileFailed(Path f, IOException e) {
return FileVisitResult.CONTINUE; // bỏ qua, đi tiếp
}
@Override public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
...
return FileVisitResult.CONTINUE;
}
});
Dài dòng hơn, nhưng cho phép bỏ qua nhánh (SKIP_SUBTREE), dừng hẳn (TERMINATE), và xử lý lỗi từng tệp. Với công cụ quét toàn ổ đĩa thì đây mới là lựa chọn đúng.
Bẫy bảo mật: đường dẫn đi ngược
Nếu tên tệp đến từ người dùng, đây là lỗ hổng kinh điển:
Path p = thuMucUpload.resolve(tenDoNguoiDungGui); // "../../etc/passwd"
resolve không kiểm tra gì, nên đường dẫn có thể thoát ra ngoài thư mục dự định.
Cách chặn:
Path dich = thuMucUpload.resolve(ten).normalize();
if (!dich.startsWith(thuMucUpload)) {
throw new SecurityException("đường dẫn không hợp lệ: " + ten);
}
normalize() rút gọn .., rồi startsWith kiểm tra kết quả vẫn nằm trong thư mục cho phép. Hai dòng, và nó chặn một lớp lỗ hổng thường xuyên xuất hiện trong các CVE.
Thuộc tính tệp
BasicFileAttributes a = Files.readAttributes(p, BasicFileAttributes.class);
kích thước : 16 byte, thư mục? false
Đọc một lần được cả kích thước, thời gian tạo, thời gian sửa, loại tệp — thay vì gọi Files.size, Files.getLastModifiedTime, Files.isDirectory riêng lẻ, mỗi cái một lần chạm đĩa.
Với hệ tệp POSIX còn có PosixFileAttributes cho quyền truy cập.
Vài thứ khác đáng biết
Files.createTempFile / createTempDirectory — tạo tệp tạm an toàn, tránh được lỗ hổng đua tranh khi tự đặt tên.
Files.newBufferedReader / newBufferedWriter — khi cần đọc ghi có kiểm soát, mặc định UTF-8.
FileSystems.getDefault().getPathMatcher("glob:*.java") — lọc theo mẫu glob, tiện hơn tự viết regex.
WatchService — theo dõi thay đổi trong thư mục, dùng cho hot-reload cấu hình.
Thử ba mươi giây
Tìm trong dự án của bạn một chỗ dùng new File(...) hoặc .listFiles().
Nếu có listFiles(), kiểm xem đoạn mã sau đó có xử lý null không. Chín trên mười lần là không — và đó là một NullPointerException đang chờ ngày thư mục bị xoá hoặc mất quyền đọc.
Ngày mai: bảng mã và chuyện đọc ghi văn bản — vì sao một tệp tiếng Việt biến thành ký tự lạ, và Java 18 đã đổi gì.