Dark Dwarf Blog background

使用装饰器模式设计一个 Web 框架

使用装饰器模式设计一个 Web 框架

在讲解了装饰器模式后,下面我们基于这个模式来设计一个简单的 Web 框架。框架基于 Exoress、加入了装饰器模式的设计。

之所以选择 Web 框架是因为问 AI 问题的时候它举了这个的例子,因此就试着写了一个简单的。

1. 总体设计

基于装饰器模式的核心思想是使用装饰器将核心逻辑托管给元数据,然后在最终的应用层中系统地处理这些元数据、从而实现 Web 逻辑。

下面是 Application Core 的设计、装饰器模块的设计以及 Web Frame 总体的工作流:

Framework Core

HTTP Request

Global Middleware

Controller @Use Middleware

Route @Use Middleware

@UsePipes Validation

Route Handler

HTTP Response

Application Class

DI Container

Controller Registry

Route Registration

Middleware Chain

Parameter Resolution

Pipe Execution


Feature Decorators

Parameter Decorators

HTTP Method Decorators

Controller Decorators

@Controller prefix

Route Registration

@Get path

@Post path

@Put path

@Delete path

@Body

@Query name

@Param name

@Use Middleware

@UsePipes Validation

@Injectable DI


DI ServicesValidation PipesRoute HandlerController ClassMiddleware ChainApplicationClientDI ServicesValidation PipesRoute HandlerController ClassMiddleware ChainApplicationClientHTTP RequestApply Global MiddlewareApply @Use() MiddlewareApply Route @Use() MiddlewareApply @UsePipes() ValidationValidate & Transform DataInject DependenciesExecute Business LogicReturn ResponseHTTP Response

下面我们开始搭建这个 Web Frame。首先是装饰器模块。

2. 装饰器模块

a.a. 常量定义

在开始实现具体装饰器逻辑前,我们先把存储元数据使用的键和类型定义好:

export const METADATA_KEYS = {
  CONTROLLER_PREFIX: "controller:prefix",
  ROUTE_METHOD: "route:method",
  ROUTE_PATH: "route:path",
  PARAM_TYPES: "design:paramtypes",
  PARAM_BODY: "param:body",
  PARAM_QUERY: "param:query",
  PARAM_PARAM: "param:param",
  MIDDLEWARE: "middleware",
  PIPES: "pipes",
} as const;

export type MetadataKeyType =
  (typeof METADATA_KEYS)[keyof typeof METADATA_KEYS];

export type HttpMethod = "get" | "post" | "put" | "delete";

export interface RouteDefinition {
  method: HttpMethod;
  path: string;
  handlerName: string;
}

export interface ParameterDefinition {
  index: number;
  type: "body" | "query" | "param";
  name?: string;
}

管道相关类型我们放到管道装饰器部分详细讲解。

这里使用 as const 方法来实现枚举,原因在”TypeScript as const”笔记用有详细阐释。

然后再提前写好一些操作元数据的一些帮助方法:

import "reflect-metadata";
import { MetadataKeyType, METADATA_KEYS } from "../decorators/constants";

/**
 * Get metadata with type-safe key access
 */
export function getMetadata<T>(
  key: MetadataKeyType,
  target: any,
  propertyKey?: string | symbol,
): T | undefined {
  return Reflect.getMetadata(key, target, propertyKey as string | symbol);
}

/**
 * Define metadata with type-safe key access
 */
export function defineMetadata<T>(
  key: MetadataKeyType,
  value: T,
  target: any,
  propertyKey?: string | symbol,
): void {
  Reflect.defineMetadata(key, value, target, propertyKey as string | symbol);
}

/**
 * Check if metadata exists with type-safe key access
 */
export function hasMetadata(
  key: MetadataKeyType,
  target: any,
  propertyKey?: string | symbol,
): boolean {
  return Reflect.hasMetadata(key, target, propertyKey as string | symbol);
}

