使用 json schema

本文最后更新于:2023年9月28日 凌晨

json schema 是什么

jsonschema 是一种用于描述 json 数据的语言,它的作用是帮助我们定义 json 数据的结构,以及对数据进行校验。

应用场景

json schema 的应用场景广泛,包括

  • 定义配置文件的格式以供编辑时提示,例如在 vscode 中编辑 package.json、manifest.json 会有提示
  • 服务端的接口定义,例如 openapi/openrpc 等
  • 数据校验,例如校验客户端的参数或者服务端返回的数据
  • 更多。。。

为什么要选择它

与 jsonschema 类似的工具有很多,在不同的场景中不同的等价项,例如在数据校验方面,zodjs 是一种使用 js api 作为 json 校验的工具。在定义服务端接口方面,竞争者更是众多,包括 golang 的 grpc、fackbook 的 graphql、typescript 的 trpc 等等。但是 json schema 有以下优势:

  • 跨语言 – json schema 是一种标准,因此可以跨语言使用,例如可以使用 json schema 来定义前后端交互的接口,然后使用不同语言生成对应的接口定义。与之相对的,zodjs 适用于 js,grpc 比较适用于 golang,graphql/trpc 比较适用于 js/ts 等等。
  • 工具支持 – json schema 的工具支持非常丰富,数据校验有 ajv、jsonschema 等,生成 typescript 接口有 json-schema-to-typescript,生成 openapi 的接口有 json-schema-to-openapi-schema,许多工具多已经有现成的了
  • 运行时存在 – json schema 本身就是 json,因此可以直接在运行时使用,例如在客户端校验参数或者在服务端校验返回值,而 grpc/graphql/trpc 等则需要在运行时使用对应的工具来解析 schema

基本使用

语法

json schema 本身也使用 json 编写,可以简单的编写。

一个基本示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"type": "object",
"description": "用户信息",
"properties": {
"name": {
"type": "string",
"description": "姓名"
},
"age": {
"type": "number",
"description": "年龄"
},
"sex": {
"type": "integer",
"description": "性别,0 为女,1 为男",
"enum": [0, 1]
},
"hobbies": {
"type": "array",
"items": {
"type": "string",
"description": "爱好"
},
"description": "爱好"
}
},
"required": ["name", "age", "sex", "hobbies"]
}

这描述了一个对象的结构,它对应的 typescript 接口是

1
2
3
4
5
6
7
8
9
10
11
/** 用户信息 */
interface User {
/** 姓名 */
name: string
/** 年龄 */
age: number
/** 性别,0 为女,1 为男 */
sex: 0 | 1
/** 爱好 */
hobbies: string[]
}

校验数据

有了 jsonschema,就可以校验数据了,这里使用 jsonschema 库

1
2
3
4
5
6
7
8
9
import * as jsonschema from 'jsonschema'

const res = jsonschema.validate(data, schema)
if (res.valid) {
// 数据校验通过
} else {
// 数据校验失败
console.log(res.errors)
}

生成类型定义

如果希望生成类型定义来便于开发,可以使用 json-schema-to-typescript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { compile } from 'json-schema-to-typescript'

const r = await compile(
{
type: 'object',
description: '用户信息',
properties: {
name: {
type: 'string',
description: '姓名',
},
age: {
type: 'number',
description: '年龄',
},
sex: {
type: 'integer',
description: '性别,0 为女,1 为男',
enum: [0, 1],
},
hobbies: {
type: 'array',
items: {
type: 'string',
description: '爱好',
},
description: '爱好',
},
},
required: ['name', 'age', 'sex', 'hobbies'],
},
'User',
{
bannerComment: '',
additionalProperties: false,
},
)
console.log(r)

得到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 用户信息
*/
export interface User {
/**
* 姓名
*/
name: string
/**
* 年龄
*/
age: number
/**
* 性别,0 为女,1 为男
*/
sex: 0 | 1
/**
* 爱好
*/
hobbies: string[]
}

openapi

下面来看一个实际用例,使用 openapi 定义服务端 restful 接口,并根据 schema 生成客户端。

在实际探索生成客户端之前,先考虑生成的客户端大概是什么样的,这里可以参考 octokit.js,使用方式形如

1
2
3
4
5
6
7
8
// Create a personal access token at https://github.com/settings/tokens/new?scopes=repo
const octokit = new Octokit({ auth: `personal-access-token123` })

// Compare: https://docs.github.com/en/rest/reference/users#get-the-authenticated-user
const {
data: { login },
} = await octokit.rest.users.getAuthenticated()
console.log('Hello, %s', login)

可以看到整体上是 2 级关系,使用模块-方法名,然后传入参数,返回结果,好像它们只是普通的异步函数一样,所以下面也将尝试生成这样的 client。

下面定义一个简单的 openapi schema,假设它是 test.openapi.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"paths": {
"/ping": {
"get": {
"operationId": "ping",
"description": "测试服务是否正常",
"responses": {
"200": {
"description": "服务正常",
"content": {
"text/plain": {
"schema": {
"type": "string",
"example": "pong"
}
}
}
}
}
}
}
}
}

希望生成的类型定义

1
2
3
4
5
6
7
export interface Test {
ping(): Promise<string>
}

export type ApiInstance = {
test: Test
}

希望使用 fetch 适配器创建 client 使用方式

1
2
const api = createClient<ApiInstance>(runtime,request)
console.log(await apis.test.ping()) // pong

期望

  • 以类似 github api sdk 的方式使用,不需要使用面向对象的使用方式
  • 所有的接口均为强类型调用,能识别绝大多数接口定义
  • 允许合并一些接口,例如某些接口无法正确识别,但允许自定义并合并覆盖生成的
  • 允许自定义底层的请求实现,例如可以自行选择 fetch/axios 等,也能添加特定的 token 之类的

现有的工具

  1. OpenAPI Generator: 这是一个强大的工具,可以从 OpenAPI spec 生成客户端 SDK、服务器存根和 API 文档。它支持许多语言和框架,包括 TypeScript。– 生成的 sdk 非常难用,所有参数都是平铺的,而不是对象的形式,对与可选参数非常不友好。
  2. Swagger Codegen: 这是一个早期的工具,可以从 Swagger 或 OpenAPI 3.0 spec 生成客户端和服务器代码。它也支持 TypeScript,但是请注意,这个项目已经不再活跃,大部分的开发工作已经转移到 OpenAPI Generator。– 已废弃
  3. NSwag: 这是一个开源的、用于生成 TypeScript 和 C# 客户端的工具,可以从 Swagger 和 OpenAPI spec 中生成。– 和 OpenAPI Generator 有一样的问题
    GitHub 链接:<>
  4. swagger-typescript-api: 这是一个生成 TypeScript API 的工具,可以从 Swagger 和 OpenAPI spec 中生成。它生成的代码是基于 axios 的,因此如果你的项目已经使用了 axios,这可能是一个好的选择。– 生成的 sdk 每个文件一个 api 和 config,无法在一个配置统一全部的 api

希望自定义生成逻辑的话,基于现有的库实现也很简单,实际在公司的项目中,为服务端的所有 api 生成 ts 客户端也就 3000 行代码左右。

基本生成流程图.drawio.svg

基本上,可以分为开发时和运行时,开发时主要负责代码生成,使用生成的 ts 辅助类型校验,运行时主要负责使用 jsonschema 进行数据校验,以及一些可能需要在运行时使用的信息,例如 openapi 中的 url/method 等参数。

  1. 读取 openapi schema,找到每个端点并且解析获取对应的 params/result 的 json schema
  2. 生成对应的 runtime 信息,包含 operationId、url、method、params/result 的 json schema 等必要信息
  3. 生成对应的类型定义,包含 params/result 及 ref 引用的 ts 类型定义
  4. 使用 adapter 生成客户端,或者编写服务端代码(强类型&校验)

