本文正在参与「金石方案 . 分割6万现金大奖」

Cron表达式是用来表达时刻相关信息的字符串,用来做守时使命这类需求是最好的挑选,前端在浏览器端不太会用得到,但假如是node.js相关的业务,这便是一个必备的技术,学好cron表达式,再结合一些东西库(node-schedule),你就能够轻松写好一切场景的守时使命。

Cron表达式

Cron其实是类Unix系统中的使命调度程序,以固守时刻、日期或间隔守时履行脚本使命,最适合来组织重复性的使命,比如守时发送邮件,监控数据指标等。

冷知识:Cron的姓名来源于chronos,希腊语中的时刻

守时使命必定得结合场景,下面便是一个简略的比如

场景:周五晚上10点提醒我发周报

Cron表达式:0 0 22 ? * 6

上面的表达式看不懂不要紧,下面就来详细解说每个字符是什么意思

字符意义

前端必备的守时使命技术 - Cron + node-schedule

年用的不多,就没有展示

如上图所示,Cron表达式的字符串是由简略的几个字符和空格拼接而成,一般会有4-6个空格,空格隔开的那部分就会形成时刻子装备。

子装备 是否必填 规模 允许的特别字符 阐明
0-59 * , - / 不常用
0-59 * , - /
小时 0-23 * , - /
几号 1-31 * , - / ? L W
月份 1-12或JAN – DEC * , - /
星期几 0-7或SUN-SAT * , - / ? L # 0和7是周六,1是周日
1970-2099 * , - / 不常用

特别字符阐明

*:匹配恣意单个原子值,每分钟,每小时,每天等

,:分隔列表项,星期项 2,4,6表明每周的一三五匹配

-:界说规模,小时项 9-18表明早上9点到下午6点,每小时都匹配

/:表明间隔时刻履行,小时项9/2表明从早上9点开端,每2小时履行一次

?:只用于日期项和星期项,为了表明互斥,日期项假如有值,星期项就得用?,反之亦然,否则装备会失效

L:只用于日期项和星期项,表明一个月的倒数第几天或一星期中的倒数第几天,5L表明倒数第五天

W:只用于日期项,表明距离作业日(周一到周五)最近的一天

#:只用于星期项,5#3对应于每个月的第三个星期五

常见场景

每天早上8点签到抽奖

0 0 8 * * ?

一三五该锻炼身体了

0 30 19 ? * 2,4,6

作业2小时,摸鱼20分钟

0 0 9/2 ? * MON-FRI

月末写总结

0 15 10 L * ?

双11尾款人冲鸭

59 59 23 10 11 ?

node-schedule

node.js并没有原生的办法支持调用cron,需要借助第三方库来完成,首要仍是依赖于setTimeout来完成。

node-schedule是一个灵敏时刻调度东西库,支持cron表达式一般Date目标内置语义化类等时刻格局

装置:

npm install node-schedule
// or
yarn add node-schedule

时刻规矩

node-schedule提供scheduleJon办法来注册调度使命,第一个参数是时刻规矩,第二个参数是时刻匹配后的回调

cron

前端必备的守时使命技术 - Cron + node-schedule

表达式中的L和W暂不支持

固守时刻(只会履行一次)

前端必备的守时使命技术 - Cron + node-schedule

语义化时刻实例

前端必备的守时使命技术 - Cron + node-schedule

这是node-schedule内置的语义化时刻规矩RecurrenceRule,装备起来更加明晰,能够避免由于目炫而导致cron表达式配错

属性如下所示:

  • second (0-59)
  • minute (0-59)
  • hour (0-23)
  • date (1-31)
  • month (0-11)
  • year
  • dayOfWeek (0-6) Starting with Sunday
  • tz

tz – time zone时区,默认系统时区

界说规模

前端必备的守时使命技术 - Cron + node-schedule

上面的代码就界说了规矩收效的规模,在未来5秒内,每秒都履行

Job工作

经过上面的简略运用介绍,能够看到每次调用scheduleJob函数,都会回来一个job实例,这儿来讲讲job相关的工作处理

job.cancel(reschedule = false)

撤销当时job一切守时使命,入参是一个布尔值,true表明清空后从头开启当时守时使命,默认为false

job.cancelNext(reschedule = true)

和cancel工作类似,可是这是撤销下一次即将履行的函数,reschedule默认为true

job.reschedule(spec)

对job从头界说时刻规矩

job.nextInvocation()

手动调用最近一次时刻匹配的触发器

Job还基于EventEmitter来完成了几个通用的工作监听,简略用法如下:

前端必备的守时使命技术 - Cron + node-schedule

run:表明当时使命正在履行

success:会回来invoke成果,支持异步Promise语法

error:异步Promise报错才会有回来成果

scheduled:使命注册或从头注册实施

cancel:使命撤销时履行

不了解EventEmitter,能够看下简易版别的nanoevent,逻辑相当好了解

export let createNanoEvents = () => ({
  events: {},
  emit(event, ...args) {
    let callbacks = this.events[event] || []
    for (let i = 0, length = callbacks.length; i < length; i++) {
      callbacks[i](...args)
    }
  },
  on(event, cb) {
    this.events[event]?.push(cb) || (this.events[event] = [cb])
    return () => {
      this.events[event] = this.events[event]?.filter(i => cb !== i)
    }
  }
})

自界说封装

Job的使命注册和工作监听其实是分开用的,细心想想能不能把两者结合起来,整组成一个Class类,所以我封装了下面这个Schedule类:

class Schedule {
  constructor() {
    if (!this.spec) {
      throw new Error('Time config (spec) Required')
    }
    const job = schedule.scheduleJob(this.spec, this.invoke)
    this._registerEvent(job)
    this.job = job
  }
  async invoke() {}
  _registerEvent(job) {
    job.on('success', (val) => {
      this._log('success', val)
      this.successCb(val)
    })
    job.on('error', (err) => {
      this._log('error', err)
      this.errorCb(err)
    })
  }
  // 成功的回调
  successCb() {}
  // 失利的回调
  errorCb() {}
  // 可自界说封装其他
  _log(type, info) {
    if (type === 'error') {
      // console.error(info)
      return
    }
    // console.log(type, info)
  }
}

具体用法:

class SendEmail extends Schedule {
  get spec() {
    return '0/5 * * * * *'
  }
  async invoke() {
    const res = await mockSms(1)
  	return res
  }
  errorCb() {
    // retry
		mockSms(1)
  }
}
new SendEmail()

装备时刻规矩,回调,错误处理就能在一个类里面处理。

同时,你还能够在Schedule里统一打你的大局日志,不漏掉任何调用信息:

前端必备的守时使命技术 - Cron + node-schedule

源码

其实node-schedule全体的逻辑代码并不多,只要500行左右,所以也十分值得花点小时刻研究一下,下面是我画的一张依赖引证相关图

前端必备的守时使命技术 - Cron + node-schedule

虚线其实便是中心办法scheduleJob的调用链路,入口代码也很好了解,其实便是判断下相关的参数,然后实例化Job类,从而调用schedule进行注册守时使命:

function scheduleJob() {
  if (arguments.length < 2) {
    throw new RangeError('Invalid number of arguments');
  }
  const name = (arguments.length >= 3 && typeof arguments[0] === 'string') ? arguments[0] : null;
  const spec = name ? arguments[1] : arguments[0];
  const method = name ? arguments[2] : arguments[1];
  const callback = name ? arguments[3] : arguments[2];
  if (typeof method !== 'function') {
    throw new RangeError('The job method must be a function.');
  }
  // 校验完入参后,开端构造使命单元
  const job = new Job(name, method, callback);
  if (job.schedule(spec)) {
    return job;
  }
  return null;
}

使命注册器 – Job

