vlambda博客
学习文章列表

通过 OvernightJS 装饰 Express 来学习 TypeScript


近期掘金上有小伙伴问阿宝哥装饰器的应用场景,这让阿宝哥突然萌生了通过优秀的 TS 开源项目,来学习 TS 的想法。

本文阿宝哥将以 Github 上的 OvernightJS 开源项目为例,来介绍一下如何使用 TypeScript 装饰器来装饰 Express,从而让你的 Express 好用得飞起来。

接下来本文的重心将围绕 装饰器 的应用展开,不过在分析装饰器在 OvernightJS 的应用之前,阿宝哥先来简单介绍一下 OvernightJS。

一、OvernightJS 简介

TypeScript decorators for the ExpressJS Server.

OvernightJS 是一个简单的库,用于为要调用 Express 路由的方法添加 TypeScript 装饰器。此外,该项目还包含了用于管理 json-web-token 和打印日志的包。

1.1 OvernightJS 特性

OvernightJS 并不是为了替代 Express,如果你之前已经掌握了 Express,那你就可以快速地学会它。OvernightJS 为开发者提供了以下特性:

  • 使用 @Controller 装饰器定义基础路由;
  • 提供了把类方法转化为 Express 路由的装饰器( 比如 @Get,@Put,@Post,@Delete);
  • 提供了用于处理中间件的 @Middleware@ClassMiddleware 装饰器;
  • 提供了用于处理异常的 @ErrorMiddleware 装饰器;
  • 提供了 @Wrapper@ClassWrapper 装饰器用于包装函数;
  • 通过 @ChildControllers 装饰器支持子控制器。

出于篇幅考虑,阿宝哥只介绍了 OvernightJS 与装饰器相关的部分特性。了解完这些特性,我们来快速体验一下 OvernightJS。

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
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 装饰器实现上述的功能。

二、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 装饰 Express 来学习 TypeScript

通过以上对比可知,利用 OvernightJS 提供的装饰器,可以让我们开发起来更加便捷。但大家要记住 OvernightJS 底层还是基于 Express,其内部最终还是通过 Express 提供的 API 来处理路由。

接下来为了能更好理解后续的内容,我们先来简单回顾一下 TypeScript 装饰器。

2.1 TypeScript 装饰器简介

装饰器是一个表达式,该表达式执行后,会返回一个函数。在 TypeScript 中装饰器可以分为以下 4 类:

通过 OvernightJS 装饰 Express 来学习 TypeScript

需要注意的是,若要启用实验性的装饰器特性,你必须在命令行或 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 装饰 Express 来学习 TypeScript

在 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 装饰器的处理流程:

通过 OvernightJS 装饰 Express 来学习 TypeScript

分析完 @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 类图如下所示:

通过 OvernightJS 装饰 Express 来学习 TypeScript

此外,在 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 装饰 Express 来学习 TypeScript

在 OvernightJS 内部除了 @Controller@Get@Post@Delete 等装饰器之外,还提供了用于注册中间件的 @Middleware 装饰器及用于设置异常处理中间件的 @ErrorMiddleware 装饰器。感兴趣的小伙伴可以参考一下阿宝哥的学习思路,自行阅读 OvernightJS 项目的源码。

希望通过这篇文章,可以让小伙伴们对装饰器的应用场景有一些更深刻的理解。

三、参考资源

  • Github - overnight
  • expressjs.com

- END -