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

node-cron主要用來執行定時任務,它不只提供cron語法,並且增長了NodeJS子進程執行和直接傳入Date類型的功能。javascript

1、前言

  在理解node-cron以前,須要先知道它的基本用法,下面是一個在每分鐘的第20秒到第50秒之間每隔4秒執行一次的定時任務:java

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的原理:node

  • 部分注意事項
  • cron格式的解析
  • 使用setTiemout執行定時任務時的細節處理
  • 如何計算cron格式下的時間間隔

2、注意事項

  在正式進入源碼的探索時,最好了解node-cron的基本用法以及相關參數的含義。正則表達式

一、傳參方式

  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;
      ...
    }
  }
複製代碼
二、回調函數

  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是否在定時任務初始化階段執行一次:app

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;
  }
複製代碼

3、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();
  }
複製代碼
一、基本常量

  在瞭解cron解析原理以前,首先須要理解如下幾個常量:

  • timeUnits: second, minute, hour, dayOfMonth, month, dayOfWeek 分別對應'* * * * * *'中的各個星號;
  • constraints: 每一個時間單元的時間範圍;
  • monthConstraints: 每月的天數限制;
  • parseDefaults: 默認的解析格式;
  • aliases: 月份以及一週的別名。

  以上常量都是採用數組的格式,內容正好與數組下標一一對應。

二、解析流程

  下面以'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對異常值進行檢測,避免形成無限循環。

4、定時任務執行流程

  node-cron中經過start方法開啓定時任務,大致流程很容易能夠想到:

  1. 計算當前時間距離下個節點的時間間隔。
  2. 時間間隔無效執行步驟4,不然執行步驟3。
  3. setTimeout調用fireOnTick方法,執行步驟1。
  4. 清除定時器,執行onComplete。
一、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; // 加上減小的時間
    }

    ...

  }
複製代碼
二、計算時間間隔

  對於時間間隔的計算無非是起始時間與終止時間毫秒數的計算,可是對於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;
    }
  }
複製代碼

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

5、結尾

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

相關文章
相關標籤/搜索