基于 typebox 和 ajv 封装的 egg validate 插件。
一直以来,在 typescript 的 egg 项目里,对参数校验 ctx.validate 是比较难受的,比如:
class HomeController extends Controller {
async index() {
const { ctx } = this;
// 写一遍 js 的类型校验
ctx.validate({
id: 'string',
name: {
type: 'string',
required: false,
},
timestamp: {
type: 'number',
required: false,
},
}, ctx.params);
// 写一遍 ts 的类型定义,为了后面拿参数定义
const params: {
id: string;
name?: string;
timestamp: number;
} = ctx.params;
...
ctx.body = params.id;
}
}
export default HomeController;
可以看到这里我们写了两遍的类型定义,一遍 js 的定义(用 parameter 库的规则),另一遍用 ts 的方式来强转我们的参数类型,方便我们后面写代码的时候能得到 ts 的类型效果。 对于简单的类型写起来还好,但是对于复杂点的参数定义,开发体验就不是那么好了。
这就是这个库想要解决的问题,对于参数校验,写一遍类型就够了:
+ import { Static, Type } from 'egg-typebox-validate/typebox';
class HomeController extends Controller {
async index() {
const { ctx } = this;
// 写 js 类型定义
- ctx.validate({
- id: 'string',
- name: {
- type: 'string',
- required: false,
- },
- timestamp: {
- type: 'number',
- required: false,
- },
- }, ctx.params);
+ const paramsSchema = Type.Object({
+ id: Type.String(),
+ name: Type.Optional(Type.String()),
+ timestamp: Type.Optional(Type.Integer()),
+ });
// 直接校验
+ ctx.tValidate(paramsSchema, ctx.params);
// 不用写 js 类型定义
+ const params: Static<typeof paramsSchema> = ctx.params;
- const params: {
- id: string;
- name?: string;
- timestamp: number;
- } = ctx.params;
...
ctx.body = params.id;
}
}
export default HomeController;
用 Static<typeof typebox>
推导出的 ts 类型:
- 安装
npm i egg-typebox-validate -S
- 在项目中配置
// config/plugin.ts
const plugin: EggPlugin = {
typeboxValidate: {
enable: true,
package: 'egg-typebox-validate',
},
};
- 在业务代码中使用
+ import { Static, Type } from 'egg-typebox-validate/typebox';
// 写在 controller 外面,静态化,性能更好,下面有 benchmark
+ const paramsSchema = Type.Object({
+ id: Type.String(),
+ name: Type.String(),
+ timestamp: Type.Integer(),
+ });
// 可以直接 export 出去,给下游 service 使用
+ export type ParamsType = Static<typeof paramsSchema>;
class HomeController extends Controller {
async index() {
const { ctx } = this;
// 直接校验
+ ctx.tValidate(paramsSchema, ctx.params);
// 不用写 js 类型定义
+ const params: ParamsType = ctx.params;
...
}
}
export default HomeController;
- 类型组合方式特别香,能解决很多 DRY(Don't Repeat Yourself) 问题。比如有几张 db 表,都定义了 name 必填和 description 选填,那这个规则可以在各个实体类方法中被组合了。
Show me the code!
export const TYPEBOX_NAME_DESC_OBJECT = Type.Object({
name: Type.String(),
description: Type.Optional(Type.String()),
});
// type NameAndDesc = { name: string; description?: string }
type NameAndDesc = Static<typeof TYPEBOX_NAME_DESC_OBJECT>;
// controller User
async create() {
const { ctx } = this;
const USER_TYPEBOX = Type.Intersect([
TYPEBOX_NAME_DESC_OBJECT,
Type.Object({ avatar: Type.String() }),
])
ctx.tValidate(USER_TYPEBOX, ctx.request.body);
// 在编辑器都能正确得到提示
// type User = { name: string; description?: string } & { avatar: string }
const { name, description, avatar } = ctx.request.body as Static<typeof USER_TYPEBOX>;
...
}
// controller Photo
async create() {
const { ctx } = this;
const PHOTO_TYPEBOX = Type.Intersect([
TYPEBOX_NAME_DESC_OBJECT,
Type.Object({ location: Type.String() }),
])
ctx.tValidate(PHOTO_TYPEBOX, ctx.request.body);
// 在编辑器都能正确得到提示
// type Photo = { name: string; description?: string } & { location: string }
const { name, description, location } = ctx.request.body as Static<typeof PHOTO_TYPEBOX>;
...
}
- 校验规则使用的是业界标准的 json-schema 规范,内置很多开箱即用的类型。
'date-time',
'time',
'date',
'email',
'hostname',
'ipv4',
'ipv6',
'uri',
'uri-reference',
'uuid',
'uri-template',
'json-pointer',
'relative-json-pointer',
'regex'
- 写定义的时候写的是 js 对象(
Type.Number()
),有类型提示,语法也比较简单,有提示不容易写错;写 parameter 规范的时候,写字符串('nunber'
)有时候会不小心写错 😂,再加上它对于复杂嵌套对象的写法还是比较困难的,我每次都会查文档,官方的文档也不全。但是 typebox,就很容易举一反三了。
egg-typebox-validate 底层使用的是 ajv, 官网上宣称是 The fastest JSON validator for Node.js and browser.
结论是在静态化的场景下,ajv 的性能要比 parameter 好得多,快不是一个数量级,详见benchmark
suite
.add('#ajv', function() {
const rule = Type.Object({
name: Type.String(),
description: Type.Optional(Type.String()),
location: Type.Enum({shanghai: 'shanghai', hangzhou: 'hangzhou'}),
})
ajv.validate(rule, DATA);
})
.add('#ajv define once', function() {
ajv.validate(typeboxRule, DATA);
})
.add('#parameter', function() {
const rule = {
name: 'string',
description: {
type: 'string',
required: false,
},
location: ['shanghai', 'hangzhou'],
}
p.validate(rule, DATA);
})
.add('#parameter define once', function() {
p.validate(parameterRule, DATA);
})
在 MacBook Pro(2.2 GHz 六核Intel Core i7)上,跑出来结果是:
#ajv x 941 ops/sec ±3.97% (73 runs sampled)
#ajv define once x 17,188,370 ops/sec ±11.53% (73 runs sampled)
#parameter x 2,544,118 ops/sec ±4.68% (79 runs sampled)
#parameter define once x 2,541,590 ops/sec ±5.34% (77 runs sampled)
Fastest is #ajv define once
- 把原来字符串式 js 对象写法迁移到 typebox 的对象写法。typebox 的写法还算简单和容易举一反三
- 把
ctx.validate
替换成ctx.tValidate
- 建议渐进式迁移,先迁简单的,对业务影响不大的
切换到 egg-typebox-validate 校验后:
- 可以解决 ts 项目中参数校验代码写两遍类型的问题,提升代码重用率,可维护性等问题
- 用标准 json-schema 来做参数校验,是更加标准的业界做法,内置更多业界标准模型
ctx.tValidate
参数校验失败后,抛出错误,内部实现(错误码、错误标题等)逻辑和ctx.validate
的保持一致
+ import { Static, Type } from 'egg-typebox-validate/typebox';
ctx.tValidate(Type.Object({
name: Type.String(),
}), ctx.request.body);
ctx.tValidateWithoutThrow
直接校验,不抛出错误
+ import { Static, Type } from 'egg-typebox-validate/typebox';
const valid = ctx.tValidateWithoutThrow(Type.Object({
name: Type.String(),
}), ctx.request.body);
if (valid) {
...
} else {
const errors = this.app.ajv.errors
// handle errors
...
}
- ⭐⭐⭐ 装饰器 decorator
@Validate([ [rule1, ctx => ctx.xx1], [rule2, ctx => ctx.xx2] ])
调用(写法更干净,推荐使用!️)
+ import { Validate, ValidateFactory } from 'egg-typebox-validate/decorator';
const ValidateWithRedirect = ValidateFactory(ctx => ctx.redirect('/422'));
class HomeController extends Controller {
+ @Validate([
+ [paramsSchema, ctx => ctx.params],
+ [bodySchema, ctx => ctx.request.body, (ctx, errors) => 'MyErrorPrefix: ' + errors.map(e => e.message).join(', ')],
+ ])
async index() {
const { ctx } = this;
// 直接校验
- ctx.tValidate(paramsSchema, ctx.params);
- ctx.tValidate(bodySchema, ctx.request.body);
// 不用写 js 类型定义
const params: ParamsType = ctx.params;
...
}
+ @ValidateWithRedirect([paramsSchema, ctx => ctx.params])
async post() {
// ...
}
}
export default HomeController;
目前装饰器只支持有 this.ctx
的 class 上使用,比如 controller,service 等。也可以通过内置的 ValidateFactory
自定义校验失败后的回调逻辑,更多使用案例可以看这个项目里写的测试用例。
参考 https://github.com/sinclairzx81/typebox#types
比如:
const body = { name: ' david '}
ctx.tValidate(Type.Object({
name: Type.String({ minLength: 1, maxLength: 5, transform: ['trim'] })
}), body)
- 是可以通过校验的
- 会对 body 有副作用,改写 name 字段,trim name 字段,body 会变成
{ name: 'david' }
更多 ajv 对 string 的 transform 操作,详见 https://ajv.js.org/packages/ajv-keywords.html#transform
比如想校验上传的 string 是否是合法的 json string,我们可以对 Type.String 的 format 做 patch,针对 string 加一个 'json-string' 的 format
- 在 config.default.ts 里 patch 默认 ajv 实例的规则
config.typeboxValidate = {
patchAjv: (ajv) => {
ajv.addFormat('json-string', {
type: 'string',
validate: (x) => {
try {
JSON.parse(x);
return true;
} catch (err) {
return false;
}
}
});
}
}
- 使用
async someFunc() {
const typebox = Type.Object({
jsonString: Type.Optional(Type.String({ format: 'json-string' })),
});
const res = ctx.tValidate(typebox, { a: '{"a":1}' }) // valid
const res = ctx.tValidate(typebox, { a: 'wrong{"a":1}' }) // invalid
}
当然也可以定义其他各种规则,比如我们常见的 semver 规范,那可以在我们的配置里继续 patch ajv string format
+ import { valid } from 'semver';
config.typeboxValidate = {
patchAjv: (ajv) => {
ajv.addFormat('json-string', {
type: 'string',
validate: (x) => {
try {
JSON.parse(x);
return true;
} catch (err) {
return false;
}
}
});
+ ajv.addFormat("semver", {
+ type: "string",
+ validate: (x) => valid(x) != null,
+ })
}
}
使用例子:
async someFunc() {
const typebox = Type.Object({
version: Type.String({ format: 'semver' }),
});
const res = ctx.tValidate(typebox, { a: '1.0.0' }) // valid
const res = ctx.tValidate(typebox, { a: 'a.b.c' }) // invalid
}
上面例子是 string 的例子,当然也可以对其他类型做其他 patch,比如 number,array 等,限制你的只有想象力。
全部 json-schema 支持的类型:https://json-schema.org/understanding-json-schema/reference/type.html