☰
NestJS 模块化架构入门:四个核心文件讲清依赖注入
2026/10/21 17:30:45 网站建设 项目流程

最近团队接了一个内部服务重构的活儿,好几个新同事都是刚接触 NestJS。他们的第一反应出奇一致:打开项目目录,面对一堆 .module.ts、.controller.ts、.service.ts 文件,不知道从哪里看起。我每次都用同一句话回复他们:不用慌,先看四个文件,看懂这四个文件,NestJS 的模块化架构就能拿下七八成。

这四个文件就是脚手架默认生成的那一套:main.ts、app.module.ts、app.controller.ts、app.service.ts。它们分别对应了启动、组装、接客、干活四件事。别小看这四兄弟,NestJS 号称的“架构魔法”,全藏在这四段代码里。这篇就带你把四个文件从头到尾剥开看一遍,顺带聊聊依赖注入和模块化背后到底是怎么转起来的。

1. 第一次打开 NestJS 项目时的困惑从哪来

1.1 和 Express 相比,NestJS 多出来那层“架子”

如果你之前只写过 Express,第一次看到 NestJS 的目录结构,大概率会懵:一个app.controller.ts为什么要单独占一个文件?getHello()这种方法有啥好封装的?直接在app.get('/')里 return 一行字符串不就行了?

这里就涉及到框架定位的差异。Express 是一个库,它给你的是一个极简的核心能力——路由、中间件、请求响应处理,剩下的全靠自觉。很多 Express 项目前期写得爽,代码量上去之后,路由散落在各个文件里,业务逻辑和 HTTP 层搅在一起,要改一个功能得顺着调用链翻半天。

NestJS 则是一套完整的应用架构方案。它刻意用模块、控制器、服务、依赖注入这些东西,把你往“分层清晰、边界明确”的方向推。你不需要自己发明架构,照着框架的约定写,代码天然就是有组织的。

1.2 为什么是“四个文件”,而不是全目录都看

NestJS 创建出来的项目里有src目录、test目录、各种配置文件。src里除了那四个基础文件,还可能有app.module.ts引用的其他模块。对新手来说,与其一次把所有文件都啃一遍,不如先把四个文件连成的链路看懂:请求进来 -> 控制器接住 -> 服务处理 -> 模块把它们组合起来 -> main.ts 启动整个应用。

这条链路,刚好是 NestJS 一次请求的标准路径,也是理解模块化架构的最小闭环。看完这四个文件,再看别的模块,你会发现都是同一个套路:控制器管路由,服务管业务,模块管组装。

2. main.ts:一切从这行代码开始

脚手架生成的main.ts非常短:

import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(3000); } bootstrap();

我第一次看这段代码的时候,觉得它简单得像假的一样。Express 启动是app.listen(3000),NestJS 也是app.listen(3000),区别就在NestFactory.create(AppModule)这一步。

2.1 create 背后发生了什么

NestFactory.create(AppModule)是 NestJS 启动的关键。它会做这样几件事:

  • 扫描传入模块的元数据,找到模块声明的控制器和提供者
  • 创建一个 IoC 容器,把所有provider实例化并存入容器
  • 建立依赖关系,容器在实例化每个类的时候,自动把依赖的实例注入进去
  • 创建 HTTP 服务实例(默认基于 Express 引擎),注册控制器中的路由
  • 准备好中间件、守卫、拦截器、管道等机制的执行链路

打个比方:公司开张之前,你得先把各部门名单定下来,谁负责什么岗位,谁向谁汇报。create()做的就是这件事——把整个应用的“组织结构图”建立起来。然后listen(3000)才真正开张接客。

这个初始化过程不需要你手动干预,但理解它非常重要。后面遇到“为什么这个接口 404”“为什么依赖注入报错”之类的问题,很大程度都是因为容器的初始化和你的预期不一致。

2.2 在 main.ts 里做全局配置的经验

main.ts 也是做全局配置的地方。实际项目中,我的习惯是启动前在这里先setGlobalPrefix('api')加统一前缀,再启用全局校验管道:

const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api'); app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, })); await app.listen(3000);

这里注意一个使用细节:useGlobalPipes和app.setGlobalPrefix必须在listen之前调用,否则配置不生效。另一个容易踩的点是 CORS 配置,前后端分离的项目里经常需要app.enableCors(),而且要注意具体参数,比如允许的域名,而不是图省事直接给origin: true。

3. app.module.ts:模块化架构的“根”

打开app.module.ts,代码长这样:

import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @Module({ imports: [], controllers: [AppController], providers: [AppService], }) export class AppModule {}

