本节目标:理解 NestJS 的模块(Module)、提供者(Provider)与依赖注入(DI)三者如何协作;能熟练使用
@Injectable、构造函数注入、自定义提供者(useClass/useValue/useFactory/useExisting)与注入令牌;能划清模块边界、处理循环依赖,并说清楚为什么 DI 让单元测试变得轻松。读完本节,你能为真实后端项目设计出层次清晰、可测试、可替换的模块结构。
6.1 模块、提供者与依赖注入
在第 5 章我们用 Fastify 手写 HTTP 服务与中间件时,所有依赖都是自己 new 出来的。项目一大,手动装配就会失控:谁依赖谁、谁先初始化、测试时怎么把数据库换成假实现,全变成人肉维护。NestJS 给出的答案是依赖注入容器——你只声明「我需要什么」,由框架负责「给你什么」。本节把模块、提供者、DI 三个概念拆开讲透。如果你对装饰器语法还不熟,可以先看 装饰器与元编程
,NestJS 的整套语法都建立在它之上。
6.1.1 从手动装配说起
先看不使用 DI 的写法,它的问题一眼可见:
class UserRepository {
async findById(id: string) {
return { id, name: "小明" };
}
}
class UserService {
private repo = new UserRepository(); // 硬编码依赖
async getProfile(id: string) {
return this.repo.findById(id);
}
}
这里的 UserService 被永久绑死在 UserRepository 上:单元测试时你无法注入一个「返回固定数据的假仓库」,除非去改源码。依赖注入要解决的就是这个耦合。它其实只有两个动作:
- 声明依赖:
UserService在构造函数里说「我需要一个UserRepository」。 - 解析依赖:由一个容器在运行时负责创建并把实例塞进去。
NestJS 的容器叫 IoC 容器(Inversion of Control)。这套思想并非 NestJS 独创,Java 的 Spring IoC 容器 是同一模式的经典实现,对照阅读能更快建立直觉。
6.1.2 第一个模块与提供者
NestJS 里一切都是围绕**模块(Module)**组织的,模块是最小的装配单元。一个最小的模块长这样:
import { Module } from "@nestjs/common";
import { UserService } from "./user.service";
import { UserController } from "./user.controller";
@Module({
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
@Module 装饰器的元数据有四个关键字段,它们的含义必须记牢:
| 字段 | 作用 | 常见坑 |
|---|---|---|
imports | 导入其他模块,拿到它们 exports 出来的提供者 | 只 imports 不等于能用自己的 provider |
providers | 本模块内可被注入的类(服务、工厂、策略等) | 未注册的类无法注入 |
controllers | 本模块暴露的路由控制器 | 控制器也走 DI,可注入本模块 provider |
exports | 允许被其他模块使用的提供者子集 | 不写 exports,别的模块就注入不到 |
其中 provider 是 DI 的核心。任何被 providers 注册的类,都成为容器可解析的「可注入项」。
6.1.3 @Injectable 与构造函数注入
让一个类可被注入,需要 @Injectable() 装饰器,并在构造函数里声明依赖:
import { Injectable } from "@nestjs/common";
@Injectable()
export class UserService {
constructor(private readonly repo: UserRepository) {}
async getProfile(id: string) {
return this.repo.findById(id);
}
}
private readonly repo: UserRepository 用的是 TypeScript 的参数属性写法,一行同时完成「声明字段 + 收参数 + 赋值」。NestJS 靠 reflect-metadata 读取构造函数参数的类型,从而知道该注入哪个类的实例。因此有三个前提缺一不可:
tsconfig.json里开启emitDecoratorMetadata: true与experimentalDecorators: true;- 入口文件(通常是
main.ts)顶部import "reflect-metadata"; - 被注入的类本身也在某个模块的
providers里注册过。
漏掉任何一条,运行时会抛出经典错误:
Nest can't resolve dependencies of the UserService (?).
Please make sure that the argument UserRepository at index [0] is available in the UserModule context.
看到这句,先检查三件事:UserRepository 有没有进 providers?它是从别的模块来的、那个模块有没有 exports?当前模块有没有 imports 那个模块?这三步排查能解决九成的注入失败。
6.1.4 模块的边界:imports 与 exports
模块不是可有可无的分组,而是可见性边界。一个 provider 默认只在本模块内可见,想跨模块使用,必须在来源模块 exports、在消费模块 imports:
// database.module.ts
@Module({
providers: [DatabaseService],
exports: [DatabaseService], // 对外暴露
})
export class DatabaseModule {}
// user.module.ts
@Module({
imports: [DatabaseModule], // 才能注入 DatabaseService
providers: [UserService],
controllers: [UserController],
})
export class UserModule {}
一个常被忽略的点:模块之间不会自动传递 exports。若 A 导入了 B,B 导入了 C,A 并不能直接注入 C 的 provider——除非 B 把 C 重新 exports 出去(这叫「再导出」)。合理的做法是让核心模块(如 DatabaseModule、ConfigModule)设为全局,避免每个模块都写一遍 imports:
import { Global, Module } from "@nestjs/common";
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
@Global() 只建议用在真正的基础设施上。滥用全局模块会让依赖关系变得隐形,反而损害可维护性。
6.1.5 自定义提供者
大多数时候,providers: [UserService] 这种「类提供者」就够了——容器会 new UserService() 并注入其构造参数。但真实工程常需要更灵活的方式,NestJS 提供四种:
useClass:替换实现
@Module({
providers: [
{ provide: UserRepository, useClass: PostgresUserRepository },
],
})
export class UserModule {}
当 UserRepository 是抽象类或接口标记时,可以让容器在需要它时实例化具体实现。测试环境换成 InMemoryUserRepository 只需改这一处。
useValue:注入固定值
const provider = { provide: "APP_VERSION", useValue: "1.2.0" };
适合注入配置常量、外部客户端实例、mock 对象。
useFactory:按需构造,可依赖其他 provider
const provider = {
provide: "REDIS_CLIENT",
useFactory: (config: ConfigService) => {
return createClient({ url: config.get("REDIS_URL") });
},
inject: [ConfigService],
};
工厂提供者通过 inject 显式声明参数依赖;useClass 的类也可通过构造函数声明依赖,inject 数组里的 provider 会先被解析再作为参数传入。异步初始化也在这里做(返回 Promise 即可)。
useExisting:别名
const provider = { provide: "AliasService", useExisting: UserService };
它不会创建新实例,而是让两个令牌指向同一个对象,常用于「新老接口名并存」的过渡期。
6.1.6 注入令牌与 @Inject
类作为令牌是常态,但当提供者不是类时(字符串、Symbol、接口),构造函数参数的类型信息不足以让容器找到它,必须显式用 @Inject 指定令牌:
import { Inject, Injectable } from "@nestjs/common";
@Injectable()
export class CacheService {
constructor(
@Inject("REDIS_CLIENT") private readonly redis: RedisClient,
) {}
}
这里 RedisClient 只是给 TypeScript 看的类型,真正起作用的是字符串令牌 "REDIS_CLIENT"。强烈建议把令牌集中定义成常量或 Symbol,避免散落各处的字符串字面量拼错:
export const REDIS_CLIENT = Symbol("REDIS_CLIENT");
用 Symbol 做令牌还能天然避免命名冲突,是生产项目的推荐做法。
6.1.7 循环依赖与 forwardRef
两个模块或两个服务互相依赖时,容器会陷入「先有鸡还是先有蛋」的死循环,报错如下:
Error: Nest cannot create the UserModule instance.
The module at index [0] of the UserModule "imports" array is undefined.
Potential causes:
- A circular dependency between modules. Use forwardRef() to avoid it.
解决办法是用 forwardRef 把「立即求值」推迟到「运行时求值」:
// 服务层
@Injectable()
export class UserService {
constructor(
@Inject(forwardRef(() => AuthService))
private readonly auth: AuthService,
) {}
}
// 模块层
@Module({
imports: [forwardRef(() => AuthModule)],
})
export class UserModule {}
但请注意:forwardRef 是止痛药,不是解药。服务之间互相依赖通常意味着职责划分有问题——把共享逻辑抽到第三个服务里,让依赖变成单向的,才是更健康的设计。把它当作代码坏味道的信号。
6.1.8 作用域与常见陷阱
NestJS 的 provider 默认是单例(singleton):整个应用生命周期内只创建一次,所有请求共享。此外还有两种作用域:
| 作用域 | 行为 | 代价 |
|---|---|---|
DEFAULT(单例) | 全局唯一实例 | 无 |
REQUEST | 每个请求一个实例 | 沿依赖链向上传播,性能开销大 |
TRANSIENT | 每次注入都新建 | 实例数不可控 |
用 @Injectable({ scope: Scope.REQUEST }) 声明。要特别注意作用域会「传染」:若 A(单例)依赖 B(请求级),A 会被强制升级为请求级,一路向上,最终可能让整条链都变成请求级,显著拖慢性能。
因此不要用请求作用域来存「当前用户」这类请求数据。正确做法是用 AsyncLocalStorage 承载请求上下文——第 5 章 中间件与请求上下文
已经讲过这套模式,可以无缝迁移到 NestJS。
6.1.9 测试中的依赖替换
DI 最大的回报是可测试性。因为依赖是从构造函数进来的,测试里可以直接传入替身(stub / mock),无需任何框架魔法:
import { describe, it, expect, vi } from "vitest";
import { UserService } from "./user.service";
describe("UserService", () => {
it("返回用户资料", async () => {
const repo = { findById: vi.fn().mockResolvedValue({ id: "1", name: "小明" }) };
const service = new UserService(repo as any);
await expect(service.getProfile("1")).resolves.toEqual({
id: "1",
name: "小明",
});
expect(repo.findById).toHaveBeenCalledWith("1");
});
});
如果需要更贴近容器真实行为的测试,可以用 @nestjs/testing 的 Test.createTestingModule,只覆写关心的 provider:
const moduleRef = await Test.createTestingModule({
providers: [
UserService,
{ provide: UserRepository, useValue: fakeRepo },
],
}).compile();
const service = moduleRef.get(UserService);
这正是第 4 章 Vitest 单元测试 里强调的「依赖注入换实现」在 NestJS 场景的落地。把每个 service 的依赖都显式声明出来,等价于给测试预留了所有接缝。
小结
- 模块是装配与可见性的最小单元:
imports拿别人的exports,providers注册可注入项,exports决定对外可见性,缺一不可。 @Injectable+ 构造函数参数属性是最常用的注入方式,它依赖emitDecoratorMetadata与reflect-metadata,两者缺失会在运行时抛「Nest can’t resolve dependencies」。- 四种自定义提供者各有其位:
useClass换实现、useValue注固定值、useFactory按需构造且可声明依赖、useExisting做别名。 - 非类令牌必须用
@Inject,建议用Symbol定义令牌,杜绝字符串拼写错误。 forwardRef只是权宜之计,循环依赖往往是职责划分不当的信号,优先重构而非掩盖。- 作用域会沿依赖链传染,请求级 provider 会拖慢整条链;请求数据请用
AsyncLocalStorage,而不是请求作用域。 - DI 的最终回报是可测试性:依赖从构造函数进来,测试里直接注入替身即可,这是「面向接口编程」在工程里的具体收益。
- 服务之间「谁能进、谁先跑」已经清楚了,接下来要解决的是「请求进来后如何被校验、放行与包装」。下一节 管道、守卫与拦截器 就来补齐这条请求处理流水线。
阅读导航:上一节:5.3 优雅关闭与健康检查 · 下一节:6.2 管道、守卫与拦截器 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。