十月底我应邀在一个技术群里做个分享,思来想去我选择了 API 这个话题,因为很多互联网初创公司产品的第一步就是如何定义和设计一套 API,来满足产品核心所能提供的用户体验。我把 2016 年我在 Tubi 做 UAPI,2018 年我在 ArcBlock 做 Goldorin,2019 年做 Forge TX pipeline / Forge Patron 的经验揉了进去,又重新研究和回顾了 Swagger(Open API)/ GraphQL / GRPC gateway 这几个我曾经在各种场合使用过的工具,写下了一个 slides。那次讲座之后,同样的内容我又在 Tubi 内部讲了一遍,脱敏后的讲座 slides 如果大家感兴趣可以在这里获取:
https://github.com/tyrchen/quenya(在打开的页面搜索 next-gen)
随后我就渐渐萌发出在这个方向上做点什么的冲动。虽然 Tubi 已经不需要我写任何运行在生产环境的代码,但整日泡在文山会海里的我,对创作的向往依旧是这个样子的:
于是,在刚刚过去的感恩节四天小长假,在花了一整天给姑娘们做各种美食(包括新疆大列巴和美味南瓜饼)捕获她们的芳胃得到接下来三天可以随心所欲地把自己关在屋子里写代码后,我把脑海里游荡的各种思绪汇总起来,撰写了一个名为 Quenya 的 API 框架:用户只需要撰写定义良好的 OpenAPI v3 spec,Quenya 就可以为其生成整套 API 系统的主代码,其中包含:
- 所有请求数据(HTTP 头,path,query,body)的验证
- 所有返回数据(HTTP 头,body)的验证
- Mocking handler,可以生成符合 API schema 的模拟数据,供客户端测试使用
- 内置的 swagger UI,可以让用户很方便和 API 交互
之后的一周,我每天晚上哄完娃睡觉后,会接着奋笔疾书到 12 点,又完善了:
- 所有 API 的 property testing。根据 API 的 schema,用 property based testing 生成大量请求数据的组合,来测试 API(good cases)。
- API 安全方案的支持。目前支持 JWT。
- 两个通过 Quenya 生成的样例项目 todo 和 petstore。其中 petstore 是 OpenAPI 官方的样例 spec,我做了一些删改,然后通过 Quenya 为其生成全部代码(4.5k LoC API 代码,2.5k LoC UT 代码)。
目前,虽然 Quenya 还极度早期,但它已经可以让你无需写一行代码,仅仅从 OpenAPI spec 开始就能得到一个功能完整,类型安全,严格验证,有良好 UT(目前 good cases 全部覆盖)且很方便扩展的 API 项目。对于 API 开发者来说,只需要撰写商业逻辑,把剩下的事情都交给 Quenya 去做就好了。
目前 Quenya 是开源的状态,感兴趣的同学可以试着运行 Quenya 项目里的例子(examples/petstore),或者自己用 mix quenya.new
生成试试看。
那么 Quenya 究竟是怎么做到这一切的?它是如何架构的?为什么选用 OpenAPI?下一步 Quenya 会怎么做?我将会用一系列文章来回答这些问题,包括但不限于:
- 总览(本文)
- 架构
- OpenAPI 介绍
- 实现思路
- 代码生成
- 保证类型安全(请求和响应的验证)
- Mocking
- property based testing
- API 安全
- 客户端 SDK
- 模拟器
- 下一步
本文接下来的部分,将会谈谈我过去四五年做 API 或者相关领域的一些体会。
什么是好的 API?
对用户来说,好的 API 有这些性质:
- 容易上手,很难误用
- 有完整的文档和随时可以试用的 playground
- 有新的 API 需求可以很快得到实现(至少是 mocking server)
- (最好)有完整的 client SDK 可以把 API 的调用细节都封装起来
对程序员来说,好的 API 是这样子的:
- 很容易阅读和维护 API 代码
- 很容易添加或者扩展已有的 API
- 单元测试的覆盖率足够好
下图是一个比较好的描述 API lifecycle,我们挨个环节看我心目中的好的 API 该怎么处理:
Design
在 API 定义或者说设计阶段,往往是产品/前端/后端坐在一起讨论 API 的需求,并将其写成一个大家认可的 spec。好的 API 设计,design 阶段往往花费了很多时间,大家详细讨论 API 的功能,并敲定接口的每一处细节。这讨论应该发生在 github 中,新的想法直接在 PR 中的 commit 里得到体现。在这个阶段,使用一些所见即所得的工具,如 swagger,GraphQL playground,能大大加强讨论的效率。
Mocking
API 在定义或者设计完成后,客户端团队最想立刻有个 mocking server,让工程师可以开始尝试和开发。然而后端总有其自己的排期,即便安排上了,mocking server 再简单也有个实现和部署的过程。这一来一回的,往往就是以周为单位白白浪费了时间,不仅拉长了整个开发的周期,还让反馈链变得冗长。最理想的方法是,mocking server 可以通过 spec 来生成 mocking data 供客户端随时使用。这样子前后端就像两个彼此独立的线程,在 design 阶段 join 了一下后,迅速分开,并行各做各的开发:客户端自己运行 mocking server,并根据 mocking server 的数据来处理 UI 层的需求;服务端则省去了开发 mocking server 的时间,直接进入到 API 业务逻辑的开发中。等 API 开发测试完毕,通知客户端即可,客户端切换一下所用服务器就可以了。
Mocking data 的生成并不是一件容易的事情,尤其是想要生成的数据有意义。这个我们暂且按下不表,等单开一文讲 mocking 的时候再说。
Implementation
API 的实现是一件表面上看起来简单,做好并不容易的事情。从 request 到 response,一条链上有太多的工作要做,如果没有一个精心设计的架构,API 的实现很容易陷入又长又臭难以维护的误区。
我在 2016 年做 UAPI 时,设计了这样一个 API pipeline:
它包含三个部分:
- preprocessing:所有在真正业务逻辑运行前需要运行的公共逻辑,比如请求的类型安全校验的逻辑。
- actions/handlers:真正处理该路由的商业逻辑的代码。mock 就发生在这一层。
- post-processing:在发送请求前,还需要处理的工作。
这个 API pipeline 虽然是我四年前的想法,但它的核心思想如今依旧有效。好的 API 应该有一条可定制的,如乐高积木般连接而成的 pipeline。
Pipeline 解决了代码复用性不高,又长又臭的问题,但它让写代码变得无趣 - 因为一个完整的 API 流程要经历拆解和粘合两个过程,里面不可避免有很多脚手架代码,写起来很乏味。记得 Paul Graham 在《黑客与画家》里说,懒惰驱动技术进步。我们不想写这样的代码,那么自动生成就好啦 — 我们可以通过一个配置文件(比如 yaml)来生成脚手架代码。2019 年,在设计 Forge TX pipeline(处理 blockchain tx)时,我对 pipeline 做了这样的设计:
在 Forge 处理 TX 的流程里,有大量的各种各样的预先写好的 building block,我们称之为 pipes,一个 TX 的处理要经历若干的 pipes 最终运行结束,处理完毕或者抛出异常。如何确定某个 TX 要经历哪些 pipes 呢,我们定义了一个 yaml 文件来描述这个 pipeline。编译期 yaml 文件会被编译成代码,运行上图下半部分的 pipeline。
生成代码这件事是会让人上瘾的,因为它的好处实在是太多了。那么 implementation 阶段还有什么代码可以生成的?2018 年,为了回答这个问题,我做了一个叫 Golderin 的工具(是的,如果你是指环王的粉丝,那么 Quenya / Goldorin 这些精灵族语言你应该不会太陌生),试图从一个API spec(自定义的 yaml)中生成:
- 数据库的 schema 和 migration
- GraphQL 的 schema,notation 和 resolver
- 甚至,API 文档
(数据结构的生成)
(API 代码的生成)
最终,为了撰写 GraphQL API,我们只需要撰写对应的 resolver 函数即可。当时 Goldorin 的扩展性做得不好,所以一直没有开源,后来也就没时间继续提升其扩展性了。想了解更多有关 Goldorin 的同学可以移步 google 搜索:"use goldorin elixirconf",看 youtube 上我在 elixir conf 上做的 lightening talk。
有关 API 实现环节的代码自动生成的讨论,我们单开一文详细说。
Testing
如果你爱一个人,就让她写大量的单元测试,因为那是天堂;如果你恨一个人,就让他写大量的单元测试,因为那是地狱。- 《北京程序员在纽约》
曾经和朋友调侃,对单元测试的喜爱值可以判断一个程序员的有效工龄。没有程序员不「喜欢」写单元测试,因为这是程序界的政治正确。但真心愿意写单元测试的,往往是那些被线上的产品毒打过,被墨菲定律折磨过,开始老老实实夹着尾巴,写代码时瞳孔的反光中都写着责任二字的中老年程序员。
撰写单元测试虽然需要巨大的时间和精力投入,但起码思考如何测试还算一件有趣的事情;API 的单元测试就纯粹是一件烦人的事情:一切可能性都被定义清楚,连思考的空间都没有,只剩下机械地写下各种场景下的测试代码,偏偏,各种场景的组合又多如牛毛:url 本身就有 path variable / query 的各种组合,再加上各种可能的 HTTP header,还有 content negotiation 导致的返回数据的各种不同的序列化方式 — 所有这些组合再辅以 good case 和 bad case,单元测试才算覆盖完整。
这样看来,API 的单元测试最好也要能够自动生成,且尽量覆盖 spec 上声明的所有内容。如果要做到这一点,使用 property based testing 是省心省力,且必不可少的。
为了生成的测试有足够好的扩展性,测试本身也需要定义设计良好的 pipeline。我们之后在谈到 API property based testing 一文中再详说。
Virtualization
当我们构建好一个可运行的 API 系统时,除了 unit testing 覆盖每个 API 的输入输出和基本功能,integration testing 覆盖每个 API 及其依赖(如数据库)的协同,我们还需要用户使用场景的模拟测试(simulation)。对于模拟测试,我们可以撰写代码来模拟用户的行为,但更好的方式是开发一个模拟器,创建一个模拟环境,在其上运行与先定义好的模拟脚本 — 整个过程类似大家喜欢玩的模拟人生,主题医院,分手厨房之类的游戏。2019 年我在 ArcBlock 曾经做了一个叫 Forge Patron 的工具,实现了一个简单的模拟器,上面可以运行一些模拟脚本(yaml)来测试各种用户使用场景。脚本定义了:
- 模拟环境(Simulation env):比如两条测试链(配置:一条 5 个节点,一条 3 个节点,每个节点的参数等),以及 10 个用户,它们的初始状态,以及关系。
- 模拟流(Simulation flow):用户之间各种各样的行为。比如 B 想买一张门票,但 B 钱不够,A 向 B 转账(或者借贷),B 有了足够的钱去 C (可能是票代,也可能是个人)那里购买门票,最后 B 拿着票在入场处消费,检票员或者检票机器 D 验票后将其销毁。(黑体字代表这个 flow 里调用的 API)。
如果这样的模拟用代码来实现,其代价是巨大的,而且非常不经济。但如果我们构建好模拟器,然后让测试工程师去撰写模拟脚本,那么我们可以在不耗费太多精力的情况下,将产品的 API 部分打磨地非常完善。
HTTP API 的模拟测试和我说的上述场景类似,可能在模拟环境上更简单一些。对于一个类似 Tubi 这样可以免费播放视频的流媒体产品,其 API 的一个模拟流可以是:
- 用户用 gmail 注册一个新用户
- 在新用户流程里选择自己的偏好
- 打开主页
- 选择随意一个类别下随意一个视频请求播放
- 同时请求广告,请求该视频的相关推荐
- 汇报当前播放的进度
- 回到主页,查看是否存在 "continue watching" 类别
- 进入 continue watching,查看刚才播放的视频是否存在,并且播放进度是否正确
- 退出登录
- 打开主页,查看是否存在 "continue watching" 类别
这样用用户场景去模拟并测试 API 的调用,不引入真实的客户端,测试的效率很高,且有利于优化整个 API flow。此外,模拟测试可以很方便 scale,日后新的场景也可以很方便融入已有的 flow。
Documentation
虽然现有的一些 API spec 方案都会把 API 的文档集成在 API spec 里,然后用一些工具展示在线文档,比如 swagger 和 GraphQL playground。但当我们需要一个更正式一些,给其他部门的同事阅读的 API 文档时,这些工具就不太实用。不过,我们还是可以用 spec 来生成额外的文档,比如在 Goldorin 里,我做了对 slate (https://github.com/slatedocs/slate)文档格式的支持。
自动生成的文档也有不够灵活的问题,所以在文档生成的过程中,需要有足够的 hook 点,让其可以灵活扩展。
Deployment/Runtime
API 的部署是整个 lifecyle 中很重要的一环,它关乎着 API 的安全,性能,(额外的)监控等。但这跟 API 架构和构建本身关系并不太大,我们放下不表。
Clients
API 是为客户端服务的。好的 API 系统需要充分考虑客户端的需求,为客户端提供量身定制的 SDK。好的客户端 SDK 可以让 API 成为一个黑盒:客户端工程师只需要调用客户端 SDK 提供的函数,而无需关心 API 协议是 HTTP,HTTPS 还是 HTTP/2,API 系统是 REST API,GraphQL 还是 gRPC,使用了哪些 header,需要传入哪些参数,request/response 怎么做序列化/反序列化等等这些不必要的麻烦。
甚至,一个好的 SDK 还应该帮助工程师以统一且优雅的方式做类型安全检测,错误处理,重传,本地缓存,请求队列,请求的批处理(一些非实时处理的请求,如 analytic events,monitoring events),服务降级等等。
同样的,我们可以使用 code generation 来提供 Client SDK 的许多功能,这样可以最大化减轻 Client SDK 的维护成本。注意,虽然一些工具会提供客户端的代码生成,如 Apollo(GraphQL),OpenAPI code generator(OpenAPI),gRPC codegen(gRPC),但这些方案基本上只实现了最基本的功能,可用但可以做得更好。
这个主题也值得开一篇文章单独详述。
为什么选用 OpenAPI?
通过上文 API 系统以及其 lifecycle 的各个环节理想情况下应该如何处理的介绍,相信我们如何做一个好的 API 系统有一些共识,这里我稍微总结一下:
- 以 API Spec 为中心进行设计开发和测试。Spec 是整个 lifecycle 的 single source of truth(唯一可信来源)。
- API 的输入输出必须有严格的 schema 去保证类型安全。
- 只要有可能,就自动生成而不是手工撰写代码和文档。这对服务端代码和客户端 SDK 都适用,也适用于各种类型的测试。
- Thinking in pipeline。一切处理流程,尽可能将其 pipeline 化。包括但不限于:API 的处理流程,API 的测试流程,API 的文档生成流程。
有了这些共识,我们看看可以使用的工具。
首先看 sepc。我们可以自定义 spec,如我在 Goldorin 做的那样。自定义 spec 并不是一种推荐的行为,要慎用,除非现有的工具都不趁手,非自定义不可。在 Goldorin 身上,我看到了一开始定义不严谨导致后续不断打补丁的现象。
如果用已有的 spec 方案,市面上主流的方案有:OpenAPI v3,GraphQL 和 gRPC。Amazon 也开源了其用于 aws API 的 spec 方案 smithy,很好很强大,但目前整个生态还不够好,放下不表。
在 OpenAPI / GraphQL / gRPC 三者间,我们可以粗略不严谨地列一下它们大方向上的能力:
目前主流的 HTTP API 的思路还鲜有需要 streaming / bidirectional 的需求,有需要也是局部需求。而 gRPC 一般应用在内部 service / service 调用上,跨客户端服务器使用的情况不多,主要是 gRPC 在 web 上还没有特别好的直接支持方案,只能通过 proxy。所以 gRPC 首先出局。
我一度很喜欢 GraphQL 的交互方式:query / mutation / subscription 完整涵盖可客户端几乎所有使用场景,query 本身也非常灵活,可以最大程度减少客户端的 roundtrip,让整个用户体验更快更好。但 GraphQL 的 API 设计破坏了 HTTP 的生态圈的很多共识,比如:
- GraphQL 所有请求都是 POST,这破坏了整个 HTTP 缓存的生态
- 所有响应都是 200 OK,这破坏了 HTTP semantics,所有依赖 status code 进行相应处理的监控、日志等都无法正常工作
- 所有同一个 schema 下的 API 指向同一个 API 地址,这破坏了基于 URL 的路由,同样破坏依赖 URL 进行分类的监控行为
此外,GraphQL query 的复杂性处理,N 1 问题等都是让人头疼的问题。
由于 GraphQL 的这些问题,使得围绕其构建的 API 系统还需要额外构建相应的 logging / monitoring /caching 等工具,因而它也出局。
gRPC w/grpc-gateway 可以让开发者正常定义 gRPC 服务,撰写 gRPC 服务,并自动生成相应的 OpenAPI 接口,供客户端使用。我在一些小项目上用过,很惊艳,但问题也不少。最大的问题是所有和 HTTP API 层相关的内容都是通过额外的 gRPC annotation 语法来完成的,写起来一点也不轻松,功能还处处受限。感觉它更像是内部使用 gRPC 做微服务的团队之间如果偶尔需要 HTTP 接口而提供的工具,并不太适合外部 API 使用。
最后,我们剩下的没有什么大毛病的工具就剩下了 OpenAPI。OpenAPI 的 spec 用 yaml 书写,核心部分是几个对象:
- server: 定义如何连接服务器
- path/operation:定义 API 的行为
- security:定义 API 的安全方案
- request:定义 HTTP 请求的各种参数及其 schema
- response:定义 HTTP 响应的各种参数及其 schema
上手简单,整个生态圈有着丰富的工具,且因为 RESTful API 还是目前主流的 API 方向,因而各个云服务商的支持也不错。
OpenAPI 还支持丰富的扩展,这使得我们可以围绕着这个标准做一些自定义的行为,比如类似 grpc-gateway 这样的 proxy。我们可以定义一个扩展,使得 OpenAPI 支持从 gRPC 服务获取数据,并且进行 REST/gRPC 之间请求和响应的数据转换,用于自动生成 API 实现。