qinghuan
← 工程实践 / 全部笔记

单元 52 / 工程实践

P0 · 必会主线L1 · 基础

OpenAPI:把接口输入、输出和错误说明白

把参数、成功结果和各类失败写清楚,再对照运行时校验与授权检查,避免文档完整但接口行为不一致。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能写清订单查询接口的参数、响应和错误约定(接口契约),并用例子验证文档与实际行为一致。

学习理由、准备要求与知识点

为什么学它

接口约定连接前后端与测试,明确参数、错误和版本比只生成一个文档页面更重要。

先读这些内容

知识点清单

  • OpenAPI 3.1
  • 路径参数
  • Schema
  • required 与 nullable
  • 响应状态
  • 错误结构
  • 安全方案声明
  • 契约与运行时校验

运行前先确认OpenAPI 3.1.1、JSON Schema 2020-12 与 Java 17。算法只覆盖文中固定字段,输入已按约定解析为 Map,不是完整 JSON Schema 引擎,未执行真实 HTTP 或授权集成。BigDecimal 源码核验 Microsoft 17.0.20.1,上游 jdk-17.0.20-ga。

01先弄懂它是什么

接口文档要让调用方知道:能传什么、会收到什么、失败怎么区分。OpenAPI 把路径、方法、参数、正文、响应和安全方案写成工具可读取的格式,但不会替所有服务端自动检查。比如声明 quantity 最小为 1,仍需运行时代码正确读取、校验并拒绝不合法数量。

本篇使用 OpenAPI 3.1.1,其中用 Schema,也就是一份可由工具读取的数据规则,说明字段的类型和限制;它与 JSON Schema 2020-12 体系相关。先分清四种情况:没传字段、传了 null、传了空字符串、传了数字 0。

required 只规定字段必须出现;能否为 null,还要看类型规则,不能照搬旧版的 nullable 写法。路径参数有额外要求:in: path 的参数必须声明 required: true。

契约不只包含成功 DTO。调用方还需要知道参数错误、未认证、权限不足、对象不可见、业务冲突和依赖失败如何表示。响应应包含媒体类型、字段类型和稳定错误结构,避免只写一个 description 就认为接口已定义完整。内部数据库列与外部 DTO 各有演进周期,不应自动把所有数据库字段暴露成响应。

文中这几个词,先认一认

契约
调用方与服务端关于输入、输出和失败处理的明确约定,可以据此核对实际行为。
Schema 校验
检查数据的字段、类型和限制是否符合声明;不能替代业务状态或权限检查。
兼容性
新旧客户端和服务端组合使用时仍能按约定工作,要说明具体支持哪些组合。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

先选定一个具体操作,列出路径、HTTP 方法与输入来源:path、query、header 或请求体。再定义必填字段和约束。创建订单示例要求 skuId 为正的 int64 范围整数、quantity 为一到一百的整数,note 可缺失或为 null,若是字符串则最多二十个码点,额外属性拒绝。这样的精确边界才能指导实现和测试。

相应 Schema 的关键声明是 type: object、required: [skuId, quantity] 和 additionalProperties: false;属性 skuId、quantity 使用 type: integer 及 minimum、maximum,note 使用 type: [string, 'null'] 与 maxLength: 20。

完整 OpenAPI 文档还需 openapi、info、paths、requestBody 和 responses 等上下文,本段只是用于解释约束的局部说明。

JSON Schema 的整数关注数学值,而不只看文本是否出现小数点,因此 1.0 可以是整数,1.5 不可以。Java 绑定层若直接用 longValue 截断,会把后一种错误输入变成看似合法的一。应明确解析器数字策略,使用精确转换或校验;format: int64 也不应被假定在每个工具中都自动执行同样的范围检查。

请求通过结构校验后,还需要业务规则和对象授权。例如订单编号是合法正数,并不代表当前用户可以读取该订单。OpenAPI security scheme 描述认证方式,实际凭据验证由服务执行,对象所有权还需结合业务数据检查。返回 404 隐藏不可见对象或返回 403 的选择应按产品安全策略一致实现。

契约验证应双向进行:非法请求被正确拒绝,合法响应也符合声明。针对缺失、null、边界和他人对象等用例检查真实状态码与结构,避免文档和实现各自通过却互不一致。新增响应字段常对宽容客户端兼容,但严格拒绝额外字段的客户端可能失败,因此不能脱离消费者行为宣称新增字段一定兼容。

