鍵.png)
ASP.NET Web API快速入門介紹
本質(zhì)上,我們需要讓 some_endpoint
同時支持異步和阻塞兩種使用方式。這里的關(guān)鍵問題是,當(dāng)你有幾十個端點(diǎn)時,你該如何實(shí)現(xiàn)這一點(diǎn)?而且,你怎樣才能讓用戶在異步和同步之間輕松切換呢?
這是最初實(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]。
第二種方法[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)了。
另一種可能的解決方法是,正如 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
。但這聽起來完全超出了我們的范疇。
第三次嘗試[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):
ureq
,這樣就不會引入 tokio
及其相關(guān)依賴Cargo.toml
中配置一個標(biāo)志不過,先停下來想幾分鐘,試試看你能不能找出為什么不應(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″來修復(fù),參考文檔也對此進(jìn)行了很好的解釋[27]。從 2021 版本開始,這個新版本已經(jīng)默認(rèn)啟用了,但你也可以在之前的版本的 Cargo.toml
中指定使用它。這個新版本除了其他改進(jìn),還在一些特殊情況下避免了特性的統(tǒng)一,但不包括我們的情況:
為了以防萬一,我自己嘗試復(fù)現(xiàn)了這個問題,結(jié)果確實(shí)如我所料。這個代碼庫[28]是一個特性沖突的例子,在任何特性解析器下都會出錯。
有一些 crate 也存在這個問題:
arangors
[29] 和 aragog
[30] :ArangoDB 的包裝器。兩者都用于 maybe_async
在異步和同步之間切換(arangors
事實(shí)上,的作者是同一個人)^5[31] ^6[32]。inkwell
[33] :LLVM 的包裝器。它支持多個版本的 LLVM,但彼此之間不兼容[7][34]。k8s-openapi
[35] :Kubernetes 的包裝器,與 inkwell
^8[36]存在同樣的問題。隨著這個 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)討論可能會很有趣:
oneof
配置謂詞(類似 #[cfg(any(…))]
)來支持互斥特性。這只是讓在別無選擇的情況下?lián)碛袥_突特性變得更容易,但特性仍應(yīng)該是嚴(yán)格疊加的。cargo test --all-features
將涵蓋所有情況。但如果不是,用戶就必須用多個特性標(biāo)志組合運(yùn)行命令,這相當(dāng)麻煩。非官方的 cargo-hack
[44] 已經(jīng)可以實(shí)現(xiàn)這一點(diǎ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]。
我們目前面臨以下選擇:
maybe_async
并為我們庫中的每個端點(diǎn)添加 _async
和 _sync
后綴。ncspot
或 spotifyd
是阻塞的,而其他如 spotify-tui
使用異步,所以我不確定他們會怎么想。我知道這是我給自己強(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ù)