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

40 阅读7分钟

前言

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

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

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

控制器系统的实现原理

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

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和重复代码。那么我们需要设计一个路由注册系统,最好可以这么用:

@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. 类装饰器:接收一个函数,返回一个函数,或者不返回。
declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
  1. 属性装饰器:
declare type PropertyDecorator = (
    // 如果装饰的是实例属性:target = ClassName.prototype 
    // 如果装饰的是static属性:target = ClassName
    target: Object, 
    propertyKey: string | symbol
) => void;
  1. 方法装饰器:
declare type MethodDecorator = (
  target: Object, // 同属性装饰器
  propertyKey: string | symbol,
  descriptor: PropertyDescriptor, // 可通过改写 descriptor.value 修改原方法
) => PropertyDescriptor | void;
  1. 访问器装饰器:
declare type AccessorDecorator = (
  target: Object, // 同属性装饰器
  propertyKey: string | symbol,
  descriptor: PropertyDescriptor, //可通过改写 descriptor.set / descriptor.get 修改原方法
) => PropertyDescriptor | void;
  1. 参数装饰器:
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类型定义:

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)绑定对应的路由前缀。

import 'reflect-metadata';

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

2. Get装饰器的实现:

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

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

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 列表:
// 路由提取: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 为例,这是一段简易的服务启动代码:

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

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

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等等,并且位置是随机的。例如:

  @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 即可。

// 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!
    );
  };
};

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

const paramsMetaData = {
  "Query:0": {
    index: 0,
    data: "name",
  },
  "Req:1": {
    index: 1,
    data: undefined,
  },
  "Res:2": {
    index: 2,
    data: undefined,
  },
};

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

// 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去执行。下面这段代码是方便理解,源码中嵌套比较深但原理是一样的。

// 路由注册: 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 框架还包含依赖注入容器、中间件、异常过滤器等诸多高级特性,但控制器与路由系统正是构建整个框架的起点和基础。

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