一、背景
我们在egg项目中,所有接口通常都存在请求入参校验的需求,例如:
- 校验必填项字段,是否存在
- 校验字段类型,是否与期望类型相符
- 字符串数字,如果期望类型为数字,则需要转成数字类型,如果期望类型为字符串,则无需处理
……
程序员要追求dry(don't repeat yourself)不要重复自己,要提高代码重用率,缩减代码量。同时,提高代码可读性和可维护性,当需要修改时,只需修改一个地方就好。因此,我们对入参校验进行提取,并进行方案改造。
二、初步方案:封装公用参数校验方法
封装公用方法后,调用时还是比较啰嗦,对代码的侵入性还是比较强。
而我们希望的方案是:定义好接口入参数据结构、类型、相关字段是否必填后,传入参数对象,直接返回入参校验错误信息。
三、理想方案探索:装饰器
ES7 的 decorator 概念是从 Python 借来的,作用于一个目标函数,对这个目标函数做一些额外的操作,然后返回一个新的函数。ES7 中的 decorator 同样借鉴了这个语法糖,不过依赖于 ES5 的Object.defineProperty 方法。
1、Object.defineProperty()
Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象现有属性,并返回此对象。
语法:
Object.defineProperty(obj, prop, descriptor)
参数:
- obj:要定义属性的对象
- prop:要定义或修改的属性的名称或 Symbol
- descriptor:要定义或修改的属性描述符,包含4个属性:
eg:
var obj = {};
var descriptor = Object.create(null);
Object.defineProperty(obj, "key", { //显式
enumerable: false,
configurable: false,
writable: false,
value: "static"
});
2、装饰器的分类
3、各类装饰器——应用场景
1)类装饰器
a、React 生命周期函数
* 页面初始化装饰,在使用 React 经常会在 componentDidMount 进行请求 api 数据
eg:
const InitDate = func => target => {
const componentDidMount = target.prototype.componentDidMount;
target.prototype.componentDidMount = () => {
if (componentDidMount) componentDidMount();
func();
};
return target;
};
@InitDate(action.init)
export default class Index extends Component { //... }
//等同于:
export default class Index extends Component {.
componentDidMount() {
action.init();
}
}
* 同样的这种方式可以推广的更多的生命周期函数。例如:在 componentDidCatch 里进行错误捕捉等。
b、React 性能优化
编写一个装饰器,实现在 React shouldComponentUpdate 里进行性能优化,指定优化的字段:
//渲染优化
const RenderOptimization = (key: string | string[]) => (target): void => {
const scu = target.prototype.shouldComponentUpdate || (_ => true);
target.prototype.shouldComponentUpdate = function (nextProps, nextState) {
let flag = true;
if (Array.isArray(key)) {
for (let i = 0; i < key.length; i++) {
flag = flag && (this.props[key[i]] === nextProps[key[i]]);
}
} else {
flag = this.props[key] !== nextProps[key];
}
return flag && scu(nextProps, nextState);
};
};
//在 props id 改变时才渲染
@RenderOptimization('id')
export default class Index extends Component {
//...
2)方法装饰器
a、日志输出
eg:
const Log = name => (target, name, descriptor) => {
const oldValue = descriptor.value;
descriptor.value = function (...arg) {
const val = oldValue.apply(this, arg);
console.log(`调用函数:${name},参数为:${arg},结果为:${val}`);
return val;
};
return descriptor;
};
export default class Index extends Component {
@Log('add')
add(state, payload) {
return state.count + payload;
}
}
b、异步函数装饰
对异步函数进行装饰实现,一些 loading 显示效果。例如:在 React 里会有很大的 onSubmit 提交,可以在提交之后显示 loading,提交成功后移除 loading。
eg:
const LoadingWrap = prefix => (target, name, descriptor) {
const oldValue = descriptor.value;
descriptor.value = function (...arg) {
Toast.info(`${prefix}中`);
const val = oldValue.apply(this, arg);
Toast.success(`${prefix}${val ? '成功' : '失败'}`);
return val;
};
return descriptor;
};
export default class Index extends Component {
@LoadingWrap('提交')
onSubmit = e => {
return true; //根据返回 true/false 来断定
}
}
3)方法参数装饰器
a、入参校验
/app/controller/menuDev.ts:
...
@Post('/updateMenuDev')
async updateMenuDev (@Body('menuDevUpdate') menuDevUpdate:MenuDevUpdate) {
const { menuId } = menuDevUpdate
return await this.ctx.service.menuDev.updateMenuDev(menuDevUpdate, { menuId })
}
...
/app/classes/menuDevUpdate.ts:
import { Number } from '../lib/decorator'
import MenuDev from './menuDev'
export default class MenuDevUpdate extends MenuDev {
@Number
menuId: number;
modifyUser: string;
modifyUserType: number;
updatedAt: Date;
}
/app/classes/menuDev.ts:
import { IsInt, IsNotEmpty } from 'class-validator'
export default class MenuDev {
@IsInt()
menuPid: number;
@IsInt()
applicationId: number;
menuPath: string;
@IsNotEmpty()
menuName: string;
menuIcon: string;
menuCategory: number;
menuOrder: number;
isHidden: boolean;
menuDesc: string;
}
* 请求入参校验为:方法参数装饰器,以上代码是我们@Body的实现代码。
4)属性装饰器
a、数据格式转换
利用属性装饰,实现时间的格式化。
eg:
const TimeFormat = (format: string, p: string) => (target, key) => {
let val = target[key];
function getter() {
if (typeof val === 'object') {
val[p] = dayjs(val[p]).format(format);
}
return val;
}
function setter(newVal) {
val = newVal;
}
Object.defineProperty(target, key, {
get: getter,
set: setter
});
};
export default class Index extends Component {
@TimeFormat('YYYY-MM-DD', 'time')
state = {
time: '2019-05-27 00:36:52' // => 2019-05-27
}
}
四、元数据
Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与proxy handlers的方法相同。Reflect不是一个函数对象,因此它是不可构造的。主要的API如下:
Reflect Metadata 是 ES7 的一个提案,它主要用来在声明时添加和读取元数据。在 Angular 2+ 的版本中,控制反转与依赖注入便是基于此实现。
1)Reflect Metadata提案的目标
- 许多用例(组合/依赖注入,运行时类型断言,反射/镜像,测试)都希望能够以一致的方式,向类中添加其他元数据
- 为了使各种工具和库能够推理出元数据,需要一种一致的方法
- 产生元数据的修饰符(nee。“Annotations”),通常需要与可变修饰符组合在一起
- 元数据不仅应在对象上可用,而且可以通过具有相关陷阱的Proxy可用?
- 定义生成元数据的装饰器,操作简单
- 元数据应与ECMAScript的其他语言和运行时功能一致
2)句法
- 元数据的声明式定义
-
class C { @Reflect.metadata(metadataKey, metadataValue) method() { } }
- 元数据的命令式定义
Reflect.defineMetadata(metadataKey, metadataValue, C.prototype, "method");
- 元数据的命令内省
let obj = new C();
let metadataValue = Reflect.getMetadata(metadataKey, obj, "method");
3)API
五、参数装饰器,入参转化与校验
- class-transformer 用于数据格式的转换
- class-validator 用于入参的数据验证
六、参数校验装饰器@Body代码实现
1、安装 reflect-metadata 包
npm install reflect-metadata –save
2、在tsconfig.json配置文件中,添加如下两个配置项:
3、在controller层中:
1) 使用@Body装饰器,传入入参名称、实参、数据结构类型
/app/controller/menuDev.ts:
...
@Post('/updateMenuDev')
async updateMenuDev (@Body('menuDevUpdate') menuDevUpdate:MenuDevUpdate) {
const { menuId } = menuDevUpdate
return await this.ctx.service.menuDev.updateMenuDev(menuDevUpdate, { menuId })
}
...
2) 定义好接口入参数据结构MenuDevUpdate类,与其字段类型,并在该类中为必填项字段、字符串数字期望转数字类型等,添加相应注解
/app/classes/menuDevUpdate.ts:
import { Number } from '../lib/decorator';
import MenuDev from './menuDev';
export default class MenuDevUpdate extends MenuDev {
@Number
menuId: number;
modifyUser: string;
modifyUserType: number;
updatedAt: Date;
}
/app/classes/menuDev.ts:
import { IsInt, IsNotEmpty } from 'class-validator';
export default class MenuDev {
@IsInt()
menuPid: number;
@IsInt()
applicationId: number;
menuPath: string;
@IsNotEmpty()
menuName: string;
menuIcon: string;
menuCategory: number;
menuOrder: number;
isHidden: boolean;
menuDesc: string;
}
3) 使用class-transformer,将实参进行数据格式转换,转化成MenuDevUpdate类的数据类型
4) 使用class-validator,对实参进行数据验证:
- 校验必填项字段,是否存在
- 校验字段类型,是否与期望类型相符
- 字符串数字,如果期望类型为数字,则需要转成数字类型,如果期望类型为字符串,则无需处理
……
4、定义@Body装饰器,在装饰器内部,使用Reflect Metadata,存储挂载在controller原型请求方法名上的元数据:
1) 挂载key为Symbol('routeArguments'),value 为如下格式的路由参数元数据(该元数据是累加的):
{
[`${paramtype}:${index}`]: {
paramtype, //路由参数类型(REQUEST=0、RESPONSE=1、BODY=2、QUERY=3、PARAM=4、HEADERS=5、FILE_STREAM=6)
paramIndex: index, //路由-参数类型-索引
propName: data, //路由-参数位置-索引
pipes //转换器
}
}
2) 当参数类型是:QUERY 或者PARAM时,key为Symbol('__routeArgumentTypesIndex__'),value为路由参数类型索引index的元数据;
以下是挂载在Constroller 原型上的元数据格式:
{
…
"请求方法名": {
…
Symbol('routeArguments'): { //路由参数(累加)
[`${paramtype}:${index}`]: { //路由参数类型(REQUEST=0、RESPONSE=1、BODY=2、QUERY=3、PARAM=4、HEADERS=5、FILE_STREAM=6)
paramtype, //路由-参数类型-索引
paramIndex: index, //路由-参数位置-索引
propName: data,
pipes
}
},
Symbol('routeArgumentTypesIndex'): index, //QUERY与PARAM参数独有,路由参数类型索引
Symbol('commonParamNum'): { //需要转成数字的字符串行数字入参字段(累加)
[name: string]: {
decoratortype: number,
name: string, //需要转化的字段名称
type: any //期望转换后的字段类型
}
}
}
}
然后按照如下流程进行操作:
七、总结
综上,我们实现了:
1、自定义入参校验装饰器
2、使用Reflect Metadata元数据,获取存储:获取入参的类型信息
3、使用class-transformer,将入参对象进行类型转化
4、使用class-validator,进行入参类验证器
以上是我们项目中装饰器的一个小实践应用,分享出现与大家一起交流学习。
目前,我们正在进行egg.js装饰器中间件开发封装,主要内容如下:
请大家敬请期待(๑•̀ㅂ•́)و✧(๑•̀ㅂ•́)و✧(๑•̀ㅂ•́)و✧!
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 扩展介绍 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Egg:是为企业级框架和应用而生的Node.js 渐进式开发框架。它奉行『约定优于配置』,按照一套统一的约定进行应用开发,可以帮助开发团队和开发人员降低开发和维护成本。并且具有高度可扩展的插件机制,和内置多进程管理,提供了基于 Egg定制上层框架的能力。
官网:https://eggjs.org
Tower:是基于Egg封装的,适用于京东内部的Node.js MVC框架,集成了京东内部常用的系统工具,并打通了JDos部署,通过Tower框架,可以帮助开发团队和开发人员降低开发和部署成本。
官网:http://tower.jd.com/