💥 记一次 Spring Boot + Vue 前后端 JSON 格式不匹配引发的惨案 (及解决方案) 😱
哈喽大家好!👋 今天想跟大家分享一次我在开发中遇到的经典前后端交互 Bug:后端期望接收 List
(数组),前端却发送了单个 Object
(对象),导致 Spring Boot 后端使用 Jackson 解析 JSON 时直接抛出 HttpMessageNotReadableException
异常。这个问题虽然常见,但排查过程和解决方案还是值得记录一下的。希望能给遇到类似问题的小伙伴们一些参考。🚀
😭 问题现象:一声叹息的 ERROR
那天,我正在愉快地开发一个“批量寄售入库”的功能。前端是一个 Vue 表格,用户可以输入多行商品的入库数量,点击“保存”后,理论上应该将所有输入的行一次性提交给后端。
然而,现实是残酷的 😭,当我点击保存按钮时,浏览器控制台“波澜不惊”,而后端却默默打印出了这样的错误日志:
2025-04-29 18:25:00.854 ERROR 87141 --- [nio-8087-exec-3] c.p.common.exception.ExceptionHandle : 参数解析失败
org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize instance of `java.util.ArrayList` out of START_OBJECT token; nested exception is com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize instance of `java.util.ArrayList` out of START_OBJECT token
at [Source: (PushbackInputStream); line: 1, column: 1]
at org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.readJavaType(AbstractJackson2HttpMessageConverter.java:245)
# ... (省略一长串堆栈信息)
Caused by: com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize instance of `java.util.ArrayList` out of START_OBJECT token
at [Source: (PushbackInputStream); line: 1, column: 1]
# ... (省略 Caused by 堆栈信息)
错误信息的核心非常明确:
Cannot deserialize instance of \
java.util.ArrayList` out of START_OBJECT token`
翻译过来就是:“我(Jackson 库)本来想帮你把请求体里的 JSON 解析成一个 ArrayList
(Java 列表/数组),结果 JSON 数据是以 {
(START_OBJECT,对象开始) 开头的,而不是我期望的 [
(数组开始)!解析不了,报错!”
很明显,后端接口期望接收一个 JSON 数组 [...]
,但前端实际发送的是一个 JSON 对象 {...}
。
🤔 排查过程:抽丝剥茧找真凶
-
后端确认 ✅: 首先检查 Spring Boot Controller 层的代码。果然,处理这个请求的方法签名是这样的:
@PostMapping("/saveConsignmentSummary") public ResponseEntity<?> saveConsignmentSummary(@RequestBody List<ConsignmentSummaryDto> summaryList) { // ... 业务逻辑 ... // 这里的 @RequestBody List<ConsignmentSummaryDto> 表明期望接收一个 DTO 对象的列表 }
后端确实期望接收一个
List
。 -
前端审视 👀: 接着去看 Vue 组件中
handleSubmit
的逻辑。不看不知道,一看吓一跳!之前的代码是这样的:// 错误示范 ❌ private async handleSubmit() { // ... 省略校验 ... this.loading = true; try { // 错误点:在循环中为每一项单独调用 API const promises = this.list.map(item => saveConsignmentSummary({ // 每次调用都传递单个对象 {} consignmentSettlementId: this.consignmentSettlementId, productId: item.productId, // ... 其他 item 属性 ... }).then(response => response.data) ); const results = await Promise.all(promises); // 等待多个请求完成 // ... 处理多个结果 ... } catch (error) { /* ... */ } finally { /* ... */ } }
这段代码的意图是遍历表格数据 (
this.list
),然后为 每一行数据 都 单独调用一次saveConsignmentSummary
API,请求体自然就是 单个的对象{}
。这完全违背了后端期望一次性接收一个包含 所有行数据的数组[]
的设计初衷! -
真相大白 👍: 前后端预期不一致!前端以为要一条一条保存,后端却等着接收一个包含所有条目的“包裹”(数组)。
💡 解决方案:拨乱反正,重归于好
核心思路:前端必须改造 handleSubmit
方法,将所有需要提交的数据组装成一个数组,然后只调用一次 API,将整个数组作为请求体发送。
-
前端 Vue 组件改造 (
handleSubmit
) 🛠️:// 正确示范 ✅ import { saveConsignmentSummary, ConsignmentSummaryDto } from '@/api/consignment-settlement'; // 假设 DTO 接口已导出 private async handleSubmit() { if (!this.validateData()) return; // 优化后的校验 this.loading = true; try { // 1. 构建要发送的数组 (payload) const payload: ConsignmentSummaryDto[] = this.list .filter(item => { /* 过滤出有效的、需要提交的行 */ }) .map(item => ({ // 将每行数据转换为 DTO 格式 consignmentSettlementId: this.consignmentSettlementId, productId: item.productId, code: item.code, name: item.name, jancode: item.jancode, stockInCount: Number(item.stockInCount), currentSettlementPrice: item.settlementPrice, status: 1 })); // 2. 空数组检查 if (payload.length === 0) { this.$message.warning('没有有效的入库数据可提交'); this.loading = false; return; } // 3. 单次调用 API,传递整个数组 const response = await saveConsignmentSummary(payload); // payload 是 ConsignmentSummaryDto[] // 4. 处理单个响应 (代表整个批量操作的结果) if (response?.code === 0) { this.$message.success('保存成功'); this.$emit('after-save'); this.closeDialog(); } else { this.$message.error(response?.message || '部分或全部数据保存失败'); } } catch (error) { /* ... */ } finally { this.loading = false; } }
关键改动:
- 移除
map
+Promise.all
的循环调用结构。 - 使用
filter
和map
构建一个包含所有有效数据的payload
数组。 - 只调用一次
saveConsignmentSummary(payload)
。 - 处理返回的单个响应。
- 移除
-
前端 API 定义调整 (
src/api/...ts
) 🔧:
仅仅修改 Vue 组件还不够!因为 TypeScript 需要类型检查,我们还需要确保调用saveConsignmentSummary
时,传递的参数类型(数组)与函数定义匹配。所以,需要修改 API 定义文件中的函数签名:// api/consignment-settlement.ts export interface ConsignmentSummaryDto { /* ... DTO 字段定义 ... */ } // 旧定义 ❌ (参数是单个对象) // export const saveConsignmentSummary = (data: ConsignmentSummaryDto) => { ... } // 新定义 ✅ (参数是 DTO 数组) export const saveConsignmentSummary = (data: ConsignmentSummaryDto[]) => { // <--- 改成数组类型 [] return request({ url: '/api/consignmentSettlement/saveConsignmentSummary', method: 'post', data // axios 等库会自动将数组序列化为 JSON 数组 }); };
这样修改后,Vue 组件中调用
saveConsignmentSummary(payload)
时,TypeScript 就不会再报错了。
📊 表格总结:前后对比一目了然
对比项 | 修改前 (Before) ❌ | 修改后 (After) ✅ |
---|---|---|
前端逻辑 | 循环遍历列表,为每项单独发送请求 | 构建包含所有项的数组,发送单次请求 |
发送请求次数 | N 次 (N 为列表项数) | 1 次 |
请求体 (Body) | 单个 JSON 对象 {...} | 包含多个对象的 JSON 数组 [{...}, {...}] |
后端接口期望 | JSON 数组 List<DTO> | JSON 数组 List<DTO> |
结果 | HttpMessageNotReadableException 报错 😭 | 批量保存成功 🎉 |
TypeScript 检查 | API 定义与调用可能不匹配 (如后续修改) | API 定义 (DTO[] ) 与调用参数 (payload ) 匹配 |
🗺️ Mermaid 流程图:排查与解决之路
⏳ Mermaid 时序图:正确的交互流程
✨ 最终效果
经过上述修改,再次点击“保存”按钮,后端不再报错,数据被成功地批量保存到了数据库中!🎉 问题圆满解决!
🤔 反思与总结
这次 Bug 排查虽然过程不复杂,但也带来几点思考:
- 前后端接口契约至关重要: 对于批量操作,是接收数组一次性处理,还是前端循环调用单次保存接口,必须在设计阶段就明确约定好。
- 仔细阅读错误信息:
HttpMessageNotReadableException
和MismatchedInputException
已经非常清晰地指明了是 JSON 结构与目标 Java 类型不匹配的问题。读懂错误是解决问题的第一步。 - 理解框架机制: 了解 Spring Boot 如何通过
@RequestBody
和 Jackson 自动进行 JSON 到 Java 对象的映射,有助于快速定位此类问题。 - TypeScript 的优势: 在前端调整 API 调用方式后,TypeScript 的类型检查能帮助我们及时发现 API 定义文件中的函数签名也需要同步修改,避免了潜在的运行时错误。
希望这篇博客对你有所帮助!如果你也遇到过类似的“前后端格式大战”,欢迎在评论区分享你的故事和解决方案!👇👇👇