定义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,也合并了 shellException、postApplyment 等分模块 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.ts 的 startTimers() 中,框架会:
- 读取
lib/timers/index; - 对每个 timer 调用
scheduleJob(name, cron, ...); - 到点后根据 timer 类型执行:
BaseTimer走timer(context);FreeTimer走timer(builder);- 如果 timer 具备
entity,就按 watcher 的方式执行。
因此,timer 只是“调度层”不同,真正的数据处理方式仍然沿用了 Oak 已有的上下文和 watcher 能力。
三种 timer 执行失败时,AppLoader 都会记录错误并调用 publishInternalError('timer', ...)。Watcher 风格 timer 复用 watcher 的事务处理;BaseTimer 由 loader 创建 context,成功时提交,失败时回滚(OakPartialSuccess 除外);FreeTimer 的 context 生命周期由实现通过 builder 自行管理。不要为了让调度继续运行而在 fn 中笼统捕获并吞掉异常,否则 loader 会把本次 watcher context 当作成功提交。
常用属性
| 属性 | 是否必填 | 说明 |
|---|---|---|
name | 是 | timer 名称,必须唯一 |
cron | 是 | cron 表达式,也可以是 Date、number、RecurrenceRule |
singleton | 否 | 集群环境下只允许一个实例执行 |
如果你用的是 watcher 风格 timer,还可以继续使用:
- 三种 watcher 都有:
entity、filter、singleton、lazy; BBWatcher使用:action、actionData;WBWatcher/WBFreeWatcher使用:projection、fn、forUpdate、exclusive;WBFreeWatcher还必须使用type: 'free'。
虽然当前底层 BBWatcher 类型仍带有 exclusive 字段,但 AppLoader 会警告并忽略它,因此不要在 BB 形态中配置 exclusive。Watcher 类型也没有 sorter、count、indexFrom 等字段;需要排序或截断时,应在 fn 中明确处理查询结果。
这里的 filter、projection、forUpdate 也不是 timer 自己的一套新语法,而是直接复用 watcher / 查询章节里的同一套定义:
filter/projection的写法,参见查询和操作对象;forUpdate的含义,和 watcher 里一样,适合“先扫出来,再逐条改”的串行处理场景。
timer 和 watcher 应该怎么选
一个简单判断就够了:
- “到某个时间点必须执行” ->
timer - “只要库里有这种状态的数据,就应持续扫描处理” ->
watcher
例如:
- 每晚 2 点同步一次第三方目录:更适合
timer - 每隔一会儿重试发送失败消息:更适合
watcher
一个实践建议
timer 更适合做“触发”,而不是做“无限复杂的大任务”。
如果某个任务非常重,通常更好的做法是:timer 只负责按时挑出待处理数据,再通过状态位、分批处理、幂等动作等方式,把复杂工作拆开,而不是把整个大流程都塞进一次 cron 回调里。