从一行入口代码啃透 NestJS:工厂模式、装饰器和依赖注入是怎么串起来的

之前用 Express 写后端,入口文件长这样:

javascript 复制代码
const express = require('express')
const app = express()
app.listen(3000)

三行搞定,很直觉。然后最近在看 NestJS,同样是创建一个 HTTP 服务,入口文件让我愣了一下:

typescript 复制代码
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 直接 express() 就完了,你这 NestFactory.create(AppModule) 又是 Factory 又是 Module 的,搞这么复杂干嘛?

说实话一开始我是带着"又在过度设计"的偏见看的。但顺着这行代码往下翻,我发现它不是随便起个名字装X,而是真的在遵循一套设计思想。而且这套思想不是 NestJS 发明的,是从 Angular、Spring 这些老牌企业级框架一脉相承下来的。我花了一晚上从这一个点往外捋,把工厂模式、装饰器、依赖注入、MVC 分层这些之前只听过名字没真正搞懂的概念串成了一条线,趁还记得清楚写下来。

蜜雪冰城帮我搞懂了工厂模式

我先没急着看 NestJS 的源码,而是看教程里用的一个类比------蜜雪冰城。

你想喝奶茶,不需要自己去煮茶、熬糖、加珍珠(这叫流程代码,你得知道每一步怎么做)。你只需要走到柜台跟服务员说"来一杯柠檬水",服务员背后就是工厂,按你的需求给你生产出一杯。你不需要知道柠檬水怎么做的,你只要知道说"lemon"就能拿到柠檬水,拿到的东西一定有个统一的"喝"的方法。

我动手写了个最小 demo 来验证这个想法:

javascript 复制代码
// 蜜雪冰城的产品线,每个产品都实现了相同的 show() 接口
class IceCream {
    constructor() {
        this.name = '冰淇淋';
        this.price = 3;
    }
    show() { console.log(`${this.name} 价格 ${this.price}`); }
}

class LemonoTea {
    constructor() {
        this.name = '柠檬水';
        this.price = 4;
    }
    show() { console.log(`${this.name} 价格 ${this.price}`); }
}

class MilkTea {
    constructor() {
        this.name = '珍珠奶茶';
        this.price = 5;
    }
    show() { console.log(`${this.name} 价格 ${this.price}`); }
}

// 工厂类:消费者只跟它打交道
class MixueFactory {
    static create(type) {
        switch (type) {
            case 'ice':   return new IceCream();
            case 'lemon': return new LemonoTea();
            case 'milk':  return new MilkTea();
        }
    }
}

// 我只需要说我要什么,不需要手动 new
const drink1 = MixueFactory.create('ice');
drink1.show(); // 冰淇淋 价格 3

const drink2 = MixueFactory.create('lemon');
drink2.show(); // 柠檬水 价格 4

跑了一下,输出没问题。

捋到这我突然想通了工厂模式解决的核心问题:把"对象怎么创建"和"对象怎么使用"分开 。调用方不直接 new 具体类,而是通过工厂方法获取,这样调用方和具体类之间就解耦了。以后蜜雪冰城新增了"芋圆葡萄",我只需要在工厂里加一个 case,调用方还是 MixueFactory.create('yuyuan'),不用改之前的代码。

那 NestFactory.create 到底在干嘛

带着对工厂模式的理解回去看那行代码:

typescript 复制代码
const app = await NestFactory.create(AppModule);

NestFactory 就是那个"蜜雪冰城工厂",它的 create 方法接收一个参数------你要创建什么类型的应用。为什么需要这个参数?因为 NestJS 不止能创建 HTTP 服务,它还能创建微服务(TCP、gRPC)、WebSocket 网关、甚至 Serverless 函数。NestFactory.create() 根据你传入的模块配置,帮你组装好一个对应的应用实例。

你不需要知道底层是 Express 还是 Fastify(对,Nest 默认用 Express,但你可以切换成 Fastify),不需要手动去注册中间件、配置路由、连接容器,工厂全帮你搞定了。你只需要传一个"配方"进去------也就是 AppModule

AppModule 是什么?一个"组装说明书"

翻到 app.module.ts,代码很短:

typescript 复制代码
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TodosModule } from './todos/todos.module';

@Module({
  imports: [TodosModule],       // 引入其他业务模块
  controllers: [AppController], // 控制器:处理请求、参数校验
  providers: [AppService],      // 服务层:业务逻辑、数据操作
})
export class AppModule {}

我一开始看到 @Module(...) 这个带 @ 的东西,觉得是什么黑魔法。后来才知道这就是 TypeScript 的装饰器(Decorator),本质就是一个函数,给下面那个类动态地"贴"上一些元数据。你可以类比成 Java 里的注解,或者 Vue 里选项式 API 的配置对象------它在告诉 NestJS 这个模块里面有哪些控制器、哪些服务、依赖了哪些其他模块。

