TypeScript 中装饰器的(错误)用法——以编写 Web API 为例

/ 0评 / 0

事实上我们是离不开装饰器的。任何希望能借助反射进行各种操作的语言都是离不开装饰器的。正确使用装饰器有益身心健康。

因为最近去了一趟 Ignite, 所以对 ASP.NET Core 产生了些许兴趣。但是由于电脑不给力,Rider 一步三卡,Linux 下又没有 Visual Studio 可用,而且配置上也出了不少问题,于是作罢,回到 Node 的怀抱。

但是毕竟是在 C# 里蹚了一圈(约 15 分钟),对于其中的一些设计觉得还挺方便的,比如:

[ApiController]
[Route("[controller]")]
public class ExecController : ControllerBase
{
    [HttpPost]
    public Message OnCommand([FromBody] RawCommand cmd)
    {
        var username = HttpContext.Session.GetString("username");
        // ...
        return new Message(cmd.Command);
    }
}

这就看起来很让人感觉很清爽,至少比这样写好得多:

app.use(parser.json())
app.post('/exec', (req, res) => {
  let cmd = req.body;
  let uesrname = req.session['username'];
  // ...
  return new Message(cmd.command);
});

于是就想,能不能利用某种方法,让我能写出像这样的玩意呢?

@Service('/')
class ExecService {
  @HttpPost('/exec')
  public exec(@FromSession username: string, @FromBody cmd: Command): Message {
    // ...
    return new Message(cmd.command);
  }
}

实际上是可以的。借助 TypeScript 的装饰器能力,我们第三段的代码是完全可行的。(毕竟要是不行我也就不会写了。就像没人会发表失败的实验的论文一样。尽管我们说没有失败的实验,只有错误的实验,但是也没见多少论文是说某个实验统计学不显著所以提出的假设被推翻,大多数都是评论或者什么。当然这个就扯远了。)

什么是装饰器

直到发稿时,ECMAScript 还没有正式接收装饰器提案,所以尽管 TypeScript 有装饰器这个东西,但实际上还是通过一大堆的 Hack 来实现的。这个 Hack 的核心思路在于当一个模块(或者说文件)被加载的时候,将被装饰的各个内容依次喂入函数并组装成一个完整的模块。当然这里只是十分粗略(甚至错误地)描写了这个情况,不过就生成的 JS 代码而言其功能基本如同描述。

所以,TypeScript 中的装饰器是一个函数——当然也可以是高阶函数。应用于不同目标的装饰器有不同的参数,这个会在之后更详细地进行说明。

装饰器的应用顺序是有严格定义的,总结起来是先参数、再成员、再方法、再构造函数,最后到类。多个装饰器对同一目标进行修饰时,按照最靠近目标的装饰器优先的顺序进行。

装饰器的语法和其他语言中的语法都十分相似,不再赘述。

启用装饰器

TypeScript 默认是不使用装饰器的,因为这个东西仍然在开发,而且有可能之后会有大的更动,所以想把这个东西用在生产中的同志们可以先按捺一下。

需要在 tsconfig.json 中添加 experimentalDecorators 项目且目标 JS 版本不小于 ES5 才会启用,如下:

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es5",
    "experimentalDecorators": true
  }
}

参数装饰器

那么我们先从参数装饰器开始设计。参数装饰器是不需要额外参数的,可以直接作用于参数本身,还是比较直接的。

参数装饰器的参数按顺序依次为:该类的原型(实例成员)或构造函数(静态成员)、函数名和参数位置。

卧槽,那参数名呢?参数类型呢?这玩意好鸡肋啊!

不过好在参数名还是可以拿到的,因为我们可以通过这些参数拿到方法,拿到方法就可以 toString() 得到函数本体,进而可以提取出函数签名,再分割,得到参数列表。

但是类型就不好弄了。因为 JS 作为弱类型的语言,在运行时类型是被擦除的。要是再单独传入类型参数又让人觉得十分累赘,非常纳闷。好在 TypeScript 提供了元数据生成,可以用 emitDecoratorMetadata 打开,如下:

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es5",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

但是这已然没有啥卵用,因为这个 Metadata 还是不能保存更具体的类型信息。对于自定义对象,他们在元数据内都是 object. 所以对于对象的有效性,不得不交给业务逻辑进行检查。

