4. Schema 定义与类型系统:JSON Schema 基础

好,咱们进入数据契约最核心的部分——Schema 定义。说白了,Schema 就是数据的「宪法」。你接口返回什么字段、字段是什么类型、能不能为空、取值范围是多少,全得靠它来约束。

我个人习惯把 Schema 看作「接口的接口」。为什么这么说?因为它是契约双方(服务端和客户端)共同遵守的规则。没有 Schema,你的接口文档就是一本「仅供参考」的小说,而不是可执行的规范。

4.1 JSON Schema 是什么?

JSON Schema 是一种基于 JSON 的声明式语言。它用来描述 JSON 数据的结构、类型和约束。你想想看,我们每天都在写接口、调接口,但怎么保证我给你的数据就是你想要的?JSON Schema 就是干这个的。

举个例子,一个最简单的 Schema:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "name": { "type": "string" },
    "age": { "type": "integer", "minimum": 0 }
  },
  "required": ["name"]
}

这段 Schema 说了三件事:第一,数据必须是个对象;第二,name 字段是字符串,age 是整数且不能小于 0;第三,name 是必填的。就这么简单直接。

我的习惯:每个接口的 Schema 我都会加上 $schema 字段,指定 Draft 版本。虽然不写也能用,但写了之后 IDE 和校验工具能给你更好的提示。我一般用 Draft-07,兼容性最好。

4.2 基本类型:string, number, object, array

JSON Schema 支持的类型其实不多,但够用。我按使用频率排个序:

类型 说明 常用约束 我在项目中踩过的坑
string 字符串 minLength, maxLength, pattern, format 忘记加 format: "email",结果客户端传了个 "abc" 过来
number 数值(含小数) minimum, maximum, multipleOf 用 integer 定义金额,结果丢了分
integer 整数 minimum, maximum 分页参数没设 minimum: 1,有人传了 -1
object 对象 properties, required, additionalProperties 没关 additionalProperties,客户端多传了个字段
array 数组 items, minItems, maxItems, uniqueItems 列表接口没设 maxItems,一次返回了 10 万条
boolean 布尔值 无特殊约束 有人传了字符串 "true",校验没通过
null 空值 通常配合 oneOf 使用 字段允许 null 但没写进 Schema,客户端直接崩了

嗯,这里要注意:number 和 integer 是分开的。我见过不少团队把年龄定义成 number,结果传了个 25.5 过来。虽然业务上不合理,但 Schema 校验是能过的。所以该用 integer 就用 integer。

4.2.1 string 的 format 约束

string 类型有个特别好用的东西叫 format。它相当于内置的「正则表达式快捷方式」:

{
  "type": "string",
  "format": "email"      // 校验邮箱格式
  // 还有: date, date-time, uri, ipv4, uuid 等
}

我曾经在项目中遇到过一个坑:用户注册接口的邮箱字段没加 format,结果有人传了个 "abc" 进来。系统没报错,但后续的邮件发送模块直接炸了。从那以后,我所有字符串字段都会问自己一句:「这个字段有没有标准格式?」

4.2.2 array 的 items 约束

数组的 items 有两种写法:

// 方式一:所有元素类型相同
"items": { "type": "string" }

// 方式二:元组模式,每个位置类型不同
"items": [
  { "type": "string" },
  { "type": "integer" },
  { "type": "boolean" }
]

方式一最常见,比如返回用户 ID 列表。方式二用得少,但处理固定长度的坐标对(如 [经度, 纬度])时特别好用。

避坑指南:数组的 additionalItems 默认是 true。如果你用了元组模式,记得把 additionalItems: false 加上,否则客户端可以在元组后面继续塞元素,Schema 不会报错。

4.3 枚举与常量

枚举(enum)和常量(const)是 Schema 里最「硬」的约束。它们把值限定在几个固定选项里,没有任何商量余地。

4.3.1 enum:只能选我列出来的

{
  "type": "string",
  "enum": ["pending", "approved", "rejected"]
}

这个 Schema 说:status 字段只能取这三个值,多一个不行,少一个也不行。我在项目中用 enum 定义状态机流转,效果特别好。客户端传 "pending" 就是 "pending",传 "PENDING" 就是非法——enum 是大小写敏感的。

4.3.2 const:就这一个值

{
  "type": "string",
  "const": "v1"
}

const 比 enum 更极端:它只允许一个值。我一般在版本号、固定标识符上用 const。比如 API 版本字段,写死了就是 "v1",谁也别想改。

我的经验:enum 和 const 是「契约精神」的最佳体现。它们把不确定性降到最低。你想想看,如果 status 字段没有 enum,客户端可能传 "success"、"ok"、"1" 等各种奇奇怪怪的值。有了 enum,大家都按规矩来。

