前言
可乐他们团队最近在做一个文章社区平台,由于人手不够,前后端都是由前端同学来写。后端使用 nest
来实现。
某一天周五下午,可乐正在快乐摸鱼,想到周末即将来临,十分开心。然而,产品突然找到了他,说道:可乐,我们要做一个文章定时发布功能。
现在我先为你解释一下这个功能里意义:定时发布功能赋予了作者更大的灵活性和自由度,他们可以提前准备好文章,然后在适当的时机发布,而不必在发布当天临时抓紧时间编辑和发布。
由于上次你做一个点赞做了五天,各个老板已经颇有微词,这个功能你别再搞幺蛾子,周一要提测,你自己把控好时间。
说罢,产品就走了。
可乐挠了挠头,本来想以普通人的身份跟你们相处。结果换来的却是疏远,今天我就给你露一手,半天使用 Nest+React
实现一下文章的定时发布功能。
Cron表达式
Cron 表达式是一种时间表达式,通常用于在计算机系统中指定定时任务的执行时间。它是由特定的格式和字段组成的字符串,用来表示任务在何时执行。
Cron
表达式一般由六个或七个字段组成,每个字段表示任务执行的一个时间单位。这些字段的含义通常如下:
- 秒(Seconds): 表示分钟的秒数,取值范围为
0~59
。 - 分钟(Minutes): 表示小时的分钟数,取值范围为
0~59
。 - 小时(Hours): 表示一天中的小时数,取值范围为
0~23
。 - 日期(Day of month): 表示一个月中的哪一天,取值范围为
1~31
。 - 月份(Month): 表示一年中的哪个月份,取值范围为
1~12
,也可以使用缩写形式如JAN
,FEB
等。 - 星期(Day of week): 表示一周中的哪一天,取值范围为
0~7(0 和 7 都代表周日)
,也可以使用缩写形式如SUN
,MON
等。 - 年(Year): 可选字段,表示哪一年,取值范围为
1970
至2099
。
在 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
这个库进行处理。
上述代码是 @nestjs/schedule
的源码,它收集到 nest
项目中注册的定时任务信息后,会调用 node-cron
中的 CronJob
来创建一个定时任务。
其中 options
就是包含了 cron
表达式在内的定时任务配置信息; target
就是需要执行的逻辑,比如上述的 handle
方法。
创建完 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(); } }
大概解释一下上面的代码;
MAXDELAY
是setTimeout
函数允许的最大等待时间,这里设置为JavaScript
中setTimeout
函数的最大值。timeout
是根据配置的cron
表达式计算得出的定时器的超时时间。remaining
用于跟踪剩余的睡眠时间,即在定时器触发后,如果需要再次睡眠,会记录下剩余的睡眠时间。startTime
记录了定时器开始执行的时间。setCronTimeout()
函数用于设置下一个定时器。它会计算下一个定时器的超时时间,并设置定时器。callbackWrapper()
是定时器的回调函数。它负责处理定时器超时后的逻辑,包括计算是否需要进一步休眠以及执行回调函数。- 如果
remaining
中有剩余的睡眠时间,则根据剩余的时间设置下一个定时器。 - 如果没有剩余的睡眠时间,则表示已经到达了正确的执行时间点。此时会记录下最后一次执行的时间,执行回调函数,并根据是否设置了
runOnce
属性来决定是否再次启动定时器。 - 最后,根据计算出的
timeout
设置下一个定时器。
总的来说, start
实现了一个基于 setTimeout
的定时任务调度器,它会在指定的时间点执行回调函数,并可以根据需要进行循环执行或者只执行一次。
前端实现
加一个定时发布的时间字段,仅仅在第一次发布时生效,并且只能选大于当前时间至少五分钟的时间,最小时间颗粒度是分钟。
{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
是必须加索引的。
定时发布实现
要实现定时发布,主要需要实现以下两个逻辑:
- 当设置了定时发布时,需要往定时任务表里塞一条记录
- 定时任务去扫描表里符合要求的记录,更新文章信息及定时任务表信息
发布逻辑修改
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
字段时:
- 如果已经对当前记录设置了定时发布,则更新发布时间字段
- 创建一条定时发布记录,
targetId
是对应的文章id
;excutionTime
是发布时间;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秒
都执行的定时任务,从定时任务表中捞数据,其中条件是:
status=0
,未执行的定时任务type=1
,定时发布文章excutionTime
小于或者等于当前执行的时间,满足这个时间的任务才是我们需要执行的
然后开启一个事务,更新两张表:
- 更新文章表中的文章状态字段,从草稿更新为已发布
- 更新定时任务表的状态字段,从未执行更新为已执行
最后
本文介绍了在 Nest+React
中文章定时发布的一种实现方式,如果你觉得有意思的话,点点关注点点赞吧~