参数校验和转换
我们经常要在方法调用时执行一些类型检查,参数转换的操作,Midway 提供了一种简单的能力来快速检查参数的类型,这个能力来源于 joi ,同时也提供了参数的转换能力,这个能力来自于 class-transformer
。
背景
最常用参数校验的地方是 控制器(Controller),同时你也可以在任意的 Class 中使用这个能力。
我们以控制器(Controller)中使用为例,还是那个 user。
➜ my_midway_app tree
.
├── src
│ ├── controller
│ │ └── user.ts
│ ├── interface.ts
│ └── service
│ └── user.ts
├── test
├── package.json
└── tsconfig.json
普通情况下,我们从 body
上拿到所有 Post 结果,并进行一些校验。
// src/interface.ts
export interface User {
id: number;
firstName: string;
lastName: string;
age: number;
}
// src/controller/home.ts
import { Controller, Get, Provide, ALL } from '@midwayjs/decorator';
@Provide()
@Controller('/api/user')
export class HomeController {
@Post('/')
async updateUser(@Body(ALL) user: User) {
if (!user.id || typeof user.id !== 'number') {
throw new Error('id error');
}
if (user.age <= 30) {
throw new Error('age not match');
}
// xxx
}
}
如果每个方法都需要这么校验,会非常的繁琐。
Midway 提供了 @Validate
和 @Rule
装饰器,用来快速定义校验的规则,帮助用户减少这些重复的代码。
适用场景
- HTTP 请求
- Websocket 的处理场景
- MQ 的处理场景
- Task 的处理场景
- service 等其他场景
定义检查规则
按照上面的逻辑,我们需要 重新定义一个新的 Class,因为装饰器只能装饰在实际的 Class 上,而不是 interface。
为了方便后续处理,我们将 user 放到一个 src/dto
目录中。
Data Transfer Object(数据传输对象)DTO 是一组需要跨进程或网络边界传输的聚合数据的简单容器。它不应该包含业务逻辑,并将其行为限制为诸如内部一致性检查和基本验证之类的活动。
// src/dto/user.ts
import { Rule, RuleType } from '@midwayjs/decorator';
export class UserDTO {
@Rule(RuleType.number().required())
id: number;
@Rule(RuleType.string().required())
firstName: string;
@Rule(RuleType.string().max(10))
lastName: string;
@Rule(RuleType.number().max(60))
age: number;
}
由于这个类属于一个 PlainObject
,也不需要被依赖注入管理,我们不需要提供 @Provide
装饰器。
这个 User Class 提供了三个属性和他们对应的校验规则。
id
一个必填的数字类型firstName
一个必填的字符串类型lastName
一个可选的最长为 10 的字符串类型age
一个最大不超过 60 的数字
@Rule
装饰器用于 修饰需要被校验的属性,它的参数为 RuleType
对象提供的校验规则的链式方法。
这里的 RuleType
即为 joi 对象本身。
joi 提供了非常多的校验类型,还可以对对象和数组中的字段做校验,还有例如字符串常用的 RuleType.string().email()
,以及 RuleType.string().pattern(/xxxx/)
正则校验等,具体可以查询 joi 的 API 文档。
校验参数
定义完类型之后,就可以直接在业务代码中使用了,开启校验能力还需要 @Validate
装饰器。
// src/controller/home.ts
import { Controller, Get, Provide, ALL } from '@midwayjs/decorator';
import { UserDTO } from './dto/user';
@Provide()
@Controller('/api/user')
export class HomeController {
@Post('/')
@Validate()
async updateUser(@Body(ALL) user: UserDTO) {
// user.id
}
}
所有的校验代码都通通不见了,业务变的更纯粹了,当然,记得要把原来的 user interface 换成 Class。
一旦校验失败,浏览器或者控制台就会报出类似的错误。
ValidationError: "id" is required
同时,由于定义了 id
的类型,在拿到字符串的情况下,会自动将 id 变为数字。
@Validate()
async updateUser(@Body(ALL) user: UserDTO ) {
// typeof user.id === 'number'
}
常见的校验写法
RuleType.number().required(); // 数字,必填
RuleType.string().empty(''); // 字符串非必填
RuleType.number().max(10).min(1); // 数字,最大值和最小值
RuleType.number().greater(10).less(50); // 数字,大于 10,小于 50
RuleType.string().max(10).min(5); // 字符串,长度最大 10,最小 5
RuleType.string().length(20); // 字符串,长度 20
RuleType.string().pattern(/^[abc]+$/); // 字符串,匹配正则格式
RuleType.object().length(5); // 对象,key 数量等于 5
RuleType.array().items(RuleType.string()); // 数组,每个元素是字符串
RuleType.array().max(10); // 数组,最大长度为 10
RuleType.array().min(10); // 数组,最小长度为 10
RuleType.array().length(10); // 数组,长度为 10
RuleType.string().allow(''); // 非必填字段传入空字符串
级联校验
Midway 支持每个校验的 Class 中的属性依旧是一个对象。
我们给 UserDTO
增加一个属性 school
,并且赋予一个 SchoolDTO
类型。
import { Rule, RuleType } from '@midwayjs/decorator';
export class SchoolDTO {
@Rule(RuleType.string().required())
name: string;
@Rule(RuleType.string())
address: string;
}
export class UserDTO {
@Rule(RuleType.number().required())
id: number;
@Rule(RuleType.string().required())
firstName: string;
@Rule(RuleType.string().max(10))
lastName: string;
// 这里传入 SchoolDTO 作为校验参数,此时会默认是required字段,
// 如果用户不想要required,则@Rule(SchoolDTO, {required: false})
@Rule(SchoolDTO)
school: SchoolDTO;
// 如果是数组,则也只要下面这样写,这边装饰器会判断类型是否是数组,只能适用这种class类型
@Rule(SchoolDTO)
xxxx: SchoolDTO[];
}
这个时候, @Rule
装饰器的参数可以为需要校验的这个类型本身。
继承校验
Midway 支持校验继承方式,满足开发者抽离通用的对象属性的时候做参数校验。
例如我们下面 CommonUserDTO
抽离接口的通用的一些属性,然后 UserDTO
作为特殊接口需要的特定参数。
import { Rule, RuleType } from '@midwayjs/decorator';
export class CommonUserDTO {
@Rule(RuleType.string().required())
token: string;
@Rule(RuleType.string())
workId: string;
}
export class UserDTO extends CommonUserDTO {
@Rule(RuleType.string().required())
name: string;
}
老版本需要在子类上面加,新版本不需要啦~
如果属性名相同,则取当前属性的规则进行校验,不会和父类合并。
从原有 DTO 创建新 DTO
有时候,我们会希望从某个 DTO 中获取一部分属性,变成一个新的 DTO 类。
Midway 提供了 PickDto
和 OmitDto
两个方法根据现有的的 DTO 类型创建新的 DTO。
PickDto
用于从现有的 DTO 中获取一些属性,变成新的 DTO,而 OmitDto
用于将其中某些属性剔除,比如:
// src/dto/user.ts
import { Rule, RuleType, PickDto } from '@midwayjs/decorator';
export class UserDTO {
@Rule(RuleType.number().required())
id: number;
@Rule(RuleType.string().required())
firstName: string;
@Rule(RuleType.string().max(10))
lastName: string;
@Rule(RuleType.number().max(60))
age: number;
}
// 继承出一个新的 DTO
export class SimpleUserDTO extends PickDto(UserDTO, ['firstName', 'lastName']) {}
// const simpleUser = new SimpleUserDTO();
// 只包含了 firstName 和 lastName 属性
// simpleUser.firstName = xxx
export class NewUserDTO extends OmitDto(UserDTO, ['age']) {}
// const newUser = new NewUserDTO();
// newUser.age 定义和属性都不存在
// 使用
async login(@Body() user: NewUserDTO) {
// ...
}
参数校验技巧
有人如果我很多都是字符串必填,或者类似需求,写 RuleType.string().required()
有点长,有点烦,那应该怎么办?
const requiredString = RuleType.string().required(); // 自己在一个文件中定义一下你们部门的规范或常用的。
export class UserDTO {
@Rule(requiredString) // 这样就不用写上面这么长的了
name: string;
@Rule(requiredString) // 同上
nickName: string;
@Rule(requiredString) // 同上
description: string;
}
const maxString = (length) => RuleType.string().max(length); // 自己在一个文件中定义一下你们部门的规范或常用的。
export class UserDTO {
@Rule(requiredString) // 同上
name: string;
@Rule(requiredString) // 同上
nickName: string;
@Rule(requiredString) // 同上
description: string;
@Rule(maxString(50)) // 这样通过换个参数即可
info: string;
@Rule(maxString(50).required()) //这样也行
info2: string;
}
相当于通过定义常用方法或变量。
参数转换
除了校验参数, @Validate
装饰器还承担了转换对象的职责。你会发现传入的参数已经被自动变为 UserDTO 的实例。
这样做的好处是,可以直接调用对象中的一些方法,比如下面的 getName
和 isAdult
。在后续的 Model 保存到数据库等操作中有着非常明显的优势。
// src/dto/user.ts
import { Rule, RuleType } from '@midwayjs/decorator';
export class UserDTO {
@Rule(RuleType.number().required())
id: number;
@Rule(RuleType.string().required())
firstName: string;
@Rule(RuleType.string().max(10))
lastName: string;
getName() {
return this.firstName + ' ' + this.lastName;
}
isAdult() {
return this.age > 36 && this.age < 60;
}
}
要关闭这一默认行为,可以在使用 @Validate
装饰器时加入 false 参数
@Validate(false)
async updateUser(@Body(ALL) user: UserDTO ) {
// user instanceof UserDTO => false
}
其他问题
1、由于部分用户在参数校验的时候,希望允许出现没有定义的字段。但是这个 PR 由于社区的意见不一致,导致 PR 一直没合并进来。如果用户有这样的需求,暂时的解法: 在 config.default.ts 里面
import { defaults } from 'joi/lib/common.js';
defaults.allowUnknown = true;
2、用户咨询如何让对应的信息透出给端侧或者上游? 参数校验、转换,不仅可以使用在 http 请求、websocket、task、service 等场景。 然后当 @Validate 失败的时候,会向外抛一个 error。 如果 http 请求想要将对应信息处理一下抛给前端,则只需要通过用一个 middleware try catch 一下即可。