NestJS 依赖注入:从"你要什么"到"容器给你"

0 阅读6分钟

系列第 02 篇 · 拆解 constructor(private readonly appService: AppService) {} 这一行背后发生了什么

零、这篇文章讲什么

01 NestJS 是什么 里,我说 NestFactory.create() 是最外层的第一层工厂,它内部还藏着一层更重要的工厂——依赖注入容器(IoC Container)。这一篇就钻进去,把它讲透。

这一篇不是概念科普,而是对着代码逐行拆。看完你会明白:

  1. 你写的那句 constructor(private readonly appService: AppService) 到底在干什么
  2. 谁负责 new AppService(),为什么不让你自己 new
  3. Provider(提供者)是什么,为什么它不只是 Service

一、问题:为什么不让你手动 new

先看直觉做法。如果没有依赖注入,你的 controller 大概会这样写:

export class AppController {
  appService: AppService;

  constructor() {
    this.appService = new AppService();   // 手动 new,紧紧耦合
  }

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

看起来没什么问题?三个隐患:

  • 硬耦合:controller 必须知道 AppService 怎么构造。哪天 AppService 构造器加了个数据库参数,这里全要改。
  • 难以测试:想给 controller 喂假数据测逻辑,得先能替换 appService 的实现,手动 new 的方式替换不了。
  • 实例管理混乱:如果三个 controller 都用 AppService,谁来保证它是同一个实例?各自 new 一遍,状态就分裂了。

NestJS 的做法:你别 new,你只声明"我要什么"

二、依赖注入的三件套

依赖注入不是一句口号,它在 NestJS 里由三处代码配合完成。你的 hello 项目里三件套正好齐全。

① 供给端:@Injectable() 标记"我交给容器管"

app.service.ts

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

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

@Injectable() 告诉 Nest:这个类可以被注入,请把我登记进容器。 它是 provider 的"登记证"。

② 消费端:构造器里声明"我要这个"

app.controller.ts

export class AppController {
  constructor(private readonly appService: AppService) {}
}

这里只做了一件事:声明依赖——"我这个类需要一份 AppService"。至于这份 AppService 怎么来的,我不管。

③ 容器端:启动时自动装配

main.ts

const app = await NestFactory.create(AppModule);

Nest 启动时扫描到 AppController 的构造器参数是 AppService,就从容器里取一个实例注入进去。整个过程你一行 new 都没写过。

三件套缺一不可:没有 @Injectable(),容器不知道它可以被管;没有构造器声明,容器不知道该给谁;没有容器装配,前两件就是空话。

三、那行语法糖,拆开看

constructor(private readonly appService: AppService) {}

这一行其实是 TypeScript 的参数属性语法糖,等价于手写四行:

export class AppController {
  appService: AppService;                  // ① 声明实例属性

  constructor(appService: AppService) {    // ② 构造器接收参数
    this.appService = appService;          // ③ 赋值给属性
  }
}

逐字解释:

  • private —— 让 appService 成为实例的私有属性,外部访问不到
  • readonly —— 只读,注入后不允许中途被换掉
  • appService: AppService —— 参数名 + 类型,Nest 靠类型知道该注入谁

所以你的 getHello() 里才能直接用 this.appService.getHello()。这一行就是把"声明属性 + 构造器收参 + 赋值"三步压缩成一步的语法糖。

注意:NestJS 用的是构造器注入,但依赖注入不止这一种注入方式。还有属性注入、Setter 注入等,NestJS 中构造器注入是主流和推荐。

四、没有 DI vs 有 DI

把两种写法并排看,DI 的价值就一目了然:

// ❌ 手动 new:调用方负责创建,紧耦合
export class AppController {
  constructor() {
    this.appService = new AppService();
  }
}

// ✅ 依赖注入:调用方只声明,容器负责创建
export class AppController {
  constructor(private readonly appService: AppService) {}
}
手动 new依赖注入
谁创建对象调用方容器
换实现所有调用处都要改只改容器配置
单元测试换 mock做不到注入 mock 即可
实例唯一性各 new 各的容器统一管理(默认单例)

对照 factory_demo/1.mjs 里你写的 MixueFactory.create("ice") —— 那是最朴素的工厂:手动传 type,工厂返回实例。DI 容器是更高级的工厂:它不用你传 type,而是靠反射读取类的元数据(@Injectable)自动决定怎么造、造几份。你之前学的工厂模式,在这里长成了它的成年形态。

五、Provider:提供者是什么

前面反复出现"provider",这一节给它正名。

中文译名

Provider 目前没有官方中文译名,最常见的翻译是 "提供者"(也有人译作"供应者""服务提供者")。但比译名更重要的是理解它指什么——它是一个分类概念,不是某一种具体的类。

本质:三个条件

一个对象要成为 NestJS 的 provider:

@Injectable()                        // ① 标记:我可以被容器管理
export class AppService { ... }      // ② 是一个类
// ③ 被登记进 @Module 的 providers 数组
@Module({
  providers: [AppService],           // ③ 登记
})

一句话定义:

Provider = 被 @Injectable() 标记、由 Nest 容器创建并管理、可以被注入到其他类里使用的对象。

为什么叫"提供者"

命名很讲究,它站在"被使用方"的角度:

  • 你的 controller 说:"我需要一个 AppService"
  • 容器说:"好,我这里有,我提供给你"
  • 于是 AppService 就是那个"被提供的对象" → Provider

最大的误区:Provider 不只是 Service

Service 只是 provider 最常见的一种。 Provider 是一个更宽泛的集合:

Provider 形态作用例子
Service业务逻辑你的 AppService
Repository数据访问UserRepository
Factory动态生产值读取环境变量的 configFactory
Helper / 工具类通用能力加密、日期格式化

它们全都叫 provider,因为形态上是一回事:@Injectable() 标记 + 登记进模块 + 可被注入。区别只在"提供的能力是什么"。

六、DI 带来的三样东西

1. 解耦:改实现,不改调用方

controller 只认接口(类型),不认具体怎么来的。将来把 AppService 从返回字符串改成查数据库,controller 一行不用动,只改容器配置。

2. 可测试:注入 mock 就行

测试时把真实 AppService 换成假数据版本:

const controller = new AppController(mockService);

不用起服务器,不用连数据库。这也是为什么 NestJS 项目里天然带着 *.spec.ts 测试文件——DI 让单元测试变得极其自然。

3. 生命周期管理:默认单例

@Injectable() 的 provider 默认是单例——整个应用里只有一个实例,所有消费它的地方共用这一个。这是容器在替你管的"造几份"的问题。

生命周期有几种取值,会放在系列后面(模块作用域篇)详细讲。这里只需记住:默认单例,一个 provider 全应用一份。

用人才库比喻收尾这一节:

@Injectable()  AppService  →  登记进人才库(providers: [AppService])
                                    │
                                    │ 人才库(容器)持有它的实例
                                    │
AppController 构造器说:             │
  constructor(appService)  ←────────┘  人才库把这号人才"派发"给 controller
  • 人才库 = 容器(IoC Container)
  • 登记的人才 = Provider(提供者)
  • 派发人才 = 注入(Injection)
  • 被派发到的人 = 消费方(Consumer)

七、小结

依赖注入这一篇的完整逻辑链:

为什么不让你手动 new?
  └─ 因为:硬耦合、难测试、实例乱
怎么解决?三件套:
  ├─ @Injectable()            供给端:我交给容器管
  ├─ constructor(... 声明)    消费端:我要这个
  └─ NestFactory.create()     容器端:我帮你注入
这一行背后是语法糖:
  └─ private readonly 参数属性 = 声明属性 + 构造器收参 + 赋值
被注入的东西叫什么?
  └─ Provider(提供者):可注入、可复用、默认单例,Service 只是其中一种

下一篇预告:DI 讲透了,就可以进入路由与参数了——@Get(':id')@Body()@Param()@Query() 这些参数装饰器怎么把前端请求的每一块数据送到你的方法里。这是让 hello 项目从"返回固定字符串"走向"处理真实请求"的第一步。