項目對外提供 REST API 時,面臨典型衝突:
Content-Type: application/json,請求體直接攜帶 JSON,結構清晰、巢狀對象自然表達、前端友好、Postman 測試便捷Content-Type: application/x-www-form-urlencoded,表單預設編碼,key1=value1&key2=value2 扁平結構,嵌套對象序列化極其痛苦、手工測試幾乎不可能核心需求:單一 Controller 方法同時兼容兩種 Content-Type,不修改現有調用方,新調用方使用 JSON。
| Content-Type | Controller 參數註解 | 底層處理器 | 適用場景 |
|---|---|---|---|
| application/json | @RequestBody | MappingJackson2HttpMessageConverter | 複雜對象、巢狀結構、陣列 |
| application/x-www-form-urlencoded | 無註解 或 @ModelAttribute | FormHttpMessageConverter | 簡單扁平表單、文件上傳 |
關鍵衝突:加上 @RequestBody 後,表單請求會報 400 Bad Request(Required request body is missing);去掉 @RequestBody,JSON 請求無法綁定到對象。
核心思路:實現 HttpMessageConverter<Object>,內部委託 FormHttpMessageConverter 解析表單為 MultiValueMap,針對特定欄位(如 jsonParam)進行二次反序列化,最後統一由 Jackson 轉換為目標對象。
public class UnifiedHttpMessageConverter implements HttpMessageConverter<Object> {
private final FormHttpMessageConverter formConverter = new FormHttpMessageConverter();
private final ObjectMapper objectMapper = new ObjectMapper();
// 單例 MultiValueMap Class 引用,避免重複獲取
private static final LinkedMultiValueMap<String, String> EMPTY_MAP = new LinkedMultiValueMap<>();
private static final Class<? extends MultiValueMap<String, ?>> MAP_CLASS =
(Class<? extends MultiValueMap<String, ?>>) EMPTY_MAP.getClass();
@Override
public boolean canRead(Class<?> clazz, MediaType mediaType) {
// 支援表單類型且 Jackson 能序列化目標類型
return formConverter.canRead(MAP_CLASS, mediaType)
&& objectMapper.canSerialize(clazz);
}
@Override
public boolean canWrite(Class<?> clazz, MediaType mediaType) {
return false; // 僅處理讀取
}
@Override
public List<MediaType> getSupportedMediaTypes() {
return formConverter.getSupportedMediaTypes(); // 繼承表單支援類型
}
@Override
public Object read(Class<?> clazz, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException {
// 1. 先用表單轉換器解析為單值 Map
Map<String, Object> inputMap = formConverter
.read(MAP_CLASS, inputMessage).toSingleValueMap();
// 2. 關鍵處理:若存在約定欄位 jsonParam,進行二次反序列化
final String JSON_PARAM_KEY = "jsonParam";
if (inputMap.containsKey(JSON_PARAM_KEY)) {
String jsonStr = inputMap.get(JSON_PARAM_KEY).toString();
// 反序列化為通用 Map 或具體 DTO,這裡示範通用處理
Object jsonObj = objectMapper.readValue(jsonStr, Map.class);
inputMap.put(JSON_PARAM_KEY, jsonObj);
}
// 3. 統一由 Jackson 將處理後的 Map 轉換為目標對象
return objectMapper.convertValue(inputMap, clazz);
}
@Override
public void write(Object o, MediaType contentType, HttpOutputMessage outputMessage) {
throw new UnsupportedOperationException("僅支援請求讀取");
}
}
<mvc:annotation-driven>
<mvc:message-converters>
<bean class="com.config.UnifiedHttpMessageConverter" />
<!-- 保留其他默認轉換器 -->
</mvc:message-converters>
</mvc:annotation-driven>

@PostMapping("/search")
public Result search(@RequestBody SearchRequest request) {
// 同時支援:
// 1. 表單提交:jsonParam={"keyword":"xxx","page":1}&token=abc
// 2. JSON 提交:{"keyword":"xxx","page":1,"token":"abc"}
return service.search(request);
}

| 優點 | 缺點/限制 |
|---|---|
| 零侵入業務代碼,Controller 保持乾淨 | 強依賴約定欄位名 jsonParam,非通用方案 |
| 完美兼容現有表單調用方,零升級成本 | convertValue 不支援泛型類型(如 CommonParamInfoDto<SearchParamInfo<ProductSearchInfo>>) |
| 新調用方享受 JSON 結構化優勢 | 雙重解析帶來微小效能開銷 |
| Postman 測試即刻支援 JSON 格式 | 錯誤訊息追蹤相對困難 |
TypeReference 或自定義 JavaType 解決 convertValue 泛型擦除問題ContentNegotiationManager 根據 Accept 標頭動態選擇響應格式| 方案 | 實現複雜度 | 兼容性 | 推薦指數 |
|---|---|---|---|
| 自定義 HttpMessageConverter(本文) | 中 | 完全兼容 | ⭐⭐⭐⭐ |
| 兩個 Controller 方法(不同路徑/參數) | 低 | 需調用方配合 | ⭐⭐ |
| Servlet Filter 預處理轉換 Content-Type | 中 | 完全兼容 | ⭐⭐⭐ |
| @RequestParam Map + 手工綁定 | 高(業務代碼污染) | 完全兼容 | ⭐ |