Skip to content

refactor: typescript 5 decorator #5

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Sep 26, 2024
Merged

Conversation

JohnsonMao
Copy link
Owner

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

前言

在這篇文章中,我們將介紹如何使用 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 const Controller = (basePath: string) => {
    return (_: new (...args: any[]) => {}, ctx: ClassDecoratorContext) => {
        ctx.metadata[MetadataKeys.BASE_PATH] = basePath;
    };
};

實作 Method 的裝飾器

接下來,我們將為常見的 HTTP 方法(如:GETPOSTPUTDELETE)實作對應的裝飾器,這些裝飾器會將每個路由的資訊存入 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 const validateMetadata = <T extends new (...args: any[]) => {}>(target: T) => {
    return metadataSchema.parse(target[Symbol.metadata]);
};

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

const methodDecoratorFactory = (method: Method) => {
    return (path: string = '') => {
        return (_: Function, ctx: ClassMethodDecoratorContext) => {
            const metadataRouters = ctx.metadata[MetadataKeys.ROUTERS];
            const routers = 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");

解法來源:microsoft/TypeScript#53461 (comment)

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

// ./controller.decorator.ts 檔案

import './symbol.polyfill';

總結

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

@JohnsonMao JohnsonMao self-assigned this Sep 26, 2024
@JohnsonMao JohnsonMao merged commit c9fa594 into main Sep 26, 2024
@JohnsonMao JohnsonMao deleted the refactor/typescript-5-decorator branch September 26, 2024 14:59
@angelhdzmultimedia
Copy link

Thanks! This helped big time! 🔥🎉🤯💙

By any chance, you have any idea when this polyfill will be available in TypeScript?
I'm on TypeScript 5.7.2...

Google Translated:

謝謝!這幫了大忙! 🔥🎉🤯💙

無論如何,你知道這個 polyfill 何時會在 TypeScript 中可用嗎?
我使用的是 TypeScript 5.7.2...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants