契约优先的 Java 开放接口落地:OpenAPI 3.1.1 与 JSON Schema 2020-12 的双向校验链

契约优先的 Java 开放接口落地:OpenAPI 3.1.1 与 JSON Schema 2020-12 的双向校验链

1. 先把要解决的问题说清楚

假设你所在的团队维护一套 Java 订单系统,业务方希望你把这套能力开放给外部开发者:合作方用自己的系统下单、查单、接收状态回调。这时你面对的不是"再多写几个 Controller",而是一份长期存在的对外承诺。外部开发者不在你的办公室里,看不到你的代码,也不会跟着你的版本节奏升级;他们只会照着文档写代码,然后期待你的接口在几年内保持可用。

这件事之所以难,是因为开放接口有三类风险同时存在:

第一类是语义风险 。你用 amount 表示金额,但没写清它是分还是元、是否含税、上限多少。外部开发者按元传,你按分存,双方都没报错,但账错了。

第二类是演进风险。你在响应里删掉一个字段,或者把一个原本必填的请求参数改成可选,内部调用方改一行代码就完事,外部开发者可能已经上线三个月并且依赖了旧行为。

第三类是信任风险。外部请求会伪造、会重放、会超频、会传畸形 JSON,你的服务端不能假设对方守规矩。

契约优先(contract-first)要解决的就是第一、二类问题:先把对外的接口契约写成机器可读的文档,再让服务端实现、客户端 SDK、测试、文档都从这份契约派生,从而让"对外承诺"成为可检查、可版本化、可回归的工程产物。第三类问题则要在服务端实现里用校验、签名、幂等键、限流这些机制兜住,而且这些机制必须和契约里的描述保持一致,不能文档说一套、代码做一套。

本文围绕一条主线展开:对一份"创建订单 + 查询订单 + 状态回调"的开放接口,如何用 OpenAPI 3.1.1 描述它、用 JSON Schema 2020-12 约束它的请求和响应体、在 Java 服务端把校验链真正落地,并管理它的兼容性生命周期。

需要提前说明证据边界:本文没有附带实测日志,也没有在某个具体团队的生产环境中运行过。文中出现的版本号、规范条款属于可查的官方信息;文中的端口、超时、配额数字是示意值,实际取值必须结合你的部署容量和压测结果确定;涉及"某工具支持某特性"的说法,都以该工具官方文档为准,本文只在必要处指出需要核对的方向,不虚构支持矩阵。

2. 环境与版本边界:先固定"标准"和"工具"两个层面

契约优先最容易踩的坑,是把"规范保证的行为"和"某个工具的当前实现"混为一谈。所以在写第一行 YAML 之前,先把版本钉死。

本文采用的技术版本如下:

  • 运行时:Java 21 LTS(java --version 应显示 21)。
  • 接口描述规范:OpenAPI Specification 3.1.1。
  • 数据结构描述规范:JSON Schema 2020-12。
  • HTTP 语义:RFC 9110(HTTP Semantics)。

这四个东西分层不同,必须分开理解:

  • OpenAPI 3.1.1 描述的是"有哪些路径、用了什么 HTTP 方法、请求和响应长什么样、如何认证"。它解决接口目录和结构问题。
  • JSON Schema 2020-12 描述的是"一个 JSON 实例是否合法"。它解决单个 JSON 文档的形状和取值约束问题。OpenAPI 3.1 的一个重要变化是:它的 Schema Object 直接采用 JSON Schema 2020-12 的语义,而不是像 3.0 那样使用一套近似但不完全相同的方言。这意味着你在 OpenAPI 文档里写的 type、required、oneOf、$ref 等关键字,可以按 JSON Schema 2020-12 的规则去理解。
  • RFC 9110 约束的是 HTTP 方法语义和状态码含义。比如 POST 不是幂等的、201 Created 应带 Location、429 表示请求过多、412 表示前置条件失败。契约里的状态码要符合这些语义,不能自创"200 表示业务失败但 body 里写 error"。
  • Java 21 是实现层。它决定了你能用哪些语言特性和哪些库版本。

这里有一个需要明确区分的点:规范的一致性要求 和工具的一致性支持 是两件事。OpenAPI 3.1.1 规范定义了 Schema Object 的语义,但一个具体版本的代码生成器、文档渲染器或校验库,可能只实现了其中一部分关键字,或者对 $ref 解析范围有限制。官方规范不会保证"所有工具都完整支持 2020-12 的全部关键字"。因此工程上要建立一条纪律:凡是契约中用到的关键字,必须在你的工具链里实际验证过它被正确处理,验证不了的就不要用,或者降级成更保守的写法。

这条纪律的具体操作方式,会在第 12 节给出可执行的检查清单。

3. 先建模资源,再决定方法

OpenAPI 文档的骨架是路径和操作。但路径是从资源模型推导出来的,不是先画路由再想语义。

对外开发者理解接口的方式是"名词 + 动作"。所以资源命名要稳定、可数、无动词:

  • orders 表示订单集合。
  • orders/{orderId} 表示单个订单。
  • orders/{orderId}/events 表示这个订单的事件流(可选,用于查询状态变更历史)。

HTTP 方法的语义(RFC 9110)决定每个路径能挂什么操作:

操作 方法 是否安全(不改状态) 是否幂等 典型成功状态码
创建订单 POST /orders 否 否 201 Created
查询订单 GET /orders/{orderId} 是 是 200 OK
取消订单 DELETE /orders/{orderId} 否 是 204 No Content
全量替换(如需要) PUT /orders/{orderId} 否 是 200 OK 或 204

表格里"是否幂等"是 HTTP 语义层面的定义:多次执行同一个请求,服务端状态的效果与执行一次相同。注意它不等于"业务幂等键"。后者是应用层面的去重机制,用来让一个非幂等的 POST 在重试时只生效一次。这两个概念必须在文档里分开写清楚,否则外部开发者会误以为"反正 POST 重试也没事"。

状态码的选择要遵守 RFC 9110 的含义,而不是按内部习惯:

  • 请求体格式非法或有字段不满足约束:400 Bad Request。
  • 认证凭据缺失或无效:401 Unauthorized。
  • 凭据有效但无权访问该资源:403 Forbidden。
  • 资源不存在:404 Not Found。
  • 请求与当前资源状态冲突,比如重复创建同一个业务键:409 Conflict。
  • 前置条件失败(如 If-Match 版本不匹配):412 Precondition Failed。
  • 请求体合法但语义上无法处理(如字段组合矛盾):422 Unprocessable Content。
  • 触发配额或频率限制:429 Too Many Requests。
  • 服务端未预期错误:5xx。

一个常见反例:把所有业务错误都返回 200,在 body 里写 {"code": 4001}。这会让 HTTP 层的缓存、重试、监控、网关策略全部失效,也让外部开发者无法用通用库处理错误。契约优先要求错误也走 HTTP 语义。

4. 错误模型:一次定义,双向使用

错误响应必须结构化,并且同一个结构在文档、SDK、服务端、测试里复用。一个可用的错误模型如下:

  • type:稳定的错误类别标识,用 URI 或短字符串,例如 https://errors.example.com/order/invalid-amount。它是给程序判断的,不随文案变化。
  • title:人类可读的短标题,英文或中文固定,不用于程序判断。
  • status:与 HTTP 状态码一致。
  • detail:针对本次请求的具体说明,可以包含字段名。
  • code:平台内部错误码,可选,用于对接工单系统。
  • traceId:排查用的请求追踪 ID。
  • errors:字段级错误数组,每项包含 pointer(指向请求体中的位置,如 /amount)和 message。

这个结构类似业界常见的 problem details 风格,但本文不声称它等同于某个具体 RFC 的强制要求;如果你要严格对齐某个标准,请以该标准原文为准。

关键设计点:字段级错误的 pointer 要能用 JSON Pointer 表达,这样外部开发者可以把错误直接映射到自己表单的某个输入框。这就要求服务端校验时保留"哪个字段错了"的信息,而不是只返回一句"参数错误"。

错误模型和校验链的关系是:请求体通过 JSON Schema 校验失败 → 服务端把校验库给出的路径信息转换成 errors[].pointer → 返回 400 和上述结构。响应体校验失败(服务端自己生成了不符合契约的数据)→ 这是服务端缺陷,应记录告警并返回 500,不能把内部结构泄露给外部。

5. 契约的核心:OpenAPI 3.1.1 与 JSON Schema 2020-12 怎么配合

5.1 两个规范各管什么

用一个类比:OpenAPI 文档像"接口说明书目录",它告诉你每个端点接受什么媒体类型、有哪些参数、成功和失败分别返回什么结构。JSON Schema 像"数据规格卡",它告诉你某一份 JSON 实例的字段、类型、范围、必填关系。OpenAPI 3.1.1 的 Schema Object 采用 JSON Schema 2020-12 的语义,所以"规格卡"可以直接嵌在"说明书"里。

这意味着两件事:

  1. 你可以在 OpenAPI 文档里用 2020-12 的关键字,比如 type: ["string", "null"](联合类型,3.0 时代通常写成 nullable: true)、const、if/then/else、prefixItems、unevaluatedProperties 等。
  2. 契约里的 Schema 可以被单独抽出来,直接喂给一个 JSON Schema 2020-12 校验器做运行时校验。这对"双向校验链"至关重要:服务端不是解析 OpenAPI 文档去校验请求,而是把其中请求体和响应体的 Schema 抽取为独立的 JSON Schema 资源,用标准校验器执行。

5.2 为什么强调"双向"

很多团队只做请求校验:外部传进来的 JSON 用 Schema 卡一遍,不合法就 400。这只是一半。响应也需要校验,原因有三:

  • 防止服务端内部对象序列化出契约之外的字段或类型,比如金额本应是整数,却因为某个 DTO 用了 BigDecimal 被序列化成 12.50。
  • 让 SDK 生成器和契约测试有可信的响应样本。
  • 当契约演进时,能立即发现"代码没跟上文档"的情况。

响应校验的代价是每次响应多一次校验开销。工程上通常只在测试环境和灰度环境做全量响应校验,生产环境按采样比例开启,并对失败打点告警。这个取舍要在文档里写明,不要让外部开发者以为"你们每次响应都校验过"。

5.3 规范与工具差异的第一个具体点:$ref 的解析

JSON Schema 2020-12 支持 $id 和跨文档 $ref。OpenAPI 3.1.1 也支持在文档内用 $ref 引用组件。但具体校验库和代码生成器对"跨文件引用""按 $id 解析远程引用"的支持程度不同。工程上最稳妥的做法是:在契约里使用文档内引用或明确的本地文件引用,不依赖远程 $id 解析;构建时把所有引用打包成一份自包含的 Schema 资源,再做校验和代码生成。