/**
 * Get metadata with default value - accepts both MetadataKeyType and string values
 */
export function getMetadataOrDefault<T>(
  key: MetadataKeyType | string,
  target: any,
  defaultValue: T,
  propertyKey?: string | symbol,
): T {
  return (
    Reflect.getMetadata(key, target, propertyKey as string | symbol) ??
    defaultValue
  );
}

/**
 * Define metadata - accepts both MetadataKeyType and string values
 */
export function defineMetadataValue<T>(
  key: MetadataKeyType | string,
  value: T,
  target: any,
  propertyKey?: string | symbol,
): void {
  Reflect.defineMetadata(key, value, target, propertyKey as string | symbol);
}

b.b. Controller

Controller 负责处理路由前缀,我们把路由前缀信息存储到对应 Metadata 的 key 中。这是一个类装饰器:

export function Controller(prefix: string = ""): ClassDecorator {
  return function (target: any) {
    defineMetadataValue(METADATA_KEYS.CONTROLLER_PREFIX, prefix, target);
  };
}

c.c. HTTP 方法装饰器

同样地,我们将对应路由的信息存储到 Metadata 中。这里我们可以写一个装饰器工厂,然后生产出不同 HTTP 方法装饰器:

import "reflect-metadata";
import { HttpMethod, RouteDefinition, METADATA_KEYS } from "./constants";
import { getMetadataOrDefault, defineMetadataValue } from "../utils/metadata";

function createHttpMethodDecorator(method: HttpMethod) {
  return function (path: string = ""): MethodDecorator {
    return function (
      target: any,
      propertyKey: string | symbol,
      descriptor: PropertyDescriptor,
    ) {
      const existingRoutes = getMetadataOrDefault<RouteDefinition[]>(
        METADATA_KEYS.ROUTE_METHOD,
        target.constructor,
        [],
      );

      existingRoutes.push({
        method,
        path,
        handlerName: propertyKey.toString(),
      });

      defineMetadataValue(
        METADATA_KEYS.ROUTE_METHOD,
        existingRoutes,
        target.constructor,
      );
    };
  };
}

export const Get = createHttpMethodDecorator("get");

这里有需要注意的地方:我们要将这个 Metadata 记录到 target.constructor 上。我们举一个具体的例子说明这件事。假设我们的框架看到了下面的东西:

@Controller("/users")
class UserController {
  @Get("/")
  getAllUsers() {
    /* ... */
  }

  @Get("/:id")
  getUserById() {
    /* ... */
  }

  @Post("/")
  createUser() {
    /* ... */
  }
}

我们希望 UserController 最后得到一个这样的路由清单:

[
  { "method": "get", "path": "/", "handlerName": "getAllUsers" },
  { "method": "get", "path": "/:id", "handlerName": "getUserById" },
  { "method": "post", "path": "/", "handlerName": "createUser" }
]

如果我们把 @Get 的信息记录在 target 上,也就是 getUserById 等方法上,我们的框架在处理 UserController 时必须一个一个进入这些方法才能获取这些信息。这非常繁琐。但是如果我们把这些信息记录在 target.constructor,也就是 UserController 的构造函数上,框架就只需要看这一个地方,就可以获得所有信息了。

方法装饰器的元数据附加目标一般都是 target.constructor

d.d. 参数装饰器 & 中间件装饰器

参数装饰器和中间件装饰器的实现方法也一样,对于中间件我们同时考虑控制器中间件和方法中间件:

export function Param(name: string): ParameterDecorator {
  return function (
    target: any,
    propertyKey: string | symbol | undefined,
    parameterIndex: number,
  ) {
    const existingParameters = getMetadataOrDefault<ParameterDefinition[]>(
      METADATA_KEYS.PARAM_PARAM,
      target,
      [],
      propertyKey as string,
    );

    existingParameters.push({
      index: parameterIndex,
      type: "param" as const,
      name,
    });

    defineMetadataValue(
      METADATA_KEYS.PARAM_PARAM,
      existingParameters,
      target,
      propertyKey as string,
    );
  };
}
export function Use(
  middleware: RequestHandler | RequestHandler[],
): ClassDecorator & MethodDecorator {
  return function (
    target: any,
    propertyKey?: string | symbol,
    descriptor?: PropertyDescriptor,
  ) {
    // If propertyKey is undefined, this is a class decorator
    if (propertyKey === undefined) {
      // Class-level middleware
      const existingMiddleware = getMetadataOrDefault<RequestHandler[]>(
        METADATA_KEYS.MIDDLEWARE,
        target,
        [],
      );
      const middlewareArray = Array.isArray(middleware)
        ? middleware
        : [middleware];
      const combinedMiddleware = [...existingMiddleware, ...middlewareArray];
      defineMetadataValue(METADATA_KEYS.MIDDLEWARE, combinedMiddleware, target);
    } else {
      // Method-level middleware
      const existingMiddleware = getMetadataOrDefault<RequestHandler[]>(
        METADATA_KEYS.MIDDLEWARE,
        target.constructor,
        [],
        propertyKey.toString(),
      );
      const middlewareArray = Array.isArray(middleware)
        ? middleware
        : [middleware];
      const combinedMiddleware = [...existingMiddleware, ...middlewareArray];
      defineMetadataValue(
        METADATA_KEYS.MIDDLEWARE,
        combinedMiddleware,
        target.constructor,
        propertyKey.toString(),
      );
    }
  };
}

e.e. 管道装饰器

i.i. 数据类型定义

一个管道装饰器应该接收两个参数:

  1. value:传入的需要管道处理的值。
  2. metadata:可选元数据,这里是 metatype,也就是类本身。有了这个东西,我们可以做一些类上的操作。

然后返回处理后的 value,也就是 any

export interface PipeTransform {
  transform(value: any, metadata: { metatype?: any }): any;
}

ii.ii. 装饰器实现

管道装饰器是方法装饰器,我们把管道元数据记录到 target.constructor 上:

export function UsePipes(
  pipe: PipeTransform | PipeTransform[],
): MethodDecorator {
  return function (
    target: any,
    propertyKey: string | symbol,
    descriptor: PropertyDescriptor,
  ) {
    const existingPipes = getMetadataOrDefault<PipeTransform[]>(
      METADATA_KEYS.PIPES,
      target.constructor,
      [],
      propertyKey.toString(),
    );
    const pipeArray = Array.isArray(pipe) ? pipe : [pipe];
    const combinedPipes = [...existingPipes, ...pipeArray];
    defineMetadataValue(
      METADATA_KEYS.PIPES,
      combinedPipes,
      target.constructor,
      propertyKey.toString(),
    );
  };
}

iii.iii. 数据验证管道组件

下面利用定义的接口、实现一个简单的数据验证管道。

在数据验证管道中,我们可以使用 class-validatorclass-transformer 来操作 metatype 的类,具体流程如下:

  1. 将我们传入的 valuemetatype 组合成类的实例。
  2. class-validatorvalidate 方法进行 validation。这个过程是异步的。
  3. 返回结果。
import { validate, ValidationError } from "class-validator";
import { plainToInstance } from "class-transformer";
import { PipeTransform } from "../decorators/constants";

export class ValidationPipe implements PipeTransform {
  async transform(value: any, metadata: { metatype?: any }): Promise<any> {
    if (!metadata.metatype || !this.toValidate(metadata.metatype)) {
      return value;
    }

    const object = plainToInstance(metadata.metatype, value);
    const errors = await validate(object);

    if (errors.length > 0) {
      const errorMessages = this.formatErrors(errors);
      const validationError = new Error("Validation failed");
      validationError.name = "ValidationError";
      (validationError as any).details = errorMessages;
      (validationError as any).status = 400;
      throw validationError;
    }

    return object;
  }
}

3. 依赖注入

实现了基本的装饰器组件后,我们再看看该怎么用装饰器完成依赖注入。

a.a. 概念与实现原理

首先我们看看什么是依赖注入。假如我们需要给 Controller 传入 Service,一般的做法如下:

// 传统方式:手动创建依赖
class UserController {
  private userService: UserService;

  constructor() {
    // ❌ 硬编码依赖,紧耦合
    this.userService = new UserService();
    // 如果 UserService 还需要其他依赖,还需要在这里创建
  }

  getUsers() {
    return this.userService.findAll();
  }
}

而有了依赖注入组件后,只要把 Controller 装饰成 @Injectable(),我们的框架就可以自动处理这些依赖:

@Injectable()
class UserController {
  constructor(private readonly userService: UserService) {}
  // ✅ 依赖通过构造函数自动注入,无需手动创建
}

我们的 @Injectable() 装饰器并不使用什么具体的逻辑,只是标记一下这个 Controller 需要执行依赖注入。

然后,在执行依赖注入的时候,我们可以直接读取 TypeScript 记录好的依赖元数据。这个元数据放在 'design:paramtypes' 中。例如对于 UserController,它的依赖元数据就是 UserService。有了这些依赖记录后,我们一个一个实例化这些依赖即可。

b.b. 具体实现

首先我们实现 Injectable 装饰器,这个装饰器什么都不需要做,直接返回 constructor 即可:

export type ConstructorType<T = {}> = new (...args: any[]) => T;

然后,根据前面所说,我们读取元数据中记录的依赖,并递归实例化。我们使用一个 Container.instance 单例来管理依赖,然后把解析后的实例都存放在 instances 中:

export class Container {
  private static readonly instance = new Container();
  private readonly instances = new Map<any, any>();

  private constructor() {}

  public static getInstance(): Container {
    return Container.instance;
  }

  public resolve<T>(target: ConstructorType<T>): T {
    // Check if instance already exists (Singleton pattern)
    if (this.instances.has(target)) {
      return this.instances.get(target);
    }

    // Get constructor parameter types using reflect-metadata
    const paramTypes = Reflect.getMetadata("design:paramtypes", target) || [];

    // Resolve dependencies recursively
    const resolvedDependencies = paramTypes.map((paramType: any) => {
      return this.resolve(paramType);
    });

    // Create new instance with resolved dependencies
    const instance = new target(...resolvedDependencies);

    // Store the instance
    this.instances.set(target, instance);

    return instance;
  }
}

4. Application 应用层

a.a. 实现原理

最后我们要封装我们前面实现的这些装饰器,我们读取装饰器附加在类或方法上的元数据,把它们转化成 express 框架可以理解的真实路由与逻辑。总体流程如下:

  1. 读取路由定义:消费 @Controller@http-method
  2. 解析和注入参数:消费 @Param(还有 @Body@Query
  3. 应用管道:消费 @UsePipes
  4. 执行控制器方法并得到响应。

b.b. 路由读取

首先我们读取 @Controller 类注释器的 prefix 和 @http-method 的 path,得到路由路径,然后从 DI Container 获取 Controller 实例,把它注册到 express route.method 上:

private registerController(ControllerClass: ConstructorType<any>): void {
  const controllerInstance = ContainerInstance.resolve<any>(ControllerClass);
  const prefix = getMetadataOrDefault<string>(METADATA_KEYS.CONTROLLER_PREFIX, ControllerClass, '');
  const routes = getMetadataOrDefault<RouteDefinition[]>(METADATA_KEYS.ROUTE_METHOD, ControllerClass, []);
  const controllerMiddleware = getMetadataOrDefault<RequestHandler[]>(METADATA_KEYS.MIDDLEWARE, ControllerClass, []);

  routes.forEach((route: RouteDefinition) => {
    const fullPath = prefix + route.path;
    const handler = controllerInstance[route.handlerName];
    const routeMiddleware = getMetadataOrDefault<RequestHandler[]>(METADATA_KEYS.MIDDLEWARE, ControllerClass, [], route.handlerName);

    if (typeof handler === 'function') {
      const allMiddleware = [...controllerMiddleware, ...routeMiddleware];
      this.app[route.method](fullPath, ...allMiddleware, this.createRouteHandler(handler, controllerInstance));
    }
  });
}

Controller 到 express handler 的转换可以使用异步节约转换时间,这个过程需要参数解析,具体过程后面讲解:

private createRouteHandler(handler: Function, controllerInstance: any) {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      const args = await this.resolveParameters(handler, controllerInstance, req);
      const result = await handler.apply(controllerInstance, args);

      if (result !== undefined && result !== null) {
        res.json(result);
      } else {
        res.status(204).send();
      }
    } catch (error) {
      next(error);
    }
  };
}

c.c. 参数解析

然后我们使用 @Param 与管道装饰器,读取参数并使用管道处理,返回最终的参数。参数用一个 Array 存着:

// Process body parameters
for (const param of bodyParams) {
  let value = req.body;

  // Apply pipes to body parameters
  for (const pipe of pipes) {
    if (pipe && typeof pipe.transform === "function") {
      value = await pipe.transform(value, {
        metatype: paramTypes[param.index],
      });
    }
  }

  args[param.index] = value;
}
// Process query parameters
for (const param of queryParams) {
  let value = param.name ? req.query[param.name] : req.query;

  // Apply pipes to query parameters
  for (const pipe of pipes) {
    if (pipe && typeof pipe.transform === "function") {
      value = await pipe.transform(value, {
        metatype: paramTypes[param.index],
      });
    }
  }

  args[param.index] = value;
}
// Process route parameters
for (const param of routeParams) {
  let value = param.name ? req.params[param.name] : undefined;

  // Apply pipes to route parameters
  for (const pipe of pipes) {
    if (pipe && typeof pipe.transform === "function") {
      value = await pipe.transform(value, {
        metatype: paramTypes[param.index],
      });
    }
  }

  args[param.index] = value;
}

return args;

d.d. 中间件等其他配置

其他的 Application 配置直接使用 express 的即可:

public use(...args: any[]): void {
  this.app.use(...args);
}

public listen(port: number, callback?: () => void): void {
  this.app.listen(port, callback);
}

public getApp(): Express {
  return this.app;
}

5. 使用 Application

现在我们看看如何具体使用我们的 Application 单例!

a.a. 数据类型定义与管道处理

我们可以给数据类型加上 class-validator 的 validate 装饰器,我们前面的数据验证管道可以把这些装饰器用于数据验证:

import {
  IsString,
  IsEmail,
  IsOptional,
  IsInt,
  Min,
  Max,
  Length,
} from "class-validator";

export class CreateUserDto {
  @IsString()
  @Length(2, 50, { message: "Name must be between 2 and 50 characters" })
  name!: string;

  @IsEmail({}, { message: "Please provide a valid email address" })
  email!: string;

  @IsInt({ message: "Age must be an integer" })
  @Min(1, { message: "Age must be at least 1" })
  @Max(120, { message: "Age must not exceed 120" })
  age!: number;
}

export class UpdateUserDto {
  @IsOptional()
  @IsString()
  @Length(2, 50, { message: "Name must be between 2 and 50 characters" })
  name?: string;

  @IsOptional()
  @IsEmail({}, { message: "Please provide a valid email address" })
  email?: string;

  @IsOptional()
  @IsInt({ message: "Age must be an integer" })
  @Min(1, { message: "Age must be at least 1" })
  @Max(120, { message: "Age must not exceed 120" })
  age?: number;
}

export interface User {
  id: number;
  name: string;
  email: string;
  age: number;
  createdAt: Date;
}

b.b. 给 Controller 注入 Service 依赖

我们先实现下面这个简单的 UserService

import { Injectable } from "../../src/decorators/injectable";
import { CreateUserDto, User } from "../dtos/user.dto";

@Injectable()
export class UserService {
  private users: User[] = [
    {
      id: 1,
      name: "John Doe",
      email: "john@example.com",
      age: 30,
      createdAt: new Date(),
    },
    {
      id: 2,
      name: "Jane Smith",
      email: "jane@example.com",
      age: 25,
      createdAt: new Date(),
    },
  ];

  public findAll(page?: number, limit?: number): User[] {
    const pageNum = page ? parseInt(page.toString()) : 1;
    const limitNum = limit ? parseInt(limit.toString()) : 10;
    const startIndex = (pageNum - 1) * limitNum;

    return this.users.slice(startIndex, startIndex + limitNum);
  }

  public findById(id: string): User | undefined {
    const userId = parseInt(id);
    return this.users.find((user) => user.id === userId);
  }

  public create(createUserDto: CreateUserDto): User {
    if (!createUserDto.name || !createUserDto.email) {
      throw new Error("Name and email are required");
    }

    const newUser: User = {
      id: Math.max(...this.users.map((u) => u.id)) + 1,
      name: createUserDto.name,
      email: createUserDto.email,
      age: createUserDto.age || 18,
      createdAt: new Date(),
    };

    this.users.push(newUser);
    return newUser;
  }

  public update(
    id: string,
    updateDto: Partial<CreateUserDto>,
  ): User | undefined {
    const userId = parseInt(id);
    const userIndex = this.users.findIndex((user) => user.id === userId);

    if (userIndex === -1) {
      throw new Error("User not found");
    }

    this.users[userIndex] = {
      ...this.users[userIndex],
      ...updateDto,
    };

    return this.users[userIndex];
  }

  public delete(id: string): { message: string } {
    const userId = parseInt(id);
    const userIndex = this.users.findIndex((user) => user.id === userId);

    if (userIndex === -1) {
      throw new Error("User not found");
    }

    this.users.splice(userIndex, 1);
    return { message: "User deleted successfully" };
  }
}

这个 Service 是可依赖注入的,直接传给 Controller 即可。然后我们使用前面的装饰器声明路由及其参数等信息:

import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Query,
  Param,
  Use,
  UsePipes,
} from "../../src/decorators";
import { Injectable } from "../../src/decorators/injectable";
import { CreateUserDto, User, UpdateUserDto } from "../dtos/user.dto";
import { UserService } from "../services/user.service";
import { ValidationPipe } from "../../src/pipes/validation.pipe";
import { LoggerMiddleware } from "../middleware/logger.middleware";

@Injectable()
@Controller("/api/users")
@Use(LoggerMiddleware)
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Get()
  public getUsers(
    @Query("page") page?: number,
    @Query("limit") limit?: number,
  ): User[] {
    return this.userService.findAll(page, limit);
  }

  @Get("/:id")
  public getUserById(@Param("id") id: string): User | undefined {
    return this.userService.findById(id);
  }

  @Post()
  @UsePipes(new ValidationPipe())
  public createUser(@Body() createUserDto: CreateUserDto): User {
    return this.userService.create(createUserDto);
  }

  @Put("/:id")
  @UsePipes(new ValidationPipe())
  public updateUser(
    @Param("id") id: string,
    @Body() updateDto: UpdateUserDto,
  ): User | undefined {
    return this.userService.update(id, updateDto);
  }

  @Delete("/:id")
  public deleteUser(@Param("id") id: string): { message: string } {
    return this.userService.delete(id);
  }
}

c.c. 返回最终的 Application

import "reflect-metadata";
import express from "express";
import { Application } from "../src/core/Application";
import { UserController } from "./controllers/user.controller";

const app = new Application();

// Set up global middleware using the new flexible approach
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Register controllers
app.registerControllers([UserController]);

const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;

app.listen(PORT, () => {
  console.log(
    `TypeScript Web Framework Server is running on http://localhost:${PORT}`,
  );
});

export default app;