Skip to content

API 契约与前后端联调 ​

前后端联调时最常听到的话是:“你那边返回的字段怎么和说好的不一样?”

  • 后端说返回 userName,实际返回的是 user_name;
  • 前端按“列表为空时返回空数组”写的代码,后端返回了 null;
  • 订单 ID 在后端日志里是 1234567890123456789,前端请求详情时变成了 1234567890123456800,查不到订单;
  • 金额 0.1 + 0.2 在前端显示成了 0.30000000000000004。

这些问题的根源是:接口的约定散落在聊天记录、文档和代码里,彼此不一致,也没有任何机制去检查。API 契约就是把接口的样子写成一份双方都认可、机器可以校验的文件。

读完这篇你会

  • 用 OpenAPI 契约驱动前后端并行开发
  • 在契约里约定好 ID、空值、时间、金额这些最容易出错的细节
  • 用工具自动发现接口和契约不一致、以及破坏性变更

1. 原理:一份合同,多处使用 ​

契约就像一份合同:接口的路径、参数、返回结构、字段类型、是否必填,都以它为准。它最大的价值是成为唯一的事实来源,并且可以自动生成各种东西:

text
OpenAPI 契约(openapi.yaml)
  ├─► 前端:生成 TypeScript 类型和请求函数
  ├─► 后端:生成请求校验、接口骨架
  ├─► Mock:按契约自动返回假数据,前端不用等后端
  ├─► 文档:自动渲染,永远和契约一致
  └─► 测试:校验真实接口的响应是否符合契约

手写两份类型(前端一份、后端一份),迟早会不一致;从同一份契约生成,就不会。

yaml
# openapi.yaml 片段
components:
  schemas:
    Order:
      type: object
      required: [id, amountCents, status, createdAt]
      properties:
        id: { type: string, description: 订单 ID,使用字符串避免精度丢失 }
        amountCents: { type: integer, description: 金额,单位:分 }
        status: { type: string, enum: [pending, paid, shipped] }
        createdAt: { type: string, format: date-time }
        remark: { type: string, nullable: true, description: 没有备注时为 null }

2. 坑一:大整数 ID 到了前端,最后几位变了 ​

现象:后端的订单 ID 是 1234567890123456789,前端拿到后再请求详情,传过去的却是 1234567890123456800,后端返回“订单不存在”。

原因:JavaScript 的数字类型能精确表示的最大整数是 2^53 - 1(约 9 千万亿,16 位左右)。很多后端使用 64 位整数作为 ID(比如雪花算法生成的 ID 有 18~19 位),超过了这个范围。JSON.parse 把它解析成数字时,末尾几位就被“四舍五入”掉了。

js
JSON.parse('{"id": 1234567890123456789}').id // 1234567890123456800
Number.MAX_SAFE_INTEGER                      // 9007199254740991

修复:ID 在接口中一律用字符串传输。后端序列化时把 64 位整数转成字符串,契约里把 ID 的类型定义为 string。

怎么避免再犯:在契约规范里明确写上“所有 ID 字段使用字符串”,评审契约时检查。

3. 坑二:null、不返回、空字符串,到底什么意思 ​

现象:前端写了 order.remark.length,有时报错 Cannot read properties of null,有时报 undefined,取决于后端代码走了哪个分支。

原因:“没有备注”这件事,后端有时返回 null,有时干脆不返回这个字段,有时返回空字符串 ""。三种情况前端要分别处理,而谁也没约定过。

修复:在契约里给每个字段明确两件事:

属性含义
required(必填)这个字段是否一定会出现在响应里
nullable(可空)出现时,值是否可能是 null

推荐的约定:字段尽量总是返回(required),“没有值”统一用 null 表示;列表类字段没有数据时返回空数组 [] 而不是 null。这样前端只需要处理一种情况。

4. 坑三:时间差了 8 小时 ​

现象:后端返回 "2026-10-23 10:00:00",前端显示成了 18:00;或者在不同浏览器里解析结果不同。

原因:这个字符串没有时区信息。后端说的是北京时间,前端(或某个中间环节)把它当成了 UTC,又转换成本地时间,于是多了 8 小时。不带 T 的格式在部分浏览器里甚至无法解析。

