OpenAPI 与 AsyncAPI 契约、预览、校验和兼容治理手册
文档显示成功,调用却在现场失败
前端根据接口文档把 orderId 当成数字,服务端实际上已经改成带前缀的字符串;另一个消费者从事件样例里复制了真实手机号,后来这份样例进入公开文档。两条流水线都显示绿色:Markdown 能构建,Mock 也返回 200。真正缺失的不是一张更漂亮的页面,而是机器可执行契约、负向证据和兼容门禁。
OpenAPI 描述 HTTP API 的路径、参数、请求、响应与安全方案;AsyncAPI 描述消息驱动 API 的服务器、channel、operation、message、payload 与协议绑定。两者都把 YAML 或 JSON 变成对象图:解析器先读取入口文档并解析 $ref,再按规范验证对象形状,lint 叠加团队规则,diff 比较两个版本,预览、mock 和生成器消费验证后的模型。任何一步使用不同入口文件、不同工具版本或不同生成产物,都可能让“同一份契约”出现多个事实版本。
先固定三条版本轴
OpenAPI 规范站点 已列出 3.2.0、3.1.2 和 3.0.4 等正式版本。openapi 字段表示规范特性集,不是业务 API 版本;info.version 才是服务契约版本。OpenAPI 的 patch 版本用于勘误与澄清,工具声明支持 3.1 时应兼容 3.1.*,但 minor 版本可能包含不向后兼容的变化。
OpenAPI 示例使用 3.1.2,因为它继承 JSON Schema Draft 版本标识 2020-12 的语义,且主流校验、mock 与生成链已有明确支持。准备采用 3.2.0 时,先让 lint、预览、mock、diff、代码生成和目标框架各跑一遍契约套件;只因为规范已发布就改根字段,会把工具支持风险带给所有消费者。
AsyncAPI 规范 3.1.0 描述消息驱动 API;asyncapi 同样是规范版本,info.version 是应用契约版本。AsyncAPI 3 把 channel 与 operation 分开:channel 表达消息能出现在哪里,operation 用 send / receive 表达应用视角的动作。它不是“把 Kafka topic 写进 YAML”这么简单。
第三条轴是工具版本。下面的命令固定 Redocly CLI 2.39.0、AsyncAPI CLI 6.0.2、Prism CLI 5.15.11、OpenAPI Generator npm 包装器 2.39.1 和 HTML 模板 3.5.6。这些版本的 Node 要求并不相同:Redocly CLI 2 支持 Node 22.12+ 或特定 Node 20 线,而 AsyncAPI CLI 6 和 Prism 5.15.11 要求 Node 24,其中 Prism 还要求 24.14+。统一使用满足最高要求的 Node 24 运行线,避免本机能 lint、CI 却连 CLI 都起不来的情况。
建立可重复工具目录
在项目中创建独立契约目录:
contracts/
├── openapi/
│ ├── openapi.yaml
│ └── baseline.yaml
├── asyncapi/
│ ├── asyncapi.yaml
│ └── baseline.yaml
├── redocly.yaml
└── generated/
├── openapi/
└── asyncapi/工具作为项目开发依赖精确锁定,让本机与 CI 共用 lockfile:
npm install --save-dev --save-exact \
@redocly/cli@2.39.0 \
@asyncapi/cli@6.0.2 \
@stoplight/prism-cli@5.15.11 \
@openapitools/openapi-generator-cli@2.39.1 \
@asyncapi/html-template@3.5.6不要在自动化里长期使用 npx package@latest。它适合探测新版本,不适合证明同一个提交可重复构建。升级时单独提交 lockfile、CLI 版本、规则变化和生成差异,再运行兼容检查。
Redocly CLI 2 是 ESM-only;官方安装文档列出的 Node 下限必须在升级前核对。AsyncAPI CLI 官方用法 给出了 validate、diff、start studio、generate models 与 generate fromTemplate 的参数。OpenAPI Generator 的 npm 包只是跨平台包装器,真正生成器版本由 openapitools.json 固定:
{
"$schema": "node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"generator-cli": {
"version": "7.23.0"
}
}包装器版本、Java 生成器版本、目标语言运行时依赖是三件事。只锁 npm 包而让 JAR 漂移,生成文件仍会无缘无故变化。OpenAPI Generator 安装文档 同时列出了 npm、Docker、JAR 和版本管理入口;CI 应选择一种并锁定。
写出第一个 OpenAPI 契约
把下面内容保存为 contracts/openapi/openapi.yaml:
openapi: 3.1.2
info:
title: Order Query API
version: 1.0.0
description: Query a single order visible to the authenticated tenant.
license:
name: Internal use only
identifier: LicenseRef-Internal
servers:
- url: http://127.0.0.1:4010
description: Local contract mock
tags:
- name: Orders
description: Read operations for tenant-visible orders.
paths:
/orders/{orderId}:
get:
operationId: getOrder
summary: Get one order
tags: [Orders]
security:
- bearerAuth: []
parameters:
- name: orderId
in: path
required: true
description: Public opaque order identifier.
schema:
type: string
pattern: '^ord_[A-Za-z0-9]+$'
example: ord_demo7F3
responses:
'200':
description: Order found
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
examples:
sanitized:
value:
id: ord_demo7F3
status: PAID
customerRef: customer_demo_01
'401':
description: Missing or invalid bearer token
'404':
description: Order is absent or not visible to this tenant
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
Order:
type: object
additionalProperties: false
required: [id, status, customerRef]
properties:
id:
type: string
pattern: '^ord_[A-Za-z0-9]+$'
status:
type: string
enum: [CREATED, PAID, CANCELLED]
customerRef:
type: string
description: Synthetic external reference, never a name, phone or email.这里有两个容易混淆的版本:openapi: 3.1.2 决定解析规则,info.version: 1.0.0 表示订单查询契约的发布版本。bearerFormat: JWT 只是提示,不会让 mock 或服务自动验证 JWT;真正授权仍由网关和应用完成。
Redocly 配置保存为 contracts/redocly.yaml:
extends:
- recommended
rules:
no-unused-components: error
operation-operationId: error
operation-4xx-response: error
security-defined: error执行结构校验与团队 lint:
npx redocly lint contracts/openapi/openapi.yaml \
--config contracts/redocly.yaml
npx redocly bundle contracts/openapi/openapi.yaml \
--output contracts/generated/openapi/openapi.bundle.yamllint 成功的退出码为 0;bundle 解析跨文件 $ref 后生成单文件交付物。bundle 成功不代表兼容,也不代表服务实现与契约一致。OpenAPI 3.1 要按完整文档和 URI 基准解析引用,不能把任意 schema 片段脱离父文档后假设语义不变;规范还明确提醒错误的引用基准会带来安全风险。
在编辑器与本机预览
编辑器扩展适合边写边显示语法错误,但团队不能把某位开发者的插件设置当作门禁。扩展应绑定仓库里的入口文件和 redocly.yaml,CI 再执行同一 CLI。未知扩展可能上传文档或远程解析 $ref,含内部接口的仓库应先核对扩展网络权限。
OpenAPI 可以构建成单文件页面:
npx redocly build-docs contracts/openapi/openapi.yaml \
--output contracts/generated/openapi/index.html打开 index.html 后应看到 GET /orders/{orderId}、Bearer 安全方案、200/401/404 和脱敏样例。Redocly CLI 2 也提供项目级 preview,但产品包与 plan 会影响可见能力;只需要本地 API reference 时,build-docs 的静态输出更容易固定和审计。Redocly CLI 命令文档 列出了开源 lint、bundle、build-docs 与项目 preview 的区别。
用 Prism 做正向 Mock
Prism 能把 OpenAPI 2/3 文档启动为 HTTP mock,并支持验证代理。启动时只监听回环地址:
npx prism mock contracts/openapi/openapi.yaml \
--host 127.0.0.1 --port 4010另一个终端执行:
curl -i \
-H "Authorization: Bearer DEMO_TOKEN_NOT_A_REAL_CREDENTIAL" \
-H "Prefer: example=sanitized" \
http://127.0.0.1:4010/orders/ord_demo7F3预期状态码为 200,响应体包含 ord_demo7F3、PAID 和 customer_demo_01。这个实验只能证明路径、参数、内容协商和示例能驱动 mock。Prism 路线图仍把完整安全校验列为独立能力,Mock 收到一个任意 Bearer 字符串不等于认证实现正确。
把路径改成 /orders/7 再请求,动态校验模式应暴露 orderId 不符合正则;若使用静态示例模式却仍返回成功,要在契约测试中显式开启请求校验,而不是把 mock 的宽松行为当成服务承诺。
写出第一个 AsyncAPI 事件契约
把下面内容保存为 contracts/asyncapi/asyncapi.yaml:
asyncapi: 3.1.0
info:
title: Order Events API
version: 1.0.0
description: Events emitted after durable order state changes.
defaultContentType: application/json
servers:
localKafka:
host: 127.0.0.1:9092
protocol: kafka
description: Local broker used by contract tests only.
security:
- $ref: '#/components/securitySchemes/saslScram'
channels:
orderCreated:
address: orders.created.v1
messages:
orderCreated:
$ref: '#/components/messages/OrderCreated'
operations:
sendOrderCreated:
action: send
summary: Publish an order-created event.
channel:
$ref: '#/channels/orderCreated'
messages:
- $ref: '#/channels/orderCreated/messages/orderCreated'
components:
securitySchemes:
saslScram:
type: scramSha256
description: Credentials are injected at runtime, never stored here.
messages:
OrderCreated:
name: OrderCreated
title: Order created
summary: Announces a durably created order.
contentType: application/json
correlationId:
location: $message.header#/correlationId
headers:
type: object
additionalProperties: false
required: [eventId, correlationId]
properties:
eventId:
type: string
pattern: '^evt_[A-Za-z0-9]+$'
correlationId:
type: string
pattern: '^corr_[A-Za-z0-9]+$'
payload:
type: object
additionalProperties: false
required: [orderId, tenantRef, status]
properties:
orderId:
type: string
pattern: '^ord_[A-Za-z0-9]+$'
tenantRef:
type: string
status:
type: string
const: CREATED
examples:
- name: sanitized
headers:
eventId: evt_demoA1
correlationId: corr_demoB2
payload:
orderId: ord_demo7F3
tenantRef: tenant_demo
status: CREATED从发送应用视角,action: send 表示应用把消息发到 channel;消费者文档中的 operation 应使用 receive。这不是 broker 的“生产/消费”全局视角。orders.created.v1 把不兼容的大版本意图放进 topic 名,但仍需 diff 与运行时兼容测试,不能靠后缀自动获得兼容性。
执行规范验证并启动本地 Studio:
npx asyncapi validate contracts/asyncapi/asyncapi.yaml \
--fail-severity error
npx asyncapi start studio contracts/asyncapi/asyncapi.yaml \
--port 3210 --no-interactive校验成功应退出 0;Studio 在本机显示 channel、operation、message、payload 与诊断。用于只读评审时可改用 asyncapi start preview。Studio 使用官方 JavaScript parser 做语法与规范检查,但“符合规范”与“符合团队事件规则”是两层验证;AsyncAPI 校验指南 建议再用 Spectral 等规则引擎表达治理约束。
反向实验要留下故障证据
先破坏 OpenAPI:从 components.securitySchemes 删除 bearerAuth,保留 operation 的引用,然后运行 lint。预期看到未解析引用或未定义安全方案,退出码非零。再把 orderId 从 string 改为 integer,结构校验可能仍然通过,因为它在语法上合法;这正说明 schema 校验抓不住兼容破坏。
AsyncAPI 的稳定反例是把 message 的 payload.required 加上 customerEmail,却不定义该属性,或者把 operation 的 channel 引用改成不存在的节点:
npx asyncapi validate contracts/asyncapi/asyncapi.yaml \
--diagnostics-format stylish --fail-severity error预期诊断指向无效 schema 或未解析 $ref 并返回非零。若只在网页预览里看到红线、CI 仍成功,门禁并没有接入真正的入口文件。
兼容检查比较的是两个事实版本
把主分支发布过的契约保存或从 Git 提取为 baseline.yaml,PR 文件作为 candidate。OpenAPI 使用 oasdiff 检测破坏:
oasdiff breaking \
contracts/openapi/baseline.yaml \
contracts/openapi/openapi.yaml把 orderId 从字符串改成整数、删除 200 响应字段、缩窄枚举或把可选响应字段变成必需字段,通常会产生 breaking change 并返回失败。添加可选响应字段对“拒绝未知字段”的消费者也可能破坏,因此自动分类只是门禁下限,仍需消费者契约和真实流量证据。oasdiff 支持 OpenAPI 3.0/3.1;采用 3.2 前必须重新核对工具支持。
AsyncAPI CLI 直接比较两个文档:
npx asyncapi diff \
contracts/asyncapi/baseline.yaml \
contracts/asyncapi/asyncapi.yaml \
--type breaking --format md默认情况下 breaking change 会使命令失败;--no-error 会取消这层失败语义,不能出现在必需 CI 检查里。删除 channel、修改地址、收紧 payload、改变消息名称或协议绑定都要查看 diff;事件系统还要结合 Schema Registry 的兼容模式和真实消费者测试,因为文档 diff 不知道消费者是否忽略未知字段、是否按顺序反序列化,也不知道旧消息仍会在 retention 内存活多久。
兼容策略需要同时看方向:
| 变化 | HTTP API 风险 | 事件 API 风险 |
|---|---|---|
| 新增可选字段 | 严格客户端可能拒绝未知字段 | 旧消费者可能拒绝未知字段 |
| 删除字段 | 读取该字段的客户端失败 | retention 内旧/新消息混读失败 |
| 收紧枚举或正则 | 既有请求不再合法 | 历史消息可能无法重放 |
| 修改 operation / channel 地址 | SDK 方法或路由变化 | 生产者与消费者落到不同 topic |
| 修改认证方案 | 客户端凭证流程变化 | broker ACL、SASL/TLS 配置变化 |
Mock 与生成代码都不是实现
Mock 根据 schema 和 examples 合成响应,不执行数据库约束、授权策略、幂等、事务、限流、重试或消息顺序。它适合前后端并行和故障样例,但必须为 401、404、409、超时与非法 payload 建立负向用例,并在真实服务上复跑。
OpenAPI Generator 可以生成客户端骨架:
npx openapi-generator-cli generate \
-i contracts/openapi/openapi.yaml \
-g typescript-fetch \
-o contracts/generated/openapi/typescript-fetchAsyncAPI 生成类型模型:
npx asyncapi generate models typescript \
contracts/asyncapi/asyncapi.yaml \
--output contracts/generated/asyncapi/models \
--tsModelType interface \
--no-interactive生成代码应进入独立目录,先清理旧输出,再生成、格式化、编译和测试。不要在生成目录里写业务逻辑;重新生成会覆盖它。是否提交生成物由消费者交付方式决定:提交可让差异可审查,但增加仓库体积和冲突;构建时生成减少重复文件,却要求所有环境都能可靠取得固定工具和模板。
AsyncAPI 的 generate fromTemplate 能产出文档、客户端或应用骨架,模板与 generator 存在独立兼容范围。Generator 版本说明 要求检查模板声明的 generator 版本;模板是可执行 npm 依赖,私有 registry token 只能通过 CI secret 注入。生成前审查模板来源、锁定版本和完整性,生成后仍要编译与测试。
把契约接进项目与 CI
项目脚本按因果顺序运行:先验证,后比较,再生成和编译。前一步失败时不应留下看似可用的新产物。
{
"scripts": {
"contract:lint:openapi": "redocly lint contracts/openapi/openapi.yaml --config contracts/redocly.yaml",
"contract:lint:asyncapi": "asyncapi validate contracts/asyncapi/asyncapi.yaml --fail-severity error",
"contract:diff:openapi": "oasdiff breaking contracts/openapi/baseline.yaml contracts/openapi/openapi.yaml",
"contract:diff:asyncapi": "asyncapi diff contracts/asyncapi/baseline.yaml contracts/asyncapi/asyncapi.yaml --type breaking --format md",
"contract:test": "npm run contract:lint:openapi && npm run contract:lint:asyncapi && npm run contract:diff:openapi && npm run contract:diff:asyncapi"
}
}baseline 不能由 PR 先覆盖再比较,否则 old 与 new 永远相同。CI 应从默认分支、发布 tag 或不可变制品取得已发布契约,并记录其提交 ID。多服务仓库按服务维护入口文件和 owner,避免任何一次变更都触发全仓生成;共享 schema 变化则必须计算所有引用者并扩大验证范围。
服务实现还需要运行时契约测试。HTTP 链路至少验证请求与响应的 status、header、content type 和 body;事件链路至少验证序列化、反序列化、header、key、topic、重复投递和历史消息重放。生成器通过不证明 Spring、Express、Kafka 客户端或 broker 配置真的采用了同一模型。
凭证与样例不是文档装饰
OpenAPI securitySchemes 和 AsyncAPI securitySchemes 描述认证形状,不保存真实值。仓库里只出现变量名和假值:
ORDER_API_BASE_URL=http://127.0.0.1:4010
ORDER_API_TOKEN=DEMO_TOKEN_NOT_A_REAL_CREDENTIAL
KAFKA_BOOTSTRAP_SERVERS=127.0.0.1:9092
KAFKA_SASL_USERNAME=demo_contract_user
KAFKA_SASL_PASSWORD=DEMO_PASSWORD_NOT_A_REAL_CREDENTIAL真实 token、SASL 密码、客户端证书和私钥由本地密码库或 CI secret 注入。命令行参数、完整环境转储、HTML“Try it”持久化、构建日志和失败快照都可能泄漏凭证。共享预览默认关闭真实生产调用;需要交互时只接低权限沙箱,限制 origin、scope、速率和数据集。
事件 examples 必须是合成数据。不要把生产消息抓包后只替换姓名:订单号、租户 ID、设备 ID、时间序列和自由文本组合后仍可能重新识别。建立字段级样例策略:稳定假前缀、无真实域名、无真实手机号邮箱、无客户内容、无内部 topic 与主机;CI 扫描常见密钥模式和组织敏感标识。
容量、成本与发布形态
单个 YAML 很小,但大规模契约会产生解析、bundle、diff、生成、静态页面、制品保留和评审等待成本。远程 $ref 还引入网络延迟、凭证和供应链故障。共享 schema 应通过固定提交或不可变制品引用,CI 对下载设置超时与缓存,并在缓存命中时校验摘要。
观察这些趋势比设一个通用文件大小阈值更可靠:
lint、bundle、diff 和生成耗时是否随契约数量异常增长。同一提交重复生成后的文件摘要是否稳定。未使用 schema、孤立 channel 和无 owner 契约是否持续增加。
breaking 例外数量、最长等待年龄和豁免到期后残留是否增长。Mock 通过但真实契约测试失败的比例是否上升。
开源 CLI 可以本地运行,不要求购买托管平台;协作编辑、托管 registry、门户、RBAC、SSO、审计和私有预览可能属于不同产品或团队计划。采购前在各厂商官方价格与权限页核对用户数、私有项目、构建额度、数据驻留、导出和退出能力,不能从开源 CLI 能力推断 SaaS 权益。
故障证据从哪一层取
$ref 本机成功、CI 失败
先比较入口文件、工作目录、文件名大小写和 URI 基准,再检查私有远程引用凭证。执行 redocly bundle 或 asyncapi bundle 能把“找不到引用”与后续 lint 分开。不要为了让 CI 通过把私有 registry token 写进 URL。
预览正常、lint 失败
预览器可能宽容未知字段或只渲染可识别部分;以规范解析与仓库规则的非零退出码为准。确认编辑器与 CI 使用同一 CLI、配置和入口,而不是关闭规则。
diff 没报错,消费者仍然失败
自动规则不知道框架是否拒绝未知字段、是否依赖 enum 穷举、是否保存旧事件重放。保留真实消费者契约测试、历史样本的脱敏回放和灰度指标;对自动工具误判建立有 owner、有理由、可到期的例外,不能永久加 --no-error。
重新生成后出现大面积差异
先比较 npm wrapper、JAR、模板、目标语言运行时和格式化器版本。确认生成前已清空输出,排除旧文件残留。若只是工具升级,单独提交生成差异;若契约也变化,两类差异混在一起会让评审无法判断兼容影响。
清理、回滚与长期治理
本地预览和 mock 用 Ctrl+C 停止,确认 3210、4010 等端口已释放。contracts/generated 可以删除后重建;源契约、baseline 和兼容例外不能随生成目录一起清理。临时 token、npm 配置和 broker 测试账号在实验后撤销,沙箱 topic 与测试消息按 run ID 精确清理。
契约发布失败时,先恢复上一份不可变契约制品和对应生成器版本。已经有消费者采用的新字段时,简单 git revert 可能制造第二次不兼容;优先采用兼容性补丁、并行字段或新 operation/channel,再按消费证据下线旧版本。事件系统还要等待旧消息越过 retention 或完成重放验证。
每个契约登记服务 owner、消费者、规范线、入口文件、发布制品、兼容策略、认证方案、数据分类、生成器和例外审批人。CI 必须能证明 schema 合法、团队 lint 通过、breaking change 已处理、生成可重复、样例已脱敏;运行时测试再证明真实 HTTP 与消息链遵守契约。到这一步,文档才不只是“能打开”,而是能约束系统演进。