3.1 @Module 装饰器:三个字段的含义

@Module是 NestJS 模块化架构的核心装饰器。它做的事情很简单:给这个类打上标记,告诉 Nest 这个模块包含哪些东西。三个字段各司其职:

字段作用通俗理解
imports引入其他模块我需要的零部件,从别人那进货
controllers注册控制器前台接待处,负责接请求
providers注册服务类真正干活的员工,随叫随到

AppModule是根模块,整个应用必须有且仅有一个。它就像一棵树的根,其他模块通过imports挂到它下面。Nest 从main.ts传入的AppModule出发,沿着imports递归扫描所有关联模块,收集所有控制器和提供者,最终构建出完整的应用。

看起来简单,但这套机制背后藏着一个重要理念:模块不仅仅是代码组织方式,更是依赖边界的声明。

3.2 为什么模块要显式声明依赖

很多新手会问:我都已经在app.controller.ts里写了import { AppService },为什么还要在模块的providers里再登记一遍?

这正是 NestJS 和普通 JavaScript 最重要的区别。TypeScript 的import解决的是“类能不能引用到”的问题,NestJS 的providers解决的是“谁来创建这个实例、怎么注入”的问题。

举个例子。假设 Controller 里种了一块地用@Inject()暂务依赖:

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

你在 Controller 里import了AppService类型,但如果你不在某个模块的providers里注册它,NestJS 的 IoC 容器就根本不知道有这个类存在。运行时会直接报错:Nest can't resolve dependencies of the AppController。

这就是显式声明的好处:模块的依赖关系一目了然,不需要通过全局搜索代码来猜谁依赖谁。要替换实现的时候,也只需要在providers里换掉对应的类,不用改 Controller 里的一行代码。

4. Controller 和 Service:请求的流动路径

4.1 Controller:只负责接住请求

看app.controller.ts:

import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() getHello(): string { return this.appService.getHello(); } }

Controller 在 NestJS 里的职责非常单一:定义路由、接收请求、返回响应。它不应该写任何业务逻辑,顶多做入参校验和格式转换。

@Controller()装饰器把一个普通类变成控制器。括号里可以传路径前缀,比如@Controller('users'),之后所有路由都会自动带上/users前缀。@Get()装饰器标注这个方法处理 GET 请求,括号里还可以传路径参数,比如@Get(':id')。

constructor(private readonly appService: AppService)这一行,是依赖注入的画龙点睛。它同时做了三件事:

  • 声明了 AppController 依赖 AppService 这个类
  • 要求 NestJS 在实例化 Controller 时自动把 AppService 实例传进来
  • 把传入的实例赋给this.appService,等价于手写的constructor(appService: AppService) { this.appService = appService }

4.2 Service:业务逻辑的归属地

再看app.service.ts:

import { Injectable } from '@nestjs/common'; @Injectable() export class AppService { getHello(): string { return 'Hello World!'; } }

@Injectable()装饰器是这个类的通行证——它告诉 NestJS:这个类可以被注入到其他类里使用。NestJS 默认是单例模式,也就是说整个应用生命周期里,同一个 provider 只会创建一个实例,到处注入的都是同一个对象。

看到这里,你可能觉得 Service 不就是个普通的类吗?既然函数体里只是返回一个字符串,为什么不直接在 Controller 里写?

我的经验是:现在看着简单,是因为这个例子故意做到最小。真实项目里,getHello()可能变成取数据库用户信息、调用第三方接口、做复杂的权限校验,这些逻辑放在 Service 里,Controller 保持薄薄一层,将来加日志、加缓存、做单元测试都方便得多。测试时你不需要启动 HTTP 服务,直接 new 一个 Service 就能测。

Controller 和 Service 的分层,本质上是把“接客”和“干活”分开。接客的前台可以换,干活的部门不能乱。

5. 依赖注入的魔法,其实是一张写好的清单

很多初学者把依赖注入当成黑魔法:构造函数里写了个参数,实例就自动传进来了。搞清楚它运作的机制之后,你会发现它只是一张写好的清单。

5.1 容器、注册、注入三步走

整个依赖注入机制可以拆成三步:

  1. 注册:在模块的providers里声明哪些类可以被注入
  2. 容器管理:NestJS 启动时,为每个 provider 创建实例,统一放进 IoC 容器里
  3. 注入:实例化某个类时,容器检查构造函数的参数类型,从容器里找到对应的实例传进去

这里的关键是容器怎么知道构造函数需要什么类型的参数。NestJS 借助 TypeScript 编译时的emitDecoratorMetadata选项,自动给类生成元数据,记录构造函数的参数类型。容器读这些元数据,就知道该去找哪个 provider。

