This article also has an English version.
本篇将介绍我在 Rust 中构建可靠的上下文传递组件的一些思考、设计与实现。我实现的 certain-map 已经开源(一年多以前开源的,近期做了更多改进,本文后续会介绍),欢迎使用!
项目地址:https://github.com/ihciah/certain-map
它解决了什么问题:
- 在跨组件传递上下文时,它可以借助编译器保证字段的存在性(即当某组件对 Context 中某字段存在读依赖时,前置组件必须写入过该字段,否则无法通过编译)
- 通用组件实现依赖的上下文可被定义为泛型参数并加以约束,这使组件实现更为通用,不耦合 Context 具体类型
注:虽然项目名字看起来是一种 map 实现,但其实这是一个基于过程宏生成的 struct。之所以命名为 certain-map 是因为其设计初衷是为了替换 TypeMap,并保证字段的存在性。
Service 抽象
如果你对此已经很熟悉,可以跳过这一节。
在 Rust 中,利用类型系统,我们很容易构建分层抽象,将复杂过程拆分为独立的组件,并在使用时将其组合。一个典型的例子是 tower Service(我也定义了一个面向纯 async 的 Service 及其周边工具:service-async)。
一个典型的 Service 定义如下:
1 | trait Service<Request> { |
以我正在开发的通用网关框架为例,L7 能力可能构建在 L5 能力之上,而 L5 又是基于 L4 实现的。为了使逻辑充分解耦并可插拔,利用 Service 抽象我们可以实现下面的 Service:
L4Svc: Service<(SocketAddr, T)>
TLSSvc: Service<T> where T: AsyncRead + AsyncWrite
H1Dispatcher: Service<T> where T: AsyncRead + AsyncWrite
H1Svc: Service<http::Request<Bytes>, Response = http::Response<Bytes>>
在构建组件时,L4Svc
作为最外层组件,接收对端地址和连接,并将连接传递给下一层组件。而 H1Dispatcher
则需要实现一个循环,持续解析 HTTP 请求,并将请求交给 H1Svc
处理,最后将 Response 写回。
所以我们可以有类似下面的代码:
1 | struct L4Svc<T> { |
是不是非常容易?我们可以通过组合不同的 Service 来构建复杂的逻辑,这些 Service 可以是不同的人实现的,我们只需要明确每个 Service 接收什么样的 Request,并约束其 inner 实现什么样的 Service。
Context 传递
从前面的例子中我们可以看出,Service 的本质是对异步函数的抽象,其借助类型系统将异步函数的输入和输出约束在了一起。外层 Service 产生的信息都可以丢给内层 Service,并由内层 Service 丢给下一层 Service。
这种显式传递可以很好地标注 Request 和 Response 的类型变换,反映 Service 的逻辑和约束(例如 H1Dispatcher 接收 impl Read+Write
并约束其 inner svc 能够处理 http::Request<Bytes>
,那么显然其内部主要实现 http 协议编解码)。
但有时候这种显式传递也会导致代码冗余,例如,我们期望每层都可以访问到请求者的 IP 地址(前面例子中的 SocketAddr
),那么我们就需要在每一层都传递这个数据。如果我们有非常多需要跨层传递的信息,那么这种显式传递就会变得非常繁琐,并使组件不再通用,耦合度增加。
要解决这个问题,一个方法是将这类信息全部收集到一个结构内,并在每一层传递这个结构。这个结构可以是预定义的 struct,也可以是一个 type map。这样我们就可以在每一层通过这个结构访问到所有信息,并写入后续 Service 可能会用到的信息。
两种 Context 存储方式
Struct-based Context
1 | struct MyContext { |
MyContext
由用户定义,其中包含了所有需要传递的信息。此处“用户”指开发了一部分 Service 的开发者,它可能会使用其他开发者提供的 Service。
此时所有 Service 实现均需要接收 concreate type MyContext
,并将其传递给下一层 Service。显然,基于这种方式实现的 Service 不具有任何可复用性,因为每个用户的 Context 都是不同的类型。
TypeMap-based Context
TypeMap 并非是一个特殊结构,其本质是一个 HashMap,key 是一个 type id,value 是一个 trait object。它可以存放任意的不同类型的数据。
1 | impl<T, R> Service<(TypeMap, SocketAddr, R)> for L4Svc<T> |
这个方式解决了 struct-based context 的问题,因为 TypeMap 足够通用,被所有 Service 强耦合是可以接受的。
hyper 使用这种形式传递一些内部使用的上下文信息,http::Request
和 http::Response
中的 Extensions
就是一个 TypeMap。
我们可以定义一个 NewType 来包装字段,以避免 key 冲突:
1 | struct PeerAddr(pub SocketAddr); |
TypeMap 的缺陷
由前面的分析,我们可以看出 TypeMap 是一个非常通用的解决方案,但它也有一些缺陷:
- 无法保证 Value 的存在性,即在取值时无法保证 Value 的存在,这可能导致 panic 或书写多余的错误处理逻辑。
- 存在堆分配开销
这两个问题中,第一个问题尤为严重。我在字节跳动参与开发了内部的 RPC 框架(现在它开源为 Volo),在初期它发生了多次意外的 panic,这些 panic 是由于 TypeMap 的 Value 不存在导致的。外层 Service 由于疏漏导致在某些分支忘记插入某个 Context 值,而内层 Service 又强依赖并假设这个值一定存在,这就导致了 panic。
另外,Golang 中的 Context 也存在该问题。
可靠的 Context 传递
让我们反思一下,为什么 TypeMap 存在这种缺陷?这是因为 TypeMap 是运行时读写的 map,所以无法充分利用编译期检查能力。
要引入编译期检查解决该问题,一个 idea 是,我们利用不同的类型来描述不同存在性的状态,并有条件的为它们实现某些 trait。当字段存在性变化时,我们也需要变更类型。
我们需要解决三个问题:
- 如何设计 Trait 来约束字段的存在性并对其进行操作?
- 如何定义类型并随字段存在性变更类型?
- 如何为字段存在性不同的状态可选地实现 Trait?
设计 Trait 来操作字段
在我的这篇文章里也介绍了类似的设计:Rust HTTP 框架设计 - 以 Axum 0.6 为例
Rust 标准库提供了 AsRef
, AsMut
等抽象,Linkerd 中也设计了 Param
描述类似抽象,axum 中设计了反向描述的 FromRef
。
我们可以将这种设计发扬光大,提供更多的操作 Trait:
1 | pub trait Param<T> { |
当我们需要约束某个字段的存在性时,我们就可以声明 CX: Param<PeerAddr>
来约束它。
当涉及对字段的插入、删除等变更字段存在性的操作时,我们需要获得当前类型的所有权并返回新的类型,新的类型定义为该 trait 的关联类型。
定义类型并在操作时变更类型
struct 显然是最佳的存储方式,它足够高效。由于 field 个数和类型都取决于用户定义,所以我们需要基于用户的定义生成代码,过程宏是我们的最佳选择。
在开发过程宏前,我们需要手动写出一个目标结构,用于证明这种设计的可行性,并将其作为模板。过程宏的实现并不复杂,在此我只展示目标结构。
对于下面这种用户定义的结构:
1 | struct MyContext { |
我们可以生成下面的结构:
1 | struct MyContext<T1, T2> { |
我们可以定义两个辅助结构放在我们的 lib 中:
1 | pub struct Occupied<T>(T); |
这两个结构即可去填充 T1、T2 的位置。
例如,初始状态下其类型展开为:
1 | struct MyContext { |
在写入 peer addr 后,其类型变为:
1 | struct MyContext { |
在写操作时(写操作指对字段存在性有变更的操作),我们需要拿到当前类型的所有权,并拆分字段重组到新类型中。例如:
1 | impl<T1, T2> ParamSet<PeerAddr> for MyContext<T1, T2> { |
为不同状态有条件地实现 Trait
有了前面的设计,我们就可以为不同状态有条件地实现 Trait 了。例如:
1 | impl<T2> ParamRef<PeerAddr> for MyContext<Occupied<PeerAddr>, T2> { |
MyContext<Vacant, T>
则不实现 ParamRef<PeerAddr>
。
更进一步,我们可以定义 Available 和 MaybeAvailable 两个 trait 来表示字段可操作能力,这样可以减少生成代码的复杂度:
1 | pub trait Available { |
其中,Available 表示字段一定存在(只为 Occupied<T>
实现),主要涉及读方法、take 方法等;MaybeAvailable 表示字段可能存在(所以可以为 Occupied<T>
和 Vacant
两个类型实现),含有覆盖写方法、remove 方法等。为了减少误用可能,我们还可以引入 sealed trait 来限制 trait 的实现。
实现效果
到目前为止,我们解决了前面三个问题,定义好了操作 trait、生成了目标结构、实现了字段存在性变更的操作。我们可以在编译期检查字段的存在性,避免 panic。
下面贴一个 certain-map 的 example(link):
1 | use certain_map::{certain_map, Param, ParamRef, ParamRemove, ParamSet, ParamTake}; |
如果我们在插入 UserName 前,或删除 UserName 后读取它,则编译器会报错,无法通过编译。
该 example 中,context 结构的类型做了如下变化:
- 初始状态下,
MyCertainMap
是一个空的 struct,没有任何字段,所以对应MyCertainMap<Vacant, Vacant>
,其 size 是 0。 - 插入 UserName 后,
MyCertainMap
变为MyCertainMap<Occupied<UserName>, Vacant>
,其 size 等同于UserName
。 - Take UserName 后,
MyCertainMap
变为MyCertainMap<Vacant, Vacant>
,其 size 又变为 0。 - 插入 UserAge 后,
MyCertainMap
变为MyCertainMap<Vacant, Occupied<UserAge>>
,其 size 等同于UserAge
。
到此为止,该设计已经完美地解决了我们的问题,它可以保证只要可以编译,则不可能有字段不存在的情况。另外,由于引入了 param 系列 trait,我们可以在定义 Service 时将 Context 作为泛型参数,解耦其 concreate 类型,让组件更通用。
更高效的 Context 传递
我将 certain-map(v0.2)集成在 MonoLake 中,这是我在字节跳动开发的一个基于 Monoio 的通用网关框架(尚未开源,但预计会在近期开源)。
在对其做压力测试和 perf 时,我观察到一些相关的内存拷贝开销。这些开销有两个来源:
- 由于字段存在性变更时 struct 类型和 size 都存在变更,所以需要拆分字段并重组到新类型中,这会导致一定的栈拷贝。
- 当外层 Service 传递 Context 到内层时,也可能存在一定的拷贝开销(该开销是否实际存在取决于 async 生成器是否做了该优化)。
为了解决这个问题,我提出了一个新的设计:将存储与状态分离,存储部分预分配全部字段空间,并传递状态和存储的引用构成的一个复合结构。这样,当状态变更时,只需要修改状态,而存储不移动,这样就避免了上述的栈拷贝开销。
这个设计的实现在 certain-map 的 v0.3 版本中。
存储结构与状态结构
存储结构是一个预分配的 struct,其不感知状态(即字段的存在性)。例如,对于下面这种用户定义的结构:
1 | struct MyContext { |
其实际的存储结构是(下文称其为 Store 结构):
1 | pub struct MyContextStorage { |
而状态结构是一个零大小的 struct,泛型参数表示存储结构中对应字段的存在性(下文称其为 State 结构):
1 | pub struct MyContextState<T1, T2> { |
最终传递的结构是一个复合结构(下文称其为 Handler 结构;该结构实质上等价于单个引用,所以传递成本非常低):
1 | pub struct MyContextHandler<'a, T1, T2> { |
在 trait 层面我们依旧可以采用前面的设计,我们需要为 MyContextHandler 实现 Param
等 trait;Available/MaybeAvailable 设计也可以继续沿用(函数签名需要修改)。
我们还需要一些新的 trait 来提供生成 Handler 结构等能力:
1 | pub trait Handler { |
最后,我们还需要为 Handler 结构实现 Drop 方法,以保证存储结构的字段被正确 drop。
优势与问题
对比前面的设计,这种设计有以下优势:
- 存储结构预分配在栈上,在可以快速访问的同时避免了栈拷贝开销(对比 TypeMap 的方案则节约了堆开销),对 CPU 缓存也更友好。
- 传递的结构是一个引用,所以传递成本非常低。可以避免不必要的栈拷贝开销。
- 当状态变更时,只需要修改状态,而存储不移动,这样就避免了栈拷贝开销。
但是,这个设计也引入了新的问题:
- 用户需要感知 Store 结构和 Handler 结构
- 生命周期管理更加复杂(下文中会介绍遇到的问题与解法)
- clone 需要特殊实现
第一个问题并不是一个大问题,用户只需要创建这个存储,并生成其 Handler,之后就可以像之前使用 Context 结构一样使用 Handler。
下面我们会展开讨论后面两个问题。
生命周期与约束定义
由于 Service 抽象下调用往往是嵌套的,所以理论上讲,用户将 Store 结构放在外层 Service 的栈上,并将其 Handler 传递给内层 Service,这样是没有问题的。
但实际上,在实现 Service 时,由于不绑定 Context 的 concreate type,所以基于 trait 的操作方式需要关心生命周期。
在这个例子中,我们实现了几个简单的 Service 并将其组合:
1 | struct Add1<T>(T); |
可以验证其正确性:
1 | let svc = Add1(Mul2(Identical)); |
调用时需要创建 Store 结构,并生成 Handler 后作为 CX 作为 Request 的一部分传递。
但我并不满足于此,Context 自动注入也应当实现为一个 Service!
1 | struct CXSvc<CXStore, T> { |
HRTB is your friend!
此处 Handler 结构需要写明 lifetime,而结构定义与 Service 上均无 lifetime。此时需要利用 HRTB 来约束 inner svc(类型 T)可以接受任意 lifetime 的 Handler。
看起来一切正常?并没有!
cannot return value referencing local variable
store
returns a value referencing data owned by the current function
其原因是,编译器无法证明返回的 Response 或 Error 中不包含对 store 的引用。如果包含,那么因为 store 是在函数内部创建的,其引用将会在函数结束后失效,则不能返回。
为了解决这个问题,我们需要一些小技巧:
1 | impl<CXStore, T, R, RESP, ERR> Service<R> for CXSvc<CXStore, T> |
这种实现即可正确编译。但是为什么呢?
通过将 Response 和 Error 作为泛型参数,我们可以将其类型与 Handler 的生命周期解除绑定,即对于任意 lifetime 的 Handler 均返回相同的 Response 和 Error,这样编译器就可以证明 Response 和 Error 不会包含对 store 的引用。
可以验证现在的实现符合预期:
1 | let svc = CXSvc::<MyCertainMap, _>::new(svc); |
实现 Fork
在 0.2 版本中,我们直接 derive Clone 即可实现 Clone。但在 0.3 版本中,存储与状态分离,我们需要实现特殊的 trait 来表达 Clone 语义,此处称之为 Fork。
我将 Clone 语义拆分为下面两个行为,一个是使用 Handler 拷贝 Store 和 State(必须使用 Handler,否则不知道 State 中字段的存在性),另一个是使用 Store 和 State 构造 Handler:
1 | pub trait Fork { |
在前面例子的基础上,我们可以新实现一个 Service 来测试 Fork。这个 Svc 会约束 Req: Copy
(实际调用时是数字)以及 Resp: Add<Output = Resp>
(实际调用时也是数字),并将其实现为使用 Req 调用两次 inner svc,并将结果相加:
1 | struct DupSvc<T>(T); |
只写这个 Svc 定义看起来编译可以通过,但预期正常执行吗?并不,在调用时编译器会 complain 约束不满足!
我们需要认真搞清楚 HRTB 的使用姿势。我们需要将代码修改为下面这样:
1 | impl<T, R, CXIn, CXStore, CXState, Resp, Err> Service<(R, CXIn)> for DupSvc<T> |
它们的差别在于这里:
1 | // fail |
在错误的 case 中,我们将 HDR 作为泛型参数,T: Service<(R, HDR)>
实质上是一个存在性约束(取交集),但对于入参,我们应当使用 HRTB 来约束其生命周期,即约束所有可能的 Hdr<'a>
(取并集)。
在正确使用 HRTB 后,我们可以正确编译并执行这个 Svc:
1 | let svc = CXSvc::<MyCertainMap, _>::new(DupSvc(Add1(Mul2(Identical)))); |
总结
在这篇文章中,我介绍了如何设计一个可靠的 Context 传递方案,并给出了两种实现以及实现时的设计思路。
在 certain-map 0.3 版本中,我将存储和状态分离,通过 Handler 结构传递,避免了栈拷贝开销,提高了性能。但这提高了生命周期管理的复杂度,所以我给出了使用 HRTB 解决该问题的正确方式。
最后欢迎大家在项目中使用这个组件,也欢迎提出建议和改进。