简单易懂的 JSON Schema

700 阅读9分钟

前言

大多数人(包括我)之前肯定都没有听说过这样一个名词,JSON Schema,可能平时在开发中也没有使用过类似的东西

然而它其实早就默默应用在软件开发的各个方面中,最贴近我们前端的就是在 VS Code 中的配置项,就是通过 JSON Schema 定义的

那么,到底它是什么,为什么要使用它,应该怎么使用它(哲学三大拷问),接下来,我就简单的带大家了解一下

什么是 JSON Schema

大家都知道,JSON 是一种十分灵活的数据格式,然而,太过于灵活也就导致它用起来毫无章法,巨大的灵活性伴随着巨大的责任,因为同一个概念可以以多种方式表示。

例如:我们可以使用两种不同的 JSON 表达式表达一个人的基本信息

// 基本信息一
{
  "name": "George Washington",
  "birthday": "February 22, 1732",
  "address": "Mount Vernon, Virginia, United States"
}
// 基本信息二
{
  "first_name": "George",
  "last_name": "Washington",
  "birthday": "1732-02-22",
  "address": {
    "street_address": "3200 Mount Vernon Memorial Highway",
    "city": "Mount Vernon",
    "state": "Virginia",
    "country": "United States"
  }
}

显然,第二种要比第一种更加规整、正式,这里不是说孰对孰错,但是在某些场合下,我们需要对提供的 JSON 数据做一个定义,需要告诉别人哪些字段应该表示什么意思,防止对方做出不可预期的赋值,最简单的例子就是:通过 JSON 去做配置

在此场景下,JSON Schema 诞生了。

Schema 的含义

JSON Schema 是由两个单词组成的,JSON 大家都很熟悉了,而 Schema 在很多行业都有特指的术语,比如在数据库中,叫架构或者模式,而在这里,我认为和 XML Schema 类似: 是元数据的一个抽象集合,包含一套schema component: 主要是元素与属性的声明、复杂与简单数据类型的定义

以下 JSON Schema 片段描述了上面第二个示例的结构

{
  "type": "object",
  "properties": {
    "first_name": { "type": "string" },
    "last_name": { "type": "string" },
    "birthday": { "type": "string", "format": "date" },
    "address": {
      "type": "object",
      "properties": {
        "street_address": { "type": "string" },
        "city": { "type": "string" },
        "state": { "type": "string" },
        "country": { "type" : "string" }
      }
    }
  }
}

显然,JSON Schema 由 JSON 表达,它是用于“描述其他数据结构”的声明性格式,或者你也可以理解为通过 JSON 的定义,表达出了一种新的数据结构

JSON Schema 规范

以下的规范要么对一个关键字做解释,要么对一组行为做解释

声明数据类型

通过关键字 type 定义,type 是 JSON Schema 的基础,type 的值可以是一个字符串或字符串数组,可选的类型如下:

  • string
  • number
  • integer
  • object
  • array
  • boolean
  • null

例子:

// 表示该字段为一个数字
{ type: number }

// 表示该字段为字符串或数字
{ "type": ["number", "string"] }

string 类型的其他字段

以下字段在 type: string 时才有用

minLength

最小长度,值为非负整数

maxLength

最大长度,值为非负整数

pattern

正则表达式,该正则语法是由 JavaScript 定义的,以下字段匹配一个简单的北美电话

{
   "type": "string",
   "pattern": "^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$"
}

format

format 允许对常用的某些类型的字符串值进行基本语义验证,以下是部分规范中的指定格式,全部规范在这里

  • date-time 日期时间

  • date 日期

  • time 时间

  • email 邮件

  • hostname internet 主机名

  • ipv4 ipv4 地址

  • uri 通用资源标识符

  • regex 正则表达式

    这里和 pattern 不一样,pattern 指的是这个字段验证方式,字段本身是一个普通字符串,而 regex 指的是这个字段应该是一个正则

number 类型的其他字段

以下字段在 type: number 时才有用

multipleOf

将数字限制为给定数字的倍数,值为任何正数

minimum

最小值,值为非负整数

maximum

最大值,值为非负整数

object 类型的其他字段

以下字段在 type: object 时才有用

properties

对象的属性,值为一个对象,其中每个键是属性的名称,每个值是用于验证该属性的模式

例如:我们定义一个简单的地址对象如下

{
  "type": "object",
  "properties": {
    "number": { "type": "number" },
    "street_name": { "type": "string" },
    "street_type": { "enum": ["Street", "Avenue", "Boulevard"] }
  }
}

// 该地址符合定义
{ "number": 1600, "street_name": "Pennsylvania", "street_type": "Avenue" }

patternProperties

对象的属性,该值符合特定的正则表达式,例如:

// 以 S 开头的属性,为 string 类型
// 以 I 开头的属性,为 integer 类型
{
  "type": "object",
  "patternProperties": {
    "^S_": { "type": "string" },
    "^I_": { "type": "integer" }
  }
}

// OK
{ "S_25": "This is a string" }

// not OK
{ "I_42": "This is a string" }

additionalProperties

是否允许额外的属性,也就是除了规定的属性外,是否允许自定义其他属性,例子如下:

{
  "type": "object",
  "properties": {
    "number": { "type": "number" },
    "street_name": { "type": "string" },
    "street_type": { "enum": ["Street", "Avenue", "Boulevard"] }
  },
  "additionalProperties": false
}

// not OK,额外属性“direction”使对象无效
{ "number": 1600, "street_name": "Pennsylvania", "street_type": "Avenue", "direction": "NW" }

另一种 additionalProperties 使用方式,允许你更详细的规定额外的属性

{
  "type": "object",
  "properties": {
    "number": { "type": "number" },
    "street_name": { "type": "string" },
    "street_type": { "enum": ["Street", "Avenue", "Boulevard"] }
  },
  "additionalProperties": { "type": "string" }
}

required

定义所必须的对象属性,值为字符串数组,例如:

{
  "type": "object",
  "properties": {
    "name": { "type": "string" },
    "email": { "type": "string" },
    "address": { "type": "string" },
    "telephone": { "type": "string" }
  },
  "required": ["name", "email"]
}

propertyNames

如果有一些列属性名遵循特定约定,可使用该字段定义

// 规定属性名开头为字母
{
  "type": "object",
  "propertyNames": {
    "pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
  }
}

// OK
{
  "_a_proper_token_001": "value"
}
 // not OK
{
  "001 invalid": "value"
}

minProperties

属性数量最小值,值为非负整数

maxProperties

属性数量最大值,值为非负整数

array 类型的其他字段

以下字段在 type: array 时才有用

items

定义数组中的每个字段,该值和 JSON Schema 规范的定义是一致的

在下面的例子中,我们定义数组中的每一项都是一个数字:

{
  "type": "array",
  "items": {
    "type": "number"
  }
}

[1, 2, 3, 4, 5] // OK
[1, 2, "3", 4, 5]  // not OK,单个“非数字”会导致整个数组无效

也可以分别定义数组中的每个元素

{
  "type": "array",
  "items": [
    { "type": "number" },
    { "type": "string" },
    { "enum": ["Street", "Avenue", "Boulevard"] },
    { "enum": ["NW", "NE", "SW", "SE"] }
  ]
}

[1600, "Pennsylvania", "Avenue", "NW"] // OK
[24, "Sussex", "Drive"]  // not OK,“Drive”不是可接受的街道类型之一

additionalItems

定义附加元素,同对象的 additionalProperties 字段类似

contains

定义数组的元素必须包含的类型,下面的例子定义数组必须包含数字:

{
   "type": "array",
   "contains": {
     "type": "number"
   }
}

minItems

数组长度最小值,值为非负整数

maxItems

数组长度最大值,值为非负整数

uniqueItems

限制每个元素是唯一的,值为 true | false

enum

该关键字用于将值限制为一组固定的值。它必须是一个包含至少一个元素的数组,其中每个元素都是唯一的,你可以类比他的值就是下拉框中的 options

例如:某个字段是字符串,它的值为几个颜色中的一个

{
  "type": string,
  "enum": ["red", "amber", "green"]
}

"red" // OK
"blue"  // not OK

const

定义一个字段的值为一个常量

contentMediaType

定义该字段为的MIME类型,例如:mp4,mp3 等等,具体的 MIME 类型可参考

contentEncoding

定义该字段的编码类型,例如:binary, base64

Schema 组合

组合适用于多个字段相关的验证,例如:某个字段即可以是数字,也可以是字符串,那么就需要多个定义组合起来验证

allOf

字段对于所有 Schema 生效,也就是 &&

