前言
在 Spring 應用程式中使用 RestTemplate 進行 API 呼叫時,經常遇到一個令人困惑的問題:回傳的 JSON 資料中的 Object 類型會被自動轉換為 LinkedHashMap
,導致後續無法直接使用強型別的物件。
問題現象
典型錯誤場景
1 2 3 4 5 6 7 8 9
| ApiResponse apiResponse = restTemplate.exchange(url, HttpMethod.POST, requestEntity, ApiResponse.class);
if (apiResponse.getData() instanceof LinkedHashMap) { ObjectMapper mapper = new ObjectMapper(); MyDto convertedData = mapper.convertValue(apiResponse.getData(), MyDto.class); }
|
常見錯誤訊息
1 2
| java.lang.IllegalArgumentException: Unrecognized field "isDiplomat" (class com.example.dto.UserDto), not marked as ignorable
|
問題根本原因
為什麼會變成 LinkedHashMap?
Spring 的 RestTemplate 在反序列化 JSON 時,對於泛型類型的處理存在局限性:
- 類型擦除:Java 的泛型在執行時會被擦除,
ApiResponse<T>
變成 ApiResponse
- 預設行為:Jackson 無法確定具體的泛型類型,會將嵌套物件轉換為
LinkedHashMap
- 缺乏類型資訊:RestTemplate 只知道最外層的類型,對於內層的泛型類型無從得知
JSON 結構示例
1 2 3 4 5 6 7 8 9
| { "code": "2000", "message": "Success", "data": { "id": "123", "name": "測試用戶", "isDiplomat": false } }
|
解決方案詳解
方案 1:配置 ObjectMapper 忽略未知欄位(推薦)
這是最簡單且實用的解決方案:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Override public ApiResponse postRequest(String endpoint, Object requestBody) { if (apiResponse != null && apiResponse.getData() instanceof LinkedHashMap) { ObjectMapper mapper = new ObjectMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false); mapper.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true); Object convertedData = mapper.convertValue(apiResponse.getData(), TargetDto.class); apiResponse.setData(convertedData); } return apiResponse; }
|
方案 2:使用 ParameterizedTypeReference
保持完整的泛型類型資訊:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public <T> ApiResponse<T> postRequest(String endpoint, Object requestBody, Class<T> responseType) { try { String url = buildUrl(endpoint); HttpEntity<Object> requestEntity = new HttpEntity<>(requestBody, headers);
ParameterizedTypeReference<ApiResponse<T>> typeRef = ParameterizedTypeReference.forType( ResolvableType.forClassWithGenerics(ApiResponse.class, responseType).getType() );
ResponseEntity<ApiResponse<T>> response = restTemplate.exchange( url, HttpMethod.POST, requestEntity, typeRef);
return response.getBody(); } catch (Exception e) { log.error("Request failed: {}", e.getMessage(), e); throw new ApiException("API_ERROR", endpoint); } }
|
方案 3:使用 TypeReference(最靈活)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| public <T> ApiResponse<T> postRequest(String endpoint, Object requestBody, TypeReference<ApiResponse<T>> typeReference) { try { String url = buildUrl(endpoint); HttpEntity<Object> requestEntity = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> response = restTemplate.exchange( url, HttpMethod.POST, requestEntity, String.class);
ObjectMapper objectMapper = new ObjectMapper(); objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); return objectMapper.readValue(response.getBody(), typeReference); } catch (Exception e) { log.error("Request failed: {}", e.getMessage(), e); throw new ApiException("API_ERROR", endpoint); } }
TypeReference<ApiResponse<UserDto>> typeRef = new TypeReference<ApiResponse<UserDto>>() {}; ApiResponse<UserDto> result = postRequest(endpoint, requestBody, typeRef);
|
方案 4:全域 ObjectMapper 配置
在配置類中統一設定:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| @Configuration public class JacksonConfig { @Bean @Primary public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false); mapper.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); return mapper; } @Bean public RestTemplate restTemplate() { RestTemplate restTemplate = new RestTemplate(); List<HttpMessageConverter<?>> messageConverters = restTemplate.getMessageConverters(); messageConverters.removeIf(converter -> converter instanceof MappingJackson2HttpMessageConverter); messageConverters.add(new MappingJackson2HttpMessageConverter(objectMapper())); return restTemplate; } }
|
DTO 設計最佳實踐
使用 JsonIgnoreProperties 註解
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| @JsonIgnoreProperties(ignoreUnknown = true) @Data @Builder @AllArgsConstructor @NoArgsConstructor public class UserDto { private String id; private String name; @JsonProperty("isDiplomat") private Boolean diplomat; private Boolean isActive; @JsonFormat(pattern = "yyyy-MM-dd") private LocalDate birthday; @JsonIgnore private String internalField; }
|
處理不同的資料類型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public class DataTypeHandlingDto { @JsonProperty("count") private Integer count; @JsonProperty("tags") private List<String> tags = new ArrayList<>(); @JsonProperty("address") private AddressDto address; @JsonDeserialize(using = CustomDateDeserializer.class) private Date customDate; }
|
錯誤處理和調試
調試技巧
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| public ApiResponse debugConversion(String endpoint, Object requestBody) { ApiResponse apiResponse = callApi(endpoint, requestBody); if (apiResponse != null && apiResponse.getData() instanceof LinkedHashMap) { LinkedHashMap<String, Object> rawData = (LinkedHashMap<String, Object>) apiResponse.getData(); log.info("Raw data keys: {}", rawData.keySet()); log.info("Raw data structure: {}", rawData); rawData.forEach((key, value) -> { log.info("Field: {} = {} (type: {})", key, value, value != null ? value.getClass().getSimpleName() : "null"); }); try { ObjectMapper mapper = createConfiguredMapper(); Object convertedData = mapper.convertValue(rawData, TargetDto.class); apiResponse.setData(convertedData); } catch (IllegalArgumentException e) { log.error("Conversion failed for field: {}", e.getMessage()); throw new DataConversionException("Failed to convert API response", e); } } return apiResponse; }
private ObjectMapper createConfiguredMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false); mapper.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true); return mapper; }
|
常見錯誤及解決方法
錯誤類型 |
原因 |
解決方法 |
Unrecognized field |
JSON 欄位在 DTO 中不存在 |
使用 @JsonIgnoreProperties(ignoreUnknown = true) |
Cannot deserialize value |
資料類型不匹配 |
檢查 DTO 欄位類型,使用包裝類型 |
Failed on null for primitives |
primitive 類型接收到 null |
改用包裝類型 (Boolean, Integer 等) |
Date parsing error |
日期格式不匹配 |
使用 @JsonFormat 指定格式 |
效能考量
ObjectMapper 重用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Component public class ApiResponseConverter { private final ObjectMapper objectMapper; public ApiResponseConverter() { this.objectMapper = new ObjectMapper(); configureMapper(); } private void configureMapper() { objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); objectMapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false); } public <T> T convertValue(Object fromValue, Class<T> toValueType) { return objectMapper.convertValue(fromValue, toValueType); } }
|
快取 TypeReference
1 2 3 4 5 6 7 8 9 10 11
| @Component public class TypeReferenceCache { private final Map<Class<?>, TypeReference<?>> cache = new ConcurrentHashMap<>(); @SuppressWarnings("unchecked") public <T> TypeReference<ApiResponse<T>> getApiResponseTypeReference(Class<T> dataType) { return (TypeReference<ApiResponse<T>>) cache.computeIfAbsent(dataType, k -> new TypeReference<ApiResponse<T>>() {}); } }
|
最佳實踐總結
- 優先使用方案 1:配置 ObjectMapper 忽略未知欄位,簡單有效
- DTO 設計:
- 使用
@JsonIgnoreProperties(ignoreUnknown = true)
- 使用包裝類型 (Boolean, Integer) 而非原始類型
- 合適使用
@JsonProperty
處理欄位名稱對應
- 全域配置:在生產環境中建議配置全域的 ObjectMapper
- 錯誤處理:提供詳細的調試資訊,方便問題定位
- 效能優化:重用 ObjectMapper 實例,避免重複建立
結語
LinkedHashMap 轉換問題是 Spring + Jackson 開發中的常見議題,理解其成因並選擇合適的解決方案,可以大幅提升開發效率。建議根據專案的複雜度選擇最適合的方案,同時注意效能和維護性的平衡。