这个做法不是因为规范不允许远程引用,而是因为工具实现差异会让 CI 在不同机器上表现不一致。这一点属于工程建议,不是规范强制。

6. 一个贯穿示例:订单开放接口

下面这条主线会一直用到文章结束。为了可复现,先给出完整的最小工程结构、依赖版本和启动方式;所有端口、密钥、配额都是占位或示意值。

6.1 前置环境

  • JDK 21(java --version 输出 21.x)。
  • Maven 3.9.x(或等价的 Gradle 版本,本文以 Maven 为例)。
  • 一个本地可用的 HTTP 客户端,如 curl。
  • 一个可选的 JSON Schema 校验器依赖:com.networknt:json-schema-validator(本文写作时的常见选择,版本需自行在 Maven Central 核对最新稳定版;下文的版本号 1.5.x 只是示意,务必以实际解析到的版本为准)。

目录结构(示意):

text 复制代码
openapi-demo/
  pom.xml
  src/main/java/com/example/openapi/
    OpenApiDemoApplication.java
    api/OrderController.java
    api/ErrorHandler.java
    contract/ContractValidator.java
    model/OrderRequest.java
    model/OrderResponse.java
  src/main/resources/
    openapi/order-api.yaml
    schema/order-create-request.json
    schema/order-response.json
    schema/error.json
  src/test/java/com/example/openapi/
    ContractValidationTest.java

6.2 依赖(pom.xml 关键片段)

xml 复制代码
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>openapi-demo</artifactId>
  <version>0.1.0</version>
  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
      <version>3.3.0</version>
    </dependency>
    <dependency>
      <groupId>com.networknt</groupId>
      <artifactId>json-schema-validator</artifactId>
      <version>1.5.0</version>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <version>3.3.0</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>

版本说明:Spring Boot 3.3.0 要求 Java 17 及以上,与 Java 21 兼容。json-schema-validator 的版本会随 JSON Schema 2020-12 支持进度变化,上面的 1.5.0 只是占位示意,构建前应到官方仓库确认该版本是否声明支持 2020-12 方言,以及它默认使用哪个方言。如果默认方言不是 2020-12,需要显式设置。这一段正是"规范支持 ≠ 工具默认行为"的典型例子。

6.3 契约文件

src/main/resources/openapi/order-api.yaml(示意,完整但精简):

yaml 复制代码
openapi: 3.1.1
info:
  title: Order Open API
  version: 1.0.0
  description: 面向外部开发者的订单接口。
servers:
  - url: https://sandbox.example.com/api/v1
    description: 沙箱环境
paths:
  /orders:
    post:
      operationId: createOrder
      summary: 创建订单
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
            minLength: 16
            maxLength: 64
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '../schema/order-create-request.json'
      responses:
        '201':
          description: 创建成功
          headers:
            Location:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '../schema/order-response.json'
        '400':
          description: 请求体或参数校验失败
          content:
            application/json:
              schema:
                $ref: '../schema/error.json'
        '409':
          description: 幂等键与已有请求体冲突
          content:
            application/json:
              schema:
                $ref: '../schema/error.json'
        '429':
          description: 超过配额或频率限制
          content:
            application/json:
              schema:
                $ref: '../schema/error.json'
  /orders/{orderId}:
    get:
      operationId: getOrder
      summary: 查询订单
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: string
            pattern: '^ord_[A-Za-z0-9]{20}$'
      responses:
        '200':
          description: 查询成功
          content:
            application/json:
              schema:
                $ref: '../schema/order-response.json'
        '404':
          description: 订单不存在
          content:
            application/json:
              schema:
                $ref: '../schema/error.json'

src/main/resources/schema/order-create-request.json(JSON Schema 2020-12):

json 复制代码
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://schemas.example.com/order-create-request.json",
  "type": "object",
  "required": ["buyerId", "amount", "currency"],
  "properties": {
    "buyerId": {
      "type": "string",
      "minLength": 1,
      "maxLength": 64
    },
    "amount": {
      "type": "integer",
      "minimum": 1,
      "maximum": 100000000
    },
    "currency": {
      "type": "string",
      "enum": ["CNY", "USD"]
    },
    "note": {
      "type": ["string", "null"],
      "maxLength": 200
    }
  },
  "additionalProperties": false
}

src/main/resources/schema/order-response.json:

json 复制代码
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://schemas.example.com/order-response.json",
  "type": "object",
  "required": ["orderId", "status", "amount", "currency", "createdAt"],
  "properties": {
    "orderId": {
      "type": "string",
      "pattern": "^ord_[A-Za-z0-9]{20}$"
    },
    "status": {
      "type": "string",
      "enum": ["CREATED", "PAID", "CANCELLED"]
    },
    "amount": { "type": "integer", "minimum": 1 },
    "currency": { "type": "string", "enum": ["CNY", "USD"] },
    "createdAt": { "type": "string", "format": "date-time" }
  },
  "additionalProperties": false
}

src/main/resources/schema/error.json:

json 复制代码
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://schemas.example.com/error.json",
  "type": "object",
  "required": ["type", "title", "status"],
  "properties": {
    "type": { "type": "string" },
    "title": { "type": "string" },
    "status": { "type": "integer", "minimum": 400, "maximum": 599 },
    "detail": { "type": "string" },
    "code": { "type": "string" },
    "traceId": { "type": "string" },
    "errors": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["pointer", "message"],
        "properties": {
          "pointer": { "type": "string" },
          "message": { "type": "string" }
        },
        "additionalProperties": false
      }
    }
  },
  "additionalProperties": false
}

