TS 的裝飾器還能夠這樣用 | 掘金技術徵文-雙節特別篇

近期掘金上有小夥伴問阿寶哥裝飾器的應用場景,這讓阿寶哥忽然萌生了經過優秀的 TS 開源項目,來學習 TS 的想法。html

本文屬於 「TS 源碼分析」 專題的第 2 篇文章,第 1 篇文章是 從 13K 的前端開源項目我學到了啥?| 410 個👍,感謝掘友的鼓勵與支持。前端

本文阿寶哥將以 Github 上的 OvernightJS 開源項目爲例,來介紹一下 如何使用 TypeScript 裝飾器來裝飾 Express,從而讓你的 Express 好用得飛起來。node

接下來本文的重心將圍繞 裝飾器 的應用展開,不過在分析裝飾器在 OvernightJS 的應用以前,阿寶哥先來簡單介紹一下 OvernightJS。git

閱讀說明:阿寶哥並非推薦小夥伴們在實際項目中使用 OvernightJS 這個庫,若想在實際項目中使用的話,能夠直接使用 NestJS 這個框架。對於 Koa 來講,能夠考慮使用 「水歌」 推薦的 routing-controllers 這個庫,來裝飾你的 Koa。es6

本文的主要目的是介紹 TypeScript 裝飾器與 Reflect API 在 Node.js Web 服務器的應用。阿寶哥將以 如何定義元數據,如何保存元數據和如何使用元數據 三個關鍵點展開,選用 OvernightJS 這個庫的主要緣由是該庫的實現比較簡單、輕量更容易理解。github

1、OvernightJS 簡介

TypeScript decorators for the ExpressJS Server.web

OvernightJS 是一個簡單的庫,用於爲要調用 Express 路由的方法添加 TypeScript 裝飾器。此外,該項目還包含了用於管理 json-web-token 和打印日誌的包。typescript

1.1 OvernightJS 特性

OvernightJS 並非爲了替代 Express,若是你以前已經掌握了 Express,那你就能夠快速地學會它。OvernightJS 爲開發者提供瞭如下特性:shell

  • 使用 @Controller 裝飾器定義基礎路由;
  • 提供了把類方法轉化爲 Express 路由的裝飾器(好比 @Get,@Put,@Post,@Delete);
  • 提供了用於處理中間件的 @Middleware@ClassMiddleware 裝飾器;
  • 提供了用於處理異常的 @ErrorMiddleware 裝飾器;
  • 提供了 @Wrapper@ClassWrapper 裝飾器用於包裝函數;
  • 經過 @ChildControllers 裝飾器支持子控制器。

出於篇幅考慮,阿寶哥只介紹了 OvernightJS 與裝飾器相關的部分特性。瞭解完這些特性,咱們來快速體驗一下 OvernightJS。express

1.2 OvernightJS 入門

1.2.1 初始化項目

首先新建一個 overnight-quickstart 項目,而後使用 npm init -y 命令初始化項目,而後在命令行中輸入如下命令來安裝項目依賴包:

$ npm i @overnightjs/core express -S
複製代碼

在 Express 項目中要集成 TypeScript 很簡單,只需安裝 typescript 這個包就能夠了。但爲了在開發階段可以在命令行直接運行使用 TypeScript 開發的服務器,咱們還須要安裝 ts-node 這個包。要安裝這兩個包,咱們只需在命令行中輸入如下命令:

$ npm i typescript ts-node -D
複製代碼
1.2.2 爲 Node.js 和 Express 安裝聲明文件

聲明文件是預約義的模塊,用於告訴 TypeScript 編譯器的 JavaScript 值的形狀。類型聲明一般包含在擴展名爲 .d.ts 的文件中。這些聲明文件可用於全部最初用 JavaScript 而非 TypeScript 編寫的庫。

幸運的是,咱們不須要重頭開始爲 Node.js 和 Express 定義聲明文件,由於在 Github 上有一個名爲 DefinitelyTyped 項目已經爲咱們提供了現成的聲明文件。

要安裝 Node.js 和 Express 對應的聲明文件,咱們只須要在命令行執行如下命令就能夠了:

$ npm i @types/node @types/express -D
複製代碼

該命令成功執行以後,package.json 中的 devDependencies 屬性就會新增 Node.js 和 Express 對應的依賴包版本信息:

{
  "devDependencies": {
     "@types/express": "^4.17.8",
     "@types/node": "^14.11.2",
     "ts-node": "^9.0.0",
     "typescript": "^4.0.3"
  }
}
複製代碼
1.2.3 初始化 TypeScript 配置文件

爲了可以靈活地配置 TypeScript 項目,咱們還須要爲本項目生成 TypeScript 配置文件,在命令行輸入 tsc --init 以後,項目中就會自動建立一個 tsconfig.json 的文件。對於本項目來講,咱們將使用如下配置項:

{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "rootDir": "./src",
    "outDir": "./build",
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "strict": true
  }
}
複製代碼
1.2.4 建立簡單的 Web 服務器

在建立簡單的 Web 服務器以前,咱們先來初始化項目的目錄結構。首先在項目的根目錄下建立一個 src 目錄及 controllers 子目錄:

├── src
│   ├── controllers
│   │   └── UserController.ts
│   └── index.ts
複製代碼

接着新建 UserController.tsindex.ts 這兩個文件並分別輸入如下內容:

UserController.ts

import { Controller, Get } from "@overnightjs/core";
import { Request, Response } from "express";

@Controller("api/users")
export class UserController {
  @Get("")
  private getAll(req: Request, res: Response) {
    return res.status(200).json({
      message: "成功獲取全部用戶",
    });
  }
}
複製代碼

index.ts

import { Server } from "@overnightjs/core";
import { UserController } from "./controllers/UserController";

const PORT = 3000;

export class SampleServer extends Server {
  constructor() {
    super(process.env.NODE_ENV === "development");
    this.setupControllers();
  }

  private setupControllers(): void {
    const userController = new UserController();
    super.addControllers([userController]);
  }

  public start(port: number): void {
    this.app.listen(port, () => {
      console.log(`⚡️[server]: Server is running at http://localhost:${PORT}`);
    });
  }
}

const sampleServer = new SampleServer();
sampleServer.start(PORT);
複製代碼

完成上述步驟以後,咱們在項目的 package.json 中添加一個 start 命令來啓動項目:

{
  "scripts": {
    "start": "ts-node ./src/index.ts"
  },
}
複製代碼

添加完 start 命令,咱們就能夠在命令行中經過 npm start 來啓動 Web 服務器了。當服務器成功啓動以後,命令行會輸出如下消息:

> ts-node ./src/index.ts

⚡️[server]: Server is running at http://localhost:3000
複製代碼

接着咱們打開瀏覽器訪問 http://localhost:3000/api/users 這個地址,你就會看到 {"message":"成功獲取全部用戶"} 這個信息。

1.2.5 安裝 nodemon

爲了方便後續的開發,咱們還須要安裝一個第三方包 nodemon。對於寫過 Node.js 應用的小夥伴來講,對 nodemon 這個包應該不會陌生。nodemon 這個包會自動檢測目錄中文件的更改,當發現文件異動時,會自動重啓 Node.js 應用程序。

一樣,咱們在命令行執行如下命令來安裝它:

$ npm i nodemon -D
複製代碼

安裝完成後,咱們須要更新一下前面已經建立的 start 命令:

{
  "scripts": {
    "start": "nodemon ./src/index.ts"
  }
}
複製代碼

好的,如今咱們已經知道如何使用 OvernightJS 來開發一個簡單的 Web 服務器。接下來,阿寶哥將帶你們一塊兒來分析 OvernightJS 是如何使用 TypeScript 裝飾器實現上述的功能。

2、OvernightJS 原理分析

在分析前面示例中 @Controller@Get 裝飾器原理前,咱們先來看一下直接使用 Express 如何實現一樣的功能:

import express, { Router, Request, Response } from "express";
const app = express();

const PORT = 3000;
class UserController {
  public getAll(req: Request, res: Response) {
    return res.status(200).json({
      message: "成功獲取全部用戶",
    });
  }
}

const userRouter = Router();
const userCtrl = new UserController();
userRouter.get("/", userCtrl.getAll);

app.use("/api/users", userRouter);

app.listen(PORT, () => {
  console.log(`⚡️[server]: Server is running at http://localhost:${PORT}`);
});
複製代碼

在以上代碼中,咱們先經過調用 Router 方法建立了一個 userRouter 對象,而後進行相關路由的配置,接着使用 app.use 方法應用 userRouter 路由。下面咱們用一張圖來直觀感覺一下 OvernightJS 與 Express 在使用上的差別:

經過以上對比可知,利用 OvernightJS 提供的裝飾器,可讓咱們開發起來更加便捷。但你們要記住 OvernightJS 底層仍是基於 Express,其內部最終仍是經過 Express 提供的 API 來處理路由。

接下來爲了能更好理解後續的內容,咱們先來簡單回顧一下 TypeScript 裝飾器。

2.1 TypeScript 裝飾器簡介

裝飾器是一個表達式,該表達式執行後,會返回一個函數。在 TypeScript 中裝飾器能夠分爲如下 4 類:

須要注意的是,若要啓用實驗性的裝飾器特性,你必須在命令行或 tsconfig.json 裏啓用 experimentalDecorators 編譯器選項:

命令行

tsc --target ES5 --experimentalDecorators
複製代碼

tsconfig.json

{
  "compilerOptions": {
     "experimentalDecorators": true
   }
}
複製代碼

瞭解完 TypeScript 裝飾器的分類,咱們來開始分析 OvernightJS 框架中提供的裝飾器。

2.2 @Controller 裝飾器

在前面建立的簡單 Web 服務器中,咱們經過如下方式來使用 @Controller 裝飾器:

@Controller("api/users")
export class UserController {}
複製代碼

很明顯該裝飾器應用在 UserController 類上,它屬於類裝飾器。OvernightJS 的項目結構很簡單,咱們能夠很容易找到 @Controller 裝飾器的定義:

// src/core/lib/decorators/class.ts
export function Controller(path: string): ClassDecorator {
  return <TFunction extends Function>(target: TFunction): void => { addBasePathToClassMetadata(target.prototype, "/" + path); }; } 複製代碼

經過觀察以上代碼可知,Controller 函數是一個裝飾器工廠,即調用該工廠方法以後會返回一個 ClassDecorator 對象。在 ClassDecorator 內部,會繼續調用 addBasePathToClassMetadata 方法,把基礎路徑添加到類的元數據中:

// src/core/lib/decorators/class.ts
export function addBasePathToClassMetadata(target: Object, basePath: string): void {
  let metadata: IClassMetadata | undefined = Reflect.getOwnMetadata(classMetadataKey, target);
  if (!metadata) {
      metadata = {};
  }
  metadata.basePath = basePath;
  Reflect.defineMetadata(classMetadataKey, metadata, target);
}
複製代碼

addBasePathToClassMetadata 函數的實現很簡單,主要是利用 Reflect API 實現元數據的存取操做。在以上代碼中,會先獲取 target 對象上已保存的 metadata 對象,若是不存在的話,會建立一個空的對象,而後把參數 basePath 的值添加該對象的 basePath 屬性中,元數據設置完成後,在經過 Reflect.defineMetadata 方法進行元數據的保存。

下面咱們用一張圖來講明一下 @Controller 裝飾器的處理流程:

在 OvernightJS 項目中,所使用的 Reflect API 是來自 reflect-metadata 這個第三方庫。該庫提供了不少 API 用於操做元數據,這裏咱們只簡單介紹幾個經常使用的 API:

// define metadata on an object or property
Reflect.defineMetadata(metadataKey, metadataValue, target);
Reflect.defineMetadata(metadataKey, metadataValue, target, propertyKey);

// check for presence of a metadata key on the prototype chain of an object or property
let result = Reflect.hasMetadata(metadataKey, target);
let result = Reflect.hasMetadata(metadataKey, target, propertyKey);

// get metadata value of an own metadata key of an object or property
let result = Reflect.getOwnMetadata(metadataKey, target);
let result = Reflect.getOwnMetadata(metadataKey, target, propertyKey);

// get metadata value of a metadata key on the prototype chain of an object or property
let result = Reflect.getMetadata(metadataKey, target);
let result = Reflect.getMetadata(metadataKey, target, propertyKey);

// delete metadata from an object or property
let result = Reflect.deleteMetadata(metadataKey, target);
let result = Reflect.deleteMetadata(metadataKey, target, propertyKey);
複製代碼

相信看到這裏,可能有一些小夥伴會有疑問,經過 Reflect API 保存的元數據何時使用呢?這裏咱們先記住這個問題,後面咱們再來分析它,接下來咱們來開始分析 @Get 裝飾器。

2.3 @Get 裝飾器

在前面建立的簡單 Web 服務器中,咱們經過如下方式來使用 @Get 裝飾器,該裝飾器用於配置 Get 請求:

export class UserController {
  @Get("")
  private getAll(req: Request, res: Response) {
    return res.status(200).json({
      message: "成功獲取全部用戶",
    });
  }
}
複製代碼

@Get 裝飾器應用在 UserController 類的 getAll 方法上,它屬於方法裝飾器。它的定義以下所示:

// src/core/lib/decorators/method.ts
export function Get(path?: string | RegExp): MethodDecorator & PropertyDecorator {
  return helperForRoutes(HttpVerb.GET, path);
}
複製代碼

Controller 函數同樣,Get 函數也是一個裝飾器工廠,調用該函數以後會返回 MethodDecorator & PropertyDecorator 的交叉類型。除了 Get 請求方法以外,常見的 HTTP 請求方法還有 Post、Delete、Put、Patch 和 Head 等。爲了統一處理這些請求方法,OvernightJS 內部封裝了一個 helperForRoutes 函數,該函數的具體實現以下:

// src/core/lib/decorators/method.ts
function helperForRoutes(httpVerb: HttpDecorator, path?: string | RegExp): MethodDecorator & PropertyDecorator {
  return (target: Object, propertyKey: string | symbol): void => {
      let newPath: string | RegExp;
      if (path === undefined) {
          newPath = '';
      } else if (path instanceof RegExp) {
          newPath = addForwardSlashToFrontOfRegex(path);
      } else { // assert (path instanceof string)
          newPath = '/' + path;
      }
      addHttpVerbToMethodMetadata(target, propertyKey, httpVerb, newPath);
    };
}
複製代碼

觀察以上代碼可知,在 helperForRoutes 方法內部,會繼續調用 addHttpVerbToMethodMetadata 方法把請求方法和請求路徑這些元數據保存起來。

// src/core/lib/decorators/method.ts
export function addHttpVerbToMethodMetadata(target: Object, metadataKey: any, httpDecorator: HttpDecorator, path: string | RegExp): void {
    let metadata: IMethodMetadata | undefined = Reflect.getOwnMetadata(metadataKey, target);
    if (!metadata) {
        metadata = {};
    }
    if (!metadata.httpRoutes) {
        metadata.httpRoutes = [];
    }
    const newArr: IHttpRoute[] = [{
      httpDecorator,
      path,
    }];
    newArr.push(...metadata.httpRoutes);
    metadata.httpRoutes = newArr;
    Reflect.defineMetadata(metadataKey, metadata, target);
}
複製代碼

addHttpVerbToMethodMetadata 方法中,會先獲取已保存的元數據,若是 metadata 對象不存在則會建立一個空的對象。而後會繼續判斷該對象上是否含有 httpRoutes 屬性,沒有的話會使用 [] 對象來做爲該屬性的屬性值。而請求方法和請求路徑這些元數據會以對象的形式保存到數組中,最終在經過 Reflect.defineMetadata 方法進行元數據的保存。

一樣,咱們用一張圖來講明一下 @Get 裝飾器的處理流程:

分析完 @Controller@Get 裝飾器,咱們已經知道元數據是如何進行保存的。下面咱們來回答 「經過 Reflect API 保存的元數據何時使用呢?」 這個問題。

2.4 元數據的使用

要搞清楚經過 Reflect API 保存的元數據何時使用,咱們就須要來回顧一下前面開發的 SampleServer 服務器:

export class SampleServer extends Server {
  constructor() {
    super(process.env.NODE_ENV === "development");
    this.setupControllers();
  }

  private setupControllers(): void {
    const userController = new UserController();
    super.addControllers([userController]);
  }

  public start(port: number): void {
    this.app.listen(port, () => {
      console.log(`⚡️[server]: Server is running at http://localhost:${PORT}`);
    });
  }
}

const sampleServer = new SampleServer();
sampleServer.start(PORT);
複製代碼

在以上代碼中 SampleServer 類繼承於 OvernightJS 內置的 Server 類,對應的 UML 類圖以下所示:

此外,在 SampleServer 類中咱們定義了 setupControllersstart 方法,分別用於初始化控制器和啓動服務器。咱們在自定義的控制器上使用了 @Controller@Get 裝飾器,所以接下來咱們的重點就是分析 setupControllers 方法。該方法的內部實現很簡單,就是手動建立控制器實例,而後調用父類的 addControllers 方法。

下面咱們來分析 addControllers 方法,該方法位於 src/core/lib/Server.ts 文件中,具體實現以下:

// src/core/lib/Server.ts
export class Server {
  public addControllers(
    controllers: Controller | Controller[],
    routerLib?: RouterLib,
    globalMiddleware?: RequestHandler,
  ): void {
       controllers = (controllers instanceof Array) ? controllers : [controllers];
       // ① 支持動態設置路由庫
       const routerLibrary: RouterLib = routerLib || Router; 
       controllers.forEach((controller: Controller) => {
         if (controller) {
             // ② 爲每一個控制器建立對應的路由對象
             const routerAndPath: IRouterAndPath | null = this.getRouter(routerLibrary, controller);
             // ③ 註冊路由
             if (routerAndPath) {
                  if (globalMiddleware) {
                      this.app.use(routerAndPath.basePath, globalMiddleware, routerAndPath.router);
                  } else {
                      this.app.use(routerAndPath.basePath, routerAndPath.router);
                  }
              }
            }
        });
    }
}
複製代碼

addControllers 方法的整個執行過程仍是比較清晰,最核心的部分就是 getRouter 方法。在該方法內部就會處理經過裝飾器保存的元數據。其實 getRouter 方法內部還會處理其餘裝飾器保存的元數據,簡單起見咱們只考慮與 @Controller@Get 裝飾器相關的處理邏輯。

// src/core/lib/Server.ts
export class Server {
 private getRouter(routerLibrary: RouterLib, controller: Controller): IRouterAndPath | null {
        const prototype: any = Object.getPrototypeOf(controller);
        const classMetadata: IClassMetadata | undefined = Reflect.getOwnMetadata(classMetadataKey, prototype);

			  // 省略部分代碼
        const { basePath, options, ...}: IClassMetadata = classMetadata;

        // ① 基於配置項建立Router對象
        const router: IRouter = routerLibrary(options);

   			// ② 爲路由對象添加路徑和請求處理器
        let members: any = Object.getOwnPropertyNames(controller);
        members = members.concat(Object.getOwnPropertyNames(prototype));
        members.forEach((member: any) => {
            // ③ 獲取方法中保存的元數據
            const methodMetadata: IMethodMetadata | undefined = Reflect.getOwnMetadata(member, prototype);
            if (methodMetadata) {
                const { httpRoutes, ...}: IMethodMetadata = methodMetadata;
                let callBack: (...args: any[]) => any = (...args: any[]): any => {
                    return controller[member](...args);
                };
                // 省略部分代碼
                if (httpRoutes) { // httpRoutes數組中包含了請求的方法和路徑
                    // ④ 處理控制器類中經過@Get、@Post、@Put或@Delete裝飾器保存的元數據
                    httpRoutes.forEach((route: IHttpRoute) => {
                        const { httpDecorator, path }: IHttpRoute = route;
                        // ⑤ 爲router對象設置對應的路由信息
                        if (middlewares) {
                            router[httpDecorator](path, middlewares, callBack);
                        } else {
                            router[httpDecorator](path, callBack);
                        }
                    });
                }
            }
        });
        return { basePath, router, };
    }
}
複製代碼

如今咱們已經知道 OvernightJS 內部如何利用裝飾器來爲控制器類配置路由信息,這裏阿寶哥用一張圖來總結 OvernightJS 的工做流程:

在 OvernightJS 內部除了 @Controller@Get@Post@Delete 等裝飾器以外,還提供了用於註冊中間件的 @Middleware 裝飾器及用於設置異常處理中間件的 @ErrorMiddleware 裝飾器。感興趣的小夥伴能夠參考一下阿寶哥的學習思路,自行閱讀 OvernightJS 項目的源碼。

若是不理解 OvernightJS 內部爲什麼這樣實現,能夠再閱讀一下未使用裝飾器實現簡單 Web 服務器的代碼。其實 OvernightJS 底層仍是基於 Express,因此最終仍是使用 Express 提供的路由 API 來配置路由。

但願經過這篇文章,可讓小夥伴們對裝飾器的應用場景有進一步的理解。若是你還意猶未盡的話,能夠閱讀阿寶哥以前寫的 」了不得的 IoC 與 DI「 這篇文章,該文章介紹瞭如何利用 TypeScript 裝飾器和 reflect-metadata 這個庫提供的 Reflect API 實現一個 IoC 容器。

3、參考資源

🏆 掘金技術徵文|雙節特別篇

相關文章
相關標籤/搜索