Backend

99 Ngày Java — Ngày 46: NIO.2 — Path & Files

SSite Admin
11 tháng 09, 2026 7 phút đọc 6 lượt xem
99 Ngày Java — Ngày 46: NIO.2 — Path & Files

Hôm qua ta đọc ghi nội dung file bằng java.io. Hôm nay lên một tầng: NIO.2 — bộ API ra đời cùng Java 7 để thay java.io.File, và nó sửa đúng ba chỗ đau nhất của lớp cũ: đường dẫn là đối tượng chứ không phải chuỗi, lỗi nói rõ nó là lỗi gì thay vì trả về false im lặng, và duyệt cây thư mục không còn phải tự viết đệ quy.

Sketchnote Ngày 46: NIO.2 với Path và Files — Path là đối tượng với resolve và normalize, bẫy path traversal, Files gom mọi thao tác cùng ATOMIC_MOVE, duyệt cây bằng walk find và glob, WatchService nghe thay đổi

Path — cái tên, không phải cái file

Path là thứ đầu tiên phải đổi thói quen. Nó không chạm vào đĩa: tạo một Path trỏ tới file không tồn tại là chuyện hoàn toàn hợp lệ, không lỗi gì cả. Nó chỉ là một cái tên đã được phân tích thành từng đoạn — và chính vì thế nó biết cách ghép, cắt, dọn dẹp đường dẫn đúng theo hệ điều hành đang chạy.

// PATH — đường dẫn là một ĐỐI TƯỢNG, không phải một chuỗi.
// Đây là khác biệt lớn nhất so với java.io.File của thế kỷ trước.

Path goc = Path.of("/var/du-lieu");           // Java 11+, gọn nhất
Path file = goc.resolve("bao-cao/2026.csv");  // /var/du-lieu/bao-cao/2026.csv

// ❌ Vì sao ĐỪNG tự nối chuỗi:
String xau = goc + "/" + ten;   // Windows dùng '\', Linux dùng '/'
                                // ten = "a//b" hay "../.." thì bạn tự chịu
// ✅ resolve() biết dấu phân cách của HỆ ĐIỀU HÀNH đang chạy.

// BỐN phép biến đổi cần thuộc:
goc.resolve("con.txt");        // ghép thêm một đoạn
goc.resolveSibling("kia.txt"); // thay phần cuối, giữ thư mục cha
goc.relativize(file);          // bao-cao/2026.csv — đường đi TỪ goc TỚI file
file.normalize();              // dọn "." và ".." trong đường dẫn

// ❗ BẪY BẢO MẬT kinh điển — path traversal:
Path xin = goc.resolve(tenNguoiDungGui);      // "../../etc/passwd" 😱
if (!xin.normalize().startsWith(goc)) {       // ✅ normalize RỒI mới kiểm tra
    throw new SecurityException("Ra ngoài thư mục cho phép");
}
// Thiếu normalize() thì startsWith vẫn đúng mà file thật lại nằm ngoài.

// Path KHÔNG chạm vào đĩa. Nó chỉ là cái tên.
Path ao = Path.of("/khong/he/ton/tai");       // hợp lệ, không lỗi gì cả
Files.exists(ao);                             // false — ĐÂY mới là lúc đọc đĩa
  • resolve thay cho nối chuỗi: nó biết Windows dùng \ còn Linux dùng /, còn bạn thì hay quên.

  • Path traversal là bẫy bảo mật kinh điển — luôn normalize() trước khi startsWith, vì thiếu bước đó thì kiểm tra vẫn qua mà file thật lại nằm ngoài thư mục cho phép.

  • relativize trả lời câu "đi từ A tới B như thế nào" — rất hợp khi cần lưu đường dẫn tương đối vào cấu hình hay database.

Files — một lớp, mọi thao tác, và lỗi biết nói

Lớp java.io.File cũ có một tật khó tha: delete() trả về false mà không nói vì sao — do file không tồn tại, do thiếu quyền, hay do thư mục chưa rỗng? Files thay tất cả bằng ngoại lệ có tên rõ ràng: NoSuchFileException, AccessDeniedException, DirectoryNotEmptyException.