4.4 组合模式:allOf, oneOf, anyOf

组合模式是 JSON Schema 最强大的部分。它让你能把多个 Schema「拼」在一起,形成更复杂的约束。我刚开始学的时候觉得有点绕,但用熟了之后发现,它几乎能描述任何数据结构。

4.4.1 allOf:必须同时满足

allOf 的意思是「所有子 Schema 都得通过」。我常用它来做「基础 Schema + 扩展 Schema」的模式:

{
  "allOf": [
    { "$ref": "#/definitions/baseUser" },
    {
      "required": ["role"],
      "properties": {
        "role": { "enum": ["admin", "editor"] }
      }
    }
  ]
}

这段代码说:这个用户必须满足 baseUser 的所有约束,同时还得有 role 字段,且 role 只能是 admin 或 editor。说白了就是「继承 + 扩展」。

4.4.2 oneOf:只能满足一个

oneOf 是「排他性选择」——多个子 Schema 中,数据只能满足其中一个。我处理「多态」场景时必用它:

{
  "oneOf": [
    {
      "type": "object",
      "properties": {
        "type": { "const": "text" },
        "content": { "type": "string" }
      },
      "required": ["type", "content"]
    },
    {
      "type": "object",
      "properties": {
        "type": { "const": "image" },
        "url": { "type": "string", "format": "uri" },
        "width": { "type": "integer" },
        "height": { "type": "integer" }
      },
      "required": ["type", "url"]
    }
  ]
}

这个 Schema 说:消息要么是文本类型(有 content),要么是图片类型(有 url、width、height),不能同时是两种。我曾经在即时通讯项目里用 oneOf 定义消息体,客户端和服务端对得严丝合缝。

注意:oneOf 要求「恰好一个」匹配。如果数据同时匹配了多个子 Schema,或者一个都没匹配上,都会校验失败。所以写 oneOf 时,一定要确保子 Schema 之间有明确的「区分标志」,比如上面例子里的 type 字段。

4.4.3 anyOf:满足一个或多个

anyOf 比 oneOf 宽松——只要满足任意一个子 Schema 就行,可以同时满足多个:

{
  "anyOf": [
    { "type": "string", "minLength": 1 },
    { "type": "number", "minimum": 0 }
  ]
}

这个 Schema 说:字段可以是非空字符串,也可以是非负数。两个都满足?也行。anyOf 适合「多种合法类型」的场景,比如搜索关键词可以是文字也可以是数字 ID。

4.5 组合模式对比

组合关键字 匹配规则 典型场景 我常用的技巧
allOf 全部匹配 Schema 继承、字段扩展 配合 $ref 做基础 Schema 复用
oneOf 恰好匹配一个 多态、联合类型、状态机 加 discriminator 字段做区分
anyOf 至少匹配一个 宽松类型、可选格式 注意校验顺序,优先匹配严格规则

4.6 本章知识体系

下面这张图把本章的核心知识点串起来了。你可以看到,从基本类型到组合模式,是一层一层往上构建的:

JSON Schema 类型系统知识体系 JSON Schema 基本类型 枚举与常量 组合模式 引用与复用 string number object array boolean null enum: 固定选项列表 const: 唯一固定值 allOf: 全部满足 oneOf: 恰好一个 anyOf: 至少一个 $ref: 引用定义 definitions: 复用块 应用场景:接口契约定义 · 数据校验 · 文档生成 · 代码生成 核心原则:从简单到复杂,从具体到抽象 基本类型 → 枚举常量 → 组合模式 → 引用复用 每一层都在上一层的基础上增加表达力和复用性

4.7 实战建议

说了这么多,最后给几个我在项目中沉淀下来的建议:

  • 从基本类型开始:别一上来就搞 allOf、oneOf。先把 string、number、object 这些基础类型用熟,再逐步引入组合模式。
  • 能用 enum 就别用正则:enum 的语义更清晰,校验更快。只有 enum 覆盖不了的情况(比如邮箱格式)才用 pattern。
  • 组合模式一定要加区分字段:用 oneOf 时,每个子 Schema 里加一个 const 字段做「标签」。这样校验器能快速定位到正确的子 Schema,性能更好,错误信息也更友好。
  • 别忘了 additionalProperties:object 类型默认允许额外字段。如果你希望严格校验,记得加上 additionalProperties: false。我见过太多因为多传了一个字段导致数据不一致的案例了。
一个小技巧:写 Schema 时,先用在线校验器(比如 jsonschemavalidator.net)跑一遍你的测试数据。我每次写完 Schema 都会拿几个典型数据去测,包括正常值、边界值、非法值。这一步能帮你发现 80% 的问题。