Backend

99 Ngày Java — Ngày 47: Serialization & JSON

SSite Admin
12 tháng 09, 2026 7 phút đọc 0 lượt xem
99 Ngày Java — Ngày 47: Serialization & JSON

Hôm qua ta đọc ghi file. Hôm nay là câu hỏi kế tiếp: làm sao đưa cả một object ra ngoài chương trình — ghi xuống đĩa, gửi qua mạng, đẩy vào hàng đợi? Java có sẵn Serializable từ 1997, và bài học lớn nhất hôm nay lại là vì sao gần như không ai còn dùng nó. Phần còn lại dành cho thứ cả ngành đã chọn thay thế: JSON với Jackson.

Sketchnote Ngày 47: Serialization và JSON — vì sao Serializable của Java bị bỏ, ObjectMapper tạo một lần dùng mãi, bẫy TypeReference khi đọc List, record hợp với JSON, JsonIgnore và JavaTimeModule

Serializable — và vì sao ngành đã rời bỏ nó

Cài một interface rỗng là object của bạn ghi xuống đĩa được. Nghe quá tiện. Nhưng cái giá nằm ở chỗ không nhìn thấy, và nó lớn tới mức chính Oracle gọi đây là "sai lầm khủng khiếp năm 1997" và đang tìm cách gỡ bỏ khỏi ngôn ngữ.

// SERIALIZATION CỦA JAVA — có sẵn từ 1997, và gần như không ai còn dùng.

class NguoiDung implements Serializable {      // chỉ cần cài interface RỖNG
    private static final long serialVersionUID = 1L;
    String ten;
    transient String matKhau;                  // transient = KHÔNG ghi ra
}

try (var out = new ObjectOutputStream(Files.newOutputStream(p))) {
    out.writeObject(u);                        // object → chuỗi byte nhị phân
}

// Nhìn thì gọn. Vấn đề nằm ở chỗ không nhìn thấy:

// ① LỖ HỔNG BẢO MẬT — đây là lý do lớn nhất.
//    readObject() DỰNG LẠI object mà KHÔNG chạy constructor của bạn.
//    Mọi bất biến (invariant) bạn cẩn thận bảo vệ trong constructor đều bị bỏ qua.
//    Tệ hơn: chuỗi byte quyết định class nào được nạp — kẻ tấn công gửi
//    một chuỗi byte khéo léo là chạy được code trên máy bạn. Cả một họ CVE
//    ("deserialization gadget chain") sinh ra từ đúng chỗ này.
//    Oracle gọi nó là "sai lầm khủng khiếp năm 1997" và đang tìm cách gỡ bỏ.

// ② KHÓA CHẶT CẤU TRÚC LỚP.
//    Đổi tên một field là file cũ đọc không ra. serialVersionUID phải tự quản.
//    Nó biến chi tiết BÊN TRONG của lớp thành một API CÔNG KHAI vĩnh viễn.

// ③ CHỈ JAVA ĐỌC ĐƯỢC. Đưa cho Python hay JavaScript thì chịu.

// ➜ Kết luận thực dụng: KHÔNG dùng cho dữ liệu đi qua mạng hay lưu lâu dài.
//    Cần định dạng trao đổi thì dùng JSON. Đó là phần còn lại của bài này.
  • readObject() dựng lại object mà không chạy constructor — mọi ràng buộc bạn bảo vệ trong constructor đều bị bỏ qua.

  • Chuỗi byte quyết định class nào được nạp, nên một chuỗi byte khéo léo có thể chạy code trên máy bạn — cả một họ CVE sinh ra từ đây.

  • Nó biến chi tiết bên trong lớp thành API công khai vĩnh viễn: đổi tên một field là file cũ đọc không ra.

  • Và chỉ Java đọc được — vô dụng ngay khi hệ thống của bạn có một service viết bằng ngôn ngữ khác.

Jackson — ObjectMapper và cái bẫy generic

Jackson là thư viện JSON mặc định của thế giới Java, và là thứ Spring Boot dùng bên dưới mỗi lần bạn trả một object từ @RestController. Chỉ cần nhớ hai method — writeValueAsStringreadValue — cộng một cái bẫy mà ai cũng vấp đúng một lần.

// JACKSON — thư viện JSON mặc định của thế giới Java (và của Spring Boot).

ObjectMapper mapper = new ObjectMapper();      // ⭐ TỐN KÉM để tạo, AN TOÀN luồng
                                               //    → tạo MỘT lần, dùng lại mãi

// GHI: object → JSON
String json = mapper.writeValueAsString(nguoiDung);
mapper.writeValue(Files.newOutputStream(p), nguoiDung);

// ĐỌC: JSON → object
NguoiDung u = mapper.readValue(json, NguoiDung.class);

// ❗ ĐỌC RA COLLECTION — chỗ ai cũng vấp một lần:
List<NguoiDung> ds = mapper.readValue(json, List.class);        // ❌ ra List<LinkedHashMap>!
// Vì XÓA KIỂU (Ngày 39): lúc chạy, List.class không mang theo <NguoiDung>.
List<NguoiDung> ds = mapper.readValue(json,                     // ✅
        new TypeReference<List<NguoiDung>>() {});
// TypeReference là lớp ẩn danh (Ngày 19) — nhờ có lớp con nên kiểu generic
// được ghi vào bytecode và Jackson đọc lại được.

// RECORD (Ngày 20) chạy thẳng, không cần annotation gì cả:
record Sach(String tieuDe, int nam) {}
Sach s = mapper.readValue("{\"tieuDe\":\"Java\",\"nam\":2026}", Sach.class);
// Jackson tìm constructor chính của record — đây là lý do record + JSON rất hợp nhau.

