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

前言
在這篇文章中,我們將介紹如何使用 TypeScript 的裝飾器來生成 Express 路由,從而讓程式碼更加簡潔。過去,由於 TypeScript 的裝飾器功能尚未完善,我們通常需要使用 reflect-metadata 這樣的套件來擴展裝飾器對 metadata 的支援。然而,隨著 TypeScript 5.2 的推出,裝飾器已經原生支援 metadata,因此不需要再依賴外部套件。
不過,請注意,目前 JavaScript 還沒有完全支援 Symbol.metadata,所以在 TypeScript 中使用這些功能時仍然需要一些注意。建議在等 JavaScript 完全支援後再使用這些功能到正式的產品環境中。
若在使用裝飾器的 metadata 時遇到以下錯誤:

你需要在 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
TypeScript 後記
終於完成連續三十天的鐵人賽文章撰寫!距離我第一次參加鐵人賽已經三年之久了。這次參加主要是因為朋友邀請一起參賽,同時我自己也想藉此機會深入了解 TypeScript 5 的新功能,強迫自己透過寫作來吸收知識。
TypeScript 實戰:製作 npm Package
在開發專案時,經常會遇到跨專案共用邏輯或工具的情況,例如常用的 logger 或驗證工具。如果每次都從一個專案複製貼上到另一個專案,難免會出現版本不同步或維護困難的問題。為了解決這類問題,我們可以將這些共用邏輯獨立成為 npm 套件,以便在多個專案中重複使用。
TypeScript 實戰:前端表單與串接 API
在現代前端開發中,與後端進行資料交換是常見的需求。透過 HTTP 請求,我們可以將資料送出並取得結果。這篇文章將介紹如何使用 TypeScript 實作前端表單與串接 API,並展示如何將這些功能結合到 React 應用中。同時,文章還會介紹如何解決跨域問題(CORS),以便讓前後端能順利溝通。
Johnson Mao
A passionate software engineer and blogger sharing thoughts on technology, AI, and development.