Skip to content
从概述到组件:模块、控制器与提供者的分工
任何 NestJS 应用最终都由若干模块拼装起来,而每个模块内部又靠控制器和提供者来完成请求处理和业务逻辑。
三者的分工:
- 模块负责划定边界,决定哪些功能属于同一个聚合单元。
- 控制器负责绑定路由,把 HTTP 请求映射到具体的方法上。
- 提供者负责承载业务逻辑,通过依赖注入被控制器或其他提供者使用。
模块:用 @Module() 定义应用边界
模块在 NestJS 里是一个带 @Module() 装饰器的类。类本身不需要写任何逻辑,它的作用只是提供元数据——告诉框架这个模块包含哪些控制器、哪些提供者,以及它依赖了哪些外部模块。
一个最简单的模块:
typescript
import { Module } from '@nestjs/common';
@Module({})
export class AppModule {}空的 @Module() 没有实际用途。需要往里填配置项,才算定义了一个有意义的边界。
@Module() 的常用配置项
@Module() 接受一个对象,其中四个字段最常用:
| 配置项 | 作用 |
|---|---|
controllers | 注册该模块拥有的控制器 |
providers | 注册该模块拥有的提供者 |
imports | 导入其他模块,从而使用它们导出的提供者 |
exports | 把本模块的提供者暴露出去,供其他模块注入 |
一个稍完整的模块定义:
typescript
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class CatsModule {}这里 CatsController 和 CatsService 都被注册到了当前模块。此时 CatsService 只在 CatsModule 内部可见——外部模块无法直接注入它,除非显式加到 exports 数组里。
模块本质上是一层封装边界:框架通过模块树来解析依赖关系,确定哪些提供者的实例可以被创建和注入。一个应用至少有一个根模块,通常就是 AppModule,其他功能模块通过 imports 被根模块引入。
控制器:绑定路由与处理请求
控制器使用 @Controller() 装饰器声明,负责把 HTTP 请求路由到对应的处理方法。
@Controller() 可以接受一个字符串参数作为路由前缀。下面这个例子中,所有路由都会以 /cats 开头:
typescript
import { Controller, Get } from '@nestjs/common';
@Controller('cats')
export class CatsController {
@Get()
findAll(): string {
return 'This action returns all cats';
}
}@Get() 装饰器告诉 Nest 创建一个 GET /cats 的路由处理器。当请求命中这个路由时,findAll 方法被执行,返回值被框架自动转换为 HTTP 响应。
路由装饰器与响应返回
NestJS 提供了一组与 HTTP 方法对应的装饰器:@Get()、@Post()、@Put()、@Delete()、@Patch()、@Options()、@Head()。每个装饰器都可以接受一个路径字符串,拼接到控制器的前缀后面:
typescript
@Controller('cats')
export class CatsController {
@Get() // GET /cats
findAll() {}
@Get(':id') // GET /cats/:id
findOne() {}
@Post() // POST /cats
create() {}
}关于返回值:Nest 对处理器的返回值类型没有严苛限制。可以返回原始类型(字符串、数字)、对象(会被自动序列化为 JSON)、Promise 或 RxJS Observable。框架内部会处理异步解析和响应发送,不需要手动调用 res.json()。
这里有一个容易忽略的细节:如果返回一个对象,Nest 默认会设置 Content-Type: application/json 并做 JSON 序列化。如果返回字符串,Content-Type 会是 text/html。这个行为由 Express/Fastify 底层平台决定,Nest 不会额外干预。
提供者:使用 @Injectable() 声明服务
控制器负责路由,但业务逻辑应该放在提供者里。提供者是使用 @Injectable() 装饰器声明的类:
typescript
import { Injectable } from '@nestjs/common';
@Injectable()
export class CatsService {
private readonly cats: string[] = [];
findAll(): string[] {
return this.cats;
}
addCat(name: string): void {
this.cats.push(name);
}
}@Injectable() 的作用是告诉 Nest 这个类可以被注入到其他地方。光声明还不够——必须在某个模块的 providers 数组里注册,框架才会管理它的实例化:
typescript
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class CatsModule {}注册后,CatsService 就成了模块内部的一个可注入标记。框架默认采用单例模式:同一模块内对该提供者的所有注入请求会拿到同一个实例。
依赖注入:构造函数参数如何被解析
在控制器中使用 CatsService 时,不需要手动 new。只要在构造函数中声明参数类型,Nest 会自动创建实例并传入:
typescript
@Controller('cats')
export class CatsController {
constructor(private readonly catsService: CatsService) {}
@Get()
findAll(): string[] {
return this.catsService.findAll();
}
}Nest 的依赖注入基于 TypeScript 的类型元数据。编译时如果开启了 emitDecoratorMetadata,TypeScript 会把构造函数参数的类型信息保留到运行时。框架读取这些元数据之后,就知道 catsService 需要的是 CatsService 类的实例,然后去当前模块的上下文里找对应的提供者,创建并注入。
这里有一个关键约束:注入依赖时用的是类型标记,而非字符串名称。也就是说,constructor(private readonly catsService: CatsService) 中的 CatsService 既是类型标注,也是注入时的查找标记。如果定义的是一个接口而不是类,运行时是没有类型信息的,注入就会失败。这是 TypeScript 的固有限制,NestJS 提供了自定义 provider 来解决这个问题。
整个过程是自动的:不需要配置 XML、不需要注解扫描路径、不需要手动从容器 getBean。在模块里注册了提供者之后,所有声明了对应类型参数的构造函数都会拿到注入实例。
示例:组装一个完整的用户模块
下面定义一个用户模块,包含控制器、服务和一个简单的数据结构。
users.service.ts——提供者,承载业务逻辑:
typescript
import { Injectable } from '@nestjs/common';
export interface User {
id: number;
name: string;
}
@Injectable()
export class UsersService {
private users: User[] = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
findAll(): User[] {
return this.users;
}
findById(id: number): User | undefined {
return this.users.find((u) => u.id === id);
}
}users.controller.ts——控制器,绑定路由并调用服务:
typescript
import { Controller, Get, Param } from '@nestjs/common';
import { UsersService, User } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
findAll(): User[] {
return this.usersService.findAll();
}
@Get(':id')
findById(@Param('id') id: string): User | undefined {
return this.usersService.findById(Number(id));
}
}users.module.ts——模块,把控制器和提供者注册到一起:
typescript
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}在根模块中导入 UsersModule:
typescript
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
@Module({
imports: [UsersModule],
})
export class AppModule {}启动应用后,发送 GET /users 会返回:
json
[
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
]GET /users/1 返回:
json
{ "id": 1, "name": "Alice" }这个流程中框架做了什么:启动时解析 AppModule → 发现导入了 UsersModule → 解析 UsersModule 的 controllers 和 providers → 收集路由定义 → 创建 UsersService 实例 → 在创建 UsersController 实例时把 UsersService 注入进去。请求到达时,路由匹配到对应方法,方法调用服务,服务返回数据,框架处理 HTTP 响应。
模块间交互:导入与导出提供者
上面的例子中 UsersService 只在 UsersModule 内部使用。如果另一个模块(比如 OrdersModule)也需要访问用户数据,就需要 UsersModule 把 UsersService 导出去。
导出通过 exports 数组完成:
typescript
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}导入方在自己的 imports 中声明依赖:
typescript
@Module({
imports: [UsersModule],
controllers: [OrdersController],
providers: [OrdersService],
})
export class OrdersModule {}此时 OrdersModule 内部的 OrdersService 或 OrdersController 就可以在构造函数中注入 UsersService 了。Nest 的依赖解析会跨模块查找,但前提是提供者所在模块已经将其导出。
注意点:提供者可见性与模块封装
模块默认封装其内部的提供者。这一点与 Angular 的 NgModule 机制类似,但与 Java Spring 的全局 @Component 扫描有本质区别。在 Spring 中,只要类被 @Component(或派生注解)标记并且被扫描到,它就是全局可见的候选 Bean。而 NestJS 中,不导出就不对外可见,即使是 @Injectable() 修饰的类。
这带来的影响:
- 模块边界是强制性的,不是约定性的。不写
exports,其他模块就是无法拿到实例,编译期不会报错,但运行时会抛出依赖解析失败的异常。 - 这个设计迫使开发者显式声明模块间的依赖关系,避免隐式的全局耦合。在多个团队协作的大型项目中,明确的提供者可见性有助于理解功能边界。
imports和exports只影响提供者的可见性。控制器不会被导出——一个模块的控制器始终只属于该模块,路由也只在控制器所属模块中生效。
另外,如果在一个模块中重复注册同一个提供者(比如同时在两个模块的 providers 里写了同一个 Service 类),会导致实例不共享。每个模块会创建自己的一份实例。正确的做法是让提供者只在一个模块中注册,然后通过 exports 共享。
这三个概念——模块、控制器、提供者——加上依赖注入,构成了 NestJS 日常开发中最频繁接触的层面。下一篇会在此基础上展开 CLI 工具的实际用法:如何用命令创建项目、生成模块和组件文件。
