如果你已经写过一段时间的Node.js,大概会有这样的体会:Express足够灵活,但项目一大,路由、中间件、业务逻辑容易散落在各处,后期维护成本不低。NestJS出现的意义,并不是要替代Express,而是在它之上提供一套更明确的结构约定。它默认运行在Express之上,也可以切换为Fastify,但开发者面对的是统一的模块、控制器、服务与依赖注入体系。这篇文章围绕NestJS的核心机制展开,同时给出可落地的项目实例与个人实践建议。
一、NestJS的定位与底层选择
NestJS是一个基于Node.js的服务端框架,完整支持TypeScript,同时允许用纯JavaScript编写。它吸收了三类编程范式:面向对象编程、函数式编程以及函数响应式编程。实际写业务代码时,最常感受到的是面向对象风格的依赖注入和装饰器语法。
底层HTTP能力默认交给Express处理,也可以配置为Fastify。为什么默认不是Fastify?原因在于Express生态成熟、中间件兼容面广,对于大多数团队来说,迁移成本和踩坑概率更低。Fastify在性能基准上表现更好,但如果项目没有极致的吞吐量需求,Express的稳定性和社区资源往往是更稳妥的起点。NestJS的价值在于,它把底层框架做了抽象,业务代码不需要直接绑死在某个HTTP库上,未来替换或扩展时改动范围可控。
二、安装与创建项目:从CLI开始
在本地创建NestJS项目之前,需要先确认Node.js和npm已经安装。Node.js安装包可以从https://nodejs.org/获取。之后通过npm全局安装NestCLI:
npm i -g @nestjs/cli
安装完成后,用下面的命令初始化一个项目:
nest new my-nestjs-01
这里有一个容易忽略的细节:原文命令写的是npmnew,实际NestJS官方CLI命令是nestnew。如果直接执行npmnew,npm并不认识这个子命令。建议养成习惯,创建项目时明确使用nestnew,避免在第一步就卡住。
项目创建过程中,CLI会询问使用npm还是yarn,按团队习惯选择即可。生成后的目录里,src文件夹是后续开发的主战场,绝大部分业务代码都会放在这里。
三、项目结构拆解:每个文件到底负责什么
刚生成的项目包含几个核心文件,理解它们的职责,比死记语法更重要。
main.ts是应用入口。它调用NestFactory.create()创建应用实例,然后监听端口。默认端口是3000。这里有一个实践中的建议:端口不要硬编码在代码里,应该通过环境变量读取,否则部署到不同环境时每次都要改代码。
app.module.ts是根模块。NestJS用模块来组织应用,每个模块通过@Module装饰器声明自己的导入、控制器和提供者。根模块把AppController和AppService组装在一起。
app.controller.ts负责处理HTTP请求。它通过构造函数注入AppService,并在@Get()装饰的方法中调用服务层。
app.service.ts承载业务逻辑。它用@Injectable()标记,表示可以被依赖注入系统管理。
app.controller.spec.ts是控制器对应的单元测试文件。很多初学者会直接忽略测试文件,但NestJS默认生成它是有原因的:依赖注入让单元测试变得容易,控制器和服务可以分开测试,不必启动完整HTTP服务。
四、入口文件与启动流程
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();
这段代码做了三件事:导入根模块、创建Nest应用实例、监听端口。bootstrap()被定义为async函数,因为NestFactory.create()和app.listen()都返回Promise。用await等待服务器真正启动,而不是仅仅调用完就继续往下执行,这在需要启动后执行初始化逻辑时尤其重要。
个人经验:如果后续要加入全局管道、全局过滤器或Swagger文档,都会在这个文件里通过app.useGlobalPipes()、app.useGlobalFilters()等方式注册。把main.ts保持得干净,只做启动和全局配置,业务逻辑一律下沉到模块内部,后期排查问题会轻松很多。
五、模块:NestJS的组织单元
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 {}
@Module装饰器接收三个主要属性:imports用来导入其他模块,controllers声明本模块的控制器,providers声明可被注入的服务。根模块的controllers和providers初始只有一组,因为项目刚创建时只有一个功能单元。
这里值得展开的是模块的边界意识。很多从Express转过来的开发者习惯把所有路由写在一个文件里,NestJS的模块机制鼓励按业务域拆分。比如后续要加课程管理,可以创建CoursesModule,把课程相关的控制器和服务都收进去,然后在AppModule的imports中引入。这样每个模块的职责清晰,测试和复用都更方便。
六、控制器与服务:依赖注入的实际样子
控制器代码:
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();
}
}
服务代码:
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
控制器通过构造函数参数privatereadonlyappService:AppService完成注入。NestJS的依赖注入容器会根据类型自动解析并传入实例,不需要手动newAppService()。这是NestJS与Express在写法上最明显的差异之一。
为什么用依赖注入而不是直接导入服务实例?直接导入会让控制器和服务强耦合,测试时难以替换。依赖注入让替换实现、打桩测试变得自然。比如在单元测试中,可以轻松传入一个模拟的AppService,而不必关心它内部到底调用了什么。
@Get()装饰器把getHello方法映射到HTTPGET请求。默认路径是根路径/。当访问http://localhost:3000时,请求进入控制器,控制器调用服务,服务返回字符串,最终作为响应体输出。
七、运行项目与验证
启动开发服务器:
npm run start
如果希望代码变更后自动重启,可以使用:
npm run start:dev
浏览器访问http://localhost:3000,会看到HelloWorld!。这个输出来自AppService.getHello(),经过控制器返回。虽然简单,但它完整跑通了“请求→控制器→服务→响应”这条链路。
八、项目实例:课程列表接口
为了把知识点落到实际场景,这里用一个课程列表接口做示例。假设我们要提供GET/courses,返回课程名称列表。
先创建课程服务:
import { Injectable } from '@nestjs/common';
@Injectable()
export class CoursesService {
private readonly courses = ['NestJS 基础', 'TypeScript 进阶', 'Node.js 工程化'];
findAll(): string[] {
return this.courses;
}
}
再创建课程控制器:
import { Controller, Get } from '@nestjs/common';
import { CoursesService } from './courses.service';
@Controller('courses')
export class CoursesController {
constructor(private readonly coursesService: CoursesService) {}
@Get()
findAll(): string[] {
return this.coursesService.findAll();
}
}
之后创建课程模块:
import { Module } from '@nestjs/common';
import { CoursesController } from './courses.controller';
import { CoursesService } from './courses.service';
@Module({
controllers: [CoursesController],
providers: [CoursesService],
})
export class CoursesModule {}
在AppModule的imports中加入CoursesModule,启动后访问http://localhost:3000/courses,就能拿到课程数组。这个例子虽小,但体现了模块、控制器、服务三层分工。实际项目中,服务层会连接数据库或外部API,控制器只负责接收请求和返回响应,职责边界保持清晰。
九、类型安全与错误处理
NestJS完整支持TypeScript,这意味着可以在编译阶段发现不少问题。比如控制器方法的返回类型、服务方法的参数类型,都应该明确标注。不要为了省事到处用any,否则TypeScript的优势会被削弱。
错误处理方面,NestJS提供了内置的HTTP异常类,例如NotFoundException、BadRequestException。在服务层抛出这些异常,框架会自动转换为对应的HTTP状态码和响应体。比手动拼接错误响应更规范,也更容易统一维护。
import { Injectable, NotFoundException } from '@nestjs/common';
@Injectable()
export class CoursesService {
private readonly courses = ['NestJS 基础', 'TypeScript 进阶'];
findOne(index: number): string {
const course = this.courses[index];
if (!course) {
throw new NotFoundException('课程不存在');
}
return course;
}
}
这种做法比返回null或自定义错误对象更符合NestJS的惯用风格,也让前端拿到的错误结构保持一致。
十、本节课程知识要点
-
NestJS默认基于Express,可切换Fastify,核心价值在于结构约定和依赖注入。
-
使用
nestnew创建项目,而不是npmnew。 -
main.ts负责启动和全局配置,app.module.ts负责根模块组装。 -
控制器处理HTTP请求,服务承载业务逻辑,两者通过依赖注入关联。
-
模块按业务域拆分,避免所有代码堆在根模块。
-
优先使用内置HTTP异常,保持错误响应一致。
-
TypeScript类型标注不要偷懒,这是NestJS工程化的重要基础。
十一、练习与思考
-
创建一个
StudentsModule,提供GET/students返回学生姓名列表,并尝试在服务层加入根据索引查询单个学生的接口。 -
思考:如果课程数据来自数据库,
CoursesService应该承担哪些职责?控制器是否应该直接调用数据库?为什么? -
尝试在
main.ts中注册一个全局管道,观察请求参数类型转换的变化。
NestJS的学习曲线主要集中在对模块和依赖注入的理解上。一旦习惯这种组织方式,后续引入数据库、认证、微服务等能力时,会发现框架已经预留了清晰的扩展点。保持模块边界清晰、类型标注严谨、错误处理统一,是让项目长期可维护的关键。