正在加载流程图…

结构、授权与业务规则分层执行;任何文档声明都需要对应实现及验证。
查看流程图源码(Mermaid)
flowchart TD
 A["请求字节与媒体类型"] --> B["受限解析"]
 B --> C["结构和数值校验"]
 C --> D["认证与对象权限"]
 D --> E["业务规则与操作"]
 E --> F["按契约构造响应"]
 C --> G["结构化错误"]
 D --> G

按顺序看,每一步做了什么

  1. 定义操作与每个参数来源。
  2. 区分存在性、null、类型和范围。
  3. 写成功与失败响应结构。
  4. 实现绑定、校验和授权。
  5. 用真实请求核对双向契约并检查兼容性。

03对照实现代码

BigDecimal.toBigIntegerExact:避免数字绑定时静默截断 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/math/BigDecimal.java · java.math.BigDecimal#toBigIntegerExact()

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
import java.math.*;
public final class SourceModel {
  static long positiveId(BigDecimal value) {
    if(value==null) throw new IllegalArgumentException("required");
    if(value.compareTo(BigDecimal.ONE)<0 ||
       value.compareTo(BigDecimal.valueOf(Long.MAX_VALUE))>0)
      throw new IllegalArgumentException("range"); // 先限制表示范围。
    BigInteger integer=value.toBigIntegerExact(); // 有非零小数部分时失败,不静默截断。
    return integer.longValueExact(); // 整数也必须落在目标 Java 类型范围。
  }
}

本机 BigDecimal.toBigIntegerExact 使用不允许舍入的方式将 scale 调整为零,再返回整数表示。非零小数部分会导致 ArithmeticException,而诸如 1.0 这样的值可以精确转换。这与普通 longValue 可能丢弃小数部分的行为不同,正好对应接口整数绑定需要明确拒绝非整数的边界。

教学模型在精确转换之前检查正数和 long 上界,避免把超出业务范围的值继续当作候选编号。它是原创的 Java 数值校验片段,没有实现 JSON 解析器,也不是 OpenAPI 或 JSON Schema 引擎。输入已经被解析为 BigDecimal 的前提必须由接入层兑现,不能对任意 Number 随意强转。

限制最终数字不能超过 long,并不限制解析它要花多少资源。数字文本长度、指数范围和整个请求大小,应在解析阶段就限制,否则可能还没到业务校验就分配大量内存。精确转换保证数字没被悄悄截断,但不能独自处理所有超大输入问题。

完整算法用有限的固定字段展示存在性、类型、范围和额外字段检查,不声称覆盖 allOf、引用、条件 Schema、递归对象或所有格式。真实项目采用成熟校验器时,也应核对启用的方言与选项,并以相同边界案例验证绑定后的实际行为。

代码先调用谁,再调用谁

  1. 受限 JSON 解析为约定的 Java 值
  2. 区分键缺失和显式 null
  3. 检查数字数学整性
  4. 精确转换与范围检查
  5. 形成字段错误或领域输入

04动手写一个例子

固定订单请求的确定性校验器

输入已解析的 Map:数字统一为 BigDecimal,字符串为 String,null 保留。按固定字段顺序返回错误路径与代码,未知字段按名字排序;不解析 JSON,不执行授权或下单。

输入 1
skuId=1.0,quantity=2,note=null
输出 1
无错误
输入 2
quantity=1.5,缺 skuId
输出 2
skuId:required、quantity:integer

首先使用 containsKey 判断必填属性,而不是只调用 get,因为 get 返回 null 可能代表缺失,也可能是明确传入 null。数字类型只接受本题约定的 BigDecimal,字符串形式的 '2' 不被自动转换,避免文档说数字而运行时却接受多个含糊表示。

对数字使用 stripTrailingZeros 后的 scale 判断是否为数学整数,再检查上下界。这样 1.0 与 1 都合法,1.5 被拒绝。校验不通过就记录该字段第一个明确错误,返回顺序固定,便于客户端测试和排障;是否聚合全部错误是接口契约的一部分。

note 独立处理可缺失与可空语义,非空时要求字符串并按码点数限制长度。未知属性统一收集并排序,防止客户端误拼字段名却被服务静默忽略。算法不生成业务对象,也不证明 skuId 存在或用户有权限,这些步骤在结构通过之后另行执行。

