JSON 工具箱

JSON 轉 Java 類:生成器只給骨架,生產還要補的五件事

結論:用生成器實測 JSON → Java 的預設輸出——number 映射成 Double、嵌套物件變 static 內部類別、不生成任何注解。對照三路英文圈的實戰經驗,給出金額精度、欄位改名、泛型擦除、未知欄位、日期與無參建構子五個必須手工補齊的位置與可複製程式碼。

進階約 10 分鐘發佈於 2026-11-04更新於 2026-11-04
把 JSON 生成 Java POJO 類別程式碼的轉換工具介面封面

結論:生成器十秒鐘給出的類別結構可以直接進編譯,但不能直接進生產。本站生成器的實測輸出是 number→Double、嵌套物件→static 內部類別、不生成任何 Jackson 注解——金額精度、欄位改名、泛型擦除、未知欄位、日期這五件事,它一件都不會替你做。

把一份介面回應變成 Java 類別是純機械勞動:看著 JSON 抄欄位,套上 getter/setter。機械勞動的代價是那把看不見的刀——屬性名抄錯一個字母不會編譯報錯,欄位只會靜默地變成 null,等你到生產日誌裡才發現。生成類別結構就是為了從第一天對齊名字和型別,但生成器能對齊的只有「從樣本裡看得見」的部分。

一、先把生成器跑一遍:它到底給了什麼

拿一份有代表性的介面回應,貼進本站的 JSON 轉 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 是單獨的 static 內部類別。逐條對照映射規則:

JSON 裡的形態 生成的 Java 型別 備註
"name": "王五" String 字串恆為 String
"qty": 2 Double 所有數字都是 Double,見第二節
"vip": true Boolean 包裝型別,允許 null
"buyer": {...} Buyer(static 內部類別) 嵌套物件自成一類
"items": [...] List<Item> 元素型別取自首元素
order_id orderId 蛇形命名自動轉駝峰

類別名走 PascalCase(Root),欄位走 camelCase(orderId),撞上 Java 關鍵字(class、new、int)時自動加後綴避開。可選參數三個:packageName 套件名、className 根類別名、useLombok 生成 @Data 還是手寫 getter/setter。開啟 Lombok 後整段樣板程式碼收斂成幾個注解。

這份骨架有兩個邊界要心裡有數。第一,它不生成任何 Jackson/Gson 注解——@JsonProperty、@JsonIgnoreProperties、@JsonFormat 一概沒有,因為注解決定的是「反序列化怎麼執行」,而樣本資料推不出執行策略。第二,它沒有反向轉換:從 Java 類別反推 JSON 不在能力範圍內,這與 JSON 轉 TypeScript Interface 那條前向路徑是同一種單向設計。

二、第一件要補的事:Double 不是金額該有的型別

生成器把 "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 的整數(訂單號、雪花 ID)如果走 Double,末幾位會被靜默捨入,20 位數字掉三位末尾是實測會發生的,機制與 JSON 大整數為什麼會丟精度 是同一個。這類欄位改成 String 或 Long,讓數字以文字形式進出。

欄位性質 生成器給的 應該改成
金額、費率 Double BigDecimal(字串建構)
訂單號、ID(可能超 2^53) Double String 或 Long
數量、頁碼這類整數 Double Integer / Long
真正參與科學計算的浮點 Double 保留

三、第二件:欄位名對不上,靠 @JsonProperty

生成器把 order_id 轉成 orderId 是「善意推測」:它假設你希望 Java 側是駝峰。但 Jackson 預設按欄位名逐字匹配 JSON 鍵,兩邊對不上時不會報錯,orderId 只會是 null。兩種修法:

欄位級——加 @JsonProperty,一個注解釘死一個映射:

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

全域級——ObjectMapper 換命名策略,整個專案不用逐欄位寫注解:

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

選哪條取決於對接面:只對接一個固定 API 用欄位級更清晰;整個團隊都對蛇形命名的外部服務時用全域策略。這裡有一個值得單獨強調的反面事實——名字寫錯的失敗模式是「靜默 null」而不是異常。jsonformater 的工具頁把這一點當作生成器存在的理由:機械抄寫正是在這一步出錯率最高。所以補完注解後,至少寫一個斷言測試確認關鍵欄位非 null。

四、第三件:泛型擦除,List 別裸著反序列化

生成器給了你 List<Item> 的欄位宣告,但真正把 JSON 陣列塞進這個 List 的是 Jackson。在頂層直接這樣寫會掉進泛型擦除的坑:

// 錯誤:執行時拿到的元素是 LinkedHashMap
List<Item> items = mapper.readValue(json, List.class);

// 正確:TypeReference 保留泛型參數
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});

readValue(json, List.class) 能編譯能執行,但 Java 的泛型只在編譯期存在,Jackson 執行時只看到 List,元素只好落成 LinkedHashMap。錯誤不會立刻爆發,等到某處 item.getSku() 才拋 ClassCastException,報錯位置離出錯位置隔了十萬八千里。三種正確寫法按場景選:

// 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。這是三路教學都單獨立條的坑,因為它失敗得晚、報得遠。

五、第四件:未知欄位,別讓 API 演進弄壞反序列化

介面加一個新欄位,是再正常不過的演進。Jackson 的預設行為卻是對未知欄位拋 UnrecognizedPropertyException——對端加了欄位,你的服務直接開始報錯。兩個層級關掉它:

// 類級:只容忍這個類別的未知欄位
@JsonIgnoreProperties(ignoreUnknown = true)
public class Root { ... }

// 全域:整個 mapper 都容忍
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