说白了 AppModule 就是一张清单:这个应用由哪些零件组成,零件之间是什么关系。NestFactory 拿到这张清单,就知道该怎么组装整个应用了。

这里的设计思路是高度模块化 。每个业务功能是一个独立的 Module,比如我写的 Todo 功能就单独拆了一个 TodosModule

typescript 复制代码
// todos/todos.module.ts
@Module({
    controllers: [TodosController],
    providers: [TodosService],
})
export class TodosModule {}

然后在根模块 AppModuleimports 里引入它就行。大型项目可能有几十个 Module,用户模块、订单模块、商品模块各管各的,互不干扰。

Controller 和 Service 为什么要分家

看完 Module 再往下看 Controller 和 Service。我用 Todo 做例子写了个完整的 CRUD,Controller 长这样:

typescript 复制代码
import { Controller, Get, Param, Post, Body, Delete, Put } from '@nestjs/common';
import { TodosService } from './todos.service';

@Controller('todo')
export class TodosController {
    // 这里很容易搞反:Controller 持有 Service 的引用,但不是自己 new 的
    constructor(private readonly todosService: TodosService) {}

    @Get()
    findAll() {
        return this.todosService.findAll();
    }

    @Get(':id')
    findOne(@Param('id') id: string) {
        return this.todosService.findOne(Number(id));
    }

    @Post()
    create(@Body('title') title: string) {
        return this.todosService.create(title);
    }

    @Delete(':id')
    remove(@Param('id') id: string) {
        this.todosService.remove(Number(id));
        return { message: `todo ${id} 删除成功` };
    }

    @Put(':id')
    update(@Param('id') id: string, @Body() patch: Partial<Todo>) {
        return this.todosService.update(Number(id), patch);
    }
}

Service 层则是真正处理数据的地方:

typescript 复制代码
import { Injectable, NotFoundException } from '@nestjs/common';

export interface Todo {
    id: number;
    title: string;
    completed: boolean;
}

// 先用内存数组模拟数据库
let todos: Todo[] = [
    { id: 1, title: '学习nestjs', completed: false },
    { id: 2, title: '学习nestjs 2', completed: false },
    { id: 3, title: '学习nestjs 3', completed: false },
];

@Injectable() // 这个装饰器标记了它可以被"注入"到别的地方
export class TodosService {
    findAll(): Todo[] {
        return todos;
    }

    findOne(id: number): Todo {
        const todo = todos.find(t => t.id === id);
        if (!todo) {
            // NestJS 提供了标准异常类,会自动转成规范的错误响应
            throw new NotFoundException(`todo not found ${id}`);
        }
        return todo;
    }

    create(title: string): Todo {
        const todo: Todo = { id: todos.length + 1, title, completed: false };
        todos.push(todo);
        return todo;
    }

    remove(id: number): void {
        const index = todos.findIndex(t => t.id === id);
        if (index === -1) throw new NotFoundException(`todo not found ${id}`);
        todos.splice(index, 1);
    }

    update(id: number, patch: Partial<Todo>): Todo {
        const todo = this.findOne(id);
        Object.assign(todo, patch);
        return todo;
    }
}

刚开始写的时候我有个疑问:为什么不把查询逻辑直接写在 Controller 里?一个文件搞定多省事。后来才发现这其实就是经典的 MVC 分层思想(准确说 Nest 里更像是 MC,因为 View 层一般交给前端):

  • Controller:像餐厅的服务员,负责接待客人(接收请求)、确认菜单(参数校验)、然后把订单传给后厨。它不做菜,只做"中转+简单校验"。
  • Service:像后厨,真正做菜的地方(业务逻辑、数据库 CRUD)。多个服务员都可以点同一道菜,后厨不需要关心是谁点的,只管做。

这样分层的好处是 Service 可以被多个 Controller 复用,而且以后换数据库(比如从内存数组换成 MySQL),只改 Service 层就行,Controller 完全不用动。

跑起来之后我测了几个接口:

查询所有 todo:

查询单个 todo:

POST 创建新 todo:

都正常返回了。

翻完源码我愣了:谁帮我 new 的 Service?

Controller 的代码里有一行让我盯着看了很久:

typescript 复制代码
constructor(private readonly todosService: TodosService) {}

这行代码的意思是在构造函数里声明了一个私有属性 todosService,类型是 TodosService。但是------我在整个项目里搜了一遍,哪里都没有 new TodosService()

Controller 里直接用 this.todosService.findAll(),它怎么就有值了?谁帮我实例化的?

这就是依赖注入(Dependency Injection,简称 DI),也是我之前一直模棱两可的概念。翻完 NestJS 的文档和一些原理文章我才明白:

还记得 Service 类上面那个 @Injectable() 装饰器吗?它标记了这个类"可以被注入到其他类中"。然后在 Module 的 providers 数组里声明了 TodosService,Nest 的 IoC 容器(Inversion of Control,控制反转容器)就会在启动时自动帮你 new 一个 TodosService 的实例,并且在 Controller 需要的时候自动传进构造函数。

我打了个比方来理解这个事:

以前你在家吃饭,得自己买菜、洗菜、做饭------这叫"自己控制依赖",你要什么就自己 new 什么。现在你住酒店,酒店房间里有个小冰箱,里面的东西都是酒店提前放好的,你需要什么直接拿就行------这叫"控制反转",依赖的创建权交给了外部容器(Nest 的 IoC 容器),你只需要在构造函数里"声明"你需要什么,容器自动给你送进来。

所以整个流程是这样的:

  1. 应用启动时,NestFactory.create(AppModule) 读取模块配置
  2. Nest 的 IoC 容器扫描所有 @Injectable() 标记的类,帮你把它们都实例化好(默认是单例)
  3. 当 Controller 需要 TodosService 时,容器发现构造函数里声明了这个依赖,就把已经创建好的实例传进来
  4. 你直接用就行,不用关心它什么时候创建的、怎么创建的

依赖注入的核心好处是解耦。Controller 不直接依赖具体的 Service 实现类的创建过程,如果以后要做单元测试,可以很方便地注入一个 Mock 的 Service 替换掉真实的数据库操作。

几个我踩过的小坑

写到这把基本流程跑通了,中间也遇到几个容易出错的地方,点一下:

路径容易搞混@Controller('todo') 里定义的是路由前缀,所以接口路径是 /todo 而不是 /todos。我之前写的时候习惯性加了 s,结果 404 找了半天。另外 @Get(':id') 这种动态路由要放在具体路径的后面,不然会把其他 GET 路径也匹配到。

别忘了在 Module 里注册 。新建了 Controller 或者 Service,一定要在对应 Module 的 controllersproviders 数组里声明,不然 Nest 不认。新建了业务 Module 也要在根模块的 imports 里引入,不然整个模块都不会被加载。

异常别自己 throw new Error() 。NestJS 提供了一套标准的 HTTP 异常类(NotFoundExceptionBadRequestExceptionUnauthorizedException 等),用这些类抛出的异常会自动转成规范的 JSON 错误响应,带正确的 HTTP 状态码。你自己抛 Error 的话返回的就是 500,前端不好处理。

POST 参数要加 @Body() 。一开始我直接在 create() 方法参数里写 title: string,结果一直拿不到值。一定要用 @Body('title') 或者 @Body() 来装饰参数,Nest 才知道去请求体里取。

NestJS 默认用的是 Express 适配器,但很多教程里的 API 是通用的。如果你后面切换到 Fastify 适配器,少数中间件和装饰器的行为可能会有细微差别,遇到问题记得查对应适配器的文档。

捋完之后的一点感受

花了几个小时从一行入口代码顺藤摸瓜,把工厂模式→装饰器→模块化→MVC分层→依赖注入这一整条链路搞懂了,说实话比我之前零散看文档理解得深多了。

之前觉得 NestJS "重",是因为没理解它为什么要这么设计。Express 是轻量,但项目大了之后,你会发现自己在手动做模块拆分、手动管理依赖、手动处理错误规范------这些事情 NestJS 在框架层面就帮你约定好了。它的"重"是有代价的,但换来的是统一的项目结构、清晰的分层、可测试性、以及团队协作时大家不用再争论"这段逻辑该写哪里"。

适用场景的话,如果你是个人项目、小工具、快速原型,Express 甚至 Koa 就够了,NestJS 的启动成本确实高一些。但如果是企业级项目、多人协作、需要长期维护的后端服务,NestJS 这套架构能省不少后面的麻烦。

另外我发现设计模式这东西,光背定义是真的记不住,但当你看到它在一个真实框架里怎么被用起来的,一下子就通了。工厂模式不就是"帮你创建对象"嘛,依赖注入不就是"你不用自己 new,框架给你送过来"嘛------说穿了都不复杂,难的是理解它们各自解决什么问题。


顺手捋的核心知识点

写到这顺手把核心的点捋了一遍,我自己回头复盘方便,你们要是懒得翻长文也可以直接看这部分。

1. 工厂模式的核心思想

核心结论:将"对象创建"和"对象使用"分离,调用方通过工厂方法获取实例而非直接 new,实现调用方与具体类的解耦。NestJS 中的 NestFactory.create() 就是典型应用,传入模块配置即可获得组装好的应用实例。

易错提醒:工厂模式不是"多一个类包一层"那么简单,关键在于工厂封装了创建逻辑(可能包含复杂初始化),且生产的对象实现统一接口,调用方可以放心调用相同方法。

2. 装饰器的本质

