契约优先的前端开发:从 Java OpenAPI 到生成客户端

契约优先的前端开发:从 Java OpenAPI 到生成客户端

前后端联调中,有一种返工特别隐蔽:接口已经能请求成功,页面也能显示,但双方理解的并不是同一份契约。后端把整数 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>>
}

它应该回答:路径是什么、参数放在哪里、请求体和响应体是什么结构。它不应该顺便决定页面如何分页、错误怎么提示、权限不足跳到哪里,也不应该掺入具体组件状态。

生成目录建议明确标记为只读,并遵守三条规则:

  1. 不手改;发现错误回到服务端描述、模板或生成配置修复;
  2. 不对生成物做无关格式化,避免把真正的契约 diff 淹没;
  3. 生成结果必须进入评审,不能因为“机器生成”就跳过检查。

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 应该怎样读

生成后我不会先看文件有多少行变化,而是按风险顺序检查:

  1. 路径和 HTTP 方法是否新增、删除或改变;
  2. 请求字段的必填、可空与位置是否变化;
  3. 响应字段类型、枚举和数组元素是否变化;
  4. ID 是否在 stringnumber 间变化;
  5. 是否出现重复模型、异常命名或退化成 any
  6. 调用方的权限、错误处理和业务状态是否仍成立。

一个很典型的误区是“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 能表达结构、路径、安全方案和大量校验信息,但很难完整表达“什么状态允许什么动作”。复杂规则仍要由领域代码、测试和必要的说明共同定义。

从手写接口迁移,不必一次推倒

一套旧项目可以按下面的顺序渐进迁移:

  1. 先选一个边界清楚的只读模块,建立 OpenAPI 生成与 diff 检查;
  2. 保留原请求实例,只替换参数与响应类型;
  3. 把通用响应解包和错误转换放到适配层;
  4. 稳定后再覆盖写接口,并补上冲突、幂等和权限场景测试。

第一阶段的目标不是生成全部接口,而是证明这条链路可复现、可评审、失败时不会污染工作树。

结语

契约优先的价值,不是让前端少写几百行类型,而是让一次后端变更沿着固定路径抵达所有调用方:源头有版本,中间有校验,产物有 diff,业务层有明确责任。

当接口变化能够在代码评审和类型检查阶段被看见,联调就不再承担“发现双方理解不同”的职责。它应该验证真实环境和业务流程,而不是替静态契约收拾残局。

延伸阅读:

ihopeful Blog 由博主亲笔撰写,重要信息可放心引用。