近期掘金上有小夥伴問阿寶哥裝飾器的應用場景,這讓阿寶哥忽然萌生了經過優秀的 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
TypeScript decorators for the ExpressJS Server.web
OvernightJS 是一個簡單的庫,用於爲要調用 Express 路由的方法添加 TypeScript 裝飾器。此外,該項目還包含了用於管理 json-web-token 和打印日誌的包。typescript
OvernightJS 並非爲了替代 Express,若是你以前已經掌握了 Express,那你就能夠快速地學會它。OvernightJS 爲開發者提供瞭如下特性:shell
@Controller
裝飾器定義基礎路由;@Middleware
和 @ClassMiddleware
裝飾器;@ErrorMiddleware
裝飾器;@Wrapper
和 @ClassWrapper
裝飾器用於包裝函數;@ChildControllers
裝飾器支持子控制器。出於篇幅考慮,阿寶哥只介紹了 OvernightJS 與裝飾器相關的部分特性。瞭解完這些特性,咱們來快速體驗一下 OvernightJS。express
首先新建一個 overnight-quickstart
項目,而後使用 npm init -y
命令初始化項目,而後在命令行中輸入如下命令來安裝項目依賴包:
$ npm i @overnightjs/core express -S
複製代碼
在 Express 項目中要集成 TypeScript 很簡單,只需安裝 typescript
這個包就能夠了。但爲了在開發階段可以在命令行直接運行使用 TypeScript 開發的服務器,咱們還須要安裝 ts-node
這個包。要安裝這兩個包,咱們只需在命令行中輸入如下命令:
$ npm i typescript ts-node -D
複製代碼
聲明文件是預約義的模塊,用於告訴 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"
}
}
複製代碼
爲了可以靈活地配置 TypeScript 項目,咱們還須要爲本項目生成 TypeScript 配置文件,在命令行輸入 tsc --init
以後,項目中就會自動建立一個 tsconfig.json
的文件。對於本項目來講,咱們將使用如下配置項:
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"rootDir": "./src",
"outDir": "./build",
"esModuleInterop": true,
"experimentalDecorators": true,
"strict": true
}
}
複製代碼
在建立簡單的 Web 服務器以前,咱們先來初始化項目的目錄結構。首先在項目的根目錄下建立一個 src
目錄及 controllers
子目錄:
├── src
│ ├── controllers
│ │ └── UserController.ts
│ └── index.ts
複製代碼
接着新建 UserController.ts
和 index.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":"成功獲取全部用戶"}
這個信息。
爲了方便後續的開發,咱們還須要安裝一個第三方包 nodemon
。對於寫過 Node.js 應用的小夥伴來講,對 nodemon
這個包應該不會陌生。nodemon
這個包會自動檢測目錄中文件的更改,當發現文件異動時,會自動重啓 Node.js 應用程序。
一樣,咱們在命令行執行如下命令來安裝它:
$ npm i nodemon -D
複製代碼
安裝完成後,咱們須要更新一下前面已經建立的 start
命令:
{
"scripts": {
"start": "nodemon ./src/index.ts"
}
}
複製代碼
好的,如今咱們已經知道如何使用 OvernightJS 來開發一個簡單的 Web 服務器。接下來,阿寶哥將帶你們一塊兒來分析 OvernightJS 是如何使用 TypeScript 裝飾器實現上述的功能。
在分析前面示例中 @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 裝飾器。
裝飾器是一個表達式,該表達式執行後,會返回一個函數。在 TypeScript 中裝飾器能夠分爲如下 4 類:
須要注意的是,若要啓用實驗性的裝飾器特性,你必須在命令行或 tsconfig.json
裏啓用 experimentalDecorators
編譯器選項:
命令行:
tsc --target ES5 --experimentalDecorators
複製代碼
tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true
}
}
複製代碼
瞭解完 TypeScript 裝飾器的分類,咱們來開始分析 OvernightJS 框架中提供的裝飾器。
在前面建立的簡單 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
裝飾器。
在前面建立的簡單 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 保存的元數據何時使用呢?」 這個問題。
要搞清楚經過 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
類中咱們定義了 setupControllers
和 start
方法,分別用於初始化控制器和啓動服務器。咱們在自定義的控制器上使用了 @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 容器。