打包升級:node-cron原理詳解

descire發表於2018-10-10

node-cron主要用來執行定時任務,它不僅提供cron語法,而且增加了NodeJS子程式執行和直接傳入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);
  }
複製程式碼

  這兩種回撥函式都允許使用NodeJS子程式處理,舉個例子:

  // 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方法開啟定時任務,大體流程很容易可以想到:

  1. 計算當前時間距離下個節點的時間間隔。
  2. 時間間隔無效執行步驟4,否則執行步驟3。
  3. setTimeout呼叫fireOnTick方法,執行步驟1。
  4. 清除定時器,執行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並不是非常的準確,這個特性在瀏覽器中表現的特別突出,不過好在NodeJS中的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;
    }
  }
複製程式碼

  以這樣的方式不斷的校正對應的時間單元,最終得到下一個節點的終止時間,從而得到時間間隔。

五、結尾

  感謝讀者耐心的看到這裡。

相關文章