书接上文:构建下一代 HTTP API - 总览
在构思 Quenya 的时候,我已经有之前 UAPI 和 Goldorin 在生产环境下的应用经验。总结起来,就是 UAPI 有一个很好的结构(见我四年前的文章:再谈 API 的撰写 - 架构),但它做事的顺序反了,先有代码,再有 spec,通过代码生成 spec(当时是 swagger 2.0);Goldorin 纠正了这一做法,通过 spec 来生成代码,但 Goldorin 的问题在于自己定义 spec,并没有深思熟虑。另外,Goldorin 还有两个问题:
- 使用起来还是比较繁琐的,没有提供一个项目生成器,可以一条命令生成 API 项目
- parser / builder / utilities 绑定得太紧,应该拆分开来
结合这些经验教训,Quenya 设定了这样的结构:
- 单独的项目生成器 quenya_installer,负责创建服务端项目。quenya_install 没有任何依赖,它只是把一个模板项目应用到一个目录下,类似于
npm init
,cargo new
,mix new
。新的 API 项目可以通过一条语句生成,再也无需繁琐的设置。 - OpenAPI v3 解析器 quenya_parser,负责解析 spec,并且将解析好的 spec 装入相应的数据结构。
- 服务端的代码生成器 quenya_builder,负责处理整个服务端代码生成的逻辑。quenya_builder 提供一个命令行工具可以随时将 OpenAPI v3 spec 编译成 elixir 代码。未来也许通过代码模板支持 rust 的服务端代码生成(基于 tide)。quenya_builder 里所有代码都运行在编译期。
- 服务端基础库 quenya,提供服务器 API 运行期的各种库函数。生成的代码会调用这些函数在运行时进行相应的处理,比如各种各样的 API middleware。
- 客户端代码生成器 quenya_client_builder,负责处理整个客户端 SDK 生成的逻辑(尚未支持)。quenya_client_builder 也提供命令行工具可以将 OpenAPI v3 spec 编译成客户端 SDK(typescript / kotlin / swift)。quenya_client_builder 里所有代码都运行在编译期。
- 客户端代码基础库 quenya_client,提供客户端 SDK 运行期使用的各种库函数。比如 auth middle ware。
注意,虽然 Quenya 是用 Elixir 撰写的,Quenya 所生成的服务器端代码也是 Elixir 代码,但整个架构和语言关系不大,我也尽量会让文章不涉及太多语言细节导致影响大家的阅读。在这里我所阐述的是我个人对这样一个问题的解决思路,尤其是我如何得到这样一个思路的思考过程。
Quenya 的架构(上)
有了这样一个结构上的大方向,Quenya 的架构并不困难。在具体落地架构前,我们先来探讨一下使用 Quenya 的大致的开发流程,因为这个开发流程和架构中所做的一些决策息息相关:
- 前后端工程师和产品经理一起撰写/审核 OpenAPI v3 spec,敲定 API 的接口。
- 后端工程师用
quenya_installer
创建项目awesome_api
,生成 API 代码,并开始后端的开发和 UT。 - 前端工程师用后端工程师创建好的项目
awesome_api
,生成 client SDK,运行 mocking server,然后在自己的项目里引入 client SDK,开始前端开发和 UT。 - 前后端开发完毕后,两边集成测试。
- 最后 API 部署上线,客户端打包发布。
后续 API 如果要进行不引入 breaking change(接口没有语义上的修改,没有删除,只有添加和弃用声明)的修改:
- 前后端工程师和产品经理一起修改/审核已有的 OpenAPI v3 spec,敲定新的 API 的接口。
- 后端工程师根据修改后的 spec 生成新的 API 代码,开始后端的开发和 UT。
- 前端工程师用
awesome_api
生成新的 client SDK,运行 mocking server,然后引入 cient SDK,开发前端功能,进行 UT。 - 两端集成测试。
- API 部署上线,客户端打包发布。
按照这个流程,以及前文所述 Quenya 子项目的结构,我们可以绘制一个 Quenya 各个子项目之间,以及子项目和用户的 API app / client app 之间的关系图:
我先不一一解释图中的要素,请你自己花个 5-10 分钟思考一下为什么各个项目间是这样一种关系。代码生成属于元编程(meta programming)的范畴,就像代数(Algebra)之于算术(Arithmatic),是进一步的抽象。所以我们想要理解这种抽象,需要先看一个具体的 API 项目是如何架构的,回过头来再想为了得到这样的架构,我们需要怎么做。
API 项目的架构
一个 HTTP API Server 主要是由几部分构成的:
- Router(路由):预先定义好的一张路由表,它决定如何分发流量。一般而言,REST API 的路由根据请求路径(request path)来完成。
- Middleware(中间件):在请求被路由前,可能会运行一系列的中间件对请求做预处理。
- Handlers(路由的处理函数):每条路由会有一个到多个路由处理函数,它们依次处理请求(Request)对象,并更新响应(Response)对象。
- Hooks(钩子):在 API 的整个处理流程中,开发者可以插入一些钩子函数,以便在特定的上下文完成一些特殊处理。比如
before_send
就是一个很重要的钩子,我们常常用其来做一些日志处理,性能分析等。
用图像表示更直观一下:
这里为了示意,我放了两个 hooks:before_routing
和 before_send
。我们不必纠结 hooks 是否足够,是否需要 after_routing
,after_send
等,这个不重要。事实上用的最多的 hook 是 before_send
,其它的随需而设即可。
这个结构从架构的角度看没有问题,事实上几乎所有的极简主义 web 框架( minimalist web framework,比如 nodejs 的 express,python 的 flask,Elixir 的 plug,rust 的 tide)都是这样的结构。但具体开发的时候,我们依旧会发现两个不算严重但最好能够改善的问题:
- 框架能提供的是公共组件,如何组织和使用这些公共组件依旧需要开发者花功夫处理
- handler 本身的撰写依旧会非常复杂,冗长,且充满重复的脚手架性质的代码,其复用性不高
后者的问题尤其突出。上一篇文章我展示了 UAPI 当时的设计:
尽管我们在框架上做了很多公共环节的处理,让开发者只需要撰写 API 接口的 schema 的定义和实现 route action(相当于 handler),但在 UAPI 过去几年的使用过程中我还是看到,水平一般的开发者依旧会写出质量一般的 route action,所以我们还需要进一步地优化这个结构。
解决的方案并不复杂:
- 根据 spec 我们可以在编译期就知道我们需要哪些公共组件,以及如何组织它们。这一块我们可以把大部分代码自动化。
- handler 既然会变得冗长,复用性不高,那么我们就进一步将其的主要动作拆分,并提供足够多的可重用的组件。这些组件,一些跟业务无关,可以在框架内完成;一些跟业务相关,框架只能提供足够好的外围工具让开发者可以很方便地把复杂的 handler 拆分成可重用的组件。
多说两句框架。开发者的工作语言是非常灵活的,可以解决的问题无边无际,没有限制;而框架的作用是添加限制,规定边界,让开发者「有所为有所不为」,从而更好地解决特定问题。框架会制约有能力的程序员产出更高效的代码(好处是大大节约了他们的时间),但是会极大地拉高能力一般的程序员的产出水准。这样,同一个框架下的代码,水平差别不会太大,可读性会更强,也更容易维护。
有了这样一个初步的考量后,接下来要思考的是哪些代码可以由框架本身完成,哪些代码需要开发者手工完成。虽然在这两者间划一条清晰的界限是很难的,但我们的目标是:让开发者尽可能少些代码,并且, 尽可能只写组件(building blocks),而粘合组件的工作,应该由框架来辅助。比如说下图标绿的部分可以由框架完成:
但如何能够由框架完成 routing 以及 API handler 内部的组件呢?它们跟具体的项目息息相关。别忘了,我们可以依赖 API spec 生成相应的代码,这就是整个 Quenya 项目所需要做的事情了。
Quenya 的架构(下)
Quenya Builder
我们回到这张架构图,把焦点放在 quenya_builder 上:
因为我们想要尽可能地减少开发者手工撰写的代码,所以我们要从 OpenAPI spec 中找到可以为这个目标服务的部分。OpenAPI spec 描述 API 的几个主要内容:
- Servers:API 运行的服务器
- Paths:API 支持的路由,paths 进一步又根据 method 细分,对应一个个 Operation。每个 Operation 描述了请求长什么样子,使用什么样的 MediaType,各种不同状态码下的响应长什么样子,接受那些 MediaType(content negotiation)。
- Schema:几乎所有的数据都可以有对应的 JSON schema,包括:header,path param,query,request body,response。
- Security:API 所用的安全方案。
通过这些内容,我们可以撰写相应的 generator 生成特定的代码:
- Router:API 路由可以根据 paths 生成,无需开发者操心(但整个路由还需要提供 hook 让开发者添加其它辅助路由)
- Request Validator:每个 API operation 都提供请求的详细 schema,因此我们完全可以为每条路由量身定制属于它的 request validator 组件。
- Response Validator:同样,我们也可以生成每条路由的 response validator。在整个处理流程的 pipeline 中,当开发者撰写的业务逻辑走完之后,可以通过 Response validator 验证其 response 是否符合规范(比如 response header 是否正确设置,response body 是否符合 schema)。一般而言,Response validator 只需要在 开发/测试/staging 这样的环境中使用,保证类型问题在很早期的时候就被解决掉。因为 Response validator 额外消耗 CPU 时间,在生产环境可以省略。
- Fake handler:每个 API operation 有定义好的 response schema,我们可以利用这个信息通过 schema 来生成符合 schema 的数据。这样,我们就可以提供开发环境下的 mocking server 了。数据的 mocking 是一个很有挑战的话题,我会单开一篇文章详述。
- Unit test。依赖 API spec 里每个 API Operation 的 Request / Response schema,我们可以生成一个 API 客户端,发送符合 Request schema 的数据,然后验证 API 返回的数据是否符合 Response schema,这样,我们可以在还没有真正撰写 API 的业务逻辑前,就准备好相应的 UT。有关 unit test generator 如何运行,我们单开文章详述。
这些是目前我能想到的和特定 API 处理相关的 generator,也许随着 Quenya 的深入,这个列表还会更长。
Quenya Parser
为了支持 Quenya builder 的各种 generator,我们要把 OpenAPI spec 解析成一个中间状态,供 这些 generator 以及其他工具使用。这就是为何我们抽取出 Quenya Parser 这样一个子项目:
Quenya Parser 非常简单,就是一对一翻译(当然还有一些校验),无需过多解释。
Quenya
考虑到给开发者生成的 API 代码中会用到一些公共组件,而这部分代码会被开发者在生成的项目中引用,我们需要一个新的子项目提供这些功能:
以 Swagger 组件为例。既然是 OpenAPI v3 的项目,我们自然就要集成 swagger,来方便 API 开发者和客户端开发者使用 API,因为它几乎是每个项目必备的工作。那为什么不在代码生成的阶段就集成进去呢?所以 Quenya 会提供 Swagger 组件,让生成好的 API 项目运行起来直接就可以交互,比如 Petstore 的例子:
其他的公共组建和方法都是类似的思路,这里就不赘述。
Quenya Installer
最后,就是易用性的考虑。好的工具一定要有好的用户体验,让用户很轻松地使用。为了让用户能够很快上手 Quenya,一个项目生成器必不可少 — 它可以让用户在没有阅读大量文档的前提下,很快就把项目设置和运行起来,然后跟项目交互,观察其行为。这便是所谓「先上车,后买票」。等开发者在车上一览风光后,便会主动花时间了解更多。安装好 Quenya installer 后,一条命令 友好的提示,可以扫清初学者的障碍(感谢 Phoenix framework,quenya_install 直接摘抄于 Phoenix installer):
代码语言:javascript复制$ mix quenya.new ~/projects/mycode/quenya/parser/test/fixture/todo todo
* creating todo/config/config.exs
* creating todo/config/dev.exs
* creating todo/config/prod.exs
* creating todo/config/staging.exs
* creating todo/config/test.exs
* creating todo/lib/todo/application.ex
* creating todo/lib/todo.ex
* creating todo/mix.exs
* creating todo/README.md
* creating todo/.formatter.exs
* creating todo/.gitignore
* creating todo/test/test_helper.exs
Fetch and install dependencies? [Yn]
* running mix deps.get
* running mix deps.compile
We are almost there! The following steps are missing:
$ cd todo
$ mix compile.quenya
You can run your app inside IEx (Interactive Elixir) as:
$ iex -S mix
If in future you modified config for quenya building options, or changed spec, run:
$ mix compile.quenya
如何让代码能够优雅地协同?
生成的代码和开发者撰写的代码之间如何优雅地协作,是一件必须处理好的事情。否则,每次修改 spec 后重新生成代码,就不得不去手工修改一系列文件,会非常麻烦,影响工作效率。我采取的方式是将生成的组件和开发者自己写的组件都揉在一个 pipeline 中,pipeline 的定义用配置文件完成,而这个配置文件,也会根据 spec 创建出来,以后 spec 修改,配置文件中用户没有修改的部分会随 spec 修改而修改(比如新增的路由),用户修改过的部分不会被覆盖(比如用户对已有路由缺省 pipeline 的修改)。这样,一条路由会走什么样的 pipeline,完全由开发者说了算,而我们又根据 spec 为开发者准备了缺省的 pipeline,因而大部分情况,开发者仅需做少量修改即可。如下图所示(红色部分为开发者修改):
总结
Quenya 尝试提供一个极简的框架,并且尽最大可能替开发者生成尽可能多的代码。client 端 SDK 的代码虽然在整个构想之中,但我目前还没有功夫实现(也许圣诞假期可以做点什么),且其思路和服务端代码生成大致相同,这里就不介绍了。大家如果要做类似的项目,还需要妥善处理的一件很重要的事情是:让生成的代码和用户撰写的代码很好地工作。我提供的思路仅是其中一种方案,抛砖引玉而已。