Routing-Controllers 中自动生成 OpenAPI 3.0 规范文档

在写这篇文章之前,我觉得有必要交代一下前提背景。之前项目我是基于 Node.JS 使用 TypeScript 进行开发,在路由这一块我是使用 routing-controllers 来做的,这个库写起来代码比较易读,该有的都有了,所以一开始觉得没有文档的必要性。每个路由的代码是这样的:

/**
 * 获取新闻列表
 */
@Get('/news')
async getArticleNewsList(@QueryParams() query: IArticleQuery): Promise<Article[]> {
    return this.articleService.getArticleNewsList(query)
}

从这里看,说明,路由的 methodurl,以及 query 的信息和返回值的信息都已经具备了。但是前端看不习惯,说要个文档,刚好自己也有兴趣接触下这一块文档自动生成,于是就开始动手。但是过程并没有自己想象的顺利,中间还是遇到了很多问题的,不过最终还是顺利的生成了一份勉强能用的文档了,代码我已经放到 GitHub 上了:https://github.com/ruiming/routing-controllers-openapi

接下来说一说我遇到的问题和解决的过程,以及思路。

问题

要做到文档自动生成并非一件容易的事情。特别是对于 TypeScript 这样的一个编译时的语言,我这里说编译时,是因为 TypeScript 的一些特有语法和 interface 这些概念,在运行时是不存在的。

如果换成 Java 或者 C# 之类的强类型语言,运行时我们可以通过反射得到数据的类型。但 TypeScript 的运行时其实就是 JavaScript 的运行时,不存在什么 interface 之类的东西。

对于上面的代码,基本的因素都已经具备了,我们要做的是把它整理输出到文档中。但我们怎么拿到类型信息呢?

另外,我们应该输出什么样的文档呢?

再有一个问题,比如上面代码,获取新闻列表,这里写法是比较简单的写法,虽然我们查询的时候并不一定会把全部数据都查出来,但是我在写这个的时候,并不关心返回值类型的准确性,这里准确性的意思是代表了真实返回值的结构,所以我都一概以表 Article 的接口代之。

简而言之,三个问题:

  1. 怎么得到类型信息
  2. 输出怎样格式的文档
  3. 返回值类型的处理

怎么得到类型信息

我最开始尝试的方案是从运行时,利用 routing-controllersmetadataArgsStorage 着手。routing-controllers 把路由的信息都整理到了 metadataArgsStorage 里面,可以通过 getMetadataArgsStorage 方法获取到。

这些信息包括非常齐全,但是单独缺乏一个信息,就是参数类型以及函数返回值类型。

这个问题困扰了我一段时间,我知道通过反射可以拿到一个类中的一个属性的类型(JS 类型),但是怎么拿到函数里面的参数类型确实一开始不太清楚。

所以后面我打算去写正则去做匹配,因为路由的书写还是比较有规律的。果不其然,我顺利的拿到了不少信息,但是,在函数参数的匹配上面,问题出来了,因为涉及到括号匹配这个正则语法解决不了的问题,所以我又得写一些比较坑的方法去获取,做到这里就继续不下去了。

然后我就开始用另一种方案了,既然类型不存在于运行时,那我就手动代码加一下,通过给路由方法添加元数据的方式来实现:

@Get('/news')
@Reflect.metadata('query', 'IArticleQuery')
@Reflect.metadata('res', 'Article[]')
async getArticleNewsList(@QueryParams() query: IArticleQuery): Promise<Article[]> {
    return this.articleService.getArticleNewsList(query)
}

然后在去分析 routing-controllers 的数据,利用反射取出 queryres 数据即可。

但是这种方式无疑还是过于麻烦。最后在看 TS 编译输出代码的时候看到了下面这些代码:

__decorate([
    docHelper_1.Description('获取新闻置顶文章'),
    routing_controllers_1.Get('/news/headline'),
    __param(0, routing_controllers_1.QueryParams()),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", [Article_1.IArticleHeadlineQuery]),
    __metadata("design:returntype", Promise)
], ArticleController.prototype, "getArticleNewsHeadlineList", null);

tsconfig.json 中配置 emitDecoratorMetadatatrue 时,TS 会在编译时将一些类型信息通过元数据的方式加到方法里面去。这里的 __metadata 可以通过反射获取到,如下:

Reflect.getMetadata('design:paramtypes', target, action.method)

