JSON Schema 与 JSON 数据验证

我们总在最不懂爱的年纪,遇到最想守护的人,然后在懂爱的年纪,失去那个最想守护的人

Posted by yishuifengxiao on 2025-09-03

什么是 JSON Schema

JSON Schema 本身就是一个 JSON 文档,它用一种基于 JSON 的格式来定义其他 JSON 数据的结构和内容。它可以用来:

  1. 验证:确保 JSON 数据符合预期的格式(必填字段、数据类型、范围等)。
  2. 文档化:作为数据模型的清晰、机器可读的文档。
  3. 交互式验证:为前端表单提供动态校验规则。
  4. 自动化测试:验证 API 请求和响应的结构。

如何开始使用?

  1. 在线验证工具:访问 https://jsonschema.devhttps://www.jsonschemavalidator.net,可以直观地编写 Schema 和测试数据。
  2. 选择版本:在 Schema 根节点使用 "$schema" 关键字指定版本,如 "https://json-schema.org/draft/2020-12/schema",这有助于编辑器和验证器提供正确的功能。
  3. 编程使用:几乎所有主流语言都有 JSON Schema 验证库(如 Python 的 jsonschema, Java 的 everit-org/json-schema, JavaScript 的 ajv)。

核心概念与关键字

类别 关键字 用途
核心 $schema, $id, title, description 标识 Schema 版本、提供元信息和文档
类型 type 定义基本数据类型
对象 properties, required, additionalProperties 定义对象结构、必填字段、允许额外字段
数组 items, minItems, maxItems, uniqueItems 定义数组元素、长度、唯一性
字符串 minLength, maxLength, pattern, format 定义字符串长度、格式、预定义格式
数值 minimum, maximum, exclusiveMinimum, multipleOf 定义数值范围、倍数
逻辑 enum, const 限制值为固定选项
组合 allOf, anyOf, oneOf, not 组合多个验证条件
条件 if, then, else 根据条件应用不同验证规则
复用 $defs, $ref 定义和引用可重用的子模式

类型约束:type

这是最核心的关键字,用于定义 JSON 数据的类型。基本类型有:string, number, integer, boolean, null, object, array

验证一个字符串

// Schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string"
}

// 有效数据
"Hello, World!"
"123" // 注意,用引号括起来就是字符串

// 无效数据
42
null
{"name": "Alice"}

验证一个整数

// Schema
{
"type": "integer"
}

// 有效数据
42
-10
0

// 无效数据
3.14 // integer 要求无小数部分
"50" // 这是一个字符串,不是数字

对象属性约束:properties, required, additionalProperties

用于定义 JSON 对象的结构。

  • properties: 定义对象中各个属性的 schema。
  • required: 定义一个字符串数组,列出必须存在的属性。
  • additionalProperties: 布尔值,定义是否允许出现 properties 中未定义的额外属性。false 表示不允许。

定义一个用户对象

// Schema
{
"type": "object",
"properties": {
"name": {
"type": "string"
},
"age": {
"type": "integer",
"minimum": 0 // 年龄不能为负数
},
"email": {
"type": "string",
"format": "email" // 使用内置格式校验邮箱
}
},
"required": ["name", "age"], // email 是可选的
"additionalProperties": false // 不允许其他属性,如 "address"
}

// 有效数据
{
"name": "Alice",
"age": 30
}
{
"name": "Bob",
"age": 25,
"email": "bob@example.com"
}

// 无效数据
{
"name": "Charlie",
"age": -5 // age 小于 minimum: 0
}
{
"age": 30 // 缺少 required 字段 "name"
}
{
"name": "David",
"age": 40,
"address": "123 Main St" // additionalProperties 为 false,不允许额外属性
}

数组约束:items, minItems, maxItems, uniqueItems

用于定义 JSON 数组的结构。

  • items: 定义数组内每个元素的 schema。
  • minItems / maxItems: 定义数组的最小/最大长度。
  • uniqueItems: 布尔值,定义数组元素是否必须全部唯一。

定义一个数字列表

// Schema
{
"type": "array",
"items": {
"type": "number"
},
"minItems": 1,
"maxItems": 5,
"uniqueItems": true
}

// 有效数据
[1, 2, 3]
[3.14]

// 无效数据
[] // 数组为空,小于 minItems: 1
[1, 2, 3, 4, 5, 6] // 数组长度6,大于 maxItems: 5
[1, 2, 2] // 元素不唯一,违反了 uniqueItems: true
["hello"] // 元素类型是 string,不是 number

字符串约束:minLength, maxLength, pattern, format

基本使用

用于对字符串进行更细致的校验。

  • minLength / maxLength: 字符串的最小/最大长度。
  • pattern: 使用正则表达式验证字符串格式。
  • format: 使用预定义格式验证(如 email, date-time, uri, ipv4 等)。

定义用户名和邮箱

// Schema
{
"type": "object",
"properties": {
"username": {
"type": "string",
"minLength": 3,
"maxLength": 20,
"pattern": "^[a-zA-Z0-9_]+$" // 只允许字母、数字、下划线
},
"birthdate": {
"type": "string",
"format": "date" // 必须符合 YYYY-MM-DD 的日期格式
}
},
"required": ["username"]
}

// 有效数据
{
"username": "alice_123",
"birthdate": "1990-01-01"
}

// 无效数据
{
"username": "ab", // 长度小于 minLength: 3
}
{
"username": "john doe", // 包含空格,不符合 pattern 规则
}
{
"username": "bob",
"birthdate": "1990/01/01" // 格式不符合 ISO date 标准
}

内置格式

具体参见 https://json-schema.fullstack.org.cn/understanding-json-schema/reference/string

以下是 JSON 模式规范中指定的格式列表。

日期和时间

日期和时间以 RFC 3339,第 5.6 节 的形式表示。这是日期格式的子集,也通常称为 ISO8601 格式

  • "date-time":日期和时间组合在一起,例如,2018-11-13T20:20:39+00:00
  • "time":草案 7 中新增时间,例如,20:20:39+00:00
  • "date":草案 7 中新增日期,例如,2018-11-13
  • "duration":2019-09 草案中新增由ISO 8601 ABNF for “duration”定义的持续时间。例如,P3D表示持续时间为 3 天。

电子邮件地址

  • "email":互联网电子邮件地址,请参阅 RFC 5321,第 4.1.2 节
  • "idn-email":草案 7 中新增互联网电子邮件地址的国际化形式,请参阅RFC 6531。

主机名

  • "hostname":互联网主机名,请参阅 RFC 1123,第 2.1 节
  • "idn-hostname":草案 7 中新增国际化的互联网主机名,参见RFC5890,第 2.3.2.3 节。

IP 地址

资源标识符

  • "uuid":2019-09 草案中新增通用唯一标识符 (UUID),根据RFC 4122定义。例如:3e4666bf-d5e5-4aa7-b8ce-cefe41c7568a
  • "uri": 通用资源标识符 (URI),根据 RFC3986
  • "uri-reference":草案 6 中的新增内容URI 引用(URI 或相对引用),根据RFC3986,第 4.1 节。
  • "iri":草案 7 中新增”uri” 的国际化等效项,根据RFC3987。
  • "iri-reference":草案 7 中新增”uri-reference” 的国际化等效项,根据RFC3987如果模式中的值可以相对于特定的源路径(例如,来自网页的链接),通常最好使用 "uri-reference"(或 "iri-reference")而不是 "uri"(或 "iri")。"uri" 应仅在路径必须为绝对路径时使用。

URI 模板

  • "uri-template":草案 6 中的新增内容根据RFC6570的 URI 模板(任何级别)。如果您还不知道什么是 URI 模板,您可能不需要此值。

JSON 指针

  • "json-pointer":草案 6 中的新增内容JSON 指针,根据RFC6901。在构建复杂模式中,对 JSON 模式中使用 JSON 指针进行了更多讨论。请注意,这应仅在整个字符串仅包含 JSON 指针内容时使用,例如/foo/bar。JSON 指针 URI 片段,例如#/foo/bar/应使用"uri-reference"
  • "relative-json-pointer":草案 7 中新增相对 JSON 指针。

正则表达式

  • "regex":草案 7 中新增正则表达式,应根据ECMA 262方言有效。

数字约束:minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf

用于对数字进行范围和小数精度校验。

  • minimum / maximum: 定义数值的下限/上限(包含等于)。
  • exclusiveMinimum / exclusiveMaximum: 定义数值的下限/上限(不包含等于)。
  • multipleOf: 定义数值必须是某个数字的倍数。

定义商品价格和数量

// Schema
{
"type": "object",
"properties": {
"price": {
"type": "number",
"minimum": 0,
"exclusiveMaximum": 1000 // 价格必须 < 1000
},
"discount": {
"type": "number",
"minimum": 0,
"maximum": 1 // 折扣比例在 0 到 1 之间
},
"quantity": {
"type": "integer",
"minimum": 1,
"multipleOf": 10 // 数量必须是10的整数倍
}
}
}

// 有效数据
{
"price": 999.99,
"discount": 0.2,
"quantity": 50
}

// 无效数据
{
"price": 1000, // 等于 exclusiveMaximum: 1000,无效
}
{
"discount": 1.5, // 大于 maximum: 1
}
{
"quantity": 15 // 不是 multipleOf: 10
}

枚举与常量:enum, const

用于将值限制在一个固定的集合中。

  • enum: 值必须是列表中的某一个。
  • const: 值必须严格等于这个常量。

定义订单状态

// Schema
{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["placed", "shipped", "delivered", "cancelled"]
},
"type": {
"const": "online-order" // 这个字段的值必须永远是 "online-order"
}
},
"required": ["status", "type"]
}

// 有效数据
{
"status": "shipped",
"type": "online-order"
}

// 无效数据
{
"status": "returned", // "returned" 不在 enum 列表中
"type": "online-order"
}
{
"status": "placed",
"type": "in-store" // 不等于 const 值 "online-order"
}

条件逻辑:if, then, else

允许你根据数据的某些条件来应用不同的验证规则。

条件验证 - 如果用户在国内,则邮编必填且格式为6位数字;如果在国外,则邮编可选

// Schema
{
"type": "object",
"properties": {
"country": {
"type": "string",
"enum": ["China", "Other"]
},
"postalCode": {
"type": "string"
}
},
"required": ["country"],
"if": {
"properties": {
"country": { "const": "China" }
}
},
"then": {
"properties": {
"postalCode": {
"pattern": "^\\d{6}$" // 匹配6位数字
}
},
"required": ["postalCode"] // 如果在中国,postalCode 变为必填
},
"else": {
// 如果不是中国,对 postalCode 没有特殊要求
}
}

// 有效数据 (中国)
{
"country": "China",
"postalCode": "100000"
}

// 无效数据 (中国)
{
"country": "China",
"postalCode": "10000" // 不是6位数字
}
{
"country": "China" // 缺少 then 子句中要求的 postalCode
}

