【Rust 新手小册】Day 4. 字节跳动开源的 Volo 框架简介

2,855 阅读18分钟

本期内容介绍:

   1. 为什么要做 Volo            

   2. Volo 为什么这么设计 

   3. Volo 与其他框架对比

01 为什么要做 Volo

在正式回答“为什么要做 Volo?”这个问题之前,首先应该解决一个前置问题,我们为什么要用 Rust?

很显然,这是因为我们相信 Rust 会比 Go 有更好的性能。大家通过网络搜索会发现 Rust 的性能可与 C/C++ 相媲美,远远优于 Go 语言。字节内部很多业务是用 Go 语言写的,因此我们会尝试把一些比较重视性能的业务从 Go 迁移到 Rust,或者把一些基础组件从 Go 迁移到 Rust。

实际业务收益

下图是我们已经实际获得的业务收益。我们相信 Rust 比 Go 的性能更好,所以在业务增长到一定规模之后,我们可能会优化一下业务的性能,因此会用 Rust 重写这些服务,使用更少的资源承担起服务从而获得收益。

生态现状

回到最初的问题,我们为什么要创造 Volo 框架?

这与当时 Rust RPC 的生态现状是有关的。我们当时调研过整个社区的生态,发现没有生产可用的 Async Thrift 实现。哪怕是社区中最成熟的 Tonic 框架,它的服务治理功能也是比较弱的,而且易用性也不够强。更重要的是当时在 Rust 语言社区,还没有基于 Generic Associated Type(GAT,Rust 语言最新的⼀个重量级 Feature)和 Type Alias Impl Trait(TAIT,另⼀个重量级 Feature)的设计。字节内部的很多服务都是用 Thrift RPC 承载的,如果要从 Go 迁移到 Rust,就需要一个 Thrift RPC 框架,所以我们创造了 Volo 框架。

02 Volo 为什么这么设计

整体架构

首先介绍一下 Volo 的整体架构。如下图所示,它的整体架构主要分为四个部分:

  1. User Interface 用户接口:分为 Server 端和 Client 端两部分。
  2. Middleware 中间件:基于我们设计的抽象 Motore 完成的,分为 Layer 和 Service 两部分。
  3. Codegen 代码生成:由我们自研的的一个 Pilota 负责,主要对用户的 IDL 生成对应的 Rust 结构以及接口,所以这里会有一个 Parser 解析用户的 IDL,然后转化到 Ir 做一些符号分析和类型检查,最后生成序列化和反序列化代码。
  4. Ttransport:主要负责网络 IO 相关的内容,把结构的序列化和反序列化组成 Codec,即编解码,以及设计一些请求的上下文。

如何进行一次 RPC 调用

那么 Volo 是怎么使用的呢?首先我们需要写一个 IDL。如下图所示,这个简单的 IDL 里面有三个结构:Request、Response 和 Service,Service 的定义是 RPC 暴露出来的接口。

写好 IDL 后,通过 Volo Build 把 IDL 生成一些代码,最后可以直接通过 Client 和 Server 进行使用。

Client 端调用非常简单,只要通过生成的 ClientBuilder 传入一个 Service Name,构造一个 Client 出来,直接调用方法即可拿到 Response。

Server 端处理请求也很简单。如下图所示,只需实现生成的 Trait,实现这两个方法的逻辑之后,我们就可以把它传入到 ServerBuilder 里面,建立起端口,直接运行 Server 即可。

在某些场景中,我们可能需要写一个中间件。如下图所示,这个中间件的作用是把 Request 和 Response 都 Log 出来。在 LogService 的逻辑里面,我们通过 Tracing 库输出 Request 和 Response。然后我们还需要写一个 LogLayerLogLayer 需要实现 Layer 这个方法,它会接受一个 Service,产生一个 LogService,最后只需把 LogLayer传入到 ************************layer(self, inner: S) ************************ 中即可。这就是一个简单中间件的使用。

RPC 调用流程

了解完使用方法之后,我们了解一下 RPC调用流程。

