官网文档地址:Documentation | NestJS - A progressive Node.js framework
队列是一种强大的设计模式,可以帮助您处理常见的应用程序扩展和性能挑战。队列可以帮助您解决的一些问题示例如下:
(1)平滑处理峰。例如,如果用户可以在任意时间启动资源密集型任务,则可以将这些任务添加到队列中,而不是同步执行它们。然后,您可以让工作进程以受控的方式从队列中提取任务。随着应用程序的扩展,您可以轻松地添加新的Queue消费者来扩展后端任务处理。
(2)分解单体任务,否则可能会阻塞Node.js事件循环。例如,如果用户请求需要诸如音频转码之类的CPU密集型工作,则可以将此任务委托给其他进程,从而释放面向用户的进程以保持响应。
(3)提供跨各种服务的可靠通信通道。例如,您可以在一个进程或服务中对任务(作业)进行排队,并在另一个进程或服务中使用它们。在作业生命周期中,任何进程或服务的完成、错误或其他状态更改都可以通知您(通过侦听状态事件)。当队列生产者或消费者失败时,它们的状态将被保留,并且任务处理可以在节点重新启动时自动重新启动。
Nest提供了@nestjs/bull包作为bull的抽象/包装器,bull是一个流行的、支持良好的、高性能的基于Node.js的队列系统实现。这个包可以很容易地以一种巢友好的方式将公牛队列集成到您的应用程序中。
Bull使用Redis来持久化作业数据,所以你需要在你的系统上安装Redis。因为它是redis支持的,所以你的Queue架构可以是完全分布式和平台无关的。例如,你可以让一些Queue生产者、消费者和侦听器在一个(或几个)节点上运行在Nest中,而其他的生产者、消费者和侦听器在其他网络节点上的其他Node.js平台上运行。
本章介绍了@nestjs/bull包。我们还建议阅读Bull文档,了解更多背景和具体实现细节。
(1)安装依赖
$ npm install --save @nestjs/bull bull
(2)把BullModule导入根模块AppModule。
app.module.ts
import { Module } from '@nestjs/common'; import { BullModule } from '@nestjs/bull'; @Module({ imports: [ BullModule.forRoot({ redis: { host: 'localhost', port: 6379, }, }), ], }) export class AppModule {}
(3)注册队列,导入BullModule.registerQueue()动态模块
BullModule.registerQueue({ name: 'audio', });
由于作业在Redis中是持久化的,每次实例化一个特定的命名队列(例如,当一个应用程序启动/重新启动时),它都会尝试处理之前未完成会话中可能存在的任何旧作业。
每个队列可以有一个或多个生产者、消费者和侦听器。消费者以特定的顺序从队列中检索作业:先进先出(默认)、后进先出或根据优先级。
作业生成器将作业添加到队列中。生产者通常是应用服务(Nest提供商)。要将作业添加到队列中,首先将队列注入到服务中,如下所示:
import { Injectable } from '@nestjs/common'; import { Queue } from 'bull'; import { InjectQueue } from '@nestjs/bull'; @Injectable() export class AudioService { constructor(@InjectQueue('audio') private audioQueue: Queue) {} }
现在,通过调用队列的add()方法添加一个作业,并传递一个用户定义的作业对象。作业被表示为可序列化的JavaScript对象(因为这是它们存储在Redis数据库中的方式)。你通过的工作形式是任意的;用它来表示作业对象的语义。
- const job = await this.audioQueue.add({
- foo: 'bar',
- });
job的一些选项
作业可以有与之关联的其他选项。在Queue.add()方法的job参数之后传递一个options对象。作业选项属性有:
priority: number -可选的优先级值。取值范围从1(最高优先级)到MAX_INT(最低优先级)。注意,使用优先级会对性能产生轻微影响,因此要谨慎使用。
delay: number -等待该作业被处理的时间(毫秒)。请注意,为了获得准确的延迟,服务器和客户机都应该同步它们的时钟。
attempts: number -在任务完成之前尝试的总次数。
repeat: RepeatOpts—根据cron规范重复作业。看到RepeatOpts。
backoff: number | BackoffOpts—在作业失败时自动重试的回退设置。看到BackoffOpts。
lifo: boolean -如果为真,则将作业添加到队列的右端而不是左端(默认为false)。
timeout: number—作业超时失败的毫秒数。
jobId: number | string—覆盖作业ID—默认情况下,作业ID是一个唯一的整数,但您可以使用此设置来覆盖它。如果使用此选项,则由您决定是否确保jobId是惟一的。如果尝试添加具有已存在id的作业,则不会添加该作业。
removeOnComplete: boolean | number -如果为true,则在作业成功完成时删除作业。数字指定要保留的作业数量。默认行为是将作业保留在已完成的集合中。
removeOnFail: boolean | number -如果为true,则在所有尝试失败后删除作业。数字指定要保留的作业数量。默认行为是将作业保留在失败集中。
stackTraceLimit: number -限制将记录在堆栈跟踪中的堆栈跟踪行数。
下面是一些使用工作选项定制工作的例子。
(1)要延迟作业的启动,请使用delay配置属性。
const job = await this.audioQueue.add( { foo: 'bar', }, { delay: 3000 }, // 3 seconds delayed );(2)要将作业添加到队列的右端(以后进先出的方式处理作业),请将配置对象的后进先出属性设置为true。
const job = await this.audioQueue.add( { foo: 'bar', }, { lifo: true }, );(3)要对作业进行优先级排序,请使用priority属性。
const job = await this.audioQueue.add( { foo: 'bar', }, { priority: 2 }, );
消费者是定义方法的类,这些方法可以处理添加到队列中的作业,或者监听队列上的事件,或者两者兼而有之。使用@Processor()装饰器声明一个消费者类,如下所示:
import { Processor } from '@nestjs/bull'; @Processor('audio') export class AudioConsumer {}提示
消费者必须注册为 providers,这样@nestjs/bull包才能接收到它们。
在消费者类中,通过使用@Process()装饰器装饰处理程序方法来声明作业处理程序。
-
- import { Processor, Process } from '@nestjs/bull';
- import { Job } from 'bull';
-
- @Processor('audio')
- export class AudioConsumer {
- @Process()
- async transcode(job: Job
) { - let progress = 0;
- for (let i = 0; i < 100; i++) {
- await doSomething(job.data);
- progress += 1;
- await job.progress(progress);
- }
- return {};
- }
- }
装饰方法(例如,transcode())在worker空闲并且队列中有作业要处理时被调用。此处理程序方法接收作业对象作为其唯一参数。处理程序方法返回的值存储在作业对象中,以后可以访问,例如在已完成事件的侦听器中。
可以指定作业处理程序方法只处理特定类型的作业(具有特定名称的作业),方法是将该名称传递给@Process()装饰器,如下所示。在给定的消费者类中可以有多个@Process()处理程序,对应于每个作业类型(名称)。当您使用命名作业时,请确保每个名称都有对应的处理程序。
- @Process('transcode')
- async transcode(job: Job
) { ... }
警告
当为同一个队列定义多个消费者时,@Process({concurrency: 1})中的并发选项不会生效。最小并发性将与定义的消费者数量匹配。即使@Process()处理程序使用不同的名称来处理命名作业,这也适用。
请求范围内消费者
当消费者被标记为请求作用域时(在这里了解更多关于注入作用域的信息),将为每个作业专门创建一个新的类实例。该实例将在作业完成后进行垃圾收集。
@Processor({ name: 'audio', scope: Scope.REQUEST, })由于请求作用域的消费者类是动态实例化的,并且作用域为单个作业,因此可以使用标准方法通过构造函数注入JOB_REF。
constructor(@Inject(JOB_REF) jobRef: Job) { console.log(jobRef); }提示
JOB_REF令牌是从@nestjs/bull包导入的。1
当队列和/或作业状态发生变化时,Bull生成一组有用的事件。Nest提供了一组修饰符,允许订阅一组核心标准事件。这些是从@nestjs/bull包导出的。
事件监听器必须在消费者类中声明(即,在用@Processor()装饰器装饰的类中)。要监听事件,请使用下表中的装饰器之一来声明该事件的处理程序。例如,要在音频队列中监听作业进入活动状态时发出的事件,请使用以下构造:
- import { Processor, Process, OnQueueActive } from '@nestjs/bull';
- import { Job } from 'bull';
-
- @Processor('audio')
- export class AudioConsumer {
-
- @OnQueueActive()
- onActive(job: Job) {
- console.log(
- `Processing job ${job.id} of type ${job.name} with data ${job.data}...`,
- );
- }
- ...
由于Bull在分布式(多节点)环境中运行,因此它定义了事件局部性的概念。这个概念认识到事件可以完全在单个进程中触发,也可以在来自不同进程的共享队列上触发。本地事件是在本地流程中的队列上触发操作或状态更改时产生的事件。换句话说,当您的事件生产者和消费者是单个流程的本地事件时,在队列上发生的所有事件都是本地事件。
当一个队列被多个进程共享时,我们可能会遇到全局事件。对于一个进程中的侦听器,要接收由另一个进程触发的事件通知,它必须注册全局事件。
每当发出相应的事件时,都会调用事件处理程序。使用下表所示的签名调用处理程序,提供对与事件相关的信息的访问。我们将在下面讨论本地和全局事件处理程序签名之间的一个关键区别。
在监听全局事件时,方法签名可能与本地签名略有不同。具体地说,任何方法签名在本地版本中接收作业对象,而不是在全局版本中接收jobId(编号)。在这种情况下,要获取对实际作业对象的引用,请使用queue# getJob方法。应该等待这个调用,因此应该将处理程序声明为async。例如:
- @OnGlobalQueueCompleted()
- async onGlobalCompleted(jobId: number, result: any) {
- const job = await this.immediateQueue.getJob(jobId);
- console.log('(Global) on completed: job ', job.id, ' -> result: ', result);
- }
提示
要访问Queue对象(进行getJob()调用),当然必须注入它。此外,Queue必须在注入它的模块中注册。
除了特定的事件监听器装饰器,你还可以将通用的@OnQueueEvent()装饰器与BullQueueEvents或BullQueueGlobalEvents枚举组合使用。点击这里了解更多。
队列有一个API,允许您执行管理功能,如暂停和恢复、检索处于不同状态的作业计数等。您可以在这里找到完整的队列API。直接在Queue对象上调用这些方法中的任何一个,如下面的暂停/恢复示例所示。
使用Pause()方法调用暂停队列。暂停队列在恢复之前不会处理新作业,但正在处理的当前作业将继续处理,直到完成它们。
await audioQueue.pause();
要恢复暂停的队列,使用resume()方法,如下所示:
await audioQueue.resume();
作业处理程序也可以在单独的(分叉的)进程(源)中运行。这有几个好处:
- //app.module.ts
- import { Module } from '@nestjs/common';
- import { BullModule } from '@nestjs/bull';
- import { join } from 'path';
-
- @Module({
- imports: [
- BullModule.registerQueue({
- name: 'audio',
- processors: [join(__dirname, 'processor.js')],
- }),
- ],
- })
- export class AppModule {}
请注意,因为你的函数是在一个分叉的进程中执行的,所以依赖注入(和IoC容器)是不可用的。这意味着处理器函数需要包含(或创建)它所需的所有外部依赖项实例。
- //processor.ts
- import { Job, DoneCallback } from 'bull';
-
- export default function (job: Job, cb: DoneCallback) {
- console.log(`[${process.pid}] ${JSON.stringify(job.data)}`);
- cb(null, 'It works');
- }
您可能希望异步传递公牛选项,而不是静态传递。在这种情况下,使用forRootAsync()方法,该方法提供了几种处理异步配置的方法。同样,如果您希望异步传递队列选项,请使用registerQueueAsync()方法。
一种方法是使用工厂函数:
- BullModule.forRootAsync({
- useFactory: () => ({
- redis: {
- host: 'localhost',
- port: 6379,
- },
- }),
- });
我们的工厂的行为和其他异步提供程序一样(例如,它可以是异步的,并且可以通过inject注入依赖项)。
- BullModule.forRootAsync({
- imports: [ConfigModule],
- useFactory: async (configService: ConfigService) => ({
- redis: {
- host: configService.get('QUEUE_HOST'),
- port: configService.get('QUEUE_PORT'),
- },
- }),
- inject: [ConfigService],
- });
或者,你可以使用useClass语法:
- BullModule.forRootAsync({
- useClass: BullConfigService,
- });
上面的构造将在BullModule中实例化BullConfigService,并通过调用createSharedConfiguration()来使用它来提供一个选项对象。注意,这意味着BullConfigService必须实现SharedBullConfigurationFactory接口,如下所示:
- @Injectable()
- class BullConfigService implements SharedBullConfigurationFactory {
- createSharedConfiguration(): BullModuleOptions {
- return {
- redis: {
- host: 'localhost',
- port: 6379,
- },
- };
- }
- }
为了防止在BullModule中创建BullConfigService并使用从其他模块导入的提供商,你可以使用useExisting语法。
- BullModule.forRootAsync({
- imports: [ConfigModule],
- useExisting: ConfigService,
- });
这个构造与useClass的工作原理相同,但有一个关键的区别——BullModule将查找导入的模块来重用现有的ConfigService,而不是实例化一个新的。