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

定义timer

timer 是 Oak 中按 cron 调度执行的后台任务。它和 watcher 的区别非常明确:

  • watcher:固定每 120 秒轮询一次;
  • timer:由你自己定义 cron 表达式,交给 node-schedule 调度。

所以,只要你对执行时间点有明确要求,例如“每小时整点”“每天凌晨”“每 5 分钟”,就应该优先使用 timer

timer 的类型

oak-domain/src/types/Timer.ts 中把 timer 定义成了三种可能:

type Timer =
    | BaseTimer
    | FreeTimer
    | (Watcher & { cron: ... });

这意味着 Oak 中的 timer 其实有三种写法。

第一种:BaseTimer

最直接的写法。你提供一个 timer(context) 函数,Oak 在 cron 到点时创建 context 并执行它。

第二种:FreeTimer

如果你希望自己决定何时创建 context,可以使用 type: 'free' 的形式,此时执行函数会收到 builder: () => Promise<context>

第三种:Watcher 风格的 timer

这也是实际项目里非常常见的一种写法。它直接复用完整的 Watcher 联合类型,再额外增加一个 cron,因此可以是:

  • BBWatcher:按 entity/filter/action/actionData 直接执行一次 operate;
  • WBWatcher:按 entity/filter/projection 查询后,把结果交给 fn(context, data)
  • WBFreeWatcher:设置 type: 'free',查询后把 context builder 和数据交给 fn(builder, data)

不能只根据是否存在 projection/fn 判断 timer 是否为 watcher 风格;运行时以是否存在 entity 为准,并交给和普通 watcher 相同的 execWatcher() 执行路径。

编写位置

一般把 timer 写在 src/timers 目录下,并在 src/timers/index.ts 里统一导出。

bm-smart/src/timers/index.ts 中就导出了一个 timer 数组,其中既有示例 timer,也合并了 shellExceptionpostApplyment 等分模块 timer。

一个真实例子

下面这个例子来自 bm-smart/src/timers/license.ts

const timers: Array<Timer<EntityDict, 'license', BackendRuntimeContext>> = [
    {
        name: '定期过期license',
        cron: cronMap[process.env.NODE_ENV || 'development'],
        entity: 'license',
        filter: async () => {
            return {
                expired: false,
                expiredAt: {
                    $lte: Date.now()
                }
            }
        },
        projection: {
            id: 1,
        },
        fn: async (context, data) => {
            const ids = data.map(item => item.id!);
            if (ids.length === 0) {
                return context.opResult;
            }
            await context.operate('license', {
                id: await generateNewIdAsync(),
                action: 'expire',
                filter: {
                    id: {
                        $in: ids
                    }
                },
                data: {}
            }, {});
            return context.opResult;
        },
    },
];

这个 timer 的意思非常清楚:按照不同环境的 cron 周期,定期扫描已经到期但尚未标记过期的 license,再统一执行 expire 动作。

AppLoader 是如何执行 timer 的

oak-backend-base/src/AppLoader.tsstartTimers() 中,框架会:

  1. 读取 lib/timers/index
  2. 对每个 timer 调用 scheduleJob(name, cron, ...)
  3. 到点后根据 timer 类型执行:
  4. BaseTimertimer(context)
  5. FreeTimertimer(builder)
  6. 如果 timer 具备 entity,就按 watcher 的方式执行。

因此,timer 只是“调度层”不同,真正的数据处理方式仍然沿用了 Oak 已有的上下文和 watcher 能力。

三种 timer 执行失败时,AppLoader 都会记录错误并调用 publishInternalError('timer', ...)。Watcher 风格 timer 复用 watcher 的事务处理;BaseTimer 由 loader 创建 context,成功时提交,失败时回滚(OakPartialSuccess 除外);FreeTimer 的 context 生命周期由实现通过 builder 自行管理。不要为了让调度继续运行而在 fn 中笼统捕获并吞掉异常,否则 loader 会把本次 watcher context 当作成功提交。

常用属性

属性是否必填说明
nametimer 名称,必须唯一
croncron 表达式,也可以是 DatenumberRecurrenceRule
singleton集群环境下只允许一个实例执行

如果你用的是 watcher 风格 timer,还可以继续使用:

  • 三种 watcher 都有:entityfiltersingletonlazy
  • BBWatcher 使用:actionactionData
  • WBWatcher / WBFreeWatcher 使用:projectionfnforUpdateexclusive
  • WBFreeWatcher 还必须使用 type: 'free'

虽然当前底层 BBWatcher 类型仍带有 exclusive 字段,但 AppLoader 会警告并忽略它,因此不要在 BB 形态中配置 exclusive。Watcher 类型也没有 sortercountindexFrom 等字段;需要排序或截断时,应在 fn 中明确处理查询结果。

这里的 filterprojectionforUpdate 也不是 timer 自己的一套新语法,而是直接复用 watcher / 查询章节里的同一套定义:

  • filter / projection 的写法,参见查询和操作对象
  • forUpdate 的含义,和 watcher 里一样,适合“先扫出来,再逐条改”的串行处理场景。

timer 和 watcher 应该怎么选

一个简单判断就够了:

  • “到某个时间点必须执行” -> timer
  • “只要库里有这种状态的数据,就应持续扫描处理” -> watcher

例如:

  • 每晚 2 点同步一次第三方目录:更适合 timer
  • 每隔一会儿重试发送失败消息:更适合 watcher

一个实践建议

timer 更适合做“触发”,而不是做“无限复杂的大任务”。

如果某个任务非常重,通常更好的做法是:timer 只负责按时挑出待处理数据,再通过状态位、分批处理、幂等动作等方式,把复杂工作拆开,而不是把整个大流程都塞进一次 cron 回调里。