TS装饰器最佳实践——给Koa包装一个路由分发器

510 阅读10分钟

最近在做项目需求过程中代码不少地方都用到了ts装饰器,比如依赖注入,切面编程等,之前对ts装饰器了解的还不够全面,比如装饰器的执行顺序,执行时机等,于是抽空研究了下ts装饰器,并用ts装饰器给Koa包转了一个带路由守卫功能的路由分发器。

一、ts装饰器基础

为什么我们需要装饰器?装饰器的强大魅力在于:

  • 快速优雅的复用逻辑
  • 提供注释一般的解释说明效果
  • 对业务代码进行能力增强,又不改变原代码结构

1.1 装饰器的种类

装饰器分为类装饰器、方法装饰器、属性装饰器、方法参数装饰器

在开始前,你需要确保在tsconfig.json中设置了experimentalDecorators为 true

首先要明确地是,TS中的装饰器实现本质是一个语法糖,它的本质是一个函数,如果调用形式为@deco(),那么这个函数应该再返回一个函数来实现调用。

类装饰器

function loadClass1Decorates(name) {
    return (target) => {
        target.prototype.name = name;
    }
} 

function loadClass2Decorates(age) {
    return (target) => {
        target.age = age;
    }
}

@loadClass1Decorates('zhangsan')
@loadClass2Decorates(18)
class Myclass {
    name: string;
    static age: number;
}

console.log(new Myclass().name) // zhangsan
console.log(Myclass.age) // 18

类装饰器的形式为:(target) => {},其中参数 target 为类的构造函数,指向类本身

方法装饰器

方法装饰器的形式为:(target, propertyName, descriptor) => {},入参分别为 类的原型对象、 属性名 以及属性描述符(descriptor) ,其属性描述符包含writable enumerable configurable value,我们可以在这里去配置其相关信息。

function log(target, name, descriptor) {
    var oldValue = descriptor.value;
    descriptor.value = function () {
        console.log(`Calling "${name}" with`, arguments);
        return oldValue.apply(null, arguments);
    }
}

class Maths {
    @log
    add(a, b) {
        return a + b;
    }
}

const math = new Maths();
math.add(2, 4); 
let result = math.add(2, 4);
console.log('result=', result)
// Calling "add" with [Arguments] { '0': 2, '1': 4 }
// result= 6

属性装饰器

属性装饰器的形式为:(target, propertyName) => {},类似于方法装饰器,但它的入参少了属性描述符。

function propertyTitleDecorates1(title) {
    return (target, name) => {
        console.log(`call ${name} property`);
        target[name] = title;
    }
}
class Myclass {
    @propertyTitleDecorates1('teacher')
    title: string;
}
console.log(new Myclass().title);
// call title property
// teacher

方法参数装饰器

方法参数装饰器的形式为:(target, propertyName, index) => {},方法参数装饰器的入参首要两位与属性装饰器相同,第三个参数则是参数在当前函数参数中的索引

function parmasDecorates1() {
    return (target, name, index) => {
        console.log(`call ${name}-${index} parmas1`);
    }
}

function parmasDecorates2() {
    return (target, name, index) => {
        console.log(`call ${name}-${index} parmas2`);
    }
}

class Myclass {
    sayHi(@parmasDecorates1() name, @parmasDecorates2() age) {
        console.log(name, age);
    }
}
new Myclass().sayHi('zhangsan', 18);
// call sayHi-1 parmas2
// call sayHi-0 parmas1
// zhangsan 18

1.2 装饰器的执行顺序

当存在多个装饰器来装饰同一个声明时执行顺序是怎样的呢?当一个类里同时有上面说的四种装饰器时的执行顺序又是怎样的呢?下面来一探究竟。

多个装饰器装饰同一个声明

function methodDecorates1(method1) {
    console.log(method1);
    return (target, name, descriptor) => {
        console.log(`call ${name} method1`);
    }
}

function methodDecorates2(method2) {
    console.log(method2);
    return (target, name, descriptor) => {
        console.log(`call ${name} method2`);
    }
}

class Myclass {
    @methodDecorates1('method1')
    @methodDecorates2('method2')
    sayHello() {
        console.log('sayHello');
    }
}
new Mycalss().sayHello();
// method1
// method2
// call sayHello method2
// call sayHello method1
// sayHello

从打印的顺序可以得出结论

  • 首先,由上至下依次对装饰器表达式求值,得到返回的真实函数(如果有的话)
  • 而后,求值的结果会由下至上依次调用 类似洋葱模型

同时有多种装饰器

当同时有多种装饰器时执行的顺序是怎样的呢,我查了一些资料,但得到的结论很模糊

image.png

image.png 看来只有亲自执行一遍才能得出正确的结论

function loadClass1Decorates(name) {
    console.log('class1');
    return (target) => {
        console.log('call class1 decorates');
        target.prototype.name = name;
    }
} 

function loadClass2Decorates(age) {
    console.log('class2');
    return (target) => {
        console.log('call class2 decorates');
        target.age = age;
    }
}

function methodDecorates1(method1) {
    console.log(method1);
    return (target, name, descriptor) => {
        console.log(`call ${target[name]} method1`);
    }
}

function methodDecorates2(method2) {
    console.log(method2);
    return (target, name, descriptor) => {
        console.log(`call ${name} method2`);
    }
}

function propertyTitleDecorates1(property1) {
    console.log(property1);
    return (target, name) => {
        console.log(`call ${name} property1`);
    }
}

function propertyTitleDecorates2(property2) {
    console.log(property2);
    return (target, name) => {
        console.log(`call ${name} property2`);
    }
}

function parmasDecorates1(parmas1) {
    console.log(parmas1);
    return (target, name, index) => {
        console.log(`call ${name}-${index} parmas1`);
    }
}

function parmasDecorates2(parmas2) {
    console.log(parmas2);
    return (target, name, index) => {
        console.log(`call ${name}-${index} parmas2`);
    }
}

@loadClass1Decorates('class1')
@loadClass2Decorates(18)
class Myclass {
    name: string;
    static age: number;

    @methodDecorates1('method1')
    @methodDecorates2('method2')
    sayHello() {}

    sayHi(@parmasDecorates1('parmas1') name, @parmasDecorates2('parmas2') age) {}

    @propertyTitleDecorates1('property1')
    @propertyTitleDecorates2('property2')
    title: string;
}
// method1
// method2
// call sayHello method2
// call sayHello() { } method1
// parmas1
// parmas2
// call sayHi-1 parmas2
// call sayHi-0 parmas1
// property1
// property2
// call title property2
// call title property1
// class1
// class2
// call class2 decorates
// call class1 decorates

看打印结果,首先多个装饰器装饰同一个声明的执行顺序进一步得到了验证,类似洋葱模型,不同种类的装饰器执行顺序依次是方法装饰器方法参数装饰器属性装饰器类装饰器,但是仔细看代码,在类里面,代码书写的顺序依次是方法装饰器方法参数装饰器属性装饰器,莫非不同种类的装饰器执行顺序和代码书写顺序相关?那改下类里面装饰器的书写顺序看看结果

@loadClass1Decorates('class1')
@loadClass2Decorates(18)
class Myclass {
    name: string;
    static age: number;

    sayHi(@parmasDecorates1('parmas1') name, @parmasDecorates2('parmas2') age) {}

    @propertyTitleDecorates1('property1')
    @propertyTitleDecorates2('property2')
    title: string;

    @methodDecorates1('method1')
    @methodDecorates2('method2')
    sayHello() {}
}
// parmas1
// parmas2
// call sayHi-1 parmas2
// call sayHi-0 parmas1
// property1
// property2
// call title property2
// call title property1
// method1
// method2
// call sayHello method2
// call sayHello() { } method1
// class1
// class2
// call class2 decorates
// call class1 decorates

可以看到,类装饰器依然是最后执行,当类里面装饰器的书写顺序变为方法参数装饰器属性装饰器方法装饰器后执行顺序也变为了方法参数装饰器属性装饰器方法装饰器,所以可以得出结论:

  • 类装饰器最后执行
  • 方法装饰器、属性装饰器、方法参数装饰器的执行顺序与他们在类里的书写顺序一致

1.3 装饰器的执行时机

阮一峰老师在ES6装饰器一节里有这样的说明:

注意,装饰器对类的行为的改变,是代码编译时发生的,而不是在运行时。这意味着,装饰器能在编译阶段运行代码。也就是说,装饰器本质就是编译时执行的函数

其实在同时有多种装饰器执行顺序一节中的演示代码就可以看出来,代码里根本就没有 new Myclass(),也没有调用 Myclass 里的任何方法或属性,但是执行代码后装饰器全部执行了,可以印证装饰器本质是编译时执行的函数。

二、 用ts装饰器给Koa包装一个路由分发器

基本路由分发实现

一个基本的路由分发机制大概长这样,通过匹配的路由执行相应的方法

// 文件路径:/src/routers/user.ts
import * as Koa from 'koa'
import { get, post } from '../utils/decors'
export default class User {
    @get('/users')
    public list(ctx: Koa.Context) {
        ctx.body = { ok: 1, data: [{ name: 'zhangsan', age: 18 }] }
    }

    @post('/users')
    public add(ctx: Koa.Context) {
        ctx.body = { ok: 1 }
    }
}

装饰器方法可以这样写

// 文件路径:/src/utils/decors.ts
import * as KoaRouter from 'koa-router';
const router = new KoaRouter()

export const get = (path: string) => {
    return (target, propertyName, descriptor) => {
        // 注册路由和对应的中间件
        router.get(path, target[propertyName]);
    }
}

export const post = (path: string) => {
    return (target, propertyName, descriptor) => {
        // 注册路由和对应的中间件
        router.get(path, target[propertyName]);
    }
}

export const load = (): KoaRouter => {
    //这句代码的目的就是为了让src/routers/user.ts里的 User类参与编译,在编译阶段 router 注册路由和对应的中间件
    require('../routes/user'); 
    return router
}

接下来启动服务器

// 文件路径:/src/index.ts
import * as Koa from 'koa'
import * as bodify from 'koa-body';
import { load } from './utils/decors';
const app = new Koa()
app.use(
    bodify({
        multipart:true,
        strict:false
    })
)
const router = load(); //在调用 load 方法时 router 已经注册了路由和对应的中间件
app.use(router.routes())
app.listen(3000 ,() => {
    console.log('服务器启动成功。。')
})

这样一个简易的路由分发就写好了,已经可以正常工作了,但是在 /src/utils/decors.ts 文件下的装饰器方法 getpost 的代码是重复的,可以用函数柯里化让代码变得更优雅

// 文件路径:/src/utils/decors.ts
import * as KoaRouter from 'koa-router';

type HTTPMethod = 'get' | 'put' | 'del' | 'post' | 'patch'
const router = new KoaRouter()

const decorate = (method: HTTPMethod, path: string, router: KoaRouter) {
     return (target, propertyName, descriptor) => {
        // 注册路由和对应的中间件
        router[method](path, target[propertyName]);
    }
}

const method = method => (path: string) => decorate(method, path, router)
export const get = method('get')
export const post = method('post')
export const put = method('put')
export const del = method('del')

export const load = (): KoaRouter => {
    //这句代码的目的就是为了让src/routers/user.ts里的 User类参与编译,在编译阶段 router 注册路由和对应的中间件
    require('../routes/user'); 
    return router
}

路由守卫功能

改造之后的代码清爽了很多,我们可以让装饰器为我们做更多的事,现在装饰器里只有一个路径参数,可以再添加一个中间件参数,实现一个类似路由守卫的功能。

// 文件路径:/src/routers/user.ts
import * as Koa from 'koa'
import { get, post } from '../utils/decors'
export default class User {
    @get('/users')
    public list(ctx: Koa.Context) {
        ctx.body = { ok: 1, data: [{ name: 'zhangsan', age: 18 }] }
    }

    @post('/users', {
        middlewares: [
            async function validation(ctx: Koa.Context, next: () => Promise<any>) {
                // 用户名必填
                const name = ctx.request.body.name
                if (!name) {
                    ctx.body = { code: 1, msg: '请输入用户名'};
                } else {
                     // 省略校验过程
                     // 校验通过
                    await next();
                }
            }
        ]
    })
    public add(ctx: Koa.Context) {
        ctx.body = { ok: 1 }
    }
}

post 装饰器上添加了第二个参数,目的就是当匹配到路由时需要先执行第二个参数里的中间件函数来检验用户名,校验通过之后才能执行 add 方法,装饰器文件里需要做对应的修改。

// 文件路径:/src/utils/decors.ts
import * as Koa from 'koa';
import * as KoaRouter from 'koa-router';

type HTTPMethod = 'get' | 'put' | 'del' | 'post' | 'patch'
type RouteOptions = {
    middlewares?: Array<Koa.Middleware>
}
const router = new KoaRouter()

const decorate = (method: HTTPMethod, path: string, options: RouteOptions = {}, router: KoaRouter) {
     return (target, propertyName, descriptor) => {
         const middlewares = []
         if (options.middlewares) {
             // 收集参数里的中间件
             middlewares.push(...options.middlewares)
         }
         middlewares.push(target[property])
        // 注册路由和对应的中间件
        router[method](path, ...middlewares)
    }
}

const method = method => (path: string, options?: RouteOptions) => decorate(method, path, options, router)
export const get = method('get')
export const post = method('post')
export const put = method('put')
export const del = method('del')

export const load = (): KoaRouter => {
    //这句代码的目的就是为了让src/routers/user.ts里的 User类参与编译,在编译阶段 router 注册路由和对应的中间件
    require('../routes/user'); 
    return router
}

方法装饰器上的中间件已经处理好了,那么如果类装饰器上有中间件怎么弄呢,比如,只有当 token 校验通过后才能匹配类里的路由,代码如下:

// 文件路径:/src/routers/user.ts
import * as Koa from 'koa'
import { get, post, middlewares } from '../utils/decors'

// 类装饰器
@middlewares([
    async function guard(ctx: Koa.Context, next: () => Promise<any>) {
        if (ctx.header.token) {
            await next();
        } else {
            ctx.body = { code: 1, msg: '请登录'};
        }
    }
])
export default class User {
    @get('/users')
    public list(ctx: Koa.Context) {
        ctx.body = { ok: 1, data: [{ name: 'zhangsan', age: 18 }] }
    }

    @post('/users', {
        middlewares: [
            async function validation(ctx: Koa.Context, next: () => Promise<any>) {
                // 用户名必填
                const name = ctx.request.body.name
                if (!name) {
                    ctx.body = { code: 1, msg: '请输入用户名'};
                } else {
                     // 省略校验过程
                     // 校验通过
                    await next();
                }
            }
        ]
    })
    public add(ctx: Koa.Context) {
        ctx.body = { ok: 1 }
    }
}

在类上有一个装饰器,只有通过类上装饰器的中间件检验后才能匹配后续的路由,这里需要注意一个问题,类上的装饰器执行时间是最后的,为了实现路由守卫的功能,现在需要让类上的装饰器最先执行,代码实现如下:

// 文件路径:/src/utils/decors.ts
import * as Koa from 'koa';
import * as KoaRouter from 'koa-router';

type HTTPMethod = 'get' | 'put' | 'del' | 'post' | 'patch'
type RouteOptions = {
    middlewares?: Array<Koa.Middleware>
}
const router = new KoaRouter()

const decorate = (method: HTTPMethod, path: string, options: RouteOptions = {}, router: KoaRouter) {
     return (target, propertyName, descriptor) => {
         // 用异步来收集中间件和注册路由,可以确保收集中间件时,
         // 类装饰器里的中间件已经加到了原型对象上
         process.nextTick(() => {
             const middlewares = []

             //收集类装饰器上的中间件
             if (target.middlewares) {
                 middlewares.push(...target.middlewares)
             }

             //收集方法装饰器上的中间件
             if (options.middlewares) {
                 // 收集参数里的中间件
                 middlewares.push(...options.middlewares)
             }
             middlewares.push(target[property])
            // 注册路由和对应的中间件
            router[method](path, ...middlewares)
         })
    }
}

const method = method => (path: string, options?: RouteOptions) => decorate(method, path, options, router)
export const get = method('get')
export const post = method('post')
export const put = method('put')
export const del = method('del')

// 类装饰器
export const middlewares = (middlewares: Koa.Middleware[]) => {
    return function (target) {
        // 将中间件存到类的原型对象上
        target.prototype.middlewares = middlewares
    }
}

export const load = (): KoaRouter => {
    //这句代码的目的就是为了让src/routers/user.ts里的 User类参与编译,在编译阶段 router 注册路由和对应的中间件
    require('../routes/user'); 
    return router
}

上面的代码通过在方法装饰器上用异步收集中间件的方式巧妙的实现了让“类装饰器先执行”的目的,已经有了比较完备的路由守卫能力,自此整个功能实现完毕。

三 总结

本文通过先介绍ts装饰器的基本知识入手,到用ts装饰器在 Koa 框架上封装了一个有路由守卫能力的路由分发功能。

ts装饰器基础知识包括:ts装饰器分为 类装饰器、方法装饰器、属性装饰器、方法参数装饰器。

当多个装饰器装饰同一个声明时的执行顺序为洋葱模型:

  • 首先,由上至下依次对装饰器表达式求值,得到返回的真实函数(如果有的话)
  • 而后,求值的结果会由下至上依次调用

同时有多种装饰器的执行顺序为:

  • 类装饰器最后执行
  • 方法装饰器、属性装饰器、方法参数装饰器的执行顺序与他们在类里的书写顺序一致

装饰器本质就是编译时执行的函数。