核心结论:装饰器是一个函数,用来给类/方法/参数附加元数据或动态添加行为,写法是 @xxx。NestJS 中 @Module()@Controller()@Get()@Injectable() 都是装饰器,它们在告诉框架"这个类/方法是干什么的"。

易错提醒:装饰器不是 NestJS 独有的黑魔法,它是 TypeScript 的语言特性(实验性),编译后就是普通的函数调用。理解了这一点就不会觉得 @ 是什么高深东西。

3. Module 的作用

核心结论:Module 是 NestJS 的基本组织单元,用 @Module() 装饰器声明,里面通过 importscontrollersproviders 描述"这个模块有哪些零件"。AppModule 是根模块,其他业务模块通过 imports 挂载上去。

易错提醒:新建的 Controller/Service 必须在对应 Module 中注册,新 Module 必须在根 Module 或父 Module 中 import,否则框架不会加载,请求直接 404。

4. Controller 和 Service 的分工

核心结论:Controller 负责接收请求、参数校验、返回响应,不直接操作数据;Service 负责业务逻辑和数据 CRUD,被 @Injectable() 标记。这是典型的分层思想,目的是解耦和复用。

易错提醒:不要在 Controller 里直接写数据库查询逻辑。虽然能跑,但后续维护和测试都会很痛苦。Controller 应该很"薄",Service 才是"厚"的那一层。

5. 依赖注入(DI)和 IoC 容器

核心结论:依赖注入是指类不需要自己 new 它依赖的对象,而是通过构造函数参数"声明"需要什么,由外部的 IoC 容器自动创建并传入。NestJS 在启动时扫描所有 @Injectable() 标记的类,统一实例化管理,在需要时注入。

易错提醒:被注入的类必须加 @Injectable() 并且在 Module 的 providers 里注册。忘记加装饰器或忘记注册,启动时会报依赖找不到的错误。另外 Nest 默认是单例模式,整个应用共享同一个 Service 实例。

6. 标准异常处理

核心结论:NestJS 提供了 @nestjs/common 中的标准 HTTP 异常类(NotFoundExceptionBadRequestExceptionUnauthorizedExceptionForbiddenException 等),抛出后框架自动转为带正确状态码的 JSON 错误响应。

易错提醒:不要直接 throw new Error(),这样返回的是 500 错误且错误格式不统一。用框架提供的异常类才能返回正确的 HTTP 状态码(比如 404、400、401)。

7. 路由装饰器的用法

核心结论:@Controller('prefix') 定义路由前缀,@Get()@Post()@Put()@Delete() 分别对应 HTTP 方法,@Param('id') 取路径参数,@Body('key') 取请求体字段,@Query('key') 取查询参数。

易错提醒:参数装饰器(@Param@Body@Query)不能省略,否则拿不到值。动态路由 :id 的定义顺序要注意,放在通配路由前面,避免被提前匹配。

8. 手写一个最小工厂模式实现

核心结论:理解工厂模式最直接的方式是自己写一个。关键步骤:

  • 定义多个产品类,每个类实现相同的接口方法(比如 show()
  • 创建一个工厂类,提供静态 create(type) 方法
  • 工厂内部用 switch/映射表根据 type 返回对应的产品实例
  • 调用方只和工厂交互,不直接 new 产品类

关键代码就是前面蜜雪冰城那个 demo 的结构,抓住"统一接口 + 工厂封装创建逻辑"这两个要点就对了。


我目前理解到的程度大概就是这些,工厂模式、依赖注入这些概念以前看文章总觉得隔着一层,这次顺着一个真实框架的入口代码一路拆下来,感觉确实清晰多了。如果有理解不到位的地方,或者你们学 NestJS 的时候有什么更好的切入点,评论区唠唠。

相关推荐
Python私教1 小时前
多个项目怎么安全合并?先适配,再切换
后端·python·架构
Python私教3 小时前
从表格到管理系统:别先写页面,先补齐权限、流程和审计
数据库·后端·架构
东方小月3 小时前
从零开发一个 Coding Agent(十二):实现版本化 JSONL 与真实 CLI 入口
前端·设计模式·前端框架
北斗落凡尘3 小时前
LangGraph 入门实战(12)--使用MCP
后端·python·langchain
SomeB1oody5 小时前
【RustyML入门】6.0. 数学工具
开发语言·后端·机器学习·rust·教程
程序员贺加贝5 小时前
轻制造SaaS的生产闭环建模-BOM工单领料报工质检与入库
java·设计模式·架构
IT_陈寒5 小时前
为什么我的JavaScript闭包总是漏掉那个变量?
前端·人工智能·后端
用户8356290780516 小时前
如何使用 Python 将 PowerPoint 转换为 PDF 文档
后端·python
倔强的石头_6 小时前
从 8 行报错到一条故障链:我用蓝耘 MaaS 做了一个 AI 日志分析助手
后端