关于 format: date-time 的一点重要说明:JSON Schema 2020-12 把 format 定义为注解(annotation)而不是强制校验断言,具体校验器可以选择是否按格式校验。所以"时间格式必须合法"这件事如果对业务很关键,不能只靠 format,要在服务端实现里额外显式解析校验,并在文档中说明这一行为。这是规范语义和工程期待之间的真实差异。

6.4 服务端校验链

核心思路:把三个 Schema 文件在启动时加载并编译成可复用的校验对象,请求进来先校验请求体,响应出去前按需校验响应体。下面是一段示意实现,不是完整可运行程序,省略了异常映射和线程安全细节:

java 复制代码
package com.example.openapi.contract;

import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.InputStream;
import java.util.Set;

public final class ContractValidator {

    private final ObjectMapper mapper = new ObjectMapper();
    private final JsonSchema requestSchema;
    private final JsonSchema responseSchema;

    public ContractValidator() {
        JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012);
        this.requestSchema = factory.getSchema(read("/schema/order-create-request.json"));
        this.responseSchema = factory.getSchema(read("/schema/order-response.json"));
    }

    private JsonNode read(String classpathResource) {
        try (InputStream in = getClass().getResourceAsStream(classpathResource)) {
            return mapper.readTree(in);
        } catch (Exception e) {
            throw new IllegalStateException("无法加载契约资源: " + classpathResource, e);
        }
    }

    public Set<ValidationMessage> validateRequest(JsonNode body) {
        return requestSchema.validate(body);
    }

    public Set<ValidationMessage> validateResponse(JsonNode body) {
        return responseSchema.validate(body);
    }
}

这段代码有三个需要解释的点:

  1. SpecVersion.VersionFlag.V202012 是显式指定方言。如果不指定,校验器可能使用默认方言,而默认方言不一定是 2020-12。这就是第 6.2 节强调"构建前确认默认方言"的原因。
  2. 校验器返回的是 ValidationMessage 集合,每条消息通常带路径信息。控制器要把这些路径转换成错误模型的 pointer,而不是直接把库的原始错误字符串返回给外部。
  3. 校验器对象在启动时构建一次,避免每个请求都解析 Schema 文件。

控制器示意:

java 复制代码
package com.example.openapi.api;

import com.example.openapi.contract.ContractValidator;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;
import java.util.Map;

@RestController
@RequestMapping("/api/v1")
public class OrderController {

    private final ContractValidator validator = new ContractValidator();

    @PostMapping("/orders")
    public ResponseEntity<?> createOrder(
            @RequestHeader("Idempotency-Key") String idempotencyKey,
            @RequestBody JsonNode body) {

        var messages = validator.validateRequest(body);
        if (!messages.isEmpty()) {
            return ResponseEntity.badRequest().body(Map.of(
                    "type", "https://errors.example.com/order/invalid-request",
                    "title", "请求体校验失败",
                    "status", 400,
                    "detail", messages.iterator().next().getMessage()
            ));
        }

        // 此处应是业务处理与持久化;本文示例用固定值代替。
        String orderId = "ord_0123456789abcdefghij";
        Map<String, Object> response = Map.of(
                "orderId", orderId,
                "status", "CREATED",
                "amount", body.get("amount").asInt(),
                "currency", body.get("currency").asText(),
                "createdAt", "2025-01-01T00:00:00Z"
        );
        return ResponseEntity.created(URI.create("/api/v1/orders/" + orderId)).body(response);
    }
}

这段代码是示意 ,不是可直接上线的实现:它没有处理幂等键存储、没有做认证、没有做限流,返回的 createdAt 是硬编码,金额取值范围依赖 Schema。之所以这样写,是为了让校验链的骨架足够清楚。

6.5 启动与验证

启动:

bash 复制代码
mvn -q spring-boot:run

正常路径(预期返回 201,Location 头指向新订单):

bash 复制代码
curl -i -X POST http://localhost:8080/api/v1/orders \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 9f1c2a7e5b3d4f60a1b2c3d4e5f60718' \
  -d '{"buyerId":"buyer-1","amount":1234,"currency":"CNY"}'

预期观察:状态行 HTTP/1.1 201,响应体包含 orderId、status: CREATED、amount: 1234,Location 头形如 /api/v1/orders/ord_...。

边界路径(金额为 0,不满足 minimum: 1):

bash 复制代码
curl -i -X POST http://localhost:8080/api/v1/orders \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 9f1c2a7e5b3d4f60a1b2c3d4e5f60718' \
  -d '{"buyerId":"buyer-1","amount":0,"currency":"CNY"}'

预期观察:HTTP/1.1 400,响应体 type 为 invalid-request。注意:示例实现只返回了第一条错误的说明,没有构造 errors[] 数组。这不是契约缺陷,而是示意实现的简化;真实实现应把每条 ValidationMessage 的路径映射为 pointer,返回完整的字段级错误。

失败路径(多传一个契约外字段):

bash 复制代码
curl -i -X POST http://localhost:8080/api/v1/orders \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 9f1c2a7e5b3d4f60a1b2c3d4e5f60718' \
  -d '{"buyerId":"buyer-1","amount":100,"currency":"CNY","coupon":"X"}'

预期观察:400,因为请求 Schema 设置了 additionalProperties: false。

这里要交代一个取舍:additionalProperties: false 让契约非常严格,外部开发者多传字段立刻失败;好处是防止拼写错误和隐式依赖,代价是未来新增可选字段时,旧客户端如果回传了完整对象可能不受影响,但服务端新增字段本身不会导致旧请求失败。这个取舍要在文档里明说。

7. 请求与响应校验链的执行顺序

把一次请求的处理画成链路,可以清楚看到校验发生在哪些位置:
#mermaid-svg-Ja9oVFEregPdjJYj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Ja9oVFEregPdjJYj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Ja9oVFEregPdjJYj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Ja9oVFEregPdjJYj .error-icon{fill:#552222;}#mermaid-svg-Ja9oVFEregPdjJYj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Ja9oVFEregPdjJYj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Ja9oVFEregPdjJYj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Ja9oVFEregPdjJYj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Ja9oVFEregPdjJYj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Ja9oVFEregPdjJYj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Ja9oVFEregPdjJYj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Ja9oVFEregPdjJYj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Ja9oVFEregPdjJYj .marker.cross{stroke:#333333;}#mermaid-svg-Ja9oVFEregPdjJYj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Ja9oVFEregPdjJYj p{margin:0;}#mermaid-svg-Ja9oVFEregPdjJYj .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Ja9oVFEregPdjJYj .cluster-label text{fill:#333;}#mermaid-svg-Ja9oVFEregPdjJYj .cluster-label span{color:#333;}#mermaid-svg-Ja9oVFEregPdjJYj .cluster-label span p{background-color:transparent;}#mermaid-svg-Ja9oVFEregPdjJYj .label text,#mermaid-svg-Ja9oVFEregPdjJYj span{fill:#333;color:#333;}#mermaid-svg-Ja9oVFEregPdjJYj .node rect,#mermaid-svg-Ja9oVFEregPdjJYj .node circle,#mermaid-svg-Ja9oVFEregPdjJYj .node ellipse,#mermaid-svg-Ja9oVFEregPdjJYj .node polygon,#mermaid-svg-Ja9oVFEregPdjJYj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Ja9oVFEregPdjJYj .rough-node .label text,#mermaid-svg-Ja9oVFEregPdjJYj .node .label text,#mermaid-svg-Ja9oVFEregPdjJYj .image-shape .label,#mermaid-svg-Ja9oVFEregPdjJYj .icon-shape .label{text-anchor:middle;}#mermaid-svg-Ja9oVFEregPdjJYj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Ja9oVFEregPdjJYj .rough-node .label,#mermaid-svg-Ja9oVFEregPdjJYj .node .label,#mermaid-svg-Ja9oVFEregPdjJYj .image-shape .label,#mermaid-svg-Ja9oVFEregPdjJYj .icon-shape .label{text-align:center;}#mermaid-svg-Ja9oVFEregPdjJYj .node.clickable{cursor:pointer;}#mermaid-svg-Ja9oVFEregPdjJYj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Ja9oVFEregPdjJYj .arrowheadPath{fill:#333333;}#mermaid-svg-Ja9oVFEregPdjJYj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Ja9oVFEregPdjJYj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Ja9oVFEregPdjJYj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ja9oVFEregPdjJYj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Ja9oVFEregPdjJYj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ja9oVFEregPdjJYj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Ja9oVFEregPdjJYj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Ja9oVFEregPdjJYj .cluster text{fill:#333;}#mermaid-svg-Ja9oVFEregPdjJYj .cluster span{color:#333;}#mermaid-svg-Ja9oVFEregPdjJYj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Ja9oVFEregPdjJYj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Ja9oVFEregPdjJYj rect.text{fill:none;stroke-width:0;}#mermaid-svg-Ja9oVFEregPdjJYj .icon-shape,#mermaid-svg-Ja9oVFEregPdjJYj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ja9oVFEregPdjJYj .icon-shape p,#mermaid-svg-Ja9oVFEregPdjJYj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Ja9oVFEregPdjJYj .icon-shape .label rect,#mermaid-svg-Ja9oVFEregPdjJYj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ja9oVFEregPdjJYj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Ja9oVFEregPdjJYj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Ja9oVFEregPdjJYj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
外部开发者发起请求
网关:认证与限流
服务端:请求体 JSON Schema 校验
校验通过?
返回 400 与字段级错误
业务处理与持久化
构造响应对象
响应体 JSON Schema 校验
校验通过?
记录告警并返回 500
返回 200/201

顺序很重要:请求校验必须在业务处理之前,否则畸形数据会进入持久化层;响应校验必须在响应写出之前,否则会污染外部开发者看到的契约。

一个常见误解是"网关已经校验过",但网关通常只能校验结构(是不是合法 JSON)和简单字段,无法知道你的业务 Schema 里 amount 的上限和 currency 的枚举。所以服务端的请求体校验不可省略。

另一个边界是:JSON Schema 校验的是"数据形状与取值约束",它不能表达"这个 buyerId 是否真实存在""该订单当前状态是否允许取消"这类跨资源、随着时间变化的业务规则。这些要在业务层做,并映射到 404、409、422 等状态码。不要试图把所有业务规则塞进 Schema,那样会让契约难以维护,也难以被工具正确处理。

8. 幂等键和认证凭据:必须分开的两件事

这是开放平台里最容易混淆的一组概念。

认证凭据回答的是"你是谁"。它证明调用方身份,通常是一个 API Key、一对签名密钥,或一个 OAuth 令牌。它会被长期携带,泄露后需要吊销轮换。

幂等键回答的是"这是不是同一次业务操作"。它由调用方为每一次"想要创建的资源"生成一个唯一字符串,并在重试时复用同一个值。它不证明身份,也不应被当作身份使用。

两者的生命周期完全不同:

维度 认证凭据 幂等键
目的 证明调用方身份 让重试只生效一次
生成方 平台签发 调用方生成
有效期 长期,需轮换 短期,通常数小时到数天
泄露后果 他人可冒充调用 他人在窗口期内可能重放同一操作
存储要求 加盐/加密,可吊销 与请求摘要关联,去重

一个错误的做法是用认证凭据当幂等键,或者反过来把幂等键写进长期配置。更隐蔽的错误是:服务端只记住幂等键,不校验"同一个幂等键对应的请求体是否相同"。如果调用方用同一个幂等键发送了两次不同的请求体,服务端应该返回 409 Conflict,而不是悄悄用第一次的结果覆盖。

一个可用的幂等处理流程(示意,不是完整实现):

  1. 从 Idempotency-Key 头取值,长度和字符集按契约校验。
  2. 计算请求体的规范化摘要(例如对规范化后的 JSON 做 SHA-256)。
  3. 以"调用方身份 + 幂等键"为键,查存储。
  4. 不存在:写入"处理中"状态,执行业务,成功后写入响应快照。
  5. 存在且摘要相同:返回已存响应快照(通常带 200 或与首次相同的 201)。
  6. 存在但摘要不同:返回 409。
  7. 存在但仍在"处理中":返回 409 或 425 Too Early(需确认你的网关和客户端是否支持后者;不确定时用 409 并说明重试间隔更稳妥)。

关于第 7 步:425 Too Early 在 RFC 8470 中定义,语义是防止重放,不是通用的"处理中"。把它用于"处理中"是否合适取决于你的契约设计,本文不把它当作推荐做法,只提示需要核对语义。工程上更常见的是返回 409 并附带重试建议。

响应快照要保存多久?这取决于业务的可重试窗口。太长会占存储,太短会让晚到的重试又新建一单。这个值必须在开发者文档里写明,比如"幂等键在 24 小时内有效",并说明超期后的行为。

9. 签名、验签与回放防护

开放接口的请求签名通常解决两个问题:防篡改和防重放。

一个常见设计(示意)是:调用方对"HTTP 方法 + 路径 + 时间戳 + 随机数 + 请求体摘要"用私钥签名,服务端用公钥验签,并检查时间戳在允许偏差内、随机数未被使用过。

必须强调:具体签名算法、拼接顺序、编码方式必须由你平台自己的文档规定,不能由本文代替。不同平台的签名规则完全不同,本文不给一个"通用正确"的拼接字符串。

需要说清的关键点:

  • 时间戳偏差窗口决定了时钟误差容忍度,也决定了重放窗口的大小。窗口越大越宽松,也越危险。
  • 随机数(nonce)去重需要存储,存储保留时间至少覆盖时间戳窗口。
  • 签名校验失败返回 401,而不是 400,因为这是认证问题。
  • 请求体摘要的计算要基于原始字节,而不是解析再序列化后的 JSON,否则字段顺序或空白差异会导致合法请求被拒。

回放防护和幂等键的区别:回放防护挡住"别人截获后原样重发",幂等键挡住"调用方自己因超时重试"。两者可以同时存在,但不能互相替代。

10. 限流与配额

限流(rate limiting)控制单位时间内的请求数,配额(quota)控制一段较长时间内的总用量。它们是资源保护手段,也是对外承诺的一部分。

如果契约里承诺了配额,就要在响应头里返回可观察的信息,常见做法是:

  • RateLimit-Limit:当前窗口允许量。
  • RateLimit-Remaining:剩余量。
  • RateLimit-Reset:窗口重置的秒数。

这些头字段的标准化程度和命名在不同平台、不同草案中可能不同。本文不声称它们由某个已定稿 RFC 强制规定;如果你的平台要严格对齐某个标准草案,请以该草案为准。工程上更重要的是行为一致 :触发限制时返回 429,并在 body 的 error 里给出 type,在响应头给出重试建议。

一个需要提前想清楚的问题:限流按什么维度计数?按 API Key、按调用方账号、按 IP、还是按端点?不同维度对同一批请求的判定完全不同。契约文档里要写明维度,否则外部开发者无法预估自己的行为。

另一个边界:限流是"尽量保护",不是"精确计费"。分布式环境下多个节点各自计数,会有短暂超额。如果你的业务要精确配额,需要中心化计数,代价是延迟和可用性依赖。要把这个取舍告诉外部开发者,而不是让他们以为配额是硬保证。

11. 回调(Webhook):重试与顺序

外部开发者不仅调用你,还要接收你的通知。这就是回调,也叫 Webhook。

设计回调契约时,至少要说清:

  • 事件类型和事件体结构(用 JSON Schema 描述)。
  • 如何验签(通常是对"时间戳 + 请求体"做签名,放在头部)。
  • 重试策略:间隔、最大次数、最终失败后的行为。
  • 顺序保证:是否保证同一订单的事件按时间顺序送达。
  • 幂等要求:接收方必须按事件 ID 去重,因为重试和乱序都可能发生。