// FILES — mọi thao tác với file gom vào MỘT lớp tiện ích tĩnh.

Files.createDirectories(thuMuc);              // tạo cả cây, đã có sẵn cũng không lỗi
Files.copy(nguon, dich, REPLACE_EXISTING);
Files.move(nguon, dich, ATOMIC_MOVE);         // đổi tên nguyên tử trong cùng ổ đĩa
Files.delete(p);                              // ném NoSuchFileException nếu không có
Files.deleteIfExists(p);                      // trả boolean — thường là cái bạn muốn

// ❗ ATOMIC_MOVE là mẹo ghi file an toàn: ghi ra file tạm rồi mới đổi tên.
Path tam = dich.resolveSibling(dich.getFileName() + ".tmp");
Files.writeString(tam, noiDung, UTF_8);
Files.move(tam, dich, ATOMIC_MOVE, REPLACE_EXISTING);
// Người đọc không bao giờ thấy file ghi DỞ DANG — hoặc bản cũ, hoặc bản mới.
// Chỉ nguyên tử khi cùng một hệ thống file; qua ổ khác thì JVM ném lỗi.

// ĐỌC THUỘC TÍNH — một lần gọi thay vì bốn:
BasicFileAttributes a = Files.readAttributes(p, BasicFileAttributes.class);
a.size(); a.creationTime(); a.lastModifiedTime(); a.isDirectory();
// Files.size() + Files.isDirectory() + ... là BỐN lần hỏi hệ điều hành.

// ❗ exists() và notExists() KHÔNG phải phủ định của nhau — có ba trạng thái:
//   tồn tại · không tồn tại · KHÔNG BIẾT (thiếu quyền đọc thư mục)
// Cả hai cùng trả false ở trạng thái thứ ba.
// Nên ĐỪNG "kiểm tra rồi mới mở" — giữa hai lệnh, file có thể biến mất:
try (var in = Files.newInputStream(p)) { ... }      // ✅ cứ mở, bắt lỗi
catch (NoSuchFileException e) { ... }
  • Ghi file an toàn thì ghi ra file tạm rồi ATOMIC_MOVE đè lên — người đọc không bao giờ thấy bản ghi dở dang.

  • readAttributes hỏi hệ điều hành một lần cho cả kích thước, thời gian và loại; gọi Files.size() rồi Files.isDirectory() là hỏi bốn lần.

  • existsnotExists không phải phủ định của nhau — có trạng thái thứ ba là "không biết", và cả hai cùng trả false.

  • Đừng "kiểm tra rồi mới mở": giữa hai lệnh file có thể biến mất. Cứ mở và bắt ngoại lệ.

walk, find, glob và WatchService

Đây là phần khiến NIO.2 đáng học nhất. Trước Java 7, muốn tìm mọi file .log trong một cây thư mục là phải tự viết hàm đệ quy. Giờ nó là một dòng Stream — với đúng một điều kiện: phải đóng nó.

// DUYỆT CÂY THƯ MỤC — ba công cụ, ba mục đích khác nhau.

// ① walk — duyệt SÂU toàn bộ cây, lười, trả Stream<Path>
try (var s = Files.walk(goc)) {                    // ❗ LÀ tài nguyên — phải đóng
    s.filter(Files::isRegularFile)
     .filter(p -> p.toString().endsWith(".log"))
     .forEach(System.out::println);
}   // quên đóng = rò rỉ file descriptor, chạy lâu là hết handle (Ngày 44)

// ② find — như walk nhưng lọc bằng cả THUỘC TÍNH, khỏi hỏi đĩa hai lần
try (var s = Files.find(goc, 3,                    // 3 = độ sâu tối đa
        (p, attr) -> attr.isRegularFile() && attr.size() > 10_000_000)) {
    s.forEach(System.out::println);
}

// ③ newDirectoryStream + GLOB — chỉ MỘT tầng, lọc bằng mẫu, rất nhanh
try (var ds = Files.newDirectoryStream(goc, "*.{csv,json}")) {
    for (Path p : ds) { ... }
}
// Cú pháp glob:  *  một đoạn tên   **  xuyên nhiều thư mục
//                ?  một ký tự      {a,b}  một trong các lựa chọn
PathMatcher m = FileSystems.getDefault().getPathMatcher("glob:**/*.java");

