个人比较喜欢规范化的开发。从自己封装装饰器+IoC 的轻量框架,到业务复杂化后被迫寻求开源方案——回头再看,Node.js 的项目最后还是要回归大型框架。本文记录这段历程和反思。
一、自研框架的起点
我一直对代码的规范化有执念。用 Express 写接口时,路由声明和业务逻辑混在一起,TypeScript 的装饰器语法让我看到了另一种可能:
// 我一直期望的代码风格
@Controller('/users')
class UserController {
@Inject()
private userService: UserService;
@Get('/:id')
async getUser(@Param('id') id: string) {
return this.userService.findById(id);
}
@Post('/')
async createUser(@Body() dto: CreateUserDto) {
return this.userService.create(dto);
}
}
受 Java Spring 和 Angular 的启发,我开始动手实现自己的框架。核心三件套:
装饰器路由
const ROUTE_METADATA = Symbol('route');
export function Controller(path: string): ClassDecorator {
return (target) => {
Reflect.defineMetadata(ROUTE_METADATA, { path }, target);
};
}
export function Get(path: string): MethodDecorator {
return (target, key, descriptor) => {
const routes = Reflect.getMetadata(ROUTE_METADATA, target.constructor) || [];
routes.push({ method: 'get', path, handler: key });
Reflect.defineMetadata(ROUTE_METADATA, routes, target.constructor);
};
}
依赖注入容器
class Container {
private providers = new Map<string, any>();
register<T>(token: string, provider: new () => T): void {
this.providers.set(token, new provider());
}
resolve<T>(token: string): T {
return this.providers.get(token);
}
}
路由注册
function bootstrap(controllerClasses: any[]) {
const app = express();
for (const ControllerClass of controllerClasses) {
const prefix = Reflect.getMetadata(ROUTE_METADATA, ControllerClass).path;
const routes = Reflect.getMetadata(ROUTE_METADATA, ControllerClass.prototype) || [];
for (const route of routes) {
app[route.method](`${prefix}${route.path}`, async (req, res) => {
const instance = container.resolve(ControllerClass.name);
const result = await instance[route.handler](req);
res.json(result);
});
}
}
app.listen(3000);
}
小项目里,这套东西工作得很好。新增接口就是写一个 Controller 类,加几个装饰器,框架自动完成路由注册和依赖注入。
二、业务增长的瓶颈
随着项目规模扩大,需求从「完成功能」变成了「保障质量」:
参数校验。参考 class-validator 的装饰器校验思路,写了自定义校验模块。但很快发现需要国际化错误消息、嵌套校验、数组校验——代码量开始失控。
中间件体系。需要灵活的守卫机制(认证/授权),自定义了 @UseGuards() 装饰器。
ORM 集成。Service 层需要注入 Repository,又实现了一套 @InjectRepository() 装饰器。
单元测试。每个 Controller 都需要 mock Container 和 Service,测试代码比业务代码还长。
问题逐渐显现:框架从 500 行膨胀到 5000+ 行;没有社区验证的边界 case 在生产环境暴露;文档永远跟不上;安全漏洞靠自己排查。
三、Node.js 主流框架对比
Express
最底层的 HTTP 框架。优点是简单、自由。缺点是过于自由——团队超过 3 个人时,每个人都有自己的一套文件结构和错误处理方式。
Koa
在 Express 基础上优化了异步控制和中间件洋葱模型。但依然不提供开箱即用的结构——路由、请求体解析、参数校验都要自己选库组合。
Nest.js
目光转向 Nest.js 时,我几乎笑出来——它长得和我自研的框架一模一样:
@Controller('users')
export class UsersController {
constructor(private readonly userService: UserService) {}
@Get(':id')
findOne(@Param('id') id: string) {
return this.userService.findOne(id);
}
}
但差别在于:
- Pipe:内建参数校验和类型转换,
@Body(new ValidationPipe()) dto一行搞定 - Guard:认证/授权中间件,声明式使用,可全局或路由级
- Interceptor:统一的响应格式、日志记录、缓存
- Filter:全应用统一的异常处理
- Module 系统:按功能拆分模块,清晰的依赖边界
- Swagger 集成:
@nestjs/swagger自动生成 API 文档
我花了一年时间在自研框架上实现的功能,Nest.js 已经做了六年。
Fastify
对延迟敏感的场景,Fastify 是高性能选择。内置 JSON Schema 校验,但生态规模不如 Nest.js。
| 维度 | Express | Koa | Nest.js | Fastify |
|---|---|---|---|---|
| 学习曲线 | 低 | 低 | 中高 | 中 |
| 项目结构 | 自由 | 自由 | 强约束(Module) | 插件约定 |
| 依赖注入 | 无 | 无 | 内建 | 可选 |
| TypeScript | 手动配置 | 手动配置 | 一等公民 | 良好 |
| 适合规模 | 小 | 中小 | 中大型 | 中小 |
四、为什么最终回归框架
社区生态碾压个人能力。class-validator 有六个维护者处理了 Unicode 边界 case 和循环引用检测——一个人绝不可能做到同等级别的覆盖度。
团队协作需要共同语言。新人加入团队,面对自研框架每个决策都要来问作者。换用 Nest.js 后,花一周看文档就能上手。
造轮子的时机判断。该造:学习原理,或现有方案无法满足性能需求。不该造:只是觉得现有方案「写法不够优雅」,而它已有成熟的社区和数百万用户。
五、自研框架的长期价值
虽最终回归开源框架,但这一年的自研经历并不浪费:
理解原理。亲手实现过路由匹配、中间件洋葱模型和 DI 容器后,再去看 Nest.js 源码,看到的不是「魔法」,而是可以逐行理解的工程实现。
成为更好的框架使用者。很多人用框架是「黑盒调用」——复制文档代码,遇到问题不知从何排查。亲手造过框架的人会去看源码、issue、PR 讨论,因为你知道每个抽象层下面是什么。
适用场景。自研框架在今天仍有合理场景:教学目的、极简微服务(少于 5 个端点)、对性能有极致追求且现有框架无法满足时。但在团队协作的生产项目中,拥抱开源是更理性的选择。
总结
从 Express 到自研 IoC 框架,再到 Nest.js——这是很多 Node.js 开发者的必经之路。写框架和用框架学到的是两回事,前者教你「为什么这么设计」,后者教你「怎么大规模协作」。
如果你也在自研框架和开源方案之间纠结,我的建议是:造过一个,你就会知道什么时候不该造。