// 字段是字符串,长度也不超过 5
{
  "allOf": [
    { "type": "string" },
    { "maxLength": 5 }
  ]
}

anyOf

字段满足任意一个或多个给定 Schema,也就是 ||

oneOf

字段满足一个 Schema 就可以

not

字段不能满足给定的 Schema,也就是 !

模式依赖

这种适用于多个字段关联的验证

dependentRequired

必要依赖,如果一个对象存在某个特定的属性,则另一个属性也必须存在,例如:

// 有 credit_card 字段时,必须有 billing_address 字段
{
  "type": "object",

  "properties": {
    "name": { "type": "string" },
    "credit_card": { "type": "number" },
    "billing_address": { "type": "string" }
  },

  "required": ["name"],

  "dependentRequired": {
    "credit_card": ["billing_address"]
  }
}

// OK
{
  "name": "John Doe",
  "credit_card": 5555555555555555,
  "billing_address": "555 Debtor's Lane"
}

 // not OK,这个实例有一个credit_card,但缺少一个billing_address。
{
  "name": "John Doe",
  "credit_card": 5555555555555555
}

dependenciesSchemas

定义某个字段存在时,另外的字段应该应用特定的 Schema,例如:

// 定义当 credit_card 字段存在时,billing_address 必填且为字符串
{
  "type": "object",

  "properties": {
    "name": { "type": "string" },
    "credit_card": { "type": "number" }
  },

  "required": ["name"],

  "dependentSchemas": {
    "credit_card": {
      "properties": {
        "billing_address": { "type": "string" }
      },
      "required": ["billing_address"]
    }
  }
}

条件语句

定义条件,使用 if, thenelse 关键字,关键字里的内容区域也是 JSON Schema

例如,假设您想编写一个模式来处理美国和加拿大的地址。这些国家/地区有不同的邮政编码格式,我们希望根据国家/地区选择要验证的格式。如果地址在美国,则该 postal_code 字段是“邮政编码”:五个数字后跟可选的四位后缀。如果地址在加拿大,则该 postal_code 字段是一个六位字母数字字符串,其中字母和数字交替出现。

{
  "type": "object",
  "properties": {
    "street_address": {
      "type": "string"
    },
    "country": {
      "default": "United States of America",
      "enum": ["United States of America", "Canada"]
    }
  },
  "if": {
    "properties": { "country": { "const": "United States of America" } }
  },
  "then": {
    "properties": { "postal_code": { "pattern": "[0-9]{5}(-[0-9]{4})?" } }
  },
  "else": {
    "properties": { "postal_code": { "pattern": "[A-Z][0-9][A-Z] [0-9][A-Z][0-9]" } }
  }
}

// OK
{
  "street_address": "1600 Pennsylvania Avenue NW",
  "country": "United States of America",
  "postal_code": "20500"
}

// not OK
{
  "street_address": "24 Sussex Drive",
  "country": "Canada",
  "postal_code": "10000"
}

对于更复杂的情况,例如:如果有多个国家,那么条件模式可以和组合一起使用,例如:

{
  "type": "object",
  "properties": {
    "street_address": {
      "type": "string"
    },
    "country": {
      "default": "United States of America",
      "enum": ["United States of America", "Canada", "Netherlands"]
    }
  },
  "allOf": [
    {
      "if": {
        "properties": { "country": { "const": "United States of America" } }
      },
      "then": {
        "properties": { "postal_code": { "pattern": "[0-9]{5}(-[0-9]{4})?" } }
      }
    },
    {
      "if": {
        "properties": { "country": { "const": "Canada" } },
        "required": ["country"]
      },
      "then": {
        "properties": { "postal_code": { "pattern": "[A-Z][0-9][A-Z] [0-9][A-Z][0-9]" } }
      }
    },
    {
      "if": {
        "properties": { "country": { "const": "Netherlands" } },
        "required": ["country"]
      },
      "then": {
        "properties": { "postal_code": { "pattern": "[0-9]{4} [A-Z]{2}" } }
      }
    }
  ]
}

模式识别

这里应用于 JSON Schema 与 JSON Schema 之间的继承,引用,在具体应用于继承和引用之前,我们先来认识以下如下的关键字

$id

定义 JSON Schema 的唯一标识,例如:你可以定义 $id 为网站的某个 URI

{ "$id": "http://yourdomain.com/schemas/myschema.json" }

JSON 指针

非 JSON Schema 关键字

一个标识子 JSON Schema 的路径 URI,例如:

example.com/schemas/add… URI 标识了如下 JSON Schema 的 street_address 字段,那么在别的 JSON Schema 就可以通过该 URI 引用这个定义

{
  "$id": "https://example.com/schemas/address",
  "type": "object",
  "properties": {
    "street_address": { "type": "string" },
    "city": { "type": "string" },
    "state": { "type": "string" }
  },
  "required": ["street_address", "city", "state"]
}

$anchor

这是另一种标识子 JSON Schema 的路径 URI 的方式,就是在字段位置使用锚点关键字

例如:example.com/schemas/add… 标识了如下 JSON Schema 的 street_address 字段

{
  "$id": "https://example.com/schemas/address",
  "type": "object",
  "properties": {
    "street_address":{
      "$anchor": "#street_address",
      "type": "string"
    },
    "city": { "type": "string" },
    "state": { "type": "string" }
  },
  "required": ["street_address", "city", "state"]}

接下来,我们可以定义 JSON Schema 之间的继承及引用

$schema

定义本 JSON Schema 的格式,你可以认为它定义了一个基类,例如:如下定义表明该 JSON Schema 遵循 https://json-schema.org/draft/2019-09/schema 规范

"$schema": "https://json-schema.org/draft/2019-09/schema"

$ref

定义引用的 JSON Schema 地址,$ref 的值是根据模式的Base URI解析的 URI 引用

例如:我们先定义一个 address 字段,再在其他定义中引用该字段定义

// address 定义
{
  "$id": "https://example.com/schemas/address",
  "type": "object",
  "properties": {
    "street_address":{ "type": "string" },
    "city": { "type": "string" },
    "state": { "type": "string" }
  },
  "required": ["street_address", "city", "state"]
}

// 一个客户 Schema 的定义,其中的地址字段引用上面的定义
{
  "$id": "https://example.com/schemas/customer",
  "type": "object",
  "properties": {
    "first_name": { "type": "string" },
    "last_name": { "type": "string" },
    "shipping_address": { "$ref": "/schemas/address" },
    "billing_address": { "$ref": "/schemas/address" }
  },
  "required": ["first_name", "last_name", "shipping_address", "billing_address"]
}

可以通过 # 来实现递归功能,例如:

{
  "type": "object",
  "properties": {
    "name": { "type": "string" },
    "children": {
      "type": "array",
      "items": { "$ref": "#" }
    }
  }
}

定义了如下数据格式

{
  "name": "Elizabeth",
  "children": [
    {
      "name": "Charles",
      "children": [
        {
          "name": "William",
          "children": [
            { "name": "George" },
            { "name": "Charlotte" }
          ]
        },
        {
          "name": "Harry"
        }
      ]
    }
  ]
}

$defs

可以认为是局部变量,例如上面所说的 address ,如果只在本 JSON Schema 中大量使用,可以只用 $defs 定义在本 JSON Schema 中

{
  "$id": "https://example.com/schemas/customer",

  "type": "object",
  "properties": {
    "first_name": { "$ref": "#/$defs/name" },
    "last_name": { "$ref": "#/$defs/name" },
    "shipping_address": { "$ref": "/schemas/address" },
    "billing_address": { "$ref": "/schemas/address" }
  },
  "required": ["first_name", "last_name", "shipping_address", "billing_address"],

  // 局部定义
  "$defs": {
    "name": { "type": "string" }
  }
}

你也可以通过 $defs 直接定义一整套复杂的局部 JSON Schema,这样引用的时候可以直接用定义的 $id

{
  "$id": "https://example.com/schemas/customer",
  "$schema": "https://json-schema.org/draft/2019-09/schema",

  "type": "object",
  "properties": {
    "first_name": { "type": "string" },
    "last_name": { "type": "string" },
    "shipping_address": { "$ref": "/schemas/address" },
    "billing_address": { "$ref": "/schemas/address" }
  },
  "required": ["first_name", "last_name", "shipping_address", "billing_address"],

  "$defs": {
    "address": {
      "$id": "/schemas/address",
      "$schema": "http://json-schema.org/draft-07/schema#",

      "type": "object",
      "properties": {
        "street_address": { "type": "string" },
        "city": { "type": "string" },
        "state": { "$ref": "#/definitions/state" }
      },
      "required": ["street_address", "city", "state"],

      "definitions": {
        "state": { "enum": ["CA", "NY", "... etc ..."] }
      }
    }
  }
}

JSON Schema 实战

事实上,现如今流行的框架、库都有自己的 JSON Schema 配置,例如:package.json、tsconfig.json 等等,可以在 schemastore 找到熟知的 JSON Schema 定义,接下来我们挑几个一起看下

package.json

{
  $schema: "http://json-schema.org/draft-04/schema#",
  "definitions": {
    ......
    "scriptsStart": {
      "description": "Run by the 'npm start' command.",
      "type": "string",
      "x-intellij-language-injection": "Shell Script"
    }
    ......
  }
  "properties": {
    "name": {
      "description": "The name of the package.",
      "type": "string",
      "maxLength": 214,
      "minLength": 1,
      "pattern": "^(?:@[a-z0-9-*~][a-z0-9-*._~]*/)?[a-z0-9-~][a-z0-9-._~]*$"
    },
    "keywords": {
      "description": "This helps people discover your package as it's listed in 'npm search'.",
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "scripts": {
      "description": "The 'scripts' member is an object hash of script commands that are run at various times in the lifecycle of your package. The key is the lifecycle event, and the value is the command to run at that point.",
      "type": "object",
      "properties": {
        ......
        "start": {
          "$ref": "#/definitions/scriptsStart"
        }
      }
    }
    ......
  }
}

根据定义,我们可以看到

  • 遵循 JSON Schema draft-04 规范(当前已到 Draft 2020-12
  • name 字段实际上是有一个正则校验的,虽然涵盖的范围比较广,比如:肯定不能空格开头,在此之前是不是还以为是所有字符串呢
  • keywords 字段是一个字符串数组
  • scripts 字段是一个对象,其中的 start 属性,定义来自于局部定义 /definitions/scriptsStart,其中这个定义了该字段为一个字符串

这里看到 scriptsStart 还有个自定义关键字 x-intellij-language-injection,这个关键字应该是规范之外的关键字,但是 vscode 或者 Intellij 编辑器应该都适配了这个关键字,定义了该字段应该使用的语言

tsconfig.json

{
  "$schema": "http://json-schema.org/draft-04/schema#",
  "allOf": [
    {
      "$ref": "#/definitions/compilerOptionsDefinition"
    }
    ......
  ],
  "definitions": {
    "compilerOptionsDefinition": {
      "properties": {
        "compilerOptions": {
          "type": "object",
          "description": "Instructs the TypeScript compiler how to compile .ts files.",
          "properties": {
            "baseUrl": {
              "description": "Specify the base directory to resolve non-relative module names.",
              "type": "string",
              "markdownDescription": "Specify the base directory to resolve non-relative module names.\n\nSee more: https://www.typescriptlang.org/tsconfig#baseUrl"
            },
            "paths": {
              "description": "Specify a set of entries that re-map imports to additional lookup locations.",
              "type": "object",
              "additionalProperties": {
                "type": "array",
                "uniqueItems": true,
                "items": {
                  "type": "string",
                  "description": "Path mapping to be computed relative to baseUrl option."
                }
              },
              "markdownDescription": "Specify a set of entries that re-map imports to additional lookup locations.\n\nSee more: https://www.typescriptlang.org/tsconfig#paths"
            },
          }
        }
      }
    }
  }
}
  • ts 的定义比较粗暴,他是由 allOf 组合与一系列 definitions 定义组成的
  • 我们看到常用的 compilerOptions 的定义,其中 paths 字段是一个对象,每个对象的属性是一个数组,且该数组需保持元素唯一
  • 同时他还是有自定义的关键字 markdownDescription,应该表示一个 markdown 描述地址

最后

感谢大家看到这里,在我写这篇文章之前,我其实对 JSON Schema 的了解也只局限于一部分简单的定义,为什么我要了解他,实际上是做项目的过程中,有一些 JSON 数据过于灵活,不得不需要一些规范去定义他,然后才发现 JSON Schema 其实早已深入到我们写代码的方方面面。

在完成这篇分享的过程中,我又更加深入的了解了 JSON Schema,以及通过浏览目前流行的库的定义过程中,我对于他们定义的配置在文档之上又有了更进一步的认识,也希望这篇文章带给你们全新的认识,谢谢大家