前后端联调中,有一种返工特别隐蔽:接口已经能请求成功,页面也能显示,但双方理解的并不是同一份契约。后端把整数 ID 改成字符串,前端仍按 number 保存;字段从必填变成可空,页面却继续断言它一定存在;错误码已经细分,调用层仍把所有失败归为“网络异常”。
这些问题很少能靠再写一份接口文档解决。文档、代码和类型只要分别维护,漂移只是时间问题。
我更认可的一条路径是:让服务端产生机器可读的 OpenAPI 描述,前端从同一份描述生成请求类型与端点声明,再把业务适配留在手写代码里。它不是“用工具省掉几个 interface”,而是把契约变更变成一条可追踪、可审查、会在编译阶段暴露问题的流水线。
本文只讨论通用方法。示例中的仓库、模块、接口、字段和命令均为脱敏后的简化版本。
契约优先,不等于生成器优先
OpenAPI 规范把 HTTP API 描述为与编程语言无关的接口,既供人阅读,也供工具生成文档、客户端和测试。它很适合做前后端之间的结构契约,但不能替代业务语义。
我会把整条链路分成五层:
服务端代码与约束
↓
OpenAPI 描述
↓
校验、暂存与版本确认
↓
生成的 TypeScript 类型和端点
↓
手写适配层与业务页面
这里真正的原则是单向派生:下游可以包装上游,但不能回头手改生成物,假装契约已经改变。
第一层:服务端负责定义事实
以一个普通分页接口为例,服务端应该明确路径参数、查询参数、响应模型、枚举、空值语义和错误边界:
public record ProjectSummary(
String id,
String name,
ProjectStatus status,
Instant updatedAt
) {}
public record PageResult<T>(
List<T> records,
long total,
long page,
long size
) {}
代码只是示意。关键是这些约束来自真正执行请求的服务端代码,而不是前端根据一段示例 JSON 猜出来。
有几个细节值得在源头处理:
- 长整型 ID 如果可能超过 JavaScript 安全整数范围,应在契约中按字符串传输;
- “字段不存在”“字段为 null”“字段为空数组”要有稳定含义;
- 枚举要给出机器可读的合法值,不要只在注释里写中文状态;
- 时间字段要明确格式和时区语义;
- 错误响应不能只有一段给人看的 message,还要有稳定错误码。
如果这些信息在服务端都不明确,生成出来的 TypeScript 只会更快地复制模糊。
第二层:OpenAPI 是中间表示,不是手写说明书
服务端生成的 OpenAPI 大致会包含下面这些信息:
openapi: 3.0.3
paths:
/api/workspaces/{workspaceId}/projects:
get:
parameters:
- in: path
name: workspaceId
required: true
schema:
type: string
- in: query
name: page
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/ProjectPage"
我把 OpenAPI 看作编译过程中的中间表示:它必须能被解析,版本必须受支持,paths 不能意外为空,引用必须能解析,目标模块也必须完整。仅仅生成了一个 JSON 文件,不代表契约可用。
这也是为什么我不赞成前端把某个测试环境的 Swagger 地址当作唯一输入。环境可能未部署、权限可能过滤接口、缓存可能滞后。更可靠的方式是从明确的服务端提交或干净工作树生成,并把实际 commit 记录在日志中。
第三层:先校验和暂存,再发布生成结果
契约生成经常被写成一条很长的 shell 命令:生成失败一半时,旧文件已经被覆盖,新文件又不完整。下一位开发者看到的工作树处在无法解释的中间状态。
更稳妥的流程是两阶段发布:
async function refreshContract(target: Target) {
const staging = await generateOpenApiToStaging(target)
try {
await validateOpenApi(staging)
await publishContractAtomically(staging)
await generateTypeScriptClient(target)
} catch (error) {
await discardStaging(staging)
throw error
}
}
实际实现还应考虑:
- 多模块契约要么一起更新,要么全部回滚;
- 发布前备份旧缓存,移动失败时恢复;
- 临时仓库和临时目录无论成功失败都要清理;
- 并发更新导致 Git 非快进时,重新同步后必须重新生成,不能直接重试 push;
- 生成器版本、模板和配置要进入锁文件与代码评审。
这一段看起来比直接执行生成器复杂,但它解决的是构建的原子性。契约属于源码供应链的一部分,不能用“本地跑过一次”作为完整保障。
第四层:生成物只承担机械责任
从 OpenAPI 生成的代码,适合承担结构明确、重复度高的工作:
export interface ProjectSummary {
id?: string
name?: string
status?: 'ENABLED' | 'DISABLED'
updatedAt?: string
}
export interface GetProjectsParams {
workspaceId: string
page?: number
size?: number
}
export interface ApiGet {
'/api/workspaces/{workspaceId}/projects': (
params: GetProjectsParams
) => Promise<ApiResponse<ProjectPage>>
}
它应该回答:路径是什么、参数放在哪里、请求体和响应体是什么结构。它不应该顺便决定页面如何分页、错误怎么提示、权限不足跳到哪里,也不应该掺入具体组件状态。
生成目录建议明确标记为只读,并遵守三条规则:
- 不手改;发现错误回到服务端描述、模板或生成配置修复;
- 不对生成物做无关格式化,避免把真正的契约 diff 淹没;
- 生成结果必须进入评审,不能因为“机器生成”就跳过检查。
swagger-typescript-api、OpenAPI Generator 等工具都能完成客户端生成。工具选型不是这套方法的核心,稳定输入、固定版本和清晰边界才是。
第五层:手写适配层保留业务语义
页面直接消费生成响应,通常很快就会出现一串 data?.data?.records ?? []。这说明结构契约虽然统一了,业务边界仍没有落位。
我通常在生成客户端与页面之间保留一层很薄的手写服务:
import type { ProjectSummary } from '~/request'
import { api } from '~/request'
export async function listProjects(input: {
workspaceId: string
page: number
size: number
}) {
const response = await api.get[
'/api/workspaces/{workspaceId}/projects'
](input)
if (response.code !== 'OK' || !response.data) {
throw toApplicationError(response)
}
return {
list: response.data.records ?? [],
total: response.data.total ?? 0
} satisfies {
list: ProjectSummary[]
total: number
}
}
这一层可以做响应解包、分页协议适配、错误转换和少量跨端差异处理,但不重新声明一份 ProjectSummaryDTO。类型仍从生成目录导入,业务代码只补充运行时语义。
这条边界很重要:
- 生成层负责“服务端说数据长什么样”;
- 适配层负责“本应用如何消费这类响应”;
- 页面层负责“用户现在看到什么、能做什么”。
如果适配层开始复制所有字段、吞掉所有错误或伪造缺失状态,它就会重新变成第二份契约。
契约 diff 应该怎样读
生成后我不会先看文件有多少行变化,而是按风险顺序检查:
- 路径和 HTTP 方法是否新增、删除或改变;
- 请求字段的必填、可空与位置是否变化;
- 响应字段类型、枚举和数组元素是否变化;
- ID 是否在
string与number间变化; - 是否出现重复模型、异常命名或退化成
any; - 调用方的权限、错误处理和业务状态是否仍成立。
一个很典型的误区是“TypeScript 通过,所以契约已同步”。类型检查只能证明当前代码接受当前声明,不能证明 OpenAPI 来自正确提交,也不能证明后端运行时一定遵守描述。契约生成、服务端测试、前端构建和联调 UAT 是不同证据,应该分别记录。
把漂移变成 CI 中可见的失败
理想情况下,CI 使用明确的服务端版本重新生成契约,然后检查工作树:
pnpm --filter @app/admin api
pnpm --filter @app/admin typecheck
git diff --exit-code -- packages/admin/src/request
如果生成后出现 diff,说明仓库里的客户端不是当前契约的产物;如果类型检查失败,说明契约变化已经影响调用方。两种失败都比问题留到联调阶段更便宜。
但我不会让生成命令默认悄悄修改服务端分支或推送远端。读取哪个提交、是否同步分支、是否允许远端写入,都应该显式配置并留在日志里。开发机可以支持本地源码模式,CI 则应尽量使用可复现的只读输入。
常见的五种反模式
1. 手写 DTO 与生成类型并存
短期看是“避免影响旧代码”,长期一定变成两套事实。迁移期可以包装,但新业务类型应从生成契约派生。
2. 直接修改生成文件救急
下次生成就会丢失,而且服务端并不知道问题存在。正确修复点通常在注解、模型、生成模板或适配层。
3. 只生成,不记录输入版本
同一个命令今天和明天可能基于不同服务端提交得到不同结果。没有输入版本,产物就无法追溯。
4. 用 any 消灭生成错误
这只是把编译期问题延期到运行时。真正要判断的是契约不准确、生成器能力不足,还是业务调用写错。
5. 把 OpenAPI 当成完整业务规范
OpenAPI 能表达结构、路径、安全方案和大量校验信息,但很难完整表达“什么状态允许什么动作”。复杂规则仍要由领域代码、测试和必要的说明共同定义。
从手写接口迁移,不必一次推倒
一套旧项目可以按下面的顺序渐进迁移:
- 先选一个边界清楚的只读模块,建立 OpenAPI 生成与 diff 检查;
- 保留原请求实例,只替换参数与响应类型;
- 把通用响应解包和错误转换放到适配层;
- 稳定后再覆盖写接口,并补上冲突、幂等和权限场景测试。
第一阶段的目标不是生成全部接口,而是证明这条链路可复现、可评审、失败时不会污染工作树。
结语
契约优先的价值,不是让前端少写几百行类型,而是让一次后端变更沿着固定路径抵达所有调用方:源头有版本,中间有校验,产物有 diff,业务层有明确责任。
当接口变化能够在代码评审和类型检查阶段被看见,联调就不再承担“发现双方理解不同”的职责。它应该验证真实环境和业务流程,而不是替静态契约收拾残局。
延伸阅读: