前言
在 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)。
- 类装饰器:接收一个函数,返回一个函数,或者不返回。
declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
- 属性装饰器:
declare type PropertyDecorator = (
// 如果装饰的是实例属性:target = ClassName.prototype
// 如果装饰的是static属性:target = ClassName
target: Object,
propertyKey: string | symbol
) => void;
- 方法装饰器:
declare type MethodDecorator = (
target: Object, // 同属性装饰器
propertyKey: string | symbol,
descriptor: PropertyDescriptor, // 可通过改写 descriptor.value 修改原方法
) => PropertyDescriptor | void;
- 访问器装饰器:
declare type AccessorDecorator = (
target: Object, // 同属性装饰器
propertyKey: string | symbol,
descriptor: PropertyDescriptor, //可通过改写 descriptor.set / descriptor.get 修改原方法
) => PropertyDescriptor | void;
- 参数装饰器:
declare type ParameterDecorator = (
target: Object, // 同上,取决于装饰的是实例方法的参数还是静态方法的参数
propertyKey: string | symbol, // 装饰的方法名,如果装饰的是constructor的话会不一样
parameterIndex: number, // 参数的位置,从0开始
) => void;
注意:
@Log
与@Log('z')
的区别在于要不要传参数,后者需要return
一个函数接收target
、propertyKey
等参数。
通过装饰器,我们可以传入自定义参数,为类、方法、属性等结构添加元数据。但仅靠装饰器语法本身,无法实现统一的数据收集与跨模块访问,因为这些数据不会自动集中存储或暴露,那么就需要借助外部力量将这些数据存储起来,比如:
- 某个方法有几个参数?参数类型是什么?
- 某个类是否被打了某个装饰器?装饰器上有没有传配置?
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个部分:
@Controller()
类装饰器@Get
/@Post
等方法装饰器Router
对象@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
)绑定对应的method
和 url
。
@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' }));
}
这里可以利用参数装饰器提供的索引来记住需要传入的参数和位置,分两步:
- 记住被装饰参数的索引,装饰类型,和传入的参数(如果有的话)
- 在注册路由时,生成所需要的参数传入
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 框架还包含依赖注入容器、中间件、异常过滤器等诸多高级特性,但控制器与路由系统正是构建整个框架的起点和基础。
这篇文章的代码可以点击这里查看,后续我们将逐步引入更丰富的功能,完善整个微型框架的架构能力。