第二课:Controller — 请求的入口

0 阅读15分钟

覆盖文档:Controllers, Versioning 前置知识:第一课(NestJS 基础概念、项目创建、模块系统) 源码重点:packages/common/decorators/http/request-mapping.decorator.ts, packages/common/decorators/core/controller.decorator.ts, packages/core/router/routes-resolver.ts, packages/core/router/router-explorer.ts, packages/core/router/paths-explorer.ts


一、路由与请求处理

[基础] 本节面向初学者,掌握 Controller 的核心用法。

1.1 什么是 Controller

Controller 是 NestJS 中处理 HTTP 请求的入口。每个 Controller 负责接收特定路由的请求,执行相应的业务逻辑,并返回响应。

客户端请求 → 路由匹配 → Controller 方法 → 返回响应
                           ↓
                     调用 Service 处理业务

1.2 @Controller() 路径前缀

@Controller() 装饰器将一个类标记为控制器,可选参数指定路由前缀

import { Controller, Get } from '@nestjs/common';

@Controller('cats')     // 路由前缀:/cats
export class CatsController {
  @Get()                // 完整路由:GET /cats
  findAll(): string {
    return 'This action returns all cats';
  }

  @Get('breed')         // 完整路由:GET /cats/breed
  findBreed(): string {
    return 'This action returns all cat breeds';
  }
}

路由组合规则:全局前缀 + 模块路径 + Controller前缀 + 方法路径

1.3 HTTP 方法装饰器

NestJS 为每种 HTTP 方法提供了对应的装饰器:

import {
  Controller, Get, Post, Put, Delete, Patch, Options, Head, All,
} from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Get()              // GET /cats
  findAll() { return []; }

  @Get(':id')         // GET /cats/:id
  findOne() { return {}; }

  @Post()             // POST /cats
  create() { return 'created'; }

  @Put(':id')         // PUT /cats/:id
  update() { return 'updated'; }

  @Delete(':id')      // DELETE /cats/:id
  remove() { return 'removed'; }

  @Patch(':id')       // PATCH /cats/:id
  partialUpdate() { return 'patched'; }

  @Options()          // OPTIONS /cats
  options() { return; }

  @Head()             // HEAD /cats
  head() { return; }

  @All('any')         // 匹配所有 HTTP 方法 → /cats/any
  handleAll() { return 'any method'; }
}
装饰器HTTP 方法语义
@Get()GET查询资源
@Post()POST创建资源
@Put()PUT全量替换资源
@Patch()PATCH部分更新资源
@Delete()DELETE删除资源
@Options()OPTIONS预检请求(CORS)
@Head()HEAD获取响应头(无 body)
@All()ALL匹配所有方法

1.4 请求参数提取

NestJS 提供了丰富的参数装饰器,用于从请求对象中提取数据:

import {
  Controller, Get, Post, Param, Query, Body, Headers, Ip, Req, Res, Session, HostParam,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { CreateCatDto } from './dto/create-cat.dto';

@Controller('cats')
export class CatsController {
  // 路由参数
  @Get(':id')
  findOne(@Param('id') id: string): string {
    return `Cat #${id}`;
  }

  // 多个路由参数
  @Get(':userId/cats/:catId')
  findUserCat(
    @Param('userId') userId: string,
    @Param('catId') catId: string,
  ): string {
    return `User ${userId}, Cat ${catId}`;
  }

  // 查询参数
  @Get()
  findAll(
    @Query('page') page: number = 1,
    @Query('limit') limit: number = 10,
  ): string {
    return `Page ${page}, Limit ${limit}`;
  }

  // 请求体
  @Post()
  create(@Body() createCatDto: CreateCatDto): string {
    return `Created cat: ${createCatDto.name}`;
  }

  // 请求体的单个字段
  @Post('name')
  createName(@Body('name') name: string): string {
    return `Name: ${name}`;
  }

  // 请求头
  @Get('auth')
  getAuth(@Headers('authorization') auth: string): string {
    return `Auth: ${auth}`;
  }

  // IP 地址
  @Get('ip')
  getIp(@Ip() ip: string): string {
    return `IP: ${ip}`;
  }

  // 原始请求对象(不推荐,降低平台可移植性)
  @Get('raw')
  getRaw(@Req() req: Request): string {
    return `Method: ${req.method}, URL: ${req.url}`;
  }
}

1.5 装饰器到请求对象映射表

装饰器对应 Express 对象
@Request() / @Req()req
@Response() / @Res()res
@Next()next
@Session()req.session
@Param(key?)req.params / req.params[key]
@Body(key?)req.body / req.body[key]
@Query(key?)req.query / req.query[key]
@Headers(name?)req.headers / req.headers[name]
@Ip()req.ip
@HostParam(key?)req.hosts / req.hosts[key]

注意:使用 @Res() 后,NestJS 不会自动发送响应,你必须手动调用 res.json()res.send()。如果既想用 res 又想保持自动响应,请使用 @Res({ passthrough: true })(详见 2.5 节)。

1.6 DTO(Data Transfer Object)

DTO 用于定义和验证请求体的数据结构。NestJS 推荐使用 class 而非 interface 定义 DTO,因为 class 在运行时保留,可配合 Pipe 做校验:

// dto/create-cat.dto.ts
export class CreateCatDto {
  readonly name: string;
  readonly age: number;
  readonly breed: string;
}

// dto/update-cat.dto.ts — 所有字段可选(部分更新)
export class UpdateCatDto {
  readonly name?: string;
  readonly age?: number;
  readonly breed?: string;
}

配合 class-validator 实现自动验证(后续课程详解 Pipe):

import { IsString, IsInt, Min, Max } from 'class-validator';

export class CreateCatDto {
  @IsString()
  readonly name: string;

  @IsInt()
  @Min(0)
  @Max(30)
  readonly age: number;

  @IsString()
  readonly breed: string;
}

为什么用 class 不用 interface? TypeScript 的 interface 在编译后会被擦除,运行时不存在。而 class 编译后仍然是一个构造函数,class-validatorclass-transformer 等库需要在运行时读取元数据,所以必须使用 class。

1.7 响应状态码

默认情况下,POST 请求返回 201,其他方法返回 200。使用 @HttpCode() 修改:

import { Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Post()
  @HttpCode(HttpStatus.NO_CONTENT)   // 204 No Content
  remove(): void {
    // 无返回体
  }

  @Post('batch')
  @HttpCode(HttpStatus.ACCEPTED)     // 202 Accepted(异步处理)
  batchCreate(): string {
    return 'Batch job started';
  }
}

常用状态码枚举(HttpStatus):

枚举值状态码含义
OK200成功
CREATED201已创建
ACCEPTED202已接受(异步处理)
NO_CONTENT204无内容
BAD_REQUEST400客户端错误
UNAUTHORIZED401未授权
FORBIDDEN403禁止访问
NOT_FOUND404未找到

1.8 响应头

使用 @Header() 设置自定义响应头:

import { Controller, Get, Header } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Get()
  @Header('Cache-Control', 'no-store')
  @Header('X-Custom-Header', 'my-value')
  findAll(): string {
    return 'This action returns all cats';
  }
}

1.9 重定向

使用 @Redirect() 实现路由重定向:

import { Controller, Get, Redirect, Query } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  // 静态重定向
  @Get('old')
  @Redirect('https://nestjs.com', 301)    // 301 永久重定向
  handleOld() {
    // 不需要返回值
  }

  // 动态重定向 — 返回值会覆盖 @Redirect 参数
  @Get('docs')
  @Redirect('https://docs.nestjs.com', 302)
  getDocs(@Query('version') version: string) {
    if (version === '5') {
      return { url: 'https://docs.nestjs.com/v5/', statusCode: 302 };
    }
    // 不返回值则使用装饰器中的默认地址
  }
}

返回对象的结构:{ url: string, statusCode: number }


二、高级路由技巧

[中阶] 本节面向有一定经验的开发者,掌握复杂路由场景。

2.1 路由通配符

NestJS 支持基于模式的路由匹配(底层使用 path-to-regexp):

@Controller('cats')
export class CatsController {
  // 匹配 /cats/abcd, /cats/abcd/efgh, /cats/abcd/123 等
  @Get('abcd/*splat')
  findWild(): string {
    return 'This route uses a wildcard';
  }

  // 正则约束路由参数
  @Get(':id(\\d+)')       // 只匹配数字
  findOne(@Param('id') id: string): string {
    return `Cat #${id}`;
  }
}

注意:Express 和 Fastify 对通配符的语法支持略有不同。上面的 *splat 语法适用于 path-to-regexp v8+(NestJS 11)。在旧版本中,通配符使用 *(.*) 语法。

2.2 路由参数排序

静态路由必须定义在动态(参数化)路由之前,否则静态路由会被参数化路由"吞掉":

@Controller('cats')
export class CatsController {
  // 静态路由 — 必须放在前面
  @Get('breed')
  findBreed(): string {
    return 'All breeds';
  }

  // 参数化路由 — 放在后面
  @Get(':id')
  findOne(@Param('id') id: string): string {
    return `Cat ${id}`;
  }
}

// 如果 @Get(':id') 放在 @Get('breed') 前面,
// 访问 GET /cats/breed 会匹配到 findOne(),id='breed'

2.3 子域名路由

@Controller() 支持通过 host 选项做子域名路由:

// 匹配 admin.example.com 的请求
@Controller({ host: 'admin.example.com' })
export class AdminController {
  @Get()
  index(): string {
    return 'Admin page';
  }
}

// 动态子域名 — 提取子域名参数
@Controller({ host: ':account.example.com' })
export class AccountController {
  @Get()
  getInfo(@HostParam('account') account: string): string {
    return `Account: ${account}`;
  }
}

注意:子域名路由要求底层 HTTP 适配器支持。Fastify 不支持嵌套路由器,因此使用子域名路由时推荐 Express 平台。

2.4 异步控制器

Controller 方法天然支持 async/await

@Controller('cats')
export class CatsController {
  constructor(private readonly catsService: CatsService) {}

  // async/await 方式
  @Get()
  async findAll(): Promise<Cat[]> {
    return this.catsService.findAll();
  }

  // RxJS Observable 方式 — NestJS 会自动订阅并发送最终值
  @Get('observable')
  findAllObservable(): Observable<Cat[]> {
    return this.catsService.findAllObservable();
  }
}

两种方式完全等价,NestJS 内部统一处理。选择标准:

  • 团队熟悉 Promise → 用 async/await
  • 需要流式/响应式操作(如 WebSocket、SSE) → 用 Observable

2.5 Library-Specific 模式

当需要直接操作底层响应对象(如设置 Cookie、流式传输文件)时,可以注入 @Res()

import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express';

@Controller('cats')
export class CatsController {
  // 完全接管响应 — 必须手动发送响应
  @Get('download')
  download(@Res() res: Response) {
    res.setHeader('Content-Type', 'application/octet-stream');
    res.download('/path/to/file');
    // NestJS 不再处理返回值
  }

  // passthrough 模式 — 既能用 res 设置头,又保持自动响应
  @Get('with-cookie')
  withCookie(@Res({ passthrough: true }) res: Response): string {
    res.cookie('token', 'abc123');
    return 'Cookie has been set';  // NestJS 自动发送这个返回值
  }
}
模式@Res()@Res({ passthrough: true })
响应发送手动 res.send()自动(框架处理)
可设置响应头
可设置状态码res.status()@HttpCode()res.status()
拦截器生效
推荐场景文件下载、流式传输设置 Cookie、自定义头

2.6 复杂查询参数解析

@Controller('cats')
export class CatsController {
  @Get()
  findAll(
    @Query('page') page: number = 1,
    @Query('limit') limit: number = 20,
    @Query('sort') sort: string = 'createdAt',
    @Query('order') order: 'asc' | 'desc' = 'desc',
    @Query('breed') breed?: string,
  ): string {
    // GET /cats?page=2&limit=10&sort=name&order=asc&breed=persian
    return `Page ${page}, Limit ${limit}, Sort ${sort} ${order}`;
  }

  // 获取所有查询参数为一个对象
  @Get('search')
  search(@Query() query: Record<string, any>): object {
    return query;
  }
}

注意@Query() 获取的值默认为 string 类型。如果需要自动类型转换,需使用 ValidationPipe 配合 class-transformer(后续课程 Pipe 详解)。

2.7 单例与请求作用域

Controller 默认是单例的(整个应用生命周期只有一个实例),所有请求共享同一实例:

// 默认:单例(Singleton)
@Controller('cats')
export class CatsController {
  // 注意:不要在 Controller 中存储请求相关的状态!
  // private currentUser;  ← 错误!多个并发请求会互相覆盖
}

需要请求级隔离时,使用 Scope.REQUEST

import { Controller, Scope } from '@nestjs/common';

// 每个请求创建一个新实例
@Controller({ path: 'cats', scope: Scope.REQUEST })
export class CatsController {
  // 现在可以安全存储请求级状态
  // 但性能会下降,因为每个请求都要实例化
}

最佳实践:99% 的情况使用默认的单例模式。只有需要请求级状态(如多租户场景、请求级缓存)时才使用 Scope.REQUEST


三、API 版本控制

[高阶] 本节面向需要管理 API 版本迭代的开发者。

3.1 为什么需要版本控制

当 API 的行为需要变更而旧客户端仍在使用时,版本控制允许同一接口提供多个版本并行运行,确保向后兼容。

3.2 四种版本控制策略

main.ts 中启用版本控制:

import { NestFactory } from '@nestjs/core';
import { VersioningType } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 策略一:URI 版本控制(最常用)
  // 路由:GET /v1/cats, GET /v2/cats
  app.enableVersioning({
    type: VersioningType.URI,
  });

  // 策略二:Header 版本控制
  // 请求头:Custom-Header: 1
  app.enableVersioning({
    type: VersioningType.HEADER,
    header: 'Custom-Header',
  });

  // 策略三:Media Type 版本控制
  // Accept: application/json;v=1
  app.enableVersioning({
    type: VersioningType.MEDIA_TYPE,
    key: 'v=',
  });

  // 策略四:Custom 版本控制
  // 自定义版本提取逻辑
  app.enableVersioning({
    type: VersioningType.CUSTOM,
    extractor: (request: Request) => {
      // 从任意位置提取版本号
      return request.headers['x-api-version'] || '1';
    },
  });

  await app.listen(3000);
}
bootstrap();
策略客户端传参方式示例适用场景
URIURL 路径GET /v1/cats公共 API,最直观
Header请求头X-API-Version: 1内部 API,URL 保持简洁
Media TypeAccept 头Accept: app/json;v=1严格遵循 REST 标准
Custom自定义逻辑任意特殊需求

3.3 Controller 级版本

整个 Controller 绑定到一个版本:

@Controller({
  path: 'cats',
  version: '1',         // 所有路由属于 v1
})
export class CatsControllerV1 {
  @Get()                 // GET /v1/cats
  findAll(): string {
    return 'v1: all cats';
  }
}

@Controller({
  path: 'cats',
  version: '2',         // 所有路由属于 v2
})
export class CatsControllerV2 {
  @Get()                 // GET /v2/cats
  findAll(): string {
    return 'v2: all cats with pagination';
  }
}

3.4 路由级版本

同一个 Controller 内不同方法属于不同版本,使用 @Version() 装饰器:

import { Controller, Get, Version } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Version('1')
  @Get()                 // GET /v1/cats
  findAllV1(): string {
    return 'v1: all cats';
  }

  @Version('2')
  @Get()                 // GET /v2/cats
  findAllV2(): string {
    return 'v2: all cats with enhanced response';
  }

  // 同时响应多个版本
  @Version(['1', '2'])
  @Get('count')          // GET /v1/cats/count 和 GET /v2/cats/count
  count(): number {
    return 42;
  }
}

3.5 VERSION_NEUTRAL — 版本无关路由

某些路由(如健康检查)不应受版本控制影响:

import { Controller, Get, VERSION_NEUTRAL } from '@nestjs/common';

@Controller({
  path: 'health',
  version: VERSION_NEUTRAL,   // 不带版本前缀
})
export class HealthController {
  @Get()                       // GET /health(无论版本策略)
  check(): string {
    return 'OK';
  }
}

也可以在默认版本中设置全局默认:

app.enableVersioning({
  type: VersioningType.URI,
  defaultVersion: '1',               // 未标注版本的路由默认为 v1
  // 或
  defaultVersion: VERSION_NEUTRAL,   // 未标注版本的路由不受版本控制
  // 或
  defaultVersion: ['1', '2'],        // 未标注版本的路由同时响应 v1 和 v2
});

3.6 中间件版本控制

中间件也可以按版本过滤:

import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';

@Module({})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes({
        path: 'cats',
        method: RequestMethod.GET,
        version: '2',               // 仅对 v2 的 GET /cats 生效
      });
  }
}

四、源码:路由注册机制

[资深] 本节面向希望深入理解框架内部机制的读者。

4.1 @Controller() 装饰器源码

文件位置:packages/common/decorators/core/controller.decorator.ts

export function Controller(
  prefixOrOptions?: string | string[] | ControllerOptions,
): ClassDecorator {
  const defaultPath = '/';

  const [path, host, scopeOptions, versionOptions] = isUndefined(
    prefixOrOptions,
  )
    ? [defaultPath, undefined, undefined, undefined]
    : isString(prefixOrOptions) || Array.isArray(prefixOrOptions)
      ? [prefixOrOptions, undefined, undefined, undefined]
      : [
          prefixOrOptions.path || defaultPath,
          prefixOrOptions.host,
          { scope: prefixOrOptions.scope, durable: prefixOrOptions.durable },
          Array.isArray(prefixOrOptions.version)
            ? Array.from(new Set(prefixOrOptions.version))
            : prefixOrOptions.version,
        ];

  return (target: object) => {
    Reflect.defineMetadata(CONTROLLER_WATERMARK, true, target);
    Reflect.defineMetadata(PATH_METADATA, path, target);
    Reflect.defineMetadata(HOST_METADATA, host, target);
    Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, scopeOptions, target);
    Reflect.defineMetadata(VERSION_METADATA, versionOptions, target);
  };
}

源码解读:

  1. 参数多态:支持三种调用方式 — @Controller()@Controller('cats')@Controller({ path: 'cats', host: '...', version: '1' })
  2. 元数据存储:通过 Reflect.defineMetadata 将信息存储在类上
    • CONTROLLER_WATERMARK:标记为控制器(布尔值 true),模块扫描器据此识别 Controller
    • PATH_METADATA:路由路径前缀
    • HOST_METADATA:子域名约束
    • SCOPE_OPTIONS_METADATA:作用域(Singleton / Request / Transient)
    • VERSION_METADATA:API 版本号
  3. 版本去重:当版本是数组时,使用 new Set() 去重

4.2 @Get() / @Post() 等装饰器源码

文件位置:packages/common/decorators/http/request-mapping.decorator.ts

// 底层 RequestMapping 装饰器
export const RequestMapping = (
  metadata: RequestMappingMetadata = defaultMetadata,
): MethodDecorator => {
  const pathMetadata = metadata[PATH_METADATA];
  const path = pathMetadata && pathMetadata.length ? pathMetadata : '/';
  const requestMethod = metadata[METHOD_METADATA] || RequestMethod.GET;

  return (
    target: object,
    key: string | symbol,
    descriptor: TypedPropertyDescriptor<any>,
  ) => {
    Reflect.defineMetadata(PATH_METADATA, path, descriptor.value);
    Reflect.defineMetadata(METHOD_METADATA, requestMethod, descriptor.value);
    return descriptor;
  };
};

// 工厂函数 — 生成各 HTTP 方法装饰器
const createMappingDecorator =
  (method: RequestMethod) =>
  (path?: string | string[]): MethodDecorator => {
    return RequestMapping({
      [PATH_METADATA]: path,
      [METHOD_METADATA]: method,
    });
  };

// 所有 HTTP 方法装饰器都是工厂的产物
export const Get = createMappingDecorator(RequestMethod.GET);
export const Post = createMappingDecorator(RequestMethod.POST);
export const Put = createMappingDecorator(RequestMethod.PUT);
export const Delete = createMappingDecorator(RequestMethod.DELETE);
export const Patch = createMappingDecorator(RequestMethod.PATCH);
export const Options = createMappingDecorator(RequestMethod.OPTIONS);
export const Head = createMappingDecorator(RequestMethod.HEAD);
export const All = createMappingDecorator(RequestMethod.ALL);

源码解读:

  1. 工厂模式createMappingDecorator 是一个高阶函数,接收 HTTP 方法,返回装饰器
  2. 元数据位置:注意与 @Controller 不同 — 这里的元数据存储在 方法descriptor.value)上,而不是类上
  3. 关键元数据
    • PATH_METADATA:方法级路由路径
    • METHOD_METADATA:HTTP 方法枚举值(RequestMethod.GET = 0, .POST = 1, ...)

4.3 路由注册流程

应用启动时,路由是如何从装饰器变成可访问的 HTTP 端点的?

NestFactory.create(AppModule)
       │
       ├─→ DependenciesScanner.scan()     扫描所有模块,收集 Controller
       │
       ├─→ InstanceLoader                 实例化所有 Controller 和 Provider
       │
       └─→ RoutesResolver.resolve()       注册路由到 HTTP 引擎
                │
                ├─→ 遍历所有模块的 controllers Map
                │
                ├─→ RouterExplorer.extractRouterPath(metatype)
                │     → 读取 @Controller 的 PATH_METADATA
                │     → 得到 Controller 前缀(如 '/cats')
                │
                ├─→ RouterExplorer.explore(instanceWrapper, ...)
                │     │
                │     ├─→ PathsExplorer.scanForPaths(instance)
                │     │     → MetadataScanner 扫描实例的所有方法
                │     │     → 对每个方法读取 PATH_METADATA 和 METHOD_METADATA
                │     │     → 返回 RouteDefinition[] 数组
                │     │
                │     └─→ applyPathsToRouterProxy()
                │           → 组合 globalPrefix + modulePath + ctrlPath + methodPath
                │           → 调用 httpAdapter.get/post/put/delete(fullPath, handler)
                │           → 路由正式注册到 Express/Fastify
                │
                └─→ 日志输出:Mapped {GET, /v1/cats} route

4.4 RoutesResolver — 顶层路由解析

文件位置:packages/core/router/routes-resolver.ts

export class RoutesResolver implements Resolver {
  public resolve<T extends HttpServer>(
    applicationRef: T,
    globalPrefix: string,
  ) {
    const modules = this.container.getModules();
    // 遍历所有模块
    modules.forEach(({ controllers, metatype }, moduleName) => {
      const modulePath = this.getModulePathMetadata(metatype)!;
      this.registerRouters(
        controllers, moduleName, globalPrefix, modulePath, applicationRef,
      );
    });
  }

  public registerRouters(routes, moduleName, globalPrefix, modulePath, applicationRef) {
    routes.forEach(instanceWrapper => {
      const { metatype } = instanceWrapper;
      // 读取 @Controller 的 host 元数据
      const host = this.getHostMetadata(metatype!);
      // 提取 Controller 路径前缀
      const routerPaths = this.routerExplorer.extractRouterPath(metatype as Type<any>);
      // 读取 Controller 级版本
      const controllerVersion = this.getVersionMetadata(metatype!);

      routerPaths.forEach(path => {
        // 组合路由路径元数据
        const routePathMetadata: RoutePathMetadata = {
          ctrlPath: path,
          modulePath,
          globalPrefix,
          controllerVersion,
          versioningOptions: this.applicationConfig.getVersioning(),
        };
        // 交给 RouterExplorer 探索并注册方法级路由
        this.routerExplorer.explore(
          instanceWrapper, moduleName, applicationRef, host!, routePathMetadata,
        );
      });
    });
  }
}

4.5 PathsExplorer — 扫描方法元数据

文件位置:packages/core/router/paths-explorer.ts

export class PathsExplorer {
  public scanForPaths(instance: Controller): RouteDefinition[] {
    const instancePrototype = Object.getPrototypeOf(instance);

    return this.metadataScanner
      .getAllMethodNames(instancePrototype)
      .reduce((acc, method) => {
        const route = this.exploreMethodMetadata(instance, instancePrototype, method);
        if (route) {
          acc.push(route);
        }
        return acc;
      }, [] as RouteDefinition[]);
  }

  public exploreMethodMetadata(instance, prototype, methodName): RouteDefinition | null {
    const prototypeCallback = prototype[methodName];

    // 读取 @Get/@Post 等设置的元数据
    const routePath = Reflect.getMetadata(PATH_METADATA, prototypeCallback);
    if (isUndefined(routePath)) {
      return null;  // 没有路由元数据 → 不是路由处理器
    }

    const requestMethod = Reflect.getMetadata(METHOD_METADATA, prototypeCallback);
    const version = Reflect.getMetadata(VERSION_METADATA, prototypeCallback);

    return {
      path: isString(routePath) ? [addLeadingSlash(routePath)] : routePath.map(addLeadingSlash),
      requestMethod,
      targetCallback: instance[methodName],
      methodName,
      version,
    };
  }
}

核心逻辑总结:

  1. MetadataScanner 遍历原型链上的所有方法名
  2. 对每个方法,尝试读取 PATH_METADATA — 没有则跳过(说明不是路由处理器)
  3. 读取 METHOD_METADATAVERSION_METADATA
  4. 返回 RouteDefinition 对象,包含路径、HTTP 方法、回调函数、版本

4.6 元数据流全景图

  编译时(装饰器执行)                         运行时(路由注册)
  ─────────────────                         ─────────────────

  @Controller('cats')                       RoutesResolver
    ↓ Reflect.defineMetadata                   │
    ├ CONTROLLER_WATERMARK = true               ├→ Reflect.getMetadata(PATH_METADATA, CatsController)
    ├ PATH_METADATA = 'cats'                    │   → 'cats'
    ├ HOST_METADATA = undefined                 ├→ Reflect.getMetadata(HOST_METADATA, CatsController)
    └ VERSION_METADATA = '1'                    │   → undefined
                                                └→ Reflect.getMetadata(VERSION_METADATA, CatsController)
  @Get(':id')                                       → '1'
    ↓ Reflect.defineMetadata
    ├ PATH_METADATA = ':id'                    PathsExplorer
    └ METHOD_METADATA = RequestMethod.GET        │
                                                 ├→ Reflect.getMetadata(PATH_METADATA, findOne)
                                                 │   → ':id'
                                                 └→ Reflect.getMetadata(METHOD_METADATA, findOne)
                                                     → RequestMethod.GET

  最终注册:Express.get('/v1/cats/:id', handler)

五、RESTful API 设计

[架构] 本节面向技术负责人和架构师。

5.1 资源命名规范

