API 优先(API-first)是先设计并评审接口契约,再开始依赖该接口的实现工作。契约是实现、Mock、客户端和测试共同使用的受版本控制交付物。
写出一份 OpenAPI 文件并不等于 API 优先;如果实现可以偏离规范,或者行为约束只存在于代码里,规范仍只是过期文档。
在 CI 中同时检查规范、破坏性变更和真实响应,并为无法用模式表达的授权、幂等与状态转换添加场景测试。
是什么,为什么存在
API 优先把接口当作团队先要达成的设计决定,而不是服务写完之后生成的说明书。团队先定义可观察的请求与响应,再由消费者和提供者共同评审。评审通过的 API 契约(API contract) 进入版本控制,后续代码必须满足它。
这里的契约不只是一组 JSON 字段。对 HTTP API 来说,它至少涉及方法、路径、参数、媒体类型、状态码、响应头、认证方式和消息模式。重试语义、授权规则、状态转换和并发条件也属于契约,但其中一部分需要用文字或场景测试表达。
这种工作方式解决的是协调问题。客户端若只能等服务上线后再观察真实响应,接口命名、错误格式或必填字段的问题会在集成阶段才暴露。契约先稳定下来后,客户端可以对 Mock 开发,服务端可以独立实现,双方仍围绕同一条可验证边界工作。
你会在多个团队共享服务、公开 API、移动客户端或发布周期不同的系统中遇到 API 优先。一个人维护的小型内部端点也可以先写契约,但收益取决于是否真有独立消费者。没有明确消费者时,简短设计评审往往比庞大的生成流程更合适。
第一版契约不必预测未来的每个操作。它只需详细到足以确定当前消费者交互,包括调用者必须处理的失败。保持边界小而明确,更容易评审,也会减少以后不得不维护的推测性字段。
API 优先不规定必须生成服务端代码,也不要求使用 OpenAPI。GraphQL SDL、Protocol Buffers 或其他接口描述同样可以承担中心契约的角色。本文用 OpenAPI,是因为它能精确演示 HTTP API 的路径、操作和消息形状。
契约一旦发布,后续修改的处理方式就变了。字段重命名不再是局部重构,而是一项必须与旧承诺比较的接口变更提议。这道评审边界,才是 API 优先与提前编写文档之间的实际区别。
OpenAPI 也不是全部事实来源。数据库事务、调用者是否有权访问某个 资源(resource) ,以及操作是否只产生一次业务效果,不能靠响应模式自动证明。可靠的流程会明确区分机器可读描述、补充语义和可执行测试。
工作原理
API 优先流程从消费者任务开始,而不是从控制器类开始。先写出几个具体交互:谁发起请求、想改变什么、成功后能观察到什么、可能怎样失败。只有这些例子足够清楚,才把它们抽象成可复用模式。
图中的发布不是把规范复制到文档站。它意味着给契约一个可追踪版本,让构建可以稳定取得同一份内容。Mock、客户端或服务端骨架可以生成,也可以手写;关键是它们接受同一个契约检查。
一份最小但完整的操作
下面的 OpenAPI 3.2 文档定义创建订单的单个操作。它明确请求必填字段、成功状态、Location 响应头和一种错误表示。示例值同时给 Mock 和文档提供具体交互,但不替代模式约束。
openapi: 3.2.0
info: { title: Order API, version: 1.0.0 }
paths:
/orders:
post:
operationId: createOrder
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [sku, quantity]
properties:
sku: { type: string, minLength: 1 }
quantity: { type: integer, minimum: 1 }
example: { sku: 'desk-42', quantity: 2 }
responses:
'201':
description: Order created
headers:
Location:
required: true
schema: { type: string, format: uri-reference }
content:
application/json:
schema: { type: object, required: [id, sku, quantity] }
example: { id: 'ord-1001', sku: 'desk-42', quantity: 2 }
'400':
description: Invalid request
content:
application/problem+json:
schema: { type: object, required: [type, title, status] }这里的路径和方法确定操作身份,requestBody 与 responses 描述消息边界。Order 是资源状态的一种 表示(representation) ,不是数据库行的逐字段公开。服务端可以换存储方案,只要可观察的交换仍满足契约。
契约成为控制点
契约提交后,CI 首先解析并校验文档本身。第二层把新旧版本比较,找出删除操作、收紧输入或删除响应字段等潜在破坏。第三层对运行中的提供者发送请求,验证状态码、响应头和响应体是否符合已发布契约。
这三层检查回答不同问题。规范校验只能说明文件结构合法;差异检查只能按预设规则识别变化;提供者验证只能覆盖执行过的交互。它们组合起来仍不能证明业务正确,因此关键状态转换还需要领域测试。
| 检查 | 能发现 | 不能证明 |
|---|---|---|
| 描述校验 | 非法结构与引用 | 实现符合契约 |
| 兼容性差异 | 已知的结构破坏模式 | 消费者运行时行为 |
| 提供者验证 | 已观察到的 HTTP 不一致 | 未执行的领域规则 |
契约评审要有消费者参与。提供者单方面写出的模式可能语法完美,却漏掉调用者处理失败所需的稳定错误码,或者把内部表结构暴露成外部模型。评审应从交互示例回到消费者任务,并记录暂时无法机器化的约束。
评审一项变更提议
有效的变更评审会从修改行出发,沿着既有承诺向外追踪。对于字段变更,只指出受影响操作和模式还不够;评审者还要知道谁生产该消息、谁读取它,以及哪些已部署版本仍在运行。
- 用具体请求与响应示例展示旧交互和变更后的交互。
- 判断修改的是请求还是响应,并指出消息生产者。
- 执行结构兼容规则;若有例外,应说明理由,不要静默屏蔽。
- 让至少一个受影响消费者对变更后的 Mock 或提供者执行测试。
- 记录发布顺序、回退方法,以及何时可以移除旧承诺。
产出可以是简短的拉取请求记录,不必另写设计文档。重点是让后续评审者能区分已接受的迁移与意外漂移。仅在生成差异上留下批准,既保留不了假设,也保留不了证据。
变更进入同一循环
修改接口时,先改契约并让检查失败,再更新 Mock、消费者和提供者。新增可选字段通常比删除字段安全,但兼容性始终取决于双方如何读取和验证消息。严格拒绝未知字段的旧客户端可能被一个理论上可扩展的响应击中。
契约版本与 URL 版本不是同一个概念。每次兼容修改也应产生可追踪的契约修订,但并不需要把路径从 /v1 改成 /v2。只有无法在现有承诺内演进时,才需要迁移策略或新的公开接口版本。
示例
三个示例沿用创建订单操作。为了让每段代码都能独立执行,它们把所需的契约片段直接放在文件中。教学用检查器只演示控制流,不应替代完整的 OpenAPI 与 JSON Schema 实现。
从契约生成操作清单
第一个程序读取一个缩小的 OpenAPI 对象,列出操作 ID、输入必填字段和已声明响应。团队可以用同类索引检查命名,或者把操作交给后续生成步骤。
const document = {
openapi: "3.2.0",
paths: {
"/orders": {
post: {
operationId: "createOrder",
requestBody: {
content: {
"application/json": {
schema: { required: ["sku", "quantity"] },
},
},
},
responses: { "201": {}, "400": {} },
},
},
},
};
for (const [path, pathItem] of Object.entries(document.paths)) {
for (const [method, operation] of Object.entries(pathItem)) {
const schema = operation.requestBody.content["application/json"].schema;
const responses = Object.keys(operation.responses).join(", ");
console.log(`${operation.operationId}: ${method.toUpperCase()} ${path}`);
console.log(`required: ${schema.required.join(", ")}`);
console.log(`responses: ${responses}`);
}
}createOrder: POST /orders
required: sku, quantity
responses: 201, 400输出来自契约,而不是路由实现。只要操作 ID 稳定,生成器就能用它命名客户端方法;方法、路径或响应集合变化时,索引输出也会变化。真实工具还会解析引用并验证更多 OpenAPI 规则。
用契约示例提供确定性 Mock
第二个程序把已发布的请求约束与响应示例放进 Mock。有效请求得到契约中的 201 形状,缺少必填字段的请求得到已声明的 400 形状。固定示例让消费者测试可重复,而不是每次生成随机数据。
const operation = {
required: ["sku", "quantity"],
responses: {
201: {
headers: { Location: "/orders/ord-1001" },
body: { id: "ord-1001", sku: "desk-42", quantity: 2 },
},
400: {
headers: { "Content-Type": "application/problem+json" },
body: { type: "about:blank", title: "Invalid request", status: 400 },
},
},
};
function mockCreateOrder(requestBody) {
const missing = operation.required.filter((name) => !(name in requestBody));
if (missing.length > 0) return operation.responses[400];
const response = structuredClone(operation.responses[201]);
response.body.sku = requestBody.sku;
response.body.quantity = requestBody.quantity;
return response;
}
for (const request of [
{ sku: "desk-42", quantity: 2 },
{ sku: "desk-42" },
]) {
const response = mockCreateOrder(request);
console.log(response.body.status ?? 201, JSON.stringify(response.body));
}201 {"id":"ord-1001","sku":"desk-42","quantity":2}
400 {"type":"about:blank","title":"Invalid request","status":400}这个 Mock 故意很小:它只检查字段是否存在,没有实现整数、最小值或未知字段校验。消费者可以借它开发成功路径与错误解析,但不能据此判断真实服务已经正确验证输入。Mock 证明消费者理解契约,不证明提供者满足契约。
对真实响应执行契约检查
第三个程序演示提供者验证的核心。检查器拒绝未声明的状态码,并验证 201 响应必须包含 Location 以及三个响应字段。第一份生成的处理器响应失败,第二份响应通过。
const responseContract = {
201: {
requiredHeaders: ["Location"],
requiredBody: ["id", "sku", "quantity"],
},
400: {
requiredHeaders: ["Content-Type"],
requiredBody: ["type", "title", "status"],
},
};
function verifyResponse(response) {
const expected = responseContract[response.status];
if (!expected) return [`status ${response.status} is not documented`];
const errors = [];
for (const name of expected.requiredHeaders) {
if (!(name in response.headers)) errors.push(`missing header: ${name}`);
}
for (const name of expected.requiredBody) {
if (!(name in response.body)) errors.push(`missing body field: ${name}`);
}
return errors;
}
const generated = {
status: 200,
headers: {},
body: { id: "ord-1001", sku: "desk-42", quantity: 2 },
};
const conforming = {
status: 201,
headers: { Location: "/orders/ord-1001" },
body: { id: "ord-1001", sku: "desk-42", quantity: 2 },
};
console.log("generated:", verifyResponse(generated).join("; "));
console.log("conforming:", verifyResponse(conforming).join("; ") || "OK");generated: status 200 is not documented
conforming: OK完整的提供者验证还要检查媒体类型、模式关键字、引用和每个已声明交互。更重要的是,它要调用真实的 HTTP 边界,而不是直接测试控制器返回的对象。代理、中间件和序列化层都可能改变线上可见结果。
陷阱
修复方法: 把规范解析、兼容性差异和提供者响应验证设为必需检查。部署制品应关联准确的契约修订,失败信息要指出操作与不一致字段,而不是只说文档过期。
修复方法: 在生成前评审消费者任务和可观察行为,把生成目录视为可替换制品。业务逻辑放在稳定的手写边界后面,并在 CI 中从干净目录重新生成,确认版本库没有漂移。
修复方法: 为每个操作提供少量有名字的场景,至少覆盖主要成功与调用者必须处理的失败。场景应该确定且可选择;随机字段适合属性测试,不适合替代可复现的集成夹具。
修复方法: 从 HTTP 边界捕获状态行、相关响应头和字节级消息,再按契约验证。请求验证也要覆盖路径、查询、请求头和媒体类型,不能只检查反序列化后的对象。
修复方法: 分别评估请求与响应的生产者、消费者。记录严格客户端等已知行为,用真实消费者契约补充通用差异规则;需要破坏现有承诺时,提供并行版本和迁移期限。
契约边界与兼容性
描述文件能证明什么
OpenAPI 描述的是 HTTP 接口的形状和一部分语义。它可以声明参数位置、数据模式、安全方案、状态码和响应头,也可以给出示例。合规工具据此发现未声明响应或字段类型错误,这些都是有价值且可自动化的失败。
模式无法单独证明调用者有权读取 orderId 对应订单,也无法证明相同幂等键不会创建两份订单。它还不能从一句 description 自动验证余额变化或通知顺序。把这些文字视为契约的一部分,并用领域测试、策略测试或消费者场景让关键承诺可执行。
示例也不是约束。quantity: 2 说明一条典型消息,却不能说明 0 是否有效,也不能限制字符串出现在该位置。示例负责沟通,模式负责一类输入;两者都需要评审。
四个方向的兼容性
请求与响应的兼容方向相反。服务端收紧请求输入会拒绝旧客户端以前能发送的内容;服务端放宽请求输入通常不影响旧客户端。服务端删除响应字段会破坏读取该字段的客户端,而新增响应字段只有在客户端容忍未知字段时才安全。
因此,差异工具给出的安全或破坏标签只是初筛。更改默认值、排序稳定性或错误码含义可能没有改变模式,却改变消费者看到的行为。相反,修正文档中从未实现的错误字段可能产生很大的文本差异,却没有改变生产行为;这说明契约此前已经漂移,仍需调查而不是自动忽略。
兼容评审应记录方向、已知消费者和迁移证据。公开 API 通常要假设存在未知消费者,所以容错空间更小。内部 API 可以结合调用遥测与消费者测试做判断,但没有调用记录不等于没有依赖。
OpenAPI 验证与消费者契约
OpenAPI 提供者验证从提供者声明出发:实现是否满足发布的接口描述。消费者驱动 契约测试(contract testing) 从具体消费者交互出发:提供者是否仍满足该消费者实际依赖的请求和响应。二者范围不同,可以同时使用。
消费者契约不应允许每个客户端私自重新定义服务语义。提供者仍要审核交互,拒绝不可能维持或违反领域规则的期望。契约代理或共享仓库保存已发布交互,部署检查再确认目标提供者版本通过相关验证。
仅靠消费者测试也可能漏掉没有消费者覆盖的新操作、安全要求或通用错误格式。仅靠 OpenAPI 则可能不知道哪些可选字段对某个消费者实际必需。把 OpenAPI 作为公共边界,把消费者契约作为具体依赖证据,能让两类盲区更容易被看见。
契约检查的边界
契约检查从服务边界外部观察行为。这样可以看到序列化和中间件的影响,但也意味着它无法检查两次数据库写入是否原子提交。内部状态转换即使已经损坏,响应仍可能符合契约。
需要控制事务、时钟与故障的领域不变量,应放在更低层测试中。认证与资源级授权则应保留 HTTP 边界测试,因为凭据、路由绑定和策略中间件都属于可观察交换。高风险承诺跨越两层时,可以保留少量重复覆盖。
部署门禁应指出失败证据的类型。模式不一致、消费者交互失败和余额不变量被破坏,需要不同负责人和修复方式。把它们统一折叠成“API 测试失败”,反而会削弱团队对流水线的信任。
按失败选择自动化
先确定要阻止的失败,再选择能观察它的最小检查。描述检查用于发现非法契约,差异检查用于分类变更,提供者验证用于检查线上交换,消费者场景用于验证具名依赖。所有这些路径都不强制要求代码生成。
工具应允许经过评审的例外,但例外要有负责人、理由和失效日期。永久的全局屏蔽会让精确规则沦为背景噪声,团队也会退回到多了几份文件的代码优先流程。
交付物所有权
中心契约需要明确维护者和变更路径,但不应由提供者独占决定。所有权意味着有人处理评审、弃用和工具升级,并不意味着消费者只能在实现完成后接受结果。高风险变更应把消费者确认或迁移计划作为合并条件。
生成制品不应反过来成为无法追踪的第二份事实来源。若仓库存放生成客户端,应记录生成器版本与契约提交,并能重复生成相同公共接口。若构建时生成,则要固定工具版本,避免同一契约因工具升级产生未评审变化。
最终的发布单位应能回答三个问题:它实现哪一版契约,哪些消费者验证过它,发现不兼容时怎样停止部署。这比拥有一份漂亮的文档页更接近 API 优先的实际价值。
5个问题 · 1 道输出预测题 · 1 道找错题