用大白话说:你不需要自己打电话一个个通知,只需要在模块的“花名册”里登记好名单,Nest 会替你把人都带到工位上。这个“花名册”就是providers数组。

5.2 为什么比手动 new 更优越

传统的写法,逻辑大概是这样的:某 Controller 的代码里const service = new AppService()。这行代码直接把 Controller 和 AppService 的创建绑死了。

依赖注入之后,Controller 不再关心AppService是怎么创建的,它只需要在构造函数里声明“我要一个 AppService”。这个需求由 IoC 容器满足。好处立刻体现:

  • 测试时可以注入一个 mock 对象,不用真的去连数据库
  • 想换实现时,不动 Controller 代码,只改 provider 注册
  • 所有被注入的实例由容器统一管理,可以做到单例、作用域控制

5.3 三个最常见的 DI 报错

实践里大多数人第一次遇到的 DI 报错就三类:

  1. 忘了加 @Injectable():NestJS 找不到该 class 的元数据,报Nest can't resolve dependencies
  2. provider 没在模块里注册:类被引用了,但没写进providers数组
  3. 循环依赖:A 模块 import B 模块,B 模块又 import A 模块,容器初始化时互相等待形成死锁

遇到循环依赖,我建议先从代码结构上重新审视:是不是两个模块的边界划错了?能不能抽一个公共模块放共享依赖?这些其实都不是 bug,是在提醒你架构设计的问题。依赖注入本身很直观:每次报错,本质都是它在告诉你——清单上有你需要的名字,可是花名册里没有这个人。

6. 跑起来之后:我的实测结果与几个容易踩的坑

6.1 实际运行:从终端到浏览器

按照上面的代码,在项目根目录执行:

npm run start:dev

终端显示Nest application successfully started,然后访问http://localhost:3000,浏览器里出现Hello World!。

start:dev是开发模式,带热重载。改完代码保存,NestJS 会自动重启应用。这里有个坑:NestJS 的热重载是整进程重启,不是像前端那样只刷新模块,所以你可以看到终端里每次改代码都会重新打印启动日志。如果你的电脑配置一般,项目大了之后每次重启要等几秒,原因是 NestJS 要重新执行整个依赖分析过程,这个并不是 bug,而是设计使然。

6.2 我自己踩过的坑

第一个坑:忘了把 Controller 注册到模块里。我还没完全适应模块化写法时,新建了一个user.controller.ts,在@Controller('user')里写了路由,然后在main.ts里引用过来,结果访问/user直接 404。排查了半天,最后发现app.module.ts的controllers数组里压根没有UserController。NestJS 有一套扫描机制,但前提是模块声明里包含它。

第二个坑:构造函数里private写错。有次手滑把private readonly appService写成了private appService,编译没报错,运行时注入也正常,但访问接口发现appService是 undefined,因为构造函数参数的属性修饰符变化导致赋值行为不同。这个细微差别,IDE 不一定每次都能自动修复。

第三个坑:想当然以为所有 class 都会被自动注入。只有注册到模块providers里的类才是可注入的。普通类就是普通类,不会因为你@Injectable()就自动进入容器。

6.3 从四个文件延伸到整个项目

把四个文件吃透后,再回头看看src目录,一切豁然开朗。新增一个用户模块,套路一模一样:

src/ modules/ users/ users.module.ts users.controller.ts users.service.ts

UsersModule里controllers注册UsersController,providers注册UsersService,imports里引用了数据库相关的模块。根模块AppModule的imports里加一个UsersModule。一次请求的链路,依然是你熟悉的那个:Controller 接住 -> Service 干活 -> 模块负责组装。

结尾:依赖注入的“魔法”说到底是一种纪律

不夸张地说,理解这四个文件之后,我对 NestJS 的整个认知就通了。它不是什么高级魔法,而是一套显式声明的纪律:类不自己找依赖,模块不偷偷藏依赖,一切依赖关系都摆在明面上。这种纪律感,恰恰是大规模团队协作里最稀缺的东西。

最后给一个个人建议:如果你想真正熟悉 NestJS,别只看文档,把这四个文件里的代码亲手敲一遍,再试着把AppService改成一个真实的业务场景——比如“获取用户列表”这种。改动过程中你会发现,模块化的设计会在你准备乱写的时候自己站出来提醒你:该加 DTO 了,该拆一个子模块了,该把逻辑放 Service 了。

等你在这种约束里写顺手了,就再也回不去那种路由全部堆在一个文件里的日子了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询