Skip to content

注入作用域 (Injection Scopes)

揭秘服务的“生命周期”:NestJS 注入作用域深度解析

到目前为止,我们创建的所有服务都有一个共同的、看不见的特性:它们都是单例 (Singleton) 的。

这是什么意思呢?

把它想象成你的公司只有一个 CEO。无论哪个部门(比如销售部或技术部)需要向 CEO 汇报工作,他们面对的都是同一个人。CEO 在公司成立时(应用启动时)“创建”,直到公司倒闭(应用关闭)才“销毁”。

在 NestJS 中,默认情况下,每个服务类都像这位 CEO。当应用启动时,NestJS 会为每个服务类创建一个单一实例。之后,在应用的整个生命周期中,任何地方需要注入这个服务,NestJS 都会提供这同一个、共享的实例。

这在 99% 的情况下都是你想要的,因为它性能最好。

但有时,我们需要不同类型的“员工”:

  • 项目顾问 (Request Scope):为每个新项目(请求)专门聘请一位顾问。顾问只负责这一个项目,项目结束后就离开。不同的项目由不同的顾问负责。
  • 临时工 (Transient Scope):每次你需要有人干活时,都叫一个新的临时工来。即使在同一个项目中,你让 A 部门和 B 部门分别去找临时工,他们找来的也是两个不同的人。

NestJS 的注入作用域就允许我们定义服务是“CEO”、“项目顾问”还是“临时工”。

1. Scope.DEFAULT (Singleton) - 默认的“CEO”模式

这是我们一直在使用的默认行为。服务实例在应用启动时创建一次,并在所有地方共享。

让我们用代码证明一下它的共享性:

第一步:创建一个带“身份ID”的服务

src/singleton-test/task.service.ts

typescript
import { Injectable } from '@nestjs/common';

@Injectable() // 默认就是 Singleton
export class TaskService {
  private readonly taskId: string;

  constructor() {
    // 在构造函数中生成一个随机 ID,用来唯一标识这个实例
    this.taskId = Math.random().toString(36).substring(2);
    console.log(`[TaskService] 实例被创建,ID: ${this.taskId}`);
  }

  getTaskId() {
    return this.taskId;
  }
}

第二步:创建两个不同的服务,都依赖 TaskService

src/singleton-test/reporter.service.ts

typescript
import { Injectable } from '@nestjs/common';
import { TaskService } from './task.service';

@Injectable()
export class ReporterService {
  constructor(private readonly taskService: TaskService) {}

  report() {
    const id = this.taskService.getTaskId();
    console.log(`[ReporterService] 报告:我使用的 TaskService ID 是 ${id}`);
    return id;
  }
}

src/singleton-test/worker.service.ts

typescript
import { Injectable } from '@nestjs/common';
import { TaskService } from './task.service';

@Injectable()
export class WorkerService {
  constructor(private readonly taskService: TaskService) {}

  work() {
    const id = this.taskService.getTaskId();
    console.log(`[WorkerService] 工作:我使用的 TaskService ID 是 ${id}`);
    return id;
  }
}```

**第三步:在控制器中同时使用它们**

```typescript
// 在某个 Controller 中
@Get('test-singleton')
testSingleton() {
  // 注意:我们只是注入了 ReporterService 和 WorkerService
  // 并没有直接注入 TaskService
  const reporterId = this.reporterService.report();
  const workerId = this.workerService.work();

  return {
    message: 'Are they using the same TaskService instance?',
    reporterUsedId: reporterId,
    workerUsedId: workerId,
    areEqual: reporterId === workerId,
  };
}

当你启动应用并访问 /test-singleton 时,你会看到:

控制台输出:

bash
# 应用启动时只会创建一次
[TaskService] 实例被创建,ID: 7xqzj9p2o3
...
# 每次请求时
[ReporterService] 报告:我使用的 TaskService ID 是 7xqzj9p2o3
[WorkerService] 工作:我使用的 TaskService ID 是 7xqzj9p2o3

浏览器响应:

json
{
  "message": "Are they using the same TaskService instance?",
  "reporterUsedId": "7xqzj9p2o3",
  "workerUsedId": "7xqzj9p2o3",
  "areEqual": true
}

结论ReporterServiceWorkerService 注入了完全相同TaskService 实例。这就是单例作用域。

2. Scope.REQUEST - 为每个请求定制的“项目顾问”

当一个提供者被标记为 REQUEST 作用域时,NestJS 会为每一个新的 HTTP 请求创建一个新的实例。这个实例会与该请求的所有其他 REQUEST 作用域的提供者共享,并在请求处理完成后被垃圾回收。

什么时候用? 当你需要在一次请求的多个地方共享某些与该请求相关的状态时。最经典的例子是:

  • 在日志中追踪同一个请求的 request-ID
  • 在多个服务中访问当前登录的用户信息,而不想通过参数传来传去。
  • 管理每个请求的数据库事务。

代码示例:追踪请求 ID

src/request-scope/request.service.ts

typescript
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';

// 1. 设置作用域为 REQUEST
@Injectable({ scope: Scope.REQUEST })
export class RequestService {
  private readonly requestId: string;

  // 2. 我们可以直接注入原始的 REQUEST 对象
  constructor(@Inject(REQUEST) private readonly request: Request) {
    this.requestId = (this.request as any).id; // 假设有中间件为请求添加了 id
    console.log(`[RequestService] 实例被创建,处理请求 ID: ${this.requestId}`);
  }

  getRequestId() {
    return this.requestId;
  }
}

控制器:

typescript
// 在某个 Controller 中
@Get('test-request')
async testRequestScope() {
  // 为了对比,我们调用两次
  const id1 = this.requestService.getRequestId();
  await new Promise(resolve => setTimeout(resolve, 10)); // 模拟一些操作
  const id2 = this.requestService.getRequestId();

  console.log(`在同一次请求中,两次获取的 ID 是否相同: ${id1 === id2}`);
  return { requestId: id1 };
}

操作

  1. 确保你有一个中间件,为每个请求附加一个唯一 ID。
  2. 用 Postman 或浏览器连续两次快速访问 /test-request 接口。

控制台输出:

bash
# 第一次请求
[RequestService] 实例被创建,处理请求 ID: req-abc-123
在同一次请求中,两次获取的 ID 是否相同: true

# 第二次请求
[RequestService] 实例被创建,处理请求 ID: req-xyz-789
在同一次请求中,两次获取的 ID 是否相同: true

结论

  • 对于同一次请求,无论注入或调用多少次,你得到的都是同一个 RequestService 实例。
  • 对于不同的请求,会创建全新的 RequestService 实例。

⚠️ 性能警告REQUEST 作用域的性能远低于 SINGLETON,因为它涉及为每个请求创建对象。更重要的是,如果一个单例服务注入了一个 REQUEST 作用域的服务,那么这个单例服务实际上也会隐式地变成 REQUEST 作用域,这被称为作用域冒泡,可能会导致意料之外的性能下降。所以,请谨慎使用!

3. Scope.TRANSIENT - 随叫随到的“临时工”

TRANSIENT 作用域的提供者是完全不共享的。每一次注入,都会创建一个全新的实例。

什么时候用? 非常少见。通常用于需要完全隔离状态的场景。比如,一个 MailerService,你希望每次调用 sendEmail 都有一个纯净的、不带任何上一次调用状态的实例。

代码对比 REQUEST vs TRANSIENT

让我们修改之前的 TaskService,并让 ReporterServiceWorkerService 都注入它。

src/transient-test/task.service.ts

typescript
// 只需要修改这里的 scope
@Injectable({ scope: Scope.TRANSIENT }) // 或 Scope.REQUEST
export class TaskService {
  // ... 其他代码和之前一样
}

控制器:

typescript
@Get('test-scope-diff')
testScopeDifference() {
  // ReporterService 和 WorkerService 在构造时都注入了 TaskService
  const reporterId = this.reporterService.report();
  const workerId = this.workerService.work();

  return {
    areEqual: reporterId === workerId,
  };
}

结果

  • 如果 TaskServiceREQUEST 作用域,响应为 { "areEqual": true }。因为在同一次请求中,它们共享同一个实例。
  • 如果 TaskServiceTRANSIENT 作用域,响应为 { "areEqual": false }。因为 ReporterService 在构造时拿到了一个新TaskService 实例,WorkerService 在构造时拿到了另一个全新TaskService 实例。

总结与选择指南

作用域生命周期性能核心思想何时使用?
SINGLETON (默认)应用启动到关闭最高全局共享,一个实例服务所有人绝大多数情况。无状态服务,全局配置服务等。
REQUEST一次 HTTP 请求的开始到结束较低请求内共享,一个实例服务一次请求需要在一次请求的多个处理环节中共享状态(如用户ID,事务对象)。
TRANSIENT每一次注入时创建最低完全独享,每次注入都是新的极少使用。需要确保每次使用都是一个纯净、无状态的实例。

核心建议:始终坚持使用默认的 SINGLETON 作用域,除非你遇到了一个非用 REQUEST 作用域不可的问题。理解这些作用域的差异,可以帮助你在遇到特定场景时,做出正确的设计决策,并避免无意中引入性能问题。