node-cron 主要用来执行定时任务, 它不仅提供 cron 语法, 而且增加了 Node.JS 子进程执行和直接传入 Date 类型的功能.
一, 前言
在理解 node-cron 之前, 需要先知道它的基本用法, 下面是一个在每分钟的第 20 秒到第 50 秒之间每隔 4 秒执行一次的定时任务:
- const CronJob = require('../lib/cron.JS').CronJob
- const job = new CronJob('20-50/4 * * * * *', onTick)
- job.start()
- function onTick () {
- const d = new Date()
- console.log('tick:', d)
- }
接下来会从以下几个方面带你了解 node-cron 的原理:
部分注意事项
cron 格式的解析
使用 setTiemout 执行定时任务时的细节处理
如何计算 cron 格式下的时间间隔
二, 注意事项
在正式进入源码的探索时, 最好了解 node-cron 的基本用法以及相关参数的含义.
1, 传参方式
node-cron 提供 CronJob 函数创建定时任务, 并且允许两种传参方式:
载荷形式: a, b, c
对象形式:{ a: a, b: b, c: c }
- /**
- * 为了节约篇幅, 示例代码只展示主要内容
- */
- function CronJob (cronTime, onTick, onComplete, startNow, timeZone, context, runOnInit, utcOffset, unrefTimeout) {
- var _cronTime = cronTime;
- var argCount = 0;
- // 排除传入的参数是 undefined 的情况(要是我就直接 argCount = arguments.length)
- for (var i = 0; i <arguments.length; i++) {
- if (arguments[i] !== undefined) {
- argCount++;
- }
- }
- // 判断参数为对象类型的条件
- if (typeof cronTime !== 'string' && argCount === 1) {
- onTick = cronTime.onTick;
- ...
- }
- }
2, 回调函数
node-cron 中有两种回调函数:
onTick: 每个时间节点触发的回调函数;
onComplete: 定时任务执行完后的回调函数.
从 CronJob 函数中可以看到 onTick 回调函数是放在_callbacks 中的, 但是通过 CronJob 只能设置一个 onTick 函数, 如果需要设置多个 onTick 函数, 可以采用 CronJob 原型上的 addCallback 方法, 并且这些 onTick 的执行顺序需要注意一下:
- var fireOnTick = function () {
- // 利用_callbacks 数组模拟栈的行为 后进先出
- for (var i = this._callbacks.length - 1; i>= 0; i--) {
- this._callbacks[i].call(this.context, this.onComplete);
- }
- };
另外通过 runOnInit 参数决定 onTick 是否在定时任务初始化阶段执行一次:
- if (runOnInit) {
- this.lastExecution = new Date();
- fireOnTick.call(this);
- }
这两种回调函数都允许使用 Node.JS 子进程处理, 举个例子:
- // examples/basic.JS
- const CronJob = require('../lib/cron.JS').CronJob;
- const path = require('path');
- const job = new CronJob('20-50/4 * * * * *', `node ${path.join(__dirname, './log.JS')}`);
- job.start();
- // examples/log.JS
- const fs = require('fs');
- const now = new Date();
- fs.appendFile('./examples/demo.log', `${now}\n`, err => {
- if (err) {
- throw new Error(err);
- }
- });
对于这种方式, CronJob 函数中采用 command2function 对 onTick 和 onComplete 参数统一处理:
- function command2function(cmd) {
- var command;
- var args;
- /**
- * 采用 spawn 的方式创建子进程
- */
- switch (typeof cmd) {
- case 'string':
- args = cmd.split(' ');
- command = args.shift();
- cmd = spawn.bind(undefined, command, args);
- break;
- case 'object':
- command = cmd && cmd.command;
- if (command) {
- args = cmd.args;
- var options = cmd.options;
- cmd = spawn.bind(undefined, command, args, options);
- }
- break;
- }
- return cmd;
- }
三, cron 格式解析
node-cron 中通过 CronTime 处理时间, 而且它还支持普通 Date 类型:
- if (this.source instanceof Date || this.source._isAMomentObject) {
- // 支持 Date 类型
- this.source = moment(this.source);
- this.realDate = true; // 标识符
- } else {
- // 处理 cron 格式
- this._parse();
- this._verifyParse();
- }
1, 基本常量
在了解 cron 解析原理之前, 首先需要理解以下几个常量:
timeUnits: second, minute, hour, dayOfMonth, month, dayOfWeek 分别对应'* * * * * *'中的各个星号;
constraints: 每个时间单元的时间范围;
monthConstraints: 每个月的天数限制;
parseDefaults: 默认的解析格式;
aliases: 月份以及一周的别名.
以上常量都是采用数组的格式, 内容正好与数组下标一一对应.
2, 解析流程
下面以'20-50/4 * * * jan-feb *'为例进行解析过程.
第一步, CronTime 函数中会根据 timeUnits 创建各个时间单元:
- // CronTime 函数
- var that = this;
- timeUnits.map(function(timeUnit) {
- that[timeUnit] = {};
- });
第二步, 通过_parse 方法处理别名以及分割输入的 cron 格式.
因为 corn 格式是字符串形式的, 所以后面会采用很多正则表达式对其处理, 下面是替换别名的操作:
- /**
- * [a-z]:a,b,c...z 字符集
- * {1,3}: 匹配前面字符至少 1 次, 最多 3 次
- */
- var source = this.source.replace(/[a-z]{1,3}/gi, function(alias) {
- alias = alias.toLowerCase();
- if (alias in aliases) {
- return aliases[alias];
- }
- throw new Error('Unknown alias:' + alias);
- });
- // 处理后的结果
- // => 20-50/4 * * * 0-1 *
提取 cron 中各个时间单元采用 split 方法, 不过这里通常需要注意头尾可能出现的空格带来的影响:
- /**
- * ^: 匹配输入的开始
- * $: 匹配输入的结束
- * |: 或
- * *: 匹配前一个表达式 0 次或者多次
- */
- var split = source.replace(/^\s\s*|\s\s*$/g, '').split(/\s+/);
- // 处理后的结果
- // => ['20-50/4', '*', '*', '*', '0-1', '*']
下面就是对各个时间单元进行处理, 这里需要注意的是在输入 cron 格式字符串时, 我们可以省去前面的几位, 一般都是省去第一位的秒(秒的缺省值为 0):
- // 由于用户输入的 cron 中的时间单元的长度时不定的, 这里必须从 timeUnits 中遍历, 设计的很巧妙.
- for (; i <timeUnits.length; i++) {
- cur = split[i - (len - split.length)] || CronTime.parseDefaults[i];
- this._parseField(cur, timeUnits[i], CronTime.constraints[i]);
- }
第三步, 采用_parseField 方法处理时间单元.
首先需要将 * 替换为 min-max 的格式:
- var low = constraints[0];
- var high = constraints[1];
- field = field.replace(/\*/g, low + '-' + high);
- // 得到的结果
- // => ['20-50/4', '0-59', '0-23', '1-31', '0-1', '0-6']
接下来就是最重要的一点, 将有效的时间点放入相应的时间单元中, 可能这里你还不太明白什么意思, 往下看.
根据'20-50/4', 可以得到起止时间为 20 秒, 终止时间为 50s, 步长为 4(步长缺省值为 1), 拿到这些信息之后, 结合前面创建的时间单元, 最终得到如下结果:
- second: {
- '20': true,
- '24': true,
- '28': true,
- '32': true,
- '36': true,
- '40': true,
- '44': true,
- '48': true
- }
现在明白需要将 cron 中各个值处理成什么效果之后, 先看一下如何提取字符串中的最小值, 最大值以及步长:
- // (?:x) 非捕获括号, 注意与 () 捕获括号的区别
- var rangePattern = /^(\d+)(?:-(\d+))?(?:\/(\d+))?$/g;
具体的处理方式:
- // _parseField
- var typeObj = this[type]
- if (allRanges[i].match(rangePattern)) {
- allRanges[i].replace(rangePattern, function($0, lower, upper, step) {
- step = parseInt(step) || 1;
- // 这里确保最小值 最大值在安全范围内
- // 并且采用 ~~ 的方式避免可能为小数的结果
- lower = Math.min(Math.max(low, ~~Math.abs(lower)), high);
- upper = upper ? Math.min(high, ~~Math.abs(upper)) : lower;
- pointer = lower;
- do {
- // 通过步长记录各个时间点
- typeObj[pointer] = true;
- pointer += step;
- } while (pointer <= upper);
- });
- } else {
- throw new Error('Field (' + field + ') cannot be parsed');
- }
第四步, 通过_verifyParse 对异常值进行检测, 避免造成无限循环.
四, 定时任务执行流程
node-cron 中通过 start 方法开启定时任务, 大体流程很容易可以想到:
计算当前时间距离下个节点的时间间隔.
时间间隔无效执行步骤 4, 否则执行步骤 3.
setTimeout 调用 fireOnTick 方法, 执行步骤 1.
清除定时器, 执行 onComplete.
1,setTimeout
第一点: setTimeout 存在一个最大的等待时间, 所有并不能直接用时间间隔, 需要不断的计算当前有效的时间间隔:
- var start = function () {
- if (this.running) return
- var MAXDELAY = 2147483647; // setTimout 的最大等待时间
- var timeout = this.cronTime.getTimeout(); // 获取时间间隔
- var remaining = 0; // 剩余时间
- ...
- if (remaining) {
- // 确保 setTimeout 接收安全值
- if (remaining> MAXDELAY) {
- remaining -= MAXDELAY;
- timeout = MAXDELAY;
- } else {
- timeout = remaining;
- remaining = 0;
- }
- _setTimeout(timeout);
- } else {
- // 到达执行时机
- self.running = false; // 等待期间的标识符
- if (!self.runOnce) self.start();
- self.fireOnTick();
- }
- }
第二点, setTimeout 并不是非常的准确, 这个特性在浏览器中表现的特别突出, 不过好在 Node.JS 中的 setTimeout 的延迟非常的小, 几乎可以忽略不计, 不过源码在这里考虑 setTimeout 提前执行的情况(试了好久, 没测试出这种情况..):
- function callbackWrapper() {
- var diff = startTime + timeout - Date.now();
- if (diff> 0) {
- var newTimeout = self.cronTime.getTimeout();
- if (newTimeout> diff) {
- newTimeout = diff;
- }
- remaining += newTimeout; // 加上减少的时间
- }
- ...
- }
2, 计算时间间隔
对于时间间隔的计算无非是起始时间与终止时间毫秒数的计算, 但是对于 cron 格式的输入, 问题就转化为了如何通过 cron 获取下一个节点的终止时间.
还记得前面花了很大精力将 cron 格式转化成时间单元中的有效节点吗? 而这里获取终止时间的策咯就是利用当前时间不断的通过这些时间单元校正当前时间, 这里我们就拿月份为例:
- // _getNextDateFrom 方法
- ...
- var date = moment()
- let i = 0
- while (true) {
- i++
- // 当前的月份是否有效
- if (!(date.month() in this.month) && Object.keys(this.month).length !== 12) {
- // 当前月份无效, 则向后推移一个月
- date.add(1, 'M');
- if (date.month() === prevMonth) {
- date.add(1, 'M');
- }
- // 重置
- date.date(1);
- date.hours(0);
- date.minutes(0);
- date.seconds(0);
- continue;
- }
- }
以这样的方式不断的校正对应的时间单元, 最终得到下一个节点的终止时间, 从而得到时间间隔.
五, 结尾
感谢读者耐心的看到这里.
来源: https://juejin.im/post/5bbe213e5188255c4834d440