Jackson là thư viện JSON mặc định của hệ sinh thái Java — Spring Boot dùng nó, và phần lớn dự án cũng vậy.

Bài này gói bốn thứ dùng hằng ngày, và một cái bẫy bảo mật mà tôi từng gặp trong mã thật.

record ánh xạ không cần chú thích

record Nguoi(String ten, int tuoi, List<String> soDienThoai) {}
  ghi : {"ten":"Minh","tuoi":30,"soDienThoai":["090","091"]}
  đọc : Nguoi[ten=Minh, tuoi=30, soDienThoai=[090, 091]]

Không @JsonCreator, không @JsonProperty, không hàm khởi tạo rỗng. Jackson hỗ trợ record từ phiên bản 2.12, và nó đọc tên thành phần trực tiếp từ metadata của lớp.

Đây là lý do record thay thế DTO viết tay gọn nhất — như bài về record đã nói. Với lớp thường, Jackson cần hàm khởi tạo không tham số cộng setter, hoặc chú thích tường minh.

Trường thiếu và trường thừa: hai hành vi khác nhau

Thiếu thì im lặng:

  thiếu tuổi : Nguoi[ten=Lan, tuoi=0, soDienThoai=null]

int về 0, kiểu tham chiếu về null. Không cảnh báo gì.

Đây là chỗ nguy hiểm hơn vẻ ngoài: nếu API bên kia đổi tên một trường, dữ liệu của bạn im lặng thành 0 hoặc null, và lỗi hiện ra ở đâu đó rất xa. Với những trường bắt buộc, hãy kiểm tra trong hàm khởi tạo rút gọn của record:

record Nguoi(String ten, int tuoi) {
    Nguoi { Objects.requireNonNull(ten, "thiếu trường 'ten' trong JSON"); }
}

Thừa thì ném lỗi:

  mặc định : UnrecognizedPropertyException
  tắt kiểm : Nguoi[ten=Lan, tuoi=5, soDienThoai=null]

Mặc định Jackson từ chối JSON có trường lạ. Tắt bằng:

new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

Chọn cái nào? Với API bên ngoài mà bạn không kiểm soát, hãy tắt — bên kia thêm trường mới là chuyện bình thường và không nên làm gãy hệ thống của bạn. Với cấu hình nội bộ, hãy giữ bật — trường lạ thường là gõ nhầm, và báo lỗi sớm tốt hơn im lặng bỏ qua.

Spring Boot mặc định tắt kiểm tra này, nên nhiều người không biết nó tồn tại.

Ngày giờ cần module riêng

  không module : InvalidDefinitionException
  có module    : {"ma":"D1","taoLuc":"2026-06-30T10:15:30Z","ngaySinh":"1995-03-15"}

Jackson lõi không biết java.time. Phải đăng ký module:

new ObjectMapper()
    .registerModule(new JavaTimeModule())
    .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

Dòng thứ hai quan trọng: không có nó, Instant được ghi thành một con số dấu phẩy động (1782123330.000000000) thay vì chuỗi ISO-8601. Con số đó vẫn đọc lại được bằng Jackson, nhưng bất kỳ hệ thống nào khác nhìn vào cũng bó tay.

Spring Boot đăng ký sẵn module này, nên bạn chỉ gặp lỗi khi tự tạo ObjectMapper.

Đổi tên trường

record CoTenKhac(@JsonProperty("full_name") String hoTen,
                 @JsonProperty("is_active") boolean hoatDong) {}
  {"full_name":"Nguyễn Văn A","is_active":true}

Rất hay cần khi API bên ngoài dùng snake_case còn Java dùng camelCase. Đổi cho cả lớp thì dùng @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class), hoặc đặt mặc định trên ObjectMapper.

Cái bẫy: nhúng JSON vào thẻ <script>

Đây là phần quan trọng nhất của bài.

var doc = new Nguoi("</script><script>alert(1)</script>", 1, List.of());
m.writeValueAsString(doc);
  Jackson mặc định : {"ten":"</script><script>alert(1)</script>","tuoi":1,...}