为什么这样写是对的每个已检查字段都按声明区分缺失、空值、类型和范围,错误顺序不依赖 Map 遍历顺序。

时间 O(f + u log u + L),f 为字段数,u 为未知字段数,L 为字符串及数字表示处理长度。额外空间 O(f+u) 错误和未知字段列表空间。

固定已知字段只检查有限次,未知字段排序提供稳定输出;码点计数和 BigDecimal 操作不能忽略输入长度。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 48 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.math.*;
import java.util.*;
public class Main {
  record Issue(String path,String code) {}
  static final Set<String> KEYS=Set.of("skuId","quantity","note");
  static void integer(Map<String,Object> body,String key,BigDecimal min,BigDecimal max,List<Issue> out) {
    if(!body.containsKey(key)) {out.add(new Issue(key,"required")); return;} // 缺失不同于显式空值。
    Object value=body.get(key);
    if(!(value instanceof BigDecimal n) || n.stripTrailingZeros().scale()>0) {
      out.add(new Issue(key,"integer")); return; // 接受数学整数 1.0,拒绝字符串和非零小数。
    }
    if(n.compareTo(min)<0 || n.compareTo(max)>0) out.add(new Issue(key,"range"));
  }
  static List<Issue> validate(Map<String,Object> body) {
    Objects.requireNonNull(body);
    List<Issue> out=new ArrayList<>();
    integer(body,"skuId",BigDecimal.ONE,BigDecimal.valueOf(Long.MAX_VALUE),out);
    integer(body,"quantity",BigDecimal.ONE,BigDecimal.valueOf(100),out);
    Object note=body.get("note");
    if(note!=null) {
      if(!(note instanceof String s)) out.add(new Issue("note","string"));
      else if(s.codePointCount(0,s.length())>20) out.add(new Issue("note","length")); // 按码点而非 char 数计数。
    }
    Set<String> unknown=new TreeSet<>();
    for(String key:body.keySet()) if(!KEYS.contains(Objects.requireNonNull(key))) unknown.add(key);
    for(String key:unknown) out.add(new Issue(key,"unknown")); // 未知属性稳定排序。
    return List.copyOf(out);
  }
  static Map<String,Object> body(Object id,Object quantity) {
    Map<String,Object> m=new HashMap<>(); m.put("skuId",id); m.put("quantity",quantity); return m;
  }
  static BigDecimal n(String s) {return new BigDecimal(s);}
  static void eq(Object a,Object b) {if(!Objects.equals(a,b)) throw new AssertionError(a+" != "+b);}
  public static void main(String[] args) {
    eq(validate(body(n("1.0"),n("2"))),List.of());
    eq(validate(Map.of()),List.of(new Issue("skuId","required"),new Issue("quantity","required")));
    eq(validate(body(null,n("1"))),List.of(new Issue("skuId","integer")));
    eq(validate(body(n("1"),n("1.5"))),List.of(new Issue("quantity","integer")));
    eq(validate(body(n("1"),n("101"))),List.of(new Issue("quantity","range")));
    Map<String,Object> nullable=body(n("1"),n("100")); nullable.put("note",null); eq(validate(nullable),List.of());
    Map<String,Object> emoji=body(n("1"),n("1")); emoji.put("note","😀".repeat(20)); eq(validate(emoji),List.of());
    emoji.put("note","😀".repeat(21)); eq(validate(emoji),List.of(new Issue("note","length")));
    Map<String,Object> unknown=body(n("1"),n("1")); unknown.put("z",true); unknown.put("a",true);
    eq(validate(unknown),List.of(new Issue("a","unknown"),new Issue("z","unknown")));
    eq(validate(body("1",n("1"))),List.of(new Issue("skuId","integer")));
    System.out.println("PASS 10");
  }
}

这些特殊情况也要试一试

  • 缺失与显式 null 产生不同的判断路径。
  • 1.0 是整数,1.5 不是。
  • note 允许空值,补充平面字符按码点计数。
  • 未知字段拒绝并稳定排序。
进阶选读 · 跨版本契约回归

场景服务与多个客户端版本独立发布。

