impl Spotify {
    async fn some_endpoint(&self, param: String) -> SpotifyResult<String> {
        let mut params = HashMap::new();
        params.insert("param", param);

        self.http.get("/some-endpoint", params).await
    }
}

本質(zhì)上,我們需要讓 some_endpoint 同時支持異步和阻塞兩種使用方式。這里的關(guān)鍵問題是,當(dāng)你有幾十個端點(diǎn)時,你該如何實(shí)現(xiàn)這一點(diǎn)?而且,你怎樣才能讓用戶在異步和同步之間輕松切換呢?

老掉牙的復(fù)制粘貼大法

這是最初實(shí)現(xiàn)的方法。它相當(dāng)簡單,而且確實(shí)能用。你只需要把常規(guī)的客戶端代碼復(fù)制到 Rspotify 的一個新的 blocking模塊[9]里。reqwest[10](我們用的 HTTP 客戶端)和 reqwest::blocking 共用一個接口,所以我們可以在新模塊里手動刪掉 async 或 .await 這樣的關(guān)鍵字,然后把 reqwest 的導(dǎo)入改成 reqwest::blocking

這樣一來,Rspotify 的用戶只需要用 rspotify::blocking::Client 替代 rspotify::Client,瞧!他們的代碼就變成阻塞式的了。這會讓只用異步的用戶的二進(jìn)制文件變大,所以我們可以把它放在一個叫 blocking 的特性開關(guān)后面,大功告成。

不過,問題后來就變得明顯了。整個 crate 的一半代碼都被復(fù)制了一遍。添加或修改一個端點(diǎn)就意味著要寫兩遍或刪兩遍所有東西。

除非你把所有東西都測試一遍,否則沒法確保兩種實(shí)現(xiàn)是等效的。這主意倒也不壞,但說不定你連測試都復(fù)制粘貼錯了呢!那可怎么辦?可憐的代碼審查員得把同樣的代碼讀兩遍,確保兩邊都沒問題 —— 這聽起來簡直就是人為錯誤的溫床。

根據(jù)我們的經(jīng)驗(yàn),這確實(shí)大大拖慢了 Rspotify 的開發(fā)進(jìn)度,尤其是對于不習(xí)慣這種折騰的新貢獻(xiàn)者來說。作為 Rspotify 的一個新晉且熱情的維護(hù)者,我開始研究其他可能的解決方案[11]

召喚 block_on

第二種方法[12]是把所有東西都在異步那邊實(shí)現(xiàn)。然后,你只需為阻塞接口做個包裝,在內(nèi)部調(diào)用 block_on。block_on 會運(yùn)行 future 直到完成,本質(zhì)上就是把它變成同步的。你仍然需要復(fù)制方法的定義,但實(shí)現(xiàn)只需寫一次:

mod blocking {
    struct Spotify(super::Spotify);

    impl Spotify {
        fn endpoint(&self, param: String) -> SpotifyResult<String> {
            runtime.block_on(async move {
                self.0.endpoint(param).await
            })
        }
    }
}

請注意,為了調(diào)用block_on,您首先必須在端點(diǎn)方法中創(chuàng)建某種運(yùn)行時。例如,使用tokio[13] :

let mut runtime = tokio::runtime::Builder::new()
    .basic_scheduler()
    .enable_all()
    .build()
    .unwrap();

這就引出了一個問題:我們是應(yīng)該在每次調(diào)用端點(diǎn)時都初始化運(yùn)行時,還是有辦法共享它呢?我們可以把它保存為一個全局變量(_呃,真惡心_),或者更好的方法是,我們可以把運(yùn)行時保存在 Spotify 結(jié)構(gòu)體中。但是由于它需要對運(yùn)行時的可變引用,你就得用 Arc<Mutex<T>> 把它包起來,這樣一來就完全扼殺了客戶端的并發(fā)性。正確的做法是使用 Tokio 的 Handle[14],大概是這樣的:

use tokio::runtime::Runtime;

lazy_static! { // You can also use once_cell     static ref RT: Runtime = Runtime::new().unwrap(); } fn endpoint(&self, param: String) -> SpotifyResult<String> {     RT.handle().block_on(async move {         self.0.endpoint(param).await     }) }

雖然使用 handle 確實(shí)讓我們的阻塞客戶端更快了^1[15],但還有一種性能更高的方法。如果你感興趣的話,這正是 reqwest 自己采用的方法。簡單來說,它會生成一個線程,這個線程調(diào)用 block_on 來等待一個裝有任務(wù)的通道 ^2[16] ^3[17]。

不幸的是,這個解決方案仍然有相當(dāng)大的開銷。你需要引入像 futures 或 tokio 這樣的大型依賴,并將它們包含在你的二進(jìn)制文件中。所有這些,就是為了…最后還是寫出阻塞代碼。所以這不僅在運(yùn)行時有成本,在編譯時也是如此。這在我看來就是不對勁。

而且你仍然有不少重復(fù)代碼,即使只是定義,積少成多也是個問題。reqwest 是一個巨大的項(xiàng)目,可能負(fù)擔(dān)得起他們的 blocking 模塊的開銷。但對于像 rspotify 這樣不那么流行的 crate 來說,這就難以實(shí)現(xiàn)了。

復(fù)制 crate

另一種可能的解決方法是,正如 features 文檔所建議的那樣,創(chuàng)建獨(dú)立的 crate。我們可以有 rspotify-sync 和 rspotify-async,用戶可以根據(jù)需要選擇其中一個作為依賴,甚至如果需要的話可以兩個都用。問題是 —— 又來了 —— 我們究竟該如何生成這兩個版本的 crate 呢?即使使用 Cargo 的一些技巧,比如為每個 crate 準(zhǔn)備一個 Cargo.toml 文件(這種方法本身就很不方便),我也無法在不復(fù)制粘貼[18]整個 crate 的情況下做到這一點(diǎn)。

采用這種方法,我們甚至無法使用過程宏,因?yàn)槟悴荒茉诤曛袘{空創(chuàng)建一個新的 crate。我們可以定義一種文件格式來編寫 Rust 代碼的模板,以便替換代碼中的某些部分,比如 async/.await。但這聽起來完全超出了我們的范疇。

最終版是:maybe_async crate

第三次嘗試[19]基于一個名為 maybe_async[20]  的 crate。我記得當(dāng)初發(fā)現(xiàn)它時,天真地以為這就是完美的解決方案。

總之,這個 crate 的思路是,你可以用一個過程宏自動移除代碼中的 async 和 .await,本質(zhì)上就是把復(fù)制粘貼的方法自動化了。舉個例子:

#[maybe_async::maybe_async]
async fn endpoint() { /* stuff */ }

生成以下代碼:

#[cfg(not(feature = "is_sync"))]
async fn endpoint() { /* stuff */ }

#[cfg(feature = "is_sync")]
fn endpoint() { /* stuff with .await removed */ }

你可以通過在編譯 crate 時切換 maybe_async/is_sync 特性來配置是要異步還是阻塞代碼。這個宏適用于函數(shù)、trait 和 impl 塊。如果某個轉(zhuǎn)換不像簡單地移除 async 和 .await 那么容易,你可以用 async_impl 和 sync_impl 過程宏來指定自定義實(shí)現(xiàn)。它處理得非常好,我們在 Rspotify 中已經(jīng)使用它一段時間了。

事實(shí)上,它效果如此之好,以至于我讓 Rspotify 變成了 HTTP 客戶端無關(guān)的,這比異步/同步無關(guān)更加靈活。這使我們能夠支持多種 HTTP 客戶端,比如 reqwest[21]  和 ureq[22] ,而不用管客戶端是異步的還是同步的。

如果你有 maybe_async,實(shí)現(xiàn)HTTP 客戶端無關(guān)并不是很難。你只需要為 HTTP 客戶端[23]定義一個 trait,然后為你想支持的每個客戶端實(shí)現(xiàn)它:

一段代碼勝過千言萬語。(你可以在這里找到 Rspotify 的 reqwest客戶端[24]的完整源代碼, ureq 也可以在這里[25]找到 )

#[maybe_async]
trait HttpClient {
    async fn get(&self) -> String;
}

#[sync_impl]
impl HttpClient for UreqClient {
    fn get(&self) -> String { ureq::get(/* ... */) }
}

#[async_impl]
impl HttpClient for ReqwestClient {
    async fn get(&self) -> String { reqwest::get(/* ... */).await }
}

struct SpotifyClient<Http: HttpClient> {
    http: Http
}

#[maybe_async]
impl<Http: HttpClient> SpotifyClient<Http> {
    async fn endpoint(&self) { self.http.get(/* ... */) }
}

然后,我們可以進(jìn)一步擴(kuò)展,讓用戶通過在他們的 Cargo.toml 中設(shè)置特性標(biāo)志來選擇他們想要使用的客戶端。比如,如果啟用了 client-ureq,由于 ureq 是同步的,它就會啟用 maybe_async/is_sync。這樣一來,就會移除 async/.await 和 #[async_impl] 塊,Rspotify 客戶端內(nèi)部就會使用 ureq 的實(shí)現(xiàn)。

這個解決方案避免了我之前提到的所有缺點(diǎn):

不過,先停下來想幾分鐘,試試看你能不能找出為什么不應(yīng)該這么做。實(shí)際上,我給你 9 個月時間,這就是我花了多長時間才意識到問題所在…

問題

嗯,問題在于 Rust 中的特性必須是疊加的:”啟用一個特性不應(yīng)該禁用功能,而且通常應(yīng)該可以安全地啟用任意組合的特性”。當(dāng)依賴樹中出現(xiàn)重復(fù)的 crate 時,Cargo 可能會合并該 crate 的特性,以避免多次編譯同一個 crate。如果您想了解更多詳細(xì)信息,參考資料對此進(jìn)行了很好的解釋[26]。

這種優(yōu)化意味著互斥的特性可能會破壞依賴樹。在我們的情況下,maybe_async/is_sync 是一個由 client-ureq 啟用的 切換特性。所以如果你試圖同時啟用 client-reqwest 來編譯,它就會失敗,因?yàn)?nbsp;maybe_async 將被配置為生成同步函數(shù)簽名。不可能有一個 crate 直接或間接地同時依賴于同步和異步的 Rspotify,而且根據(jù) Cargo 參考文檔,maybe_async 的整個概念目前是錯誤的。

新特性解析器 v2

一個常見的誤解是,這個問題可以通過”特性解析器 v2″來修復(fù),參考文檔也對此進(jìn)行了很好的解釋[27]。從 2021 版本開始,這個新版本已經(jīng)默認(rèn)啟用了,但你也可以在之前的版本的 Cargo.toml 中指定使用它。這個新版本除了其他改進(jìn),還在一些特殊情況下避免了特性的統(tǒng)一,但不包括我們的情況:

為了以防萬一,我自己嘗試復(fù)現(xiàn)了這個問題,結(jié)果確實(shí)如我所料。這個代碼庫[28]是一個特性沖突的例子,在任何特性解析器下都會出錯。

其他失敗

有一些 crate 也存在這個問題:

修復(fù) maybe_async

隨著這個 crate 開始變得流行起來,有人在 maybe_async 中提出了這個問題,解釋了情況并展示了一個修復(fù)方案: async 和 sync 在同一程序中 fMeow/maybe-async-rs #6[37]

maybe_async 現(xiàn)在會有兩個特性標(biāo)志:is_sync 和 is_async。這個 crate 會以同樣的方式生成函數(shù),但會在標(biāo)識符后面添加 _sync 或 _async 后綴,這樣就不會沖突了。例如:

#[maybe_async::maybe_async]
async fn endpoint() { /* stuff */ }

現(xiàn)在將生成以下代碼:

#[cfg(feature = "is_async")]
async fn endpoint_async() { /* stuff */ }

#[cfg(feature = "is_sync")]
fn endpoint_sync() { /* stuff with .await removed */ }

然而,這些后綴會引入噪音,所以我在想是否有可能以更符合人體工程學(xué)的方式來實(shí)現(xiàn)。我 fork 了maybe_async并嘗試了一下,你可以在這一系列評論中讀到更多相關(guān)內(nèi)容。總的來說,這太復(fù)雜了,我最終放棄了。

修復(fù)這個邊緣情況的唯一方法就是讓 Rspotify 對所有人的可用性變差。但我認(rèn)為,同時依賴異步和同步版本的人可能很少;實(shí)際上我們還沒有收到任何人的抱怨。與reqwest不同,rspotify是一個”高級”庫,所以很難想象它會在一個依賴樹中出現(xiàn)多次。

也許我們可以向 Cargo 的開發(fā)者尋求幫助?

官方支持

雖然不是官方的,但 Rust 中可以進(jìn)一步探索的另一種有趣方法是“Sans I/O”[38]。這是一個 Python 協(xié)議,它抽象了網(wǎng)絡(luò)協(xié)議(如 HTTP)的使用,從而最大限度地提高了可重用性。Rust 中現(xiàn)有的一個示例是  tame-oidc[39]。

Rspotify 遠(yuǎn)不是第一個遇到這個問題的項(xiàng)目,所以閱讀之前的相關(guān)討論可能會很有趣:

根據(jù)這條舊評論[47],這不是 Rust 團(tuán)隊已經(jīng)否決的東西;它仍在討論中。

雖然是非官方的,但另一個可以在 Rust 中進(jìn)一步探索的另一種有趣方法是 “Sans I/O”[48]。這是一種 Python 協(xié)議,它在我們的案例中抽象了 HTTP 等網(wǎng)絡(luò)協(xié)議的使用,從而最大化了可重用性。Rust 中現(xiàn)有的一個例子是  tame-oidc[49]

結(jié)論

我們目前面臨以下選擇:

我知道這是我給自己強(qiáng)加的問題。我們可以直接說”不。我們只支持異步”或”不。我們只支持同步”。雖然有用戶對能夠使用兩者感興趣,但有時你就是得說不。如果這樣一個特性變得如此復(fù)雜,以至于你的整個代碼庫變成一團(tuán)糟,而你沒有足夠的工程能力來維護(hù)它,那這就是你唯一的選擇。如果有人真的很在意,他們可以直接 fork 這個 crate 并將其轉(zhuǎn)換為同步版本供自己使用。

畢竟,大多數(shù) API 封裝庫等只支持異步或阻塞代碼中的一種。例如,serenity[50] (Discord API)、sqlx[51] (SQL 工具包)和 teloxide[52] (Telegram API)是僅異步的,而且它們非常流行。。

盡管有時候很沮喪,但我并不后悔花了這么多時間兜圈子試圖讓異步和同步都能工作。我最初為 Rspotify 做貢獻(xiàn)就是為了學(xué)習(xí)。我沒有截止日期,也沒有壓力,我只是想在空閑時間嘗試改進(jìn) Rust 中的一個庫。而且我確實(shí)學(xué)到了很多;希望在讀完這篇文章后,你也是如此。

也許今天的教訓(xùn)是,我們應(yīng)該記住 Rust 畢竟是一種低級語言,有些事情如果不引入大量復(fù)雜性是不可能實(shí)現(xiàn)的。無論如何,我期待 Rust 團(tuán)隊將來如何解決這個問題。

那么你怎么看?如果你是 Rspotify 的維護(hù)者,你會怎么做?如果你愿意,可以在下面留言。

文章轉(zhuǎn)自微信公眾號@鳥窩聊技術(shù)

上一篇:

深入探索 Rust Salvo:從簡單博客系統(tǒng)到完整 RESTful API 的實(shí)戰(zhàn)項(xiàng)目

下一篇:

axios中restful api的使用
#你可能也喜歡這些API文章!

我們有何不同?

API服務(wù)商零注冊

多API并行試用

數(shù)據(jù)驅(qū)動選型,提升決策效率

查看全部API→
??

熱門場景實(shí)測,選對API

#AI文本生成大模型API

對比大模型API的內(nèi)容創(chuàng)意新穎性、情感共鳴力、商業(yè)轉(zhuǎn)化潛力

25個渠道
一鍵對比試用API 限時免費(fèi)

#AI深度推理大模型API

對比大模型API的邏輯推理準(zhǔn)確性、分析深度、可視化建議合理性

10個渠道
一鍵對比試用API 限時免費(fèi)