RESTful API 将一切抽象为资源,URL 应使用复数名词表示资源集合:

// 正确 — 复数名词
GET    /users           → 获取用户列表
GET    /users/123       → 获取单个用户
POST   /users           → 创建用户
PUT    /users/123       → 全量更新用户
PATCH  /users/123       → 部分更新用户
DELETE /users/123       → 删除用户

// 错误示范
GET /getUsers            → 不要用动词
GET /user                → 不要用单数
POST /createUser         → HTTP 方法已表达了动作
GET /user/get-profile    → 避免嵌套动词

嵌套资源用于表示从属关系

GET    /users/123/orders         → 用户 123 的订单列表
GET    /users/123/orders/456     → 用户 123 的订单 456
POST   /users/123/orders         → 为用户 123 创建订单

5.2 HTTP 方法语义

方法幂等性安全性请求体典型用途
GET查询资源
POST创建资源、触发操作
PUT全量替换(客户端提供完整数据)
PATCH部分更新(只提供变更字段)
DELETE可选删除资源
HEAD同 GET,但不返回 body
OPTIONS查询支持的 HTTP 方法
  • 幂等性:多次执行与一次执行结果相同(GET、PUT、DELETE)
  • 安全性:不修改服务端状态(GET、HEAD、OPTIONS)

5.3 状态码策略

// 成功响应
@Post()
@HttpCode(HttpStatus.CREATED)          // 201 — 资源已创建
create() {}

@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)       // 204 — 删除成功,无返回体
remove() {}

@Post('batch')
@HttpCode(HttpStatus.ACCEPTED)         // 202 — 异步任务已接受
batchProcess() {}

// 错误响应(通常由异常过滤器处理)
throw new BadRequestException('Invalid data');       // 400
throw new UnauthorizedException('Token expired');    // 401
throw new ForbiddenException('No permission');       // 403
throw new NotFoundException('Cat not found');        // 404
throw new ConflictException('Email already exists'); // 409

5.4 全局路由前缀

main.ts 中设置全局前缀,避免在每个 Controller 上重复写:

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 所有路由自动加上 /api 前缀
  app.setGlobalPrefix('api');

  // 排除特定路由
  app.setGlobalPrefix('api', {
    exclude: [
      'health',                       // GET /health(不加前缀)
      { path: 'metrics', method: RequestMethod.GET },
    ],
  });

  await app.listen(3000);
}
// 结果:GET /api/cats, GET /api/users, GET /health

配合 URI 版本控制:

app.setGlobalPrefix('api');
app.enableVersioning({ type: VersioningType.URI });
// 结果:GET /api/v1/cats, GET /api/v2/cats

5.5 CRUD 资源生成器

NestJS CLI 提供一键生成完整 CRUD 资源的命令:

nest g resource cats

选择 REST API 后,自动生成:

src/cats/
├── cats.controller.ts       # Controller(含 CRUD 路由)
├── cats.controller.spec.ts  # Controller 测试
├── cats.service.ts          # Service(含 CRUD 方法)
├── cats.service.spec.ts     # Service 测试
├── cats.module.ts           # Module
├── dto/
│   ├── create-cat.dto.ts    # 创建 DTO
│   └── update-cat.dto.ts    # 更新 DTO(PartialType)
└── entities/
    └── cat.entity.ts        # 实体类

生成的 Controller 骨架:

@Controller('cats')
export class CatsController {
  constructor(private readonly catsService: CatsService) {}

  @Post()
  create(@Body() createCatDto: CreateCatDto) {
    return this.catsService.create(createCatDto);
  }

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

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

  @Patch(':id')
  update(@Param('id') id: string, @Body() updateCatDto: UpdateCatDto) {
    return this.catsService.update(+id, updateCatDto);
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.catsService.remove(+id);
  }
}

5.6 完整的 RESTful Controller 示例

import {
  Controller, Get, Post, Put, Patch, Delete,
  Param, Query, Body, HttpCode, HttpStatus, Header,
  ParseIntPipe, DefaultValuePipe,
} from '@nestjs/common';
import { CatsService } from './cats.service';
import { CreateCatDto } from './dto/create-cat.dto';
import { UpdateCatDto } from './dto/update-cat.dto';

@Controller('cats')
export class CatsController {
  constructor(private readonly catsService: CatsService) {}

  // POST /cats → 201 Created
  @Post()
  create(@Body() createCatDto: CreateCatDto) {
    return this.catsService.create(createCatDto);
  }

  // GET /cats?page=1&limit=10 → 200 OK
  @Get()
  findAll(
    @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
    @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
  ) {
    return this.catsService.findAll({ page, limit });
  }