如下图所示,Client 端传入一个 Request,这个 Request 经过中间件的处理之后到达 Transport,Transport 会把 Request 进行编码生成二进制,然后会把它发送到对应的服务端。

服务端 Transport 接收到二进制之后进行 Decode,生成对应的 Request 结构,对应的 Request 结构经过 Server 端的中间件到达 User Handler。

经过 User Handler 的业务处理之后会拿到一个 Response,这个 Response 又经过 Server 端的中间件处理,被 Server 端的 Transport 发出,之后 Client 端 Transport 会收到一堆二进制,这堆二进制会被解码为 Response 结构,这个 Response 结构就会进入 Client 端中间件,之后回到 User Call 产生了一个 Response。这就是整个 RPC 调用流程。

Codegen

Volo 各个部分的具体细节又是怎样的呢?我们可以逐一了解。首先从 Codegen 开始,Codegen 即代码生成。比如我们在 IDL 中写出下图所示的结构,Codegen 会生成什么呢?

其实会生成一段对应的 Rust 结构。我们给这个 Rust 结构实现了一个 Message 方法,其中主要有两个方法:encode和 decodeencode 是编码,decode是解码,其实这就是 Thrift 协议序列化和反序列化的过程。我们可以看到 Rust 结构和 Thrift 结构基本上是一个对 IDL 翻译的过程,它就是把 Thrift 类型翻译成 Rust 类型。

但是在代码生成中会遇到用户有如下需求:

  1. 用户需求一:希望生成的结构可以尽可能地 derive 常用的宏 Hash , Eq。

这个结构里面有 map,Thrift map 对应生成的是 hashmap,但 hashmap 并没有实现 Hash,所以这个 Item  结构不能 derive 宏 Hash。

但是如果用户给出下图所示的结构,这个 Item 结构是可以 derive 宏 Hash 的。那我们应该怎么处理呢?

要解决这个问题,就需要对每个结构的字段进行分析。如果我们要确保这个结构能够 Hash,那么它的每个字段都应该能够实现 Hash。但是我们在分析的过程中可能会遇到一个循环依赖,比如结构 A 依赖结构 B 和结构 C,但是结构 B 又依赖结构 A,那么结构 A 能不能被 Hash 其实取决于结构 B 和结构 C 能不能被 Hash,结构 B 能不能被 Hash 又取决于结构 A 能不能被 Hash,这个依赖就形成环。

所以我们在处理这个问题的时候,需要把那些成环的依赖给进行 delay 处理。比如在这个结构里面,A 依赖 B、B 依赖 A 这个链路循环了,那么我们就会把结构 A 里面这个字段 B 进行 delay 处理。然后优先分析结构 A 里面的字段 C,最后如果这个结构 C 可以被 Hash,那么 A/B/C 这三个结构都可以被 Hash;如果结构 C 不能被 hash,那么 A/B/C 都不能被 Hash。具体细节可以参考 Pilota 的实现。

Pilota:github.com/cloudwego/p…

  1. 用户需求二:希望生成的机构可以带上⼀些自定义 derive 宏,比如 Serde。

下图是一个很常见的场景,一个 API 服务请求下游拿了一个 Response,它需要把这个 Response 返回给前端。前端大部分场景是用 JSON 来传递的,那么这个时候就需要对 Response 进行 JSON 序列化。这个结构需要 derive 两个宏:Serialize 和 Deserialize。这个时候 Pilota 提供了一个插件系统, Plugin 暴露了 Item 这个方法,在编译阶段 Item 这个方法处理所有的 IDL 都会被调用。所以对于 IDL 里面 MessageEnum 和 NewType 这三种类型,我们都加上 Serialize 和 Deserialize 这两个宏,这样我们生成代码里面就有 Serialize 和 Deserialize,可以方便用户进行序列化。

