Skip to main content

This page has not been translated yet.

TypeScript 實戰:後端裝飾器生成路由

7 min read

cover

前言

在這篇文章中,我們將介紹如何使用 TypeScript 的裝飾器來生成 Express 路由,從而讓程式碼更加簡潔。過去,由於 TypeScript 的裝飾器功能尚未完善,我們通常需要使用 reflect-metadata 這樣的套件來擴展裝飾器對 metadata 的支援。然而,隨著 TypeScript 5.2 的推出,裝飾器已經原生支援 metadata,因此不需要再依賴外部套件。

不過,請注意,目前 JavaScript 還沒有完全支援 Symbol.metadata,所以在 TypeScript 中使用這些功能時仍然需要一些注意。建議在等 JavaScript 完全支援後再使用這些功能到正式的產品環境中。

若在使用裝飾器的 metadata 時遇到以下錯誤: metadata not found

你需要在 tsconfig 中加入如下配置,以支援最新的裝飾器功能:

{
  "compilerOptions": {
    "lib": ["ESNext.Decorators"]
  }
}

實作裝飾器

實作 Controller 的裝飾器

我們首先實作一個 Controller 裝飾器,來指定每個 Controller 的基礎路徑(basePath),並儲存在 metadata 中供之後的路由註冊使用。

enum MetadataKeys {
  BASE_PATH = 'basePath',
  ROUTERS = 'routers'
}
 
export function Controller(basePath: string) {
  return (_: new (...args: any[]) => {}, ctx: ClassDecoratorContext) => {
    ctx.metadata[MetadataKeys.BASE_PATH] = basePath
  }
}

實作 Method 的裝飾器

接下來,我們將為常見的 HTTP 方法(如:GET、POST、PUT、DELETE)實作對應的裝飾器,這些裝飾器會將每個路由的資訊存入 metadata,以便後續自動生成 Express 路由。

我們首先定義常見的 HTTP 方法:

enum Method {
  GET = 'get',
  POST = 'post',
  PUT = 'put',
  DELETE = 'delete',
}

接著,我們使用 zod 套件來定義路由配置的資料結構,確保所有保存至 metadata 中的資料符合預期的格式。

import { z } from 'zod'
 
// ...
 
const routerConfigSchema = z.object({
  method: z.enum([Method.GET, Method.POST, Method.PUT, Method.DELETE]),
  path: z.string(),
  handlerName: z.string().or(z.symbol()),
})
 
const metadataSchema = z.object({
  [MetadataKeys.BASE_PATH]: z.string(),
  [MetadataKeys.ROUTERS]: z.array(routerConfigSchema),
})

為了在註冊路由前驗證 metadata,我們可以定義一個 validateMetadata 函式來解析目標類別的 metadata,並使用 zod 進行資料驗證。

export function validateMetadata<T extends new (...args: any[]) => {}>(target: T) {
  return metadataSchema.parse(target[Symbol.metadata])
}

接下來,我們實作一個工廠函式的 methodDecoratorFactory,透過此函式生成不同 HTTP 方法對應的裝飾器。這些裝飾器會將方法、路徑和處理函數名稱一併儲存到 metadata 中。

function methodDecoratorFactory(method: Method) {
  return (path: string = '') => {
    return (_: Function, ctx: ClassMethodDecoratorContext) => {
      const metadataRouters = ctx.metadata[MetadataKeys.ROUTERS]
      const routers: z.infer<typeof routerConfigSchema>[] = Array.isArray(metadataRouters)
        ? metadataRouters
        : []
 
      routers.push({
        method,
        path,
        handlerName: ctx.name,
      })
 
      ctx.metadata[MetadataKeys.ROUTERS] = routers
    }
  }
}

最後,我們使用 methodDecoratorFactory 生成對應的 HTTP 方法裝飾器:

export const Get = methodDecoratorFactory(Method.GET)
export const Post = methodDecoratorFactory(Method.POST)
export const Put = methodDecoratorFactory(Method.PUT)
export const Delete = methodDecoratorFactory(Method.DELETE)

重構 Controller

接下來,我們將實際使用這些裝飾器,來重構 Controller。以往,我們使用函式式的方式來撰寫路由定義,現在可以將它重構為類別,並使用裝飾器。

import { NextFunction, Request, Response } from 'express'
import TaskModel, { TaskType } from '../models/task.model'
import { Controller, Delete, Get, Post, Put } from '../utils/decorators/controller.decorator'
 
@Controller('/task')
export default class TaskController {
  private taskModel = new TaskModel()
 
  @Get()
  async getAllTask(req: Request, res: Response<TaskType[]>, next: NextFunction) {
    // ...
  }
 
  @Get('/:id')
  async getTaskById(
    req: Request<{ id: string }>,
    res: Response<TaskType | null>,
    next: NextFunction
  ) {
    // ...
  }
 
  @Post()
  async createTask(
    req: Request<{}, {}, TaskType>,
    res: Response<{ id: string }>,
    next: NextFunction
  ) {
    // ...
  }
 
  @Put('/:id')
  async updateTask(
    req: Request<{ id: string }, {}, TaskType>,
    res: Response,
    next: NextFunction
  ) {
    // ...
  }
 
  @Delete('/:id')
  async deleteTask(req: Request<{ id: string }>, res: Response, next: NextFunction) {
    // ...
  }
}

收攏 Controller

接著我們將所有 Controller 都引入到 src/controllers/index.ts。

import TaskController from './task.controller'
 
export const controllers = [TaskController]

未來我們新增新的 Controller 後,只需將它們加入 controllers 陣列,就會自動生成對應的 Router。

重構 Routes

我們可以刪除之前手動撰寫的 Routes,並在主程式 main.ts 中動態註冊 Controller 的路由。

以下是在 main.ts 中的註冊方式:

import express, { NextFunction, Request, Response, Router } from 'express'
import { ZodError } from 'zod'
import { controllers } from './controllers'
import { validateMetadata } from './utils/decorators/controller.decorator'
 
// ...
class Server {
  private app = express()
 
  private registerRoutes() {
    controllers.forEach((Controller) => {
      const controller = new Controller()
      const { basePath, routers } = validateMetadata(Controller)
      const router = Router()
 
      routers.forEach(({ method, path, handlerName }) => {
        router[method](
          path,
          controller[handlerName as keyof typeof controller].bind(controller)
        )
      })
 
      this.app.use(basePath, router)
    })
  }
 
  private errorMiddleware() {
    // ...
  }
 
  start() {
    this.app.use(express.json())
 
    this.registerRoutes()
 
    this.errorMiddleware()
 
    this.app.listen(port, () => {
      console.log(`Server is running on port ${port}`)
    })
  }
}

這樣一來,我們就可以動態註冊所有 Controller 的路由,並在主程式中統一處理錯誤,使程式碼更加模組化。

添加 Polyfill

如果當初設定 tsconfig 有設定 ESNext.Decorators 的話,如果打包成 JavaScript 會因為沒有 Symbol.metadata 而導致伺服器無法啟動,這時我們要自己寫 Polyfill 來支援 Symbol.metadata 如下:

(Symbol as { metadata: symbol }).metadata ??= Symbol('Symbol.metadata')

解法來源

並在有實作 metadata 的裝飾器檔案引入這個 polyfill 檔案

// ./controller.decorator.ts 檔案
 
import './symbol.polyfill'

總結

在這篇文章中,我們使用了 TypeScript 的裝飾器來自動生成路由,大幅簡化了手動設定路由的繁瑣步驟。通過使用裝飾器,我們可以讓每個 Controller 的定義更加清晰,未來想新增 API 就只要新增對應的 Model 和 Controller,Router 會自動根據裝飾器生成,那這系列後端實戰的部分就到這裡,下一篇會介紹如何在前端 React 中使用 TypeScript,並串接我們實作的後端!

本篇程式碼變更可以看此 PR

Related Posts

Share this post
Johnson Mao

Johnson Mao

A passionate software engineer and blogger sharing thoughts on technology, AI, and development.

Comments