一个 fetch 的 adapter 示例实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
type HttpMethods =
| 'get'
| 'put'
| 'post'
| 'delete'
| 'options'
| 'head'
| 'patch'
| 'trace'

export interface OpenApiOperation {
name: string
params?: Schema
result?: Schema
extra: {
path: string
method: HttpMethods
fields: OpenAPIV3.ParameterObject[]
body: boolean
}
}

export function createClient<T>(
runtimes: Record<string, OpenApiOperation[]>,
request: (options: {
method: string
url: string
data?: any
}) => Promise<any>,
): T {
return (Object.entries(runtimes) as [string, OpenApiOperation[]][]).reduce(
(acc, [k, v]) => {
acc[k] = v.reduce((acc, it) => {
acc[it.name] = async (args: any) => {
let path: string = it.extra.path
const paramaters = Object.entries(
groupBy(it.extra.fields, (it) => it.in),
).reduce((acc, [k, v]) => {
acc[k] = v.map((it) => it.name)
return acc
}, {} as Record<string, string[]>)
if (paramaters.path) {
Object.entries(pick(args, paramaters.path)).forEach(([k, v]) => {
if (path.includes(`{${k}}`) && !Array.isArray(v)) {
path = path.replace(new RegExp(`{${k}}`, 'g'), String(v))
}
})
args = omit(args, paramaters.path)
}
if (paramaters.query) {
const u = new URLSearchParams(
Object.entries(pick(args, paramaters.query)),
)
path += '?' + u.toString()
args = omit(args, paramaters.query)
}
return await request({
method: it.extra.method,
url: path,
data:
!args ||
(args.toString() === '[object Object]' &&
Object.keys(args).length === 0)
? undefined
: args,
})
}
return acc
}, {} as any)
return acc
},
{} as any,
)
}

jsonrpc

与 openapi 相比,jsonrpc 适用范围可以更加广泛,事实上,jsonrpc 也可以用在 restful 接口上,但一般还是使用 openapi,后者与 restful 有更好的结合。但 jsonrpc 在其他场景,例如 websocket、electron 线程通信等等都可以使用,当然使用 ts 也可以,例如 comlink 就是这样做的,但 jsonrpc 可以在运行时使用,以实现数据校验的功能。

一些可能的使用场景

  • 与 web worker/iframe 通信
  • 与 worker_threads 通信
  • websocket 通信
  • electron 主进程与渲染进程通信
  • chrome extension 多线程通信

除开生成代码的部分之外,adapter 部分甚至可以不需要 runtime,因为 runtime 信息只用于校验,不像 openapi 一样还有额外的信息。

一个基本的 openrpc 定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"name": "hello",
"description": "hello world",
"params": [
{
"name": "name",
"description": "名字",
"required": true,
"schema": {
"type": "string",
"examples": ["world"]
}
}
],
"result": {
"name": "result",
"schema": {
"type": "string",
"examples": ["hello world"]
}
}
}

重点是 methods 部分,可以看到就是在用 json 表达函数的定义,有参数和返回值的定义。
它的对应 ts 定义

1
2
3
export interface Service {
hello(name: string): Promise<string>
}

这种方式虽然看起来没有使用 ts 定义来的直观,但在跨语言解析方便占有绝对优势,例如生成 golang 的接口代码可以通过 golang 的 ast 实现,而不必面临解析复杂的 ts 代码的问题。

结语

json schema 是一个不错的规范,由于 json 被绝大多数语言接受了,所以也随之在很多语言中有了实现,可以先从简单的数据校验开始尝试,然后使用它来进行更加复杂的代码生成,可能是一个不错的路径。


使用 json schema
https://blog.rxliuli.com/p/05e27a46cc1b4dfdbecfe4c8209211fe/
作者
rxliuli
发布于
2023年6月26日
许可协议