记录我的NestJS探究历程(十三)——定时任务
前言
什么是定时任务?如果各位完全没有接触过后端的同学可能根本不知道有这样一回事儿,说起来就跟前端使用setInterval执行代码那么简单,😂。为什么会有定时任务呢,比如以下的业务场景:
- 数据备份: 定时将数据库中的数据备份到指定位置,以防止数据丢失。
- 自动化任务: 执行一些重复性、机械性的任务,如文件清理、日志归档等。
- 报告生成: 在每个月底自动生成报告,并将其发送给相关人员。
- 数据同步: 将不同系统或数据库中的数据定期同步,保持一致性。
- 定时提醒: 在特定时间点发送提醒通知,如日历提醒、定时任务提醒等。
- 爬虫任务: 定期抓取网站上的数据,更新本地数据库。
另外再给大家举一些我曾经见过的例子,我曾经就职于一家金融公司,在每个交易日4点会做当时的数据清算;我现在就职的公司,某个活动,假设每天早上10点,会给昨天10点到今天10点前中奖的用户发奖;在和明星合作的活动,某个明星演唱会开始的前3天,每天都要给订阅的用户发消息通知。
以Standlone App方式运行NestJS
NestJS支持以HttpServer、MicroService、Standlone三种方式启动,定时任务采用Standlone App方式运行。
1、独立运行的优势
某些同学看到这儿可能是一头雾水的,这个知识点,我在之前也是不知道的,感谢我的后端同事不厌其烦的给我讲解里面的各种为什么。
定时任务是跟Http服务没有任何关系的,因为定时任务是满足条件就触发的,并不是要暴露给用户的,我们可以把它理解成一个后台程序跑在服务器上,仅此而已。
另外为什么定时任务不能跟HttpServer写在一起呢?这就是一个更关键的话题了。
这儿跟服务的部署有关系,首先,如果定时任务和Http服务写在一起的话,就会存在一个问题,假设Http服务挂了,定时任务也就挂了,我们强行将两个没有关系的服务绑定在了一起,增加了维护的难度,但是说到这儿,这个是主要的问题,但是从另外一个方面来讲,这样真的太铺张浪费了,😂。
另外一个关键的问题是节省资源,像Http服务一般会有较大的用户流量,那么,你的服务器响应不至于太慢的前提,系统的硬件性能肯定是不能差的,所以我们会为Http分配更多的系统资源。但是定时任务这种东西,它就是一个应用服务而已,又没有人访问它,我们给它分配的资源只要有能够满足它的运行条件即可,这样就可以节省很多的服务器资源。
在上文我就已经向大家提到过了,NestJS的核心就是HttpServer+IoC容器,如果把这个HttpServer拿掉,我们仅仅只用它的IoC能力,那么就可以了。
2、在同一个项目内编写定时任务
之前说了独立运行定时任务的优势,有的同学就会有疑问了,独立运行的话,那是不是我们就要维护两个项目了呢?可以,但是会增加我们维护的难度。
在本系列文章的开头的编程技巧我们就向大家阐述了面向Service编程的优势,当这个点做好了的话,那么就一定是有很多代码定时任务是可以复用的,如果分别维护两个项目的话,势必就只能将这些可复用的代码封装到npm包中,那每次对公共的代码进行修改,就要改动npm包,重新发包,然后两个项目都分别更新,这样不好。
所以,这样不行的话,那我就把项目做成多个入口文件,比如普通的入口文件就是app.module.ts,比如定时任务的入口文件就是schedule.module.ts,然后在增加对应的命令,分别来运行着两个入口文件。
软件编程里面没有银弹,这种方式那势必就会导致我们在代码里面判断环境的逻辑会变得复杂了,但是为了写代码时候的直接,这种不便我们就只能忍了。
为此,我就单独封装了一个Env类来进行管理了:
class Env {
static get isCronEnv() {
return /^cron-/.test(process.env.NODE_ENV);
}
static get isProd(): boolean {
return /production/i.test(process.env.NODE_ENV);
}
static get isHttpProd() {
return this.isProd && !this.isCronDev;
}
static get isCronProd() {
return this.isProd && this.isCronDev;
}
static get isDev(): boolean {
return /development/i.test(process.env.NODE_ENV);
}
static get isHttpDev() {
return !this.isCronEnv && this.isDev;
}
static get isCronDev() {
return this.isCronEnv && this.isDev;
}
static get isTesting(): boolean {
return /testing/i.test(process.env.NODE_ENV);
}
static get isHttpTesting() {
return !this.isCronEnv && this.isTesting;
}
static get isCronTesting() {
return this.isCronEnv && this.isTesting;
}
}
对于一些业务,还可以采用模板方式模式
来编写,即将公共的业务场景抽离到基础方法,Cron和HttpServer分别使用不同的实现,然后两个入口文件最终分别调用不同的业务实现类即可。
比如:
abstract class BaseService {
public getCommonHello() {
// 通用业务逻辑
}
public getCommonWorld() {
// 通用业务逻辑
const taskList = this.getTaskList()
console.log(taskList)
}
// 抽象逻辑,由子类实现。为了简便,我就写的是Array<object>,实际上可以使用接口,不明白的同学尅参考我前文关于可维护代码的文章
public abstract getTaskList(): Array<object>;
}
// Cron特征业务的实现
class CronService extends BaseService {
public getTaskList(): Array<object> {
return [{}, {}, {}, {}]
}
}
// HttpServer特征业务的实现
class ServerService extends BaseService {
public getTaskList(): Array<object> {
return [{
name: '爱国',
}, {
name: '敬业'
}, {
name: '富强'
}, {
name: '和谐'
}]
}
}
对于HttpServer的代码我们不做任何改变,在package.json中增加对应的命令即可:
{
// 省略其它配置
"scripts": {
"cron:prod": "cross-env NODE_ENV=cron-production node dist/scheduler",
"cron:testing": "cross-env NODE_ENV=cron-testing node dist/scheduler",
// 指定入口文件是src/scheduler.ts
"cron:dev": "cross-env NODE_ENV=cron-development NEST_DEBUG=1 nest start --watch --entryFile scheduler",
"cron:debug": "nest start --debug --watch --entryFile scheduler"
}
}
以下是我的scheduler.ts内容的删减版:
import { NestFactory } from '@nestjs/core';
import { CronModule } from './cron.module';
async function bootstrap() {
// 此刻我们调用的是创建独立上下文的API而不是create得到一个HttpServer
const app = await NestFactory.createApplicationContext(CronModule);
app.enableShutdownHooks();
// 等待IoC容器创建完成,触发对应模块的生命周期回调
await app.init();
// 获取到关键的启动Service的实例用以启动App。
const tasksService = app.select(TasksModule).get(TasksService, { strict: true });
// 启动定时任务
tasksService.startup();
}
bootstrap();
独立上下文的API还不止上面我用到的那么些,可以选择某个模块,然后获取某个类的实例:
const app = await NestFactory.createApplicationContext(AppModule);
const tasksService = app.select(TasksModule).get(TasksService, { strict: true });
如果需要销毁独立上下文,可以调用它的close方法进行关闭,这个方法内部会触发模块的生命周期。
async function bootstrap() {
const app = await NestFactory.createApplicationContext(AppModule);
// application logic...
await app.close();
}
bootstrap();
最后,在部署的时候,分别使用两个命令部署成两个服务即可。
在NestJS中执行定时任务
NestJS有对应的定时任务npm包的支持
npm install --save @nestjs/schedule
在入口文件注册它就可以了
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { TaskModule } from './cron/task.module.ts'
@Module({
imports: [
ScheduleModule.forRoot(),
TaskModule
],
})
export class AppModule {}
然后,编写一个Service用来执行定时任务即可。
// task.module.ts
import { Module } from '@nestjs/common';
@Module({
providers: [TasksService],
})
export class TaskModule {}
/* ================================================= */
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
// tasks.service.ts
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
@Cron('45 * * * * *')
handleCron() {
this.logger.debug('Called when the current second is 45');
}
}
上面的这个cron表达式本文就不做解释了,关于Cron的表达式大家可以查看专门的文章(如果你确实想掌握的话,😂),实际开发中,这种任务直接交给Chatgpt,又快又准,哈哈哈。 使用了@Cron装饰器装饰的方法是不需要手动执行的,我上文中手动调用是因为我的业务代码中的执行规则来源于Nacos的配置。
另外,可以通过编程的形式来执行定时任务,这也就是我在实际项目中的需求。
import { Injectable, Logger } from '@nestjs/common';
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
counter = 0;
constructor(protected schedulerRegistry: SchedulerRegistry) {}
@Cron('3 * * * * *')
handleCron() {
this.logger.log('Called when the current second is 3');
}
stop() {
console.log('准备停止任务');
const job = this.schedulerRegistry.getCronJob('test');
job.stop();
this.schedulerRegistry.deleteCronJob('test');
}
startup() {
const job = new CronJob(`3 * * * * *`, () => {
this.logger.log(`time (3) for job test to run!`);
this.counter++;
if (this.counter === 2) {
this.stop();
}
});
// 因为我的项目中装的cron和@nestjs/schedule依赖的cron版本不一致,所以我用了一下as any
this.schedulerRegistry.addCronJob('test', job as any);
job.start();
this.logger.warn(`job test added for each minute at 3 seconds!`);
}
}
NestJs提供了SchedulerRegistry这个Service供我们管理任务。
另外,NestJS还提供了Interval和Timeout的定时任务形式,这个就跟我们平时写的JS代码的用法几乎是一样的,比如我文章的开头提到的,在给明星活动的订阅用户发送推送的时候,就可以采用这种定时任务的方式。
import { Injectable, Logger } from '@nestjs/common';
import { Interval, SchedulerRegistry, Timeout } from '@nestjs/schedule';
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
@Interval(10000)
handleInterval() {
this.logger.debug('Called every 10 seconds');
}
@Interval('notifications', 2500)
handleNamedInterval() {
this.logger.debug('Called every 25 seconds');
}
@Timeout(5000)
handleTimeout() {
this.logger.debug('Called once after 5 seconds');
}
}
另外,它们也都支持动态调用,我就只为大家展示一下编程的方式调用Interval的处理。
import { Injectable, Logger } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
counter = 0;
constructor(protected schedulerRegistry: SchedulerRegistry) {}
stop() {
console.log('准备停止任务');
this.schedulerRegistry.deleteInterval('demo');
}
startup() {
const interval = setInterval(() => {
this.logger.log(`time task for job test to run!`);
this.counter++;
if (this.counter === 10) {
this.stop();
}
}, 3000);
this.schedulerRegistry.addInterval('demo', interval);
this.logger.warn(`job test added for interval`);
}
}
总结
NestJS提供的独立App的运行方式给了我们能够使用它的IoC
容器来编写Nodejs程序的能力。
定时任务是一个非常适用于这种运行方式的业务场景,如果你在编写定时任务的时候,首先在做需求分析的阶段就要考虑这个服务是否有必要跟HttpServer独立部署。
独立部署不仅可以节约一部分服务器资源,在服务出问题的时候能够分别处理,要比耦合在一起好,而且Http服务出问题也不至于把定时任务的服务也带崩。
以上就是我的后端同事给我分享的经验啦,希望对各位读者有用。
转载自:https://juejin.cn/post/7321049360358735899