NestJS 入门实战:从工厂模式到 Todo CRUD,讲透模块化、依赖注入与测试

NestJS 入门实战:从工厂模式到 Todo CRUD,彻底理解模块化、依赖注入与测试

如果你接触过 Next.js,再看 NestJS 时,最先要调整的是对框架职责的认识:Next.js 可以同时处理页面与服务端逻辑,而 NestJS 更专注于 Node.js 服务端应用。它默认使用 TypeScript,并通过模块、控制器、服务、装饰器和依赖注入,把后端代码组织成清晰的业务单元。

后端并不只是"写几个接口"。它还可能承担系统集成、并发服务、AI 基础设施以及微服务等工作。业务一旦变复杂,如果路由、参数处理和数据操作全部堆进一个文件,代码很快就会变成难以维护的"大杂烩"。NestJS 的核心价值之一,就是用明确的结构约束这种复杂度。

本文会用一个内存版 Todo CRUD 应用串起以下内容:

  • NestFactory 如何创建应用;
  • Module、Controller、Service 分别负责什么;
  • 装饰器如何把普通类变成模块、控制器和可注入服务;
  • NestJS 如何完成依赖注入;
  • 如何实现查询、新增、删除和局部更新;
  • 如何用 NotFoundException 统一表达"资源不存在";
  • 单元测试与端到端测试分别在验证什么;
  • TypeScript、ESLint、Prettier、Jest 和构建脚本如何配合。

一、先用奶茶店理解工厂模式

在进入 NestJS 之前,先看一个非常小的工厂模式示例。

假设一家奶茶店有冰激凌、柠檬水和奶茶三种产品。调用者不想记住每个产品类的构造过程,只想告诉店员自己要什么,然后直接使用拿到的产品。

js 复制代码
class IceCream {
  constructor() {
    this.name = '冰激凌';
    this.price = 3;
  }

  show() {
    console.log(`${this.name} -> ${this.price}元`);
  }
}

class LemonTea {
  constructor() {
    this.name = '柠檬水';
    this.price = 4;
  }

  show() {
    console.log(`${this.name} -> ${this.price}元`);
  }
}

class MilkTea {
  constructor() {
    this.name = '奶茶';
    this.price = 7;
  }

  show() {
    console.log(`${this.name} -> ${this.price}元`);
  }
}

class MixueFactory {
  static create(type) {
    switch (type) {
      case 'ice':
        return new IceCream();
      case 'lemon':
        return new LemonTea();
      case 'milk':
        return new MilkTea();
    }
  }
}

const product1 = MixueFactory.create('ice');
product1.show();

const product2 = MixueFactory.create('lemon');
product2.show();

const product3 = MixueFactory.create('milk');
product3.show();

运行后会得到:

text 复制代码
冰激凌 -> 3元
柠檬水 -> 4元
奶茶 -> 7元

这里有三个关键点。

第一,调用者只和 MixueFactory 打交道,不负责 new IceCream()new LemonTea()new MilkTea(),产品的创建过程被集中到了工厂中。

第二,三个产品都提供了 show() 方法。这个 JavaScript 示例没有使用 interface 关键字,它依靠的是相同的方法约定。正因为产品具有一致的使用方式,调用者拿到产品后才可以直接调用 show()

第三,调用者与具体产品类之间得到了一层隔离。以后创建规则发生变化时,主要修改工厂即可。不过当前实现也有一个明确边界:如果传入未知的 typecreate() 会返回 undefined,随后调用 show() 就会报错。

NestJS 的应用入口也出现了一个工厂:NestFactory。它与上面的奶茶店示例并非完全相同的业务实现,但理解方式很接近------开发者把根模块交给工厂,由工厂完成应用实例的创建,不需要亲自拼装底层 HTTP 应用。

二、启动一个 NestJS 应用

先安装 Nest CLI,再创建项目:

bash 复制代码
npm i -g @nestjs/cli
nest new hello

项目创建后,可以用以下命令启动开发服务器:

bash 复制代码
pnpm run start

需要监听代码变化时,使用:

bash 复制代码
pnpm run start:dev

应用入口非常短:

ts 复制代码
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

这几行代码完成了整个应用的启动。

1. 为什么 bootstrap 是异步函数