做法维护消费者使用的请求与响应样本,比较 Schema 变化并运行集成契约测试,关注字段收紧、枚举扩展和错误语义变化。

复杂度成本随版本组合和用例数增长;静态差异检查无法穷尽客户端实现行为。

好处与代价可以更早发现破坏性变化,但需要治理样本真实性和版本生命周期。

05项目里怎么用

接口文档应和实现一起评审,特别是错误码、必填字段、分页,以及时间金额写法。自动生成文档能减少手写不同步,但只看 DTO 仍可能漏掉对象权限、状态冲突或多个字段之间的规则。这些行为要用说明与请求测试表达,不能按注解多少判断接口是否完成。

拒绝未知字段能发现拼写错误,却要求客户端和服务端更紧密地协调更新;允许未知字段可能方便演进,也可能隐藏传错参数。请求与响应可以用不同策略。判断是否兼容,要看真实客户端怎样解析枚举、数字和 null,不能只比较是否多了一个字段。

响应错误格式应提供稳定机器码、合适的字段路径和可公开说明,内部堆栈留在服务端关联日志。认证与对象授权应在实际执行路径落实,并用不同身份访问同一资源验证。结构校验失败、权限失败和依赖故障应分别观察,避免全部转换为一个成功状态加错误文本,让调用方无法可靠处理。

解析、验证和执行业务各自需要资源上限。限制请求体、字段长度和嵌套深度,可以让不合法输入尽早停止;错误聚合也应有数量上限。这里的小型校验器用于推导规则,生产系统应选择支持所需方言的成熟组件,并保留针对实际绑定层的回归测试。

容易踩的坑

  • required 与允许 null 混为一谈。
  • 整数绑定直接截断小数。
  • security scheme 被误当成已执行授权。
  • 只校验请求而让实际响应偏离 Schema。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
契约先行调用方可提前协作需要持续同步实现多团队接口
代码生成文档与类型结构关联紧密容易漏掉业务和权限语义已有服务补充文档
严格字段校验及早发现拼写与类型错误演进时协调要求更高明确版本和稳定消费者

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01required 是否意味着不能为 null?

展开参考答案与推导

不是。required 约束属性存在,null 是否允许由属性 Schema 的类型等规则决定。缺失、空值和空字符串需要分别约定与测试。

021.0 是否一定不符合 integer?

展开参考答案与推导

不是。JSON Schema 的整数按数学值判断,零小数部分的 1.0 可以符合整数约束。绑定层应避免把 1.5 静默截断,也要检查目标类型范围。

03OpenAPI 安全方案会自动保护接口吗?

展开参考答案与推导

不会普遍自动执行。文档描述认证方式,服务仍需要真实凭据验证、请求规则和对象权限检查,并用不同身份的请求证明行为。

换个情况,还会处理吗

01客户端发送 qty 却得到成功,如何处理?

展开参考答案与推导

先决定未知字段策略。若契约要求 quantity 且拒绝额外字段,应同时报告缺失 quantity 与未知 qty,避免静默使用默认值;实现和文档需要一致。

02新增响应枚举值一定兼容吗?

展开参考答案与推导

不一定。客户端可能使用穷尽分支或严格解析而失败。需要检查消费者处理未知枚举的约定和实际用例,再决定版本和发布策略。

03合法订单编号能查到他人订单怎么办?

展开参考答案与推导

这是对象授权缺失,结构校验不能解决。查询必须结合当前身份与访问策略,并用同一编号、不同用户的真实请求验证可见性。

写代码试一试

01区分缺失与空值。

输入 / 条件
{} 与 {quantity:null}
输出 / 预期
required 与 integer

提示 containsKey 先判断。

展开参考答案与推导

缺失属性报告 required;属性存在但 null 不符合本题整数约束,报告 integer。仅用 get==null 会丢掉两类输入的区别。

02二十个表情是否超出二十长度?

输入 / 条件
😀 重复 20 次
输出 / 预期
本题允许

提示 按码点计数。

展开参考答案与推导

每个表情占两个 Java char,但一个码点。使用 codePointCount 计算为二十;不能直接用 String.length 将合法输入误判为四十。

03如何防止 1.5 被绑定成 1?

输入 / 条件
BigDecimal 1.5
输出 / 预期
拒绝

提示 精确转换而非截断。

展开参考答案与推导

使用 toBigIntegerExact 或等价数学整数检查,之后验证上下界;不要通过 longValue 截去小数后再声称原输入符合整数契约。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能写清订单查询接口的参数、响应和错误约定(接口契约),并用例子验证文档与实际行为一致。

本篇重点加练

Schema 的声明与运行时绑定可能不一致,必须检验数据在边界上的真实表示。

  • Schema
  • 契约与运行时校验

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    为“读取指定商户的一张订单”设计 OpenAPI 3.1 路径,说明路径参数身份、编码和范围;区分示例值与 Schema 限制。

    提交内容
    接口定义草案
    怎样算完成
    • 参数名称与路径一致
    • 说明 ID 类型及合法范围

    练到的知识点OpenAPI 3.1 · 路径参数

  2. 作业 02代码推演

    更新昵称接口区分字段缺失、显式 null 和空字符串。为请求 Schema 定义三种情况,并给出 Unicode、多余字段和长度边界样本。

    提交内容
    Schema 与请求矩阵
    怎样算完成
    • required 和类型约束分别表达
    • 样本不能代替规则

    练到的知识点Schema · required 与 nullable

  3. 作业 03设计评审

    创建订单可能成功、重复提交、参数错误、权限不足或依赖故障。设计状态码和统一错误结构,评审客户端如何区分可重试情况。

    提交内容
    响应契约与客户端决策表
    怎样算完成
    • 错误结构能关联请求
    • 不把业务结果未知伪装成确定失败

    练到的知识点响应状态 · 错误结构

  4. 作业 04代码推演

    同一金额字段可能收到科学计数法、大整数和小数文本,后端最终绑定为 Java 整数。定义契约中表示、范围和精度的独立约束。

    提交内容
    数值样本与契约表
    怎样算完成
    • 不把可解析等同于符合业务
    • 注明序列化与绑定阶段

    练到的知识点Schema

综合应用

  1. 作业 05面试场景

    文档声明 Bearer 认证,但控制器未安装任何权限逻辑。列出从规范文件到运行时验证之间需要实际验收的行为。

    提交内容
    契约执行检查表
    怎样算完成
    • 区分声明与执行
    • 包含未登录和对象归属不同的请求

    练到的知识点安全方案声明 · 契约与运行时校验

  2. 作业 06编程实践

    编写本地最小订单 API 及契约测试,自动发送自拟边界请求并核对状态、字段类型和错误结构;仅用测试身份。

    提交内容
    OpenAPI 文件、实现和测试记录
    怎样算完成
    • 请求与响应都核验
    • 合同通过不被宣称为完整安全证明

    练到的知识点OpenAPI 3.1 · 路径参数 · Schema · required 与 nullable · 响应状态 · 错误结构 · 安全方案声明 · 契约与运行时校验

  3. 作业 07设计评审

    生成的 SDK 会删除未知字段,但手写 HTTP 客户端可以保留;服务端又对未知字段有不同配置。评审契约测试应覆盖的客户端路径。

    提交内容
    路径及校验责任矩阵
    怎样算完成
    • 真实服务器行为单独核实
    • 不能仅测 SDK 发送成功

    练到的知识点契约与运行时校验

  4. 作业 08编程实践

    在本地最小 API 使用同一 Schema 生成边界请求,比较纯 JSON 校验与 Java 绑定后的校验,收集两者不一致的样本。

    提交内容
    代码、Schema 与差异报告
    怎样算完成
    • 包括 Unicode、数值范围及未知字段
    • 差异有明确版本和配置

    练到的知识点Schema · 契约与运行时校验

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    客户端已经生成 SDK,服务拟把可选字段改必填并新增状态枚举。评审兼容性和灰度发布顺序,设计旧客户端验收。

    提交内容
    变更差异与兼容矩阵
    怎样算完成
    • 分别检查请求和响应方向
    • 包含未知枚举处理

    练到的知识点Schema · required 与 nullable · 响应状态 · 错误结构 · 契约与运行时校验

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 跨域迁移

    把 API 参数来源改成模型生成的工具调用,输入可多次自我修正。应怎样区分语法合法、契约合法和允许执行业务动作?

    提交内容
    工具边界契约
    怎样算完成
    • 包含重试和修改参数
    • 模型声称已校验不能替代服务端证据

    练到的知识点Schema · 契约与运行时校验

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