關掉之後還有一個鏡像問題:容錯意味著欄位名拼錯也不報錯了,上一節那個「靜默 null」的失敗模式從此暢通無阻。折中做法是生產環境關容忍、測試環境開著跑一遍核心介面,讓拼寫錯誤在測試期暴露。Gson 預設就是忽略未知欄位的,這也是它在快速對接場景裡省心的原因——省心的代價是你永遠不知道對端加了什麼。

六、第五件:日期與無參建構子

生成器把 created_at 映射成 String,這其實是最穩妥的預設:JSON 裡日期就是字串,格式有 ISO-8601、Unix 時間戳、自訂三態,樣本推不出你想要哪種。需要真正的日期型別時補兩條:

ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
// 序列化時輸出 ISO-8601 而不是時間戳陣列
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
public class Root {
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createdAt;
}

無參建構子是另一個必查項:Jackson 反序列化靠無參建構子建立物件再逐欄位 set。你手寫了帶參建構子,預設建構子就消失了,反序列化立刻失敗。Lombok 開啟時補 @NoArgsConstructor;用 Java 17 的 record 則直接交給 Jackson 2.12+ 的 record 支援,靠建構子參數匹配,無參建構子這條要求就沒了。

七、生產兩件事:ObjectMapper 複用與入參校驗

生成器給的是類別,執行時還有兩件架構層面的事。第一,ObjectMapper 建立成本高且執行緒安全,全應用共享一個實例——在方法裡 new ObjectMapper() 是最常見的效能反模式,Spring Boot 預設注入的那個就是為此存在。全域命名策略、JavaTimeModule、未知欄位策略都配在這一個實例上,別散落各處。

第二,反序列化成功只說明型別對得上,不說明值合法。價格是負數、數量為 0、信箱格式不對,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 即在進入業務邏輯前攔截。這一層是生成器永遠不會給你的——它只能從樣本看出「欄位長什麼樣」,看不出「這個欄位的業務約束是什麼」。

八、生成與手寫的邊界

什麼時候用生成器,什麼時候手寫,邊界其實很清楚:

  • 用生成器:欄位多的介面回應、一次性對接的第三方 API、要快速出原型的場景。它消滅的是「機械抄寫出錯」這一種錯誤,十秒鐘拿到對齊了名字和型別的骨架。
  • 手寫或深度改造:金額與長整數要改型別、命名策略要全域定、核心領域物件要加校驗注解。這幾件是業務決策,樣本資料推不出來。
  • 兩邊的共同前提:先確認源 JSON 本身合法。生成器拿到殘缺輸入照樣生成,坑留給了反序列化那一步;貼進工具頁前用本站校驗頁過一遍,十秒鐘的事。

生成器解決的是「從 0 到骨架」,本文五件事解決的是「從骨架到生產」。次序不能反,也不能省。

常見問題

生成器輸出的類別能直接用於 Spring Boot 嗎?

編譯期可以,執行期建議先補三件事:金額欄位改 BigDecimal、對接蛇形命名的 API 時定命名策略或加 @JsonProperty、開 @JsonIgnoreProperties(ignoreUnknown = true) 防 API 演進。Bean Validation 注解按業務補。

為什麼生成器把數字都映射成 Double,不是 long 或 BigDecimal?

樣本資料只能區分「整數還是小數」,區分不了業務語義。一個 2 可能是數量也可能是編號,20 位訂單號和 19.99 元在 JSON 裡都是 number。把語義決策留給使用者,是這類前向生成器的合理邊界,代價就是第二節那張替換表要自己過一遍。

Lombok 的 @Data 和手寫 getter/setter 選哪個?

團隊已用 Lombok 選 @Data,樣板程式碼最少;不想引入編譯期外掛或與其他注解處理器衝突時選手寫。兩者對 Jackson 沒有區別,它只認 getter/setter 或欄位可見性。

嵌套物件生成的是 static 內部類別,有影響嗎?

對 Jackson 沒有影響,static 內部類別可以獨立實例化。但工程上更常見的是把它拆成獨立的頂層類別放進套件裡,便於複用與單獨測試——生成器的內部類別結構適合快速查看,不適合長期維護。

List 欄位反序列化報 ClassCastException 怎麼定位?

先查是不是裸 List.class 反序列化:元素實際是 LinkedHashMap,取欄位時才炸。改用 TypeReference 後,如果還報錯,用本站報錯資訊反查頁把異常資訊貼進去按語言查對應條目。

參考資料

相關文章

JSON 轉 TypeScript:介面、型別守衛與生成邊界

從範例 JSON 生成 interface 只完成了一半:可選欄位、null、聯合型別、日期與大整數都要人工確認,生成結果本身也不做執行時校驗。這篇給出生成、收斂與守衛的完整流程。

進階約 9 分鐘更新於 2026-11-04

大整數為什麼變成 9007199254740992

超過 2^53-1 的整數在 IEEE 754 雙精度裡無法精確表示,JSON.parse 會靜默把末位改掉。訂單號、雪花 ID、金額都踩過這個坑,正確解法是全程當字串傳。

進階約 4 分鐘更新於 2026-11-04

JSON 報錯資訊反查表:從報錯原文直接定位改法

按 JSON.parse 報錯原文反查原因與改法:12 條高頻報錯對應 Chrome、Firefox、Safari 三種措辭,涵蓋 HTML 錯誤頁、截斷輸入、重複解析、NDJSON 拼接等線上場景,並解釋報錯位置為什麼常指向「下游」。

實戰約 8 分鐘更新於 2026-11-04