定义trigger
在对您的业务数据进行各种增删改查操作(即对某对象的Operate行为)时,可以通过定义trigger来设置数据之间的联动。Trigger的作用是在于“当某种符合规则的Operation发生时,可能会触发其它数据的联动行为”。(可以将之理解为传统Database中的触发器,用来保持数据之间的一致性)。
当然Trigger本身也支持由Select触发,我们在本节的最后小节会加以描述
trigger的编写规范
trigger编写在src/triggers目录下,可以根据trigger的entity来分文件存放。例如:对system对象的trigger就可以写在src/triggers/system.ts文件中:
import { Trigger } from '@oak-domain/types/Trigger';
import { EntityDict } from '../oak-app-domain/EntityDict';
import { BackendRuntimeContext } from '../context/BackendRuntimeContext';
const triggers: Trigger<EntityDict, 'system', BackendRuntimeContext>[] = [
....
];
export default triggers;
再导出集成到src/triggers/index.ts中
import systemTriggers from './system';
export default [
...systemTriggers,
];
trigger的定义
trigger的定义可以参见oak-domain/types/Trigger.ts
一个trigger有以下属性需要定义:
| 属性 | 取值范围 | 是否必填 | 含义 |
|---|---|---|---|
| entity | EntityDict中的对象 | 是 | 触发的对象 |
| action | 该entity的action | 是 | 触发的操作 |
| name | 字符串 | 是 | 给trigger命名,便于后续跟踪调试(命名需要唯一) |
| priority | 1-99 | 否 | 触发器执行的优先级(只有当entity和action完全相同时才有意义),数字越小优先级越高 |
| when | 'before'/'after'/'commit' | 是 | 执行时机,在操作前/后/提交时 |
| strict | 'takeEasy'/'makeSure' | 否 | 是否需要严格执行(只有当when为commit时才有意义),见下文解释 |
| attributes | entity的属性 | 否 | 更新的属性(只有当action为update时才有意义),如果更新的属性和定义的attributes没有交集,则此trigger不会被触发 |
| check | function | 否 | 更新的数据检查(只有当action为update/remove时才有意义),如果更新/删除的操作不满足检查,则此trigger不会被触发 |
| filter | 该entity的Filter/function | 否 | 更新的条件检查(只有当action为update/remove时才有意义),如果更新/删除的数据条件不满足filter,则此trigger不会被触发 |
| mt | 'create'/'apply'/'both' | 否 | 当存在延时更新Modi时的行为控制 |
| fn | function | 是 | 触发器的行为 |
以下代码定义了一个system对象相关的trigger(代码来自oak-general-business/src/triggers/system.ts)
{
name: '当system删除前,删除相关的passports',
entity: 'system',
action: 'remove',
when: 'before',
fn: async ({ operation }, context, option) => {
const { filter } = operation;
await context.operate('passport', {
id: await generateNewIdAsync(),
action: 'remove',
data: {},
filter: {
system: filter,
},
}, option);
return 1;
},
}
这个trigger所定义的行为就是:当system对象被删除之前,先将其关联的passport对象全部删除(可以类似于Database中外键删除的处理)。
注意两个额外的细节,一是删除外键的trigger一般用before在动作之前触发,二是fn中如何将本operation对system的filter快速移植到对passport对象的filter上
关键概念解释
action
一个entity的action包括了在编写此对象时显式定义的Action,也包括通用的Action,相关描述可见编写对象。
在定义trigger时,action项可以是单个Action,也可以是Action的数组类型。当定义为数组时,数组中的任一Action发生时,均会触发此trigger。
priority
定义触发器的优先级。当entity与action相同时,按照priority定义的由小到大的顺序进行执行。
一般而言,对同一个entity的相同action,我们推荐将需要触发的行为定义在同一个entity当中,这样更利于代码的可维护性。但因为action可以支持数组,在这种情况下也需要使用priority来规范相关顺序。
当前实现中的默认trigger优先级是50。如果确定需要显式定义优先级,请优先围绕这一默认值进行调整,并结合checker的优先级表一起考虑执行顺序。
when
"before"和"after"的行为是比较容易从字面上理解的,这里要注意的是,当when定义为after时,此operation已经发生,此时使用operation的filter再去查询数据不一定成立(如果operation的data中更新了相关属性)。
因此,如果要进一步修改此operation的对象或相关联的对象,一种推荐的写法是在before的trigger中,在data中增加相应的属性(包括cascade属性),见查询和操作对象。
在before类型的trigger中,可以通过修改operation.data来影响后续持久化的数据:
{
name: '创建订单时自动填充默认值',
entity: 'order',
action: 'create',
when: 'before',
fn: async ({ operation }, context, option) => {
// 直接修改operation.data,会被带入后续的持久化过程
if (!operation.data.status) {
operation.data.status = 'pending';
}
return 1;
},
}
"commit"的意义和其字面上完全相同,就是“当此operation的事务实际提交时”(再触发)。这里就隐含了一个概念,所有when被声明为的before/after的trigger,其定义的相应的行为都会和operation发生在同一事务中,得到一致性的保护。而一旦trigger被设置为"commit",则只有当操作实际成功时才会触发(也不会得到事务的保护)。commit类型的trigger往往发生在一些需要和外部发生逻辑的地方,例如:当用户上传了100个电话号码(100次Create),需要向这100个号码发短信时。如果这时候trigger写成after,可能会发生什么?
strict
当when定义为"commit"时,strict域的定义尤其重要,它相当于一种“跨系统的一致性保护”能力。当:
- strict定义为"takeEasy"时,此trigger无论成功或失败,只会尝试执行一次(默认行为)
- strict定义为"makeSure"时,此trigger必须执行成功,否则会反复执行直到成功
在上面的例子中,如果这次短信发送必须成功,则可以把strict设置为makeSure。此时的100次Create会触发调用外部短信发送接口100次,如果其中有某次发送失败,Oak会反复调用直到发送成功(但外部短信系统应如何正确处理这种行为,使接口具有幂等性并不是Oak可以控制的)。
filter
当action为更新时(所有自定义的Action也会被视作更新)且when为"before"时,如果有定义filter,则意味着只有当该Operation的filter条件和此filter条件不“冲突”时,此trigger才会被触发。所谓“不冲突”是指这两个filter所定义的查询范围可能存在交集。
例如:如果一个trigger的定义的filter如下(对象为编写对象章节所定义的Address):
{
entity: 'address',
action: 'update',
filter: {
phone: '12345',
},
}
而当一个Address对象上的Operation为:
{
action: 'update',
data: {...},
filter: {
phone: '54321',
},
}
此时Oak会判定这个Operation的目标行一定和trigger定义的数据范围相冲突,所以trigger不会执行。
但如果另一个Operation为:
{
action: 'update',
data: {...},
filter: {
name: 'xc',
},
}
则Oak无法确定这两个filter定义的数据范围是否有交集(和实际数据相关),所以这个trigger会被执行。
由上述例子可见,filter的作用是:当某个filter只针对一个非常小的范围有效时,可以有效降低trigger不必要的调用次数。
mt
mt 和对象的 modi(延时更新)机制有关,用来声明一个 trigger 在 modi 场景中的执行时机,可取:
create:只在创建modi时执行;apply:只在真正应用modi到目标对象时执行;both:两个阶段都执行。
如果不显式声明,Oak 的默认行为是:
when: 'commit'的 trigger 默认只在apply阶段执行;- 非
commit的 trigger 默认只在create阶段执行。
因此,只有当你的业务真的接入了 modi 审批/延时更新流程时,才需要认真设置 mt;普通对象上的 trigger 往往不需要关心它。
fn
fn是定义trigger的行为,它是一个异步函数,有三个调用参数:
- object,其中存放本次触发的数据上下文
- operation:导致本次触发的operation本身
- result:如果是select动作的filter且when为after时,这个属性会包含即将返回的数据本身
- context,本次执行的上环境上下文
- option,本次执行的配置
要注意,在fn中的所有异步行为(操作其它数据)都需要用async关键字保护,否则会发生不一致行为。
fn的返回值可以是一个整数,代表在这个fn中影响(或操作)的数据行数,用于调试输出。如果trigger执行过程中抛出异常:
- before/after类型的trigger会回滚整个事务
- commit类型的trigger(跨事务trigger)的行为见下文"跨事务trigger的错误处理"
Trigger的执行机制
当Oak系统中执行某个Operation时,会检查与这个operation有关连的trigger(根据entity/action/data/filter),并将它们根据when的定义分成三类,接下来系统会:
- 按照priority从小到大的顺序执行before类型的trigger
- 执行operation
- 按照priority从小到大的顺序执行after类型的trigger
而commit类型的trigger会延时到operation提交(类似关系数据库的事务提交)之后,这种类型的trigger的行为和普通trigger区别较大,更多的细节请参看下一小节。
trigger的这种执行机制意味着可能产生递归调用,例如:在一个对A的operation触发了对B对象的另一个operation,然后后一个operation又触发了对C对象的一个operation……,因此在设计系统的时候,对象之间有关联逻辑的先后顺序应加以仔细制定,一个原则是:
应尽量减少trigger的数量,相同entity相同action的trigger尽量进行合并。
另外,trigger只在后台执行,这点是和checker的重要区别。
跨事务trigger
前面在when和strict配置项中已经初步诠释了跨事务trigger的基本含义和使用方法,跨事务trigger是业务系统和外部系统达成数据一致性的重要手段。在Oak的实现中,before/after的trigger会和触发Operation处于同一事务当中,从而保证行为的一致性。而对于跨业务系统的行为一致性较难实现,下面先简要介绍跨事务trigger的实现过程:
- 在 Oak 执行某 Operation 之前,commit trigger 都会注册事务提交回调;只有
strict: 'makeSure'还会在更新数据中加入两个持久化属性:
| 属性 | 类型 |
|---|---|
$$triggerUuid$$ | uuid |
$$triggerData$$ | object |
这是 Oak 框架自动管理的内置属性。
$$triggerData$$记录 trigger 名称、序列化上下文和 option,业务代码通常不应直接读写。
-
事务提交后,回调函数以
{ ids }作为第一个参数调用 trigger。makeSure成功后,框架会清除上述两个属性;若设置了cleanTriggerDataBySelf,则由 trigger 自行清理。 -
若
makeSuretrigger 执行失败,这两个属性不会被清除,checkpoint 会在后续 watcher 周期继续发现并重试;当前AppLoader的周期是 2 分钟。标记已物化到数据行,因此应用重启后仍可继续收敛。takeEasy不写入这些标记,也不会进入 checkpoint 重试。
跨事务trigger的错误处理
当跨事务trigger执行失败时:
- 如果
strict为takeEasy(默认),只在事务提交后尽力执行一次,失败不会重试; - 如果
strict为makeSure,失败状态会保留在数据行上,由 checkpoint 反复执行直到成功。
只有 makeSure 会通过 $$triggerUuid$$ 和 $$triggerData$$ 追踪失败状态,即使应用重启后也会继续重试。
跨事务trigger的fn
跨事务trigger的执行函数和普通trigger有两个不同:
- fn的第一个调用参数里增加了一个参数ids,以数组形式传入本次trigger涉及的id;
- fn可以返回一个更新数据对象或回调函数,如果返回的是更新数据对象,框架会在消除triggerUuid和triggerData属性的同时将这部分数据更新到数据行上,如果返回的是一个回调函数,框架会在消除triggerUuid和triggerData后执行此函数(同一事务保护)。
跨事务trigger的配置
对于跨事务trigger,除了strict之外还有一些额外的配置,简要介绍如下:
| 属性 | 取值范围 | 是否必填 | 含义 |
|---|---|---|---|
| cs | true | 否 | 在集群环境下,这个trigger涉及的数据将被分配到固定的结点上执行 |
| singleton | true | 否 | 在集群环境下,这个trigger将只会在唯一的实例上执行 |
| cleanTriggerDataBySelf | true | 否 | triggerUuid和triggerData不会自动清除 |
| grouped | true | 否 | 被同一trigger所涉及的不同批次的行,在重复执行时将被合并执行 |
当Oak框架(所编写的应用)运行在集群环境中时,会存在一个以上的应用实例。此时对trigger的处理非常微妙,会有更复杂的需求出现,我们用一个例子来加以说明:
有一个拍卖应用,当有人对某一拍品出价时,就开启一个倒计时,在10分钟之后落锤。如果在10分钟之内有人出更高的价格,就重新开始计时。
在实现上,我们需要在“拍品”这一对象(Collection)上设计一个属性“落锤时间”(confirmedAt),当这一属性被更新时,为之创建一个定时器:
{
name: '当落锤时间确定时,更新其定时器',
entity: 'collection',
action: 'confirm',
when: 'commit',
cs: true, // 标识这个trigger是cluster sensative
fn: async ({ ids }, context) => {
const collections = await context.select('collection', {
data: {
id: 1,
confirmedAt: 1,
},
filter: {
id: {
$in: ids,
},
},
});
for (const col of collections) {
const { id, confirmedAt } = col;
if (Timers[id]) {
clearTimeout(Timer[id]); // 清除上一次落锤的定时器
}
Timers[id] = setTimeout(() => {
.... // 落锤的逻辑
}, confirmedAt - Date.now());
}
}
}
这种实现方案有一个潜在的问题:即在集群环境下,每次处理同一个拍品的trigger可能在不同进程中被触发,而Timers是一个内存化的定时器集合,如果对同一条拍品的两次上述处理落在两个不同的进程中,则会出现两次落锤的逻辑,这显然是不正确的。
因此,将trigger标识为cs(cluster sensative)的意义,就使得对于同一行数据的处理一定落在同一个进程当中。另外一个属性singleton限制更加严格,一旦一个trigger被定义为singleton,则所有触发都会在全局的同一个进程中进行(当某个行为需要全局统一处理时)。
跨事务trigger的限制
目前,同一行上只能同时存在一个跨事务trigger。这意味着:首先,同一个entity的同一个action上,只能定义一个跨事务trigger;其次,当某行上有一个跨事务trigger一起不能完成,对此行新的跨事务trigger无法执行。这两种情况发生时,框架都会报错。
事实上,如果某行数据上有跨事务trigger,意味着这行的更新并未“完全完成”,在某个外部系统中还有需要更新成为一致性的数据。此时对行上的其它更新需要非常小心,应该在设计上就阻止可能产生不可预料后果的行为。无论什么情况下,如果发现跨事务trigger执行失败了,最好的处理方式都是尽快让其执行完成,达到整体一致性状态。在此之前,对系统的任何操作都要非常小心。
我们举一个例子来说明这一问题,假设当一个名为photo的对象创建时,要去某OSS上上传一张图片:
{
entity: 'photo',
action: 'create',
when: 'commit',
strict: 'makeSure',
fn: async ({ ids }, context) => {
// ...去根据ids中行的信息上传图片到OSS
}
}
那么也应该存在一个当photo对象被删除时,要去某OSS上删除这张图片:
{
entity: 'photo',
action: 'remove',
when: 'commit',
strict: 'makeSure',
fn: async ({ ids }, context) => {
// ...去根据ids中行的信息删除OSS中的文件
}
}
现在我们看看会发生什么危险的情况,假设一条photo数据被创建了,但它上传OSS的行为一直没成功(因为某种不可知原因),那么Oak框架会反复执行第一个trigger去尝试上传,但是在它成功前,用户就把这条photo数据删除了,此时第二个trigger也被激活,它会去尝试删除一张根本没有上传成功的图片!后面的行为就会变得不可预料(两个trigger究竟谁后成功?第一个trigger可能还会发生取不到数据的奇怪异常)。
所以在设计跨事务trigger时,要从根本上来杜绝这种情况。像上面这种情况,我们的解决方案可以是:
- 在photo对象中增加一个状态status,当create时,其状态设为'uploading'(可以通过下一节介绍的logicalData类型的checker来赋初值).
- 在跨事务上传动作完成后,将这个状态更新成'uploaded',
{
entity: 'photo',
action: 'create',
when: 'commit',
strict: 'makeSure',
fn: async ({ ids }, context) => {
// ...去根据ids中行的信息上传图片到OSS
return {
status: 'uploaded',
}
}
}
- 对photo的remove动作加一个row类型的checker,限定只有status为uploaded状态的图片才允许删除。
Select型trigger
我们同样允许在select行为的前后增加trigger行为。例如,如果我们规定在查询用户对象(User)时,如果当前用户(查询者)不是root,就不允许直接查询其密码(password)属性。则可以像下面这样编写trigger:
{
entity: 'user',
action: 'select',
when: 'before',
fn: async ({ operation }, context) => {
if (!context.isRoot()) {
const { data } = operation;
delete data.password;
}
}
}
trigger也可以注入到查询完成后(返回前),例如,我们想对于非root用户,对User的姓名信息打码:
{
entity: 'user',
action: 'select',
when: 'after',
fn: async ({ result }, context) => {
if (!context.isRoot()) {
for (const user of result) {
if (user.name) {
user.name = user.name.slice(0, 1) + '**';
}
}
}
}
}
上述例子表明了,当select类型的trigger的when定义为“after”时,在fn的第一个object参数时包含了查询的结果集信息(result)。