满足了用户上述需求之后,我们遇到了一个非常头痛的问题:超大型 IDL 生成的代码带来的编译压力。 什么是超大型 IDL 呢?比如用户定义一个 IDL 入口叫做 A,里面有一个 service。文件 A 依赖了文件 B,文件 B 可能依赖了文件 C、文件 D,所以这一个入口文件依赖的所有文件加起来可能有几十个文件。如果我们全部进行代码生成,会生成非常多的代码。曾经有一个业务,它的 IDL 代码量非常多,最后我们给它生成了一份 500 万行的 Rust 代码。VS code 的 rust-analyzer 插件已经不能正常使用,用户是处于没办法开发的状态的。其实 Go 框架,比如 Kitex 也会有这个问题,但是 Go 的编译器编译速度很快,IDE 也可以正常运行。所以这个问题对于 Rust 而言更为严重。

那我们应该怎么解决这个压力呢?我们对这个 Case 进行分析。如下图中的代码所示,AService 依赖结构 A,但是 A 依赖了文件 B 里面的结构 B1,但是结构 B2 是没有被依赖的,所以结构 B2 可以不做代码生成。这样的解决方案需要一个依赖收集的过程,要做依赖收集就必须做符号解析,对 IDL 里面所有符号进行解析,判断它到底是在哪里被定义以及在哪里被使用的。符号解析的过程类似于编译器里面的符号解析过程,这样才能分析出哪些结构被使用、哪些结构没有被使用。

以下图代码为例,我们会以 AService 作为入口,它依赖了结构 A, 结构A 依赖结构 b.B1,其实它整个依赖只有三个结构, AService、A 和 b.B1,B2 是没有被用到的,所以我们可以只生成这三个结构,这样就可以减少生成代码量。通过这种方式,之前生成 500 万行代码的业务也被优化到了 10 万行,IDE 也可以正常运行了,我们成功解决了这个问题。

User Interface

我们的 Service 定义了两个方法:HelloWorld 和 GetItem

我们会根据这个 Service 生成对应的用户接口,分别是 Request 和 Response 两个 enum,里面对应的是每个方法的 Request 和 Response。

我们会根据这个 Service 生成一个对应的 Trait,这就是服务端需要实现的 Trait。

我们还会在生成代码里面实现 Service Trait,这个 Service 其实就是一个异步调用抽象。我们可以观察 Service 里面的 call 方法,其中 Server 端接受一个 Request,然后它会通过 match 感知到这个 Request 属于哪一个分支,最后我们把它分发到用户的方法实现里面进行调用。我们拿到用户的 Response 之后将这些 Response 组装成一个 ItemServiceResponse 进行返回,这是 Service 中不同方法的分发流程。

我们再关注一下 Volo Server 的类型。其实 Server 里面主要有两个分支:Service 和 Layer,Layer 就是中间件,暴露给用户后可以方便用户插入一些中间件。

Middleware

既然提到了 Service 和 Layer,我们就要具体了解一下中间件。之前写过 Node 的开发者会了解洋葱模型,洋葱模型就是由最外层开始,一层一层的中间件处理一个 Request,然后到达最里面拿到一个 Response,这个过程可以形象地比喻为一个洋葱,所以我们称之为洋葱模型。

我们怎么在 Rust 里面实现洋葱模型呢?首先需要有一个异步调用的抽象 Service,我们可以这么定义这个 Service Trait,它里面有一个 call方法,接受一个 Request,返回一个 Result<Self::Response, Self::Error>。接下来我们通过 Service 的组合去实现洋葱模型。

那么应该怎么组合呢?首先我们可以实现一个 ServiceA<Inner>,然后给这个 ServiceA 实现 Service Trait,里面会实现一个 call 方法。在 ServiceAcall 方法里面,在它调用 Inner.call 之前或之后,我们都可以写一些业务逻辑,比如对 Request 进行输出或记录一下耗时。

最后我们可以组合成下面这种类型,从外到内依次是 TimeoutServiceLogServiceMetricsServiceTransport 。执行顺序是先执行 TimeoutService,后执行LogService,再执行 MetricsService,最后拿到 Response。通过这种组合方式,我们可以实现一些类似的洋葱模型。