NestFactory.create(AppModule) 会创建 Nest 应用实例,返回值需要通过 await 获取;app.listen(...) 启动 HTTP 服务同样是异步操作。因此入口函数使用了 async

2. 根模块有什么作用

传给 NestFactory.create() 的不是某个控制器,而是 AppModule。根模块描述了应用由哪些模块、控制器和服务组成。Nest 会从根模块出发建立完整的应用依赖关系。

3. 端口为什么这样写

ts 复制代码
process.env.PORT ?? 3000;

它表示优先读取环境变量 PORT;当这个值是 nullundefined 时,才使用默认端口 3000

当前 ESLint 规则会对最后一行 bootstrap() 给出"Promise 未处理"的警告。若希望明确告诉检查器这里有意忽略返回值,可以写成:

ts 复制代码
void bootstrap();

这不会改变启动流程,只是把意图表达得更清楚。

三、模块化:先划分业务,再组装应用

一个 NestJS 应用可以包含很多 Module,一个 Module 又可以组织自己的 Controller 和 Provider。Todo 业务可以单独形成一个模块:

ts 复制代码
import { Module } from '@nestjs/common';
import { TodosController } from './Todos.controller';
import { TodosService } from './Todos.service';

@Module({
  controllers: [TodosController],
  providers: [TodosService],
})
export class TodosModule {}

然后在根模块中引入它:

ts 复制代码
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() 接收的三个字段各有职责:

字段 当前应用中的内容 作用
imports TodosModule 引入当前模块依赖的其他模块
controllers AppControllerTodosController 接收请求,处理路由参数和少量控制逻辑
providers AppServiceTodosService 提供可以被容器管理和注入的业务服务

AppModule 导入 TodosModule 后,Todo 业务就进入了整个应用的模块图。以后业务继续增加,可以继续增加独立模块,而不必把所有控制器和服务都塞进根模块。

Module 不是"把文件放进同一个文件夹"

真正让类成为 Nest 模块的是 @Module() 装饰器及其元数据,而不是目录名称。目录负责帮助开发者阅读,装饰器中的 importscontrollersproviders 才负责告诉 Nest 如何组装这些类。

四、Controller 与 Service:一次请求是怎样流转的

先看最简单的根路由。

服务只负责返回数据:

ts 复制代码
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

控制器接收 GET / 请求,再把工作交给服务:

ts 复制代码
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

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

  @Get()
  getHello(): string {
    console.log('/ 的控制器');
    return this.appService.getHello();
  }
}

请求链路可以概括为:

text 复制代码
GET /
  → AppController.getHello()
  → AppService.getHello()
  → "Hello World!"

控制器知道"哪个请求应该调用哪个方法",服务知道"业务结果应该怎样产生"。这样拆分之后,路由规则与业务数据不会纠缠在一起。

有些资料会直接把这种结构称为 MVC。更准确地说,当前 API 示例主要体现的是 Controller 与 Service 的分层:它没有渲染 HTML 视图,也没有接入数据库 Model。Controller 与 Service 的职责分离,才是这里真正需要掌握的重点。

五、依赖注入:为什么没有手动 new AppService()

控制器的构造函数中有这样一行:

ts 复制代码
constructor(private readonly appService: AppService) {}

这同时做了两件事:

  1. 它利用 TypeScript 的参数属性语法,声明并初始化了 this.appService
  2. 它告诉 Nest:创建 AppController 时需要一个 AppService

AppService@Injectable() 标记,随后又被登记到模块的 providers 中。Nest 的依赖注入容器就可以负责创建服务实例,并把它传给控制器。开发者不需要在控制器里写:

ts 复制代码
const appService = new AppService();

Todo 模块也是同样的关系:

text 复制代码
TodosModule
├── controllers: TodosController
└── providers: TodosService
                    ↑
                    └── 注入 TodosController 的构造函数

这种方式把"如何获得依赖"交给容器,控制器只声明"我需要什么"。

装饰器在这里究竟做了什么

@Module()@Controller()@Get()@Injectable() 都在给类或方法附加 Nest 能够读取的元数据。TypeScript 配置中的下面两项为这套装饰器机制提供支持:

json 复制代码
{
  "experimentalDecorators": true,
  "emitDecoratorMetadata": true
}

同时,应用依赖中包含 reflect-metadata。编译之后,构造函数的参数类型等信息会以元数据形式保留下来,Nest 才能知道 TodosController 需要 TodosService

因此,这里的"装饰"不只是给原对象增加一个可见方法。它更重要的作用是声明框架元数据,让普通 TypeScript 类进入 Nest 的模块、路由和依赖注入体系。

六、设计 Todo 数据结构与内存数据源

Todo 的数据结构很简单:

ts 复制代码
export interface Todo {
  id: number;
  content: string;
  completed: boolean;
}

三个字段分别表示编号、任务内容和完成状态。应用先准备两条内存数据,并用 nextId 生成下一条记录的编号:

ts 复制代码
const todos: Todo[] = [
  { id: 1, content: '学习NestJS', completed: false },
  { id: 2, content: '学习CRUD', completed: false },
];

let nextId = 3;

这里没有数据库,数组就是暂时的数据源。因此它适合用来观察 CRUD 和请求流转,但有一个非常清楚的边界:应用进程一旦重启,运行期间新增、删除或修改的数据都会丢失。

七、在 Service 中实现 CRUD

TodosService 集中了真正的数据操作:

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

@Injectable()
export class TodosService {
  findAll(): Todo[] {
    return todos;
  }

  findOne(id: number): Todo {
    const todo = todos.find((item) => item.id === id);

    if (!todo) {
      throw new NotFoundException(`Todo ${id} 不存在`);
    }

    return todo;
  }

  create(content: string): Todo {
    const todo: Todo = {
      id: nextId++,
      content,
      completed: false,
    };

    todos.push(todo);
    return todo;
  }

  remove(id: number): void {
    const index = todos.findIndex((item) => item.id === id);

    if (index === -1) {
      throw new NotFoundException(`Todo ${id} 不存在`);
    }

    todos.splice(index, 1);
  }

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

逐个看这些方法。

1. findAll:查询全部

ts 复制代码
findAll(): Todo[] {
  return todos;
}

返回值被明确标记为 Todo[],也就是 Todo 数组。

2. findOne:查询单条并处理不存在的情况

ts 复制代码
const todo = todos.find((item) => item.id === id);

find() 返回第一个满足条件的元素;如果没有找到,则返回 undefined。而方法声明的返回类型是 Todo,不能把"可能不存在"的结果直接返回,所以需要先判断:

ts 复制代码
if (!todo) {
  throw new NotFoundException(`Todo ${id} 不存在`);
}

NotFoundException 是 NestJS 提供的内置异常。抛出它后,框架会把异常转换为规范的 HTTP 404 响应,其中会包含状态码和错误消息。业务代码只需要准确表达"资源不存在",不必在每个控制器中手写一套错误响应。

这也是后端容错的一个重要原则:不要让 undefined 继续流向后面的代码,而要在能确定问题的位置尽早终止当前请求。

3. create:新增任务

ts 复制代码
const todo: Todo = {
  id: nextId++,
  content,
  completed: false,
};

每次创建时使用当前 nextId,然后执行自增。新任务默认未完成,加入数组后再把完整对象返回。

4. remove:删除任务

删除需要的是元素位置,而不是元素本身,因此这里使用 findIndex()

ts 复制代码
const index = todos.findIndex((item) => item.id === id);

未找到时 findIndex() 返回 -1,找到后则使用:

ts 复制代码
todos.splice(index, 1);

从数组中删除该位置的一个元素。

5. update:局部更新

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

这里复用了 findOne(),因此更新不存在的 Todo 时会自动抛出相同的 404 异常,不需要再次编写查找与判断逻辑。

Partial<Todo> 会把 Todo 的所有属性变成可选属性,所以调用者可以只提交需要改变的字段。例如,只修改完成状态时不需要再次提交 content

Object.assign(todo, patch) 会直接把补丁字段合并到原对象上。因为数组中保存的就是这个对象,所以修改会立即反映到内存数据中。

当前写法也意味着补丁可以包含 id,并且没有额外校验字段内容。这正好说明了它是一个用于理解分层与 CRUD 的最小实现,而不是完整的数据校验方案。

八、在 Controller 中把 HTTP 请求映射到 Service

完整的 Todo 控制器如下:

ts 复制代码
import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Patch,
  Post,
} from '@nestjs/common';
import { TodosService, type Todo } from './Todos.service';

@Controller('todos')
export class TodosController {
  constructor(private readonly todosService: TodosService) {}

