定义checker
Checker是Oak框架中另一种强大的机制,用于检查对某个对象操作的合法性。在实际应用中,对业务对象的各种业务型操作会有非常复杂的限制条件,而这些限制条件如果依靠程序员手写检验代码,不仅会使得代码迅速膨胀到难以维护,而且还会存在非常麻烦的前后端逻辑不一致问题。
和从字面上的理解一样,checker就是让程序员来定义:当对某个entity进行某项action时,必须满足的条件。这个条件可能落在更新的数据上,也可能落在现有的数据上。我们在后面将展开阐述。
checker的编写规范
Checker编写在src/checkers目录下,可以根据checker所关联的entity来分文件存放。例如:对system对象的checker就可以写在src/checkers/system.ts文件中:
import { Checker } from '@oak-domain/types/Trigger';
import { EntityDict } from '../oak-app-domain/EntityDict';
import { BackendRuntimeContext } from '../context/BackendRuntimeContext';
const checkers: Checker<EntityDict, 'system', BackendRuntimeContext>[] = [
....
];
export default checkers;
再导出集成到src/checkers/index.ts中
import systemCheckers from './system';
export default [
...systemCheckers,
];
checker的定义
checker的前四个属性定义和trigger是完全一致的,见trigger章节的解释:
| 属性 | 取值范围 | 是否必填 | 含义 |
|---|---|---|---|
| entity | EntityDict中的对象 | 是 | 检查的对象 |
| action | 该entity的action | 是 | 检查的操作 |
| priority | 1-99 | 否 | 检查器执行的优先级(只有当entity和action完全相同时才有意义),数字越小优先级越高 |
| mt | 'create'/'apply'/'both' | 否 | 当存在延时更新Modi时的行为控制 |
第五个属性type定义了checker的类型,目前checker一共分为五种类型:
- data:对此次Operation数据的检查
- row:对Operation当前行(也包括相关行)的检查
- logical:对此次Operation行为有前置的补充操作
- logicalData:对此次Operation行为有前置的、修改Operation中的data的操作
- relation:判定用户是否有Operation的权限,这是一种虚拟的类型,一般不需要用户手动编写
data类型
当type定义成data时,在checker属性上可以声明一个函数,其传入的参数为本次action的data。在函数内可以对data进行检查,若不合法可以抛出异常。
例如,如果业务逻辑限制Address对象(见编写对象章节)的name属性不能为空,则可以像下面这样编写:
import { OakAttrNotNullException, Checker } from '@oak-domain/types';
...
{
entity: 'address',
action: 'create',
type: 'data',
checker: (data) => {
if (!data.name) {
throw new OakAttrNotNullException('address', ['name'], '地址命名不能为空');
}
}
}
这里使用到了一个通用的异常类型OakAttrNotNullException,表示某个非空属性被置空。当然您也可以定义自己的异常类型,见异常定义。
不过在实际使用中,上面这个checker并不需要用户显式定义,只要我们在定义address时,其name属性没有加"?"修饰符,框架会自动生成检查本属性非空的checker。另外系统会根据entity定义一系列的数据型检查,例如参见Address对象的定义,每当发生创建或更新时,系统都会自动去检查:
- detail的长度是否小于等于32
- default是否是有效的boolean类型
等等。但是有些复杂的检查还是得依赖用户自己编写,例如检查phone是否是一个合法的电话号码格式(代码参照oak-general-business/src/checkers/address.ts):
import { OakInputIllegalException, Checker } from '@oak-domain/types';
import { isMobile } from '@oak-domain/utils/validator';
{
type: 'data',
action: 'update',
entity: 'address',
checker: (data) => {
if (data.hasOwnProperty('phone') && !isMobile((data as EntityDict['address']['Update']['data']).phone!)) {
throw new OakInputIllegalException('address', ['phone'], '手机号非法');
}
return;
},
}
框架为 data 类型的 checker 定义了三种通用异常,可以参见 @oak-domain/types/Exception 中的定义:
| 异常 | 描述 |
|---|---|
| OakInputIllegalException | 泛指输入非法 |
| OakAttrNotNullException | 非空属性为空 |
| OakAttrCantUpdateException | 属性不允许更新 |
您也可以定义更加精细的输入数据异常,遵照规范请继承OakInputIllegalException这一类型。
row类型
当type定义为row时,在filter中可以声明检查当前被更新的数据必须满足的条件。
例如,我们如果要限制只能更新收货人名称为“张三”的收货地址(这当然并不是一个有实际意义的限制),那可以像下面这样写:
{
type: 'row',
entity: 'address',
action: 'update',
filter: {
name: '张三',
},
}
当然,filter也可以写成函数形式,比如我们要限制address只能由本人修改:
{
type: 'row',
entity: 'address',
action: 'update',
filter(operation, context) {
return {
entity: 'user',
entityId: context.getCurrentUserId(),
};
},
}
对当前数据的限制不仅仅可以落在当前entity上,也可以落在行所关联的其它entity上。例如,我们限制只有浙江省杭州市的地址可以被更新:
{
type: 'row',
entity: 'address',
action: 'update',
filter: {
area: {
name: '杭州市',
},
},
}
如果action是create,则row类型的限制必须落在行所关联的其它entity上,像下面这种写法:
{
type: 'row',
entity: 'address',
action: 'create',
filter: {
area: {
level: 3, // level 3意味着这是一个行政区规划
},
},
}
这个checker限制了创建地址时,其关联的area必须指向区这一级别。
如果action是select,则对应的filter会应用到查询数据时,例如如果我们定义:
{
type: 'row',
entity: 'address',
action: 'select',
filter: {
detail: {
$exists: true,
},
},
}
则当应用查询address时,所有detail为空的行都不会被返回。
当row类型的checker不通过,框架默认会抛出OakRowInconsistencyException类型的异常,这个异常是通知前台数据缓存过期(因为checker在前台也应该起检查作用,所以后台会认为是前台数据的状态和后台不一致导致请求被通过)。在定义checker时,可以通过以下参数注入一些发生异常时的处理:
- errMsg: string类型,出错时的提示;
- err: Exception,可以取代默认的OakRowInconsistencyException异常类型(但最好是继承这个异常类型的新异常);
- inconsistentRows: 当发生异常时,通过此定义可以返回更多的数据,去更新前台缓存。
row类型的filter还有一个很有用的参数:conditionalFilter,其类型可以是一个本entity上的filter,也可以是一个返回filter的函数。这个参数可以用来限制checker作用的行的条件,即只有当action作用在满足conditionalFilter限制的行上时,此checker才起作用,相当于在Trigger章节中介绍的filter参数。
logical类型
logical类型的checker可以用来补充用data和row类型无法表达的复杂逻辑。例如,我们规定在系统中,除了root超级管理员,其余用户都只能创建自己用户的Address信息,这个checker涉及的条件无法用单一的row来表达,此时我们用logical类型来写:
{
type: 'logical',
action: 'create',
entity: 'address',
checker: (operation, context, option) => {
const { action, data } = operation as EntityDict['address']['CreateSingle'];
const { id, entity, entityId } = data as EntityDict['address']['CreateOperationData'];
if (entity === 'user' && !context.isRoot()) {
const userId = context.getCurrentUserId();
if (userId !== entityId) {
throw new OakOperationUnpermittedException('address', operation);
}
}
}
}
Logical checker的第二种用途可以类比before类型的trigger,在operation发生之前进行一些操作。比如像下面这样,当创建一个默认地址时,将同一用户下的其它默认地址更新为非默认:
{
type: 'logical',
action: 'create',
entity: 'address',
checker: (operation, context, option) => {
const { action, data } = operation as EntityDict['address']['CreateSingle'];
if (data.default) {
const { id, entity, entityId } = data as EntityDict['address']['CreateOperationData'];
return context.operate('address', {
id: generateNewId(),
action: 'update',
data: {
default: false,
},
filter: {
id: {
$ne: id,
},
entity: entity!,
entityId: entityId!,
}
}, option);
}
}
}
当然上面这个checker可以(也应该)被优先写成trigger的形式,checker更多时候起到检查操作的作用。但有一种情况例外,当某个checker的存在导致操作无法继续进行时,需要在这个checker之前来做一些操作。例如,根据上面这个例子,假如还有一个checker来检查,每个用户只能有一个默认的default地址,则会导致当已经有一个default地址时,无法再插入一个新的default地址,这时候就需要再用一个logical类型的checker来提前处理(虽然从理论上来说,有了这个负责default唯一的checker之后,不需要再做额外的检查了)。
logicalData类型
logicalData类型和logical类型的第二种作用完全一致,只不过它被用来标识“这个checker会修改operation中的data”。这类checker的意义就是赋予data一些默认的初始值。例如,如果我们规定:若用户没有设置address中的地区,就默认将它设置为杭州市:
{
type: 'logicalData',
action: 'create',
entity: 'address',
checker: (operation, context, option) => {
const { data } = operation;
if (!data.areaId) {
data.areaId = 330100;
}
}
}
同步和异步混写
checker 不要因为其中某个分支需要 context.select(...),就把整个 checker 函数直接写成 async。
原因是 checker 会同时服务前端同步检查和后端异步检查。Oak 在同步上下文里会直接调用 checkerFn(...),不会等待一个 Promise;而 async function 无论内部有没有真的执行到 await,都会返回 Promise,连同步抛出的异常也会变成 rejected Promise。这样一来,本来应该立即失败的前端同步检查可能失效。
如果一个 checker 里既有同步分支,又有可能异步的分支,应该使用 @oak-domain/utils/executor 里的 pipeline(...):
import { pipeline } from '@oak-domain/utils/executor';
import { OakOperationUnpermittedException } from '@oak-domain/types';
{
type: 'logical',
action: 'create',
entity: 'address',
checker: (operation, context, option) => {
const { data } = operation as EntityDict['address']['CreateSingle'];
const { entity, entityId } = data as EntityDict['address']['CreateOperationData'];
if (entity !== 'user') {
return;
}
if (!entityId) {
throw new OakOperationUnpermittedException('address', operation);
}
return pipeline(
() => context.select('user', {
data: {
id: 1,
},
filter: {
id: entityId,
},
indexFrom: 0,
count: 1,
}, {
...option,
dontCollect: true,
blockTrigger: true,
}),
(users) => {
if (users.length === 0) {
throw new OakOperationUnpermittedException('address', operation);
}
}
);
},
}
pipeline(...) 的特点是:每一步可以返回普通值,也可以返回 Promise;如果前面的步骤都是同步结果,它就保持同步返回;只有真正遇到 Promise 时才进入异步链。这样同一个 checker 才能兼容前端同步路径和后端异步路径。
因此,下面这种写法应该避免:
checker: async (operation, context, option) => {
if (!needQuery(operation)) {
return;
}
const rows = await context.select(...);
if (rows.length === 0) {
throw new OakOperationUnpermittedException('address', operation);
}
}
简单规则是:checker 默认写成普通函数;只有某个分支确实返回了异步结果时,才通过 pipeline(...) 把后续步骤接起来。
属性更新矩阵 attrUpdateMatrix
有一类 checker 非常常见:限制“某个对象的哪些属性,在什么 action 下、什么行状态下可以更新”。如果每个属性都手写 checker,代码会很快变得零散。Oak 为这类规则提供了一个配置化能力:src/configuration/attrUpdateMatrix.ts。
它的含义可以直接从名字理解:属性更新矩阵。矩阵的第一层是 entity,第二层是属性,每个属性可以声明:
actions:哪些 action 可以更新这个属性;filter:被更新的当前行必须满足什么条件。
例如支付账户里常见的限制可以写成这样:
import { AttrUpdateMatrix } from '@oak-domain/types/EntityDesc';
import { EntityDict } from '../oak-app-domain';
const attrUpdateMatrix: AttrUpdateMatrix<EntityDict> = {
offlineAccount: {
name: {
actions: ['update'],
filter: {
sysAccountOper$entity: {
'#sqp': 'not in',
},
},
},
allowPay: {
actions: ['update'],
filter: {
enabled: true,
},
},
price: {
actions: ['pay', 'refund', 'deposit', 'withdrawTransfer'],
},
},
};
export default attrUpdateMatrix;
上面的配置表示:
offlineAccount.name只能通过update修改,并且当前账户不能已经有关联的sysAccountOper;offlineAccount.allowPay只能通过update修改,并且当前行必须满足enabled: true;offlineAccount.price只能由pay、refund、deposit、withdrawTransfer这些业务 action 修改。
这个文件不是只给后端看的配置。项目启动时,src/configuration/index.ts 会把它作为 CommonConfiguration 的 attrUpdateMatrix 导出,前后端都会读取这份配置。后端会把它注册成内置 checker;前端也会用它参与操作合法性检查,并在声明 action attrs 时推导当前 action 可以更新哪些属性。
最重要的一点是:只要某个 entity 出现在 attrUpdateMatrix 里,这个 entity 的更新属性就进入严格白名单模式。没有写在矩阵里的属性,不允许被更新。框架内部的 $$updateAt$$、$$triggerData$$、$$triggerUuid$$ 这类字段除外。
所以给某个对象增加矩阵时,不要只写当前正在关心的一个属性;要同时检查这个对象上所有仍然允许被业务更新的属性是否都已经列出来。否则,一个原本合法的普通更新可能会因为属性没有进入白名单而抛出 OakAttrCantUpdateException。
filter 既可以是静态 filter,也可以是函数:
filter: ({ action, data, filter }, context) => {
return {
enabled: true,
};
}
函数形式适合需要根据 action、提交数据或当前上下文动态生成限制条件的场景。但它依然属于前后端共享 checker 逻辑,写的时候要注意:如果规则需要依赖后端才能查询到的数据,前端同步检查可能无法完整判断。能用静态 filter 表达时,优先使用静态 filter;复杂到难以用属性、action、filter 表达时,再考虑手写 checker。
attrUpdateMatrix 适合表达属性级的更新权限、状态约束和动作约束,不适合表达副作用。比如“账户启用后才允许打开支付开关”适合放在这里;“支付成功后同步创建流水、通知外部系统”应该放在 trigger 或 aspect 里。
relation
这种类型的语义是用于判定用户是否有进行此项操作的权限。在Oak框架中,对权限的判定设计可以参见权限章节。为了对权限进行更灵活的管理,权限被抽象成为了数据表示,不再需要专门编写固定逻辑的checker。系统保留了这一关键字,只是为了在有的接口中表示“要进行权限类型的checker检查”。
checker的内部实现
在Oak框架中,checker的实现原理和trigger一致,都发生在满足所定义条件的某次operation执行之前。
五种类型的checker中,只有logicalData类型的可能改变operation中的data值,它具有最高的执行优先级。logical可能会修改库中的其它相关数据,它的优先级次高,而剩下三种checker都不应当对operation或者现有数据做任何改变。在Oak框架中它们的优先级定义如下:
| 类型 | 优先级 |
|---|---|
| logicalData | 31 |
| logical | 33 |
| row | 51 |
| data | 61 |
而默认的trigger优先级定为50,所以如果不手动定义priority,logicalData和logical类型的checker会在before类型的trigger之前执行,而另外三种checker会在trigger之后执行。
checker和trigger最重要的区别在于:trigger只会在后台(服务器端)执行,而checker在前端也一样可以执行。这就意味着,在Oak框架下,可以将对数据操作的检查进行唯一清晰化的表达,从而获得全局一致性的目标。
在前端快速判定操作权限
利用actions属性定义检查操作
在定义组件一章中,我们提到在 OakComponent 的配置项中,有一个名为 actions 的项,在其中可以定义并检查当前用户对当前数据的操作权限。
export default OakComponent({
entity: 'address',
isList: true, // 列表组件
actions: ['create', 'update'],
});
上面代码表示,要检查当前用户是否具有:
- 执行创建(满足filter约束条件的)address操作的权限
- 执行更新(查询出来的)address行的权限
检查结果会在formData的参数中以如下格式返回:
- 如果有对create动作的检查,在formData的参数中会有一个legalActions项,如果create动作通过检查会出现在其中;
- 如果有对其它动作的检查,在formData的参数data中的每一行返回数据中,会有一个#oakLegalActions项,其中包含了通过检查的动作。
所以在上面的例子中,可以像下面的代码一样来检查action是否可以执行:
formData({ data, legalActions }) {
const creatable = legalActions.includes('create');
const rows = data.map(
(row) => {
const { '#oakLegalActions': actions, ...rest } = row;
return {
...rest,
updatable: actions.includes('update');
}
}
);
return {
creatable,
rows,
};
}
如果是详情组件(参数中的isList声明为false),写法和上面类似,而在详情组件时,legalActions中就包含了对这行数据的判定结果了:
formData({ data, legalActions }) {
const updatable = legalActions.includes('update');
return {
updatable,
row: data,
};
}
actions检查逻辑
列表组件和详情组件中对actions的检查逻辑如下:
对查询出来的每一行数据,尝试当前用户去对之进行create以外的动作检查。检查的范围包括logical、relation和row三类checker。
对于create动作,系统会把List上的固定filter条件当成create的数据。例如,如果某个List上定义了如下的filter:
export default OakComponent({
entity: 'address',
projection: ...,
filters: [
{
filter() {
const userId = this.features.token.getCurrentUserId();
return {
entity: 'user',
entityId: userId,
};
}
}
],
isList: true, // 列表组件
actions: ['create', 'update'],
})
则框架知道在这个address的List上,有一个原生的过滤条件(地址指向当前用户),在测试是否可以create新地址时,就会把{entity: 'user', entityId: 'myUserId'}作为创建的数据进行判定。
List上哪些filter是原生过滤条件?目前只有在OakComponent中被声明的filter才被认为是列表的原生过滤条件,而在页面生命周期中通过addNameFilter接口动态添加上去的filter都会被忽略。
详情组件一般不对create动作加以判定,因为无法界定create动作的限制条件。
给actions增加默认数据
也可以对要检查的action赋予默认的data,如果确定在这个页面上执行此动作一定会赋予这些属性某些值的话。例如,假如我们创建的address一定会关联某一用户,就可以这样写(无需考虑这个需求是否合理):
export default OakComponent({
entity: 'address',
isList: true;
action: [{ action: 'create', data: { entity: 'user' }}],
formData({ data, oakLegalActions }) {
const creatable = oakLegalActions.find(
ele => ele.action === 'create',
);
....
}
});
也可以通过函数调用动态赋值,例如我们要创建的address一定会关联在当前用户上:
export default OakComponent({
entity: 'address',
isList: true;
action: () => {
return [
{
action: 'create',
data: {
entity: 'user',
entityId: this.features.token.getCurrentUserId(),
},
}
]
},
formData({ data, oakLegalActions }) {
const creatable = oakLegalActions.find(
ele => ele.action === 'create',
);
....
}
});
检查更新组件的操作权限
如果当前组件是更新组件,可以在formData中调用this.tryExecute接口来判定当前组件上的operation是否可以执行。
{
entity: 'address',
isList: false,
...
formData() {
const executable = this.tryExecute(); // true/false/Exception
return {
executable,
};
}
}