但是我们会发现一个问题,Request 里面可能有些元信息。下图的请求 metadata 里面会储存一些元信息,我们想在 Inner.call 方法拿到 Response 之后对这个元信息做处理,比如拿到元信息里面的一些信息进行输出,但问题是这个 Request 的所有权限已经被消耗掉了。所以在 Inner.call 方法执行之后,我们已经没办法拿到 metadata 了,除非在这之前对 metadata 进行 clone,但这样也会有一个潜在的 clone 开销。虽然加上 arc 可以解决这个问题,但是这对我们的结构设计是有侵入性的。

那么这个问题该怎么解决呢?我们的解决方案是自己做了一套抽象系统 Motore, 这也是我们跟 Tower 最大的区别。为什么我们需要 Motore 呢?大家可以看到下图中 Motore 的定义,它引入了 Context 这个概念,也就是 Cx,而且 Motore 是基于 GAT 的。Context 与 Request 最大的区别就是 Context 暴露出来的是一个 mut 引用,在 mut 引用的 Cx 里面我们可以存放一些关于请求的上下文信息,方便使用。

Motore: github.com/cloudwego/m…

在 Motore 中,我们怎么解决 Context 的问题呢?如下图,我们可以直接把刚才提到的 metadata 放到 Cx 里面,在 Inner.call调用完之后,这个 Cx 我们还可以接着用。因为我们传进去的 Cx 只是一个 mut 引用,它调用完之后,我们还是可以直接使用这个 Cx,这样我们就没有 clone 开销了。为了解决 clone 开销以及 Context 问题,所以我们才采用这种方案。

在 Tower 里面其实还会有一个问题,因为 Tower 返回的是 Rsult,Response 和 Error,如果返回 Error 时,我们没办法拿到 Context,除非还是采用 clone 的方式。所以我们觉得 Tower 对 Context 传递不是很友好,所以采用 Motore 这种形式,这是我们的中间件和 Tower 中间件的主要区别。

我们之前提到,可以通过 Service 组合的形式实现洋葱模型。但是我们怎么组装 Service 呢?这个时候我们需要 Layer 帮助组装。trait layer 提供了 Layer 方法,它接受了一个 Service,返回了一个新的 Service,下图是一个构造的过程。比如我们先有了一个 Transport,经过 MetricsLayer 产生了 MetricsService<Transport>,经过 LogLayer 包装后叠加了 LogService,最后经过 TimeoutLayer 叠加了 TimeoutService

需要注意的是,使用顺序是先使用了 MetricsLayer,再使用 LogLayer ,最后使用 TimeoutLayer。执行顺序与之相反,先执行 TimeoutService,再执行 LogService,最后执行 MetricsService。这对很多开发者来说是有些反直觉的,因为其他框架一般都是先组装的部分先执行。

我们怎么解决这个组装顺序问题呢?这个时候就需要 Stack 结构来帮忙。

Stack 其实就是一个 Layer,它先组装 Inner.Layer,再组装 Outer.Layer。下图中,我们将 TimeoutLayer 进行包装,加入 LogLayer 产生 Stack<LogLayer, TimeoutLayer>, 之后我们又叠加了一个 MetricsLayer 产生 Stack<MetricsLayer, Stack<LogLayer, TimeoutLayer>>,最后产生这个结构 TimeoutService<LogService<MetricsService<Transport>>>

这个结构组装 Service 又是怎样顺序呢?Layer 的使用顺序先是 TimeoutLayer,再是 LogLayer,之后是 MetricsLayer,最后执行顺序也与使用顺序相同,这也更符合开发者的直觉。

Transport

介绍完 Middleware 层,最后介绍一下 Transport 层。首先我会介绍一个非常简单的模型——Ping-Pong 模型。如下图所示,模型中在 Client 和 Server 两端会有连接,Client 端发起一个请求,Server 端里面会返回一个 Response,Client 端拿到 Response 之后再发起第二个请求,之后 Server 端返回第二个 Response。Ping-Pong 模型的特点就是一个连接只能承载一个请求,它不会像 HTTP/2 这种模型一样,可以在一个连接上同时处理多个请求。

根据 Ping-Pong 模型的这个特点,我们可以写出下图所示的伪代码。我们在这个 call方法里面收到一个 Request,对这个 Request 做编码,拿到一段二进制数据,然后从连接池里获取一个连接,通过这个连接把这段二进制数据发出去,之后通过 Response 的 decode 方法对这个连接做解码。这就是 Client 端的伪代码。

