以 NestJS 为原型看懂 Node.js 框架设计:装饰器、元数据与路由注册

前言

在 Node.js 世界中,构建高可维护性、高扩展性的 Web 框架一直是一项挑战。我们熟悉的 NestJS 通过借鉴 Angular 的设计理念,构建了一个基于 装饰器 + 依赖注入 + 模块化 的优雅架构,极大提升了开发体验与项目的可组织性。

但 NestJS 背后到底是如何工作的?@Controller()、@Get() 这些装饰器只是语法糖吗?参数如何自动注入?请求又是如何路由到目标控制器方法的?

本文将从最原始的 Node.js + HTTP API 出发,逐步构建出一个类似 NestJS 的微型框架原型,并深入讲解其中的底层机制与关键设计理念。目的不是复制 NestJS,而是理解它的设计哲学与执行过程。

控制器系统的实现原理

如果我们用node原生来创建服务,通常我们会这么做:

ts 复制代码
import http from 'http';

const server = http.createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/hello') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ message: 'Hello World' }));
  } else {
    res.writeHead(404);
    res.end();
  }
});

server.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

这段代码比较明显的问题是不好扩展,大量if else和重复代码。那么我们需要设计一个路由注册系统,最好可以这么用:

ts 复制代码
@Controller('/hello')
class HelloController {
  @Get('/')
  sayHello(req: http.IncomingMessage, res: http.ServerResponse) {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ message: 'Hello World' }));
  }
}

const router = new Router();
router.registerController(HelloController);
const server = http.createServer((req, res) => {
  router.handleRequest(req, res);
});
 
server.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

在此之前,我们需要先了解装饰器Reflect Metadata,这里只作简要概述。

装饰器

装饰器就是一种特殊的语法糖,当前主流的装饰器使用方式主要在 TypeScript 中得到支持,而 JavaScript 装饰器也已在 TC39 标准提案中推进并逐步落地(语法略有不同)。装饰器常用于在不显式修改原有业务逻辑的前提下,进行功能增强或元数据标注,但它也具备修改行为的能力。它有5种类型:类装饰器、方法装饰器、属性装饰器、参数装饰器、访问器装饰器(set/get)。

  1. 类装饰器:接收一个函数,返回一个函数,或者不返回。
ts 复制代码
declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
  1. 属性装饰器:
ts 复制代码
declare type PropertyDecorator = (
    // 如果装饰的是实例属性:target = ClassName.prototype 
    // 如果装饰的是static属性:target = ClassName
    target: Object, 
    propertyKey: string | symbol
) => void;
  1. 方法装饰器:
ts 复制代码
declare type MethodDecorator = (
  target: Object, // 同属性装饰器
  propertyKey: string | symbol,
  descriptor: PropertyDescriptor, // 可通过改写 descriptor.value 修改原方法
) => PropertyDescriptor | void;
  1. 访问器装饰器:
ts 复制代码
declare type AccessorDecorator = (
  target: Object, // 同属性装饰器
  propertyKey: string | symbol,
  descriptor: PropertyDescriptor, //可通过改写 descriptor.set / descriptor.get 修改原方法
) => PropertyDescriptor | void;
  1. 参数装饰器:
ts 复制代码
declare type ParameterDecorator = (
  target: Object, // 同上,取决于装饰的是实例方法的参数还是静态方法的参数
  propertyKey: string | symbol, // 装饰的方法名,如果装饰的是constructor的话会不一样
  parameterIndex: number, // 参数的位置,从0开始
) => void;

注意:@Log@Log('z') 的区别在于要不要传参数,后者需要return一个函数接收targetpropertyKey 等参数。

通过装饰器,我们可以传入自定义参数,为类、方法、属性等结构添加元数据。但仅靠装饰器语法本身,无法实现统一的数据收集与跨模块访问,因为这些数据不会自动集中存储或暴露,那么就需要借助外部力量将这些数据存储起来,比如:

  1. 某个方法有几个参数?参数类型是什么?
  2. 某个类是否被打了某个装饰器?装饰器上有没有传配置?

reflect-metadata

我们可以借助 reflect-metadata 库,它是对 ECMAScript 元编程提案的实现,允许在运行时保存和读取类型、参数等元数据。它可以作用于类、方法、属性、参数等语言结构,并支持沿原型链读取,广泛应用于依赖注入、路由注册等场景中。 以下是涉及到的相关API类型定义:

ts 复制代码
defineMetadata(
    metadataKey: any, // 元数据的键(建议使用 Symbol 或唯一字符串)
    metadataValue: any, // 元数据的值
    target: Object, // 目标对象(类、实例、原型等)。
    propertyKey?: string | symbol // 可选,用于指定目标对象的某个属性或方法。
): void;

getMetadata(
    metadataKey: any, 
    target: Object, 
    propertyKey?: string | symbol
): any;

hasMetadata(
    metadataKey: any, 
    target: Object, 
    propertyKey?: string | symbol
): boolean;

初步计划:实现一个最小可用 HTTP 服务 + 路由系统

基于上述知识点,我们将这个任务可拆解为4个部分:

  1. @Controller()类装饰器
  2. @Get/@Post等方法装饰器
  3. Router对象
  4. @Body/Param/@Query/@Res/@Req等参数装饰器

1. Controller装饰器的实现:

给目标class(也就是 HelloController)绑定对应的路由前缀。

ts 复制代码
import 'reflect-metadata';

export function Controller(prefix = ''): ClassDecorator {
  return target => {
    Reflect.defineMetadata('path', prefix, target);
  };
}

2. Get装饰器的实现:

给目标方法(也就是sayHello)绑定对应的methodurl

@Post 实现类似,仅 method 不同。

ts 复制代码
import 'reflect-metadata';

export function Get(path: string): MethodDecorator {
  return (target, propertyKey, descriptor) => {
    Reflect.defineMetadata('path', path, descriptor.value);
    Reflect.defineMetadata('method', 'GET', descriptor.value); 
  };
}

3. Router 实现:

Nestjs 本身不负责路由匹配,它是通过底层 express 或者 fastify 等框架注册路由并返回 req, res 等对象。根据之前保存的元数据,我们可以通过遍历所有controller中被@Get或其他装饰器装饰过的方法拿到 routes 列表,简易实现如下:

生成 routes 列表:
ts 复制代码
// 路由提取:packages/core/router/paths-explorer.ts
const routes = [];
for (const ControllerClass of allControllers) {
  const prefix = Reflect.getMetadata('prefix', ControllerClass); 
  const prototype = ControllerClass.prototype;
  // 下面这一行是核心,通过prototype获取每一个Controller下定义的方法
  const methodNames = Object.getOwnPropertyNames(prototype).filter(name => typeof prototype[name] === 'function');
  for (const methodName of methodNames) {
    const instanceCallback = prototype[methodName];
    const path = Reflect.getMetadata('path', instanceCallback);
    const requestMethod = Reflect.getMetadata('method', instanceCallback);
    if (path && httpMethod) {
        routes.push({
          path: prefix + path,
          requestMethod,
          targetCallback: instanceCallback,
          methodName,
        })
    }
  }
}
通过底层框架注册路由:

这里以 express 为例,这是一段简易的服务启动代码:

ts 复制代码
import express from 'express';
const app = express(); 
app.get('/hello', async (req, res, next) => {
    res.send('Hello World!')
})
app.listen(3000)

由于之前已经拿到了routes列表,我们仅需要在遍历列表时通过app.get的方式来注册路由,类似这样:

ts 复制代码
import express from 'express';
const app = express(); 
for(const {requestMethod, path, targetCallback} of routes) {
    app[requestMethod](path, async (req, res, next) => {
        targetCallback(req, res, next)
    });
}

但是这里有个问题,我们在定义targetCallback时 通常会引入多个参数装饰器,比如:@Query@Body@Res@Req@Param等等,并且位置是随机的。例如:

ts 复制代码
  @Get('/')
  sayHello(
      @Query('name') name, 
      @Req() req: http.IncomingMessage, 
      @Res() res: http.ServerResponse
  ) {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ message: 'Hello World' }));
  }

这里可以利用参数装饰器提供的索引来记住需要传入的参数和位置,分两步:

  1. 记住被装饰参数的索引,装饰类型,和传入的参数(如果有的话)
  2. 在注册路由时,生成所需要的参数传入targetCallback

这里以@Query为例, 其他的参数装饰器实现逻辑是一样的,只需换掉代码中的 Query 即可。

ts 复制代码
// packages/common/decorators/http/route-params.decorator.ts
const Query = (data: string): ParameterDecorator => {
  return (target, key, index) => {
    const args =
      Reflect.getMetadata(ROUTE_ARGS_METADATA, target.constructor, key!) || {};
    Reflect.defineMetadata(
      ROUTE_ARGS_METADATA,
      {
        ...args,
        [`Query:${index}`]: {
          index,
          data,
        },
      },
      target.constructor,
      key!
    );
  };
};

拿刚才的例子来说,生成的参数元数据应该是这样:

ts 复制代码
const paramsMetaData = {
  "Query:0": {
    index: 0,
    data: "name",
  },
  "Req:1": {
    index: 1,
    data: undefined,
  },
  "Res:2": {
    index: 2,
    data: undefined,
  },
};

根据装饰类型返回对应的数据:

ts 复制代码
// packages/core/router/route-params-factory.ts
function exchangeKeyForValue(
  key: RouteParamtypes | string,
  data: string,
  { req, res, next }
) {
  switch (key) {
    case RouteParamtypes.REQUEST:
      return req as any;
    case RouteParamtypes.RESPONSE:
      return res as any;
    case RouteParamtypes.BODY:
      return data && req.body ? req.body[data] : req.body;
    case RouteParamtypes.PARAM:
      return data ? req.params[data] : req.params;
    case RouteParamtypes.QUERY:
      return data ? req.query[data] : req.query;
    case RouteParamtypes.HEADERS:
      return data ? req.headers[data.toLowerCase()] : req.headers;
    // ....
    default:
      return null;
  }
}

最后通过参数索引生成对应的参数传递到handler, 再由底层的express去执行。下面这段代码是方便理解,源码中嵌套比较深但原理是一样的。

ts 复制代码
// 路由注册: packages/core/router/routes-resolver.ts
import express from 'express';
const app = express(); 

for(const {requestMethod, path, targetCallback, controllerClass, methodName } of routes) {
    app[requestMethod](path, async (req, res, next) => {
        const args = [];
        const paramsMetaData = Reflect.getMetaData('ROUTE_ARGS_METADATA', controllerClass, methodName);
        for (const [key, meta] of Object.entries(paramsMetaData)) {
            const [paramtype, indexStr] = key.split(':');
            const index = Number(indexStr);
            args[index] = extractValue(paramtype, meta.data, {req, res, next});
        }
        targetCallback(...args)
    });
}

总结

在本篇文章中,我们完成了一个核心目标:从原生的 HTTP 服务逐步演化出一个支持 @Controller、@Get 以及参数装饰器的简易框架。通过这个过程,我们深入理解并实现了以下关键机制:

  • 如何使用 装饰器 + Reflect Metadata 构建声明式路由系统;
  • 如何提取装饰器元数据,生成完整的路由映射表;
  • 如何基于 Express 自动注册路由,并绑定对应的控制器方法;
  • 如何解析参数装饰器的元信息,按顺序组装方法参数,实现自动注入请求相关数据。

虽然完整的 NestJS 框架还包含依赖注入容器、中间件、异常过滤器等诸多高级特性,但控制器与路由系统正是构建整个框架的起点和基础。

这篇文章的代码可以点击这里查看,后续我们将逐步引入更丰富的功能,完善整个微型框架的架构能力。

相关推荐
亮子AI17 天前
【NestJS】为什么return不返回客户端?
前端·javascript·git·nestjs
小p18 天前
nestjs学习2:利用typescript改写express服务
nestjs
Eric_见嘉24 天前
NestJS 🧑‍🍳 厨子必修课(九):API 文档 Swagger
前端·后端·nestjs
XiaoYu20021 个月前
第3章 Nest.js拦截器
前端·ai编程·nestjs
XiaoYu20021 个月前
第2章 Nest.js入门
前端·ai编程·nestjs
实习生小黄1 个月前
NestJS 调试方案
后端·nestjs
当时只道寻常1 个月前
NestJS 如何配置环境变量
nestjs
濮水大叔2 个月前
VonaJS是如何做到文件级别精确HMR(热更新)的?
typescript·node.js·nestjs
ovensi2 个月前
告别笨重的 ELK,拥抱轻量级 PLG:NestJS 日志监控实战指南
nestjs