schedule - 定时任务
基于请求的定时任务,如同定时请求固定接口,可以使用中间件和请求上下文。
依赖模块
- @zenweb/inject
- @zenweb/router
快速使用
npm install @zenweb/schedule
src/index.ts
import modSchedule from '@zenweb/schedule';
// ...
.setup(modSchedule())
// ...
src/schedule/echo.ts
import { Schedule } from '@zenweb/schedule';
export class EchoScheduler {
@Schedule('*/1 * * * * *')
echo() {
console.log('task echo');
return 'ok';
}
}
配置项
| 配置项 | 类型 | 默认值 | 功能 |
|---|---|---|---|
| paths | string[] | ['./app/schedule'] | 定时任务控制器加载目录 |
| patterns | string | **/*.{ts,js} | 定时任务控制器文件匹配规则 |
| disabled | boolean | false | 是否禁用定时器,可以通过环境变量 ZENWEB_SCHEDULE_DISABLED=1 控制 |
| prefix | string | /__schedule/ | 内部路由路径前缀 |
Core 挂载项
| 挂载项 | 类型 | 功能 |
|---|---|---|
| scheduleRegister | ScheduleRegister | ScheduleRegister 实例 |
Cron 语法参考
定时任务使用标准的 5 位或 6 位 Cron 表达式定义执行规则。
格式说明
┌──────── 秒 (0-59,可选)
│ ┌────── 分钟 (0-59)
│ │ ┌──── 小时 (0-23)
│ │ │ ┌── 日 (1-31)
│ │ │ │ ┌─ 月 (1-12)
│ │ │ │ │ ┌ 星期 (0-7,0 和 7 均为周日)
│ │ │ │ │ │
* * * * * *
常用表达式
| 表达式 | 说明 |
|---|---|
* * * * * | 每分钟执行 |
*/5 * * * * | 每 5 分钟执行 |
0 * * * * | 每小时整点执行 |
0 */2 * * * | 每 2 小时整点执行 |
0 0 * * * | 每天零点执行 |
0 8 * * * | 每天早上 8 点执行 |
0 8,18 * * * | 每天早上 8 点和下午 6 点执行 |
0 0 * * 1 | 每周一零点执行 |
0 0 1 * * | 每月 1 日零点执行 |
0 0 1 1 * | 每年 1 月 1 日零点执行 |
*/10 * * * * * | 每 10 秒执行(6 位,含秒) |
0 30 9 * * 1-5 | 周一至周五早上 9:30 执行 |
特殊字符
| 字符 | 说明 | 示例 |
|---|---|---|
* | 任意值 | * * * * * 每分钟 |
, | 列举多个值 | 0 8,12,18 * * * 每天 8、12、18 点 |
- | 范围 | 0 9-17 * * * 9 点到 17 点的每个整点 |
/ | 步进 | */5 * * * * 每 5 分钟 |
内部路由机制
定时任务并非直接执行函数,而是注册为内部 POST 路由,由 node-schedule 在指定时间通过模拟 HTTP 请求触发。
工作流程
- 注册阶段:扫描
paths目录下的所有文件,将@Schedule装饰的方法注册为内部 POST 路由 - 路由路径:路径格式为
{prefix}{文件名}/{方法名},例如/__schedule/cleanup/dailyClean - 定时触发:到达 Cron 表达式指定的时间时,构造一个模拟的 HTTP POST 请求并传入 Koa 中间件链
- 完整中间件:请求经过完整的中间件栈(包括 inject、metric、日志等),可以使用依赖注入、数据库等所有功能
手动触发
由于定时任务被注册为内部路由,你也可以通过 HTTP 请求手动触发:
# 手动触发 dailyClean 任务
curl -X POST http://localhost:7001/__schedule/cleanup/dailyClean
这在调试和测试定时任务时非常有用。
路由去重
如果多个文件中的方法名冲突,系统会自动在路径后追加数字序号确保唯一性:
/__schedule/report/generate # 第一个
/__schedule/report/generate1 # 重名自动编号
/__schedule/report/generate2 # 继续编号
ZENWEB_SCHEDULE_DISABLED 环境变量
设置 ZENWEB_SCHEDULE_DISABLED=1 可以禁用定时器自动执行,但路由仍然会注册。这在以下场景中很有用:
- 多实例部署:只让其中一个实例执行定时任务,其他实例禁用
- 开发调试:本地开发时禁用定时任务,避免干扰
- 手动控制:通过 HTTP 接口手动触发任务
# 禁用定时任务
ZENWEB_SCHEDULE_DISABLED=1 node dist/index.js
也可以在代码中配置:
app.setup(modSchedule({
disabled: process.env.NODE_ENV === 'development',
}));
依赖注入
定时任务方法在执行时会通过 inject 容器创建实例,因此可以使用完整的依赖注入功能。
注入服务
import { Schedule } from '@zenweb/schedule';
import { inject } from '@zenweb/inject';
import { ReportService } from '../service/ReportService';
export class ReportScheduler {
@inject
reportService!: ReportService;
@Schedule('0 8 * * *')
async dailyReport() {
const report = await this.reportService.generateDailyReport();
console.log('日报生成完成:', report.id);
}
}
使用 $mysql
import { Schedule } from '@zenweb/schedule';
import { $mysql } from '@zenweb/mysql';
export class DataScheduler {
@Schedule('0 2 * * *')
async cleanExpiredData() {
// 清理 30 天前的过期数据
const result = await $mysql.query(
'DELETE FROM sessions WHERE expired_at < DATE_SUB(NOW(), INTERVAL 30 DAY)'
);
console.log(`已清理 ${result.affectedRows} 条过期会话`);
}
}
使用请求上下文
由于定时任务通过模拟 HTTP 请求触发,因此可以访问完整的请求上下文:
import { Schedule } from '@zenweb/schedule';
import { Context } from 'zenweb';
export class NotificationScheduler {
@Schedule('0 9 * * 1-5')
async morningNotification() {
// 可以通过参数注入获取上下文相关内容
// ctx 会在运行时注入
}
}
多文件发现模式
schedule 模块会扫描 paths 配置的所有目录,自动发现和加载定时任务文件。文件名会作为路由路径的前缀,便于按业务分类组织。
目录结构示例
src/
app/
schedule/
cleanup.ts -> 路径前缀: cleanup
report.ts -> 路径前缀: report
notification.ts -> 路径前缀: notification
sync/
orders.ts -> 路径前缀: sync/orders
products.ts -> 路径前缀: sync/products
自定义扫描目录
app.setup(modSchedule({
paths: ['./app/schedule', './app/jobs'],
patterns: '**/*.{ts,js}',
}));
完整示例:每日数据清理与报告生成
清理过期会话
src/app/schedule/cleanup.ts
import { Schedule } from '@zenweb/schedule';
import { $mysql } from '@zenweb/mysql';
export class CleanupScheduler {
/**
* 每天凌晨 2 点清理过期数据
*/
@Schedule('0 2 * * *')
async dailyClean() {
console.log('[Cleanup] 开始清理过期数据...');
// 清理过期会话
const [sessions] = await $mysql.query(
'DELETE FROM user_sessions WHERE expired_at < NOW()'
);
console.log(`[Cleanup] 已清理 ${sessions.affectedRows} 条过期会话`);
// 清理过期验证码
const [codes] = await $mysql.query(
'DELETE FROM verify_codes WHERE expired_at < NOW()'
);
console.log(`[Cleanup] 已清理 ${codes.affectedRows} 条过期验证码`);
// 清理临时文件记录
await $mysql.query(
"DELETE FROM temp_files WHERE status = 'expired' AND created_at < DATE_SUB(NOW(), INTERVAL 7 DAY)"
);
console.log('[Cleanup] 清理完成');
}
/**
* 每周日凌晨 3 点清理日志
*/
@Schedule('0 3 * * 0')
async weeklyClean() {
await $mysql.query(
'DELETE FROM access_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL 90 DAY)'
);
console.log('[Cleanup] 已清理 90 天前的访问日志');
}
}
生成业务报告
src/app/schedule/report.ts
import { Schedule } from '@zenweb/schedule';
import { inject } from '@zenweb/inject';
import { $mysql } from '@zenweb/mysql';
import { ReportService } from '../service/ReportService';
export class ReportScheduler {
@inject
reportService!: ReportService;
/**
* 每天早上 8 点生成昨日业务报告
*/
@Schedule('0 8 * * *')
async dailyReport() {
console.log('[Report] 开始生成日报...');
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const dateStr = yesterday.toISOString().split('T')[0];
// 汇总昨日订单数据
const orderStats = await $mysql.query<{
total: number;
amount: number;
avg_amount: number;
}>(
`SELECT
COUNT(*) as total,
COALESCE(SUM(amount), 0) as amount,
COALESCE(AVG(amount), 0) as avg_amount
FROM orders
WHERE DATE(created_at) = ?`,
[dateStr]
);
// 汇总用户注册数
const userStats = await $mysql.query<{ count: number }>(
'SELECT COUNT(*) as count FROM users WHERE DATE(created_at) = ?',
[dateStr]
);
// 生成报告
await this.reportService.generateAndSave({
date: dateStr,
orders: orderStats[0],
newUsers: userStats[0].count,
});
console.log(`[Report] ${dateStr} 日报生成完成`);
}
/**
* 每月 1 日凌晨生成上月汇总
*/
@Schedule('0 2 1 * *')
async monthlyReport() {
const now = new Date();
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const monthStr = `${lastMonth.getFullYear()}-${String(lastMonth.getMonth() + 1).padStart(2, '0')}`;
console.log(`[Report] 开始生成 ${monthStr} 月报...`);
await this.reportService.generateMonthlyReport(monthStr);
console.log(`[Report] ${monthStr} 月报生成完成`);
}
}
数据同步
src/app/schedule/sync.ts
import { Schedule } from '@zenweb/schedule';
import { inject } from '@zenweb/inject';
import { SyncService } from '../service/SyncService';
export class SyncScheduler {
@inject
syncService!: SyncService;
/**
* 每 30 分钟同步一次商品库存
*/
@Schedule('*/30 * * * *')
async syncInventory() {
const result = await this.syncService.syncInventory();
console.log(`[Sync] 库存同步完成,更新 ${result.updated} 条`);
}
}