JSON Toolkit

JSON sang lớp Java: bộ tạo chỉ đưa khung, để lên production còn thiếu năm thứ

Kết luận: Cái nhìn thực tế về thứ một bộ tạo JSON-to-Java thực sự xuất ra — số trở thành Double, đối tượng lồng nhau thành class nội bộ static, và không sinh ra một annotation Jackson nào. Độ chính xác tiền tệ, đổi tên trường, xoá kiểu, trường lạ và ngày tháng là năm thứ bạn phải tự bổ sung, mỗi thứ kèm code chép được ngay.

Trung cấpĐọc khoảng 10 phútXuất bản 2026-11-04Cập nhật 2026-11-04
Công cụ chuyển đổi biến JSON thành code class POJO Java

Tóm gọn: đầu ra của bộ tạo compile được ngay nhưng chưa sẵn sàng cho production. Giá trị mặc định đo được của bộ tạo trên site này map mọi số JSON thành Double, biến đối tượng lồng nhau thành class nội bộ static, và không sinh một annotation Jackson nào. Độ chính xác tiền tệ, đổi tên trường, xoá kiểu, trường lạ và ngày tháng — năm thứ nó không bao giờ làm hộ bạn.

Biến một response của API thành class Java là việc cơ khí: đọc JSON, chép trường, bọc vào getter/setter. Cái giá của việc cơ khí là con dao vô hình — chép sai tên thuộc tính một chữ cái không báo lỗi compile. Trường lặng lẽ về null, và bạn phát hiện ra trong log production. Việc sinh cấu trúc class là để ăn khớp tên và kiểu từ ngày đầu. Nhưng bộ tạo chỉ ăn khớp được phần nhìn thấy được trong mẫu.

1. Chạy bộ tạo trước: nó thực ra đưa gì

Dán một response tiêu biểu vào công cụ JSON to Java của site:

{
  "order_id": 202611040001,
  "buyer": {
    "name": "王五",
    "vip": true
  },
  "items": [
    { "sku": "A-1", "price": 19.99, "qty": 2 }
  ],
  "created_at": "2026-11-04T09:30:00+08:00"
}

Khung được sinh ra (tuỳ chọn mặc định, useLombok tắt):

public class Root {
    private Double orderId;
    private Buyer buyer;
    private List<Item> items;
    private String createdAt;

    public Double getOrderId() { return orderId; }
    public void setOrderId(Double orderId) { this.orderId = orderId; }
    // ...các getter/setter còn lại theo cùng khuôn
}

Buyer và Item là các class nội bộ static riêng biệt. Quy tắc map, lần lượt:

Dạng trong JSON Kiểu Java sinh ra Ghi chú
"name": "王五" String Chuỗi luôn thành String
"qty": 2 Double Mọi số đều thành Double, xem mục 2
"vip": true Boolean Kiểu bọc, cho phép null
"buyer": {...} Buyer (class nội bộ static) Đối tượng lồng thành class riêng
"items": [...] List<Item> Kiểu phần tử lấy từ phần tử đầu
order_id orderId snake_case chuyển sang camelCase

Tên class nhận PascalCase (Root), trường nhận camelCase (orderId), va chạm với từ khoá Java (class, new, int) được tự động thêm hậu tố. Có ba tuỳ chọn: packageName, className và useLombok, chuyển giữa @Data và getter/setter viết tay. Bật Lombok thì cả khối code lặp thu gọn thành vài annotation.

Hai giới hạn của khung này nên biết trước. Thứ nhất, nó không sinh annotation Jackson/Gson nào — không @JsonProperty, không @JsonIgnoreProperties, không @JsonFormat, vì annotation quyết định deserialization chạy thế nào, còn một payload mẫu không thể nói ra chính sách thực thi của bạn. Thứ hai, không có chuyển đổi ngược: suy ra JSON từ class Java ngoài khả năng, cùng kiểu thiết kế một chiều với lộ trình JSON to TypeScript.