Server 端的伪代码更为简单,我们从 listener上面拿到连接,对于每个连接都通过 tokio::spawn 一个 Task 出去,在这个 Task 里面不断地对这些 Request 进行 decode,拿到 Request 结构,之后通过组装 Service 形式调用,最后调用到 User Handler 层,User Handler 层访问到一个 Response,然后我们通过把这个 Response 进行 encode拿到一段二进制数据,最后我们通过连接把这段二进制数据发送回去即可。

相对于 Server 端的伪代码,Client 端比较复杂的一点是它有一个连接池。如下图所示,我们会先判断一下是否存在空闲连接, 如果存在空闲连接,可以直接使用空闲连接;如果没有空闲连接,我们会通过 spawn 创建新的连接,但是在这个创建新连接的过程中,可能会突然出现某个连接被空出来了,这时我们可以直接使用空闲连接进行处理。如果在创建新连接的过程中始终都没有出现一个空闲连接,我们只能使用新连接进行处理。使用之后把这些连接放回池子里即可。这是连接池的一个简单实现的逻辑分支,其实这也就是一个 Transport 的过程。

03 Volo 与其他框架对比

很多同学会在 Volo 用户群问这个问题,Volo 和 Tonic 到底有什么区别呢?Volo VS Tonic

首先 Volo 和 Tonic 最大的区别是 Volo 支持两种协议,即 Thrift 协议和 gRPC 协议,而 Tonic 目前只支持 gRPC 协议。其实二者最大的差距在于中间件,因为它们本质上都是 RPC 模型,底层的实现不会有太大的区别,更多的是封装和抽象方式上的区别。Volo 的抽象方式采用 Motore,Tonic 采用 Tower,这就是二者最大的区别

下图中可以看到 Tonic 中间件的 intercept 类型是限定的,Tonic 的 Intercept Request 类型里面是一个空的元组,这代表它没办法接触到这个 Request 里面具体的 Message 类型,只能通过 Tonic 的中间件访问这个 Request 的元信息,但是 Request 的具体结构是在 Tonic 里面没办法感知到的。所以如果你在 Tonic 里面进行 Request 输出,比如控制台输出,它只能输出元信息,而没办法输出这个请求的具体结构,因为 Tonic 没办法把 Message 结构传到中间件这一层。

在 Volo 中,我们可以为这个 Request 加约束,比如可以要求这个类型实现 Debug 或 Send 等等,然后将它的具体结构全部进行输出。在 Volo 中间件里面可以感知到 Request/Response 的具体结构。这就是二者中间件的区别。

Pilota VS ProstPilota 是 Volo 使用的 Thrift 与 Protobuf 编译器及编解码的纯 Rust 实现,是一个有着高扩展性和高性能、支持 Thrift 和 Protobuf 的序列化实现。Prost 是一个 Rust 的 Protobuf 序列化实现。

Pilota 和 Prost 也会有一些区别。首先,二者对于 Attributes 的定义有所不同,比如我们要对生成的结构加 Serialize 和 Deserialize 这两个宏。Prost 通过 type_attribute 这个方法,对所有结构加上 Serialize 和 Deserialize。

Pilota: github.com/cloudwego/p…

Pilota 的实现会比较复杂,因为 Pilota 会通过 Plugin 形式进行接入。Pilota 会把 IDL 里面定义的每一个 Item 都传给 on_item 函数交给 Client 处理,开发者可以在 Pilota 的插件系统里面感知到所有 IDL 结构。虽然 Pilota 的实现方法更复杂,但是有的 Client 会倾向于使用 Pilota 这种形式,因为它的灵活性更高。此外,Pilota 不生成没有用到的结构,也会自动对一些可以 derive Hash 的结构进行自动判断,这些特点都是 Prost 没有实现的,这就是二者比较大的差异。在我个人看来,Pilota 更能满足开发者对于灵活性的需求。


项目地址

GitHub:github.com/cloudwego

官网:www.cloudwego.io