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.

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 — writeValueAsString và readValue — 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.ObjectMappertốn kém để tạo nhưng an toàn luồng — tạo một lần rồi dùng lại, đừngnewtrong mỗi lần gọi.Đọc ra
List<T>mà truyềnList.classthì nhận vềList<LinkedHashMap>— hậu quả của xóa kiểu ở Ngày 39; phải dùngTypeReference.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.@JsonIgnorecho 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
JavaTimeModulethìLocalDatera 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_PROPERTIESlà 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ànhnull.Đừ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ặpInvalidClassExceptiontận mắt.Đọc một mảng JSON bằng
List.classrồiget(0).getClass()— thấy nó làLinkedHashMap, sau đó sửa bằngTypeReference.Serialize một
recordcóLocalDatekhi 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
@JsonIgnorelê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!
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.