这样我们就可以拿到函数参数的类型了。返回值类型也可以拿到,但由于都是异步方法,所以这里结果都是 Promise。丢失了泛型信息,这个查了一些资料,目前没有什么办法(https://github.com/Microsoft/TypeScript/issues/3628)。针对这个情况,社区一般的做法是改写了 TSC。

既然如此,那就结合上面所说的通过多余的装饰器方式来补充返回值类型,这样不就可以了。最终代码大概这样子:

@Description('获取新闻列表')
@ResType([Article])
@Get('/news')
async getArticleNewsList(@QueryParams() query: IArticleQuery): Promise<Article[]> {
    return this.articleService.getArticleNewsList(query)
}

我这里多定义了两个装饰器,分别是 @Description@ResType,作用分别是补充路由说明信息和路由返回类型信息。

但是,上面的用法存在一个前提,就是所有的类型,除了是 JS 自建类型之外必须是 class。因为 class 在运行时是存在的,但是 interface/type 等都是不存在的。而且,在 @QueryParams 以及 @Body 所装饰的数据的类型,不能使用类型操作符。@ResType 也不能接收 interface 作为参数,这里只能传入一个 Class 或者 Function。

但这并不意味着要将大量的 interface 改用 class 实现,因为其实项目中基本都是使用了 class 而不是 interface 了。这是因为 class 可以通过装饰器来加入一些规则,从而在 routing-controllers 中可以用来做校验使用。

例如对于 IArticleQuery 这个 class ,我是这样写的:

export class IArticleQuery implements Partial<Article> {
  @IsOptional()
  @IsNumber()
  categoryId?: number

  @IsNumber() limit: number = 10

  @IsNumber() offset: number = 0
}

这里的装饰器,以及默认值都是 interface 不具备的。

之所以使用 class 而不能使用 interface,原因前面也说了,class 存在于运行时,TS 编译之后会把 class 作为函数参数类型加到元数据上面。如果不是一个 class 或者使用了类型操作符,那么 TS 编译之后只会把它视为一个 JS 对象。我们就无从得知里面有什么东西了。

说这么多,目的都是为了运行时我们能拿到那个类或者具体的类名或者 JS 内建类型。

当我们把这些类型都使用 class 来写的时候,我们就可以这样声明返回值类型了:

@ResType([Article])
@ResType(Article)

[Article] 表示返回值是一个 Article 数组。我没有想到其他好的方式,所以暂时就先这么用了。

类型获取问题解决。

输出怎样格式的文档

这个问题其实我心里早早就有了答案,就是输出 swagger 格式的文档。之前有接触了解过了 swagger 这个库,感觉它的文档呈现效果也不错。所以一开始我就是往生成 swagger 文档方向尝试的,不过后面看到了 OpenAPI 3.0 的规范,swagger-ui 也支持进行解析展示,所以后面又改到了输出 OpenAPI 3.0 的文档。

一开始也有考虑过自己输出一个自己定义的格式的文档,但是似乎成本较大,所以就放弃了。社区已经有很多不错的方案,感觉就没必要重复造轮子了。

返回值类型的处理

第一个问题已经解决了返回值类型的获取问题。这个问题其实我这边没有解决,也不打算解决。虽然现在文档的输出的数据结构不太准确,但起码涵盖了目前服务端能提供的数据的所有可能。而且有些数据,前端是可以根据业务场景无视的。

但是除了这个问题,其实还有另一个问题,就是循环引用的问题。比如说,我在设计 Comment 表的时候,保存了一个指向上一条 Comment 的引用 preCommentId,这个 Comment Model 我还有 preComment 这个字段。它存在的作用是,当我关联 preCommentId 的时候,preCommentId 的记录会填充到 preComment 中。也就是说 Comment Model 其实是真实的数据库 Model 和我 ORM 查询返回结果 Model 的合并。

因为这点,在查询评论的时候,返回值类型就会出现无限递归的情况。swagger-ui 对于这种情况的处理是输出了空对象。。。这是 Comment 关联自身的情况,当然还会存在 A 关联 B,B 关联 A,或者 B 通过 C 间接又关联回 A 的情况。这些情况都是需要去避免处理的。

对于这个问题,我觉得并不是说我最终写的这个文档生成器没有处理的问题,我更认为这个是我返回值的类型不准确的问题。也许,给每一个路由定义一个返回值的准确类型,结合 nullable 的使用,可以避免这个问题?

文档自动生成

我最后写了一个库自动生成文档,代码已经放到了 GitHub 上面:https://github.com/ruiming/routing-controllers-openapi

尴尬的是,当我想要发布到 NPM 的时候,发现这个包名已经被人起了。。。去看了下那人写的这个库,目的都是想要在运行时输出文档,但是做法还是挺不同的,我没有怎么去看他写的代码,文档的话也写不是很清楚,但看起来没有我的方便。

我现在写的这个文档自动生成器,还是存在诸多限制和要求的,但只要遵循(其实也不难遵循,我对之前的项目并不需要做太多改动),就可以生成一份我个人认为勉强能看的文档。。。

之所以说是勉强能看,是因为现在还有很多细节问题没处理,除了上面说的返回值类型的问题之外,对于 null 目前我也是没有去处理的。还有好几个其他方面的小问题没有去解决。

后话

其实一开始考虑的方案还有另一个,是我一个朋友写的 test2doc(https://github.com/stackia/test2doc.js),它的原理是基于你对路由进行的测试,记录你的路由请求和返回值,然后输出文档。

但这个方案存在两个问题:

  1. 一个路由只能进行一个测试,否则最终文档可能混乱。
  2. 只能使用 should 断言好像,我习惯用 assert,没法使用。

我目前项目,路由的测试是主体,而且朝着测试覆盖率去测试的,意味着一个路由我可能换不同的参数测试多次。另外,断言库的选择我是用 power-assert,test2doc 也无法正常工作。

test2doc 有一个不错的出发点,它可以得到真实的返回数据,但是它的前提是你路由已经开发完成了。综上,我没有使用这个库。

再说回最开始前端对我提的这个写文档的建议。其实我自己是不太喜欢做重复工作,自己去手写文档那是不可能的(如果比较少的话还有可能)。前端一开始建议我写请求示例和返回示例,这个让我头很大。主要原因是,有好几个接口数据不是从我数据库来的,而是调用其他服务得来的,所以我自己不尝试调用一次并不知道它会返回什么。感觉要写返回示例就很不情愿了0.0,而且这让我想起之前在阿里实习的时候,也吐槽了不少内部的接口文档问题,我当时跟 Java 争执的一点就是他们没有给我返回示例。。。Java 的跟我说数据是底层来的,底层接口没开发好他们也不知道是什么。。。

不过我这个跟阿里的情况不太一样,毕竟我产出的还是一份程序生成的带有粗略的返回值类型的结构化的文档,经由 swagger-ui 解析生成 HTML。而在阿里的情况,文档是用 Markdown 手写的,这个非结构化给我带来了不少困扰,比如服务端的数据结构是用表格形式展现,我这边只能对着改写成 interface,不是特别方便。