参数的来源一般有来自请求和来自会话,当然也可以来自请求头或者 Cookie.

我们需要保存参数的来源、类型、名字和位置以供之后使用——我们不能保证参数被处理的顺序,所以保留位置还是必要的。接下来就是具体写出参数装饰器了:

function extractParamInfo(gatherFrom: ParameterSource, ctor: any, propertyKey: string | symbol, index: number) {
  let method = ctor[propertyKey];
  let name = getParameterNames(method)[index];
  let type = Reflect.getMetadata('design:paramtypes', ctor, propertyKey)[index];
  ServicePool.pushServiceParam(gatherFrom, name, type, index);
}

function FromBody(ctor: any, propertyKey: string | symbol, index: number) {
  extractParamInfo(ParameterSource.BODY, ctor, propertyKey, index);
}

// ditto for other sources

ServicePool 是一个单例。这里就需要注意到 ServicePool 必须在服务模块被import 之前初始化完成。

成员装饰器

当然这里主要是希望能实现 @AutoWired 的功能。不过在 JS/TS 当中单例的实现是自然的,而在 Web API 的开发中自动装配基本上就是用来把单例装配到服务当中的。我们没有必要再单独包一层壳。

当然,我们也可以说 @AutoWired 还可以通过外部配置搞依赖注入,这样我编写服务的时候就可以不需要管我调用的组件的具体实现。但是不要忘了,TypeScript 中提供的叫装饰器,不是注解。当然装饰器的能力比注解要强,不过由于我已经做出的设计失误,所以 @AutoWired 可能就不会有了。

方法装饰器

和演示的 ASP.NET Core 不同,在这个设计中,一个类包含一个父路由和多个子路由,用于提供一组操作;比如对用户的操作可以放在 /user/* 下,这里的父路由就是 /user.

一个方法要指出自己的路由、HTTP 方法和返回的类型,以及自己的参数。我们可以利用装饰器的执行顺序来跳过参数这一条——我们在之前已经保存了参数信息,而参数装饰器并不是同时被加载的,而是随着每个方法的加载而加载的。

现在只需要获取路由、方法和返回类型了。这里的唯一难点在于返回类型,不过我们仍然能使用元数据获得——仍然是只能分辨有限的类型;方法装饰器的参数依次为:类的构造函数(静态函数)或者类的原型(实例函数)、函数名。这个也是第一个需要用到高阶函数的装饰器:

function extractRouteInfo(method: HttpRequestMethod, subroute: string, ctor: any, functionName: string) {
  let returnType = Reflect.getMetadata('design:returntype', ctor, functionName);
  ServicePool.pushServiceFunction(subroute, method, functionName, returnType);
}

function HttpGet(route: string) {
  return (ctor: any, name: string) => {
    extractRouteInfo(HttpRequestMethod.GET, route, ctor, name);
  };
}

// ditto for other methods

类装饰器

类装饰器就是设定一个父路由,并表示一个服务已经准备好组装。类装饰器的唯一参数是其构造函数。

function Service(routeBase: string) {
  return (ctor: { new(): Object }) => {
    ServicePool.generateService(ctor, routeBase);
    console.debug(`Assembled service ${ctor.name}`)
  }
}

组装一个服务

经过之前的处理,我们已经按顺序获得了一个类及其方法的具体信息。具体地对应到 Express.js 中,一个服务就是一个包装好的 Router. 所以我们先创建一个 Router, 并设计其子路径。

一个类中的每一个方法都对应一个路由,而路由的处理函数我们也可以直接生成。只需要将必要的内容从 req.{param, query, body} 中剥离出来,再传给方法获得返回值,再将返回值包装为 json 返回即可。

当然注意到,方法可能是异步的。所以需要检查返回的结果是否是 Promise, 如果是的话还需要再通过 .then 获得结果并发送。

这一部分的代码太长就不贴了。之后或许会出现在 GitHub 或者 NPM 上。


现在再来个热更新是不是就更爽了呢?当然,这个问题,我们将在之后再讨论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

Your comments will be submitted to a human moderator and will only be shown publicly after approval. The moderator reserves the full right to not approve any comment without reason. Please be civil.