数据校验之JSON Schema
source link: https://jelly.jd.com/article/6243032cf25db001d3fae0a8
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
数据校验是日常开发中的常见需求。在客户端进行数据校验可以有更好的交互体验,给予更清晰的反馈文案,并且提前预警,节省服务器端资源。而在服务器端,数据校验通常作为必备流程,来过滤不规范的请求数据。
对数据校验的需求衍生了非常多的校验工具,比如JS
的校验库JOi
,python 的jsonschema
包。当面对多语言、多服务的业务需求,意味着同样的规则需要用不同语言的编写重复的校验规则,产生重复劳动,且在数据字段更新时候,所有服务都需要进行同步更新。由此,需要一种更为通用的数据校验方式。
JSON Schema
JSON(JavaScript Object Notation)
作为一种简单的数据交换格式被广泛应用。基于简单的数据类型可以表示各种结构化数据。
而JSON schema
则是定义JSON
数据的模式,即约束JSON
数据有哪些字段、其值是如何表示的。JSON schema
本身用JSON
编写,且遵循一定规范,需要使用其他语言编写好的程序来解析与验证。
常见编程语言都对 JSON schema
规范进行了实现,包括go
/java
/python
等。也就是说同一份JSON schema
可以在不同语言中通用,实现统一的校验逻辑,而不需要在前后端分别使用不同的库来实现校验规则的编写。
以一份校验用户信息的JSON Schema
为例,如下,
{
"$schema": "http://json-schema.org/draft-07/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" }
}
}
}
}
其中$schema
关键字声明该JSON Schema
使用的是哪个版本的规范。
而type
关键字指定了校验对象的数据类型,JSON Schema
的基础数据类型囊括了大部分的编程语言中都会有的基础类型。虽然有不同的名称,但是背后的概念基本是一致的,如下图中
而对于每种类型都有仅适用于该类型的关键字,下面介绍几种常用的关键字
Object
对象类型
properties
properties
关键字则定义了对象的属性,properties
的值是一个对象,其中的键是对象属性的名称,值为验证该属性的schema
,如'first_name"
属性值的schema
为{ "type": "string" }
, 定义该属性的值为字符串。默认情况下可以省略属性,也可以提供附加属性,
properties
只会匹配与关键字中的任何属性名称所匹配的属性。// 完美匹配 ok { "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" } }
// 省略属性 ok { "first_name": "George" }
// 附加属性 ok { "nick_name": "George" }
// 属性类型匹配错误 not ok { "first_name": 123 }
additionalProperties
additionalProperties
关键字控制额外属性,设置additionalProperties
为false
则表示不允许有未列出的属性。{ "type": "object", "properties": { "number": { "type": "number" }, "street_name": { "type": "string" }, "street_type": { "enum": ["Street", "Avenue", "Boulevard"] } }, "additionalProperties": false } // OK { "number": 1600, "street_name": "Pennsylvania", "street_type": "Avenue" } // not OK,额外属性“direction”使对象无效 { "number": 1600, "street_name": "Pennsylvania", "street_type": "Avenue", "direction": "NW" }
required
required
关键字设定必须的属性,其值为一个属性名数组。{ "type": "object", "properties": { "number": { "type": "number" }, "street_name": { "type": "string" }, "street_type": { "enum": ["Street", "Avenue", "Boulevard"] } }, "required": ["name", "email"] }
// not OK,缺少必需的“email”属性会使 JSON 文档无效 { "name": "William Shakespeare", "address": "Henley Street, Stratford-upon-Avon, Warwickshire, England", }
字符串类型
minLength
/maxLength
限制字符串长度{"type": "string", "minLength": 2, "maxLength": 3}
pattern
将字符串限制为特定的正则表达式,使用Javascript
定义的语法,比如匹配一个手机号码'^[1][3,4,5,7,8][0-9]{9}$'
{"type": "string", "pattern": "^[1][3,4,5,7,8][0-9]{9}$"}
format
关键字可以进行语义验证。其中内置了一些常见的字符串格式,比如"date-time"
:日期期和时间,比如'2018-11-13T20:20:39+00:00'
"date"
:日期,比如'2018-11-13'
"email"
:邮箱地址,比如'[email protected]'
{ "type": "object", "properties": { "update_name": { "format": "date-time", "type": "string" }, "birthday": { "format": "date", "type": "string" }, "mail": { "format": "email", "type": "string" }, },
此外,还有IP 地址、资源标识符、正则表达式等,应有尽有,这里不列举了~ 可以查阅 JSON schema规范文档 ,
除了最基础的模式,JSON Schema
也提供了复杂模式的构建方式。比如对于重复代码我们通常会封装为可复用模块进行维护。同理,JSON Schema
模式也支持拆解为可复用的单元。
对于复用的部分,可以放在父节点的definitions
关键字下,比如把用户信息schema
中的birthday
提取出来
{
"definitions" : {
"birthday": { "type": "string", "format": "date" },
}
}
然后可以通过$ref
属性,从其他地方引用此模块片段,
{ "$ref": "#/definitions/birthday" }
#
开头表示是属于当前文档中的片段,#
后以斜杠分隔的路径来遍历文档中对象的键,这部分即JSON
指针。
也可以把复用片段拎出来放在单独的文件中,比如保存为definitions.json
文件,如果在其他文档中引用此片段,则需要先为在Schema
的根目录中使用$id
关键字来设置URI
,比如
在 birthday.json
中如下,
{
"$id": "http://example.com/schema/birthday.json",
"definitions": {
"birthday": { "type": "string", "format": "date" },
}
}
在userInfo.json
中
{
"$id": "http://example.com/schema/userInfo.json"
"type": "object",
"properties": {
"first_name": { "type": "string" },
"last_name": { "type": "string" },
"birthday": {"$ref": "birthday.json#/definitions/birthday" },
"address": {
"type": "object",
"properties": {
"street_address": { "type": "string" },
"city": { "type": "string" },
"state": { "type": "string" },
"country": { "type" : "string" }
}
}
}
}
校验JSON Schema
JSON Schema
本身是根据规范编写的规则,其校验依赖于各种编程语言的实现。比如,Javascript
的json schema
校验可以使用Ajv
工具,支持浏览器端以及Node
端,基本流程如下
- 引入
ajv
包 new
一个Ajv
实例compile
一个validate
校验器- 使用校验器进行校验
// or ESM/TypeScript import
import Ajv from "ajv"
// Node.js require:
const Ajv = require("ajv")
const ajv = new Ajv() // options can be passed, e.g. {allErrors: true}
const schema = {
type: "object",
properties: {
foo: {type: "integer"},
bar: {type: "string"}
},
required: ["foo"],
additionalProperties: false,
}
const data = {
foo: 1,
bar: "abc"
}
const validate = ajv.compile(schema)
const valid = validate(data)
if (!valid) console.log(validate.errors)
当数据校验失败,会提示错误信息,比如当隐藏必须的属性foo
const data = {
//foo: 1,
bar: "abc"
}
error
中会有对应提示,
[
{
keyword: 'required',
dataPath: '',
schemaPath: '#/required',
params: { missingProperty: 'foo' },
message: "should have required property 'foo'"
}
]
比起校验数据的快速,编译校验器的速度是要更慢的,所以推荐在程序初始化阶段compile
一次,然后进行复用。
当需要对多个Schema
校验器时,也可以使用addSchema
方法进行管理。
import Ajv from "ajv"
import * as schema_user from "./schema_user.json"
import * as schema_document from "./schema_document.json"
export const ajv = new Ajv()
ajv.addSchema(schema_user, "user")
ajv.addSchema(schema_document, "document")
addSchema
接受一个参数作为校验器的name
,返回编译好的校验器,然后可以在其他模块内直接引用全局的ajv
实例,使用getSchema
方法,通过name
获得对应的校验器进行使用。
import ajv from "./validation"
interface User {
username: string
}
// this is just some abstract API framework
app.post("/user", async (cxt) => {
const validate = ajv.getSchema<User>("user")
if (validate(cxt.body)) {
// create user
} else {
// report error
cxt.status(400)
}
})
Typescript生成JSON Schema
JSON Schema
虽然通用但是编写还是略繁琐,而在使用 Typescript
的时候,我们已经为对象指定了类型,虽然与JSON Schema
采用不同的方式,但是从语义上来说是一致的,所以可以通过工具来直接转换,比如typescript-json-schema
。
以用户信息Schema
为例,对应的TS
类型声明文件user.ts
如下:
export interface IAddress {
street_address: string,
city: string,
state: string,
country: string
}
export interface IUser{
first_name: string,
last_name: string,
birthday: string,
address?: IAddress
}
typescript-json-schema
支持两种方式生成Json Schema
,
使用命令行
typescript-json-schema <path-to-typescript-files-or-tsconfig> <type>
其中
type
为要导出的类型,也可以用*
表示导出文件中所有类型typescript-json-schema userInfo.ts 'IUser' --out 'result.json' --required
其中
—required
参数表示会加入require
关键字限制必须属性,如果没有,TS
会报错缺少属性值,而JSON Schema
会根据默认省略属性原则通过验证。通过编写程序的方式导出。
//export-schema.ts import { resolve } from 'path' import * as TJS from 'typescript-json-schema' const fs = require('fs') // optionally pass argument to schema generator const settings: TJS.PartialArgs = { required: true, } const program = TJS.getProgramFromFiles( [resolve('./userInfo.ts')], ) const schema = TJS.generateSchema(program, 'IUser', settings); fs.writeFileSync('./result.json', JSON.stringify(schema, null, '\t'))
运行
ts-node export-schema.ts
上述两种方式导出的schema
如下
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"IAddress": {
"properties": {
"city": {
"type": "string"
},
"country": {
"type": "string"
},
"state": {
"type": "string"
},
"street_address": {
"type": "string"
}
},
"required": [
"city",
"country",
"state",
"street_address"
],
"type": "object"
}
},
"properties": {
"address": {
"$ref": "#/definitions/IAddress"
},
"birthday": {
"type": "string"
},
"first_name": {
"description": "The size of the shape.",
"minimum": 0,
"type": "integer"
},
"last_name": {
"type": "string"
}
},
"required": [
"birthday",
"first_name",
"last_name"
],
"type": "object"
}
可以看到在TS
接口文件中指定的属性类型都映射成功了。但是,TS
只提供了静态类型检查,对于数据内容并不能进一步的限制。而JSON-Schema
中为各个类型制定了更细致的规则,比如为字符串类型提供的format
关键字限制字符串格式,minLength
限制字符串最小长度,则可以通过注解来实现。
如下,为user
的first_name
属性添加minLength
注解,
export interface IAddress {
street_address: string,
city: string,
state: string,
country: string
}
export interface IUser{
/**
* The firstname.
* @minLength 2
*/
first_name: string,
last_name: string,
/**
* @format date
*/
birthday: string,
address?: IAddress
}
可以看到生成的schema
中已经有了minimum
、format
关键字。
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
...
},
"properties": {
"address": {
"$ref": "#/definitions/IAddress"
},
"birthday": {
"format": "date", //这里这里
"type": "string"
},
"first_name": {
"minLength": 2, // 这里这里
"type": "string"
},
"last_name": {
"type": "string"
}
},
"required": [
"birthday",
"first_name",
"last_name"
],
"type": "object"
}
当使用包含长度不足2
的firstname
数据进行验证,会提示'should NOT be shorter than 2 characters'
,
使用非date
类型的birthday
,会提示'should match format "date"'
// 数据
{
'first_name': 'A',
'last_name': 'Washington',
'birthday': '1732-02-22',
'address': {
'street_address': '3200 Mount Vernon Memorial Highway',
'city': 'Mount Vernon',
'state': 'Virginia',
'country': 'United States',
},
}
// 验证结果 报错
[
{
keyword: 'minLength',
dataPath: '.first_name',
schemaPath: '#/properties/first_name/minLength',
params: { limit: 2 },
message: 'should NOT be shorter than 2 characters'
}
]
// 验证结果报错
[
{
keyword: 'format',
dataPath: '.birthday',
schemaPath: '#/properties/birthday/format',
params: { format: 'date' },
message: 'should match format "date"'
}
]
在实际使用中,也可以将转换脚本单独拎出来,在package.json
中添加命令,就可以方便的执行转换了。
"scripts": {
"transfer": "scripts/ts-node transfer.ts"
}
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK