🚀经常发文章的你是否想过定时发布是咋实现的?🚀
前言
可乐他们团队最近在做一个文章社区平台,由于人手不够,前后端都是由前端同学来写。后端使用 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
中文章定时发布的一种实现方式,如果你觉得有意思的话,点点关注点点赞吧~
转载自:https://juejin.cn/post/7361326309995708470