我是如何構建 backon 的?

backon[1] 是一個 Rust 錯誤重試庫,今天這篇文章旨在跟分享我在實現它的過程中一些技巧~

緣起

OpenDAL[2] 實現 RetryLayer 時需要提供一種 backoff 機制,以實現指數退避和 jitter 等特性。雖然我已經通過簡單的搜索找到了 backoff[3],但我並不十分滿意。首先,我注意到這個庫的維護狀況似乎不太好,有 4 個未合併的 PR,而且主分支上一次更新是在 2021 年。其次,我不喜歡它提供的 API:

async fn fetch_url(url: &str) -> Result<String, reqwest::Error> {
    retry(ExponentialBackoff::default()|| async { fetch().await }).await
}

backoff 的實現並不複雜,爲什麼不自己造一個用起來舒服的呢?

設計

我頭腦中第一個想法是使用 Iterator<Item = Duration> 來表示 backoff。任何能夠返回 Duration 類型的 iterator 都可以作爲 backoff 使用。使用 iterator 來表示 backoff 具有非常直接和清晰的含義,使用者可以輕鬆地理解和上手實現,而無需閱讀每個函數的註釋。其次,我希望爲 backoff 提供類似於 Rust 原生函數的使用體驗:

async fn fetch_url(url: &str) -> Result<String, reqwest::Error> {
      fetch.retry(ExponentialBackoff::default()).await
}

看起來很不錯:簡單直接,不打亂用戶的閱讀順序,一眼能定位業務邏輯位置,讓我們着手實現它吧!

實現

首先,我們需要了解的是,Rust 中的 async 函數本質上都是生成器(generator)。這些生成器會捕獲當前環境的變量,並生成一個匿名的 Future。如果要重試一個 async 函數,我們需要再次調用這個生成器來生成一個全新的 Future 來執行。

我曾經走過的彎路是 Failed demo for retry: we can't retry a future directly[4] ,當時我天真地想直接重試一個 TryFuture:

pub trait Retryable<B: Policy, F: Fn(&Self::Error) -> bool>: TryFuture + Sized {
    fn retry(self, backoff: B, handle: F) -> Retry<Self, B, F>;
}

現在我明白了這種做法是錯誤的。一旦 Future 進入 Poll::Ready 狀態,我們就不應該再去輪詢它,這也正如文檔所描述的:

Once a future has completed (returned Ready from poll), calling its poll method again may panic, block forever, or cause other kinds of problems

接下來需要調整自己的思路,針對 || -> impl Future<Result<T>> 來實現。首先我定義了一個 Retryable trait 併爲所有的 FnMut() -> Fut 實現:

pub trait Retryable<
    B: BackoffBuilder,
    T,
    E,
    Fut: Future<Output = Result<T, E>>,
    FutureFn: FnMut() -> Fut,
>
{
    /// Generate a new retry
    fn retry(self, builder: &B) -> Retry<B::Backoff, T, E, Fut, FutureFn>;
}

impl<B, T, E, Fut, FutureFn> Retryable<B, T, E, Fut, FutureFn> for FutureFn
where
    B: BackoffBuilder,
    Fut: Future<Output = Result<T, E>>,
    FutureFn: FnMut() -> Fut,
{
    fn retry(self, builder: &B) -> Retry<B::Backoff, T, E, Fut, FutureFn> {
        Retry::new(self, builder.build())
    }
}

這個 trait 涉及到一下類型參數:

返回的 Retry 結構體則包裝了上述這些所有類型:

pub struct Retry<B: Backoff, T, E, Fut: Future<Output = Result<T, E>>, FutureFn: FnMut() -> Fut> {
    backoff: B,
    retryable: fn(&E) -> bool,
    notify: fn(&E, Duration),
    future_fn: FutureFn,

    #[pin]
    state: State<T, E, Fut>,
}

除了 backoff 和 future_fn 之外,我們引入了 retryable 和 notify 用來實現 retryable error 檢查和通知功能。類型系統想清楚之後,接下來的工作就是給 Retry 實現正確的 Future trait 了,細節不再贅述:

impl<B, T, E, Fut, FutureFn> Future for Retry<B, T, E, Fut, FutureFn>
where
    B: Backoff,
    Fut: Future<Output = Result<T, E>>,
    FutureFn: FnMut() -> Fut,
{
    type Output = Result<T, E>;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        ...
    }
}

此外,還有一些事務性的工作需要完成:我們需要讓用戶定義哪些 Error 是可以進行重試的,並且需要提供自定義通知重試的功能。

最後組合起來的效果如下:

#[tokio::main]
async fn main() -> Result<(){
    let content = fetch
        .retry(&ExponentialBuilder::default())
          .when(|e| e.to_string() == "EOF")
        .notify(|err, dur| {
            println!("retrying error {:?} with sleeping {:?}", err, dur);
        })
        .await?;

    Ok(())
}

看起來很完美!

One More Thing

哦,等一等,backon 還不支持同步函數!沒關係,我們只需要應用相同的思路:

pub trait BlockingRetryable<B: BackoffBuilder, T, E, F: FnMut() -> Result<T, E>> {
    /// Generate a new retry
    fn retry(self, builder: &B) -> BlockingRetry<B::Backoff, T, E, F>;
}

impl<B, T, E, F> BlockingRetryable<B, T, E, F> for F
where
    B: BackoffBuilder,
    F: FnMut() -> Result<T, E>,
{
    fn retry(self, builder: &B) -> BlockingRetry<B::Backoff, T, E, F> {
        BlockingRetry::new(self, builder.build())
    }
}

由於 fn_traits[5] 特性還沒有 stable,所以我選擇給 BlockingRetry 增加了一個新的函數:

impl<B, T, E, F> BlockingRetry<B, T, E, F>
where
    B: Backoff,
    F: FnMut() -> Result<T, E>,
{
  pub fn call(mut self) -> Result<T, E> {
    ...
  }
}

在 call 中完成重試的操作,用起來感覺也很不錯,跟 Async 的版本有一種相呼應的美感。

fn main() -> Result<(){
    let content = fetch
        .retry(&ExponentialBuilder::default())
          .when(|e| e.to_string() == "EOF")
        .notify(|err, dur| {
            println!("retrying error {:?} with sleeping {:?}", err, dur);
        })
        .call()?;

    Ok(())
}

總結

在本文中,我分享了 backon 的設計和具體實現。在這個過程中,我主要使用了 Rust 的泛型機制,分別爲 FnMut() -> Fut 和 FnMut() -> Result<T, E> 來實現了自定義 trait 來增加新的功能。我希望這個實現能夠啓發大家設計更加用戶友好的庫 API。

感謝大家的閱讀!

引用鏈接

[1] backon: https://github.com/Xuanwo/backon
[2] OpenDAL: https://github.com/datafuselabs/opendal
[3] backoffhttps://github.com/ihrwein/backoff
[4] Failed demo for retry: we can't retry a future directly: https://github.com/Xuanwo/backon/pull/1
[5] fn_traits: https://github.com/rust-lang/rust/issues/29625

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/N5jmCOoqXwXyIFmNahmPBA