字节跳动微服务体系下接口测试平台实践

2023 年 3 月,CloudWeGo Day 邀请了贪玩游戏、数美科技、字节跳动业务部门的一线架构师,为大家分享,在Java 转 go 场景下、企业高可用性挑战等场景下,如何通过 CloudWeGo 来落地微服务架构。本文为 字节跳动研发工程师 陈佳庆 分享内容。

🔗 回放链接: https://juejin.cn/live/cloudwegoday002

嘉宾介绍

interface_testing1

本次分享主要分为以下三部分内容:

  1. 接口测试平台产生的背景
  2. 接口测试平台 1.0 实践
  3. 接口测试平台 2.0 结合 Kitex 泛化调用的演进
  4. 未来展望

接口测试平台产生的背景

字节跳动微服务体系的特点

首先,来认识一下字节跳动微服务体系的特点:

  • 服务基于 IDL 定义接口,服务之间通过 RPC 或者 HTTP 协议发起接口调用
  • RPC 协议不只是 Thrift 协议,还有 gRPC 和 Kitex Protobuf
  • 服务端语言不只是 Golang,还有 Python、C++、Java 和 Rust

下图是字节跳动微服务在内部环境的常见部署情况,从这张图中我们可以看到,在这个环境中部署了 3 个微服务,其中 service 1 是一个 HTTP 的服务,service 2 和 service 3 是一个 RPC 服务,它们都是基于 IDL 去定义它们的接口,服务之间的 RPC 通信通常会依赖下游的生成代码去进行。

interface_testing2

可以注意到,这些微服务所使用的 RPC 协议和服务端语言是各种各样的。字节跳动内部支持的 RPC 协议除了用的最多的 Thrift 协议,还有会有 gRPC 和 Kitex Protobuffer 协议。服务端的语言除了常用的 Golang 和 Python,还会用到比如 C ++、Java 和 Rust 等语言。所以总结下来,字节跳动的微服务体系是比较多样化和开放的。

在这套服微服务体系下,业务方做接口测试时会遇到哪些痛点?接下来了解一下。

业务方做接口测试的痛点

  • 生态: 业界和字节跳动内部都没有支持 Thrift 协议的接口测试平台

  • 业务现状

    • 业务大多数 RPC 服务都是 Thrift 协议
    • RPC 接口测试效率低:基于 IDL 生成客户端代码,在单元测试代码里调用客户端发起 RPC 请求

首先,业务方大多数的 RPC 服务都是采用 Thrift 协议来实现的,少量采用 gRPC 和 Kitex Protobuffer 协议。业界当时已经有了Postman、grpcurl 这些好用的接口测试平台和工具类的产品,它们使用起来比较方便。然而,并没有一款接口测试平台能够比较好的支持 Thrift 协议。

所以我们的业务方在测试 Thrift 接口的时候,需要先基于 IDL 去生成客户端的代码,然后在单元测试的代码里面去调用客户端去发起 RPC 的接口测试,整个流程下来效率其实并不高效,用户体验往往比不上一个通用的接口测试平台。

归根结底,缺少一款适配字节跳动微服务体系的接口测试平台!

接口测试平台的建设目标

有了这些业务现状和痛点,建设接口测试平台也成了一件自然而然的事情。平台的建设目标也很明确:

  • 支持 Thrift、HTTP 协议
  • 适配字节跳动微服务体系
  • 产品功能对标业界同类产品

接下来,我们可以了解接口测试平台1.0的实践,认识我们的接口测试平台是什么样子。

接口测试平台 1.0 实践

产品界面

interface_testing3

接口测试平台的主要功能点

  • 支持 Thrift 和 HTTP 两种协议
  • 多 Tab、测试数据缓存、视图切换、读写超时配置等基础功能
  • 请求快照、备注收藏历史记录、分享请求等实用功能
  • 请求集合文件夹、时间/接口维度查看历史记录等组织功能

从产品界面图中可以看到,平台布局分为左、右两个区域。左侧区域可以展示用户的一个请求历史记录以及请求集合的一些数据,方便用户访问常用的请求数据。同时,右侧区域是一个请求编辑的区域,平台目前支持 Thrift 和 HTTP 两种协议类型。

用户要在平台发起一次接口测试的时候,需要先去配置好要测试的接口信息,包括被测服务的名字、所使用的 IDL 版本以及要测试的接口。这些信息配置好之后,平台会根据 IDL 的定义去解析出 Request Schema,展示在 Request Body 这一栏,用户可快捷地编辑 Request Body 来构造请求。接着,配置好要测试的集群和泳道环境等信息,点击发送按钮发起接口测试,执行结果将展示在 Response Body这一栏。如果发现请求结果与预期行为不一致,需要别人协助排查,可以点击右上角的分享按钮,将这次的请求历史分享出去,以便对方获取当时的请求快照信息进行分析。

这就是我们在平台上去做接口测试的一个常规流程,在对平台有了感官上的认识之后,我们再来了解一下接口测试平台 1. 0 的实现方案。

实现方案

这个篇幅会着重介绍 RPC 协议接口测试的实现方案。下图展示了一次 RPC 接口调用的常规执行流程。

interface_testing4

客户端为了向服务端发起 RPC 接口调用,会依赖 IDL 文件生成代码,这部分生成代码会包括两部分:一部分是结构体的编解码序列化代码,另一部分是可发起 RPC 调用的桩代码。有了这两部分代码之后,客户端就可以采用高效的序列化协议来向服务端发送二进制的字节流。

而接口测试平台作为一个通用型的平台产品,需要能向任意实现了 Thrift 协议的 RPC 服务发起请求,关键点在于如何把用户在平台输入的 JSON 明文请求,转换成 RPC 调用可传输的二进制流。沿着这个思路, 1. 0 版本的技术方案也初具雏形。

下图是接口测试平台 1. 0 系统的流程图,核心思路是基于一段发送 RPC 请求的模板代码来生成可向被测服务发送 RPC 请求的可执行文件,进而通过调用可执行文件的命令来发起 RPC 调用。有三个关键节点:

  • 事先定义好「发送 RPC 请求」的模版代码
  • 从 API 元数据中心拉取 IDL,执行 Kitex 命令生成被测服务的客户端代码
  • 将用户的请求入参以及客户端代码路径作为变量注入模版代码,生成可执行文件

interface_testing5

图中的 Explorer 是接口测试平台的服务,当它接收到平台用户配置好的 RPC 请求,最终的目的是希望能调用 Kitex 客户端向被测服务发送 RPC 请求。那么为了完成这件事情,Explorer 首先会从 API 元数据中心查询被测服务的 IDL 信息,然后用 Kitex 命令结合 IDL 文件生成被测服务的客户端代码并保存在文件系统。

有了可以发起 RPC 调用的客户端代码,那么我们接下来的步骤就比较关键了。我们事先在系统里面去定义好了一段发送 RPC 请求的模板代码,这段代码有 main 函数,最终可以将它编译成一个可执行文件。模板代码里面还抽象了两个函数:一个是获取 RPC 客户端的函数,另一个是执行 RPC 请求的函数。

当平台用户的请求入参以及客户端代码的存储路径作为变量注入到模板代码里,就可以生成可正常向被测服务发送 RPC 请求的代码。这些代码经过编译生成可执行文件,最终会保存在文件系统中。之后,Explorer 调用这些可执行文件向被测服务发送 RPC 请求。

interface_testing6

发送 RPC 请求的模板代码


这就是 1. 0 系统的实现方案,逻辑上是比较简单清晰的。

存在的问题

1.0 系统上线后,稳定地运行了一段时间。随着用户的逐渐规模化,这套系统架构也开始暴露出一些问题:

  1. 【功能】生成 客户端代码 的成本太高,客户端代码更新不及时, Oncall 投诉严重。当时为了缓解这个问题,我们额外实现了一个刷新客户端的小工具,当 Oncall 有了这类问题反馈之后,就手动执行这个工具来刷新客户端代码。当然,这个用户体验确实是不太友好的。
  2. 【稳定性】Explorer 高可用改造 和运维 成本比较高 Explorer 是有状态服务,不支持多机房部署,引发诸如系统的容灾能力、可用性比较差;与被测服务跨机房通信时时延比较高,接口测试容易超时等问题。

总结:接口测试平台 1.0 难以稳定支撑用户规模化的使用。

这些问题的根源主要归结于,我们的系统目前是依赖了被测服务的客户端静态代码。为了让系统能够在大规模用户使用的场景下还能保持良好的稳定性,我们开始寻求解决系统瓶颈的技术方案。在这个时期,Kitex 开始支持基于 Thrift 协议的泛化调用,接口测试平台也迎来了 2.0 版本的迭代升级。

接口测试平台 2.0 结合 Kitex 泛化调用的演进

泛化调用介绍

先来了解一下什么是泛化调用。我们知道,正常的 RPC 请求, 客户端 需要依赖服务端所定义的服务接口和数据类型 但是,对于 API 网关、接口测试平台这类通用型的平台服务,有成千上万的服务接入,让平台依赖所有服务的 IDL 生成代码去发起 RPC 调用是不现实的。在这个背景下,业界衍生了 RPC 泛化调用,通过提供一种泛化接口,接收诸如 JSON、Map 这类泛化的数据结构,最终转化成 RPC 二进制流发起 RPC 调用。

目前支持泛化调用的 典型 方案

  • gRPC 服务端反射
  • Kitex 客户端泛化调用

先来了解一下 gRPC 服务端反射的实现方式。gRPC 框架提供了一种服务端反射的技术实现来支持实现泛化调用,像 grpcurl 这种工具就支持了 gRPC 的服务端反射。常规的交互流程,如下图所示。

interface_testing7

业界基于 gRPC 服务端反射实现泛化调用的方式


首先 gRPC server 除了对外提供自身的业务服务之外,还会注册一个反射服务。这个反射服务可以用来获取接口定义、请求参数、响应参数等等这些与服务定义相关的信息。有了服务定义的信息,客户端就可以将 JSON 这类明文请求映射成 Protobuf 的序列化协议,然后发起 RPC 调用。这种实现方式的优势在于:第一,客户端本身不持有 IDL 信息,由服务端暴露 IDL 信息,因此客户端依然足够轻量;第二,客户端所使用的服务端描述信息一定是与正在运行的被测服务保持一致的。

接着来了解下 Kitex 泛化调用。Kitex 目前支持 Thrift 协议的泛化调用,通常客户端有一份通用的协议处理代码,基于服务端的 IDL 信息动态生成协议字节流,IDL 信息可以动态更新,无需生成代码。这种实现方式的特点是,客户端持有 IDL 信息

Kitex 目前支持了4种泛化映射类型:

  1. 二进制泛化调用:用于流量中转场景
  2. HTTP 映射泛化调用:用于 API 网关场景
  3. Map 映射泛化调用
  4. JSON 映射泛化调用

实现方案

在前面的背景介绍提到,字节跳动的微服务体系是比较多样化的,服务端语言和 RPC 协议都是各种各样。接口测试平台作为一款通用的平台型产品,其实不太可能要求所有业务方的服务都要改造成支持 gRPC 的服务端反射,才能去使用我们的平台,所以我们最终选择了 Kitex 客户端泛化调用的这种实现方式。

在我们的泛化调用的技术选型确定好了之后,也开始着手设计接口测试平台 2. 0 版本的系统架构图。如下图所示,我们引入了 Kitex 泛化调用之后最大的一个变化,即不再需要基于 IDL 去生成客户端的静态代码。

interface_testing8

Executor:从 API 元数据中心拉取 IDL,新建 Kitex 泛化客户端缓存 Kitex 泛化客户端,基于 IDL 动态实时更新 Kitex 泛化客户端 Broker:基于同机房的服务发现策略,选择最适合被测服务的 Executor

系统从 1.0 的有服有状态服务演变成了 2.0 这种可扩展的分层架构。其中我们的 Executor 和 blocker 是两个核心的服务。

先简单介绍下执行层的 Executor 服务。Executor 是一个可执行 Kitex 泛化调用的执行器,首先它需要从 API 元数据中心去拉取被测服务的 API 元信息,包括传输协议类型以及被测服务所使用的 IDL 信息,然后基于 API 元信息去动态生成 Kitex 的泛化客户端。最后,Kitex 泛化客户端会结合 IDL 信息将明文的 JSON 请求体转换成 Thrift 二进制流,传输给被测服务去执行 RPC 的调用。为了更好地复用 Kitex 泛化客户端, Executor 还会使用调 LRU 算法将泛化客户端缓存起来,并且去动态地更新 IDL 的信息。

