记一次 Spring Boot + Vue 前后端 JSON 格式不匹配引发的惨案 (及解决方案)`java.util.ArrayList` out of START_OBJECT token;

💥 记一次 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 对象 {...}

🤔 排查过程:抽丝剥茧找真凶

  1. 后端确认 ✅: 首先检查 Spring Boot Controller 层的代码。果然,处理这个请求的方法签名是这样的:

    @PostMapping("/saveConsignmentSummary")
    public ResponseEntity<?> saveConsignmentSummary(@RequestBody List<ConsignmentSummaryDto> summaryList) {
        // ... 业务逻辑 ...
        // 这里的 @RequestBody List<ConsignmentSummaryDto> 表明期望接收一个 DTO 对象的列表
    }
    

    后端确实期望接收一个 List

  2. 前端审视 👀: 接着去看 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,请求体自然就是 单个的对象 {}。这完全违背了后端期望一次性接收一个包含 所有行数据的数组 [] 的设计初衷!

  3. 真相大白 👍: 前后端预期不一致!前端以为要一条一条保存,后端却等着接收一个包含所有条目的“包裹”(数组)。

💡 解决方案:拨乱反正,重归于好

核心思路:前端必须改造 handleSubmit 方法,将所有需要提交的数据组装成一个数组,然后只调用一次 API,将整个数组作为请求体发送。

  1. 前端 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 的循环调用结构。
    • 使用 filtermap 构建一个包含所有有效数据的 payload 数组。
    • 只调用一次 saveConsignmentSummary(payload)
    • 处理返回的单个响应。
  2. 前端 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 流程图:排查与解决之路

分析错误信息
发现
发现
步骤1
步骤2
将函数参数类型改为 DTO[]
成功
失败
开始
用户点击保存按钮
后端报错: HttpMessageNotReadableException
提示: Cannot deserialize ArrayList from START_OBJECT
检查后端 Controller
@RequestBody List (期望数组)
检查前端 handleSubmit
循环调用 API, 发送单个 Object {}
定位问题: 前后端数据格式不匹配
改造前端 handleSubmit
构建包含所有数据的 payload 数组 []
单次调用 API, 传递 payload 数组
修改前端 API 定义
确保 TypeScript 类型匹配
重新测试
批量保存成功 ✅
结束

⏳ Mermaid 时序图:正确的交互流程

用户操作 (浏览器) 前端 Vue 组件 前端 API 接口 (TS) 后端 Spring Boot API 数据库 点击“保存”按钮 执行 validateData() 校验数据 构建 payload 数组 (包含多条入库信息) 调用 saveConsignmentSummary(payload) 发送 POST 请求 (Body: JSON 数组) 解析 JSON 数组为 List<DTO> 循环处理 List 中的每条 DTO 数据 保存/更新单条入库记录 返回保存结果 loop [针对列表中的每条数据] 返回 HTTP 响应 (批量操作成功) 返回 Promise<ApiResponse> (成功) 显示“保存成功”提示 关闭弹窗或刷新列表 用户操作 (浏览器) 前端 Vue 组件 前端 API 接口 (TS) 后端 Spring Boot API 数据库

✨ 最终效果

经过上述修改,再次点击“保存”按钮,后端不再报错,数据被成功地批量保存到了数据库中!🎉 问题圆满解决!

🤔 反思与总结

这次 Bug 排查虽然过程不复杂,但也带来几点思考:

  1. 前后端接口契约至关重要: 对于批量操作,是接收数组一次性处理,还是前端循环调用单次保存接口,必须在设计阶段就明确约定好。
  2. 仔细阅读错误信息: HttpMessageNotReadableExceptionMismatchedInputException 已经非常清晰地指明了是 JSON 结构与目标 Java 类型不匹配的问题。读懂错误是解决问题的第一步。
  3. 理解框架机制: 了解 Spring Boot 如何通过 @RequestBody 和 Jackson 自动进行 JSON 到 Java 对象的映射,有助于快速定位此类问题。
  4. TypeScript 的优势: 在前端调整 API 调用方式后,TypeScript 的类型检查能帮助我们及时发现 API 定义文件中的函数签名也需要同步修改,避免了潜在的运行时错误。

希望这篇博客对你有所帮助!如果你也遇到过类似的“前后端格式大战”,欢迎在评论区分享你的故事和解决方案!👇👇👇


🧠 Markdown 思维导图总结

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值