  // GET /cats/123 → 200 OK
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.catsService.findOne(id);
  }

  // PUT /cats/123 → 200 OK
  @Put(':id')
  replace(
    @Param('id', ParseIntPipe) id: number,
    @Body() createCatDto: CreateCatDto,
  ) {
    return this.catsService.replace(id, createCatDto);
  }

  // PATCH /cats/123 → 200 OK
  @Patch(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateCatDto: UpdateCatDto,
  ) {
    return this.catsService.update(id, updateCatDto);
  }

  // DELETE /cats/123 → 204 No Content
  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.catsService.remove(id);
  }

  // HEAD /cats → 200 OK (headers only)
  @Head()
  @Header('X-Total-Count', '100')
  head() {
    return;
  }
}

六、课后实践

练习 1:创建 CRUD 控制器(基础)

使用 CLI 生成 dogs 资源并验证所有路由:

nest g resource dogs

# 启动后用 curl 测试
curl http://localhost:3000/dogs            # GET 列表
curl -X POST http://localhost:3000/dogs \
  -H "Content-Type: application/json" \
  -d '{"name":"Buddy","breed":"Labrador"}'  # POST 创建
curl http://localhost:3000/dogs/1          # GET 单个
curl -X PATCH http://localhost:3000/dogs/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"Max"}'                       # PATCH 更新
curl -X DELETE http://localhost:3000/dogs/1 # DELETE 删除

练习 2:参数装饰器全家桶(基础)

在一个 Controller 中,至少使用以下 6 种参数装饰器:

  • @Param() — 路由参数
  • @Query() — 查询参数
  • @Body() — 请求体
  • @Headers() — 请求头
  • @Ip() — 客户端 IP
  • @Req() — 原始请求对象

练习 3:版本控制实战(高阶)

创建一个同时支持 v1 和 v2 的 users 模块:

// v1 返回基本信息
@Controller({ path: 'users', version: '1' })
export class UsersControllerV1 { /* ... */ }

// v2 返回扩展信息(含 avatar、lastLogin)
@Controller({ path: 'users', version: '2' })
export class UsersControllerV2 { /* ... */ }

测试:

curl http://localhost:3000/v1/users/1
curl http://localhost:3000/v2/users/1

练习 4:阅读路由注册源码(资深)

打开 packages/core/router/paths-explorer.ts,阅读 scanForPaths()exploreMethodMetadata() 方法,回答:

  1. MetadataScanner.getAllMethodNames() 如何获取类的所有方法?
  2. 一个方法如果没有 @Get() / @Post() 等装饰器,exploreMethodMetadata() 会返回什么?
  3. PATH_METADATA 的值是存在方法本身上还是原型上?为什么使用 prototypeCallback 而非 instanceCallback 来读取元数据?

练习 5:设计一个 RESTful API(架构)

为一个"博客系统"设计完整的 RESTful API,包括:

  • 文章(Posts)的 CRUD
  • 评论(Comments)的 CRUD(作为文章的子资源)
  • 标签(Tags)的 CRUD
  • 正确的 HTTP 方法和状态码
  • 分页、过滤、排序参数设计

七、本课知识点总结

知识点要点
@Controller()标记类为控制器,可选路径前缀、host、version、scope
HTTP 方法装饰器@Get/@Post/@Put/@Delete/@Patch/@Options/@Head/@All
参数装饰器@Param/@Query/@Body/@Headers/@Ip/@Req/@Res/@Session/@HostParam
DTO使用 class 定义请求体结构,配合 class-validator 做验证
响应控制@HttpCode() 状态码、@Header() 响应头、@Redirect() 重定向
路由通配符基于 path-to-regexp,支持通配符和正则约束
子域名路由@Controller({ host: ':account.example.com' }) + @HostParam()
异步async/await 和 RxJS Observable 两种方式
@Res() 模式直接操作 res 对象,passthrough 模式保持框架响应能力
版本控制4 种策略(URI/Header/Media Type/Custom),Controller 级和路由级
VERSION_NEUTRAL版本无关路由,不受版本控制策略影响
源码机制@Controller → 类元数据、@Get → 方法元数据 → PathsExplorer 扫描 → RoutesResolver 注册
全局前缀app.setGlobalPrefix('api'),可排除特定路由
CRUD 生成nest g resource 一键生成完整 CRUD 骨架

下一课预告:第三课将深入 Provider 与依赖注入,学习 @Injectable()、自定义 Provider、注入作用域、循环依赖处理等 NestJS 的核心机制。