NestJS 模块、控制器与服务
用待办事项示例完整走一遍 Module/Controller/Service/DTO 的分层写法、依赖注入与模块边界。
0. 一句话理解
一个功能单元 = 模块(组装)+ 控制器(接请求)+ 服务(写逻辑)+ DTO(定义入参);控制器只负责”翻译 HTTP”,业务全部下沉到服务。
本篇用”待办事项 API”把四个文件从头写一遍,并回答两个初学高频问题:依赖注入到底发生了什么、模块之间怎么共享服务。
1. 生成功能模块
nest g module todos
nest g controller todos
nest g service todos
讲解:
nest g是生成器命令,会自动创建文件并把类注册到对应模块。- 三条命令分别生成
todos.module.ts、todos.controller.ts、todos.service.ts,目录src/todos/。 - 生成后
app.module.ts的imports会自动加入TodosModule。 - 目录习惯:一个功能一个目录,DTO 放在子目录
dto/里——项目长大以后,“按功能分目录”比”按类型分目录”好找得多。
2. DTO:定义请求体
// src/todos/dto/create-todo.dto.ts
export class CreateTodoDto {
title: string
done?: boolean
}
讲解:
- DTO(Data Transfer Object)是”请求长什么样”的类型描述,控制器用它接收并校验参数。
title必填、done可选(?),先声明类型,下一篇再用装饰器做真正的运行时校验。- 类(class)比接口(interface)更适合 DTO:编译后仍存在,能配合装饰器做校验与文档生成;接口在运行时会消失,框架拿不到任何信息。
3. Service:业务逻辑
// src/todos/todos.service.ts
import { Injectable } from "@nestjs/common"
import { CreateTodoDto } from "./dto/create-todo.dto"
export interface Todo {
id: number
title: string
done: boolean
}
@Injectable()
export class TodosService {
private todos: Todo[] = []
private nextId = 1
create(dto: CreateTodoDto): Todo {
const todo: Todo = {
id: this.nextId++,
title: dto.title,
done: dto.done ?? false
}
this.todos.push(todo)
return todo
}
findAll(): Todo[] {
return this.todos
}
findOne(id: number): Todo {
const todo = this.todos.find((t) => t.id === id)
if (!todo) {
// 找不到时抛 Nest 内置异常,自动变成 404 响应
throw new NotFoundException(`待办 ${id} 不存在`)
}
return todo
}
toggleDone(id: number): Todo {
const todo = this.findOne(id) // 不存在时由 findOne 抛 404
todo.done = !todo.done
return todo
}
remove(id: number): void {
this.todos = this.todos.filter((t) => t.id !== id)
}
}
// import 部分补充
import { Injectable, NotFoundException } from "@nestjs/common"
讲解:
@Injectable()装饰器标记该类可以被依赖注入:Nest 会在需要时自动创建单例实例。private todos: Todo[]是内存存储,重启即清空;真实项目在这里换成数据库访问(见接入数据库)。dto.done ?? false:空值合并运算符,undefined时取默认值false。NotFoundException是 Nest 内置 HTTP 异常:抛出它,框架自动返回 404 与消息 JSON,控制器一行都不用改——“业务语言表达错误,框架负责翻译成 HTTP”。remove用filter生成新数组再赋值,这是不可变更新风格,避免残留已删除项。
4. Controller:接收请求
// src/todos/todos.controller.ts
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post
} from "@nestjs/common"
import { TodosService } from "./todos.service"
import { CreateTodoDto } from "./dto/create-todo.dto"
@Controller("todos")
export class TodosController {
constructor(private readonly todosService: TodosService) {}
@Post()
create(@Body() dto: CreateTodoDto) {
return this.todosService.create(dto) // POST 默认返回 201
}
@Get()
findAll() {
return this.todosService.findAll()
}
@Get(":id")
findOne(@Param("id", ParseIntPipe) id: number) {
return this.todosService.findOne(id)
}
@Patch(":id/done")
toggleDone(@Param("id", ParseIntPipe) id: number) {
return this.todosService.toggleDone(id)
}
@Delete(":id")
remove(@Param("id", ParseIntPipe) id: number) {
this.todosService.remove(id)
}
}
讲解:
@Controller("todos")声明路由前缀:POST /todos、GET /todos、DELETE /todos/1。- 构造器参数
private readonly todosService: TodosService就是依赖注入:Nest 自动把服务实例传进来,不需要手动 new。 @Body()取出请求体并交给 DTO;@Param("id", ParseIntPipe)取出路径参数并自动转成数字,转失败返回 400——ParseIntPipe是内置管道,下一篇系统学。- 方法返回什么,Nest 就把它序列化成什么 JSON;
@Post()默认状态码 201(资源已创建),@Delete()默认 200,可用@HttpCode(204)覆盖。 - 控制器里只有一行调用——业务逻辑全部在服务里,这是分层最重要的目的:控制器可测、服务可测、互不纠缠。
5. 手动接线:Module 与依赖注入
// src/todos/todos.module.ts
import { Module } from "@nestjs/common"
import { TodosController } from "./todos.controller"
import { TodosService } from "./todos.service"
@Module({
controllers: [TodosController],
providers: [TodosService]
})
export class TodosModule {}
讲解:
controllers数组声明本模块对外提供哪些路由。providers数组声明本模块可注入的服务;Nest 启动时会解析TodosController构造函数里的依赖并自动注入。- 注入的过程值得看穿一层:启动时 Nest 扫描装饰器元数据 -> 发现控制器需要
TodosService-> 在 providers 里找到它 -> 创建单例 -> 按类型匹配传入构造器。全程没有魔法,靠的是 TS 装饰器把类型信息留到了运行时(emitDecoratorMetadata,tsconfig 默认已开)。 - 默认作用域是单例:整个应用共享一个
TodosService实例,todos数组因此能在请求之间保留。多数场景单例正确;请求级隔离(每个请求新实例)是进阶话题,遇到”实例间状态串了”再查官方 Scope 文档。
6. 跨模块共享服务
报表模块也要读待办列表,直接注入 TodosService 会报错——服务默认只属于声明它的模块:
// src/todos/todos.module.ts —— 导出服务
@Module({
controllers: [TodosController],
providers: [TodosService],
exports: [TodosService] // 加这一行,其他模块才能注入
})
export class TodosModule {}
// src/report/report.module.ts —— 导入模块后即可注入
import { Module } from "@nestjs/common"
import { TodosModule } from "../todos/todos.module"
@Module({
imports: [TodosModule]
})
export class ReportModule {}
讲解:
- 模块是依赖的”海关”:想用别的模块的服务,先
imports那个模块,且对方必须exports该服务——边界显式,依赖关系一眼可查。 - 报错
Nest can't resolve dependencies十有八九是这一节的问题:要么忘了放 providers,要么忘了 imports + exports。 - 反过来,
app.module.ts是根模块,所有功能模块都要被它(直接或间接)imports,应用才能把它们组装起来。
7. 常见陷阱
- 服务忘了写进 providers:启动报
can't resolve dependencies;生成器会自动注册,手写文件时最容易漏。 - 接口当 DTO:interface 编译后消失,校验与 OpenAPI 文档全部失效;DTO 一律用 class。
- 在控制器里
new TodosService():绕过 DI 容器,服务里的其他注入全部失效,测试也没法替换替身。 - 单例服务里存了不该共享的状态:
todos数组是”应用级数据”,故意放的没问题;但把”当前请求用户”这类请求级数据存在服务字段里,多用户并发时必然串数据——请求级信息放request对象(见守卫篇)。 - 改了服务没重启:
start:dev只监听文件变化;偶尔装饰器元数据缓存导致行为滞后,重启一次能排除一半”灵异问题”。
8. 动手试试
- 用
curl测试接口:curl -X POST http://localhost:3000/todos -H "Content-Type: application/json" -d '{"title":"学 NestJS"}',再curl http://localhost:3000/todos。 - 给 Todo 增加
priority字段(DTO、Service、接口三处同步修改)。 - 补全
toggleDone方法(服务里翻转done),验证PATCH /todos/1/done。 - 故意把
TodosService从 providers 里删掉,观察启动报错,再恢复——把报错认脸是排障的第一步。
9. 本篇小结
初学者要点:
- 控制器用装饰器声明路由、服务用类封装业务、模块把它们组装起来;依赖注入让”谁来实例化”这件事交给框架。
- 四文件心法:DTO 定形状、服务写逻辑、控制器翻译 HTTP、模块画边界。
Nest can't resolve dependencies= 注册链断了,检查 providers 与 imports/exports。
进阶注意:
- 单例是默认作用域,服务字段是”应用级状态”;请求级数据走 request 对象,不要落进服务。
- class DTO 是校验(下一篇)与文档(@nestjs/swagger)的地基,用 interface 会两头落空。