🚀经常发文章的你是否想过定时发布是咋实现的?🚀

简介: 🚀经常发文章的你是否想过定时发布是咋实现的?🚀

前言

可乐他们团队最近在做一个文章社区平台,由于人手不够,前后端都是由前端同学来写。后端使用 nest 来实现。

某一天周五下午,可乐正在快乐摸鱼,想到周末即将来临,十分开心。然而,产品突然找到了他,说道:可乐,我们要做一个文章定时发布功能。

现在我先为你解释一下这个功能里意义:定时发布功能赋予了作者更大的灵活性和自由度,他们可以提前准备好文章,然后在适当的时机发布,而不必在发布当天临时抓紧时间编辑和发布。

由于上次你做一个点赞做了五天,各个老板已经颇有微词,这个功能你别再搞幺蛾子,周一要提测,你自己把控好时间。

说罢,产品就走了。

可乐挠了挠头,本来想以普通人的身份跟你们相处。结果换来的却是疏远,今天我就给你露一手,半天使用 Nest+React 实现一下文章的定时发布功能。

Cron表达式

Cron 表达式是一种时间表达式,通常用于在计算机系统中指定定时任务的执行时间。它是由特定的格式和字段组成的字符串,用来表示任务在何时执行。

Cron 表达式一般由六个或七个字段组成,每个字段表示任务执行的一个时间单位。这些字段的含义通常如下:

  1. 秒(Seconds): 表示分钟的秒数,取值范围为 0~59
  2. 分钟(Minutes): 表示小时的分钟数,取值范围为 0~59
  3. 小时(Hours): 表示一天中的小时数,取值范围为 0~23
  4. 日期(Day of month): 表示一个月中的哪一天,取值范围为 1~31
  5. 月份(Month): 表示一年中的哪个月份,取值范围为 1~12 ,也可以使用缩写形式如 JAN, FEB 等。
  6. 星期(Day of week): 表示一周中的哪一天,取值范围为 0~7(0 和 7 都代表周日) ,也可以使用缩写形式如 SUN, MON 等。
  7. 年(Year): 可选字段,表示哪一年,取值范围为 19702099

Cron 表达式中,可以使用数字、星号 *、逗号 ,、斜杠 /、连字符 - 等符号来表示不同的时间段和重复规则。例如:

  • * 表示匹配所有可能的值。
  • 5 表示具体的一个值。
  • 1,2,3 表示多个值。
  • */5 表示每隔一定时间执行一次。
  • 10-20 表示一个范围内的值。

举例如下:

  • 0/20 * * * * ?   表示每20秒执行一次
  • 0 0 2 1 * ?   表示在每月的1日的凌晨2点执行
  • 0 0 10,14,16 * * ?   每天上午10点,下午2点,4点执行
  • 0 0 12 ? * WED    表示每个星期三中午12点执行
  • 0 0 12 * * ?   每天中午12点执行

Nest中的定时调度

nest 中,定时任务的注册与触发可以使用 @nestjs/schedule 这个库,它提供了一种装饰器注册定时任务的方式,接入与使用都十分方便。

首先在 app.module.ts 中注册 schedule 这个模块

@Module({
  imports: [
    ScheduleModule.forRoot(),
  ],
})
export class AppModule { }

然后在 service 中使用 @Cron 装饰器就可以注册定时任务了:

  @Cron('*/3 * * * * *') // 每 3 秒钟执行一次
  async handle() {
    console.log('Called every 3 seconds article service');
  }

@nestjs/schedule 在这个过程中需要做的事情是,通过装饰器收集到每个定时任务的执行器(即上面的 handle 方法)以及每个定时任务的触发时机,即上述的 cron 表达式。

而真正调度定时任务的逻辑其实不在 @nestjs/schedule 中处理, @nestjs/schedule 收集到触发时机以及执行器之后,会把它们交给 node-cron 这个库进行处理。

image.png

上述代码是 @nestjs/schedule 的源码,它收集到 nest 项目中注册的定时任务信息后,会调用 node-cron 中的 CronJob 来创建一个定时任务。

其中 options 就是包含了 cron 表达式在内的定时任务配置信息; target 就是需要执行的逻辑,比如上述的 handle 方法。

image.png

创建完 Cron 实例之后,核心调度的代码在 start 方法中,大致贴一下这个方法的代码:

start() {
  if (this.running) {
    return;
  }

  const MAXDELAY = 2147483647; // The maximum number of milliseconds setTimeout will wait.
  let timeout = this.cronTime.getTimeout();
  let remaining = 0;
  let startTime: number;

  const setCronTimeout = (t: number) => {
    startTime = Date.now();
    this._timeout = setTimeout(callbackWrapper, t);
    if (this.unrefTimeout && typeof this._timeout.unref === 'function') {
      this._timeout.unref();
    }
  };
  const callbackWrapper = () => {
    const diff = startTime + timeout - Date.now();

    if (diff > 0) {
      let newTimeout = this.cronTime.getTimeout();

      if (newTimeout > diff) {
        newTimeout = diff;
      }

      remaining += newTimeout;
    }

    if (remaining) {
      if (remaining > MAXDELAY) {
        remaining -= MAXDELAY;
        timeout = MAXDELAY;
      } else {
        timeout = remaining;
        remaining = 0;
      }

      setCronTimeout(timeout);
    } else {
      // We have arrived at the correct point in time.
      this.lastExecution = new Date();

      this.running = false;

      if (!this.runOnce) {
        this.start();
      }

      this.fireOnTick();
    }
  };

  if (timeout >= 0) {
    this.running = true;
    if (timeout > MAXDELAY) {
      remaining = timeout - MAXDELAY;
      timeout = MAXDELAY;
    }

    setCronTimeout(timeout);
  } else {
    this.stop();
  }
}

大概解释一下上面的代码;

  1. MAXDELAYsetTimeout 函数允许的最大等待时间,这里设置为 JavaScriptsetTimeout 函数的最大值。
  2. timeout 是根据配置的 cron 表达式计算得出的定时器的超时时间。
  3. remaining 用于跟踪剩余的睡眠时间,即在定时器触发后,如果需要再次睡眠,会记录下剩余的睡眠时间。
  4. startTime 记录了定时器开始执行的时间。
  5. setCronTimeout() 函数用于设置下一个定时器。它会计算下一个定时器的超时时间,并设置定时器。
  6. callbackWrapper() 是定时器的回调函数。它负责处理定时器超时后的逻辑,包括计算是否需要进一步休眠以及执行回调函数。
  7. 如果 remaining 中有剩余的睡眠时间,则根据剩余的时间设置下一个定时器。
  8. 如果没有剩余的睡眠时间,则表示已经到达了正确的执行时间点。此时会记录下最后一次执行的时间,执行回调函数,并根据是否设置了 runOnce 属性来决定是否再次启动定时器。
  9. 最后,根据计算出的 timeout 设置下一个定时器。

总的来说, start 实现了一个基于 setTimeout 的定时任务调度器,它会在指定的时间点执行回调函数,并可以根据需要进行循环执行或者只执行一次。

前端实现

image.png

加一个定时发布的时间字段,仅仅在第一次发布时生效,并且只能选大于当前时间至少五分钟的时间,最小时间颗粒度是分钟。

{data.status === 0 && (
  <Form.Item name="time" label="定时发布">
    <DatePicker
      disabledTime={() => {
        const range = (start: number, end: number) => {
          const result = [];
          for (let i = start; i < end; i++) {
            result.push(i);
          }
          return result;
        };
        const now = new Date();
        const hour = now.getHours();
        const minute = now.getMinutes();
        return {
          disabledHours: () =>
            range(0, 24).filter((h) => h < hour),
          disabledMinutes: () =>
            range(0, 60).filter((m) => m - 5 < minute),
        };
      }}
      disabledDate={(current) => {
        return current && current < dayjs().startOf("day");
      }}
      showTime={{ format: "HH:mm" }}
      format="YYYY-MM-DD HH:mm"
    />
  </Form.Item>
)}

发布的时候会判断是否填写了定时发布字段,如果有填写的话,格式化一下传给后端

if (fields.time) {
  fields.time = dayjs(fields.time).format("YYYY-MM-DD HH:mm:ss");
}

表设计

我们需要一张表,来存储定时任务的触发时间,以及任务的执行状态, DDL 语句如下:

-- jueyin.schedule_records definition
CREATE TABLE `schedule_records` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`target_id` int(11) DEFAULT NULL,
`excution_time` timestamp DEFAULT NULL,
`status` int(4) DEFAULT '0',
`created_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`type` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `schedule_records_excution_time_IDX` (`excution_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  • id :主键 id
  • target_id :比如说这里是定时发布文章, target_id 就是对应的文章主键 id
  • excution_time :执行时间
  • status :执行状态, 0未执行1已执行
  • type :定时任务的类型,比如定时发布文章、定时发布帖子等
  • created_time :创建时间
  • updated_time :更新时间

这里建了一个 excution_time 的二级索引,因为我们是定时去触发某个方法,然后这个方法根据当前的时间去表里找到需要执行的任务,所以 excution_time 是必须加索引的。

定时发布实现

要实现定时发布,主要需要实现以下两个逻辑:

  1. 当设置了定时发布时,需要往定时任务表里塞一条记录
  2. 定时任务去扫描表里符合要求的记录,更新文章信息及定时任务表信息

发布逻辑修改

 if (publishArticleDto.time) {
      // 往定时任务表塞数据
  const record = await this.scheduleRecordRepository.findOne({
    where: { targetId: id, type: 1, status: 0 },
  });
  if (record) {
    record.excutionTime = publishArticleDto.time;
    await this.scheduleRecordRepository.update({ id: record.id }, record);
  } else {
    await this.scheduleRecordRepository.save({
      targetId: id,
      type: 1,
      status: 0,
      excutionTime: publishArticleDto.time,
    });
  }
}

当发布请求传输了 time 字段时:

  1. 如果已经对当前记录设置了定时发布,则更新发布时间字段
  2. 创建一条定时发布记录, targetId 是对应的文章 idexcutionTime 是发布时间; type=1 表示当前定时任务类型,即定时发布文章; status=0 表示待执行。

定时触发逻辑实现

  @Cron('15 * * * * *') // 每分钟第15秒执行一次
  async schedulePublishAriticle() {
    console.log('Called every 30 seconds article service', Date.now());
    const currentTime = Date.now();
    const records = await this.scheduleRecordRepository.find({
      where: {
        status: 0,
        type: 1,
        excutionTime: LessThanOrEqual(currentTime),
      },
    });
    if (records.length === 0) {
      return;
    }
    await this.entityManager.transaction(async (transactionalEntityManager) => {
      const tragetIds = records.map((record) => record.targetId);
      const ids = records.map((record) => record.id);
      /**把文章状态从草稿更新到发布 */
      await transactionalEntityManager.update(
        ArticleEntity,
        { id: tragetIds },
        { status: 1 },
      );
      /**把定时任务状态更新为成功 */
      await transactionalEntityManager.update(
        ScheduleRecordEntity,
        { id: ids },
        { status: 1 },
      );
    });
  }

首先注册一个 每分钟第15秒 都执行的定时任务,从定时任务表中捞数据,其中条件是:

  1. status=0 ,未执行的定时任务
  2. type=1 ,定时发布文章
  3. excutionTime 小于或者等于当前执行的时间,满足这个时间的任务才是我们需要执行的

然后开启一个事务,更新两张表:

  1. 更新文章表中的文章状态字段,从草稿更新为已发布
  2. 更新定时任务表的状态字段,从未执行更新为已执行

最后

本文介绍了在 Nest+React 中文章定时发布的一种实现方式,如果你觉得有意思的话,点点关注点点赞吧~

相关文章
|
安全 网络协议 物联网
不看后悔系列之一篇搞懂LinuxCentOS搭建MQTT服务器及客户端操作使用
linux CentOS上搭建MQTT服务器并不难,主要就是用到了mosquitto这款消息代理服务软件。其采用发布/订阅模式传输机制,轻量、简单、开放并易于实现,被广泛应用于物联网之中。
2005 0
|
3月前
|
运维 程序员 项目管理
一份【软件工程】的学习指南已到达,请注意查收!!
该文章提供了一份软件工程的学习指南,包括学习软件工程的重要性、基本内容概览以及建议的学习方法和路径。
一份【软件工程】的学习指南已到达,请注意查收!!
|
7月前
|
数据采集 移动开发 前端开发
2023年, 前端路上的开源总结(最新更新)
2023年, 前端路上的开源总结(最新更新)
65 0
回顾 | 5G通信领域的应用研讨会内容已全部更新完毕,精彩内容全在这里!
各专家在5G通信领域的应用研讨会上所发表的演讲内容已经全部展示完毕
回顾 | 5G通信领域的应用研讨会内容已全部更新完毕,精彩内容全在这里!
|
安全 网络协议 Java
【紧急】Log4j又发新版2.17.0,只有彻底搞懂漏洞原因,才能以不变应万变,小白也能看懂
经过一周时间的Log4j2 RCE事件的发酵,事情也变也越来越复杂和有趣,就连 Log4j 官方紧急发布了 2.15.0 版本之后没有过多久,又发声明说 2.15.0 版本也没有完全解决问题,然后进而继续发布了 2.16.0 版本。大家都以为2.16.0是最终终结版本了,没想到才过多久又爆雷,Log4j 2.17.0横空出世。
639 0
|
Python
女友让我每天半夜十二点给她发晚安?我用 Python 做了个定时发消息神器!怕她干嘛!
女友让我每天半夜十二点给她发晚安?我用 Python 做了个定时发消息神器!怕她干嘛!
154 0
|
小程序 Java 机器人
使用Java实现发送微信消息(附源码)_此程序在手再也不怕对象跟你闹了
此程序在手再也不怕女朋友跟你闹了!!!!自从有了女朋友比如:早安、晚安之类的问候语可不能断,但是也难免有时候会忘记那么该咋么办呢?很简单写一个程序么,近日闲来无趣想着用Java写一个自动发送微信的小程序,实现定时给指定的好友发送指定的消息,这不就很Nice了?本文主要包括实现的思路、代码的实现、打包为jar快捷方式!
167 0
|
Java 开发者 容器
先到先学!Alibaba甩出第四次更新的JDK源码高级笔记(终极版)
作为Java开发者,面试肯定被问过多线程。对于它,大多数好兄弟面试前都是看看八股文背背面试题以为就OK了;殊不知现在的面试官都是针对一个点往深了问,你要是不懂其中原理,面试就挂了。可能你知道什么是进程什么是线程,但面试官要是问你进程之间是如何通讯的?ConcurrentHashMap 和 HashTable有什么区别?为什么wait和notify方法要在同步块代码中调用?你答不上来就只能等通知了。。。
|
缓存 前端开发 API
异步的发展,顺手学会怎么处理多请求
异步的发展,顺手学会怎么处理多请求
127 0
|
消息中间件 Java Docker
[技术杂谈]最近一段时间值得分享的内容(2)
[技术杂谈]最近一段时间值得分享的内容(2)
529 0
[技术杂谈]最近一段时间值得分享的内容(2)