一个必须诚实写进文档的点:大多数回调系统不保证严格全局顺序。如果你承诺"同一订单的事件按序送达",那需要额外的机制,比如在事件里带单调递增的序号,并要求接收方缓冲乱序事件。否则就明确写"不保证顺序,请按事件 ID 去重并按事件时间自行排序"。

重试的设计要点:

  • 用指数退避,避免接收方故障时被持续打爆。
  • 最大重试次数和总时长要有上限,超过后进入"死信"状态,允许接收方主动查询补偿。
  • 每次重试的请求体应尽量保持一致,方便接收方按事件 ID 去重。
  • 接收方返回 2xx 视为成功;非 2xx 或超时视为失败并重试。这个约定要写清。

一个常见失败场景:接收方处理成功但响应超时,平台判定失败并重试,接收方收到重复事件。如果接收方没有按事件 ID 去重,就会重复发货。所以"接收方必须去重"不是建议,而是契约的一部分。

12. 契约变更的影响分析与兼容性检查

现在进入生命周期治理。契约变了,会影响谁?

  • 调用方:他们按旧字段写的代码可能读不到字段、可能因为新增必填字段而报错。
  • 生成的客户端:SDK 生成器按旧契约生成强类型,新契约里的枚举新增值可能导致旧 SDK 反序列化失败。
  • 历史数据:旧订单存的是旧结构,新 Schema 如果加了必填字段,回读历史数据会校验失败。

兼容性规则要写进团队约定,典型分类:

变更 通常是否向后兼容 说明
新增可选请求字段 是 旧调用方不传也能工作
新增必填请求字段 否 需新版本
新增响应字段 视客户端而定 强类型客户端可能因未知字段失败
删除响应字段 否 依赖该字段的调用方会坏
收紧取值范围(如缩小 enum) 否 原本合法的值变非法
放宽取值范围(如扩大 enum) 视客户端而定 强类型枚举可能不认识新值
把可选响应字段改为必填 否 语义加剧
新增枚举值 视客户端而定 需确认客户端容错

表格里"视客户端而定"不是含糊,而是事实:兼容性从来不是单方面的,取决于调用方如何解析。所以契约治理必须包含"要求调用方容忍未知字段"的明文约定,并在生成 SDK 时体现这一点。

兼容性检查的工程化做法:

  1. 把当前已发布契约存进版本库。
  2. 每次提交新契约时,跑一个 diff 检查,识别上表里的破坏性变更。
  3. 破坏性变更必须走新版本路径(如 /api/v2)或新字段名,不能原地改。
  4. CI 里跑契约测试:用样例请求和样例响应分别过请求 Schema 和响应 Schema。
  5. 沙箱环境先用新契约对外,观察错误率再上生产。

这里有一个工具现实:diff 工具能识别"字段被删""类型变了""必填增加了",但对语义变化(比如金额单位从元改成分)无能为力。语义变化只能靠评审和文档变更说明。不要把 diff 工具当成兼容性保证。

13. 契约测试:让文档和代码同步

契约测试的目标是:文档里写的和代码实际做的一致。最小可做的三段:

  1. 请求样例测试:把文档里的示例请求拿出来,跑过服务端,断言状态码与响应结构。
  2. 响应 Schema 测试:把服务端返回的真实响应(测试环境)过响应 Schema。
  3. 错误结构测试 :构造非法请求,断言返回的错误体符合 error.json。

一个示意测试(JUnit 5,片段,非完整可运行):

java 复制代码
@Test
void createOrderRejectsZeroAmount() throws Exception {
    String body = "{\"buyerId\":\"b1\",\"amount\":0,\"currency\":\"CNY\"}";
    var result = mockMvc.perform(post("/api/v1/orders")
            .header("Idempotency-Key", "test-key-00000000000001")
            .contentType("application/json")
            .content(body))
        .andExpect(status().isBadRequest())
        .andReturn();

    JsonNode error = new ObjectMapper().readTree(result.getResponse().getContentAsByteArray());
    assertThat(error.get("status").asInt()).isEqualTo(400);
}

这段测试的边界:它验证"非法金额被拒绝",但不验证错误体的全部字段,也不验证幂等行为。完整契约测试应覆盖文档中每个错误响应至少一个样例。

契约测试要跑在哪里?建议在 CI 的每次提交里跑,并且用与生产一致的主版本(本文是 OpenAPI 3.1.1 + 2020-12 方言),避免"测试用 3.0 方言、生产用 3.1 方言"的不一致。

14. 工具链差异的具体检查清单

前面多次提到"规范和工具分离"。这里给出可直接执行的核对清单,用来避免把工具缺陷当成规范行为:

  1. 方言确认:校验库初始化时是否显式指定 2020-12?默认方言是什么?
  2. 关键字覆盖 :契约里用到的每个关键字(type 数组、const、if/then/else、unevaluatedProperties、prefixItems)是否在该版本中被实际执行?用一个小样例分别造"应通过"和"应失败"的实例来验证。
  3. format 行为 :校验器是否把 format 当作断言?如果不是,业务层是否额外校验?
  4. $ref 解析范围 :是否支持相对文件引用?是否支持 $id 解析?构建时是否把引用打包为自包含资源?
  5. 代码生成器 :生成的客户端是否支持联合类型(type: ["string","null"])?是否支持文档引用的外部 Schema?
  6. 文档渲染 :渲染器是否显示联合类型和 const?如果显示错误,外部开发者会按错误信息编码。
  7. 数值精度 :JSON Schema 的 integer 与 Java 的 int/long/BigInteger 映射是否一致?大整数在 JavaScript 客户端会丢精度,需要在文档里说明金额上限。