Jackson không thoát <, >& — và nó đúng, vì chúng hợp lệ trong JSON.

Nhưng nếu bạn dán chuỗi này vào một trang HTML:

<script>
  const duLieu = {"ten":"</script><script>alert(1)</script>", ...};
</script>

Trình duyệt gặp </script> bên trong chuỗi và đóng thẻ ngay tại đó — vì bộ phân tích HTML chạy trước bộ phân tích JavaScript và nó không biết gì về chuỗi JSON. Phần còn lại trở thành một thẻ script mới, và mã của kẻ tấn công chạy.

Đây là XSS lưu trữ, và nó không cần gì đặc biệt: chỉ cần một trường dữ liệu do người dùng nhập được hiển thị lại.

Cách chặn là thoát ba ký tự đó thành dạng unicode:

mapper.getFactory().setCharacterEscapes(new HtmlEscapes());
  có escape HTML : {"ten":"</script><script>alert(1)</script>",...}

< vẫn là dấu < với bộ phân tích JavaScript, nên dữ liệu không đổi. Nhưng bộ phân tích HTML không nhận ra nó là thẻ, nên script không bị đóng sớm.

Quy tắc: JSON nhúng vào HTML phải thoát <, >, &. JSON trả về qua HTTP với Content-Type: application/json thì không cần — trình duyệt không phân tích nó như HTML. Chỉ cần cẩn thận đúng ở chỗ chèn vào trang.

Cùng nguyên tắc áp dụng cho JSON-LD trong thẻ <script type="application/ld+json"> — một chỗ rất hay gặp khi làm SEO.

Đọc cây khi không có lớp

JsonNode cay = m.readTree(json);
cay.at("/a/b/1").asInt();
  at("/a/b/1")   : 2
  thiếu đường dẫn: '' (không NPE)

readTree trả về một cây JsonNode, và at(...) dùng cú pháp JSON Pointer để đi sâu.

Điểm hay: đường dẫn không tồn tại trả về một node rỗng, không ném NPE. asText() cho chuỗi rỗng, asInt() cho 0. Rất tiện khi xử lý JSON có cấu trúc không cố định.

Nhưng đó cũng là bẫy: dữ liệu thiếu và dữ liệu bằng 0 trông giống nhau. Cần phân biệt thì dùng cay.at(...).isMissingNode().

Vài mẹo dùng hằng ngày

@JsonIgnore bỏ một trường khi ghi. Nhớ dùng cho mật khẩu, token — đúng nguyên tắc ở bài toString.

@JsonInclude(NON_NULL) bỏ hẳn các trường null khỏi JSON, làm payload gọn hơn nhiều.

ObjectMapper an toàn luồng sau khi cấu hình xong — hãy tạo một cái dùng chung, đừng new trong mỗi request. Tạo mới mỗi lần là một lỗi hiệu năng thường gặp.

readValue(json, new TypeReference<List<Nguoi>>() {}) cho kiểu generic — đúng mẫu type token đã nói ở bài xoá kiểu.

Và một cảnh báo nhắc lại từ bài serialization: đừng bật enableDefaultTyping với dữ liệu không tin cậy. Nó ghi tên lớp vào JSON rồi tạo đối tượng theo tên đó lúc đọc — tái tạo đúng lỗ hổng của Java serialization.

Thử ba mươi giây

Ghi một đối tượng có trường chứa chuỗi </script> ra JSON, rồi dán kết quả vào một tệp HTML giữa hai thẻ <script>.

Mở bằng trình duyệt và xem mã nguồn đã render. Bạn sẽ thấy thẻ bị đóng sớm — và hiểu vì sao đây là lỗi mà mọi trang hiển thị nội dung do người dùng nhập đều cần đề phòng.

Ngày mai: java.time — chọn giữa Instant, LocalDateZonedDateTime, và vì sao nên lưu Instant còn hiển thị theo múi giờ người đọc.