// ❗ Với CLASS thường thì phải có constructor KHÔNG THAM SỐ và setter,
//    hoặc đánh dấu constructor bằng @JsonCreator. Thiếu là lỗi lúc chạy.
  • ObjectMapper tốn kém để tạo nhưng an toàn luồng — tạo một lần rồi dùng lại, đừng new trong mỗi lần gọi.

  • Đọc ra List<T> mà truyền List.class thì nhận về List<LinkedHashMap> — hậu quả của xóa kiểu ở Ngày 39; phải dùng TypeReference.

  • Record của Ngày 20 hợp với JSON tới mức không cần annotation nào: Jackson tìm thẳng constructor chính.

  • Class thường thì cần constructor không tham số cộng setter, hoặc @JsonCreator — thiếu là lỗi lúc chạy chứ không phải lúc biên dịch.

Điều khiển ánh xạ và hai quyết định quan trọng

Bốn annotation dưới đây lo được gần hết công việc hằng ngày. Nhưng thứ đáng nhớ hơn cú pháp là hai quyết định ẩn trong cấu hình mapper: có bỏ qua field lạ hay không, và có bao giờ để dữ liệu không tin cậy quyết định class được nạp hay không.

// ĐIỀU KHIỂN ÁNH XẠ — bốn thứ dùng gần như mỗi ngày.

class NguoiDung {
    @JsonProperty("full_name")     // tên trong JSON khác tên field trong Java
    String hoTen;

    @JsonIgnore                    // ⭐ KHÔNG BAO GIỜ lộ ra ngoài
    String matKhau;

    @JsonFormat(pattern = "dd/MM/yyyy")
    LocalDate ngaySinh;
}

// ❗ HAI CẤU HÌNH nên đặt ngay khi tạo mapper:

// ① java.time — KHÔNG chạy nếu thiếu module này
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// Thiếu dòng thứ hai: LocalDate ra thành mảng số [2026,9,12] thay vì "2026-09-12".
// (Spring Boot tự làm cả hai giúp bạn — nên nhiều người chỉ gặp lỗi khi dùng Jackson trần.)

// ② Bỏ qua field lạ — quyết định về KHẢ NĂNG TIẾN HÓA của API
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
// Mặc định Jackson NÉM LỖI khi JSON có field mà lớp không có.
// Tắt đi thì bên gửi thêm field mới, bên nhận vẫn chạy — cần cho hệ phân tán.
// Nhưng đánh đổi: gõ sai tên field sẽ IM LẶNG thành null. Hãy chọn có ý thức.

// ❗ BẢO MẬT — hai điều đáng nhớ hơn cả cú pháp:
//    · @JsonIgnore mọi thứ nhạy cảm, hoặc tốt hơn: DTO riêng cho tầng API,
//      đừng trả thẳng entity ra ngoài (Ngày 17 đã nói lý do).
//    · ĐỪNG bật enableDefaultTyping / activateDefaultTyping trên dữ liệu
//      không tin cậy — nó mở lại đúng lỗ hổng của Java serialization ở trên.
  • @JsonIgnore cho mọi thứ nhạy cảm — nhưng an toàn hơn cả là DTO riêng cho tầng API, đừng trả thẳng entity ra ngoài (Ngày 17).

  • Thiếu JavaTimeModule thì LocalDate ra thành mảng số [2026,9,12]; Spring Boot cấu hình sẵn giúp bạn, nên lỗi này chỉ gặp khi dùng Jackson trần.

  • FAIL_ON_UNKNOWN_PROPERTIES là lựa chọn về khả năng tiến hóa: tắt thì bên gửi thêm field vẫn chạy, đổi lại gõ sai tên field sẽ im lặng thành null.

  • Đừng bật default typing trên dữ liệu không tin cậy — nó mở lại đúng lỗ hổng của Serializable ở đầu bài.

Bài tập nhỏ

  • Ghi một object bằng ObjectOutputStream, đổi tên một field rồi đọc lại — gặp InvalidClassException tận mắt.

  • Đọc một mảng JSON bằng List.class rồi get(0).getClass() — thấy nó là LinkedHashMap, sau đó sửa bằng TypeReference.

  • Serialize một recordLocalDate khi chưa đăng ký JavaTimeModule, rồi đăng ký vào và so hai kết quả.

  • Thêm một field lạ vào JSON đầu vào để gặp UnrecognizedPropertyException, rồi tắt cấu hình đó.

  • Đặt @JsonIgnore lên field mật khẩu và kiểm tra chuỗi JSON xuất ra không còn nó.

Kết lại

Bốn ý gói lại hôm nay: Serializable của Java dựng lại object mà bỏ qua constructor và để chuỗi byte quyết định class được nạp — đủ hai lý do để không dùng nó cho dữ liệu đi ra ngoài; ObjectMapper tạo một lần dùng mãi, vì nó tốn kém để dựng nhưng an toàn luồng; đọc ra List<T> thì phải dùng TypeReference, đây là lần đầu xóa kiểu của Ngày 39 cắn bạn trong đời thực; và record hợp với JSON tới mức không cần annotation nào. Ngày 48 ta nói về thứ mọi hệ thống production đều cần mà ít ai học tử tế: logging đúng cách — và vì sao System.out.println không phải là logging. 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 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
99 Ngày Java — Ngày 46: NIO.2 — Path & Files

Path là một cái tên chứ không phải một file, resolve và normalize cùng bẫy path traversal, Files ném ngoại lệ có tên thay vì trả false, ATOMIC_MOVE để ghi file an toàn — và walk, find, glob thay hẳn đệ quy tự viết.

11 thg 9, 20267 phút6