// 有效数据 (国外)
{
"country": "Other"
}
{
"country": "Other",
"postalCode": "ANY_FORMAT"
}

组合模式:allOf, anyOf, oneOf, not

用于组合多个模式规则。

  • allOf: 数据必须满足所有给定的模式。
  • anyOf: 数据必须满足至少一个给定的模式。
  • oneOf: 数据必须满足恰好一个给定的模式。
  • not: 数据必须不满足给定的模式。

组合模式

// Schema: 定义一个既是正数又是偶数的整数
{
"allOf": [
{ "type": "integer" },
{ "minimum": 1 },
{ "multipleOf": 2 }
]
}

// 有效数据
2
4
100

// 无效数据
1 // 是正数,但不是偶数 (不满足 multipleOf: 2)
-2 // 是偶数,但不是正数 (不满足 minimum: 1)
3.14 // 不是整数 (不满足 type: integer)

结构组织:$defs / definitions$ref

为了复用和模块化 Schema,可以使用引用。

  • $defs (旧版草案中叫 definitions): 一个容器,用于在你的 Schema 中存放可重用的子模式。
  • $ref: 一个引用,指向 $defs 中的某个子模式或外部 URL。

使用 $defs$ref 复用模式

// Schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"shippingAddress": { "$ref": "#/$defs/address" }, // 引用内部定义的 address
"billingAddress": { "$ref": "#/$defs/address" } // 再次引用
},
"$defs": {
"address": { // 定义一个可复用的 address 模式
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"postalCode": { "type": "string" }
},
"required": ["street", "city"]
}
}
}

// 有效数据
{
"shippingAddress": {
"street": "123 Main St",
"city": "Beijing",
"postalCode": "100000"
},
"billingAddress": {
"street": "456 Oak Ave",
"city": "Shanghai"
}
}

注释

JSON Schema 包含一些关键字,这些关键字不严格用于验证,而是用于描述模式的各个部分。这些“注释”关键字都不需要,但鼓励为了良好的实践使用它们,并可以使您的模式“自文档化”。

注释关键字可以在任何模式或子模式中使用。与其他关键字一样,它们只能使用一次。

The titledescription 关键字必须是字符串。一个“标题”最好简短,而一个“描述”将提供关于模式所描述的数据目的的更详细的解释。

The default 关键字指定一个默认值。此值不用于在验证过程中填充缺失的值。非验证工具(例如文档生成器或表单生成器)可以使用此值来提示用户如何使用值。但是,default 通常用于表示如果一个值丢失,那么该值的语义与该值存在且为默认值时相同。The default 的值应该通过它所处的模式进行验证,但这不是必需的。

draft 6 中的新功能

The examples 关键字是一个提供验证模式的示例数组的地方。这不用于验证,但可能有助于向读者解释模式的效果和目的。每个条目都应该通过它所处的模式进行验证,但这并不是严格要求的。没有必要在 examples 数组中复制 default 值,因为 default 将被视为另一个示例。

draft 7 中的新功能

布尔关键字 readOnlywriteOnly 通常用于 API 上下文中。 readOnly 指示不应修改值。它可以用于指示更改值的 PUT 请求会导致 400 Bad Request 响应。 writeOnly 指示可以设置值,但它将保持隐藏状态。它可以用于指示您可以使用 PUT 请求设置值,但它不会包含在使用 GET 请求检索该记录时。

draft 2019-09 中的新功能

The deprecated 关键字是一个布尔值,表示该关键字适用的实例值不应使用,并且将来可能会被删除

{
"title": "匹配任何内容",
"description": "这是一个匹配任何内容的模式。",
"default": "默认值",
"examples": [
"任何内容",
4035
],
"deprecated": true,
"readOnly": true,
"writeOnly": false
}

组合模式:allOf, anyOf, oneOf, not详解

这些关键字都是基于布尔逻辑的:

  • AND (allOf): 必须满足所有条件。
  • OR (anyOf): 必须满足至少一个条件。
  • XOR (oneOf): 必须满足恰好一个条件。
  • NOT (not): 必须不满足这个条件。

它们接受一个数组作为值,数组中的每个元素都是一个独立的 schema 对象。

关键字 逻辑 描述 相当于
allOf AND (∧) 必须满足所有子模式 schema1 ∧ schema2 ∧ ...
anyOf OR (∨) 必须满足至少一个子模式 schema1 ∨ schema2 ∨ ...
oneOf XOR (⊕) 必须满足恰好一个子模式 (schema1 ∧ ¬schema2) ∨ (¬schema1 ∧ schema2)
not NOT (¬) 必须不满足该子模式 ¬schema

重要提示:

  1. oneOf 的陷阱:在使用 oneOf 时,要特别注意确保子模式之间是互斥的,否则很容易出现一个数据同时匹配多个模式的情况导致验证失败。通常需要通过添加额外的约束(如 constpattern)来明确区分它们。
  2. 性能anyOfoneOf 会按顺序尝试验证每个子模式,直到找到匹配项(anyOf)或确定只有一个匹配项(oneOf)。如果子模式很多或很复杂,可能会影响性能。
  3. 错误信息:当组合模式验证失败时,产生的错误信息可能比较难以理解,因为它需要解释复杂的逻辑关系。在设计 schema 时需要权衡复杂性和可维护性。

allOf - 逻辑与(AND)

数据必须满足 所有allOf 数组中提供的模式。这常用于组合多个约束或从多个来源继承属性。

用法: "allOf": [ {schema1}, {schema2}, ... ]

组合验证
创建一个 schema,要求数据是一个字符串,并且长度在 1 到 10 之间,同时必须全部大写。

// Schema
{
"allOf": [
{ "type": "string" },
{ "minLength": 1 },
{ "maxLength": 10 },
{ "pattern": "^[A-Z]*$" } // 正则:从开始到结束必须都是大写字母
]
}

// 有效数据
"HELLO"
"A"

// 无效数据
"hello" // 不满足 pattern (不是大写)
"" // 不满足 minLength: 1
"VERY_LONG_STRING" // 不满足 maxLength: 10
42 // 不满足 type: string

组合对象(类似继承)
组合一个基础地址 schema 和一个包含额外字段的详细地址 schema。

// Schema
{
"allOf": [
{
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" }
},
"required": ["street", "city"]
},
{
"type": "object",
"properties": {
"country": { "type": "string", "default": "USA" },
"zipcode": { "type": "string" }
},
"required": ["country"]
}
]
}

// 验证时,数据必须满足上述两个对象schema的所有要求。
// 有效数据
{
"street": "123 Main St",
"city": "Springfield",
"country": "USA",
"zipcode": "12345"
}

// 无效数据
{
"street": "123 Main St",
"city": "Springfield"
}
// 缺少第二个schema中 required 的 "country" 字段

anyOf - 逻辑或(OR)

数据必须满足 anyOf 数组中提供的 至少一个 模式。这常用于创建联合类型或定义多种可接受的数据格式。

用法: "anyOf": [ {schema1}, {schema2}, ... ]

示例:支持多种类型或格式
验证一个字段可以是字符串形式的电话号码,也可以是数字形式的 ID。

// Schema
{
"anyOf": [
{
"type": "string",
"pattern": "^\\d{3}-\\d{3}-\\d{4}$" // 匹配 123-456-7890
},
{
"type": "integer",
"minimum": 1000
}
]
}

// 有效数据 (满足第一个schema)
"555-123-4567"

// 有效数据 (满足第二个schema)
2000

// 无效数据 (两个schema都不满足)
"hello" // 不是正确的电话格式,也不是数字
500 // 是数字,但小于 minimum: 1000


oneOf - 逻辑异或(XOR)

数据必须满足 oneOf 数组中提供的 恰好一个 模式。不能同时满足多个。这常用于枚举或确保数据只有一种明确的类型。

用法: "oneOf": [ {schema1}, {schema2}, ... ]

示例:精确的类型选择
验证一个值要么是字符串,要么是数字,但不能同时满足两者的验证规则(注意:一个数字字符串 "5" 同时是字符串且可以被解释为数字)。

// Schema
{
"oneOf": [
{ "type": "string" },
{ "type": "number" }
]
}

// 有效数据 (只满足 string)
"Hello"

// 有效数据 (只满足 number)
42

// 无效数据 (同时满足两个!)
"5"
// 为什么?因为它既是 string (满足第一个schema),又可以被转换为数字 (满足第二个schema的 `type: number` 验证)。
// 要解决这个问题,需要让模式互斥,例如:
/*
{
"oneOf": [
{
"type": "string",
"pattern": "\\D" // 确保字符串包含非数字字符
},
{
"type": "number"
}
]
}
*/
// 这样 "5" 会因为不满足 pattern 而只匹配 number schema。

更实用的例子:支付方式
订单只能使用一种支付方式。

// Schema
{
"type": "object",
"oneOf": [
{ // 信用卡支付
"properties": {
"paymentType": { "const": "credit_card" },
"cardNumber": { "type": "string" }
},
"required": ["paymentType", "cardNumber"]
},
{ // PayPal支付
"properties": {
"paymentType": { "const": "paypal" },
"email": { "type": "string", "format": "email" }
},
"required": ["paymentType", "email"]
}
]
}

// 有效数据 (只匹配信用卡模式)
{
"paymentType": "credit_card",
"cardNumber": "4111..."
}

// 有效数据 (只匹配PayPal模式)
{
"paymentType": "paypal",
"email": "user@example.com"
}

// 无效数据 (匹配了0个模式,缺少required字段)
{
"paymentType": "credit_card"
// 缺少 cardNumber
}

// 无效数据 (匹配了多个模式,这是不允许的)
{
"paymentType": "credit_card",
"cardNumber": "4111...",
"email": "user@example.com"
// 这个对象同时满足了两个模式定义的properties,违反了 oneOf
}


not - 逻辑非(NOT)

数据必须 不满足 not 关键字后面提供的模式。这常用于排除某些特定的值或格式。

用法: "not": { schema }

示例:排除特定值
确保一个值不是空字符串。

// Schema
{
"not": {
"type": "string",
"maxLength": 0
}
}

// 有效数据
"Hello"
42
["a"]
{"a": "b"}

// 无效数据
"" // 满足了 not 后面的schema(是字符串且长度<=0),所以被排除。

示例:确保不是某个枚举值
状态不能是 "deprecated"

// Schema
{
"type": "string",
"not": {
"const": "deprecated"
}
}

// 有效数据
"active"
"inactive"

// 无效数据
"deprecated"


条件逻辑:if, then, else详解

