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