2. Sửa số một: Double không phải kiểu cho tiền

Bộ tạo map "price": 19.99 thành Double. Compile được, gán được, mà vẫn là kiểu sai. Số chấm động nhị phân không biểu diễn nổi 0.1, nghĩa là không biểu diễn nổi phần lớn phân số thập phân:

System.out.println(19.99);          // 19.99 — nhìn có vẻ ổn
double a = 0.1 + 0.2;
System.out.println(a);              // 0.30000000000000004
System.out.println(a == 0.3);       // false

In riêng 19.99 trông sạch vì Java áp dụng làm đẹp theo biểu diễn ngắn nhất; lỗi bung ra ngay khi giá trị bước vào phép tính. Cả ba bài kỹ thuật về đề tài này cùng một ý: giá, số tiền, tỷ lệ phải là BigDecimal, dựng từ chuỗi — new BigDecimal("19.99"), không phải new BigDecimal(19.99) — cái sau đưa thẳng sai số của double vào bên trong.

Cách sửa cơ khí là một lượt đi sau khi sinh: thay Double bằng BigDecimal chỉ ở trường tiền. Một bẫy liên quan cùng gốc: số nguyên vượt 2^53 (số đơn hàng, ID snowflake) đi qua Double lặng lẽ mất chữ số cuối — số 20 chữ số mất 3 chữ số cuối là kết quả đo được, tái lập được, và cơ chế là chính cái đứng sau vì sao JSON mất độ chính xác với số nguyên lớn. Map các trường đó thành String hoặc Long, để chữ số đi lại như văn bản.

Ý nghĩa thật của trường Bộ tạo đưa Đổi thành
Tiền, tỷ lệ Double BigDecimal (constructor chuỗi)
Số đơn hàng, ID (có thể vượt 2^53) Double String hoặc Long
Số lượng, số trang Double Integer / Long
Số thực khoa học thật sự Double giữ nguyên

3. Sửa số hai: tên không khớp, ghim bằng @JsonProperty

Chuyển order_id thành orderId là phỏng đoán thiện chí: bộ tạo assum bạn muốn camelCase phía Java. Nhưng Jackson mặc định so khớp khoá JSON với tên trường theo từng ký tự. Không khớp thì không có gì hỏng — orderId chỉ lặng lẽ là null. Hai cách vá:

Theo trường — thêm @JsonProperty, một annotation ghim một cặp map:

public class Root {
    @JsonProperty("order_id")
    private Long orderId;
}

Toàn cục — đổi naming strategy của ObjectMapper, cả dự án khỏi viết annotation từng trường:

ObjectMapper mapper = new ObjectMapper();
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);

Chọn theo mặt tiếp xúc: một API cố định — dùng cấp trường; cả đội nói chuyện với các service snake_case — dùng chiến lược toàn cục. Một thực tế tiêu cực đáng có đoạn riêng — tên gõ sai thất bại dạng null lặng lẽ, không phải exception. Trang công cụ jsonformater coi đây chính là lý do tồn tại của bộ tạo: chép máy móc là chỗ tỉ lệ lỗi này lên đỉnh. Nên sau khi thêm annotation, viết ít nhất một test assertion rằng các trường khoá không null.

4. Sửa số ba: xoá kiểu — đừng bao giờ deserialise vào List trần

Bộ tạo đưa bạn phần khai báo trường List<Item>, nhưng thứ thật sự lấp cái list đó là Jackson. Deserialise ở cấp cao nhất kiểu này rơi thẳng vào bẫy xoá kiểu:

// Sai: phần tử lúc chạy là LinkedHashMap
List<Item> items = mapper.readValue(json, List.class);

// Đúng: TypeReference giữ tham số generic
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});

