简单手写一个装饰器来加载 Express 路由
背景
- 装饰器的 stage 已经来到了 stage 3
- 最近在用
MidwayJS
, Midway 里面用到了很多装饰器
装饰器介绍
- 装饰器其实就是一个函数, 可以用来修改类的属性或者方法甚至是类本身
- 装饰器不能单独使用, 只能用在类上面
TypeScript
和JavaScript
的装饰器并不完全等价, 现在要用到装饰器的完全体需要借助一个叫 reflect-metadata 的库
创建项目
跟随下面的步骤
mkdir decorator-demo
tsc --init
experimentalDecorators
和emitDecoratorMetadata
设为true
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
, key
和 PropertyDescriptor
传进这个函数里面执行一遍, 上面的代码去除掉一些判断后, 核心就是下面几行代码
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
A 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
方法的参数也是可以使用装饰器的, 除了 target
和 key
以外, 我们还可以用这个装饰器拿到这个参数的索引位置
我们新增一个 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 这个包
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/login 和 http://localhost:3001/xxx/invalidLogin 来测试登录
会发现 invalidLogin 的登录页面会返回 missing property email, 因为表单提交的是 email1, 所有我们的 @ValidateRequestBody 装饰器成功实现
最后
简单的体验了下装饰器的用法, 没有过多深入去写一个类似 midway 或者 angular 那样的 IoC
容器
参考
转载自:https://juejin.cn/post/7123881357335330847