接着来了解逻辑层的 Broker 服务。用户在平台触发一次接口测试时,会选择被测服务的泳道、集群、机房这些信息。当请求流转到 Broker 的时候,除了执行平台的功能逻辑之外,Broker 还需要发现最适合被测服务的 Executor 实例,Broker 会基于 Consul 去对 Executor 进行服务发现,筛选出一个尽可能靠近被测服务的机房,以便进行同机房的请求调用,这样做的一个好处是 Executor 和被测服务处于同机房的时候,它们之间的网络时延往往比较低,可控性强,同时可以从客户端得到更真实的请求执行耗时。

当然,我们的 Broker 和 Executor 还是有可能会做一个跨机房的网络调用,但是这两个服务本身就是我们平台的服务,我们是可以自主去控制它们之间的读写超时配置的。基于同机房的就近调度策略,用户在平台去做接口测试的超时情况也大大减少了,我们系统的稳定性也显著提升。

对系统架构有一个整体的了解之后,再来重点了解一下 Executor 执行细节和 Kitex 泛化调用的实现逻辑。

这张图是 Executor 服务的流程图,我们来看一下它是怎么向被测服务去发起 RPC 调用的。

interface_testing9

Executor 结合 Kitex 泛化调用的实现


Executor 收到的请求会携带有被测服务的名字、要测试的接口,以及用 JSON 明文表达的请求体,还会有 IDL 分支等等这些信息,这些信息我们在后面的流程都会去使用到。

前面我们在做系统架构介绍的时候,提到了 Executor 是一个有泛化客户端缓存池的服务,所以我们这里的逻辑会先尝试从缓存池里面去获取一下泛化客户端。如果缓存池获取不到泛化客户端,那么就需要新建一个泛化客户端并放入缓存池里面。当能从缓存池里面获取到泛化客户端,会再接着去判断一下 IDL 分支是否有新的版本定义,如果有新的版本定义,那么就需要使用 IDL 去更新服务的描述信息,最终我们能够拿到一个有着最新服务描述信息的泛化客户端,就可以用它来去发起 RPC 调用,我们再回过头来看一下新建泛化客户端的一个逻辑。

首先,我们需要从 API 元数据中心去把 IDL 文件给拉取下来,然后基于 IDL 去生成 IDL Provider。IDL Provider 它的作用主要是通过解析 IDL 定义,然后拿到服务端 RPC 方法定义、请求参数定义、返回结果定义等等这些信息。有了 IDL provider,我们就可以接着构造 JSON 泛化类型,通过它能够将 JSON 明文请求序列化成二进制流,最后生成泛化客户端用来发起 RPC 接口调用。

以上就是接口测试平台执行 Kitex 泛化调用的实现逻辑,大家如果在自己的实践过程中,可以参考我们这个流程图去实现。

收益成果

随着我们平台逐渐切换到了 2.0 的一个系统,我们也取得了一些收益成果。

  1. 用户规模化

目前字节跳动内部大多数 Thrfit 协议和 HTTP 协议的服务,基本都能使用接口测试平台去做测试。

  1. 运维成本降低

系统不再需要在文件系统里管理 IDL 生成的客户端代码,服务可以做到容器化、无状态化、可扩展。当平台需要支持业务测试一个新机房时,我们只需要在这个机房把执行器,也就是 Executor 服务给部署起来就好了。

  1. 响应时延降低约50%~90%

虽然泛化调用相对于依赖 IDL 生成代码的方式,性能上是有损耗的,但是 1. 0 系统是需要额外增加两次 Shell 命令的执行开销,它所带来的成本是远远高于泛化调用带来的额外性能损耗。当被测试的服务的接口时延比较低时,这两次 Shell 命令的执行耗时占比就会很高,所以我们的响应时延最高能够降低约 90% 左右。

最佳实践

我们的平台经历了 1.0 到 2.0 的演进过程,这里其实有一些心得希望能够分享给大家。

  1. 【选型】泛化调用的技术选型没有好与坏,贴合业务现状才是最好的。在做技术选型的时候,实际上并没有对与错之分,只有适合与不适合。比如我们在选择泛化调用的方案的时候,并没有因为服务端反射有 IDL 管理上的明显优势而选择它,而是充分考虑我们平台的定位以及业务微服务多样化的特点,选择了 Kitex 泛化调用。这个技术选型带来了长期的业务收益,让我们平台的适用性更广。
  2. 【稳定性】用好缓存,缓存是提升系统性能的一剂良药。 要保证好系统的稳定性和可扩展性,这是决定我们平台能否规模化的一个重要因素。
  3. 【稳定性】尽可能遵循就近访问的原则,让请求的时延变得可控。 在我们的实践过程中可以看到,充分利用好的缓存,并且去尽可能地遵循就近访问的原则,让我们的系统的稳定性和性能都得到了一定的保障。

未来展望

提升系统性能和稳定性

  • 降低接口测试请求的时延
  • 提高客户端缓存池的命中率
  • 探索 Serverless 方向
  • ……

持续地提升系统的稳定性和性能,是一个需要长期投入的方向。在这里可以给大家分享一下我们现在做的一些思考。

第一, 我们会尽可能地降低接口测试的一个请求时延。 2. 0 系统由于采用了泛化调用,其实额外增加了编解码的开销,需要将 JSON 数据结构映射成可序列化的二进制协议。目前 Kitex 泛化调用的做法是,采用一种泛型的容器去承载映射过程中所需要处理的中间数据,这往往会带来比较大量的堆内存分配。但是对于我们做接口测试的场景,其实是不需要这些中间表示的数据。所以,我们完全可以基于 IDL 定义,将 JSON 数据逐字地翻译成 Thrift 编码。理论上,这种实现方式带来的 CPU 开销、内存开销以及时延都会降低。

如果大家有关注 CloudWeGo 社区的同学,应该会知道最近 CloudWeGo 社区开源了一个叫做 dynamicgo 的项目,它的诞生其实就是为了解决我刚刚提到的这种问题。我们也在持续地关注这个项目,希望能够把 dynamicgo 接入到 Executor 服务。

第二, 我们希望尽可能地去提高 泛化 客户端 缓存的命中率 从而避免新建客户端所带来的开销。 目前我们尝试在 Broker 加一层一致性哈希的调度算法,这样能够更好地去命中 Executor 的泛化客户端缓存。

第三, 我们也在持续地去探索 S erverless 方向。 现在我们在支持业务测试一个新机房的服务的时候,需要手动的在该机房部署 Executor,那么有没有可能让这个过程完全自动化?再者,业务做接口测试的时间分布模型是有自己的一个特殊性的,怎么样分配整个系统资源去更好地适配这套时间模型?而 Serverless 是很符合这些场景的技术方向。Executor 其实是一个很轻量级的执行器,完全是可以 Serverless 化,充分去享受云的弹性伸缩能力,优化整个系统的资源使用率。

以上这些都是我们未来希望在系统性能和稳定性方向所做的一些长期工作。

提升用户体验

用户目前反映他们在做接口测试,最大的一个痛点主要来自于请求参数的构造。举个场景,我们的业务方,他们接入层的服务是需要验证请求头部所携带的一些票据信息的。现在业务方测试接入层的接口的时候,是需要先用一些抓包工具去抓取客户端的请求,然后导出 curl 命令,提取请求头部的票据信息,最后拷贝到接口测试平台去测试接入层的 HTTP 接口。整个流程体验下来其实是不够高效的,构造请求参数也是比较困难的,所以我们尝试在平台里面将这套流程自动化。比如我们希望结合 Service Mesh 的能力,将请求流量自动抓取下来,然后提取里面的票据信息,帮用户自动构造好请求。这是我们期望去解决的一个问题。

产品功能增强

最后,希望增强产品功能,比如我们支持更多的协议类型,支持GRPC,支持 Kitex Protobuf,支持这些协议能更好地覆盖业务的使用面,同时也希望能够增强执行接口测试时候的常用功能,比如对请求结果做断言,比如在请求结束的时候自动地生成测试报告。这些都是我们未来希望去做的事情。