JsonNode 在 Spring 中的使用筆記

Posted by Adam on August 24, 2022
# JsonNode 在 Spring 中的使用筆記 `JsonNode` 是 Jackson 函式庫(Spring Boot 內建)用來表示「任意 JSON 結構」的物件模型, 不需要事先定義 POJO 就能讀寫 JSON。適合處理欄位不固定、動態、或第三方回傳結構多變的情境。 --- ## 1. 基本概念 - `JsonNode`:唯讀的樹狀節點介面(抽象基底) - `ObjectNode`:對應 JSON object `{}`,可讀可寫 - `ArrayNode`:對應 JSON array `[]`,可讀可寫 - `ObjectMapper`:Jackson 的核心工具,負責解析 / 產生 JsonNode ```java @Autowired private ObjectMapper objectMapper; // Spring Boot 已自動註冊為 Bean,不需自己 new ``` > Spring Boot 專案只要有 `spring-boot-starter-web`,Jackson 就已經在 classpath 裡, > `ObjectMapper` 也已經是可注入的 Bean(含自動設定的日期格式等)。 --- ## 2. 讀取 JSON 字串 / 轉出 JsonNode ```java String json = "{\"name\":\"Adam\",\"age\":30,\"tags\":[\"java\",\"spring\"]}"; JsonNode root = objectMapper.readTree(json); String name = root.get("name").asText(); // "Adam" int age = root.get("age").asInt(); // 30 JsonNode tagsNode = root.get("tags"); // ArrayNode ``` 也可以直接從 `InputStream`、`File`、`byte[]` 讀: ```java JsonNode node = objectMapper.readTree(inputStream); ``` --- ## 3. `get()` vs `path()`:最容易踩的坑 | 方法 | 欄位不存在時 | 用途建議 | |------|-------------|---------| | `get("key")` | 回傳 `null`(Java null) | 你確定欄位存在時使用 | | `path("key")` | 回傳 `MissingNode`(不是 null,是特殊 JsonNode) | 欄位可能不存在,避免 NPE 時使用 | ```java root.get("notExist"); // null -> 直接 .asText() 會 NPE root.path("notExist"); // MissingNode -> .asText() 回傳 "" 安全 root.path("notExist").isMissingNode(); // true ``` **建議:欄位不確定是否存在時一律用 `path()`,並搭配 `isMissingNode()` / `isNull()` 判斷。** --- ## 4. 常用判斷 / 取值方法 ```java node.isNull(); // JSON 的 null 值(欄位存在但值是 null) node.isMissingNode(); // 欄位完全不存在(只有 path() 會回傳這個) node.isObject(); node.isArray(); node.isTextual(); node.isNumber(); node.isBoolean(); node.asText(); // 轉字串(會自動 toString 其他型別) node.asText("預設值"); // 帶預設值版本 node.asInt(); node.asInt(0); node.asLong(); node.asDouble(); node.asBoolean(); ``` > `asXxx()` 系列方法對型別不符或節點缺失時通常回傳「安全預設值」而不是丟例外, > 這點跟直接 cast 或用 `.textValue()` 不同,要注意混用時的行為差異。 --- ## 5. 走訪 ObjectNode / ArrayNode ```java // 走訪 object 的所有欄位 Iterator<Map.Entry<String, JsonNode>> fields = root.fields(); while (fields.hasNext()) { Map.Entry<String, JsonNode> entry = fields.next(); System.out.println(entry.getKey() + " = " + entry.getValue()); } // 走訪 array for (JsonNode tag : root.path("tags")) { System.out.println(tag.asText()); } // 巢狀路徑一次取值(Jackson 2.10+) String city = root.at("/address/city").asText(); ``` --- ## 6. 建立 JSON(寫入) ```java ObjectNode obj = objectMapper.createObjectNode(); obj.put("name", "Adam"); obj.put("age", 30); obj.put("active", true); ArrayNode tags = obj.putArray("tags"); tags.add("java"); tags.add("spring"); ObjectNode address = obj.putObject("address"); address.put("city", "Taipei"); String json = objectMapper.writeValueAsString(obj); ``` 輸出: ```json {"name":"Adam","age":30,"active":true,"tags":["java","spring"],"address":{"city":"Taipei"}} ``` --- ## 7. JsonNode 與 POJO 互轉 ```java // JsonNode -> POJO MyDto dto = objectMapper.treeToValue(jsonNode, MyDto.class); // POJO -> JsonNode JsonNode node = objectMapper.valueToTree(dto); // Map -> JsonNode JsonNode node2 = objectMapper.valueToTree(someMap); ``` 適合「部分欄位固定、部分欄位動態」的情境:先轉成 JsonNode 抓動態部分, 再把已知結構的部分轉 POJO。 --- ## 8. 在 Spring MVC / Controller 中使用 ### 8.1 接收任意 JSON body ```java @PostMapping("/webhook") public ResponseEntity<Void> handleWebhook(@RequestBody JsonNode payload) { String eventType = payload.path("type").asText(); if ("payment.success".equals(eventType)) { JsonNode data = payload.path("data"); // ... } return ResponseEntity.ok().build(); } ``` 適合第三方 webhook(金流、通知服務)這種欄位可能隨版本增減、 但你只關心其中幾個欄位的情境 —— 不用為了每個 webhook 事件類型都建一個 DTO。 ### 8.2 回傳動態結構 ```java @GetMapping("/dynamic") public JsonNode dynamicResponse() { ObjectNode result = objectMapper.createObjectNode(); result.put("status", "ok"); result.putArray("items").add("a").add("b"); return result; } ``` Spring 會透過 Jackson `HttpMessageConverter` 自動序列化 `JsonNode`, 效果等同回傳一般 DTO。 ### 8.3 搭配 RestTemplate / WebClient 呼叫外部 API 且回應結構不穩定 ```java JsonNode response = restTemplate.getForObject(url, JsonNode.class); String value = response.path("result").path("value").asText(); ``` 不需要為每個上游 API 都建對應的 Response DTO,尤其是欄位常變動或 只需要抓其中一兩個值的情境。 --- ## 9. 常見使用情境(何時該用 JsonNode 而非 POJO) - Webhook / callback 接收端:payload 結構隨事件類型不同 - 呼叫外部 / 第三方 API,回應結構不穩定或只需要部分欄位 - 需要「原封不動轉存」JSON(例如寫入 log、存 DB 的 JSONB 欄位) - 動態 schema、多版本相容(新舊欄位並存) - 寫通用的 JSON 比對 / 轉換工具,不知道會吃到什麼結構 **不建議**用在:結構固定、有明確 API 合約的情境 —— 那種情況用 POJO/DTO 可以拿到編譯期型別檢查與更好的可讀性,不要為了「彈性」放棄型別安全。 --- ## 10. 小提醒 - `JsonNode` 本身是唯讀的,`ObjectNode` / `ArrayNode` 才可變 - 序列化時 `JsonNode` 會照原樣輸出,不會套用 `@JsonProperty` 等 POJO 上的註解 - 大量資料時 `readTree()` 會整棵樹讀進記憶體,資料量非常大時考慮 Streaming API(`JsonParser`) - 判斷「欄位不存在」與「欄位值為 null」是兩件事,混用 `get`/`path` 容易踩雷(見第 3 節)