本文最后更新于: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 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 sex : 0 | 1 hobbies : string [] }
openapi
下面来看一个实际用例,使用 openapi 定义服务端 restful 接口,并根据 schema 生成客户端。
在实际探索生成客户端之前,先考虑生成的客户端大概是什么样的,这里可以参考 octokit.js ,使用方式形如
1 2 3 4 5 6 7 8 const octokit = new Octokit ({ auth : `personal-access-token123` })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 ())
期望
以类似 github api sdk 的方式使用,不需要使用面向对象的使用方式
所有的接口均为强类型调用,能识别绝大多数接口定义
允许合并一些接口,例如某些接口无法正确识别,但允许自定义并合并覆盖生成的
允许自定义底层的请求实现,例如可以自行选择 fetch/axios 等,也能添加特定的 token 之类的
现有的工具
OpenAPI Generator : 这是一个强大的工具,可以从 OpenAPI spec 生成客户端 SDK、服务器存根和 API 文档。它支持许多语言和框架,包括 TypeScript。– 生成的 sdk 非常难用,所有参数都是平铺的,而不是对象的形式,对与可选参数非常不友好。
Swagger Codegen : 这是一个早期的工具,可以从 Swagger 或 OpenAPI 3.0 spec 生成客户端和服务器代码。它也支持 TypeScript,但是请注意,这个项目已经不再活跃,大部分的开发工作已经转移到 OpenAPI Generator。– 已废弃
NSwag : 这是一个开源的、用于生成 TypeScript 和 C# 客户端的工具,可以从 Swagger 和 OpenAPI spec 中生成。– 和 OpenAPI Generator 有一样的问题 GitHub 链接:<>
swagger-typescript-api : 这是一个生成 TypeScript API 的工具,可以从 Swagger 和 OpenAPI spec 中生成。它生成的代码是基于 axios 的,因此如果你的项目已经使用了 axios,这可能是一个好的选择。– 生成的 sdk 每个文件一个 api 和 config,无法在一个配置统一全部的 api
希望自定义生成逻辑的话,基于现有的库实现也很简单,实际在公司的项目中,为服务端的所有 api 生成 ts 客户端也就 3000 行代码左右。
基本上,可以分为开发时和运行时,开发时主要负责代码生成,使用生成的 ts 辅助类型校验,运行时主要负责使用 jsonschema 进行数据校验,以及一些可能需要在运行时使用的信息,例如 openapi 中的 url/method 等参数。
读取 openapi schema,找到每个端点并且解析获取对应的 params/result 的 json schema
生成对应的 runtime 信息,包含 operationId、url、method、params/result 的 json schema 等必要信息
生成对应的类型定义,包含 params/result 及 ref 引用的 ts 类型定义
使用 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 被绝大多数语言接受了,所以也随之在很多语言中有了实现,可以先从简单的数据校验开始尝试,然后使用它来进行更加复杂的代码生成,可能是一个不错的路径。