修复:时间统一使用 ISO 8601 格式并带上时区,或者直接使用时间戳:

json
{ "createdAt": "2026-10-23T10:00:00+08:00" }

前端按用户所在时区格式化显示。日期(没有具体时刻,比如生日)单独约定为 YYYY-MM-DD 字符串,不要用带时间的格式表示,避免跨时区时日期变成前一天。

5. 坑四:金额算出了 0.30000000000000004 ​

现象:购物车里两件商品,单价 0.1 元和 0.2 元,合计显示 0.30000000000000004;或者优惠计算后差了一分钱,和后端算出来的对不上。

原因:JavaScript(以及大多数语言)的浮点数是二进制表示的,0.1 这样的十进制小数无法被精确表示,计算时就会出现微小的误差。

js
0.1 + 0.2 // 0.30000000000000004

修复:

  • 金额在接口中用**整数的“分”**传输(amountCents: 30),前端计算也用分,只在显示时除以 100;
  • 或者用字符串传输十进制金额("0.30"),需要计算时使用专门的十进制计算库;
  • 最终金额以后端计算为准,前端的计算只用于展示预估。

6. 坑五:mock 数据和真实接口不一样 ​

现象:前端对着 mock 数据开发得很顺利,一联调全是问题:字段名不对、嵌套结构不同、分页格式不一样。

原因:mock 数据是前端根据口头描述自己编的,和后端的实现是两套东西。

修复:mock 必须从契约生成,而不是手写。使用读取 OpenAPI 文件的 mock 工具,返回符合 schema 的数据;契约变了,mock 自动跟着变。这样联调时只需要把请求地址从 mock 切换到真实服务。

7. 坑六:后端悄悄改了字段,前端上线后才发现 ​

现象:后端重构时把 userId 改成了 id,测试都通过了,上线后前端页面大面积报错。

修复:在 CI 里加两道自动检查:

  1. 契约差异检查:用 oasdiff 等工具对比新旧两版契约,发现删除字段、修改类型等破坏性变更时让 CI 失败,必须经过评审和协商;
  2. 契约一致性测试:后端的接口测试用契约去校验真实响应,返回结构和契约不符时测试失败。这样“代码改了、契约没改”的情况也会被发现。

破坏性变更的处理方式,见 API 版本管理。

8. 契约驱动的联调流程 ​

text
1. 需求评审后,前后端一起编写 OpenAPI 契约(ID、空值、时间、金额按规范来)
2. 契约提交到代码仓库,像代码一样评审后合并
3. 前端从契约生成类型和 mock,开始开发;后端从契约生成校验,开始实现
4. CI 检查:契约有无破坏性变更、后端响应是否符合契约
5. 联调:前端把 mock 切换成真实接口
6. 后续修改:先改契约、再改代码,契约变更经过评审并通知对方

这套流程把“联调当天才发现对不上”的成本,前移到了“写契约时就对齐”。问题发现得越早,解决得越便宜。

排查思路 ​

  1. 联调对不上:先在 Network 面板看真实响应,再对照契约,判断是后端没按契约实现,还是契约本身没写清楚。
  2. 数据偶尔不对:检查 ID 长度是否超过 16 位、时间字符串是否带时区、金额是否是浮点数。
  3. 前端偶发空值报错:找出这个字段在后端的所有返回分支,看是否有的分支返回 null、有的不返回。
  4. 把校验自动化:开发环境里可以用 schema 校验器对每个响应做校验,不符合契约时在控制台给出警告,问题当场暴露。

检查清单 ​

  • [ ] 有一份机器可读的契约,作为唯一事实来源
  • [ ] 前端类型和 mock 从契约生成
  • [ ] ID 使用字符串传输
  • [ ] 每个字段明确是否必填、是否可为 null,列表空时返回 []
  • [ ] 时间使用带时区的 ISO 8601 格式或时间戳
  • [ ] 金额使用整数分或十进制字符串,以后端计算为准
  • [ ] CI 检查契约的破坏性变更和响应一致性

延伸阅读 ​

为复用而记录,为理解而整理。