这三个关键字总是组合使用,其逻辑类似于编程语言中的 if-then-else 语句:

  1. if

    • 这是一个条件判断。它的值是一个 schema 对象。
    • 验证器会检查当前数据实例是否满足 if 这个 schema 中定义的规则。
    • 注意if 条件本身的验证结果只用于决定执行路径,它不会导致整个验证失败。即使数据不满足 if 条件,也只是导致 then 被跳过,验证会继续检查 else(如果存在)。
  2. then

    • 如果数据实例满足if 条件,那么验证器就会检查它是否也满足 then 这个 schema 的规则。
    • 如果此时数据不满足 then 的规则,验证就会失败。
  3. else

    • (可选关键字)如果数据实例不满足 if 条件,那么验证器就会检查它是否满足 else 这个 schema 的规则。
    • 如果此时数据不满足 else 的规则,验证就会失败。详细示例说明

特别注意:

  1. if 本身不贡献错误:数据即使不满足 if 条件,也不会因此导致验证失败。失败只发生在数据满足了 if 却不满足 then,或者不满足 if 却不满足 else 的情况下。
  2. else 是可选的:如果你只想在条件满足时施加额外约束,而在条件不满足时什么都不做,可以省略 else
  3. 条件可以是任何 Schemaif 条件不仅可以检查简单的相等性(const),还可以使用 type, pattern, enum, 甚至嵌套的 allOf/anyOf 等来构建复杂条件。
  4. 避免过度嵌套:虽然可以嵌套 if-then-else(如示例1),但过度嵌套会使 Schema 难以理解和维护。有时使用 oneOf 可能是更清晰的选择。
  5. 与组合模式结合:条件逻辑可以和你之前学到的 allOf, anyOf, not 等组合使用,构建出极其强大的验证逻辑(如示例2中 then 里使用了 not)。

基本的字段依赖验证(经典用例)

场景:一个用户对象。如果 country"USA",那么 zipCode 字段是必须的,并且必须匹配 5 位数字或 “ ZIP+4 ” 的格式。如果 country"Canada",那么 postalCode 字段是必须的,并且必须匹配加拿大的邮政编码格式。对于其他国家,这两个字段都是可选的。

{
"type": "object",
"properties": {
"country": {
"type": "string",
"enum": ["USA", "Canada", "Germany"]
},
"zipCode": {
"type": "string"
},
"postalCode": {
"type": "string"
}
},
"required": ["country"],
"if": { // IF 条件:国家是美国
"properties": {
"country": { "const": "USA" }
}
},
"then": { // THEN 规则:那么 zipCode 必须存在且格式正确
"properties": {
"zipCode": {
"pattern": "^(\\d{5}(-\\d{4})?)?$"
}
},
"required": ["zipCode"]
},
"else": { // ELSE 条件:国家不是美国
"if": { // 嵌套条件:国家是加拿大
"properties": {
"country": { "const": "Canada" }
}
},
"then": { // 那么 postalCode 必须存在且格式正确
"properties": {
"postalCode": {
"pattern": "^[A-Z]\\d[A-Z] \\d[A-Z]\\d$"
}
},
"required": ["postalCode"]
},
"else": { // 国家既不是美国也不是加拿大
// 对 zipCode 和 postalCode 没有特殊要求,它们可以是任何字符串或不存在
}
}
}

验证结果分析

数据 if 条件 应用规则 验证结果 原因
{"country": "USA"} 满足 then 无效 缺少必需的 zipCode 字段。
{"country": "USA", "zipCode": "12345"} 满足 then 有效 满足 then 的所有规则。
{"country": "USA", "zipCode": "123"} 满足 then 无效 zipCode 格式不匹配 pattern
{"country": "Canada", "postalCode": "K1A 0B1"} 不满足 外层的 else -> 内层的 then 有效 满足内层 then 的规则。
{"country": "Germany"} 不满足 外层的 else -> 内层的 else 有效 内层 else 为空,无额外约束。
{"country": "Germany", "zipCode": "abc"} 不满足 外层的 else -> 内层的 else 有效 内层 else 为空,zipCode 存在与否和格式都不受约束。

验证互斥的字段组合

场景:一个支付对象。支付方式可以是信用卡(需要 cardNumber)也可以是 PayPal(需要 email)。但不能同时提供两者。

{
"type": "object",
"properties": {
"paymentMethod": {
"type": "string",
"enum": ["credit_card", "paypal"]
},
"cardNumber": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
}
},
"required": ["paymentMethod"],
"if": {
"properties": {
"paymentMethod": { "const": "credit_card" }
}
},
"then": {
"properties": {
// 如果方式是信用卡,则必须存在cardNumber
"cardNumber": { "minLength": 1 }
},
"required": ["cardNumber"],
// 并且必须不能有paypal的email字段
"not": { "required": ["email"] }
},
"else": { // 否则,方式就是 paypal
"properties": {
// 如果方式是paypal,则必须存在email
"email": { "minLength": 1 }
},
"required": ["email"],
// 并且必须不能有信用卡的cardNumber字段
"not": { "required": ["cardNumber"] }
}
}

验证结果分析

数据 验证结果 原因
{"paymentMethod": "credit_card", "cardNumber": "4111..."} 有效 满足 if,应用 then:有 cardNumber 且无 email
{"paymentMethod": "paypal", "email": "a@b.c"} 有效 不满足 if,应用 else:有 email 且无 cardNumber
{"paymentMethod": "credit_card"} 无效 满足 if,应用 then:缺少必需的 cardNumber
{"paymentMethod": "credit_card", "cardNumber": "4111...", "email": "a@b.c"} 无效 满足 if,应用 then:但 then 中的 not 规则禁止了 email 字段的存在。
{"paymentMethod": "paypal"} 无效 不满足 if,应用 else:缺少必需的 email

验证数值范围依赖

场景:一个产品折扣对象。如果 discountType"percentage"(百分比),那么 value 必须在 0 到 100 之间。如果是 "fixed"(固定金额),那么 value 必须大于 0。

{
"type": "object",
"properties": {
"discountType": {
"type": "string",
"enum": ["percentage", "fixed"]
},
"value": {
"type": "number"
}
},
"required": ["discountType", "value"],
"if": {
"properties": {
"discountType": { "const": "percentage" }
}
},
"then": {
"properties": {
"value": {
"minimum": 0,
"maximum": 100
}
}
},
"else": {
"properties": {
"value": {
"exclusiveMinimum": 0
}
}
}
}

验证结果分析

数据 验证结果 原因
{"discountType": "percentage", "value": 50} 有效 满足 if,应用 then:50 在 0-100 之间。
{"discountType": "percentage", "value": 150} 无效 满足 if,应用 then:150 > 100。
{"discountType": "fixed", "value": 30} 有效 不满足 if,应用 else:30 > 0。
{"discountType": "fixed", "value": 0} 无效 不满足 if,应用 else:0 不大于 0 (exclusiveMinimum)。

使用条件逻辑与组合模式验证多层嵌套对象

使用条件逻辑与组合模式验证多层嵌套对象

场景描述

假设我们正在构建一个电子商务平台的订单系统,需要验证订单数据的结构。订单对象具有以下复杂要求:

  1. 订单必须包含基本信息和至少一个商品项
  2. 根据支付方式不同,需要不同的验证规则:
    • 信用卡支付需要卡号和安全码
    • PayPal支付需要邮箱地址
    • 银行转账需要银行账户信息
  3. 根据配送地址的国家不同,需要不同的邮编验证规则
  4. 某些商品类型有特殊要求:
    • 数字商品需要电子邮件地址用于发送
    • 危险品需要特殊处理标志和免责声明同意
  5. 订单总额必须等于所有商品价格加上运费和税费的总和

完整Schema示例

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/complex-order-schema.json",
"title": "复杂订单验证",
"description": "验证包含多种支付方式和配送条件的电子商务订单",
"type": "object",
"properties": {
"orderId": {
"type": "string",
"pattern": "^ORD-\\d{5}-[A-Z]{3}$"
},
"customer": {
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1 },
"email": { "type": "string", "format": "email" }
},
"required": ["name", "email"]
},
"items": {
"type": "array",
"minItems": 1,
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"type": { "const": "physical" },
"productId": { "type": "string" },
"name": { "type": "string" },
"price": { "type": "number", "minimum": 0 },
"quantity": { "type": "integer", "minimum": 1 }
},
"required": ["type", "productId", "name", "price", "quantity"]
},
{
"type": "object",
"properties": {
"type": { "const": "digital" },
"productId": { "type": "string" },
"name": { "type": "string" },
"price": { "type": "number", "minimum": 0 },
"licenseType": { "type": "string", "enum": ["single", "multi"] }
},
"required": ["type", "productId", "name", "price", "licenseType"]
},
{
"type": "object",
"properties": {
"type": { "const": "hazardous" },
"productId": { "type": "string" },
"name": { "type": "string" },
"price": { "type": "number", "minimum": 0 },
"quantity": { "type": "integer", "minimum": 1 },
"hazardLevel": { "type": "string", "enum": ["low", "medium", "high"] }
},
"required": ["type", "productId", "name", "price", "quantity", "hazardLevel"]
}
]
}
},
"shippingAddress": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"country": { "type": "string", "enum": ["US", "CA", "UK", "DE", "FR"] },
"postalCode": { "type": "string" }
},
"required": ["street", "city", "country", "postalCode"],
"if": {
"properties": {
"country": { "const": "US" }
}
},
"then": {
"properties": {
"postalCode": {
"pattern": "^\\d{5}(-\\d{4})?$"
}
}
},
"else": {
"if": {
"properties": {
"country": { "const": "CA" }
}
},
"then": {
"properties": {
"postalCode": {
"pattern": "^[A-Z]\\d[A-Z] \\d[A-Z]\\d$"
}
}
},
"else": {
"if": {
"properties": {
"country": { "const": "UK" }
}
},
"then": {
"properties": {
"postalCode": {
"pattern": "^[A-Z]{1,2}\\d[A-Z\\d]? ?\\d[A-Z]{2}$"
}
}
}
}
}
},
"payment": {
"type": "object",
"oneOf": [
{
"properties": {
"method": { "const": "credit_card" },
"cardNumber": { "type": "string", "pattern": "^\\d{16}$" },
"expiryDate": { "type": "string", "pattern": "^(0[1-9]|1[0-2])\\/\\d{2}$" },
"cvv": { "type": "string", "pattern": "^\\d{3,4}$" }
},
"required": ["method", "cardNumber", "expiryDate", "cvv"]
},
{
"properties": {
"method": { "const": "paypal" },
"email": { "type": "string", "format": "email" }
},
"required": ["method", "email"]
},
{
"properties": {
"method": { "const": "bank_transfer" },
"accountNumber": { "type": "string" },
"routingNumber": { "type": "string" },
"bankName": { "type": "string" }
},
"required": ["method", "accountNumber", "routingNumber", "bankName"]
}
]
},
"totalAmount": {
"type": "number",
"minimum": 0
},
"disclaimerAccepted": {
"type": "boolean"
}
},
"required": ["orderId", "customer", "items", "shippingAddress", "payment", "totalAmount"],
"allOf": [
{
// 验证数字商品需要电子邮件地址
"if": {
"properties": {
"items": {
"contains": {
"properties": {
"type": { "const": "digital" }
}
}
}
}
},
"then": {
"properties": {
"customer": {
"properties": {
"email": {
"type": "string",
"format": "email"
}
},
"required": ["email"]
}
}
}
},
{
// 验证危险品需要免责声明接受
"if": {
"properties": {
"items": {
"contains": {
"properties": {
"type": { "const": "hazardous" }
}
}
}
}
},
"then": {
"properties": {
"disclaimerAccepted": { "const": true }
},
"required": ["disclaimerAccepted"]
}
},
{
// 验证订单总额计算是否正确
"if": {
"properties": {
"items": {
"type": "array",
"minItems": 1
}
}
},
"then": {
"properties": {
"totalAmount": {
"type": "number"
}
}
}
}
]
}

