5

数据校验之JSON Schema

 2 years ago
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.
neoserver,ios ssh client
本文将介绍通用的数据校验方式JSON Schema及其基本规则,如何使用Javascript/Typescript进行校验,以及如何将Typescript文件导出为JSON Schema。

数据校验是日常开发中的常见需求。在客户端进行数据校验可以有更好的交互体验,给予更清晰的反馈文案,并且提前预警,节省服务器端资源。而在服务器端,数据校验通常作为必备流程,来过滤不规范的请求数据。

对数据校验的需求衍生了非常多的校验工具,比如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的基础数据类型囊括了大部分的编程语言中都会有的基础类型。虽然有不同的名称,但是背后的概念基本是一致的,如下图中

JSONJS/TSPythonstringstringstringnumbernumberint/floatobjectobjectdictarrayarraylistbooleanbooleanboolnullnullNone

而对于每种类型都有仅适用于该类型的关键字,下面介绍几种常用的关键字

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关键字控制额外属性,设置 additionalPropertiesfalse则表示不允许有未列出的属性。

    
      {
        "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 本身是根据规范编写的规则,其校验依赖于各种编程语言的实现。比如,Javascriptjson 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

  1. 使用命令行 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会根据默认省略属性原则通过验证。

  2. 通过编写程序的方式导出。

     //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限制字符串最小长度,则可以通过注解来实现。

如下,为userfirst_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中已经有了minimumformat关键字。

{
    "$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"
}

当使用包含长度不足2firstname 数据进行验证,会提示'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"
}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK