本期内容介绍:
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 的生态现状是有关的。咱们当时调研过整个社区的生态,发现没有生产可用的 AsyncThrift完成。哪怕是社区中最老练的 Tonic 结构,它的服务治理功用也是比较弱的,而且易用性也不行强。更重要的是当时在 Rust 言语社区,还没有根据 Generic Associated Type(GAT,Rust 言语最新的⼀个重量级 Feature)和 Type Alias Impl Trait(TAIT,另⼀个重量级 Feature)的规划。字节内部的许多服务都是用 Thrift RPC 承载的,假如要从 Go 迁移到 Rust,就需求一个 Thrift RPC 结构,所以咱们发明了 Volo 结构。
02 Volo 为什么这么规划
全体架构
首要介绍一下 Volo 的全体架构。如下图所示,它的全体架构首要分为四个部分:
- User Interface 用户接口:分为 Server 端和 Client 端两部分。
- Middleware 中间件:根据咱们规划的笼统Motore完结的,分为 Layer 和 Service 两部分。
- Codegen 代码生成:由咱们自研的的一个Pilota担任,首要对用户的 IDL 生成对应的 Rust 结构以及接口,所以这儿会有一个 Parser 解析用户的 IDL,然后转化到 Ir 做一些符号剖析和类型检查,最终生成序列化和反序列化代码。
- 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。然后咱们还需求写一个LogLayer,LogLayer需求完成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和decode 。encode是编码,decode是解码,其实这便是 Thrift 协议序列化和反序列化的进程。咱们能够看到 Rust 结构和 Thrift 结构基本上是一个对 IDL 翻译的进程,它便是把 Thrift 类型翻译成 Rust 类型。
可是在代码生成中会遇到用户有如下需求:
- 用户需求一:期望生成的结构能够尽或许地 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…
- 用户需求二:期望生成的组织能够带上⼀些自界说 derive 宏,比方 Serde。
下图是一个很常见的场景,一个 API 服务恳求下流拿了一个 Response,它需求把这个 Response 回来给前端。前端大部分场景是用 JSON 来传递的,那么这个时分就需求对 Response 进行 JSON 序列化。这个结构需求 derive 两个宏:Serialize 和 Deserialize。这个时分 Pilota 供给了一个插件体系,Plugin露出了Item这个办法,在编译阶段Item这个办法处理一切的 IDL 都会被调用。所以关于 IDL 里边Message、Enum 和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
办法。在 ServiceA
的 call
办法里边,在它调用 Inner.call
之前或之后,咱们都能够写一些事务逻辑,比方对 Request 进行输出或记录一下耗时。
最终咱们能够组合成下面这种类型,从外到内依次是 TimeoutService
,LogService
,MetricsService
和 Transport
。履行次序是先履行 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