Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

定义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有以下属性需要定义:

属性取值范围是否必填含义
entityEntityDict中的对象触发的对象
action该entity的action触发的操作
name字符串给trigger命名,便于后续跟踪调试(命名需要唯一)
priority1-99触发器执行的优先级(只有当entity和action完全相同时才有意义),数字越小优先级越高
when'before'/'after'/'commit'执行时机,在操作前/后/提交时
strict'takeEasy'/'makeSure'是否需要严格执行(只有当when为commit时才有意义),见下文解释
attributesentity的属性更新的属性(只有当action为update时才有意义),如果更新的属性和定义的attributes没有交集,则此trigger不会被触发
checkfunction更新的数据检查(只有当action为update/remove时才有意义),如果更新/删除的操作不满足检查,则此trigger不会被触发
filter该entity的Filter/function更新的条件检查(只有当action为update/remove时才有意义),如果更新/删除的数据条件不满足filter,则此trigger不会被触发
mt'create'/'apply'/'both'当存在延时更新Modi时的行为控制
fnfunction触发器的行为

以下代码定义了一个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的定义分成三类,接下来系统会:

  1. 按照priority从小到大的顺序执行before类型的trigger
  2. 执行operation
  3. 按照priority从小到大的顺序执行after类型的trigger

而commit类型的trigger会延时到operation提交(类似关系数据库的事务提交)之后,这种类型的trigger的行为和普通trigger区别较大,更多的细节请参看下一小节。

trigger的这种执行机制意味着可能产生递归调用,例如:在一个对A的operation触发了对B对象的另一个operation,然后后一个operation又触发了对C对象的一个operation……,因此在设计系统的时候,对象之间有关联逻辑的先后顺序应加以仔细制定,一个原则是:

应尽量减少trigger的数量,相同entity相同action的trigger尽量进行合并。

另外,trigger只在后台执行,这点是和checker的重要区别。

跨事务trigger

前面在whenstrict配置项中已经初步诠释了跨事务trigger的基本含义和使用方法,跨事务trigger是业务系统和外部系统达成数据一致性的重要手段。在Oak的实现中,before/after的trigger会和触发Operation处于同一事务当中,从而保证行为的一致性。而对于跨业务系统的行为一致性较难实现,下面先简要介绍跨事务trigger的实现过程:

  1. 在 Oak 执行某 Operation 之前,commit trigger 都会注册事务提交回调;只有 strict: 'makeSure' 还会在更新数据中加入两个持久化属性:
属性类型
$$triggerUuid$$uuid
$$triggerData$$object

这是 Oak 框架自动管理的内置属性。$$triggerData$$ 记录 trigger 名称、序列化上下文和 option,业务代码通常不应直接读写。

  1. 事务提交后,回调函数以 { ids } 作为第一个参数调用 trigger。makeSure 成功后,框架会清除上述两个属性;若设置了 cleanTriggerDataBySelf,则由 trigger 自行清理。

  2. makeSure trigger 执行失败,这两个属性不会被清除,checkpoint 会在后续 watcher 周期继续发现并重试;当前 AppLoader 的周期是 2 分钟。标记已物化到数据行,因此应用重启后仍可继续收敛。takeEasy 不写入这些标记,也不会进入 checkpoint 重试。

跨事务trigger的错误处理

当跨事务trigger执行失败时:

  • 如果 stricttakeEasy(默认),只在事务提交后尽力执行一次,失败不会重试;
  • 如果 strictmakeSure,失败状态会保留在数据行上,由 checkpoint 反复执行直到成功。

只有 makeSure 会通过 $$triggerUuid$$$$triggerData$$ 追踪失败状态,即使应用重启后也会继续重试。

跨事务trigger的fn

跨事务trigger的执行函数和普通trigger有两个不同:

  1. fn的第一个调用参数里增加了一个参数ids,以数组形式传入本次trigger涉及的id;
  2. fn可以返回一个更新数据对象或回调函数,如果返回的是更新数据对象,框架会在消除triggerUuid和triggerData属性的同时将这部分数据更新到数据行上,如果返回的是一个回调函数,框架会在消除triggerUuid和triggerData后执行此函数(同一事务保护)。

跨事务trigger的配置

对于跨事务trigger,除了strict之外还有一些额外的配置,简要介绍如下:

属性取值范围是否必填含义
cstrue在集群环境下,这个trigger涉及的数据将被分配到固定的结点上执行
singletontrue在集群环境下,这个trigger将只会在唯一的实例上执行
cleanTriggerDataBySelftruetriggerUuid和triggerData不会自动清除
groupedtrue被同一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时,要从根本上来杜绝这种情况。像上面这种情况,我们的解决方案可以是:

  1. 在photo对象中增加一个状态status,当create时,其状态设为'uploading'(可以通过下一节介绍的logicalData类型的checker来赋初值).
  2. 在跨事务上传动作完成后,将这个状态更新成'uploaded',
{
    entity: 'photo',
    action: 'create',
    when: 'commit',
    strict: 'makeSure',
fn: async ({ ids }, context) => {
        // ...去根据ids中行的信息上传图片到OSS
        return {
            status: 'uploaded',
        }
    }
}
  1. 对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)。