首要处理以下工作:

  • 解析时刻相关装备(cronDateRecurrenceRule

  • 界说Job独有的工作(cancel,cancelNext,reschedule等)

  • 分配invoke办法

解析时刻装备

  1. start,end都会在这个阶段装备

  2. cron-parser解析cron表达式,解析生成就开端参加下一次的行列调用(scheduleNextRecurrence),回来的invocation参加jobpendingInvocations

  3. cron解析失利,判断是否是Date格局,这种直接实例化Invocation,并参加等待

  4. 目标就解析成RecurrenceRule装备,剩余逻辑和流程2相同

cron-parser能够了解为cron表达式的解析东西包,便利提取每次的收效时刻

const cronParser = require('cron-parser')
const CronDate = require('cron-parser/lib/date')
Job.prototype.schedule = function(spec) {
  const self = this;
  let success = false;
  let inv;
  let start;
  let end;
  let tz;
  // save passed-in value before 'spec' is replaced
  // 记载当时的入参解析,在传入之前
  if (typeof spec === 'object' && 'tz' in spec) {
    tz = spec.tz;
  }
  if (typeof spec === 'object' && spec.rule) {
    start = spec.start || undefined;
    end = spec.end || undefined;
    spec = spec.rule;
    if (start) {
      if (!(start instanceof Date)) {
        start = new Date(start);
      }
      start = new CronDate(start, tz);
      if (!isValidDate(start) || start.getTime() < Date.now()) {
        start = undefined;
      }
    }
    if (end && !(end instanceof Date) && !isValidDate(end = new Date(end))) {
      end = undefined;
    }
    if (end) {
      end = new CronDate(end, tz);
    }
  }
  try {
    // 1. 解析cron表达式
    const res = cronParser.parseExpression(spec, {currentDate: start, tz: tz});
    // 回来下一次的调度办法
    inv = scheduleNextRecurrence(res, self, start, end);
    if (inv !== null) {
      success = self.trackInvocation(inv);
    }
  } catch (err) {
    const type = typeof spec;
    // 2. 字符串或数字判断为固守时刻 Date 格局
    if ((type === 'string') || (type === 'number')) {
      spec = new Date(spec);
    }
    // 2. 标准 Date 格局,手动注册Invocation目标
    if ((spec instanceof Date) && (isValidDate(spec))) {
      spec = new CronDate(spec);
      self.isOneTimeJob = true;
      if (spec.getTime() >= Date.now()) {
        inv = new Invocation(self, spec);
        scheduleInvocation(inv);
        success = self.trackInvocation(inv);
      }
    // 3. 假如是目标,转换成RecurrenceRule类
    } else if (type === 'object') {
      self.isOneTimeJob = false;
      if (!(spec instanceof RecurrenceRule)) {
        const r = new RecurrenceRule();
        if ('year' in spec) {
          r.year = spec.year;
        }
        if ('month' in spec) {
          r.month = spec.month;
        }
        if ('date' in spec) {
          r.date = spec.date;
        }
        if ('dayOfWeek' in spec) {
          r.dayOfWeek = spec.dayOfWeek;
        }
        if ('hour' in spec) {
          r.hour = spec.hour;
        }
        if ('minute' in spec) {
          r.minute = spec.minute;
        }
        if ('second' in spec) {
          r.second = spec.second;
        }
        spec = r;
      }
      spec.tz = tz;
      inv = scheduleNextRecurrence(spec, self, start, end);
      if (inv !== null) {
        success = self.trackInvocation(inv);
      }
    }
  }
  scheduledJobs[this.name] = this;
  return success;
};

Job独有的工作:

这儿讲个比较有代表性的cancel工作

由于一个Job可能会屡次调用schedule办法,那么就会有好几个不同的时刻装备,这时候其实便是用行列(上文有说到的pendingInvocations)往来不断办理,假如要撤销一切守时使命,那么就要遍历撤销一切等待中的Invocation实例,进行撤销。

cancelInvocation办法会在下文说到

// 撤销使命
this.cancel = function(reschedule) {
  reschedule = (typeof reschedule == 'boolean') ? reschedule : false;
  let inv, newInv;
  const newInvs = [];
  for (let j = 0; j < this.pendingInvocations.length; j++) {
    inv = this.pendingInvocations[j];
    cancelInvocation(inv);
    if (reschedule && (inv.recurrenceRule.recurs || inv.recurrenceRule.next)) {
      newInv = scheduleNextRecurrence(inv.recurrenceRule, this, inv.fireDate, inv.endDate);
      if (newInv !== null) {
        newInvs.push(newInv);
      }
    }
  }
  this.pendingInvocations = [];
  for (let k = 0; k < newInvs.length; k++) {
    this.trackInvocation(newInvs[k]);
  }
  // remove from scheduledJobs if reschedule === false
  if (!reschedule) {
    this.deleteFromSchedule()
  }
  return true;
};

分配invoke办法

Job.prototype.invoke = function(fireDate) {
  // trigger计数用于单测,无实际业务逻辑
  this.setTriggeredJobs(this.triggeredJobs() + 1);
  return this.job(fireDate);
};

守时使命履行 – Invocation

中心逻辑首要处理以下工作:

  • 守时器适配守时使命
  • job工作emit触发

守时器闭环:

这儿其实便是守时使命的中心部分了,简略来讲便是利用setTimeout来完成,经过下面的流程图其实你就能大概知道怎么完成守时使命

前端必备的守时使命技术 - Cron + node-schedule

带着上面这个图看源码:

上文中解析完cron装备后,就会履行scheduleNextRecurrence

  1. 判断当时时刻处于合理的规模内,[start, end]中
  2. 实例化Invocation,并组织守时使命 => scheduleInvocation
function scheduleNextRecurrence(rule, job, prevDate, endDate) {
  // 没有指定的开端时刻,当时时刻便是开端时刻
  prevDate = (prevDate instanceof CronDate) ? prevDate : new CronDate();
  // 回来规矩的下一次履行时刻
  const date = (rule instanceof RecurrenceRule) ? rule._nextInvocationDate(prevDate) : rule.next();
  if (date === null) {
    return null;
  }
  // 假如下次履行时刻超出完毕时刻,就直接完毕
  if ((endDate instanceof CronDate) && date.getTime() > endDate.getTime()) {
    return null;
  }
  // 实例化Invocation, 并开端组织守时使命
  const inv = new Invocation(job, date, rule, endDate);
  scheduleInvocation(inv);
  return inv;
}

scheduleInvocation 组织调度器:

  1. 依据cron表达式解析的最近时刻排序当时的invocation
  2. job 触发scheduled工作
  3. 下一层调用链 => prepareNextInvocation
function sorter(a, b) {
  return (a.fireDate.getTime() - b.fireDate.getTime());
}
// 组织当时的调度器,按时刻排序,event触发scheduled工作
function scheduleInvocation(invocation) {
  sorted.add(invocations, invocation, sorter);
  prepareNextInvocation();
  const date = invocation.fireDate instanceof CronDate ? invocation.fireDate.toDate() : invocation.fireDate;
  invocation.job.emit('scheduled', date);
}

prepareNextInvocation 设置守时器:

  1. 铲除当时已有守时器 – 同一时刻下只能有一个setTimeout存在
  2. 守时器触发后就履行invoke
  3. 触发job的run,success,error工作
  4. 铲除行列,并准备下一次的守时器 (prepareNextInvocation),有点递归的意思
function prepareNextInvocation() {
  if (invocations.length > 0 && currentInvocation !== invocations[0]) {
    // 铲除当时守时器
    if (currentInvocation !== null) {
      lt.clearTimeout(currentInvocation.timerID);
      currentInvocation.timerID = null;
      currentInvocation = null;
    }
    currentInvocation = invocations[0];
    const job = currentInvocation.job;
    const cinv = currentInvocation;
    currentInvocation.timerID = runOnDate(currentInvocation.fireDate, function() {
      currentInvocationFinished();
      if (job.callback) {
        job.callback();
      }
      console.log(cinv.recurrenceRule.recurs, cinv.recurrenceRule._endDate)
      // 把新cron解析的办法继续推进去
      if (cinv.recurrenceRule.recurs || cinv.recurrenceRule._endDate === null) {
        const inv = scheduleNextRecurrence(cinv.recurrenceRule, cinv.job, cinv.fireDate, cinv.endDate);
        if (inv !== null) {
          inv.job.trackInvocation(inv);
        }
      }
      job.stopTrackingInvocation(cinv);
      try {
        const result = job.invoke(cinv.fireDate instanceof CronDate ? cinv.fireDate.toDate() : cinv.fireDate);
        job.emit('run');
        job.running += 1;
        // invoke函数可能是异步的,这儿会判断是否成功履行
        if (result instanceof Promise) {
          result.then(function (value) {
            job.emit('success', value);
            job.running -= 1;
          }).catch(function (err) {
            job.emit('error', err);
            job.running -= 1;
          });
        } else {
          job.emit('success', result);
          job.running -= 1;
        }
      } catch (err) {
        job.emit('error', err);
        job.running -= 1;
      }
      // 假如是单次时刻调用,就直接删除Job
      if (job.isOneTimeJob) {
        job.deleteFromSchedule();
      }
    });
  }
}
// 中心基础代码,设置守时器
function runOnDate(date, job) {
  const now = Date.now();
  const then = date.getTime();
  return lt.setTimeout(function() {
    if (then > Date.now())
      runOnDate(date, job);
    else
      job();
  }, (then < now ? 0 : then - now));
}
// 铲除当时的行列,准备下一次的调用
function currentInvocationFinished() {
  invocations.shift();
  currentInvocation = null;
  prepareNextInvocation();
}

这儿还有个细节

lt.setTimeout这个其实是引了一个long-timeout的库,首要是为了处理js中最大安全数字的问题:

前端必备的守时使命技术 - Cron + node-schedule

前端必备的守时使命技术 - Cron + node-schedule

至此,守时使命就形成了闭环。

完毕

虽说守时使命一般是后端来做,可是作为前端,学习一下其中的精华仍是不错的,一些简略的场景也能用node-schedule来掩盖。

发明不易,希望jym多多 点赞 + 关注 + 收藏 三连,继续更新中!!!

PS: 文中有任何错误,欢迎掘友纠正

往期精彩

  • 前端东西小攻略之Prettier✨

  • React调试利器:React DevTools ✨

  • 怎么打造属于自己的CLI东西 ✨

参阅:

github.com/node-schedu…

www.npmjs.com/package/lon…

github.com/ai/nanoeven…