likes
comments
collection
share

简单手写一个装饰器来加载 Express 路由

作者站长头像
站长
· 阅读数 52

背景

  1. 装饰器的 stage 已经来到了 stage 3
  2. 最近在用 MidwayJS , Midway 里面用到了很多装饰器

装饰器介绍

  • 装饰器其实就是一个函数, 可以用来修改类的属性或者方法甚至是类本身
  • 装饰器不能单独使用, 只能用在类上面
  • TypeScriptJavaScript 的装饰器并不完全等价, 现在要用到装饰器的完全体需要借助一个叫 reflect-metadata 的库

创建项目

跟随下面的步骤

  1. mkdir decorator-demo
  2. tsc --init
  3. experimentalDecoratorsemitDecoratorMetadata 设为 true
  4. npm i -g ts-node 或者 npx ts-node 来执行等下编写的 .ts 文件
class Animal {
	name: string = 'cat';

	@decorator
	get getName() {
		return this.name;
	}

	@decorator
	eat() {
		console.log('eat');
	}
}

function decorator(target: any, key: string, descriptor: PropertyDescriptor) {
	console.log(target)
	console.log(key)
	console.log(descriptor)
}

方法装饰器 - Method Decorator

  • 第一个参数 target 是对象的原型 prototype
  • 第二个参数 key 是作用在对应属性, 方法, 参数上对应的名称
  • 第三个参数是 PropertyDescriptor 在 lib.es5.d.ts 中它是这样定义的
interface PropertyDescriptor {
    configurable?: boolean;
    enumerable?: boolean;
    value?: any;
    writable?: boolean;
    get?(): any;
    set?(v: any): void;
}

那大家一眼就 👀 出来这个东西非常眼熟, 和 Object.defineProperty 这个方法的第三个参数可以说一模一样, 我们可以通过修改这些属性来实现一些功能

你可能会觉得我们可以直接通过 target[key] = xxx 来直接修改它的原型, 但实际上看编译后的代码我们可以发现 return c > 3 && r && Object.defineProperty(target, key, r), r; TypeScript 会将 descriptor 作用到原型上, 所以我们不能直接赋值来修改, 需要通过 descriptor 来改变或者包一层当前的属性或方法

⚠️ 装饰器只会在我们的类第一次定义的时候执行, 而不是每次我们创建一个实例的时候执行一次

把代码复制到 TypeScript Playground 我们就可以看到编译后的 JavaScript 代码

如果我们把 @decorator 删掉, 就会看到下面代码消失

var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
__decorate([
    decorator,
    __metadata("design:type", Object),
    __metadata("design:paramtypes", [])
], Animal.prototype, "getName", null);
__decorate([
    decorator,
    __metadata("design:type", Function),
    __metadata("design:paramtypes", []),
    __metadata("design:returntype", void 0)
], Animal.prototype, "eat", null);

其实就是在我们定义完类后, TypeScript 检测到我们有用到装饰器, 然后帮我们生成一个 __decorate 函数, 然后帮我们把 prototype , keyPropertyDescriptor 传进这个函数里面执行一遍, 上面的代码去除掉一些判断后, 核心就是下面几行代码

var __decorate = function(decorators, target, key, desc){
	var desc = Object.getOwnPropertyDescriptor(target, key)
	for(var i = decorators.length - 1; i >= 0; i--){
		decorator(target, key, desc)
	}
}

所以装饰器只不过是一个语法糖而已, 我们直接手动调用我们自己写的 decorator(Animal.prototype, 'eat') 也是一样的效果

那为什么我们要有装饰器这种东西呢❓

操作原型上的属性我们都可以直接操作啊❓

下面我们把代码改成这样

class Animal {
	name: string = 'cat';

	get getName() {
		return this.name;
	}

	@decorator
	static eat() {
		console.log('eat');
		throw new Error()
	}
}

function decorator(target: any, key: string, desc: PropertyDescriptor) {
	const method = desc.value
	desc.value = function () {
		try {
			method()
		} catch (error) {
			console.log('The Cat is eating');
		}
	}
}

console.log(Animal.eat())
// eat
// The Cat is eating

通过装饰器, 我们就可以方便的捕获到对应方法抛出的错误, 针对不同的错误做相应的处理

那如果我想自定义抛错的消息内容呢❓ 我看别人都可以像调用方法一样传参数进去的啊❓

这时候我们就可以使用 闭包 来解决这个问题, 在 TypeScript文档中有专门介绍叫 Decorator Factories

class Animal {
	name: string = 'cat';

	get getName() {
		return this.name;
	}

	@log('The cat is full')
	static eat() {
		console.log('eat');
		throw new Error()
	}
}

function log(errMsg: string) {
	return function (target: any, key: string, desc: PropertyDescriptor) {
		const method = desc.value
		desc.value = function () {
			try {
				method()
			} catch (error) {
				console.log(errMsg)
			}
		}
	}
}

console.log(Animal.eat())
// eat
// The cat is full

这样我们就通过 闭包 来解决这个问题

属性装饰器 - Property Decorator

如果你直接把我们之前定义好的 decorator 直接用在 name: string = 'cat' 这个 name 属性上, 他会报错. 因为属性装饰器只接收两个参数, 没有 descriptor

我们新增一个 Property Decorator

class Animal {
  @propDecorator
	name: string = 'cat';

	get getName() {
		return this.name;
	}

	@log('The cat is full')
	static eat() {
		console.log('eat');
		throw new Error()
	}
}

function propDecorator(target: any, key: string){
	console.log(target)
	console.log(key)
	console.log(target[key])
}

function log(errMsg: string) {
	return function (target: any, key: string, desc: PropertyDescriptor) {
		const method = desc.value
		desc.value = function () {
			try {
				method()
			} catch (error) {
				console.log(errMsg)
			}
		}
	}
}

但是运行打印后的结果 target[key] 竟然是 undefined

¿ 为什么会这样呢

我们的 name 明明有值啊, 为什么访问不到 ❓

下面是 TypeScript 文档里面 Property Decorators 的 Note

Property Descriptor is not provided as an argument to a property decorator due to how property decorators are initialized in TypeScript. This is because there is currently no mechanism to describe an instance property when defining members of a prototype, and no way to observe or modify the initializer for a property. The return value is ignored too. As such, a property decorator can only be used to observe that a property of a specific name has been declared for a class.

简单翻译下来就是, 目前没有办法去知道原型上的一个成员属性在实例化之后的值, 也没有办法观察或修改属性的初始值. 所以属性装饰器一般拿来观察一个类是否有定义这一个具名的成员属性.

通常在 JavaScript 中, 原型一般存储的是我们对一个方法的定义, 而实际的实例属性, 是在构造器中定义的, 所以我们的装饰器是获取不到实例属性的值的, 而且我们之前也有讲过, 装饰器只在类定义的时候会执行一遍, 有且仅有一遍.

参数装饰器 - Parameter Decorator

方法的参数也是可以使用装饰器的, 除了 targetkey 以外, 我们还可以用这个装饰器拿到这个参数的索引位置

我们新增一个 Parameter Decorator

class Animal {
  @propDecorator
	name: string = 'cat';

	get getName() {
		return this.name;
	}

	@log('The cat is full')
	static eat(@paramDecorator status: string, @paramDecorator food: string) {
		console.log('eat');
		throw new Error()
	}
}

function paramDecorator(target: any, key: string, index: number) {
	console.log(target);
	console.log(key);
	console.log(index);
}

function propDecorator(target: any, key: string){
	console.log(target)
	console.log(key)
	console.log(target[key])
}

function log(errMsg: string) {
	return function (target: any, key: string, desc: PropertyDescriptor) {
		const method = desc.value
		desc.value = function () {
			try {
				method()
			} catch (error) {
				console.log(errMsg)
			}
		}
	}
}

console.log(Animal.eat('hungry','cat food'))
// [class Animal]
// eat
// 1
// [class Animal]
// eat
// 0

⚠️ 打印的参数顺序是反着来的, 这是因为看上面编译后的代码 else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; 就是从最后一位开始遍历的.

类装饰器 - Class Decorator

类装饰器, 它只有一个参数, 那就是这个类的构造函数

@classDecorator
class Animal {
	@propDecorator
	name: string = 'cat';

	get getName() {
		return this.name;
	}

	@log('The cat is full')
	static eat(@paramDecorator status: string, @paramDecorator food: string) {
		console.log('eat');
		throw new Error()
	}
}

function classDecorator(ctor: typeof Animal) {
	console.log(ctor)
}

function paramDecorator(target: any, key: string, index: number) {
	console.log(target);
	console.log(key);
	console.log(index);
}

function propDecorator(target: any, key: string) {
	console.log(target)
	console.log(key)
	console.log(target[key])
}

function log(errMsg: string) {
	return function (target: any, key: string, desc: PropertyDescriptor) {
		const method = desc.value
		desc.value = function () {
			try {
				method()
			} catch (error) {
				console.log(errMsg)
			}
		}
	}
}

// console.log(Animal.eat('hungry', 'cat food'))

目标

实现类似 **ts-express-decorators 这个 NPM 包中定义路由的方式

下面是伪代码

原来的使用方法

// GET method route
app.get('/', (req, res) => {
  res.send('GET request to the homepage')
})

// POST method route
app.post('/', (req, res) => {
	// do validate stuff
	if(validate(password)) res.redirect('/')
	else res.send('invalid password')
})

用了装饰器后

@Controller
class Route {
	@Get('/')
	async get(req: Express.Request, res: Express.Response){}

	@Post('/')
  @ValidateRequestBody(['email','password'])
  @Use(logger)
	async post(req: Express.Request, res: Express.Response){
		res.send('POST request to the homepage')
	}
}

什么是 metadata

metadata 是提案中的 feature 将来会加进 JavaScript 中, 虽然不确定, 但是如果装饰器未来确定会被加进 ECMAScript , 那么到时候 metadata 也会被采用

metadata 本质上来说其实就是一个信息片段, 可以用来和类的属性, 方法或类定义本身进行绑定关联起来

TypeScript 也可以为我们提供 metadata 的类型信息, 一般来说在转成 JavaScript 的时候, 类型信息都不会打包进去

需要 reflect-metadata 这个包

MidwayJSAngular 都使用了这个库

import 'reflect-metadata' 引入的时候会将 Reflect 注入到全局变量中

两个方法等会会经常用到

  • Reflect.defindMetadata
  • Relfect.getMetadata
import 'reflect-metadata'

const cat = {
	name: 'cat',
	age: 1,
}

Reflect.defineMetadata('master', 'BABA', cat)

console.log(cat)

运行, 我们可以看到打印出来的 cat 对象上, 并没有我们定义的 master 属性, 定义的 metadata 它是 ”隐形“ 的一个属性, 不是 enumerable

我们可以通过 Reflect.getMetadata('master', cat) 来获取我们刚刚定义的 master

除了直接绑定在对象上, 我们还可以把 metadata 绑定在 对象的属性上

Reflect.defineMetadata('master', 'BABA', cat, 'name')

那我们怎样把 metadata 和装饰器结合在一起呢?

class Dog {
	name = 'dog'

	@setMetadata('master', 'BABA')
	run() {
		console.log('run')
	}
}

function setMetadata(metadataKey: string, val: string) {
	return (target: Dog, key: string) => {
		Reflect.defineMetadata(metadataKey, val, target, key)
	}
}

console.log(Reflect.getMetadata('master', Dog.prototype, 'run'))

但是每次都这么获取 metadata 写的太死了, 怎样从一个地方通用的获取 metadata 然后做相应的操作呢? ————————— 答案是类装饰器

⚠️ 类装饰器相较于其他装饰器它是最后一个执行的, 我们可以在方法, 属性, 参数装饰器中定义好 metadata 然后从类装饰器中获取对应的 metadata

@printMetadata
class Dog {
	name = 'dog'

	@setMetadata('master', 'BABA')
	run() {
		console.log('run')
	}
}

function setMetadata(metadataKey: string, val: string) {
	return (target: Dog, key: string) => {
		Reflect.defineMetadata(metadataKey, val, target, key)
	}
}

function printMetadata(ctor: typeof Dog) {
	for (let key in ctor.prototype) {
		const meta = Reflect.getMetadata('master', ctor.prototype, key)
		console.log(key, meta)
	}
}

⚠️ 需要注意的是 tsconfig.json 中需要将 target 设置为 es5 否则 ctor.prototype 打印出来为 {} , 因为用 class 语法创建的方法和属性不是 enumerable

es5 编译出来一个为 [Function: Dog] , es6[Class Dog]

console.log(Object.getOwnPropertyDescriptor(ctor.prototype, 'run'))
{
  value: [Function: run],
  writable: true,
  enumerable: false,
  configurable: true
}

@Get

import 'reflect-metadata'
export function Get(path: string) {
	return function (target: any, key: string, desc: PropertyDescriptor) {
		Reflect.defineMetadata('path', path, target, key)
	}
}

@Controller

类装饰器将遍历该类的原型所有属性, 然后检查这些属性是否有定义对应的 metadata , 如果有, 那么就拿到这些信息和路由关联起来.

他可以接受前缀, 和 GET 或者 POST 等装饰器拼接成 /xxx/login 一个组合出来的路径

import 'reflect-metadata'
import { Router } from 'express'

export const router = Router()

export function Controller(routePrefix: string) {
	return function (target: Function) {
		for (const key in target.prototype) {
			const method = target.prototype[key]
			const path = Reflect.getMetadata('path', target.prototype, key)

			if (path) {
				router.get(`${routePrefix}${path}`, method)
			}
		}
	}
}

src/index.ts 引入刚定义好的路由, 运行 ts-node src/index.ts

访问 http://localhost:3001/xxx/login

我们就可以看到返回的登录表单

import bodyParser from 'body-parser'
import cookieSession from 'cookie-session'
import express from 'express'
import { router } from './controllers/decorators'

import './controllers/login'

const app = express()

app.use(bodyParser.urlencoded({ extended: true }))
app.use(cookieSession({ name: 'session', keys: ['key1', 'key2'] }))
app.use(router)

app.listen(3001, () => {
	console.log('server is running at http://localhost:3001')
})

当然, 这里我们写死了 Get 在代码里面, 那么我们要怎么样知道装饰的方法, 是应该发送 Get 还是 Post 请求呢? 要做到这一点, 我们需要定义第二个 metadata

export function Get(path: string) {
	return function (target: any, key: string, desc: PropertyDescriptor) {
		Reflect.defineMetadata('path', path, target, key)
		// 只需要加多一行, 来定义该装饰器修饰的是什么类型的请求
		Reflect.defineMetadata('method', 'get', target, key)
	}
}

然后我们再这样 copy 然后改名成 Post, Delete 等方法, 当然这样做有点丑陋, 我们可以用一个简单的工厂来生成对应的不同装饰器方法

下面这个代码我只打了个 routeFac, Copilot 就帮我生成对应的函数了, 有点🐮

import 'reflect-metadata'

function routeFactory(method: string) {
	return function (path: string) {
		return function (target: any, key: string, desc: PropertyDescriptor) {
			Reflect.defineMetadata('path', path, target, key)
			Reflect.defineMetadata('method', method, target, key)
		}
	}
}

export const Get = routeFactory('get')
export const Post = routeFactory('post')
export const Put = routeFactory('put')
export const Delete = routeFactory('delete')
export const Patch = routeFactory('patch')
export const Options = routeFactory('options')
export const Head = routeFactory('head')

Controller 函数则改成

export function Controller(routePrefix: string) {
	return function (target: Function) {

		for (const key in target.prototype) {
			const method = target.prototype[key]
			const path = Reflect.getMetadata('path', target.prototype, key)
			// 拿到 method 的类型
			const methodType = Reflect.getMetadata('method', target.prototype, key)

			if (path) {
				// 绑定在 express 的 router 上面
				router[methodType](`${routePrefix}${path}`, method)
			}
		}
	}
}

@Use

中间件可以有很多个, 是一个数组, 在我们遍历的时候获取到要用到的数组加在最后一个

然后在对应的方法里面把中间件函数挂上去

import { RequestHandler } from 'express'
import 'reflect-metadata'
import { router } from '../../router'

export function Use(middleware: RequestHandler) {
	return function (target: any, key: string, desc: PropertyDescriptor) {
		const middlewares = Reflect.getMetadata('middleware', target, key) || []
		middlewares.push(middleware)
		Reflect.defineMetadata('middleware', middlewares, target, key)
	}
}

export function Controller(routePrefix: string) {
	return function (target: Function) {

		for (const key in target.prototype) {
			const method = target.prototype[key]
			const path = Reflect.getMetadata('path', target.prototype, key)
			const methodType = Reflect.getMetadata('method', target.prototype, key)
			const middlewares = Reflect.getMetadata('middleware', target.prototype, key) || []

			if (path) {
				router[methodType](`${routePrefix}${path}`, ...middlewares, method)
			}
		}
	}
}

然后我们写个 logger 函数来打印一下当前请求的路径

import { NextFunction, Request, Response } from "express";
import { Controller, Use } from "./decorators";
import { Get, Post, Put } from "./decorators/routes";

function logger(req: Request, res: Response, next: NextFunction) {
	console.log(`${req.method} ${req.path}`);
	next();
}

@Controller('/xxx')
export class LoginController {
	@Get('/login')
	@Use(logger)
	login(req: Request, res: Response) {
		res.send(`
		<Form method="post">
			<div>
				<label>Email</label>
				<input name="email" type="email" />
			</div>
			<div>
				<label>Password</label>
				<input name="password" type="password" />
			</div>
			<button type="submit">Login</button>
		</Form>`);
	}

	@Put('/update')
	@Use(logger)
	update(req: Request, res: Response) {
		res.send('update');
	}

}

访问 http://localhost:3001/xxx/login 可以看到控制台打印 GET /xxx/login

@ValidateRequestBody

来校验 Request 请求中的 Body 参数是否带有对应的属性

和上面的装饰器一样的套路, 定义好对应的 metadata 然后在 Controller 中获取到, 最后作为中间件挂到对应的路由方法上面


function requestBodyValidator(keys: string[]) {
	return function (req: Request, res: Response, next: NextFunction) {
		if (req.method === 'POST') {
			if (!req.body) {
				res.status(422).send('Invalid request');
				return
			}
			for (const key of keys) {
				if (!req.body[key]) {
					res.status(422).send(`Missing property ${key}`);
					return
				}
			}
		}
		next();
	}
}

export function ValidateRequestBody(...args: string[]) {
	return function (target: any, key: string, desc: PropertyDescriptor) {
		Reflect.defineMetadata('validateReqBody', args, target, key)
	}
}

export function Controller(routePrefix: string) {
	return function (target: Function) {

		for (const key in target.prototype) {
			const method = target.prototype[key]
			const path = Reflect.getMetadata('path', target.prototype, key)
			const methodType = Reflect.getMetadata('method', target.prototype, key)
			const middlewares = Reflect.getMetadata('middleware', target.prototype, key) || []
			const validateReq = Reflect.getMetadata('validateReqBody', target.prototype, key) || []

			const reqValidator = requestBodyValidator(validateReq)

			if (path) {
				router[methodType](`${routePrefix}${path}`, ...middlewares, reqValidator, method)
			}
		}
	}
}
import { NextFunction, Request, Response } from "express";
import { Controller, Use, ValidateRequestBody } from "./decorators";
import { Get, Post, Put } from "./decorators/routes";

function logger(req: Request, res: Response, next: NextFunction) {
	console.log(`${req.method} ${req.path}`);
	next();
}

@Controller('')
export class HomeController {
	@Get("/")
	public index(req: Request, res: Response) {
		res.send(`<h1>Hello World</h1>
		<a href="/xxx/login">Login</a>
		`);
	}
}

@Controller('/xxx')
export class LoginController {
	@Get('/login')
	@Use(logger)
	login(req: Request, res: Response) {
		res.send(`
		<Form method="post">
			<div>
				<label>Email</label>
				<input name="email" type="email" />
			</div>
			<div>
				<label>Password</label>
				<input name="password" type="password" />
			</div>
			<button type="submit">Login</button>
		</Form>`);
	}
	@Get('/invalidLogin')
	@Use(logger)
	invalidLogin(req: Request, res: Response) {
		res.send(`
		<Form method="post">
			<div>
				<label>Email</label>
				<input name="email1" type="email" />
			</div>
			<div>
				<label>Password</label>
				<input name="password" type="password" />
			</div>
			<button type="submit">Login</button>
		</Form>`);
	}

	@Post('/login')
	@Use(logger)
	@ValidateRequestBody('email', 'password')
	postLogin(req: Request, res: Response) {
		const { email, password } = req.body;
		if (email && password) {
			req.session = { loginSuccess: true };
			res.send(`
				<h1>Welcome ${email}</h1>
				<a href="/xxx/login">Logout</a>
			`);
		} else {
			res.send('Invalid email or password');
		}
	}
	@Post('/invalidLogin')
	@Use(logger)
	@ValidateRequestBody('email', 'password')
	invalidPostLogin(req: Request, res: Response) {
		const { email, password } = req.body;
		if (email && password) {
			req.session = { loginSuccess: true };
			res.send(`
				<h1>Welcome ${email}</h1>
				<a href="/xxx/login">Logout</a>
			`);
		} else {
			res.send('Invalid email or password');
		}
	}

}

访问 http://localhost:3001/xxx/loginhttp://localhost:3001/xxx/invalidLogin 来测试登录

会发现 invalidLogin 的登录页面会返回 missing property email, 因为表单提交的是 email1, 所有我们的 @ValidateRequestBody 装饰器成功实现

最后

简单的体验了下装饰器的用法, 没有过多深入去写一个类似 midway 或者 angular 那样的 IoC 容器

代码仓库

参考

www.typescriptlang.org/docs/handbo…

github.com/tc39/propos…

github.com/tc39/propos…

转载自:https://juejin.cn/post/7123881357335330847
评论
请登录