详细解释

基本结构验证

Schema 首先定义了订单对象必须包含的基本字段:

  • orderId: 必须符合特定格式的字符串
  • customer: 包含姓名和邮箱的对象
  • items: 至少包含一个商品的数组
  • shippingAddress: 配送地址对象
  • payment: 支付信息对象
  • totalAmount: 订单总金额

商品项验证 (使用 oneOf)

商品项使用 oneOf 来确保每种商品类型都有特定的必需字段:

  • 实物商品: 需要 type, productId, name, price, quantity
  • 数字商品: 需要 type, productId, name, price, licenseType
  • 危险品: 需要 type, productId, name, price, quantity, hazardLevel

配送地址验证 (使用嵌套 if-then-else)

根据不同的国家,应用不同的邮编格式验证:

  • 美国(US): 5位或5-4位数字格式
  • 加拿大(CA): 字母数字交替格式
  • 英国(UK): 英国特有的邮编格式
  • 其他国家: 没有特定格式要求

支付方式验证 (使用 oneOf)

支付方式使用 oneOf 确保每种支付方式都有正确的字段:

  • 信用卡: 需要卡号、有效期和安全码
  • PayPal: 需要邮箱地址
  • 银行转账: 需要账户号、路由号和银行名称

条件验证 (使用 allOfif-then)

使用 allOf 组合多个条件验证规则:

规则1: 如果订单包含数字商品,则客户必须提供有效的电子邮件地址

{
"if": {
"properties": {
"items": {
"contains": {
"properties": {
"type": { "const": "digital" }
}
}
}
}
},
"then": {
"properties": {
"customer": {
"properties": {
"email": {
"type": "string",
"format": "email"
}
},
"required": ["email"]
}
}
}
}

规则2: 如果订单包含危险品,则必须接受免责声明

{
"if": {
"properties": {
"items": {
"contains": {
"properties": {
"type": { "const": "hazardous" }
}
}
}
}
},
"then": {
"properties": {
"disclaimerAccepted": { "const": true }
},
"required": ["disclaimerAccepted"]
}
}

有效数据示例

包含数字商品的信用卡订单

{
"orderId": "ORD-12345-ABC",
"customer": {
"name": "John Doe",
"email": "john@example.com"
},
"items": [
{
"type": "digital",
"productId": "DIG-001",
"name": "E-book",
"price": 19.99,
"licenseType": "single"
}
],
"shippingAddress": {
"street": "123 Main St",
"city": "New York",
"country": "US",
"postalCode": "10001"
},
"payment": {
"method": "credit_card",
"cardNumber": "4111111111111111",
"expiryDate": "12/25",
"cvv": "123"
},
"totalAmount": 19.99
}

包含危险品的银行转账订单

{
"orderId": "ORD-67890-XYZ",
"customer": {
"name": "Jane Smith",
"email": "jane@example.com"
},
"items": [
{
"type": "hazardous",
"productId": "HAZ-001",
"name": "Chemicals",
"price": 49.99,
"quantity": 2,
"hazardLevel": "medium"
}
],
"shippingAddress": {
"street": "456 Oak Ave",
"city": "Toronto",
"country": "CA",
"postalCode": "M5V 2T6"
},
"payment": {
"method": "bank_transfer",
"accountNumber": "123456789",
"routingNumber": "021000021",
"bankName": "Example Bank"
},
"totalAmount": 99.98,
"disclaimerAccepted": true
}

无效数据示例

缺少危险品免责声明

{
"orderId": "ORD-11111-AAA",
"customer": {
"name": "Bob Johnson",
"email": "bob@example.com"
},
"items": [
{
"type": "hazardous",
"productId": "HAZ-002",
"name": "Flammable Material",
"price": 29.99,
"quantity": 1,
"hazardLevel": "high"
}
],
"shippingAddress": {
"street": "789 Pine St",
"city": "London",
"country": "UK",
"postalCode": "SW1A 1AA"
},
"payment": {
"method": "paypal",
"email": "bob@example.com"
},
"totalAmount": 29.99
// 缺少 disclaimerAccepted: true
}

数字商品缺少客户邮箱

{
"orderId": "ORD-22222-BBB",
"customer": {
"name": "Alice Brown"
// 缺少 email 字段
},
"items": [
{
"type": "digital",
"productId": "DIG-002",
"name": "Software License",
"price": 99.99,
"licenseType": "multi"
}
],
"shippingAddress": {
"street": "321 Cedar Rd",
"city": "Berlin",
"country": "DE",
"postalCode": "10115"
},
"payment": {
"method": "credit_card",
"cardNumber": "5555555555554444",
"expiryDate": "06/24",
"cvv": "456"
},
"totalAmount": 99.99
}

这个复杂的示例展示了如何结合使用 JSON Schema 的条件逻辑 (if-then-else) 和组合模式 (allOf, oneOf) 来验证多层嵌套对象。关键点包括:

  1. 使用 oneOf 确保多种选项中的恰好一种被满足(如支付方式、商品类型)
  2. 使用嵌套的 if-then-else 根据特定条件应用不同的验证规则(如根据国家验证邮编格式)
  3. 使用 allOf 组合多个条件验证 确保所有相关规则都被应用(如数字商品需要邮箱、危险品需要免责声明)
  4. 使用 contains 关键字 检查数组中是否存在特定类型的元素

这种组合使用使得 JSON Schema 能够表达极其复杂和精细的验证逻辑,几乎可以满足任何现实世界中的数据验证需求。


$schema$id关键字

$schema$id 是 JSON Schema 的元数据关键字,它们不是固定不变的字符串,但其作用和写法有明确的约定。


$schema 关键字

含义$schema 关键字用于声明这个 JSON 文档本身遵循的是哪个版本的 JSON Schema 规范草案(Draft)。它就像是 XML 中的 DOCTYPE 声明或 HTML 中的 `` 声明。

为什么需要它?

  • 版本控制:JSON Schema 规范本身在不断发展,有不同的草案版本(如 Draft-07, Draft-2019-09, Draft-2020-12)。每个版本都可能引入新的关键字或修改现有关键字的行为。使用 $schema 明确告知验证器应该使用哪一套规则来解析这个 schema。
  • 工具链支持:许多编辑器(如 VS Code、IntelliJ IDEA)和验证器依赖这个值来提供自动完成、语法高亮和实时验证。如果没有它,这些工具可能不知道如何正确处理你的 schema 文件。

常用值(不是固定写法,但必须从以下中选一个)

对应的草案版本 说明
"http://json-schema.org/draft-07/schema#" Draft-07 较旧的版本,但仍被广泛使用。
"http://json-schema.org/draft/2019-09/schema" Draft-2019-09 引入了 unevaluatedProperties 等新关键字。
"https://json-schema.org/draft/2020-12/schema" Draft-2020-12 当前推荐的最新稳定版本

结论:虽然值不是固定的,但强烈建议总是在 schema 的根节点包含 $schema 关键字,并指定为你所使用的草案版本URL。这被认为是最佳实践。


$id 关键字

含义$id 为 schema 定义一个统一资源标识符(URI)。这个 URI 是 schema 的唯一标识符,可以理解为它的“命名空间”或“基础地址”。

它有两个主要作用

唯一标识(作为引用名称)

这是它的核心作用。当其他 schema 想要引用(使用 $ref 当前这个 schema 或其内部定义时,就会使用这个 $id 作为基址。

例如,你在一个 schema 中定义了:

{
"$id": "https://example.com/schemas/address.json",
"$defs": {
"street": { "type": "string" }
}
}

在另一个 schema 中,你可以这样引用它:

{
"type": "object",
"properties": {
"homeAddress": {
"$ref": "https://example.com/schemas/address.json#/$defs/street"
},
"workAddress": {
"$ref": "https://example.com/schemas/address.json"
}
}
}

这里的 $ref 值就是基于 $id 提供的基址进行构建的。

解析相对引用(作为基础URI)

在 schema 内部,如果使用相对路径进行引用(例如 "$ref": "#/$defs/myDefinition"),这个相对路径是相对于 $id 所定义的基址来解析的。

关于它的值

  • 可以是任何 URI:它不一定需要是一个真实可访问的网址。它只是一个标识符。常用的格式是 https://你的域名/schemas/文件名.json
  • 推荐使用绝对 URI:为了确保唯一性和可移植性,最好使用完整的绝对 URI(如 https://...),而不是相对路径(如 "/schemas/address.json")。
  • 在根节点定义$id 通常在 schema 的根节点定义,为整个文档设置基础 URI。但它也可以在其他位置出现,用于修改局部的基础 URI(高级用法)。

总结与对比

关键字 作用 必要性 值示例
$schema 定义本schema遵循的规范版本 强烈推荐 "https://json-schema.org/draft/2020-12/schema"
$id 为本schema定义一个唯一标识符(URI),供外部或内部引用 推荐(尤其在schema需要被复用时) "https://example.com/schemas/address.json"

所以,你给出的例子:

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/complex-order-schema.json",
...
}

含义是:

  1. “本 Schema 文档遵循 JSON Schema Draft-2020-12 版本的规范。”
  2. “本 Schema 的唯一标识符是 https://example.com/complex-order-schema.json。其他 Schema 可以通过这个 URI 来引用我,我内部的相对引用路径也基于这个 URI 进行解析。”

特别注意:

  • 始终包含 $schema
  • 如果你的 Schema 会被其他 Schema 通过 $ref 引用,或者你想清晰地命名它,那么就应该包含 $id
  • 如果你只是写一个简单的、一次性的、不被复用的 Schema,$id 可以省略。

JSON Schema 中的唯一性约束

在 JSON Schema 中,唯一性约束主要通过 uniqueItems 关键字来实现,但它有一个重要的限制:它只适用于数组(array)类型,用于确保数组中的所有元素都是唯一的。

JSON Schema 没有提供类似于数据库中的 UNIQUE 约束的关键字来直接保证整个文档中某个字段的唯一性(例如,确保所有用户对象的 email 字段值都是唯一的)。这种全局唯一性需要在应用层或数据库层实现。


数组元素的唯一性约束 (uniqueItems)

作用:当 uniqueItems 设置为 true 时,要求数组中的每个元素都是唯一的。

工作原理:验证器会使用深度比较(deep comparison)来检查数组元素是否重复。对于对象,会比较所有属性及其值;对于数组,会按顺序比较每个元素。

针对不同数据类型的示例:

基本数据类型数组

// Schema
{
"type": "array",
"items": {
"type": "number"
},
"uniqueItems": true
}

// 有效数据 - 所有数字都是唯一的
[1, 2, 3, 4, 5]
[1.5, 2.3, 3.1]

// 无效数据 - 包含重复元素
[1, 2, 3, 2, 5] // 数字 2 重复了
[1, 1, 1] // 所有元素都相同

字符串数组

// Schema
{
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true
}

// 有效数据
["apple", "banana", "orange"]
["a", "b", "c"]

// 无效数据
["apple", "banana", "apple"] // "apple" 重复了
["yes", "yes", "no"]

布尔值数组

// Schema
{
"type": "array",
"items": {
"type": "boolean"
},
"uniqueItems": true
}

// 有效数据 - 只有两个不同的布尔值,这个数组恰好都包含了
[true, false]

// 无效数据 - 尝试创建包含多个相同布尔值的数组
[true, true, false] // 第一个和第二个 true 是重复的

对象数组 - 基于整个对象的唯一性

// Schema
{
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" }
},
"required": ["id", "name"]
},
"uniqueItems": true // 要求每个对象整体都是唯一的
}

