概述
- 接触过Linux的小伙伴,应该知道Linux系统上有定时任务,而Windows系统上叫做计划任务
- 不论哪种系统,大家或多或少都了解过定时任务,它可以通过系统脚本实现
- 基于 NestJS定时任务,确实可以通过系统级脚本实现对MongoDB数据库的维护
- 在管理的是日志部分的数据库,数据库需要进行滚动和备份操作, 就需要定时任务
- NestJS的定时任务功能,有以下两个优点:
- 不受平台限制
- 它跟随业务系统运行,只要平台上有Node.js环境
- 业务系统就能运行,定时任务也能生效
- 支持多种任务类型
- NestJS的定时任务既支持静态定时任务(即随着业务系统启动就注册好)
- 也支持动态定时任务我们可以通过接口动态添加定时任务,这里有个定时任务示例叫
addCronJobaddCronJob(name: string, seconds: string) { const job = new CronJob(`${seconds} * * * * *`, () => { this.logger.warn(`time (${seconds}) for job ${name} to run!`); }); this.schedulerRegistry.addCronJob(name, job); job.start(); this.logger.warn( `job ${name} added for each minute at ${seconds} seconds!`, ); }
- 不受平台限制
场景
- 定时任务有很多应用场景,比如计划类型的业务和通知类型的业务
- 例如,我们每天要检查系统中的计划是否按照用户设定的节点推进,如果没有推进,就可以添加定时任务,每隔一段时间给用户发送通知
- 当然,这里设置的定时任务通常是批量操作,而非为每个用户单独创建。添加定时任务后,其逻辑可能是根据用户数据库关联表格查询对应字段,然后给用户发送通知或消息
配置
下面来实操NestJS定时任务,并针对当前应用场景完成数据库部分数据的定时备份、滚动和清理
1 ) 配置NestJS服务端内容
-
安装依赖:我们需要安装一个名为
@nestjs/schedule的依赖 -
配置定时任务相关内容
- 我们需要设置一个环境变量 在 .env 类似相关文件中设置
CRON_ON为trueCRON_ON=true
- 在app.module.js模块或其他拆分管理的module模块中注册:
import { Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; @Module({ imports: [ ScheduleModule.forRoot() ], }) export class AppModule {}
- 这样,是全局生效,我们也可以根据配置来根据配置进行条件生效,
- 如, 新建
conditional.module.ts文件来做拆分管理import { Module } from '@nestjs/common'; import * as dotenv from 'dotenv'; import { toBoolean } from '../utils/format'; import { MailModule } from './mail/mail.module'; import { ScheduleModule } from '@nestjs/schedule'; import { TasksService } from '@/common/cron/tasks.service'; // import { StorageModule } from './storage/storage. module'; const imports =[]; const providers = []; const conditionalImports = () => { const envFilePaths = [ `.env.${process.env.NODE_ENV |`development`}`, '.env', ]; const parsedConfig = dotenv.config({ path:'.env'}).parsed; envFilePaths.forEach((path) => { if (path ==',env') return; constconfig = dotenv.config({path}); Object.assign(parsedConfig,config.parsed); }); if(toBoolean(parsedConfig['MAIL_ON'])){ imports.push(MailModule); } if(toBoolean(parsedConfig['CRON_ON']){ imports.push(ScheduleModule.forRoot()); providers.push(TasksService); } return imports; }; @Module({ imports: conditionalImports(), providers, }); export class ConditionalModule {} - 将
conditional.module.ts加入app.module.js中
- 我们需要设置一个环境变量 在 .env 类似相关文件中设置
-
创建任务服务类:新建
common/cron目录,在下面新建一个tasks.service.ts文件,导出TasksService类import { Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; @Injectable() export class TasksService { @Cron('******') handleCron(){ console. log('test'); } }- 使用
@Injectable()装饰器,可参考官方示例 - 在类中调用
console.log进行打印,其中cron是定时表达式,写六个星号代表每秒执行一次
- 使用
-
使用定时任务:在上面的
conditional.module,将TasksService添加到providers数组中,已处理 -
这块,条件模块可以优化
import { Module } from '@nestjs/common'; import * as dotenv from 'dotenv'; import { toBoolean } from '../utils/format'; import { MailModule } from './mail/mail.module'; import { ScheduleModule } from '@nestjs/schedule'; import { TasksService } from '@/common/cron/tasks.service'; // import { StorageModule } from './storage/storage. module'; const imports =[]; const providers = []; const exportsService = []; @Module({ imports: conditionalImports(), providers, }); export class ConditionalModule { static register(): DynamicModule { const envFilePaths = [ `.env.${process.env.NODE_ENV |`development`}`, '.env', ]; const parsedConfig = dotenv.config({ path:'.env'}).parsed; envFilePaths.forEach((path) => { if (path ==',env') return; constconfig = dotenv.config({path}); Object.assign(parsedConfig,config.parsed); }); if(toBoolean(parsedConfig['MAIL_ON'])){ imports.push(MailModule); } if(toBoolean(parsedConfig['CRON_ON']){ imports.push(ScheduleModule.forRoot()); providers.push(TasksService); exportsService.push(TasksService); } imports.push(conditionalImports()); return { module: ConditionalModule, imports, providers, exports: exportsService } } } -
在 app.module.ts 中使用
ConditionalModule的时候- 替换为:
ConditionalModule.register()
- 替换为:
2 )源码解析
- NestJS的定时任务注册主要在
onApplicationBootstrap这个生命周期钩子方法中进行 - 我们可以查看
@nestjs/schedule的官方源码,在相关文件中 schedule/lib/scheduler.orchestrator.ts - 可以找到
onApplicationBootstrap方法, 定时任务就在这里注册 - 当应用关闭时,会清除对应的定时器、计时器以及定时任务
onApplicationBootstrap(){ this.mountTimeouts(); this.mountIntervals(); this. mountCron(); } onApplicationShutdown(){ this. clearTimeouts(); this.clearIntervals(); this.closeCronJobs(); }
3 ) 定时任务备份数据库
- 参考文档: backup-and-restore-tools/#deployments
- 定时任务主要有两个
- 连接到 MongoDB 并导出
connections数据 - 删除已有的
connections数据
- 连接到 MongoDB 并导出
- 删除数据时,需要删除两部分内容
- 一是当前
connections中的已备份数据,避免重复备份导致日志文件越来越大 - 二是对比备份时间,删除超过一定天数或规则的先前备份的
connections数据,实现滚动记录,防止磁盘爆满
- 一是当前
- 注意,这里不考虑集群分片的备份,一般集群会用分布式日志系统,如: ELK
- 更多参考上述文档或请教专业运维,下面仅演示单机
MongoDB 备份与恢复
-
要实现备份,需要了解如何在 Docker 中执行
mongodump命令 -
在 Docker 中有多个 MongoDB 实例,加入要导出端口为
27017的 MongoDB 中connections名为log的数据 -
执行命令如下:
docker exec -it <容器名称> mongodump --uri=<数据库连接> --collection=log --out=/tmp/<时间戳>-log -
如:
docker exec -it nestjs-starter-mongo-1 mongodump --uri=mongodb://root:exmaple@localhost:27017/nest-logs --collection=log --out=/tmp/2025-06-06-log -
上述命令将数据备份到 Docker 容器内的临时目录
/tmp下 -
可以使用以下命令验证备份是否成功:
docker exec -it <容器名称> ls -la /tmp/<时间戳>-log -
如:
docker exec -it nestjs-starter-mongo-1 ls -la /tmp -
若要恢复数据,MongoDB 提供了
mongorestore命令。执行命令如下:docker exec -it <容器名称> mongorestore --uri=<数据库连接> --nsInclude=<源数据库名称> <指定的备份目录路径> -
如:
docker exec -it nestjs-starter-mongo-1 mongorestore -uri=mongob:/root:exmaple@localhost:27017/nest-logs --nsInclude="nest-logs.log" /tmp/2025-06-06-log/nest-logs -
为避免覆盖正在使用的
connection,恢复时应指定新的connection名称(文件路径), 以便不影响正在收集的connection -
例如,从
netlogs中读取数据并恢复到netlogs.log-2025-06-06这个新的connection上,若该connection不存在则会自动创建docker exec -it nestjs-starter-mongo-1 mongorestore -uri=mongob:/root:exmaple@localhost:27017/nest-logs --nsFrom="nest-logs.log" --nsTo="nest-logs.log-2025-06-06" /tmp/2025-06-06-log/nest-logs -
也就是,如果需要查看历史备份,通常会将其恢复到一个全新名称的
connection上,而不是覆盖原有的connection -
现在,我们使用命令来做,每次都是手动的,还是比较麻烦的,我们想要使用定时任务来做,就需要完善定时任务的脚本
-
这里推荐一个 ssh 的工具: ssh2
- 这里基于文档,可以封装一个 ssh 相关的模块
- 来提供 sshService 服务
- 这里细节在最下面提供
-
现在补充定时任务,编辑
common/cron/tasks.service.ts示例如下:import { Injectable } from '@nestis/common'; import { Cron } from '@nestjs/schedule'; import { SshService } from '@/utils/ssh/ssh.service'; @Injectable() export class TasksService { constructor(private sshService: SshService){} @Cron('* * ** * *') handleCron(){ //备份:连接到MongoDB并导出对应的db中的collections的数据 //滚动记录:删除已有的collections的数据 //1.删除当前collections中的已备份数据 //2.之前备份的collections->对比collection备份的时间,如果超过t天/hours的规则,则删除 const containerName = 'mongo-mongo-1'; const uri='mongodb: //root: example@localhost: 27017/nest-logs'; const now = new Date(); const collectionName = 'log'; const outputPath =`/tmp/logs-${now.getTime()}`; const hostBackupPath = '/srv/logs'; const cmd =`docker exec -i ${containerName} mongodump --uri=${uri} --collection=${collectionName} --out=${outputPath}`; const cpCmd =`docker cp ${containerName}:${outputPath} ${hostBackupPath}`; await this.sshService.exec(`${cmd} && ${cpCmd}`).catch((err) => err) // 这里 catch 后续可以不用,因为没有 reject 了,参考下面 await this.sshService.exec(`ls-la ${hostBackupPath}`); const delCmd = `find ${hostBackupPath} -type d -mtime +30 -exec rm -rf {}\\;`; await this.sshService.exec(delCmd).catch((err)=>err); // 这里 catch 后续可以不用,因为没有 reject 了,参考下面 const res = await this.sshService.exec(`ls-la ${hostBackupPath}`); console.log('~ TasksService ~ handleCron ~ res:', res); } }
封装 ssh 相关服务参考
1 )目录结构
-
ssh
- ssh-core.module.ts
- ssh.interface.ts
- ssh.constants.ts
- ssh.module.ts
- ssh.service.ts
-
生成相关组件
- $
nest g mo utils/tools --no-spec -d --flat生成 utils/tools.module.ts 不会有 utils 目录 - $
nest g mo utils/ssh --no-spec -d生成 utils/ssh/ssh.module.ts 这里有一层 ssh 目录 - 这时候 两个模块就创建好了 在 tools.module 中会导入 ssh.module
- $
2 ) 安装依赖
- $
pnpm i ssh2 - $
pnpm i @type/ssh2 -D
3 ) ssh.module.ts 模块
-
创建2个 方法:forRoot (直传 options), forRootAsync ( 工厂方法, useFactory 依赖注入 inject configService)
-
核心代码
import { DynamicModule, Module } from '@nestjs/common'; import { SshCoreModule } from './ssh-core.module'; import { SshModuleOptions, SshModuleAsyncOptions } from './ssh.interface'; @Module({}) export class SshModule { // forRoot -> 直传 options static forRoot(options: SshModuleOptions, name?: string): DynamicModule { return { module: SshModule, imports: [SshCoreModule.forRoot(options, name)], }; } // forRootAsync -> useFactory -> inject configService static forRootAsync(options: SshModuleAsyncOptions, name?:string): DynamicModule { return { module: SshModule, imports: [SshCoreModule.forRootAsync(options, name)], }; } } -
这里外层仅作为不变的接口,核心逻辑 都在 ssh-core 和 ssh.service 中
4 ) ssh.interface.ts
核心代码
import { ModuleMetadata, Type } from '@nestjs/common';
import { ConnectConfig } from 'ssh2';
// 定义一个工厂接口,用于异步创建 SSH 模块选项
export interface SshOptionsFactory {
// 定义一个抽象方法,用于创建 SSH 模块选项
createSshOptions(): Promise<SshModuleOptions> | SshModuleOptions;
}
// 定义 SSH 模块选项接口,继承自 ssh2 的 ConnectConfig
export interface SshModuleOptions extends ConnectConfig {}
// 定义异步 SSH 模块选项接口,继承自 ModuleMetadata 的 imports 属性
export interface SshModuleAyncOptions extends Pick<ModuleMetadata, 'imports'> {
// 使用现有的工厂类
useExisting?: Type<SshOptionsFactory>;
// 使用新的工厂类
useClass?: Type<SshOptionsFactory>;
// 使用工厂函数
useFactory?: (
...args: any[]
) => SshModuleOptions | Promise<SshModuleOptions>;
// 注入依赖项
inject?: any[];
}
5 )通用变量抽离 ssh.constants.ts
export const SSH_OPTIONS = Symbol('SSH_OPTIONS');
6 )ssh-core.module.ts
import { DynamicModule, Global, Module, Type, Provider } from '@nestjs/common';
import { SshModuleAsyncOptions, SshModuleOptions, SshOptionsFactory } from './ssh.interface';
import { SSH_OPTIONS } from './ssh.constants';
@Module({})
@Global()
export class SshCoreModule {
// forRoot -> 直传options
static forRoot(options: SshModuleOptions, name?: string): DynamicModule {
const sshOptionsProvider: Provider = {
provide: name ? name + `:${SSH_OPTIONS}` : SSH_OPTIONS,
useValue: options,
};
const sshClientProviders: Provider[] = [];
if (name) {
sshClientProviders.push({
provide: `${name}:SSH`,
useFactory: async () => {
const ssh = new SshService(options);
await ssh.connect();
return ssh;
}
});
} else {
sshClientProviders.push(SshService)
}
return {
module: SshCoreModule,
providers: [sshOptionsProvider, ...sshClientProviders],
exports: [...sshClientProviders],
}
}
static createAsyncProviders(options: SshModuleAsyncOptions): Provider[] {
if (options.useExisting || options.useFactory) {
return [this.createAsyncOptionsProvider(options)];
}
const useClass = options.useClass as Type<SshOptionsFactory>;
return [
this.createAsyncOptionsProvider(options),
{
provide: useClass,
useClass,
},
];
}
static createAsyncOptionsProvider(options: SshModuleAsyncOptions): Provider {
if (options.useFactory) {
return {
provide: SSH_OPTIONS,
useFactory: options.useFactory,
inject: options.inject || [],
};
}
const inject = options.useClass || (options.useExisting as Type<SshOptionsFactory>);
return {
provide: SSH_OPTIONS,
useFactory: async (optionsFactory: SshOptionsFactory) => await optionsFactory.createSshOptions(),
inject: [inject],
};
}
// forRootAsync -> useFactory -> inject configService
static forRootAsync(options: SshModuleAsyncOptions, name?: string): DynamicModule {
const asyncProviders = this.createAsyncProviders(options);
const sshClientProviders: Provider[] = [];
if (name) {
sshClientProviders.push({
provide: `${name}:SSH`,
useFactory: async (options: SshModuleOptions) => {
const ssh = new SshService(options);
await ssh.connect();
return ssh;
},
inject: [SSH_OPTIONS],
});
} else {
sshClientProviders.push(SshService)
}
return {
module: SshCoreModule,
providers: [...asyncProviders, ...sshClientProviders],
exports: [...sshClientProviders],
};
}
}
7 )ssh.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { SshModuleOptions } from './ssh.interface';
import { SSH_OPTIONS } from './ssh.constants';
import { Client } from 'ssh2';
import * as fs from 'fs';
@Injectable()
export class SshService {
// inject 'SSH_OPTIONS' -> Client connect(options) -> instance ssh
// exec
private client: Client = new Client();
private isConnected: boolean = false;
constructor(@Inject(SSH_OPTIONS) private options: SshModuleOptions) {}
connect(): Promise<any> {
if (this.isConnected) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const privateKey = this.options.privateKey
? fs.readFileSync(this.options.privateKey)
: undefined;
const newOptions = {
...this.options,
privateKey,
};
this.client
.on('ready', () => {
this.isConnected = true;
resolve(true);
})
.on('error', (err) => {
reject(err);
})
.connect(newOptions);
});
}
exec(cmd: string): Promise<any> {
return new Promise(async (resolve, reject) => {
try {
await this.connect();
this.client.exec(cmd, (err, stream) => {
if (err) {
reject(err);
return;
}
stream
.on('close', (code, signal) => {
// console.log(`STREAM: close ${code}, ${signal}`);
resolve({ code, signal });
})
.on('data', (data) => {
// console.log(`STDOUT: ${data}`);
resolve(data.toString());
})
.stderr.on('data', (data) => {
// console.log(`STDERR: ${data}`);
reject(data.toString());
});
});
} catch (connectError) {
reject(connectError);
}
});
}
}
- 注意上面的 reject, 大多数情况下,服务端即便报错,也不要执行 reject
- 因为 reject 之后,它会走到 nest 的全局过滤器, 从而调用 logger 来打印 error 日志
- 这就不是我们期望的,我们期望 ssh 即便报错了,也要把报错信息反馈到 前端
- 另外,
.on('data')产生的是 流数据, 流数据有的问题是会响应多条数据,所以,我们需要拼接一下 - 我们需要暴露出来一个 callback 方法来调用 on data 和 on error
- 下面来优化下 上面的 exec 方法
exec(cmd: string, onData?:any): Promise<any> { return new Promise(async (resolve, reject) => { try { await this.connect(); this.client.exec(cmd, (err, stream) => { if (err) { reject(err); return; } let output = ''; stream .on('close', (code, signal) => { resolve({ code, signal, output }); }) .on('data', (data) => { output += data.toString(); if (onData) onData(data.toString()); }) .stderr.on('data', (data) => { output += `ERROR: ${data.toString()}`; if (onData) onData(`ERROR: ${data.toString()}`) }); }); } catch (connectError) { reject(connectError); } }); }- 这里对应上面 数据库备份滚动删除脚本中的 catch 相关注释
- 有了上面的优化,可以不用 catch 了
8 )在某个模块上测试,配置 auth.module.ts
后续可能需要响应多台 主机的响应的扩展
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { SshModule } from '@/utils/ssh/ssh.module';
@Module({
imports: [
SshModule.forRoot(
{
username: 'root',
password: '123456',
host: '192.168.31.77',
port: 22,
},
'ubuntu' // 这里是自定义的 name
),
SshModule.forRoot(
{
username: 'root',
password: '123456',
host: '192.168.31.77',
port: 22,
},
'ubuntu1' // 这里是自定义的 name
),
SshModule.forRootAsync(
{
inject: [ConfigService],
useFactory: (ConfigService) => {
console.log('ConfigService: ', ConfigService.get('CRON_ON'));
return {
username: 'root', // 这里可以使用 上面的 ConfigService 来动态的获取
password: '123456', // 同上
host: '192.168.31.77', // 同上
port: 22, // 同上
}
},
'ubuntu3'
})
],
controllers: [AuthController],
providers: [AuthService, SshService],
})
export class AuthModule {}
- 上面用了不同 name 的 service
9 ) 在某个模块的控制器上使用, auth.controller.ts
import { Logger } from '@nestjs/common';
import { SshService } from '../../utils/ssh/ssh.service';
@Controller('auth')
export class AuthController {
logger = new Logger();
constructor(
@Inject('ubuntu:SSH') private sshService: SshService,
@Inject('ubuntu1:SSH') private sshService1: SshService,
@Inject('ubuntu3:SSH') private sshService2: SshService,
) {}
@Post('/test')
getTest(@Body('cmd') cmd: string) {
return this.sshService.exec(cmd);
}
@Post('/test1')
getTest1(@Body('cmd') cmd: string) {
return this.sshService1.exec(cmd);
}
@Post('/testasync')
getTest1(@Body('cmd') cmd: string) {
return this.sshService2.exec(cmd);
}
}
- 这里就注入了不同的 service 的实例
- 这样实例就更灵活和通用,可以连接不同的主机
4745



被折叠的 条评论
为什么被折叠?



