以 Actix 为例,探索 Rust 拓展特征在工程中如何解耦
开篇
Actix 是 Actor 模型 的 Rust 语言实现
Actor 模型是并发计算的数学模型
关于 Actor 模型可简单参考:The actor model in 10 minutes
如果你看过 Actix
源码,你会发现在 actix::handler
中有这样一段代码
1 | rust复制代码// ... |
实现很简单,我们看看发生了什么。
tx
的类型是 Option<OneshotSender<Self>>
。OneshotSender
是 tokio::sync::Sender
的别名。
而 .send(self)
方法定义于 actix::handler
即同一个模块下。源码如下:
1 | rust复制代码// Helper trait for send one shot message from Option<Sender> type. |
其中泛型M指的是 tokio::sync::Sender<T>
。
为什么要多定义这个方法呢?
查看源码之后可以知道 MessageResponse
一共被实现了28次。也就是说上面这段代码,把所有 handle
函数中 Option
的判断统一做了处理,降低了代码耦合。
为什么不用其他方式去实现?
下面我们通过两个尝试去探讨一下,为什么采用这种方式实现更好。
尝试1: helper 函数
假设使用 helper 函数,那么函数大概如下:
1 | rust复制代码fn send<M>(tx: Option<OneshotSender<M>>, msg: M) { |
似乎代码更少更简单了?
使用的时候只需要 send(tx, msg)
就行了。
但是这么做有以下缺点:
- 代码分散,如果不看函数定义,根本不会知道能用这个函数,在团队协作中容易出现许多重复代码。并且因此为将来的重构埋坑。
- 缺少语义化的函数表达,这对于团队协作来说也是不利的。
尝试2: macro_rule 声明宏
假设用声明宏来实现,效果将是惨烈的:
1 | rust复制代码macro_rules! send { |
虽然这里编译期没有阻止你,但任何人都不能猜到 $tx
和 $msg
是什么类型,除非是你自己写的。
况且,用 macro_rule
来实现这个有种高射炮打蚊子的感觉 :)
为什么用 helper trait 更好?
trait
是 Rust 的规范,实现的功能更加强大,它可以利用泛型为多个类型复用,并且可以参与 trait bound
作为类型约束。
不过 Rust 为了防止依赖地狱,在2015年引入了孤儿原则 “Orphan Rule”,使用时还是有一些限制,简而言之 trait
和 struct/enum
必须有一个是来自于自身的 crate。
关于孤儿原则,可参考:Little Orphan Impls
实践 - 开胃菜
众所周知,内置类型也是来自外部,想要为原始类型拓展方法可以在本地编写 trait
,举个栗子:
1 | rust复制代码trait ColorExtend { |
我们编写了一个颜色拓展方法,用于判断该类型是否可以表达为颜色。
我们为 &str
类型实现一下:
1 | rust复制代码// 让 matching 可以匹配 char |
在这之后,我们就可以这样来使用了: "#0000FF".is_color()
同样的,这个方法可以给其他类型实现,比如 String
, Vec<u8>
等,甚至可以为 Option<&str>
实现。所有的泛型类型都属于一个独立的类型,这是由于 Rust 的零成本抽象,在编译器就会对泛型展开,生成独立的类型。基于此我们开始对 Actix 中的一些类型做拓展吧。
编写 Actor
先定义一个用于计数的 actor 作为例子,并为其实现一些基本特征:
1 | rust复制代码use actix::{Actor, Context}; |
获取计数 GetCounter
1 | rust复制代码use actix::{Handler, Message}; |
计数增加 CounterAdd
1 | rust复制代码/// counter add message |
n 秒内计数变化 GetDelta
此处由于需要异步,所以返回了 ResponseActFuture
。
1 | rust复制代码use std::time::Duration; |
actix::clock::sleep
并不阻塞,这是由于 actix 使用了基于 tokio 的运行时
这里的 into_actor
方法是 actix::fut::future::WrapFuture
定义的本文所提的 helper trait ,在一些库中这个思想很流行。
WrapFuture
定义:
1 | rust复制代码pub trait WrapFuture<A> |
这里为 Future
增加了一个 into_actor
方法,返回了一个 FutureWrap
,而 FutureWrap
的定义恰好用了上回提到的 pin_project
,有机会再细讲。
参考译文链接:为什么 Rust 需要 Pin, Unpin ?
模拟需求
假设这时候产品经理提出了这样一个需求:在 n 秒内,统计出counter值的变化,然后再在 n 秒后额外添加同等的值。
1 | rust复制代码/// an odd mission required by the lovely PM |
对于 map
的参数函数,三个参数的类型分别为:
Result<T, MailboxError>
&mut MyActor
&mut Context<MyActor>
假设我们暂时用朴素的方式写,大概会是这样:
1 | rust复制代码match ret { |
Emmm… 貌似也还好。
不过当 Err
分支需要处理一些特殊情况时(比如重置 Actor
),代码量比较多的情况下,如此往复也是不个好办法,我们封装一下 Result<T, MailboxError>
和 &mut Context<MyActor>
,让这部分代码抽离出来。
封装 Result<T, MailboxError>
首先,定义一个 trait
:
1 | rust复制代码pub trait ActixMailboxSimplifyExtend<T> { |
这个方法接收一个闭包函数 handler
,在 handler
中只处理正常返回的情况,而在 handle_mailbox
中统一处理 MailboxError
,实现如下:
1 | rust复制代码impl<T> ActixMailboxSimplifyExtend<T> for Result<T, MailboxError> { |
封装 Context<MyActor>
1 | rust复制代码pub trait ActixContextExtend { |
add_later
函数封装了 notify_later
,实现了在 secs
秒后自动发送 CounterAdd
消息。
封装了这两个方法之后,在遇到类似的需求时就 Don’t Repeat Yourself 了。
缺陷
当然这种方式也是有缺陷的,在使用时需要手动引入声明。编译器不会寻找其的所有实现,因为就会产生依赖混淆问题,如果遇到两个重复的方法,编译器会报如下错误:
1 | shell复制代码error[E0034]: multiple applicable items in scope |
所以尽量命名要避免与现有一些函数名重复,否则重构起来特别容易陷入混乱。
主函数
铺垫了这么多,看看实际结果吧。
1 | rust复制代码fn main() { |
这里测试了函数 add_later
的可用性,和 handle_mailbox
处理错误时是否正确。
运行结果:
1 | shell复制代码=[case 1]========================= |
总结
Rust 开创者根据多年的语言经历总结了前人开发的经验,推崇组合大于继承,可谓是集大成者。多用组合的方式去拓展你的代码,更你的代码更加 Rusty!
本文代码已推送至 github,如果对你有所启发欢迎 star。
转载请声明出处
本文转载自: 掘金