JSON Toolkit

JSON เป็นคลาส Java: เครื่องมือสร้างให้แค่โครง สำหรับโปรดักชันยังต้องเติมอีก 5 เรื่อง

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

ระดับกลางอ่านประมาณ 10 นาทีเผยแพร่ 2026-11-04อัปเดต 2026-11-04
หน้าจอเครื่องมือแปลง JSON เป็นโค้ดคลาส POJO ของ Java

ย่อสั้น ๆ: ผลลัพธ์จากเครื่องมือสร้างคอมไพล์ผ่านทันที แต่ยังไม่พร้อมโปรดักชัน ค่าเริ่มต้นที่วัดได้ของเครื่องมือบนเว็บนี้ 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 ของเว็บแล้วไล่ตามรายการของภาษาคุณ