模拟 Rust 代码的所有方法

Lobsters Hottest 工具

摘要

一篇教程,涵盖了在 Rust 中模拟网络调用的多种策略,以发出事件的 Kubernetes 控制器为例,重点强调不降低生产代码的可测试性。

<p><a href="https://lobste.rs/s/tdugvv/all_ways_mock_your_rust_code">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/05/12 15:20

# 在 Rust 中模拟(Mock)代码的所有方法 来源:https://blog.appliedcomputing.io/p/all-the-ways-to-mock-your-rust-code 辛普森一家中的纳尔逊指着说"HA HA!"(https://substackcdn.com/image/fetch/$s_!9UHJ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0f6dc60e-69f8-4fc8-bc3a-9af82909b561_936x713.png) 好,假设你写了一个Kubernetes控制器,你用的是Rust¹,然后你意识到:"嘿,这东西出故障时很难理解到底发生了什么"²。于是,你决定发射一些Kubernetes事件,为你的控制器正在做什么以及采取了哪些步骤提供更多上下文。你运行了所有测试³,期待它们全部通过⁴——因为你没有改变任何功能,只是发射一些事件,这基本上就像添加几行日志,对吧?结果所有测试都失败了。因为你不只是在添加日志行,你实际上是在向Kubernetes apiserver发起网络请求,而在你的单元测试里并没有apiserver在监听。怎么办???? 好吧,这篇博文实际上并不是假设性的,你现在大概也猜到了。这正是我最近尝试在SimKube(https://simkube.dev/)中做的事情,我花了太长时间去琢磨"绝对最佳"的方法,既要做到这一点,又要让所有测试通过。这篇文章还触及了我在编程实践中的一个特别痛点,那就是:"不要为了让代码更容易测试,就逼我把生产代码搞得更烂,逗号 混蛋"⁵。 所以我想*某人*应该从我浪费的所有时间里受益,那么来了:在Rust中模拟网络调用的所有方法。 `kube-rs`(https://kube.rs/)Rust库为编写控制器提供了内置接口;你需要提供的一个主要东西是`reconcile`函数,每当控制器需要"做某事"时就会被调用。`reconcile`的签名如下: ``` pub async fn reconcile( object: Arc<...>, global_ctx: Arc<...> ) -> Result<...> ``` 第一个参数是发生变化的Kubernetes对象,第二个参数是用户定义的"上下文",其中包含协调器成功运行所需的额外"东西"。这篇文章我们要关注的是那个上下文参数,它看起来像这样: ``` struct GlobalContext { client: kube::Client, recorder: SkEventRecorder, } ``` 第一个字段是我们用来与Kubernetes API服务器通信的客户端(出于性能考虑,我们不希望每次协调时都重新创建它)。第二个字段是我们的事件记录器,你不希望每次协调时都重新创建它,因为它会丢失"我已经发送过哪些事件"的记忆,这样你就得不到漂亮的聚合事件("此事件在过去4秒内发生了57次"),而是会在4秒内得到57个独立事件。 事件记录器结构体只是`kube::runtime::events::Recorder`(来自`kube-rs`库)的一个封装,包含几个用于生成和发送事件的辅助函数。这是真实代码的简化版本: ``` use kube::runtime::events; struct SkEventRecorder { recorder: events::Recorder, } impl SkEventRecorder { pub fn new() -> SkEventRecorder { /* 初始化一个新的记录器 */ } pub async fn send_event_A(&self) -> Result<(), Error> { let event_a = events::Event::new(/* 制作一个事件 */); Ok(self.recorder.publish(event_a).await?) } pub async fn send_event_B(&self) -> Result<(), Error> { let event_b = events::Event::new(/* 制作一个事件 */); Ok(self.recorder.publish(event_a).await?) } } ``` 在那里你可以看到我们正在做的网络调用:每次`publish`都会把事件发送到apiserver并等待响应。我们如何让这段代码变得可测试?让我细数一下方法。 ### 方法 1:接口 + 依赖注入 第一种方法是最常见的,它使用接口(https://en.wikipedia.org/wiki/Interface_(object-oriented_programming))和依赖注入(https://en.wikipedia.org/wiki/Dependency_injection),在生产代码中提供真实的记录器,在测试中提供虚假的记录器。我们要把代码改成这样: ``` #[async_trait] pub trait EventRecordable { pub async fn send_event_A(&self) -> Result<(), Error>; pub async fn send_event_B(&self) -> Result<(), Error>; } struct SkEventRecorder { recorder: events::Recorder, } impl SkEventRecorder { pub fn new() -> SkEventRecorder { /* 初始化一个新的记录器 */ } } impl EventRecordable for SkEventRecorder { pub async fn send_event_A(&self) -> Result<(), Error> { let event_a = events::Event::new(/* 制作一个事件 */); Ok(self.recorder.publish(event_a).await?) } pub async fn send_event_B(&self) -> Result<(), Error> { let event_b = events::Event::new(/* 制作一个事件 */); Ok(self.recorder.publish(event_a).await?) } } struct GlobalContext { client: kube::Client, recorder: Arc<dyn EventRecordable>, } ``` 如果你写过Go代码,你对这个模式应该很熟悉;在Go中literally每个东西都要实现一个接口,仅仅因为这是让任何东西可测试的*唯一方式*。基于上述代码,你可以在测试代码中创建一个`MockSkEventRecorder`对象,实现`EventRecordable` trait⁶,然后将其传入测试中的`GlobalContext`对象。 实际上我们可以对这段代码做一个小简化;*唯一*需要模拟的东西就是记录器本身,我们可以对两个实现使用相同的`send_event_X`方法: ``` #[async_trait] trait EventRecordable { async fn send_event(&self, event: &events::Event) -> Result<(), Error>; } struct EventRecorder { recorder: events::Recorder, } impl EventRecordable for EventRecorder { async fn send_event(&self, event: &events::Event) -> Result<(), Error> { Ok(self.recorder.publish(event_a).await?) } } struct SkEventRecorder { recorder: Arc<dyn EventRecordable>, } impl SkEventRecorder { pub fn new() -> SkEventRecorder { /* 初始化一个新的记录器 */ } #[cfg(test)] pub fn new_with_mock() -> SkEventRecorder { /* 用模拟发送器初始化一个新记录器 */ } pub async fn send_event_A(&self) -> Result<(), Error> { let event_a = events::Event::new(/* 制作一个事件 */); Ok(self.recorder.send_event(event_a).await?) } pub async fn send_event_B(&self) -> Result<(), Error> { let event_b = events::Event::new(/* 制作一个事件 */); Ok(self.recorder.send_event(event_a).await?) } } struct GlobalContext { client: kube::Client, recorder: SkEventRecorder, } ``` 第二种变体*稍微*好一点,因为它减少了阅读协调函数的开发者需要看的抽象层数,但它在功能上等同于第一个版本。 这个模式让我*深感*沮丧,原因有三: 1. 你必须使用`async_trait` crate,因为在标准Rust中你不能有`async` traits。这个crate没问题,工作得很好,只是多了一个导入和一个proc宏注解,但使用它让我不爽。 2. 现在我在代码中添加了一堆不必要的样板。*没有理由*在这里使用依赖注入,除了让测试更容易。其他来阅读代码的人需要解析一个额外的抽象层才能理解发生了什么,而我希望少一些抽象层,而不是更多。 3. 最致命的是,这段代码现在对生产运行产生了性能影响!我们有了两次指针间接引用,而之前是零次:Rust编译器不知道`impl EventRecordable`有多大,所以你*必须*在堆上创建它,而由于这是高度异步的代码,你必须使用`Arc`而不是`Box`,所以我们现在多了一次指针查找*和*一个引用计数器的开销。不仅如此,我们现在还使用了动态分发(https://en.wikipedia.org/wiki/Dynamic_dispatch)(本质上是一个vtable,也就是另一次指针查找)来确定事件记录器的函数定义在哪里。 也许如果你写的是Go代码,它已经做了很多不那么高性能的事情,你不在乎,但这是Rust,该死的!你*应该*过早地对一切进行超优化!难道没有更好的方法吗? ### 方法 2:静态分发(泛型) 我们可以通过将动态分发转换为静态分发(https://en.wikipedia.org/wiki/Static_dispatch)来解决第三个问题,消除性能影响。在Rust中,使用泛型类型参数来实现,看起来像这样: ``` #[async_trait] pub trait EventRecordable { async fn send_event(&self, event: &events::Event) -> Result<(), Error>; } struct EventRecorder { recorder: events::Recorder, } impl EventRecordable for EventRecorder { async fn send_event(&self, event: &events::Event) -> Result<(), Error> { Ok(self.recorder.publish(event_a).await?) } } struct SkEventRecorder<T: EventRecordable> { recorder: T, } impl<T: EventRecordable> SkEventRecorder<T> { pub fn new() -> SkEventRecorder<T> { /* 初始化一个新的记录器 */ } pub async fn send_event_A(&self) -> Result<(), Error> { let event_a = events::Event::new(/* 制作一个事件 */); Ok(self.recorder.send_event(event_a).await?) } pub async fn send_event_B(&self) -> Result<(), Error> { let event_b = events::Event::new(/* 制作一个事件 */); Ok(self.recorder.send_event(event_a).await?) } } struct GlobalContext<T: EventRecordable> { client: kube::Client, recorder: SkEventRecorder<T>, } ``` 这段代码将运行时成本转化为编译时成本,这很棒!Rust编译器会在构建二进制文件或运行测试时对代码进行"单态化"(monomorphize)⁷;没有堆分配,没有vtable查找。这太棒了!唯一的问题是?这丑得像¥%#&。那个愚蠢的模板参数一路向上侵入到了我的顶层`GlobalContext`结构体中!现在来阅读这段代码的人要解析*很多*东西才能弄清楚发生了什么。这是不可接受的! 还有别的吗? ### 方法 3:条件编译 你知道吗,整个 trait 的方案实在太糟糕了,不如我们……直接不做? ``` struct SkEventRecorder { recorder: events::Recorder, } impl SkEventRecorder { pub fn new() -> SkEventRecorder { /* 初始化一个新的记录器 */ } } #[cfg(not(test))] impl SkEventRecorder { pub async fn send_event_A(&self) -> Result<(), Error> { let event_a = events::Event::new(/* 制作一个事件 */); Ok(self.recorder.publish(event_a).await?) } pub async fn send_event_B(&self) -> Result<(), Error> { let event_b = events::Event::new(/* 制作一个事件 */); Ok(self.recorder.publish(event_a).await?) } } #[cfg(test)] impl SkEventRecorder { pub async fn send_event_A(&self) -> Result<(), Error> { Ok(()) } pub async fn send_event_B(&self) -> Result<(), Error> { Ok(()) } } struct GlobalContext { client: kube::Client, recorder: SkEventRecorder, } ``` 这有点可爱。我们使用 Rust 的编译时指令说:"如果你在测试下运行,就总是立即返回;但如果你是真二进制文件,就发送事件"⁸。这里有两个问题:一是如果将来某天我需要为事件记录器编写更细粒度的测试,我做不到,除非大费周章。二是实际代码中,事件记录器和上下文位于不同的 crate,而`test`配置参数不会传递给 crate 依赖——这有道理,但意味着我现在必须在我的代码中引入一个新 feature,并在各种 `Cargo.toml` 中折腾它。真恶心。 ### 方法 4:混合体 我们实际上可以把上述两种方法结合起来,弄出一堆糟糕透顶的弗兰肯 trait 代码: ``` #[async_trait] pub trait EventRecordable { async fn send_event(&self, event: &events::Event) -> Result<(), Error>; } struct EventRecorder { recorder: events::Recorder, } impl EventRecordable for EventRecorder { async fn send_event(&self, event: &events::Event) -> Result<(), Error> { Ok(self.recorder.publish(event_a).await?) } } struct SkEventRecorder { #[cfg(not(test))] recorder: EventRecorder, #[cfg(test)] recorder: MockEventRecorder, } impl SkEventRecorder { pub fn new() -> SkEventRecorder { /* 初始化一个新的记录器 */ } pub async fn send_event_A(&self) -> Result<(), Error> { let event_a = events::Event::new(/* 制作一个事件 */); Ok(self.recorder.send_event(event_a).await?) } pub async fn send_event_B(&self) -> Result<(), Error> { let event_b = events::Event::new(/* 制作一个事件 */); Ok(self.recorder.send_event(event_a).await?) } } struct GlobalContext { client: kube::Client, recorder: SkEventRecorder, } ``` 这包含了方法 3 的所有缺点,还额外加了一个好处:看起来真的非常非常蠢。 ### 方法 5:Actor 模型 好吧,这一切都既蠢又令人沮丧,让我们退一步,想想我们到底想在这里做什么⁹。我们希望:a) 发送事件,b) 通过网络,c) 并进行测试。也许问题在于我们的代码还不够*复杂*!我不打算在这里全部写出来,但解决这个问题的另一个"标准"方法是使用所谓 actor 模型(https://en.wikipedia.org/wiki/Actor_model)。基本思想是程序中的代码块是"actor",actor 之间唯一通信的方式是通过发送消息,通常通过某种类型的队列。因此,在这种模型中,我们会重写协调函数,只将事件提交到一个队列,然后在另一端有另一个代码片段异步监听队列,并通过网络连接将这些事件转发到 Kubernetes apiserver,你可以想象这不过是另一种类型的队列¹⁰。 实际上,actor 模式是一个相当不错的模式!它有很多用例,特别是对于更复杂的交互,但记住:我们只是试图写一些花哨的日志行!拜托,我们到底在干什么,我可不会为了处理主协调循环的日志行而启动整个队列和单独的异步协调循环。这太疯狂了! ### 方法 6:伪造一个 API 服务器 好吧,我们遇到的核心问题是,*所有*这些方法都要求你将生产代码从简单直接("做一件事,记录你做了这件事,做下一件事")变成一个极其可怕的怪物,只是为了让它更容易测试。如果我们……什么都不做呢?相反,我们可以保持代码不变,只需在测试工具中启动一个假的 Kubernetes apiserver 来监听和响应事件。这样我们就可以测试与真实 apiserver 的真实交互,唯一的小问题是,我们现在必须创建一个完整的 Kubernetes apiserver 才能让单元测试通过,*而且*我们在测试中引入了一个网络调用,而网络以极其健壮、从不丢包、绝不会随意制造不稳定测试而闻名,绝对不,先生。 ### 结论 就这样!以上是我所知道的在 Rust 中为了测试目的进行模拟的所有方法。没有一个令人满意。每一个都在不同方面感觉很恶心。然而,我不认为有更好的方法?静态类型、编译型语言(如 Rust,以及程度较轻的 Go)的问题在于,你必须在编译时告诉编译器你将有一个真实类型,它会做真实类型的事情。而未被处理的……

相似文章

从Go迁移到Rust

Hacker News Top

一份为Go开发者迁移到Rust编写的全面指南,专注于后端服务,对比正确性、运行时和人体工程学方面的权衡,并提供关于渐进式迁移的实用建议。

Rust语言的性能

Lobsters Hottest

本次演讲分析了Rust相较于C++的性能优势与劣势,提供了基准测试和最佳实践。附有幻灯片和阅读材料。

持久化执行:硬核方式

Hacker News Top

一本教程指南,教你如何受Kubernetes the hard way启发,从零开始使用Go和Postgres构建持久化执行引擎。