元数据卡
- 前置知识:第14章(微服务基础模式);了解 HTTP 基础
- 预计时间:45 分钟
- 完成标志:能设计 RESTful API 接口契约,使用 OpenAPI 描述并建立消费者驱动的测试
你的进度
工匠之都的作坊已经拆成独立工坊了:锻造坊、组装坊、磨刃坊。每个工坊都能独立提供零件。但问题来了——别的工坊来借工具、要零件的时候,没有一个统一的标准接口。锻造坊说要 "编号 4032 的钢锭";组装坊管同一个东西叫 "主支架原料"。结果你拿到的零件不对,工期延误三天。
每个工坊就是一个服务。服务之间需要契约——一个双方都认的接口定义,写死了,谁也别改术语。 你的任务
当系统从单体膨胀到多个服务(或仅仅是前后端分离),你需要一种精确的方式定义:你的服务提供什么能力、需要什么输入、返回什么输出、出错时怎么告知调用方。这就是 API 设计与契约治理。这一章从 REST 的资源建模到契约测试,覆盖一个生产级 API 的完整设计流程。
本章分层
- 必读:REST 资源建模、版本策略、OpenAPI 基础
- 选读:gRPC/AsyncAPI 对比
- 进阶:消费者驱动的契约测试
本章不会要求你掌握
- gRPC 的 protobuf 详细语法
- GraphQL schema 设计
破局 · 溯源
第一个问题:两个服务之间没有契约,怎么知道对方要什么?
锻造坊提供一个 "getItem" 接口,返回 {"id": 4032, "name": "钢锭", "weight_kg": 12.5}。组装坊调用了,很开心。后来锻造坊更新了服务: weight_kg 变成了 weight —— 组装坊的代码坏了。对方改了字段名,你完全不知道。
第一层:为什么 API 需要契约
API 契约是双方签署的接口说明书。它规定: 有什么资源可操作、用什么方法操作、输入输出格式、错误格式、版本变更规则。
三种主流的 API 契约协议:
REST 基于 HTTP。资源用 URI 标识,操作映射到 HTTP 方法。契约描述用 OpenAPI。
gRPC 使用 Protocol Buffers 定义接口。基于 HTTP/2、强类型、支持双向流。契约描述用 .proto 文件。
AsyncAPI 针对事件/消息驱动,描述服务的发布/订阅关系。契约描述用 AsyncAPI 规范。
# OpenAPI 示例
openapi: 3.1.0
info:
title: 锻造坊 API
version: 1.0.0
paths:
/workshops/{workshopId}/parts/{partId}:
get:
summary: 获取零件信息
parameters:
- name: workshopId
in: path
required: true
schema: { type: string }
- name: partId
in: path
required: true
schema: { type: string }
responses:
'200':
description: 零件详情
content:
application/json:
schema:
$ref: '#/components/schemas/Part'
'404':
description: 零件不存在// gRPC 等价接口
service ForgeService {
rpc GetPart(GetPartRequest) returns (Part);
}
message GetPartRequest {
string workshop_id = 1;
string part_id = 2;
}
message Part {
string id = 1;
string name = 2;
double weight_kg = 3;
}契约的最大价值: 它不再是代码里藏着的隐式约定,而是一个显式文档,人和机器都能读。
第二层:资源建模与 HTTP 语义
锻造坊有哪些东西可以被外部操作?零件、工单、库存记录——这些都是资源。REST 的资源建模核心:
资源 = 名词 → /parts, /workshops
集合 vs 单例 → /parts(集合), /parts/{partId}(单例)
子资源 → /workshops/{id}/equipmentHTTP 方法对应操作:
| 方法 | 作用 | 幂等 | 安全 |
|---|---|---|---|
| GET | 读取资源 | 是 | 是 |
| POST | 创建资源 | 否 | 否 |
| PUT | 全量替换 | 是 | 否 |
| PATCH | 部分更新 | 否 | 否 |
| DELETE | 删除资源 | 是 | 否 |
安全 = 不修改服务端状态。幂等 = 重复执行结果相同。
// REST 资源对应的控制器
@RestController
@RequestMapping("/api/v1/workshops")
public class WorkshopController {
@GetMapping
public ResponseEntity<List<WorkshopSummary>> listWorkshops(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(workshopService.list(page, size));
}
@GetMapping("/{id}")
public ResponseEntity<WorkshopDetail> getWorkshop(@PathVariable String id) {
return workshopService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<WorkshopDetail> createWorkshop(
@RequestBody @Valid CreateWorkshopRequest request) {
WorkshopDetail created = workshopService.create(request.toDomain());
return ResponseEntity.created(
URI.create("/api/v1/workshops/" + created.getId())).body(created);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteWorkshop(@PathVariable String id) {
workshopService.delete(id);
return ResponseEntity.noContent().build();
}
}资源名称用复数名词、小写、kebab-case。路径参数放在路径里,查询参数放在 query string 里。
第三层:版本策略
你改了一个字段名,调用方坏了。下次你不敢改了——但业务必须改。
两种主流方案:
URI 版本化: GET /api/v1/workshops/4032 和 GET /api/v2/workshops/4032。优点: 直观。缺点: URL 膨胀。
Header 版本化: Accept: application/vnd.forge.v2+json。优点: URL 干净。缺点: 调试不友好。
// Header 版本化实现
@GetMapping(value = "/workshops/{id}", headers = "Accept=application/vnd.forge.v1+json")
public ResponseEntity<WorkshopDetailV1> getWorkshopV1(@PathVariable String id) { ... }
@GetMapping(value = "/workshops/{id}", headers = "Accept=application/vnd.forge.v2+json")
public ResponseEntity<WorkshopDetailV2> getWorkshopV2(@PathVariable String id) { ... }版本化的基本原则: 向后兼容的变更不需要升大版本:
兼容变更(可小版本):
- 新增响应字段
- 新增可选请求参数
- 扩大输入约束(varchar(50) → varchar(100))
不兼容变更(须大版本):
- 移除或重命名字段
- 修改字段类型
- 修改请求语义(GET → POST)第四层:幂等键、分页与过滤
你的锻造坊收到两条相同的零件订单——网络重试导致的。难道你要打两把相同的零件?
幂等键解决重复请求。调用方在请求头里带一个唯一的 key,服务端记住这个 key:
@Component
public class IdempotencyFilter implements Filter {
private final Cache<String, IdempotencyRecord> cache;
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
String idempotencyKey = request.getHeader("Idempotency-Key");
if (idempotencyKey != null && isMutationRequest(request)) {
IdempotencyRecord existing = cache.get(idempotencyKey);
if (existing != null) {
writeCachedResponse(res, existing);
return;
}
request.setAttribute("idempotencyKey", idempotencyKey);
}
chain.doFilter(req, res);
}
}客户端保证 key 的唯一性(UUID 即可),窗口期通常 24 小时。
分页 — 一次请求返回 10 万条零件记录?不可以。
Page-based:
@GetMapping("/workshops/{id}/parts")
public ResponseEntity<PageResponse<PartSummary>> listParts(
@PathVariable String id,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String material) {
Page<Part> result = partRepository.findByWorkshopId(id,
PageRequest.of(page, size), material);
return ResponseEntity.ok(PageResponse.from(result));
}
public record PageResponse<T>(
List<T> content, int page, int size,
long totalElements, boolean hasNext) {
public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(page.getContent(), page.getNumber(),
page.getSize(), page.getTotalElements(), page.hasNext());
}
}Cursor-based 分页(大数据量更稳定): GET /parts?cursor=eyJsYXN0X2lkIjoiNDAyMCJ9&limit=20,返回 { "data": [...], "nextCursor": "...", "hasMore": true }。插入新数据不会导致结果跳变。
过滤 — 让服务端筛选而非客户端全量过滤: GET /parts?material=steel&status=active&weight_min=5.0。每个维度一个独立参数。
第五层:OpenAPI 规范基础
一个完整的 OpenAPI 文档:
openapi: 3.1.0
info:
title: 工匠之都锻造坊 API
version: 2.0.0
servers:
- url: https://api.forge.craft-city/v2
paths:
/parts:
get:
summary: 零件列表
parameters:
- name: material
in: query
schema: { type: string }
- name: page
in: query
schema: { type: integer, default: 0 }
responses:
'200':
description: 零件分页列表
content:
application/json:
schema:
$ref: '#/components/schemas/PartPage'
components:
schemas:
Part:
type: object
required: [id, name, material, weight]
properties:
id: { type: string, example: "part-4032" }
name: { type: string, example: "高强度钢锭" }
material: { type: string, example: "chromium-steel" }
weight: { type: number, format: double }
PartPage:
type: object
properties:
content: { type: array, items: { $ref: '#/components/schemas/Part' } }
page: { type: integer }
totalElements: { type: integer }
hasNext: { type: boolean }工具链: Swagger UI / Redoc 生成文档页面,OpenAPI Generator 生成客户端 SDK,Stoplight 做协作设计。
第六层:消费者驱动的契约测试
契约写了,两端实现了。怎么确保它们匹配?
消费者驱动的契约测试(CDCT): 消费者定义契约,提供者验证自己满足。
以 Spring Cloud Contract 为例:
// 消费者定义的契约
Contract.make {
description "获取零件详情"
request {
method GET()
url "/v1/workshops/forge-1/parts/part-4032"
headers { accept(applicationJson()) }
}
response {
status 200
headers { contentType(applicationJson()) }
body([id: "part-4032", name: "高强度钢锭", material: "chromium-steel", weight: 12.5])
}
}Pact 是另一个主流工具:
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "ForgeService")
class ForgeServiceConsumerPactTest {
@Pact(consumer = "AssemblyService")
public V4Pact createPact(PactDslWithProvider builder) {
return builder
.given("零件 part-4032 存在")
.uponReceiving("获取零件详情")
.path("/v1/workshops/forge-1/parts/part-4032")
.method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.stringType("id")
.stringType("name")
.numberType("weight"))
.toPact(V4Pact.class);
}
}CDC 的核心价值: 契约先于实现。消费者先定好,提供者按要求实现。
第七层:错误模型设计
接口不可能永远成功。零件不存在(404)、参数非法(400)、服务过载(503)。
遵循 RFC 9457 (Problem Details):
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.forge.craft-city/errors/part-validation",
"title": "零件参数校验失败",
"status": 422,
"detail": "材料类型 'wood' 不在支持列表中",
"timestamp": "2026-06-25T09:31:00Z",
"errors": [
{ "field": "material", "message": "只支持 steel, chromium-steel, titanium" }
]
}@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ProblemDetail> handleNotFound(ResourceNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
problem.setTitle("资源不存在");
problem.setType(URI.create("https://api.forge.craft-city/errors/not-found"));
return ResponseEntity.status(404).body(problem);
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ProblemDetail> handleValidation(ValidationException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
problem.setTitle("参数校验失败");
problem.setProperty("errors", ex.getFieldErrors().stream()
.map(e -> Map.of("field", e.getField(), "message", e.getMessage()))
.toList());
return ResponseEntity.status(422).body(problem);
}
}HTTP 状态码速查: 200 OK (GET/PUT 成功), 201 Created (POST), 204 No Content (DELETE), 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 422 参数语义错误, 429 限流, 500 服务端内部错误, 503 服务不可用。
常见陷阱
陷阱一:所有错误都返回 200 + 业务 code。 "成功了 code=0,失败了 code=-1"——这是最糟糕的反模式。HTTP 状态码就是专为这个设计的。
陷阱二:API 没有版本,每次更新都是 breaking change。 加一个字段没人敢加,改一个字段出大问题。内部服务之间也要有版本意识。
陷阱三:分页用 SELECT * 然后内存分页。 数据库返回 10 万条,应用层只要 20 条。分页必须在 SQL 层面做: LIMIT 20 OFFSET 0。
陷阱四:契约只在开发者脑子里。 OpenAPI 文档应该是 API 定义的第一来源,不是事后的文档快照。
通关挑战
- 热身:找一个你项目的 API,用
curl -v观察响应状态码和错误格式。 - 挑战:为你项目中的一个模块写 OpenAPI 3.1 文档,包含至少 3 个路径、分页参数和错误响应 schema。
- 挑战:用 Pact 或 Spring Cloud Contract 为你的 API 写一个 CDC 契约。
验收标准
- 你能用 OpenAPI 写一个包含 GET/POST/PUT/DELETE 的 API 文档
- 你能区分兼容和不兼容的 API 变更
- 你理解幂等键的实现原理和适用场景
常见卡点
- 不知道资源怎么拆。 一个原则: 一个资源对应一个业务概念,不要按数据库表拆分。
- 不知道 OpenAPI 写多细。 至少写类型、格式和示例值。
现在不需要理解
- gRPC stream/duplex 的完整语法
- OpenAPI codegen 的高级插件配置
旅人笔记
API 契约让你从"猜接口的作坊"变成"有标准工艺目录的大工坊"。资源建模让你知道数据长什么样,版本策略让你敢于进化,幂等键让你不被重复请求打倒——最后,统一的错误模型让你的调用方不用猜"这次返回的 JSON 结构是什么"。
下一站预告
API 契约解决了服务间"用什么格式通信"的问题。但一个更深的问题还在: 每个工坊的"领域语言"不统一。锻造坊说"毛坯",组装坊说"半成品"——同一个东西,三个名字。下一章: 领域驱动设计。