第 7 点值得单独强调:JSON 数字在 JavaScript 里是双精度浮点,超过 2^53-1 的整数会丢精度。如果你的金额上限设得很大,外部开发者用 JS 解析就会出错。所以"金额用整数分"这个设计,还要配合一个合理的上限,并在文档里写明。

15. 版本、沙箱与下线公告

开放平台的版本策略要提前定,而不是等第一次破坏性变更时临时决定。

可用的一套流程:

  1. 主版本走路径 :/api/v1、/api/v2。主版本内只做向后兼容变更。
  2. 沙箱先行:新主版本至少在沙箱存在一段观察期,外部开发者可以接入测试。
  3. 文档标注:每个操作标注自哪个版本可用、计划何时下线。
  4. 弃用信号 :响应头加 Deprecation 和 Sunset(这两个头字段的标准化状态需以对应 RFC 为准,使用前确认你的网关是否透传),并在文档里给出迁移指南。
  5. 下线公告:至少提前一个明确的周期公告,给出最后可用日期、替代端点、迁移步骤、联系方式。
  6. 灰度关停:先对新调用方关闭,再对老调用方关闭,观察错误率。

这里要说清一个事实:宣布弃用不等于立即不可用。弃用是信号,下线是动作。两者之间要留出足够时间,让外部开发者排期。具体多久取决于你的客户规模,本文不给"统一 6 个月"这种拍脑袋数字。

16. 失败与反例:几个真实会踩的坑

反例一:只校验请求,不校验响应。 某次重构把响应里的 amount 从整数改成字符串,请求侧一切正常,SDK 生成器生成的新客户端却和旧客户端行为不一致。响应校验能在测试环境就发现这个问题。

反例二:把幂等键当认证。 有团队让调用方用 API Key 当幂等键,结果同一个 Key 下两笔不同订单被去重成一笔。身份和幂等是正交的,必须分开。

反例三:用 additionalProperties: false 却不加说明。 外部开发者多传一个调试字段就 400,他们不知道原因。文档必须写清"不接受契约外字段"。

反例四:依赖 format 做强制校验。 校验器把 format 当注解,非法时间字符串通过了校验,进入业务层才炸。业务层要显式解析时间。

反例五:认为枚举新增值一定兼容。 如果客户端是强类型枚举,新增值会导致反序列化失败。所以要么要求客户端容忍未知值,要么把枚举改为字符串并在文档里说明可能扩展。

反例六:回调不做去重。 重试导致重复事件,接收方重复处理。回调契约必须写明接收方按事件 ID 去重。

这些反例都不是理论推演,而是同类系统里反复出现的模式。它们共同说明:开放接口的可靠性来自契约和实现的一致,而不是某一层的强校验。

17. 适用条件与结论

这套"契约优先 + 双向校验链 + 生命周期治理"的做法,适用条件要说清楚:

  • 适合:需要长期对外承诺、调用方不在同一团队、接口数量会增长的场景。
  • 适合:已经有一定 Java 服务端基础,愿意在 CI 里增加契约检查成本的团队。
  • 不太适合:一次性内部接口、生命周期以周计的原型。为它们维护完整契约和兼容性流程可能过重。
  • 需要额外投入:跨文档 Schema 打包、方言一致性、响应采样校验的性能评估。

结论可以归纳成四句:

  1. 先建模资源和语义,再写 OpenAPI;HTTP 方法和状态码按 RFC 9110 的含义使用,错误也走 HTTP 语义。
  2. 把 Schema 抽成独立资源,OpenAPI 3.1.1 负责目录和结构,JSON Schema 2020-12 负责数据约束,服务端用同一个 Schema 同时校验请求和响应。
  3. 规范和工具分开验证 :方言、关键字、$ref、format、代码生成器都要用最小样例实际验证,验证不了的写法不要用。
  4. 把兼容性当工程流程:diff 检查、契约测试、沙箱、弃用信号、下线公告缺一不可;幂等键和认证凭据从设计第一天就分开。

最后再强调一次证据边界:本文没有附带实际运行日志,示例代码和端口是示意,未声称已在任何环境中测试通过。所有版本相关结论都以官方规范和你实际锁定的工具版本为准;涉及兼容性支持和行为差异的部分,需要你在自己的工具链上按第 14 节的清单复核后再对外承诺。

相关推荐
EatFan1 个月前
Spring Boot 4 迁移避坑清单:Jackson 3、starter 拆分与最低 JDK 口径核对(含若依/芋道/CRMEB 升级对照)
java·数据库·spring boot·spring boot 4·java 21·jakarta ee 11·jackson 3
大傻^7 个月前
【Spring AI -01】Spring AI 2.0 架构重构深度解析:从单体核心到模块化领域的演进
人工智能·spring·架构·spring ai·模块化设计·java 21·架构迁移
华为云开发者联盟2 年前
关于Java Chassis 3的契约优先(API First)开发
华为云开发者联盟·java chassis 3·契约优先·web services·api first