// ❗ walk mặc định KHÔNG đi theo symbolic link — tránh vòng lặp vô tận.
// Bật FOLLOW_LINKS thì phải sẵn sàng đón FileSystemLoopException.

// WATCHSERVICE — hệ điều hành BÁO khi có thay đổi, khỏi hỏi vòng lặp
WatchService ws = FileSystems.getDefault().newWatchService();
thuMuc.register(ws, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE);
WatchKey key = ws.take();                          // chặn tới khi có sự kiện
for (WatchEvent<?> e : key.pollEvents()) { ... }
key.reset();                                       // ❗ quên reset = không nhận nữa
// Chỉ theo dõi MỘT tầng — muốn cả cây thì tự đăng ký từng thư mục con.
// Một lần lưu file có thể sinh NHIỀU sự kiện MODIFY: nhớ gom lại (debounce).
  • Files.walkFiles.lines đều là tài nguyên — luôn nằm trong try-with-resources của Ngày 44, nếu không thì rò rỉ file descriptor.

  • Files.find lọc bằng cả thuộc tính nên tránh được việc hỏi đĩa hai lần cho mỗi file; newDirectoryStream với glob thì nhanh nhất khi chỉ cần một tầng.

  • WatchService để hệ điều hành báo thay vì bạn hỏi vòng lặp — nhưng nhớ key.reset(), và nhớ gom sự kiện vì một lần lưu file có thể bắn ra nhiều MODIFY.

Bài tập nhỏ

  • Viết hàm nhận tên file từ "người dùng" và chặn ../../etc/passwd — thử bỏ normalize() để thấy nó lọt.

  • Dùng Files.walk đếm tổng dung lượng một thư mục, rồi viết lại bằng Files.find và so số lần chạm đĩa.

  • Ghi file bằng cách thường và bằng ATOMIC_MOVE, vừa ghi vừa đọc song song để thấy khác biệt.

  • Liệt kê mọi file .java bằng glob **/*.java, rồi đổi sang *.java để thấy một sao khác hai sao.

  • Chạy WatchService trên một thư mục rồi lưu file bằng trình soạn thảo — đếm xem nó bắn ra mấy sự kiện.

Kết lại

Bốn ý gói lại hôm nay: Pathmột cái tên chứ không phải một file nên nó không chạm đĩa, và resolve cùng normalize là hai phép phải thuộc — đặc biệt để chặn path traversal; Files gom mọi thao tác vào một chỗ và ném ngoại lệ có tên thay vì trả false im lặng; ATOMIC_MOVE là cách ghi file mà người đọc không bao giờ thấy bản dở dang; và walk, find, glob thay hẳn đệ quy tự viết — miễn là bạn nhớ chúng là tài nguyên. Ngày 47 ta nói chuyện đưa dữ liệu ra ngoài chương trình: serialization và JSON với Jackson — cùng lý do vì sao Serializable của Java gần như không ai còn dùng. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Spring — Ngày 47: Phân quyền — role & authority

Role chỉ là authority có tiền tố ROLE_, vì sao hasRole("ROLE_ADMIN") luôn cho 403, thiết kế role gom quyền để thêm vai trò không phải sửa code — và hai thứ im lặng làm hỏng method security.

12 thg 9, 20267 phút0
99 Ngày Java — Ngày 47: Serialization & JSON

Vì sao Serializable của Java bị cả ngành rời bỏ, ObjectMapper tạo một lần dùng mãi, bẫy TypeReference khi đọc List do xóa kiểu — và vì sao record hợp với JSON tới mức không cần annotation nào.

12 thg 9, 20267 phút0
99 Ngày Spring — Ngày 46: Refresh token & logout

Access token 15 phút cộng refresh token 7 ngày lưu database, vì sao refresh token không nên là JWT, rotation biến việc dùng lại token thành chuông báo động — và sự thật rằng đăng xuất của JWT là "không cấp thêm".

11 thg 9, 20268 phút11