@koa/router 为 koa 带来了 express 一样通过 router.get('/api') 等方法定义路由的能力。但这样定义路由的方法过于传统了,flask 就使用了 Python 的装饰器来简化路由的定义。开源社区也有很多项目,通过 Typescript 的装饰器 (decorators) 简化路由定义。
@app.route("/")
def hello_world():
  return "<p>Hello, World!</p>"
什么是装饰器
官方解释: Decorators provide a way to add both annotations and a meta-programming syntax for class declarations and members.
装饰器是为类和类成员提供注解和元编程语法的一种方式。还是抽象,看下面的一个例子吧。
由于装饰器还是 Typescript 中的一个实验性功能,如果需要在 Typescript 代码中使用装饰器,需要在 tsconfig 配置中添加
experimentalDecorators: true
假设我们对于需要存储的实体都需要生成一个随机 ID 与创建时间字符串,没有装饰器之前,我们可以定义一个 Entity 类
class Entity {
  id: number;
  created: string;
  constructor() {
    this.id = Math.floor(Math.random() * 1000);
    this.created = new Date().toLocaleDateString();
  }
}
有了 Entity 类之后,我们就可以以此为基础定义我们需要的数据结构,例如定义 User 和 City 两个类
class User extends Entity {
  name: string;
  constructor(name: string) {
    super();
    this.name = name;
  }
}
class City extends Entity {
  zipCode: string;
  constructor(zipCode: string) {
    super();
    this.zipCode = zipCode;
  }
}
const swi = new User('swi');
const nanjing = new City('210000');
使用装饰器,可以这样写
function Entity() {
  return function <K extends { new (...args: any[]): {} }>(constructor: K) {
    return class extends constructor {
      id = Math.floor(Math.random() * 1000);
      created = new Date().toLocaleDateString();
    };
  };
}
@Entity()
class User {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}
@Entity()
class City {
  zipCode: string;
  constructor(zipCode: string) {
    this.zipCode = zipCode;
  }
}
需要注意的是,使用装饰器的情况下,无法访问装饰器新增的字段,例如上面的例子,我们无法通过 new User('swi').id 或 new User('swi').created 访问 id 以及 created。这是一个已知问题,目前我们可以通过额外定义 interface 来解决,不过这个问题并不影响本文需要实现的功能。
interface EntityType {
  id: number;
  created: string;
}
interface User extends EntityType {}
之后就可以访问 id 与 created。上面的 Entity 属于类装饰器,装饰器共有如下几种类型
- 类装饰器 - Class Decorators
- 方法装饰器 - Method Decorators
- 属性装饰器 - Property Decorators
- 访问器装饰器 - Accessor Decorators
方法装饰器 - Method Decorators
core-decorators 提供了一些常用的装饰器示例,虽然已经被 archived 不过参考里面的示例,可以窥探一些装饰器的用法。例如其中提供的 @deprecate 装饰器,用于标记废弃的函数。当调用被标记为废弃的函数时,控制台会打印废弃信息。
type DeprecatedProps = {
  // 过期信息
  message: string;
};
function Deprecated(props?: DeprecatedProps) {
  return function (
    target: Object,
    propertyKey: string | symbol,
    descriptor: PropertyDescriptor
  ) {
    const original = descriptor.value as Function;
    const key =
      typeof propertyKey === 'string' ? propertyKey : propertyKey.toString();
    const msg = props?.message ?? 'has been marked deprecated.';
    descriptor.value = function (...args: any[]) {
      console.warn(`DEPRECATED ${target.constructor.name}#${key}: ${msg}`);
      return original.call(args);
    };
  };
}
在之前的例子里,给 User 的 retreat 方法加个装饰器
@Deprecated({ message: '已经废弃' })
retreat() {
  console.log('retreat is executed');
}
此时再次调用 retreat 就可以在控制台看到废弃的警告信息了。
属性装饰器 - Property Decorators
属性装饰器和方法装饰器相比,没有 descriptor 参数。还以上面的 User 类为例,新增一个 age 属性用来表示用户的年龄,年龄一定是正数。不使用装饰器的情况下,我们需要重写 age 的 setter 方法,并在函数内部做判断。借助装饰器,可以将比较逻辑统一封装起来,避免重复实现。
import 'reflect-metadata';
function PositiveInteger(target: object, propertyKey: string | symbol) {
  const keyType = Reflect.getMetadata('design:type', target, propertyKey);
  const key =
    typeof propertyKey === 'string' ? propertyKey : propertyKey.toString();
  if (keyType.name !== 'Number') {
    throw new Error(
      `The type of ${target.constructor.name}#${key} must be number.`
    );
  }
  let value: typeof keyType;
  Object.defineProperty(target, propertyKey, {
    set(newValue: number) {
      if (newValue <= 0) {
        throw new Error(
          `ERROR: ${target.constructor.name}#${key} must be positive!`
        );
      }
      if (newValue !== Math.floor(newValue)) {
        throw new Error(
          `ERROR: ${target.constructor.name}#${key} cann't be a float number`
        );
      }
      value = newValue;
    },
    get() {
      return value;
    },
    configurable: true,
  });
}
接下来给 age 属性添加 @PositiveInteger 装饰器。
user.age = -12;
运行代码,在控制台就能够看到报错了。
看起来
@PositiveInteger实现了需求,不过也存在如下两个问题。
user.age = -12的报错只会在运行时报错
@PositiveInteger的实现方式会导致Object.keys(user)不会返回age属性,目前看来这是一个已知问题
访问器装饰器 - Accessor Decorators
访问器装饰器作用于 Property Descriptor 来对访问器方法进行监听、修改等操作。仍然以上文中的年龄属性为例,通过访问器装饰器实现一样的功能。
访问器装饰器相较于属性装饰器,descriptor 参数又回来了
function Positive() {
  return function (
    target: any,
    propertyKey: string | symbol,
    descriptor: PropertyDescriptor
  ) {
    const originalSet = descriptor.set;
    const key =
      typeof propertyKey === 'string' ? propertyKey : propertyKey.toString();
    descriptor.set = function (...args: [number]) {
      const newValue = args[0];
      if (newValue <= 0) {
        throw new Error(
          `ERROR: ${target.constructor.name}#${key} must be positive!`
        );
      }
      originalSet?.apply(this, args);
    };
  };
}
上面的代码实现了一样的功能,访问器装饰器可以访问到 Property Descriptor 通过覆盖原先的 set 方法,在赋值前做一层逻辑判断。接下来需要修改 User 类的实现,访问器装饰器不能直接修饰属性。
class User {
  private _age?: number;
  get age(): number {
    return this._age ?? 0;
  }
  @Positive()
  set age(newValue: number) {
    this._age = newValue;
  }
}
装饰器简化路由定义
在了解了装饰器的定义以及基本用法之后,就可以开始着手结合 Koa 来简化路由的定义了。参考网络上 nodejs 项目的目录结构,大致可以整理出如下的结构。
server/
  controllers/     -- 控制器,例如同一路由地址,都指向了 A 控制器,那么 A 控制器就负责不同请求方法分发到不同的 service 中
    /v1            -- v1 版本 API
      products.ts
    /v2            -- v2 版本 API
      user.ts
  routes/          -- 路由层,将请求转向对应的 controller
  services/        -- 服务层,业务逻辑
通常每次新定义路由,都需要在 routes 目录做修改,再指向不同的 controllers。通过装饰器,定义新 API 路由时只需要在 controllers 的方法添加相应的装饰器方法,以一个 Products 为例,最终结果应当如下。
@Controller(
  '/products',
  /* 应用于所有 /products 路由的中间件 */ setResponseTime
)
class Product {
  @Get('/') // 定义请求方法与路由
  async getProducts() {}
  @Get('/:id', /* 只应用于当前 API 的中间件 */ middlewareFunction)
  async getProduct() {}
  @Delete('/:id')
  async deleteProduct() {}
  @Post('/:id')
  async createProduct() {}
}
通过继承 @koa/router 中的 Router,添加一个 load 方法,指定 controllers 的文件路径。例如,对于本节刚开始介绍的文件结构而言,使用新的 Router 构造路由的代码如下
const router = new Router({
  prefix: '/api/v1',
});
router.load(require('path').join(__dirname, './controllers'));
这样就完成了对 /products 相关路由的定义。
| Http Method | Path | 
|---|---|
| GET | /api/v1/products | 
| GET | /api/v1/products/:id | 
| Delete | /api/v1/products/:id | 
| Post | /api/v1/products/:id | 
import Twemoji from ’@/components/Twemoji.tsx’;
ttask 项目使用了 monorepo,装饰器工具就作为单独的 package 来管理。在 packages 目录下新建 nflask 文件夹,没错
@Controller 实现
@Controller 是一个类装饰器,用于修饰 controller 类,定义如下:
function Controller(basePath: string, ...middlewares: Middleware[]) {
  return (target: any) => {
    target.basePath = basePath;
    target.middlewares = middlewares;
  };
}
内部实现非常简单,就是将传递的参数,作为类的两个新属性 (类似 class 默认的 name 属性)
Http 请求方法的实现
以 GET 方法为例,直接看代码
function Get(path: RouterPath, ...middlewares: Middleware[]) {
  return (target: any, name: string, descriptor: PropertyDescriptor) => {
    descriptor.value.method = 'get';
    descriptor.value.path = path;
    descriptor.value.middlewares = [...middlewares, target[name]];
    return descriptor;
  };
}
和 @Controller 的实现思路一致,但略有差异,方法装饰器的传参多了 PropertyDescriptor 类型的参数,这样我们就能够修改原型链,将需要设置的参数保存起来。
load 方法实现
首先,定义新的 Router 类,继承自 KoaRouter,同时定义一个 load 方法,用来加载指定路径下的 controllers 文件。
class Router extends KoaRouter {
  constructor(options: RouterOptions = {}) {
    super(options);
  }
  map(DecoratedClass: any, options: LoadOptions = {}) {
    handleMap(this, DecoratedClass, options);
  }
  load(dir: string, options: LoadOptions = {}) {
    handleLoadDir(this, dir, options);
  }
}
加载完指定路径下,所有 controller 之后,就需要调用 @koa/router 提供的类似 express 的 Http 方法函数,例如 xx.get() 或 xx.post() 等。这里只看关键代码
const handleMap = (
  router: Router,
  DecoratedClass: any,
  _optinos: LoadOptions
) => {
  if (!DecoratedClass) return;
  // 1.
  const basePath = DecoratedClass.basePath;
  if (basePath) router.prefix(basePath);
  // 2.
  const staticMethods = Object.getOwnPropertyNames(DecoratedClass)
    .filter(method => !RESERVED_METHODS.includes(method))
    .map(method => DecoratedClass[method]);
  // 3.
  const DecoratedClassPrototype = DecoratedClass.prototype;
  const methods = Object.getOwnPropertyNames(DecoratedClassPrototype)
    .filter(method => !RESERVED_METHODS.includes(method))
    .map(method => DecoratedClassPrototype[method]);
  [...staticMethods, ...methods]
    .filter(item => {
      const { method, path } = item;
      return path && method;
    })
    .forEach(item => {
      let baseMiddlewares: Middleware[] = DecoratedClass.middlewares ?? [];
      const { method, path, middlewares } = item;
      // 4.
      (router as any)[method](path, ...baseMiddlewares, ...middlewares);
    });
};
简单说明一下上述代码
- 获取 @Controller装饰器中定义的基础路由,使用prefix方法作为当前controller的路由前缀
- 获取当前类中的 static静态方法
- 获取当前类中的所有其他方法
- 根据方法原型链中设置好的 method和path属性,调用@koa/router的相应方法即可