// 有效数据 - 所有对象都是唯一的
[
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" },
{ "id": 3, "name": "Charlie" }
]

// 无效数据 - 包含重复对象
[
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" },
{ "id": 1, "name": "Alice" } // 与第一个对象完全相同
]

嵌套结构数组

// Schema
{
"type": "array",
"items": {
"type": "array",
"items": {
"type": "string"
}
},
"uniqueItems": true // 要求每个内部数组都是唯一的
}

// 有效数据
[["a", "b"], ["c", "d"]]

// 无效数据
[["a", "b"], ["a", "b"]] // 两个内部数组完全相同

针对对象特定字段的唯一性约束

这是更复杂但更常见的需求:确保数组中每个对象的某个特定字段(或一组字段)的值是唯一的

JSON Schema 没有直接的关键字来实现这一点,但可以通过组合使用 uniqueItems 和其他技术来模拟这种效果。

使用枚举和大小限制

这种方法适用于已知所有可能值的情况。

// Schema: 确保角色名称唯一
{
"type": "array",
"items": {
"type": "object",
"properties": {
"roleName": {
"type": "string",
"enum": ["admin", "editor", "viewer", "guest"] // 预定义唯一值
}
},
"required": ["roleName"]
},
"maxItems": 4 // 不能超过枚举值的数量
}

// 有效数据
[
{ "roleName": "admin" },
{ "roleName": "editor" }
]

// 无效数据(通过 maxItems 间接防止重复)
[
{ "roleName": "admin" },
{ "roleName": "editor" },
{ "roleName": "viewer" },
{ "roleName": "guest" },
{ "roleName": "admin" } // 虽然能通过enum检查,但会被maxItems阻止
]

使用组合模式实现真正基于字段的唯一性

这是一种更强大的方法,使用 allOfnot 的组合来验证唯一性。

{
"type": "array",
"items": {
"type": "object",
"properties": {
"userId": { "type": "integer" },
"email": { "type": "string", "format": "email" },
"department": { "type": "string" }
},
"required": ["userId", "email"]
},
"allOf": [
{
// 约束 1: 确保 userId 字段在数组中是唯一的
"description": "All userId values must be unique",
"items": {
"type": "object",
"properties": {
"userId": {
"type": "integer"
}
}
},
"uniqueItems": true // 这里虽然用uniqueItems,但实际是通过后面的逻辑实现
},
{
// 约束 2: 确保 email 字段在数组中是唯一的
"description": "All email values must be unique",
"items": {
"type": "object",
"properties": {
"email": {
"type": "string"
}
}
},
"uniqueItems": true
}
]
}

更精确的实现方式

实际上,更准确的方法是使用应用逻辑或自定义验证,因为标准的 JSON Schema 无法直接实现基于字段的唯一性。但在某些验证器中,你可以这样模拟:

{
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" }
},
"required": ["id"]
},
// 这不是标准JSON Schema,但有些验证器支持这种思路
// 实际使用时需要在应用层实现类似逻辑
}

针对多个字段组合的唯一性约束

如果需要确保多个字段的组合是唯一的,可以使用类似的方法:

{
"type": "array",
"items": {
"type": "object",
"properties": {
"firstName": { "type": "string" },
"lastName": { "type": "string" },
"dateOfBirth": { "type": "string", "format": "date" }
},
"required": ["firstName", "lastName", "dateOfBirth"]
},
"allOf": [
{
// 确保 firstName + lastName + dateOfBirth 组合是唯一的
// 这通常需要在应用层实现
"description": "Combination of firstName, lastName and dateOfBirth must be unique"
}
]
}

实际应用建议

  1. 对于简单唯一性(整个数组元素唯一):使用 uniqueItems: true
  2. 对于字段级唯一性

    • 如果可能值已知且有限:使用 enum + maxItems
    • 对于一般情况:在应用层实现验证逻辑
    • 对于需要严格验证的场景:考虑使用数据库的唯一约束
  3. 性能考虑uniqueItems 需要对数组进行 O(n²) 的比较,对于大型数组可能影响性能。

总结

约束类型 JSON Schema 支持 实现方法
数组元素整体唯一 ✅ 直接支持 uniqueItems: true
对象特定字段唯一 ❌ 不直接支持 应用层逻辑,或使用 enum + maxItems 间接实现
多字段组合唯一 ❌ 不直接支持 应用层逻辑

JSON Schema 与 Go 校验实现

增强后的 JSON Schema

{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"tracks": {
"type": "array",
"items": {}
},
"shapes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["rectangle", "polyline", "points", "ellipse", "cuboid"]
},
"clientID": {
"type": "integer",
"minimum": 1
},
"occluded": {
"type": "boolean",
"default": false
},
"outside": {
"type": "boolean",
"default": false
},
"z_order": {
"type": "integer",
"default": 0
},
"points": {
"type": "array",
"items": {
"type": "number",
"format": "double",
"minimum": 0
},
"minItems": 2,
"maxItems": 16,
"additionalItems": false
},
"rotation": {
"type": "number",
"format": "double",
"minimum": 0,
"maximum": 360,
"default": 0.0
},
"attributes": {
"type": "array",
"items": {}
},
"elements": {
"type": "array",
"items": {}
},
"id": {
"type": ["integer", "null"],
"nullable": true
},
"frame": {
"type": "integer",
"minimum": 0
},
"label_id": {
"type": "integer",
"minimum": 0
},
"group": {
"type": ["integer", "null"],
"minimum": 0,
"nullable": true
},
"source": {
"type": "string",
"default": "manual"
}
},
"required": ["type", "clientID", "occluded", "outside", "z_order", "points", "rotation", "attributes", "elements", "frame", "label_id", "group", "source"],
"allOf": [
{
"if": {
"properties": {
"type": {
"const": "rectangle"
}
}
},
"then": {
"properties": {
"points": {
"minItems": 4,
"maxItems": 4
}
}
}
},
{
"if": {
"properties": {
"type": {
"const": "polyline"
}
}
},
"then": {
"properties": {
"points": {
"minItems": 4,
"maxItems": 100
}
}
}
},
{
"if": {
"properties": {
"type": {
"const": "points"
}
}
},
"then": {
"properties": {
"points": {
"minItems": 2,
"maxItems": 2
}
}
}
},
{
"if": {
"properties": {
"type": {
"const": "ellipse"
}
}
},
"then": {
"properties": {
"points": {
"minItems": 4,
"maxItems": 4
}
}
}
},
{
"if": {
"properties": {
"type": {
"const": "cuboid"
}
}
},
"then": {
"properties": {
"points": {
"minItems": 16,
"maxItems": 16
}
}
}
}
]
}
},
"tags": {
"type": "array",
"items": {
"type": "object",
"properties": {
"clientID": {
"type": "integer",
"minimum": 1
},
"frame": {
"type": "integer",
"minimum": 0
},
"label_id": {
"type": "integer",
"minimum": 0
},
"source": {
"type": "string",
"default": "manual"
},
"group": {
"type": ["integer", "null"],
"minimum": 0,
"nullable": true
},
"attributes": {
"type": "array",
"items": {}
}
},
"required": ["clientID", "frame", "label_id", "source", "group", "attributes"]
}
}
},
"required": ["tracks", "shapes", "tags"]
}

增强的 Go 语言校验实现

package main

import (
"encoding/json"
"fmt"
"log"

"github.com/xeipuuv/gojsonschema"
)

// 定义JSON Schema字符串
const schemaStr = `{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"tracks": {
"type": "array",
"items": {}
},
"shapes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["rectangle", "polyline", "points", "ellipse", "cuboid"]
},
"clientID": {
"type": "integer",
"minimum": 1
},
"occluded": {
"type": "boolean",
"default": false
},
"outside": {
"type": "boolean",
"default": false
},
"z_order": {
"type": "integer",
"default": 0
},
"points": {
"type": "array",
"items": {
"type": "number",
"format": "double",
"minimum": 0
},
"minItems": 2,
"maxItems": 16,
"additionalItems": false
},
"rotation": {
"type": "number",
"format": "double",
"minimum": 0,
"maximum": 360,
"default": 0.0
},
"attributes": {
"type": "array",
"items": {}
},
"elements": {
"type": "array",
"items": {}
},
"id": {
"type": ["integer", "null"],
"nullable": true
},
"frame": {
"type": "integer",
"minimum": 0
},
"label_id": {
"type": "integer",
"minimum": 0
},
"group": {
"type": ["integer", "null"],
"minimum": 0,
"nullable": true
},
"source": {
"type": "string",
"default": "manual"
}
},
"required": ["type", "clientID", "occluded", "outside", "z_order", "points", "rotation", "attributes", "elements", "frame", "label_id", "group", "source"],
"allOf": [
{
"if": {
"properties": {
"type": {
"const": "rectangle"
}
}
},
"then": {
"properties": {
"points": {
"minItems": 4,
"maxItems": 4
}
}
}
},
{
"if": {
"properties": {
"type": {
"const": "polyline"
}
}
},
"then": {
"properties": {
"points": {
"minItems": 4,
"maxItems": 100
}
}
}
},
{
"if": {
"properties": {
"type": {
"const": "points"
}
}
},
"then": {
"properties": {
"points": {
"minItems": 2,
"maxItems": 2
}
}
}
},
{
"if": {
"properties": {
"type": {
"const": "ellipse"
}
}
},
"then": {
"properties": {
"points": {
"minItems": 4,
"maxItems": 4
}
}
}
},
{
"if": {
"properties": {
"type": {
"const": "cuboid"
}
}
},
"then": {
"properties": {
"points": {
"minItems": 16,
"maxItems": 16
}
}
}
}
]
}
},
"tags": {
"type": "array",
"items": {
"type": "object",
"properties": {
"clientID": {
"type": "integer",
"minimum": 1
},
"frame": {
"type": "integer",
"minimum": 0
},
"label_id": {
"type": "integer",
"minimum": 0
},
"source": {
"type": "string",
"default": "manual"
},
"group": {
"type": ["integer", "null"],
"minimum": 0,
"nullable": true
},
"attributes": {
"type": "array",
"items": {}
}
},
"required": ["clientID", "frame", "label_id", "source", "group", "attributes"]
}
}
},
"required": ["tracks", "shapes", "tags"]
}`

// 自定义验证函数,检查points数组元素个数是否为偶数
func validatePointsEvenCount(data map[string]interface{}) []string {
var errors []string

shapes, ok := data["shapes"].([]interface{})
if !ok {
return []string{"shapes字段格式不正确"}
}

for i, shape := range shapes {
shapeMap, ok := shape.(map[string]interface{})
if !ok {
errors = append(errors, fmt.Sprintf("shape %d 格式不正确", i))
continue
}

points, ok := shapeMap["points"].([]interface{})
if !ok {
errors = append(errors, fmt.Sprintf("shape %d 的points字段格式不正确", i))
continue
}

// 检查points数组元素个数是否为偶数
if len(points)%2 != 0 {
errors = append(errors, fmt.Sprintf("shape %d (类型: %s) 的points数组元素个数(%d)不是偶数",
i, shapeMap["type"], len(points)))
}

// 检查points数组元素值是否都大于等于0
for j, point := range points {
if num, ok := point.(float64); ok {
if num < 0 {
errors = append(errors, fmt.Sprintf("shape %d 的points数组第%d个元素(%f)小于0", i, j, num))
}
} else {
errors = append(errors, fmt.Sprintf("shape %d 的points数组第%d个元素不是数字", i, j))
}
}
}

return errors
}

// 自定义验证函数,检查clientID的唯一性
func validateClientIDUnique(data map[string]interface{}) []string {
var errors []string
clientIDs := make(map[float64]string) // 存储clientID和对应的位置信息

// 检查shapes中的clientID
shapes, ok := data["shapes"].([]interface{})
if ok {
for i, shape := range shapes {
shapeMap, ok := shape.(map[string]interface{})
if !ok {
continue
}

if clientID, exists := shapeMap["clientID"]; exists {
if id, ok := clientID.(float64); ok {
if existing, exists := clientIDs[id]; exists {
errors = append(errors, fmt.Sprintf("clientID %.0f 重复: 已在 %s 中使用,又在 shape %d 中使用",
id, existing, i))
} else {
clientIDs[id] = fmt.Sprintf("shape %d", i)
}
}
}
}
}

// 检查tags中的clientID
tags, ok := data["tags"].([]interface{})
if ok {
for i, tag := range tags {
tagMap, ok := tag.(map[string]interface{})
if !ok {
continue
}

if clientID, exists := tagMap["clientID"]; exists {
if id, ok := clientID.(float64); ok {
if existing, exists := clientIDs[id]; exists {
errors = append(errors, fmt.Sprintf("clientID %.0f 重复: 已在 %s 中使用,又在 tag %d 中使用",
id, existing, i))
} else {
clientIDs[id] = fmt.Sprintf("tag %d", i)
}
}
}
}
}

return errors
}

func main() {
// 要验证的JSON数据
documentStr := `{
"tracks": [],
"shapes": [
{
"type": "rectangle",
"clientID": 1,
"occluded": false,
"outside": false,
"z_order": 0,
"points": [
345.173473783545,
26.862406965095943,
396.98014047491415,
29.58907363306207
],
"rotation": 0,
"attributes": [],
"elements": [],
"id": null,
"frame": 0,
"label_id": 1,
"group": 0,
"source": "manual"
},
{
"type": "rectangle",
"clientID": 2,
"occluded": false,
"outside": false,
"z_order": 0,
"points": [
351.7177734375,
46.494140625,
392.072265625,
83.5771484375
],
"rotation": 0,
"attributes": [],
"elements": [],
"id": 1001,
"frame": 0,
"label_id": 1,
"group": 0,
"source": "manual"
},
{
"type": "polyline",
"clientID": 3,
"occluded": false,
"outside": false,
"z_order": 0,
"points": [
364.8056640625,
120.1142578125,
373.5308071303989,
232.9984070633891
],
"rotation": 0,
"attributes": [],
"elements": [],
"id": 1002,
"frame": 0,
"label_id": 1,
"group": 0,
"source": "manual"
},
{
"type": "points",
"clientID": 4,
"occluded": false,
"outside": false,
"z_order": 0,
"points": [
313.5439453125,
21.9541015625
],
"rotation": 0,
"attributes": [],
"elements": [],
"id": null,
"frame": 0,
"label_id": 2,
"group": 0,
"source": "manual"
},
{
"type": "ellipse",
"clientID": 5,
"occluded": false,
"outside": false,
"z_order": 0,
"points": [
313.56689453125,
96.69189453125,
321.7470703125,
62.880859375
],
"rotation": 0,
"attributes": [],
"elements": [],
"id": 1003,
"frame": 0,
"label_id": 2,
"group": null,
"source": "manual"
},
{
"type": "cuboid",
"clientID": 6,
"occluded": false,
"outside": false,
"z_order": 0,
"points": [
281.3694737531205,
188.38107370873294,
281.3694737531205,
256.99307374149794,
323.3601404398105,
188.28107370873295,
323.3601404398105,
256.99307374149794,
327.5592071084795,
181.50987370545644,
327.5592071084795,
250.02187373822144,
285.56854042178946,
181.50987370545644,
285.56854042178946,
250.02187373822144
],
"rotation": 0,
"attributes": [],
"elements": [],
"id": 1004,
"frame": 0,
"label_id": 2,
"group": 1,
"source": "manual"
}
],
"tags": [
{
"clientID": 7,
"frame": 0,
"label_id": 1,
"source": "manual",
"group": 0,
"attributes": []
}
]
}`

// 创建schema加载器
schemaLoader := gojsonschema.NewStringLoader(schemaStr)

// 创建文档加载器
documentLoader := gojsonschema.NewStringLoader(documentStr)

// 验证文档是否符合schema
result, err := gojsonschema.Validate(schemaLoader, documentLoader)
if err != nil {
log.Fatalf("验证过程中出错: %v", err)
}

// 输出验证结果
valid := result.Valid()
if valid {
fmt.Println("文档符合JSON Schema!")
} else {
fmt.Println("文档不符合JSON Schema,错误如下:")
for _, desc := range result.Errors() {
fmt.Printf("- %s\n", desc)
}
}

// 将JSON字符串解析为Go结构体以进行自定义验证
var data map[string]interface{}
if err := json.Unmarshal([]byte(documentStr), &data); err != nil {
log.Fatalf("解析JSON失败: %v", err)
}
fmt.Println("JSON解析成功!")

// 执行自定义验证 - points验证
pointsErrors := validatePointsEvenCount(data)
if len(pointsErrors) > 0 {
fmt.Println("Points验证发现错误:")
for _, err := range pointsErrors {
fmt.Printf("- %s\n", err)
}
valid = false
}

// 执行自定义验证 - clientID唯一性验证
clientIDErrors := validateClientIDUnique(data)
if len(clientIDErrors) > 0 {
fmt.Println("ClientID唯一性验证发现错误:")
for _, err := range clientIDErrors {
fmt.Printf("- %s\n", err)
}
valid = false
}

// 最终验证结果
if valid {
fmt.Println("所有验证通过!")
} else {
fmt.Println("验证未通过!")
}

// 测试clientID重复的情况
fmt.Println("\n--- 测试clientID重复的情况 ---")
testDuplicateClientID()
}

// 测试函数:验证clientID重复的情况
func testDuplicateClientID() {
testDataStr := `{
"tracks": [],
"shapes": [
{
"type": "rectangle",
"clientID": 1,
"occluded": false,
"outside": false,
"z_order": 0,
"points": [100, 100, 200, 200],
"rotation": 0,
"attributes": [],
"elements": [],
"id": null,
"frame": 0,
"label_id": 1,
"group": 0,
"source": "manual"
},
{
"type": "rectangle",
"clientID": 1, // 重复的clientID
"occluded": false,
"outside": false,
"z_order": 0,
"points": [300, 300, 400, 400],
"rotation": 0,
"attributes": [],
"elements": [],
"id": null,
"frame": 0,
"label_id": 1,
"group": 0,
"source": "manual"
}
],
"tags": []
}`

var testData map[string]interface{}
if err := json.Unmarshal([]byte(testDataStr), &testData); err != nil {
log.Fatalf("解析测试JSON失败: %v", err)
}

errors := validateClientIDUnique(testData)
if len(errors) > 0 {
fmt.Println("ClientID重复测试 - 发现错误:")
for _, err := range errors {
fmt.Printf("- %s\n", err)
}
} else {
fmt.Println("ClientID重复测试 - 未发现错误 (这不应该发生)")
}
}

主要增强内容

  1. clientID 唯一性约束

    • 在 JSON Schema 中添加了 minimum: 1 约束,确保 clientID 为正整数
    • 添加了 validateClientIDUnique 自定义验证函数,检查所有 shapes 和 tags 中的 clientID 是否唯一
    • 该函数会记录重复的 clientID 及其位置信息
  2. 增强的错误报告

    • 改进了错误消息,明确指出哪个 clientID 重复以及在哪个位置
    • 区分了 shapes 和 tags 中的重复情况
  3. 测试用例

    • 添加了 testDuplicateClientID 函数,用于测试 clientID 重复的情况
    • 这有助于验证自定义验证函数的正确性
  4. 结构化验证流程

    • 将验证流程分为三个部分:
      1. JSON Schema 验证
      2. Points 相关验证(偶数个元素、非负值)
      3. ClientID 唯一性验证

使用说明

  1. 首先安装所需的Go依赖:

    go get github.com/xeipuuv/gojsonschema
  2. 将上述代码保存为validate.go文件

  3. 运行程序:

    go run validate.go
  4. 程序将输出验证结果,包括:

    • JSON Schema 验证结果
    • Points 验证结果(偶数个元素、非负值)
    • ClientID 唯一性验证结果
    • ClientID 重复测试结果

注意事项

  • 示例JSON数据中的 clientID 已经确保唯一(1-7)
  • 测试函数中特意创建了一个重复的 clientID 用于验证
  • 所有验证错误都会以清晰的方式报告,包括重复的 clientID 值和位置信息
  • clientID 现在必须是正整数(最小值1)

使用 Java 实现 JSON Schema 校验

下面我将展示如何使用 Java 实现与之前 Go 代码相同的 JSON Schema 校验功能,包括对 points 属性的约束和 clientID 唯一性检查。

添加 Maven 依赖

首先,在 pom.xml 中添加必要的依赖:

<dependencies>
<!-- JSON Schema 验证 -->
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>1.0.72</version>
</dependency>

<!-- JSON 处理 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.14.2</version>
</dependency>

<!-- 测试框架 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
</dependencies>

JSON Schema 文件

创建 schema.json 文件放在资源目录下 (src/main/resources/schema.json):

{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"tracks": {
"type": "array",
"items": {}
},
"shapes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["rectangle", "polyline", "points", "ellipse", "cuboid"]
},
"clientID": {
"type": "integer",
"minimum": 1
},
"occluded": {
"type": "boolean",
"default": false
},
"outside": {
"type": "boolean",
"default": false
},
"z_order": {
"type": "integer",
"default": 0
},
"points": {
"type": "array",
"items": {
"type": "number",
"minimum": 0
},
"minItems": 2,
"maxItems": 16
},
"rotation": {
"type": "number",
"minimum": 0,
"maximum": 360,
"default": 0.0
},
"attributes": {
"type": "array",
"items": {}
},
"elements": {
"type": "array",
"items": {}
},
"id": {
"type": ["integer", "null"]
},
"frame": {
"type": "integer",
"minimum": 0
},
"label_id": {
"type": "integer",
"minimum": 0
},
"group": {
"type": ["integer", "null"],
"minimum": 0
},
"source": {
"type": "string",
"default": "manual"
}
},
"required": ["type", "clientID", "occluded", "outside", "z_order", "points", "rotation", "attributes", "elements", "frame", "label_id", "group", "source"]
}
},
"tags": {
"type": "array",
"items": {
"type": "object",
"properties": {
"clientID": {
"type": "integer",
"minimum": 1
},
"frame": {
"type": "integer",
"minimum": 0
},
"label_id": {
"type": "integer",
"minimum": 0
},
"source": {
"type": "string",
"default": "manual"
},
"group": {
"type": ["integer", "null"],
"minimum": 0
},
"attributes": {
"type": "array",
"items": {}
}
},
"required": ["clientID", "frame", "label_id", "source", "group", "attributes"]
}
}
},
"required": ["tracks", "shapes", "tags"]
}

Java 实现代码

创建 JsonValidator.java 类:

package com.example.validator;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.*;

import java.io.InputStream;
import java.util.*;
import java.util.stream.Collectors;

public class JsonValidator {

private final JsonSchema schema;
private final ObjectMapper mapper;

public JsonValidator() throws Exception {
// 初始化 ObjectMapper
mapper = new ObjectMapper();

// 加载 JSON Schema
InputStream schemaStream = getClass().getClassLoader().getResourceAsStream("schema.json");
if (schemaStream == null) {
throw new RuntimeException("Schema file not found");
}

JsonNode schemaNode = mapper.readTree(schemaStream);
SchemaValidatorsConfig config = new SchemaValidatorsConfig();

// 配置 JSON Schema 工厂
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
schema = factory.getSchema(schemaNode, config);
}

/**
* 验证 JSON 字符串是否符合 Schema
*/
public Set<ValidationMessage> validateSchema(String jsonString) throws Exception {
JsonNode jsonNode = mapper.readTree(jsonString);
return schema.validate(jsonNode);
}

/**
* 自定义验证:检查 points 数组元素个数是否为偶数,且值 >= 0
*/
public List<String> validatePoints(String jsonString) throws Exception {
List<String> errors = new ArrayList<>();
JsonNode rootNode = mapper.readTree(jsonString);
JsonNode shapesNode = rootNode.get("shapes");

if (shapesNode == null || !shapesNode.isArray()) {
errors.add("shapes字段不存在或不是数组");
return errors;
}

for (int i = 0; i < shapesNode.size(); i++) {
JsonNode shapeNode = shapesNode.get(i);
JsonNode pointsNode = shapeNode.get("points");
JsonNode typeNode = shapeNode.get("type");

if (pointsNode == null || !pointsNode.isArray()) {
errors.add(String.format("shape %d 的points字段不存在或不是数组", i));
continue;
}

String type = typeNode != null ? typeNode.asText() : "unknown";

// 检查points数组元素个数是否为偶数
if (pointsNode.size() % 2 != 0) {
errors.add(String.format("shape %d (类型: %s) 的points数组元素个数(%d)不是偶数",
i, type, pointsNode.size()));
}

// 检查points数组元素值是否都大于等于0
for (int j = 0; j < pointsNode.size(); j++) {
JsonNode pointNode = pointsNode.get(j);
if (pointNode.isNumber()) {
double value = pointNode.asDouble();
if (value < 0) {
errors.add(String.format("shape %d 的points数组第%d个元素(%f)小于0", i, j, value));
}
} else {
errors.add(String.format("shape %d 的points数组第%d个元素不是数字", i, j));
}
}
}

return errors;
}

/**
* 自定义验证:检查 clientID 的唯一性
*/
public List<String> validateClientIdUnique(String jsonString) throws Exception {
List<String> errors = new ArrayList<>();
JsonNode rootNode = mapper.readTree(jsonString);
Map<Integer, String> clientIdMap = new HashMap<>();

// 检查 shapes 中的 clientID
JsonNode shapesNode = rootNode.get("shapes");
if (shapesNode != null && shapesNode.isArray()) {
for (int i = 0; i < shapesNode.size(); i++) {
JsonNode shapeNode = shapesNode.get(i);
JsonNode clientIdNode = shapeNode.get("clientID");

if (clientIdNode != null && clientIdNode.isInt()) {
int clientId = clientIdNode.asInt();
if (clientIdMap.containsKey(clientId)) {
errors.add(String.format("clientID %d 重复: 已在 %s 中使用,又在 shape %d 中使用",
clientId, clientIdMap.get(clientId), i));
} else {
clientIdMap.put(clientId, String.format("shape %d", i));
}
}
}
}

// 检查 tags 中的 clientID
JsonNode tagsNode = rootNode.get("tags");
if (tagsNode != null && tagsNode.isArray()) {
for (int i = 0; i < tagsNode.size(); i++) {
JsonNode tagNode = tagsNode.get(i);
JsonNode clientIdNode = tagNode.get("clientID");

if (clientIdNode != null && clientIdNode.isInt()) {
int clientId = clientIdNode.asInt();
if (clientIdMap.containsKey(clientId)) {
errors.add(String.format("clientID %d 重复: 已在 %s 中使用,又在 tag %d 中使用",
clientId, clientIdMap.get(clientId), i));
} else {
clientIdMap.put(clientId, String.format("tag %d", i));
}
}
}
}

return errors;
}

/**
* 综合验证方法
*/
public ValidationResult validate(String jsonString) throws Exception {
ValidationResult result = new ValidationResult();

// 1. 验证 JSON Schema
Set<ValidationMessage> schemaErrors = validateSchema(jsonString);
if (!schemaErrors.isEmpty()) {
result.addSchemaErrors(schemaErrors);
}

// 2. 验证 points
List<String> pointsErrors = validatePoints(jsonString);
if (!pointsErrors.isEmpty()) {
result.addCustomErrors("Points验证错误", pointsErrors);
}

// 3. 验证 clientID 唯一性
List<String> clientIdErrors = validateClientIdUnique(jsonString);
if (!clientIdErrors.isEmpty()) {
result.addCustomErrors("ClientID唯一性验证错误", clientIdErrors);
}

return result;
}

/**
* 验证结果类
*/
public static class ValidationResult {
private List<String> schemaErrors = new ArrayList<>();
private Map<String, List<String>> customErrors = new HashMap<>();
private boolean isValid = true;

public void addSchemaErrors(Set<ValidationMessage> errors) {
this.isValid = false;
this.schemaErrors.addAll(errors.stream()
.map(ValidationMessage::getMessage)
.collect(Collectors.toList()));
}

public void addCustomErrors(String category, List<String> errors) {
this.isValid = false;
this.customErrors.put(category, errors);
}

public boolean isValid() {
return isValid;
}

public List<String> getSchemaErrors() {
return schemaErrors;
}

public Map<String, List<String>> getCustomErrors() {
return customErrors;
}

public void printResults() {
if (isValid()) {
System.out.println("所有验证通过!");
return;
}

if (!schemaErrors.isEmpty()) {
System.out.println("JSON Schema验证错误:");
for (String error : schemaErrors) {
System.out.println("- " + error);
}
}

for (Map.Entry<String, List<String>> entry : customErrors.entrySet()) {
System.out.println(entry.getKey() + ":");
for (String error : entry.getValue()) {
System.out.println("- " + error);
}
}
}
}

/**
* 测试方法
*/
public static void main(String[] args) {
try {
JsonValidator validator = new JsonValidator();

// 测试数据
String testJson = "{\n" +
" \"tracks\": [],\n" +
" \"shapes\": [\n" +
" {\n" +
" \"type\": \"rectangle\",\n" +
" \"clientID\": 1,\n" +
" \"occluded\": false,\n" +
" \"outside\": false,\n" +
" \"z_order\": 0,\n" +
" \"points\": [\n" +
" 345.173473783545,\n" +
" 26.862406965095943,\n" +
" 396.98014047491415,\n" +
" 29.58907363306207\n" +
" ],\n" +
" \"rotation\": 0,\n" +
" \"attributes\": [],\n" +
" \"elements\": [],\n" +
" \"id\": null,\n" +
" \"frame\": 0,\n" +
" \"label_id\": 1,\n" +
" \"group\": 0,\n" +
" \"source\": \"manual\"\n" +
" },\n" +
" {\n" +
" \"type\": \"rectangle\",\n" +
" \"clientID\": 2,\n" +
" \"occluded\": false,\n" +
" \"outside\": false,\n" +
" \"z_order\": 0,\n" +
" \"points\": [\n" +
" 351.7177734375,\n" +
" 46.494140625,\n" +
" 392.072265625,\n" +
" 83.5771484375\n" +
" ],\n" +
" \"rotation\": 0,\n" +
" \"attributes\": [],\n" +
" \"elements\": [],\n" +
" \"id\": 1001,\n" +
" \"frame\": 0,\n" +
" \"label_id\": 1,\n" +
" \"group\": 0,\n" +
" \"source\": \"manual\"\n" +
" },\n" +
" {\n" +
" \"type\": \"polyline\",\n" +
" \"clientID\": 3,\n" +
" \"occluded\": false,\n" +
" \"outside\": false,\n" +
" \"z_order\": 0,\n" +
" \"points\": [\n" +
" 364.8056640625,\n" +
" 120.1142578125,\n" +
" 373.5308071303989,\n" +
" 232.9984070633891\n" +
" ],\n" +
" \"rotation\": 0,\n" +
" \"attributes\": [],\n" +
" \"elements\": [],\n" +
" \"id\": 1002,\n" +
" \"frame\": 0,\n" +
" \"label_id\": 1,\n" +
" \"group\": 0,\n" +
" \"source\": \"manual\"\n" +
" },\n" +
" {\n" +
" \"type\": \"points\",\n" +
" \"clientID\": 4,\n" +
" \"occluded\": false,\n" +
" \"outside\": false,\n" +
" \"z_order\": 0,\n" +
" \"points\": [\n" +
" 313.5439453125,\n" +
" 21.9541015625\n" +
" ],\n" +
" \"rotation\": 0,\n" +
" \"attributes\": [],\n" +
" \"elements\": [],\n" +
" \"id\": null,\n" +
" \"frame\": 0,\n" +
" \"label_id\": 2,\n" +
" \"group\": 0,\n" +
" \"source\": \"manual\"\n" +
" },\n" +
" {\n" +
" \"type\": \"ellipse\",\n" +
" \"clientID\": 5,\n" +
" \"occluded\": false,\n" +
" \"outside\": false,\n" +
" \"z_order\": 0,\n" +
" \"points\": [\n" +
" 313.56689453125,\n" +
" 96.69189453125,\n" +
" 321.7470703125,\n" +
" 62.880859375\n" +
" ],\n" +
" \"rotation\": 0,\n" +
" \"attributes\": [],\n" +
" \"elements\": [],\n" +
" \"id\": 1003,\n" +
" \"frame\": 0,\n" +
" \"label_id\": 2,\n" +
" \"group\": null,\n" +
" \"source\": \"manual\"\n" +
" },\n" +
" {\n" +
" \"type\": \"cuboid\",\n" +
" \"clientID\": 6,\n" +
" \"occluded\": false,\n" +
" \"outside\": false,\n" +
" \"z_order\": 0,\n" +
" \"points\": [\n" +
" 281.3694737531205,\n" +
" 188.38107370873294,\n" +
" 281.3694737531205,\n" +
" 256.99307374149794,\n" +
" 323.3601404398105,\n" +
" 188.28107370873295,\n" +
" 323.3601404398105,\n" +
" 256.99307374149794,\n" +
" 327.5592071084795,\n" +
" 181.50987370545644,\n" +
" 327.5592071084795,\n" +
" 250.02187373822144,\n" +
" 285.56854042178946,\n" +
" 181.50987370545644,\n" +
" 285.56854042178946,\n" +
" 250.02187373822144\n" +
" ],\n" +
" \"rotation\": 0,\n" +
" \"attributes\": [],\n" +
" \"elements\": [],\n" +
" \"id\": 1004,\n" +
" \"frame\": 0,\n" +
" \"label_id\": 2,\n" +
" \"group\": 1,\n" +
" \"source\": \"manual\"\n" +
" }\n" +
" ],\n" +
" \"tags\": [\n" +
" {\n" +
" \"clientID\": 7,\n" +
" \"frame\": 0,\n" +
" \"label_id\": 1,\n" +
" \"source\": \"manual\",\n" +
" \"group\": 0,\n" +
" \"attributes\": []\n" +
" }\n" +
" ]\n" +
"}";

// 执行验证
ValidationResult result = validator.validate(testJson);

// 输出结果
result.printResults();

// 测试重复 clientID 的情况
System.out.println("\n--- 测试重复 clientID 的情况 ---");
String duplicateClientIdJson = "{\n" +
" \"tracks\": [],\n" +
" \"shapes\": [\n" +
" {\n" +
" \"type\": \"rectangle\",\n" +
" \"clientID\": 1,\n" +
" \"occluded\": false,\n" +
" \"outside\": false,\n" +
" \"z_order\": 0,\n" +
" \"points\": [100, 100, 200, 200],\n" +
" \"rotation\": 0,\n" +
" \"attributes\": [],\n" +
" \"elements\": [],\n" +
" \"id\": null,\n" +
" \"frame\": 0,\n" +
" \"label_id\": 1,\n" +
" \"group\": 0,\n" +
" \"source\": \"manual\"\n" +
" },\n" +
" {\n" +
" \"type\": \"rectangle\",\n" +
" \"clientID\": 1,\n" +
" \"occluded\": false,\n" +
" \"outside\": false,\n" +
" \"z_order\": 0,\n" +
" \"points\": [300, 300, 400, 400],\n" +
" \"rotation\": 0,\n" +
" \"attributes\": [],\n" +
" \"elements\": [],\n" +
" \"id\": null,\n" +
" \"frame\": 0,\n" +
" \"label_id\": 1,\n" +
" \"group\": 0,\n" +
" \"source\": \"manual\"\n" +
" }\n" +
" ],\n" +
" \"tags\": []\n" +
"}";

ValidationResult duplicateResult = validator.validate(duplicateClientIdJson);
duplicateResult.printResults();

} catch (Exception e) {
e.printStackTrace();
}
}
}

单元测试

创建 JsonValidatorTest.java 测试类:

package com.example.validator;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class JsonValidatorTest {

private JsonValidator validator;

@BeforeEach
void setUp() throws Exception {
validator = new JsonValidator();
}

@Test
void testValidJson() throws Exception {
String validJson = "{\n" +
" \"tracks\": [],\n" +
" \"shapes\": [\n" +
" {\n" +
" \"type\": \"rectangle\",\n" +
" \"clientID\": 1,\n" +
" \"occluded\": false,\n" +
" \"outside\": false,\n" +
" \"z_order\": 0,\n" +
" \"points\": [100, 100, 200, 200],\n" +
" \"rotation\": 0,\n" +
" \"attributes\": [],\n" +
" \"elements\": [],\n" +
" \"id\": null,\n" +
" \"frame\": 0,\n" +
" \"label_id\": 1,\n" +
" \"group\": 0,\n" +
" \"source\": \"manual\"\n" +
" }\n" +
" ],\n" +
" \"tags\": [\n" +
" {\n" +
" \"clientID\": 2,\n" +
" \"frame\": 0,\n" +
" \"label_id\": 1,\n" +
" \"source\": \"manual\",\n" +
" \"group\": 0,\n" +
" \"attributes\": []\n" +
" }\n" +
" ]\n" +
"}";

JsonValidator.ValidationResult result = validator.validate(validJson);
assertTrue(result.isValid(), "有效JSON应该通过验证");
}

@Test
void testInvalidPointsCount() throws Exception {
String invalidJson = "{\n" +
" \"tracks\": [],\n" +
" \"shapes\": [\n" +
" {\n" +
" \"type\": \"rectangle\",\n" +
" \"clientID\": 1,\n" +
" \"occluded\": false,\n" +
" \"outside\": false,\n" +
" \"z_order\": 0,\n" +
" \"points\": [100, 100, 200],\n" +
" \"rotation\": 0,\n" +
" \"attributes\": [],\n" +
" \"elements\": [],\n" +
" \"id\": null,\n" +
" \"frame\": 0,\n" +
" \"label_id\": 1,\n" +
" \"group\": 0,\n" +
" \"source\": \"manual\"\n" +
" }\n" +
" ],\n" +
" \"tags\": []\n" +
"}";

JsonValidator.ValidationResult result = validator.validate(invalidJson);
assertFalse(result.isValid(), "points数组元素个数为奇数应该失败");
assertTrue(result.getCustomErrors().containsKey("Points验证错误"));
}

@Test
void testNegativePoints() throws Exception {
String invalidJson = "{\n" +
" \"tracks\": [],\n" +
" \"shapes\": [\n" +
" {\n" +
" \"type\": \"rectangle\",\n" +
" \"clientID\": 1,\n" +
" \"occluded\": false,\n" +
" \"outside\": false,\n" +
" \"z_order\": 0,\n" +
" \"points\": [100, 100, 200, -50],\n" +
" \"rotation\": 0,\n" +
" \"attributes\": [],\n" +
" \"elements\": [],\n" +
" \"id\": null,\n" +
" \"frame\": 0,\n" +
" \"label_id\": 1,\n" +
" \"group\": 0,\n" +
" \"source\": \"manual\"\n" +
" }\n" +
" ],\n" +
" \"tags\": []\n" +
"}";

JsonValidator.ValidationResult result = validator.validate(invalidJson);
assertFalse(result.isValid(), "points数组包含负值应该失败");
assertTrue(result.getCustomErrors().containsKey("Points验证错误"));
}

@Test
void testDuplicateClientId() throws Exception {
String invalidJson = "{\n" +
" \"tracks\": [],\n" +
" \"shapes\": [\n" +
" {\n" +
" \"type\": \"rectangle\",\n" +
" \"clientID\": 1,\n" +
" \"occluded\": false,\n" +
" \"outside\": false,\n" +
" \"z_order\": 0,\n" +
" \"points\": [100, 100, 200, 200],\n" +
" \"rotation\": 0,\n" +
" \"attributes\": [],\n" +
" \"elements\": [],\n" +
" \"id\": null,\n" +
" \"frame\": 0,\n" +
" \"label_id\": 1,\n" +
" \"group\": 0,\n" +
" \"source\": \"manual\"\n" +
" },\n" +
" {\n" +
" \"type\": \"rectangle\",\n" +
" \"clientID\": 1,\n" +
" \"occluded\": false,\n" +
" \"outside\": false,\n" +
" \"z_order\": 0,\n" +
" \"points\": [300, 300, 400, 400],\n" +
" \"rotation\": 0,\n" +
" \"attributes\": [],\n" +
" \"elements\": [],\n" +
" \"id\": null,\n" +
" \"frame\": 0,\n" +
" \"label_id\": 1,\n" +
" \"group\": 0,\n" +
" \"source\": \"manual\"\n" +
" }\n" +
" ],\n" +
" \"tags\": []\n" +
"}";

JsonValidator.ValidationResult result = validator.validate(invalidJson);
assertFalse(result.isValid(), "重复的clientID应该失败");
assertTrue(result.getCustomErrors().containsKey("ClientID唯一性验证错误"));
}
}

使用说明

  1. 项目设置

    • 创建一个 Maven 项目
    • 将上述代码添加到相应的包中
    • 将 JSON Schema 文件放在 src/main/resources/schema.json
  2. 运行验证

    • 可以直接运行 JsonValidator 类的 main 方法进行测试
    • 也可以运行单元测试来验证各种情况
  3. API 使用

    JsonValidator validator = new JsonValidator();
    String jsonString = "..."; // 你的 JSON 字符串
    JsonValidator.ValidationResult result = validator.validate(jsonString);

    if (result.isValid()) {
    System.out.println("验证通过");
    } else {
    result.printResults(); // 打印所有错误信息
    }

功能说明

这个 Java 实现提供了与之前 Go 代码相同的功能:

  1. JSON Schema 验证:使用 NetworkNT 的 JSON Schema 验证器验证基本结构和类型
  2. Points 验证
    • 检查 points 数组元素个数是否为偶数
    • 检查 points 数组元素值是否都大于等于 0
  3. ClientID 唯一性验证:检查所有 shapes 和 tags 中的 clientID 是否唯一
  4. 综合验证:提供统一的验证接口,返回详细的验证结果

这个实现能够有效验证您的 JSON 数据是否符合所有预期的约束条件,确保数据的一致性和完整性。


参考文档