  @Get()
  findAll(): Todo[] {
    console.log('/todos controller');
    return this.todosService.findAll();
  }

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

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

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

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

类装饰器 @Controller('todos') 统一声明了路由前缀,方法装饰器再声明具体的 HTTP 方法和路径。

HTTP 请求 控制器方法 读取的数据 Service 操作
GET /todos findAll() 返回全部 Todo
GET /todos/:id findOne() 路径参数 id 按编号查询
POST /todos create() 请求体中的 content 新增 Todo
DELETE /todos/:id remove() 路径参数 id 删除 Todo
PATCH /todos/:id update() 路径参数 id 与整个请求体 局部更新 Todo

1. @Param() 读取路径参数

GET /todos/2 中,2 来自 URL,控制器通过下面的代码拿到它:

ts 复制代码
@Param('id') id: string

路径参数进入控制器时是字符串,而 Service 的 id 参数是数字,所以调用前使用 Number(id) 做转换。

2. @Body('content') 只读取一个字段

创建接口只需要任务内容:

ts 复制代码
@Body('content') content: string

它从请求体中取出 content,再把字符串传给 create()

3. @Body() 读取完整请求体

更新需要接收不确定数量的补丁字段,因此直接读取整个请求体:

ts 复制代码
@Body() patch: Partial<Todo>

例如,请求体只包含:

json 复制代码
{
  "completed": true
}

就只会修改 completed

4. PUT 与 PATCH 的区别

PUTPATCH 都可以表达修改,但语义不同:

  • PUT 更接近用一个完整对象替换原资源;
  • PATCH 只修改请求中选中的字段。

当前更新方法使用 Partial<Todo> 接收局部字段,因此与 @Patch(':id') 是一致的。代码中不需要导入未使用的 Put,否则 ESLint 会报告未使用变量错误。

九、异常处理为什么应该放在业务层

查询、更新和删除都有可能遇到"编号不存在"。如果每个控制器方法都分别判断,会产生重复代码,还可能出现不同接口返回不同错误格式的问题。

当前实现把判断放在 Service 中:

ts 复制代码
if (!todo) {
  throw new NotFoundException(`Todo ${id} 不存在`);
}

这样有两个直接收益:

  • findOne() 无论被查询接口还是更新逻辑调用,都遵守同一条规则;
  • NestJS 负责把内置异常转成 HTTP 错误响应,Controller 不需要写重复的 try...catch

这并不意味着所有异常都可以不管。这里处理的是已经预期到的业务分支:资源不存在。重点不是"到处包一层 try...catch",而是让异常在合适的层级被准确表达,并由框架完成统一输出。

十、单元测试:只验证 Controller 的行为

NestJS 提供了测试模块,可以在测试中建立一个小型依赖注入容器:

ts 复制代码
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';

describe('AppController', () => {
  let appController: AppController;

  beforeEach(async () => {
    const app: TestingModule = await Test.createTestingModule({
      controllers: [AppController],
      providers: [AppService],
    }).compile();

    appController = app.get<AppController>(AppController);
  });

  describe('root', () => {
    it('should return "Hello World!"', () => {
      expect(appController.getHello()).toBe('Hello World!');
    });
  });
});

这段测试没有启动 HTTP 服务,也没有向 / 发送请求。它做了三件事:

  1. 注册 AppControllerAppService
  2. 编译测试模块,让 Nest 完成依赖注入;
  3. 从容器中取得控制器实例,直接调用 getHello()

因此,它验证的是控制器方法能够通过已注入的服务返回 Hello World!

项目中的 Jest 配置把 src 作为单元测试根目录,用 .*\.spec\.ts$ 匹配测试文件,并通过 ts-jest 转换 TypeScript。执行命令是:

bash 复制代码
pnpm run test

还可以使用:

bash 复制代码
pnpm run test:watch
pnpm run test:cov

分别进入监听模式和生成覆盖率结果。

十一、端到端测试:验证完整 HTTP 链路

端到端测试导入整个根模块,并创建真正的 Nest 应用:

ts 复制代码
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';

describe('AppController (e2e)', () => {
  let app: INestApplication<App>;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('Hello World!');
  });

  afterEach(async () => {
    await app.close();
  });
});

它通过 Supertest 对应用的 HTTP Server 发起 GET /,同时断言状态码是 200、响应内容是 Hello World!。这条链路会经过模块创建、路由匹配、控制器调用和服务返回,比直接调用控制器更接近真实请求。

afterEach() 中的 app.close() 也很重要,它确保每个用例结束后关闭应用,避免测试资源一直占用。

端到端测试有独立的 Jest 配置:测试根目录是 test,文件名由 .e2e-spec.ts 结尾的规则匹配。执行命令是:

bash 复制代码
pnpm run test:e2e

两种测试的差别可以总结为:

对比项 单元测试 端到端测试
测试入口 直接调用 appController.getHello() 发出 GET / 请求
组装范围 Controller + Service 完整 AppModule
是否检查路由
当前断言 方法返回 Hello World! 状态码 200 且响应为 Hello World!

当前两组测试只覆盖根路由,还没有覆盖 Todo 的新增、查询、更新、删除和异常分支。这是阅读测试结果时必须看清的边界:测试通过只能证明已经写下的断言成立,不能自动代表所有 CRUD 行为都被验证过。

十二、从开发到生产:脚本和编译配置在做什么

项目脚本覆盖了开发、构建、检查和测试:

命令 作用
pnpm run build 使用 Nest CLI 编译项目
pnpm run start 启动应用
pnpm run start:dev 监听文件变化并启动
pnpm run start:debug 使用调试与监听模式启动
pnpm run start:prod 运行编译后的 dist/main
pnpm run format 用 Prettier 格式化 srctest 下的 TypeScript
pnpm run lint 检查并自动修复指定目录中的 TypeScript
pnpm run test 运行单元测试
pnpm run test:e2e 运行端到端测试
pnpm run test:cov 运行测试并统计覆盖率

1. Nest CLI 配置

Nest CLI 以 src 为源码根目录,并在构建前删除旧输出目录:

json 复制代码
{
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true
  }
}

这可以避免已经删除或改名的旧文件继续残留在 dist 中。生产启动脚本直接运行 dist/main,所以在运行生产命令前应先完成构建。

2. TypeScript 配置

与当前应用关系最直接的选项包括:

配置 含义
module / moduleResolution: nodenext 按 Node.js 的模块规则处理模块与解析
target: ES2023 把 JavaScript 输出目标设为 ES2023
outDir: ./dist 把编译结果输出到 dist
declaration: true 生成 .d.ts 类型声明文件
sourceMap: true 生成 .js.map 源映射文件
removeComments: true 编译输出中移除注释
experimentalDecorators: true 支持当前使用的装饰器语法
emitDecoratorMetadata: true 为依赖注入生成所需的类型元数据
incremental: true 保存增量编译信息
strictNullChecks: true 区分普通值与 nullundefined
forceConsistentCasingInFileNames: true 检查文件名大小写一致性
skipLibCheck: true 跳过声明文件的类型检查

构建专用配置继承主配置,同时排除:

json 复制代码
{
  "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

也就是说,业务构建不会把依赖目录、测试、旧编译产物和单元测试文件再次编译进生产输出。

3. 编译后为什么会看到多种文件

构建后主要会出现三类产物:

  • .js:Node.js 真正执行的代码;
  • .d.ts:由 declaration: true 生成的类型声明;
  • .js.map:由 sourceMap: true 生成的源映射。

装饰器编译后还会出现辅助逻辑和设计类型元数据。比如控制器构造函数中的 AppService 类型,会进入 design:paramtypes 元数据,这正是 Nest 运行时识别构造函数依赖的重要依据。

4. package.json 与锁文件的分工

package.json 声明直接依赖、开发依赖和允许的版本范围;pnpm-lock.yaml 则记录实际解析出来的完整依赖图和精确版本。前者表达项目需要什么,后者让团队安装时尽量得到一致的依赖结果。

运行应用直接依赖的核心包包括:

  • @nestjs/common:常用装饰器、异常和公共能力;
  • @nestjs/core:Nest 核心与 NestFactory
  • @nestjs/platform-express:Express 平台适配;
  • reflect-metadata:运行时元数据支持;
  • rxjs:响应式编程依赖。

Jest、Supertest、TypeScript、ESLint、Prettier、Nest CLI 等只用于开发、构建或测试,因此位于开发依赖中。

十三、ESLint 与 Prettier:一个管问题,一个管风格

Prettier 配置很克制:

json 复制代码
{
  "singleQuote": true,
  "trailingComma": "all"
}

它要求字符串优先使用单引号,并在允许的位置保留尾逗号。

ESLint 使用 JavaScript 与 TypeScript 的推荐规则,同时接入 Prettier。它认识 Node 和 Jest 的全局变量,并启用了需要类型信息的 TypeScript 检查。几个自定义规则值得注意:

js 复制代码
{
  '@typescript-eslint/no-explicit-any': 'off',
  '@typescript-eslint/no-floating-promises': 'warn',
  '@typescript-eslint/no-unsafe-argument': 'warn',
  'prettier/prettier': ['error', { endOfLine: 'auto' }],
}
  • 允许显式使用 any
  • 未处理的 Promise 给出警告;
  • 不安全的参数传递给出警告;
  • 不符合 Prettier 的代码按错误处理,并自动适应不同系统的换行符。

这也解释了两个容易忽略的细节:没有使用的 Put 导入应当删除;入口处的 bootstrap() 会触发未处理 Promise 警告,可以用 void bootstrap() 明确意图。

十四、哪些内容不应该进入版本库

一个完整的后端项目会产生很多不适合提交的内容,因此忽略规则覆盖了:

  • distbuild 等编译结果;
  • node_modules 依赖目录;
  • coverage 测试覆盖率结果;
  • 日志、临时目录、PID 和诊断报告;
  • 本地环境变量文件;
  • 操作系统与编辑器生成的文件。

这里尤其要注意 .env 及各种本地环境配置。它们属于不同机器或不同运行环境的本地状态,不应和普通业务代码混在一起提交。

十五、回头看完整请求链路

现在可以把 GET /todos/1 的完整过程串起来:

  1. NestFactory 根据 AppModule 创建应用;
  2. AppModule 导入 TodosModule
  3. TodosModule 注册 TodosControllerTodosService
  4. 依赖注入容器创建 TodosService,并注入 TodosController
  5. @Controller('todos')@Get(':id') 匹配 GET /todos/1
  6. @Param('id') 取出字符串 1,控制器将其转成数字;
  7. Controller 调用 TodosService.findOne(1)
  8. Service 从数组中查找 Todo;
  9. 找到则返回对象,未找到则抛出 NotFoundException
  10. Nest 把返回值或异常转换为 HTTP 响应。

这条链路把工厂模式、模块化、装饰器、依赖注入、分层和异常处理全部连接了起来。

总结

这个 Todo 应用很小,但它已经具备 NestJS 项目的核心骨架:

  • NestFactory 和根模块启动应用;
  • 用业务 Module 管理边界;
  • 用 Controller 映射 HTTP 请求;
  • 用 Service 承担数据与业务操作;
  • @Injectable()、Provider 和构造函数完成依赖注入;
  • NotFoundException 表达可预期的业务错误;
  • 用 Jest 做方法级测试,用 Supertest 做完整 HTTP 链路测试;
  • 用 TypeScript、ESLint、Prettier 和 Nest CLI 保证开发与构建流程清晰。

真正值得掌握的不是某个装饰器的写法,而是它们如何协作:模块负责组装,控制器负责接收,服务负责处理,容器负责连接,测试负责验证。当应用从一个 Todo 列表扩展成多个业务模块时,这套结构才会真正显出价值。

相关推荐
思考着亮17 分钟前
11.MVCC、行锁与事务隔离级别
后端
一开17 分钟前
一个自己开发的 Agent Harness-持久化与恢复篇
后端
一开18 分钟前
一个自己开发的 Agent Harness-模型降级篇
后端
Geek漫游指南23 分钟前
AI 会回答还不够:ProofOps 业务研判平台落地实战
后端
SimonKing37 分钟前
白嫖国产多模态大模型:商汤 SenseNova 接入指南
java·后端·程序员
小江的记录本37 分钟前
【ORM框架】MyBatis核心原理、ORM思想、MyBatis vs JPA
java·数据库·后端·spring·spring cloud·oracle·mybatis
卷无止境1 小时前
FastAPI生产环境密钥管理全解析,从一个.env文件说起
后端·python·fastapi
祀爱1 小时前
C# MQTT 连接服务
后端·c#·.net
卷无止境1 小时前
SigV4与HTTPS,两套完全不同维度的安全机制
后端·python·fastapi