readValue(json, List.class) compile được, chạy được, nhưng generic của Java chỉ tồn tại lúc compile. Lúc chạy Jackson chỉ thấy List, nên phần tử đáp xuống thành LinkedHashMap. Lỗi không nổ ngay — nổ ở chỗ nào đó khi item.getSku() đầu tiên, ném ClassCastException cách xa chỗ gây lỗi. Ba dạng đúng, theo tình huống:

// 1. Cấp cao nhất chính là mảng
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});

// 2. Một trường của đối tượng cấp cao nhất
JsonNode root = mapper.readTree(json);
List<Item> items = mapper.convertValue(root.get("items"), new TypeReference<List<Item>>() {});

// 3. Dựng JavaType tường minh
JavaType type = mapper.getTypeFactory().constructCollectionType(List.class, Item.class);
List<Item> items = mapper.readValue(json, type);

Phía Gson tương đương là TypeToken. Cả ba hướng dẫn đều liệt kê riêng mục này, vì nó hỏng muộn và báo xa.

5. Sửa số bốn: trường lạ — đừng để API tiến hoá làm gãy deserialization

API thêm một trường là dạng tiến hoá bình thường nhất. Nhưng hành vi mặc định của Jackson là ném UnrecognizedPropertyException cho trường lạ — bên cấp thêm một trường, service của bạn bắt đầu báo lỗi. Tắt nó ở hai cấp:

// Cấp class: chỉ dung nạp trường lạ ở class này
@JsonIgnoreProperties(ignoreUnknown = true)
public class Root { ... }

// Toàn cục: cả mapper đều dung nạp
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

Tắt xong nảy ra vấn đề gương soi: dung nạp cũng làm câm cả tên trường gõ sai, mở toang chế độ hỏng "null lặng lẽ" của mục trước. Chừng mực giữa: chạy production với dung nạp tắt, chạy bộ test với dung nạp bật, để lỗi chính tả lộ ra trong lúc test. Gson mặc định bỏ qua trường lạ — vì thế nó đỡ nhọc khi tích hợp nhanh, và cái giá của sự nhàn đó là bạn không bao giờ biết bên cấp thêm gì.

6. Sửa số năm: ngày tháng và constructor không tham số

Bộ tạo map created_at thành String, thực ra là mặc định an toàn nhất: trong JSON, ngày chỉ là chuỗi, còn định dạng trên dây có thể là ISO-8601, Unix timestamp, hay dạng tuỳ biến — mẫu không thể đoán bạn muốn cái nào. Cần kiểu ngày thật thì thêm hai dòng:

ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
// Serialize ra ISO-8601 thay vì mảng timestamp
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
public class Root {
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createdAt;
}

Constructor không tham số là mục kiểm bắt buộc còn lại: Jackson deserialise bằng cách dựng đối tượng qua nó rồi set trường lần lượt. Viết constructor có tham số là constructor mặc định biến mất — deserialization gãy ngay. Với Lombok, thêm @NoArgsConstructor; dùng record của Java 17 thì giao cả vấn đề cho phần hỗ trợ record của Jackson 2.12+, nó khớp theo tham số constructor và xoá hẳn yêu cầu này.

7. Hai chuyện production: tái sử dụng ObjectMapper và kiểm tra đầu vào

Bộ tạo đưa bạn class; hai chuyện kiến trúc còn lại lúc chạy. Thứ nhất, tạo ObjectMapper tốn kém và nó thread-safe — chia sẻ một instance cho cả ứng dụng. Gọi new ObjectMapper() trong method là anti-pattern hiệu năng phổ biến nhất, và instance mà Spring Boot tiêm mặc định tồn tại chính vì lẽ đó. Naming strategy, JavaTimeModule và chính sách trường lạ đều đặt trên đúng instance đó, đừng rải các nơi gọi.

Thứ hai, deserialization thành công chỉ nói rằng kiểu khớp, không nói giá trị hợp lệ. Giá âm, số lượng 0, email méo — Jackson không quản. Kiểm tra đầu vào thuộc về Bean Validation (hibernate-validator), thêm annotation lên khung đã sinh:

public class CreateOrderRequest {
    @NotNull
    @DecimalMin(value = "0.01")
    private BigDecimal price;

    @Min(1)
    private Integer qty;

    @Email
    private String contact;
}

Trong Spring, @Valid chặn request trước khi vào logic nghiệp vụ. Lớp này thứ mà bộ tạo không bao giờ cho được — nó đọc được từ mẫu "trường trông ra sao", không đọc được "ràng buộc nghiệp vụ của trường là gì".

8. Ranh giới giữa sinh và viết tay

Khi nào dùng bộ tạo, khi nào viết tay — đường ranh rõ ràng:

  • Dùng bộ tạo: response nhiều trường, tích hợp bên thứ ba một lần, dựng prototype nhanh. Nó xoá đúng một lớp lỗi — sai sót chép máy móc — và đưa khung ăn khớp tên-kiểu trong vài giây.
  • Viết tay hoặc cải tạo sâu: tiền và số nguyên dài phải đổi kiểu, naming strategy cần quyết định toàn cục, đối tượng domain lõi cần annotation kiểm tra. Đây là quyết định nghiệp vụ mà payload mẫu không thể quyết thay.
  • Điều kiện chung cho cả hai: xác nhận trước JSON nguồn hợp lệ. Bộ tạo sẵn sàng sinh class từ payload hỏng và để cái bẫy cho lúc deserialization; cho qua trang kiểm tra hợp lệ của site trước khi dán. Mười giây, kiểu gì cũng vậy.

Bộ tạo lo "từ 0 đến khung". Năm điều trong bài lo "từ khung đến production". Thứ tự không đảo được, và không bước nào bỏ được.

FAQ

Class sinh ra dùng thẳng trong Spring Boot được không?

Lúc compile thì được. Trước khi chạy nên thêm ba thứ: trường tiền thành BigDecimal, naming strategy hoặc @JsonProperty cho API snake_case, và @JsonIgnoreProperties(ignoreUnknown = true) để sống sót qua tiến hoá API. Annotation Bean Validation theo quy tắc nghiệp vụ của bạn.

Vì sao bộ tạo map mọi số thành Double thay vì long hay BigDecimal?

Mẫu phân biệt được "nguyên hay thập phân" nhưng không phân biệt được ý nghĩa nghiệp vụ. Một 2 có thể là số lượng hay ID; số đơn hàng 20 chữ số và ¥19.99 trong JSON đều chỉ là number. Chịu để quyết định ngữ nghĩa cho người dùng là ranh giới hợp lý của bộ tạo một chiều — cái giá là bảng thay thế ở mục 2 phải tự bạn đi qua.

@Data của Lombok hay getter/setter viết tay — chọn cái nào?

Đội đã có Lombok thì lấy @Data cho ít code lặp nhất. Muốn tránh plugin compile-time hoặc xung đột với bộ xử lý annotation khác thì viết tay. Với Jackson thì như nhau; nó nhìn getter/setter hay khả năng thấy của trường.

Đối tượng lồng thành class nội bộ static — có sao không?

Với Jackson thì không; class nội bộ static có thể khởi tạo độc lập. Nhưng để bảo trì lâu dài, đa số đội tách chúng thành class cấp cao nhất trong package, tiện tái dùng và test riêng. Cấu trúc class nội bộ của bộ tạo dựng để nhìn nhanh, không phải cho codebase sống.

Trường List ném ClassCastException khi deserialise — định vị thế nào?

Kiểm trước xem deserialization có dùng List.class trần không: phần tử thật ra là LinkedHashMap và lỗi nổ lúc đọc trường. Chuyển sang TypeReference. Vẫn hỏng thì dán exception vào trang tra cứu thông báo lỗi của site và theo đúng mục cho ngôn ngữ của bạn.