JSON เป็นคลาส Java: เครื่องมือสร้างให้แค่โครง สำหรับโปรดักชันยังต้องเติมอีก 5 เรื่อง
สรุปสำคัญ: มองอย่างละเอียดว่าเครื่องมือสร้าง JSON-to-Java ส่งออกอะไรกันแท้ — ตัวเลขกลายเป็น Double, ออบเจกต์ซ้อนกันกลายเป็น inner class แบบ static และไม่สร้างแอโนเทชัน Jackson เลยสักตัว ความแม่นยำของเงิน การเปลี่ยนชื่อฟิลด์ type erasure ฟิลด์ที่ไม่รู้จัก และวันที่ คือ 5 เรื่องที่ต้องเติมเอง พร้อมโค้ดคัดลอกได้ทันทีสำหรับแต่ละเรื่อง

ย่อสั้น ๆ: ผลลัพธ์จากเครื่องมือสร้างคอมไพล์ผ่านทันที แต่ยังไม่พร้อมโปรดักชัน ค่าเริ่มต้นที่วัดได้ของเครื่องมือบนเว็บนี้ map ตัวเลข JSON ทุกตัวเป็น Double, เปลี่ยนออบเจกต์ซ้อนกันเป็น inner class แบบ static และไม่สร้างแอโนเทชัน Jackson สักตัว ความแม่นยำของเงิน การเปลี่ยนชื่อฟิลด์ type erasure ฟิลด์ที่ไม่รู้จัก และวันที่ — 5 เรื่องที่มันไม่มีวันทำให้คุณเอง
เปลี่ยน response ของ API เป็นคลาส Java คืองานกลไกล้วน ๆ: อ่าน JSON ลอกฟิลด์ คลุมด้วย getter/setter ราคาของงานกลคือมีดที่มองไม่เห็นนั่นแหละ — ลอกชื่อ property ผิดตัวอักษรเดียวไม่ทำให้คอมไพล์พัง ฟิลด์จะเดินมาเป็น null เงียบ ๆ แล้วคุณไปเจอใน log โปรดักชัน การสร้างโครงสร้างคลาสมีอยู่เพื่อจัดชื่อกับชนิดให้ตรงกันตั้งแต่วันแรก แต่เครื่องมือสร้างจัดตรงได้เท่าที่มองเห็นในตัวอย่างเท่านั้น
1. รันเครื่องมือสร้างก่อน: มันให้อะไรมากันแน่
วาง response ตัวอย่างที่มีตัวแทนครบลงไปใน เครื่องมือ JSON to Java ของเว็บ:
{
"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"
}
โครงที่ถูกสร้าง (ตัวเลือกเริ่มต้น, useLombok ปิด):
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; }
// ...getter/setter ที่เหลือทำตามแบบเดียวกัน
}
Buyer กับ Item เป็น inner class แบบ static แยกกัน กฎการ map ไล่ทีละข้อ:
| รูปร่างใน JSON | ชนิด Java ที่สร้างได้ | หมายเหตุ |
|---|---|---|
"name": "王五" |
String |
สตริงกลายเป็น String เสมอ |
"qty": 2 |
Double |
ตัวเลขทุกตัวกลายเป็น Double ดูหัวข้อ 2 |
"vip": true |
Boolean |
ชนิดห่อ อนุญาตให้ null |
"buyer": {...} |
Buyer (inner class แบบ static) |
ออบเจกต์ซ้อนกลายเป็นคลาสของตัวเอง |
"items": [...] |
List<Item> |
ชนิดสมาชิกเอาจากสมาชิกตัวแรก |
order_id |
orderId |
snake_case แปลงเป็น camelCase |
ชื่อคลาสได้ PascalCase (Root), ฟิลด์ได้ camelCase (orderId), และการชนกับคีย์เวิร์ดของ Java (class, new, int) จะเติมคำต่อท้ายให้อัตโนมัติ มีตัวเลือก 3 ตัว: packageName, className และ useLombok ที่สลับระหว่าง @Data กับ getter/setter เขียนมือ เปิด Lombok แล้วบล็อกโค้ดซ้ำซากทั้งก้อนยุบเหลือแอโนเทชันไม่กี่ตัว
ขอบเขตสองข้อของโครงนี้ควรรู้ล่วงหน้า หนึ่ง, มันไม่สร้างแอโนเทชัน Jackson/Gson ใด ๆ — ไม่มี @JsonProperty, ไม่มี @JsonIgnoreProperties, ไม่มี @JsonFormat เพราะแอโนเทชันกำหนดว่า deserialization จะทำงานอย่างไร ซึ่ง payload ตัวอย่างบอกนโยบายการทำงานของคุณไม่ได้ สอง, ไม่มีการแปลงย้อนกลับ: สร้าง JSON จากคลาส Java อยู่นอกขอบเขต เป็นดีไซน์ทางเดียวเหมือนเส้นทาง JSON to TypeScript
2. การแก้ที่หนึ่ง: Double ไม่ใช่ชนิดสำหรับเงิน
เครื่องมือ map "price": 19.99 เป็น Double คอมไพล์ผ่าน กำหนดค่าได้ แต่ยังเป็นชนิดที่ผิด จุดทศนิยมแบบไบนารีเก็บ 0.1 ไม่ได้ แปลว่าเก็บเศษทศนิยมส่วนใหญ่ก็ไม่ได้:
System.out.println(19.99); // 19.99 — ดูเหมือนปกติ
double a = 0.1 + 0.2;
System.out.println(a); // 0.30000000000000004
System.out.println(a == 0.3); // false
พิมพ์ 19.99 เฉย ๆ ดูสะอาดเพราะ Java จัดรูปสวยแบบตัวแทนที่สั้นที่สุดให้ ความผิดพลาดโผล่ทันทีที่ค่าเข้าสู่การคำนวณ บทความวิศวกรรมทั้งสามชิ้นในหัวข้อนี้เห็นตรงกัน: ราคา จำนวนเงิน และอัตราควรเป็น BigDecimal สร้างจากสตริง — new BigDecimal("19.99") ไม่ใช่ new BigDecimal(19.99) ที่ลากความคลาดเคลื่อนของ double เข้าไปข้างในตรง ๆ
วิธีแก้เชิงกลคือไล่แก้หลังสร้าง: เปลี่ยน Double เป็น BigDecimal เฉพาะฟิลด์เงินเท่านั้น กับดักที่เกี่ยวเนื่องรากเดียวกัน: จำนวนเต็มเกิน 2^53 (เลขที่ออเดอร์, snowflake ID) ที่วิ่งผ่าน Double จะเงียบ ๆ สูญเสียเลขท้าย — ตัวเลข 20 หลักหายไป 3 หลักท้ายเป็นผลที่วัดได้และทำซ้ำได้ กลไกเดียวกับที่อยู่เบื้องหลัง ทำไม JSON ถึงเสียความแม่นยำกับจำนวนเต็มใหญ่ จับฟิลด์พวกนี้ map เป็น String หรือ Long แล้วให้เลขเดินทางในรูปข้อความ
| ความหมายจริงของฟิลด์ | เครื่องมือให้ | เปลี่ยนเป็น |
|---|---|---|
| เงิน อัตรา | Double |
BigDecimal (constructor จากสตริง) |
| เลขที่ออเดอร์ ID (อาจเกิน 2^53) | Double |
String หรือ Long |
| จำนวนนับ เลขหน้า | Double |
Integer / Long |
| จำนวนจริงเชิงวิทยาศาสตร์ | Double |
เก็บไว้ตามเดิม |
3. การแก้ที่สอง: ชื่อไม่ตรง ตอกยึดด้วย @JsonProperty
การแปลง order_id เป็น orderId เป็นการเดาแบบสุภาพ: เครื่องมือเดาว่าคุณอยากได้ camelCaseฝั่ง Java แต่ Jackson ค่าเริ่มต้นจับคู่คีย์ JSON กับชื่อฟิลด์แบบตรงตัว ไม่ตรงกันก็ไม่มีอะไรพัง — orderId แค่เงียบเป็น null วิธีแก้สองแบบ:
รายฟิลด์ — เพิ่ม @JsonProperty หนึ่งแอโนเทชันตอกยึดหนึ่งคู่ map:
public class Root {
@JsonProperty("order_id")
private Long orderId;
}
ทั้งโปรเจกต์ — เปลี่ยน naming strategy ของ ObjectMapper แล้วไม่ต้องใส่แอโนเทชันรายฟิลด์ที่ไหนเลย:
ObjectMapper mapper = new ObjectMapper();
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
เลือกตามพื้นที่ติดต่อ: API เดียวคงที่ — ใช้ระดับฟิลด์; ทั้งทีมคุยกับบริการแบบ snake_case — ใช้กลยุทธ์กลาง ข้อเท็จจริงด้านลบที่ควรมีย่อหน้าเป็นของตัวเอง — ชื่อพิมพ์ผิดพังแบบ null เงียบ ๆ ไม่ใช่เป็น exception หน้าเครื่องมือของ jsonformater ถือว่านี่คือเหตุผลที่เครื่องมือสร้างต้องมีอยู่: การลอกแบบกลไกคือจุดที่อัตราผิดพลาดแบบนี้พุ่งสูงสุด ดังนั้นหลังใส่แอโนเทชันแล้ว เขียนอย่างน้อยหนึ่ง test assertion ว่าฟิลด์หลักไม่เป็น null
4. การแก้ที่สาม: type erasure — อย่า deserialise ลง List เปล่าเด็ดขาด
เครื่องมือส่งมอบการประกาศฟิลด์ List<Item> ให้คุณ แต่สิ่งที่เติมลิสต์นั้นจริง ๆ คือ Jackson deserialise ที่ระดับบนสุดแบบนี้ตกลงกับดัก type erasure ทันที:
// ผิด: สมาชิกตอนรันไทม์คือ LinkedHashMap
List<Item> items = mapper.readValue(json, List.class);
// ถูก: TypeReference คง generic parameter ไว้
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});
readValue(json, List.class) คอมไพล์ผ่าน รันได้ด้วย แต่ generic ของ Java มีอยู่ตอนคอมไพล์เท่านั้น ตอนรันไทม์ Jackson เห็นแค่ List สมาชิกจึงตกลงมาเป็น LinkedHashMap และไม่พังทันที — พังตอน item.getSku() ครั้งแรกที่ไหนสักแห่ง โยน ClassCastExceptionให้ที่ห่างไกลจากจุดที่ทำผิด แบบที่ถูกต้อง 3 แบบ เลือกตามสถานการณ์:
// 1. ระดับบนสุดคืออาร์เรย์
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});
// 2. ฟิลด์ของออบเจกต์ระดับบนสุด
JsonNode root = mapper.readTree(json);
List<Item> items = mapper.convertValue(root.get("items"), new TypeReference<List<Item>>() {});
// 3. สร้าง JavaType อย่างชัดเจน
JavaType type = mapper.getTypeFactory().constructCollectionType(List.class, Item.class);
List<Item> items = mapper.readValue(json, type);
ฝั่ง Gson เทียบเท่าคือ TypeToken ทั้งสามคู่มือตั้งเป็นหัวข้อแยกเหมือนกัน เพราะมันพังช้า และรายงานผิดที่
5. การแก้ที่สี่: ฟิลด์ที่ไม่รู้จัก — อย่าปล่อยให้ API วิวัฒน์ทำ deserialization พัง
API เพิ่มฟิลด์ใหม่เป็นวิวัฒนาการธรรมดาที่สุด แต่พฤติกรรมค่าเริ่มต้นของ Jackson คือโยน UnrecognizedPropertyException เมื่อเจอฟิลด์ไม่รู้จัก — คู่ค้าเพิ่มหนึ่งฟิลด์ บริการคุณเริ่มส่ง error ทันที ปิดมันสองระดับ:
// ระดับคลาส: ยอมรับฟิลด์ไม่รู้จักเฉพาะคลาสนี้
@JsonIgnoreProperties(ignoreUnknown = true)
public class Root { ... }
// ทั้งระบบ: mapper ทั้งตัวยอมรับ
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
ปิดแล้วมีปัญหากระจกตามมา: การยอมรับก็ทำให้ชื่อฟิลด์ที่พิมพ์ผิดเงียบไปด้วย เปิดทางให้โหมดพัง "null เงียบ ๆ" จากหัวข้อก่อนหน้าวิ่งสบาย ทางกลางคือรันโปรดักชันโดยปิดการยอมรับ ส่วนชุดทดสอบเปิดไว้ เพื่อให้พิมพ์ผิดโผล่ตอนทดสอบ Gson เงียบฟิลด์ไม่รู้จักเป็นค่าเริ่มต้นอยู่แล้ว — เพราะงั้นจึงดูสบายตอนเชื่อมต่อเร็ว ๆ และราคาความสบายนั้นคือคุณไม่มีวันรู้ว่าคู่ค้าเพิ่มอะไรมา
6. การแก้ที่ห้า: วันที่และ constructor ไม่มีพารามิเตอร์
เครื่องมือ map created_at เป็น String ซึ่งจริง ๆ เป็นค่าเริ่มต้นที่ปลอดภัยที่สุด: ใน JSON วันที่ก็แค่สตริง รูปแบบบนสายอาจเป็น ISO-8601, Unix timestamp หรือแบบกำหนดเอง — ตัวอย่างเดาไม่ได้ว่าคุณต้องการแบบไหน ต้องการชนิดวันที่จริงเมื่อไร เติมสองบรรทัด:
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
// ตอน serialize ปล่อย ISO-8601 แทนอาร์เรย์ timestamp
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
public class Root {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
}
constructor ไม่มีพารามิเตอร์เป็นเช็กบังคับอีกข้อ: Jackson deserialise โดยสร้างออบเจกต์ผ่านมันแล้วค่อย set ฟิลด์ทีละตัว เขียน constructor มีพารามิเตอร์ลงไปตัว default หาย — deserialization พังทันที ใช้ Lombok เพิ่ม @NoArgsConstructor; ใช้ record ของ Java 17 ส่งทั้งปัญหาให้ส่วนรองรับ record ของ Jackson 2.12+ ที่จับคู่ผ่านพารามิเตอร์ constructor และยกเงื่อนไขนี้ออกไปเลย
7. สองเรื่องโปรดักชัน: ใช้ ObjectMapper ร่วมกันและตรวจค่าที่รับเข้า
เครื่องมือให้คลาส; สองเรื่องระดับสถาปัตยกรรมยังค้างตอนรันไทม์ หนึ่ง, การสร้าง ObjectMapper มีราคาแพงและมัน thread-safe — ใช้ instance เดียวทั้งแอปพลิเคชัน เรียก new ObjectMapper() ในเมธอดเป็น anti-pattern ด้านประสิทธิภาพที่พบบ่อยที่สุด และ instance ที่ Spring Boot inject ให้ตอน default มีอยู่ก็เพราะเหตุผลนี้แหละ naming strategy, JavaTimeModule และนโยบายฟิลด์ไม่รู้จักควรอยู่บน instance เดียวนั้น อย่ากระจายตามจุดเรียก
สอง, deserialization สำเร็จบอกว่าชนิดตรงกัน ไม่ได้บอกว่าค่าถูกต้องตามกฎ ราคาติดลบ จำนวนเป็นศูนย์ อีเมลผิดรูป — Jackson ไม่สน การตรวจค่ารับเข้าเป็นงานของ Bean Validation (hibernate-validator) เติมแอโนเทชันบนโครงที่สร้างไว้:
public class CreateOrderRequest {
@NotNull
@DecimalMin(value = "0.01")
private BigDecimal price;
@Min(1)
private Integer qty;
@Email
private String contact;
}
ใน Spring @Valid ดักรับ request ก่อนเข้า business logic ชั้นนี้คือสิ่งที่เครื่องมือสร้างไม่มีทางให้ได้ — มันอ่านได้จากตัวอย่างว่า "ฟิลด์หน้าตาเป็นอย่างไร" แต่ไม่อ่านได้ว่า "ฟิลด์นี้มีข้อบังคับทางธุรกิจอะไร"
8. เส้นแบ่งระหว่างสร้างกับเขียนมือ
เมื่อไรใช้เครื่องมือสร้าง เมื่อไรเขียนมือ เส้นแบ่งชัดเจน:
- ใช้เครื่องมือสร้าง: response ฟิลด์เยอะ การเชื่อมต่อบุคคลที่สามแบบครั้งเดียว สร้างต้นแบบเร็ว มันกำจัดแค่กลุ่มข้อผิดพลาดเดียว — ลอกแบบกลไกผิด — และส่งโครงที่ชื่อ-ชนิดตรงกันมาให้ในไม่กี่วินาที
- เขียนมือหรือปรับแต่งลึก: เงินกับจำนวนเต็มยาวต้องเปลี่ยนชนิด, naming strategy ต้องตัดสินใจระดับกลาง, ออบเจกต์ domain หลักต้องมีแอโนเทชันตรวจสอบ พวกนี้เป็นการตัดสินใจเชิงธุรกิจที่ payload ตัวอย่างตัดสินไม่ได้
- เงื่อนไขร่วมของทั้งสองทาง: ยืนยันก่อนว่า JSON ต้นทางถูกต้อง เครื่องมือสร้างยินดีสร้างคลาสจาก payload พัง ๆ แล้วทิ้งกับดักไว้ตอน deserialization; ลองผ่านหน้าตรวจสอบความถูกต้องของเว็บก่อนวางลงไป สิบวินาที ไม่ว่าทางไหน
เครื่องมือสร้างเก็บงาน "จากศูนย์ถึงโครง" ห้าการแก้ในบทความนี้เก็บงาน "จากโครงถึงโปรดักชัน" ลำดับกลับไม่ได้ และก้าวไหนก็ข้ามไม่ได้
FAQ
คลาสที่สร้างได้ใช้ตรง ๆ ใน Spring Boot ได้ไหม
ตอนคอมไพล์ได้ ก่อนรันจริงควรเติมสามอย่าง: ฟิลด์เงินเป็น BigDecimal, naming strategy หรือ @JsonProperty สำหรับ API แบบ snake_case และ @JsonIgnoreProperties(ignoreUnknown = true) เพื่อรอดจากวิวัฒนาการของ API แอโนเทชัน Bean Validation ตามกฎธุรกิจของคุณ
ทำไมเครื่องมือ map ตัวเลขทุกตัวเป็น Double แทนที่จะเป็น long หรือ BigDecimal
ตัวอย่างแยก "จำนวนเต็มหรือทศนิยม" ได้ แต่ไม่ได้แยกความหมายเชิงธุรกิจ 2 เป็นจำนวนนับก็ได้ เป็น ID ก็ได้; เลขออเดอร์ 20 หลักกับ ¥19.99 ใน JSON ต่างก็เป็นแค่ number เผื่อการตัดสินใจเชิงความหมายให้ผู้ใช้เป็นขอบเขตที่สมเหตุสมผลของเครื่องมือสร้างทางเดียว — ราคาคือตารางเปลี่ยนในหัวข้อ 2 ต้องไล่ดูเอง
@Data ของ Lombok หรือ getter/setter เขียนมือ — เลือกอะไร
ทีมใช้ Lombok อยู่แล้วก็หยิบ @Data โค้ดซ้ำน้อยสุด ไม่อยากแนบ plugin ตอนคอมไพล์หรือชนกับ annotation processor อื่นก็เขียนมือ สำหรับ Jackson ไม่ต่างกัน; มันมอง getter/setter หรือการมองเห็นของฟิลด์
ออบเจกต์ซ้อนกลายเป็น inner class แบบ static — มีผลไหม
กับ Jackson ไม่มี; inner class แบบ static สร้างอินสแตนซ์ได้อิสระ แต่เพื่อดูแลระยะยาว ทีมส่วนใหญ่ดึงมันออกเป็นคลาสระดับบนสุดใน package เพื่อนำกลับมาใช้และทดสอบแยก โครงสร้าง inner class ของเครื่องมือสร้างเนื่องมาเพื่อดูเร็ว ๆ ไม่ใช่สำหรับ codebase ที่มีชีวิต
ฟิลด์ List โยน ClassCastException ตอน deserialise — หาจุดยังไง
เช็กก่อนว่า deserialization ใช้ List.class เปล่าหรือเปล่า: สมาชิกจริง ๆ คือ LinkedHashMap และ error พุ่งตอนอ่านฟิลด์ เปลี่ยนไปใช้ TypeReference ถ้ายังพัง วาง exception ลงหน้า ค้นหาข้อความ error ของเว็บแล้วไล่ตามรายการของภาษาคุณ