近期掘金上有小伙伴问阿宝哥装饰器的应用场景,这让阿宝哥忽然萌生了经过优秀的 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 容器。