前言
服务端逻辑中的参数校验逻辑是必不可缺的部分。但校验逻辑也不外乎几种型,写多了显得无趣又繁杂。
为什么不尝试简化一下呢!
如果不想动手,可以拉到文章底部,我将一些基础的接口封装好并发布到了CocosStore。
开发环境
Cocos Creator 2.4.3 + TypeScript
注意,3.x版本的TS不支持 参数装饰器。编译后会出错,很可惜
。
思路
需求
首先,校验逻辑必须能够复用。比如要求购买数量大于0就是很常见的需求
。
其次,最好能够使检验逻辑和游戏逻辑分离,互不打扰,增加代码的可读性
。
方案
综上,得出了两个方案(非正式代码,知道意思就行了):
- 使用检测函数
function test(amount: number) {
checkParam(checkPositive, amount);
// 游戏逻辑...
}
function checkParam(checkFunc: Function, paramValue: any) {
if (checkFunc(paramValue)) {
return;
}
// 参数非法
}
function checkPositive(num: number) {
return num > 0;
}
- 使用装饰器封装检测函数
@validate
function test(@positive amount: number) {
// 游戏逻辑...
}
// 存储所有函数的校验函数信息,值为校验函数二维数组(参数下标为一维,参数的校验函数为二维)
const checkMap = new Map();
// positive装饰器:实际的校验逻辑,检测数字是否大于0
function positive(target: Object, propertyKey: string | symbol, parameterIndex: number) {
// 为函数的参数添加大于0的校验函数(保存到checkMap)
}
// validate装饰器:将函数标记为需要校验的函数
function validate(target: any, propertyName: string, descriptor: TypedPropertyDescriptor<Function>) {
// 封装函数
descriptor.value = function (...args: any[]) {
// 从checkMap取出每个属性的校验函数,进行校验
// 若通过 则调用原函数
// 若未通过 则参数非法
}
}
两个方案的实现核心逻辑是一样的,封装类似的检测逻辑,运行时进行校验。
函数校验的优势在于,调用更加直接,不需要额外的内存存储函数映射关系。
装饰器校验的优势则在于,和游戏逻辑做了明显的区隔。
显然本文选了第二个方案…
主要的原因是还是希望逻辑上的分离,即使检验函数增加了,也不会影响函数体内容
。
为什么需要两个装饰器?
上文中,我们实际需要实现 validate 和 checkPositive 两个装饰器。
validate 作为函数装饰器,只需要实现一次,checkPositive 作为参数装饰器,根据需求实现多个。
不能一个参数装饰器就好了嘛!emmm… 参数装饰器能获得的数据其实是有限的…
参数装饰器表达式会在运行时当作函数被调用,传入下列3个参数:
- 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
- 成员的名字。
- 参数在函数参数列表中的索引。
我们期望实现对参数值的校验… TS压根没给你提供参数值… 想不到吧
但实际原因其实是… 参数装饰器是在类初始化的时候调用的,而且只会调用一次:

所以… 实在是给不了啊…
实现
装饰器
或许你看到装饰器有一点点陌生,但其实我们一直都在用装饰器~
@ccclass
export default class MyComponent extends cc.Component {
@property(cc.Label)
label: cc.Label = null;
}
上文中的 @ccclass 、 @property ,就是装饰器啦!
希望了解装饰器,可以查阅TS装饰器文档。
实现逻辑
按照上文的思路,我们大概需要以下工作:
- 实现validate装饰器,将函数标记为需验证参数的函数
- 取出函数对应的校验函数信息
- 遍历入参,获取每个参数的校验函数数组
- 若参数通过所有校验函数,则调用原函数
- 若参数未通过校验函数,则认为参数非法
- 实现检验装饰器(如gt0),为参数添加一个校验函数
- 实现检验函数(如gt0Check),此函数内才是真正的校验逻辑
注意,这里比上文额外增加了一个步骤(将装饰器分离为两个函数)。
如果使用匿名函数来作为校验函数,出于debug、复用等方面考虑,较为不利。
还有一个问题!
我们的校验函数要存在哪里呢?上文中用的是 checkMap 变量。
我们在装饰器中可以取到类的原型对象,你可以选择将数据存在原型对象上。也可以像思路中提到的,简单声明一个常量(即 checkMap ),自己处理逻辑。
你也可以… 引入一个新的库
!
元数据(reflect-metadata)库可以让你定义一些数值在某个对象上,并在运行中进行获取,避免了修改原型导致的隐藏问题。
下载源码,并将其中的Reflect.ts、Reflect.d.ts放到项目内。
开干!
涉及文件
| 文件名 | 说明 |
|---|---|
| CheckerHelper.ts | 校验辅助类,放置所有校验相关的装饰器、校验函数、类型声明等。 |
| ManagerHero.ts | 测试用,TS类,模拟游戏服务端逻辑。 |
| Game.ts | 测试用,组件,挂载到界面上。 |
声明
主要涉及用到的类型等辅助性质的内容。
// CheckerHelper.ts
/**导入元数据脚本 */
import "./Reflect";
/**
* 参数校验错误的特殊Error
*/
export class ParamCheckError extends Error {
constructor(message: string) {
super(message);
this.name = "ParamCheckError";
}
}
/**参数校验函数。失败时返回错误信息,成功时返回null */
type ParamCheckerFunc = (value: Object) => boolean;
/**存储"参数校验函数数组"的key */
const SymbolCheckers = Symbol("checkers");
/**参数校验函数数组。每个函数持有1个本对象,参数下标和数组下标一一对应,每个参数都有对应的校验函数数组 */
type FuncParamCheckers = ParamCheckerFunc[][];
我们暂且设定只要参数非法,就抛出异常ParamCheckError。
实现validate装饰器
// CheckerHelper.ts
export function validate(target: Object, propertyName: string, descriptor: TypedPropertyDescriptor<Function>) {
// 缓存原函数
let originFunc = descriptor.value;
// 封装函数
descriptor.value = function (...args: Object[]) {
// 取参数校验函数
let paramCheckers: FuncParamCheckers = Reflect.getOwnMetadata(SymbolCheckers, target, propertyName);
if (paramCheckers) {
// 遍历入参
for (let i = 0; i < paramCheckers.length; i++) {
const checkers = paramCheckers[i];
if (!checkers) {
continue;
}
// 遍历参数对应的校验函数
for (let checker of checkers) {
const value = args[i];
let isAllow = checker(value);
if (!isAllow) {
// 参数非法,抛出错误
throw new ParamCheckError(`参数值校验失败, 规则: ${checker.name}\n位置: 类${target.constructor.name} 函数${propertyName} 参数${i}`);
// 或者仅输出
// console.log(`参数值校验失败, 规则: ${checker.name}\n位置: 类${target.constructor.name} 函数${propertyName} 参数下标${i} 参数值${value} `);
}
}
}
}
return originFunc.apply(target, args);
}
}
实现校验函数
// CheckerHelper.ts
/**
* 添加一个校验函数
* @param target 原型对象
* @param propertyKey 函数名
* @param parameterIndex 参数下标
* @param checker 校验函数
*/
function addChecker(target: Object, propertyKey: string | symbol, parameterIndex: number, checker: ParamCheckerFunc) {
// 取出参数的校验函数数组
let paramCheckers: FuncParamCheckers = Reflect.getOwnMetadata(SymbolCheckers, target, propertyKey) || [];
let checkers = paramCheckers[parameterIndex] || (paramCheckers[parameterIndex] = []);
checkers.push(checker);
// 记录参数的校验函数数组
Reflect.defineMetadata(SymbolCheckers, paramCheckers, target, propertyKey);
}
/**
* 添加校验函数-大于0
* @param target 原型对象
* @param propertyKey 函数名
* @param parameterIndex 参数下标
*/
export function positive(target: Object, propertyKey: string | symbol, parameterIndex: number) {
addChecker(target, propertyKey, parameterIndex, checkPositive);
}
/**
* 校验函数-大于0
* @param parameterValue 参数值
*/
function checkPositive(parameterValue: number): boolean {
return parameterValue > 0;
}
测试
// ManagerHero.ts
export class ManagerHero {
@validate
public addHero(@positive heroId: number): void {
console.log("添加人物", heroId);
}
}
// Game.ts
@ccclass
export default class Game extends cc.Component {
start() {
const managerHero = new ManagerHero();
managerHero.addHero(1);
managerHero.addHero(-1);
}
}
好使
。
全局错误处理
我们为校验错误定义了一种特殊的Error类型ParamCheckError,这是为了在捕获错误的时候,方便进行区分。
// Game.ts
window.onerror = function (message, source, lineno, colno, error) {
if (error.name === "ParamCheckError") {
// 上传日志\记录\封号
console.log("发生参数错误");
}
}
注意,原生平台需要使用 window.__errorHandler 。
如果你并不希望抛出错误或者令游戏卡死,将这些逻辑写在validate中即可。
带参数的校验函数
实际使用过程中,并不是所有的参数都能够使用统一的校验函数。
这个时候校验函数实现起来会比较不同,比如我们需要可配置的参数。
// CheckerHelper.ts
/**
* 参数校验-数值大于等于
* @param value 最小值(包含)
*/
export function min(value: number) {
/**
* 校验函数-数值大于等于
* @param parameterValue 参数值
*/
function checkMin(parameterValue: number): boolean {
return parameterValue >= value;
}
/**
* 装饰器函数-数值大于等于
* @param target 原型对象
* @param propertyKey 函数名
* @param parameterIndex 参数下标
*/
function decoratorMin(target: Object, propertyKey: string | symbol, parameterIndex: number) {
addChecker(target, propertyKey, parameterIndex, checkMin);
}
return decoratorMin;
}
这里使用装饰器工厂实现校验函数,并利用了闭包的特性,使得每个gtChecker函数持有我们传入的value值,满足游戏逻辑的各种数值需求。
使用起来也很简单:
// ManagerHero.ts
export class ManagerHero {
@validate
public addHero(@min(0) heroId: number): void {
console.log("添加人物", heroId);
}
}
总结
本文提供了一种新的参数校验方式,对比自己动手写可以少掉大量重复、简单的逻辑,帮助我们把时间用在更重要的事情上。并且可以保持游戏逻辑的清晰,提高可读性。同时也可以随需要进行扩展。
方案的缺点是需要额外的空间存储校验函数,并且需要额外调用若干函数。但对比优点,私以为完全可接受。
本文中只简单地拿了几个校验参数作为示例,但在实际运用中,你可以实现各种各样的校验函数,比如数值范围、非空、id是否存在、正则匹配、字符串长度、参数类型等等…
示例中,使用的程度也是比较浅的,我们还可以为校验装饰器增加错误严重度等参数,实现更加灵活的校验反馈形式。
本文的起源是为了解决参数校验较为繁琐。
一开始想到的是Spring中的参数校验(也是使用装饰器的方式),研究过程中翻看TS文档,发现文档中对装饰器的使用示例也是用来做校验!遂成本文。若有考虑不周的地方还望各位指教。
广告
如文章开头所说,我实现了一批基础的接口,如果你不想动手,或者想看看实际的使用方式,可以通过以下CocosStore链接获取(需要一瓶快乐水):
TS装饰器参数校验示例源码
相关链接
装饰器 · TypeScript中文网 · TypeScript——JavaScript的超集
GitHub: reflect-metadata
