Rust 如何實現依賴注入

依賴注入是我在開發高可測試性和模塊化代碼時最喜歡的設計模式之一。要應用這個模式,你需要做的就是遵循兩個簡單的準則:

  1. 將對象的構造與使用分開。在實踐中,停止在構造函數中創建對象,而是將這些對象作爲輸入參數。

  2. 使用接口而不是具體類型作爲構造函數的參數。通過這種方式,接收方沒有接收接口的具體類型,因此可以提供不同的實現。

依賴注入確實是可測試性的關鍵,但它也是一個很好的設計原則,因爲它保持了系統各部分鬆散耦合。最好的一點是依賴注入是一個簡單的概念,不需要花哨的框架。

至於如何定義通用接口,技術上取決於你選擇的語言。在 c++ 中,可以定義純抽象類;在 Java, Go 和 c# 中,可以定義接口;在 Rust 中,使用 Trait。

這篇文章是關於 Rust 的,所以讓我們來談談使用 Trait 來實現依賴注入,它們會產生怎樣的副作用,以及我們能做些什麼。

一個例子

當依賴注入模式被正確應用時,一個庫 crate 就會體現出:

  1. Trait 代表一個重量級對象;

  2. 實現這些 Trait 的對象集合;

  3. 通過泛型 Trait,函數使用這些對象;

然後,庫的使用者實例化他們需要的特定對象,將它們連接在一起以創建依賴關係圖,並將它們提供給庫公開的通用業務邏輯函數。

明白了嗎?是的,我想沒有。讓我們來看一個具體的例子,這樣我們就可以爲下面的解釋提供參考代碼。爲此,我將使用我最近發佈的 db_logger crate 的代碼。

db_logger 提供了一個日誌 facade 實現,它將日誌消息記錄到數據庫中。存儲消息的數據庫取決於你,PostgreSQL 或者 SQLite(默認),這個選擇是通過依賴注入實現的。

你可能會認爲,對於選擇數據庫後端,Cargo 特性已經足夠了,但是對於這種配置來說,Cargo 特性不是一個很好的工具。Cargo 特性非常適合於控制構建的依賴關係 (db_logger 確實爲此提供了 postgres 和 sqlite 特性),但作爲用戶,仍然必須通過代碼選擇與哪個數據庫通信。配置每個後端數據庫需要不同的設置,你甚至可能希望在運行時選擇一個後端數據庫。

爲了支持運行時配置,我們首先定義一個 Trait 來表示數據庫連接。通過這種方式,日誌記錄 (業務) 邏輯可以記錄日誌條目,並且不需要感知它正在與哪個特定的數據庫通信。然後,我們添加一個函數基於這個抽象連接來初始化:

#[async_trait]
/// Operations that an arbitrary database connection can perform.
pub trait Db {
    async fn put_log_entries(&self, es: Vec<LogEntry<'_, '_>>) -> Result<()>;
}
/// Initializes the logging subsystem to record entries in `db`.
pub fn init(db: Arc<dyn Db + Send + Sync + 'static>) {
    // ...
}

有了這個接口,db_logger 的使用者可以通過使用實現 Db 特徵的對象來選擇要連接的數據庫——現在有兩個對象: PostgresDb 和 SqliteDb。

在客戶端 (比如,從 src/main.rs),當我們想要連接 PostgreSQL 時,我們可以這樣做:

let db = Arc::from(db_logger::PostgresDb::connect_lazy(
    host, port, database, username, password));
db_logger::init(db);  // Doesn't care about which specific `db`.

或者,想要與 SQLite 連接時:

let db = Arc::from(db_logger::SqliteDb::connect(uri));
db_logger::init(db);  // Doesn't care about which specific `db`.

注意:由於這種設計,業務邏輯單元測試也可以使用這種抽象來保持穩定和極快的速度。特別是,日誌邏輯的單元測試使用內存數據庫 SQLite,避免了錯誤配置或網絡問題。

看起來不錯,對吧?是的,確實如此,但是請注意上面的 Db trait 是如何在其 put_log_entries() 函數中引用 LogEntry 類型的。這種類型,db_logger 的用戶不需要知道,但是現在也必須是 public 的,因爲 Db 是 public 的。這是一個很大的傳遞問題。

問題

在 Rust 中使用 trait 進行依賴注入的關鍵問題是,函數簽名中引用的任何類型必須至少和函數本身一樣可見。這意味着,如果 Trait 是 public 的 (如上面的 Db 特徵),那麼任何特徵函數(如上面的 LogEntry 結構) 引用的任何類型也必須是 public 的。

過於廣泛的可見性是有問題的,至少有兩個原因:

這個問題並不只存在於 lib 的 crate 中,二進制 crate 也會受到影響。另外,如果有集成測試,任何 crate 都可能受到影響,因爲這些測試只能與你的公共接口交互。

那麼,我們能做些什麼來保持我們的架構正常呢?

糟糕的解決方案:“無所不能的函數”

第一個解決方案是嘗試將有問題的特性隱藏在我稱之爲 “無所不能的函數”(因爲沒有更好的名稱) 後面。

這是我去年在幾個項目中第一次做的,在那些項目中,我曾經有一個 serve_rest_api() 公共函數,它獲取數據庫連接,然後啓動它支持的 REST 服務器。爲了隱藏這些特徵,我將這個函數重命名爲 serve_rest_api_internal(),然後添加了一個新的 serve_rest_api() 函數,該函數接受配置參數來決定實例化哪個對象,從而包含了 src/main.rs 中先前存在的大部分邏輯。

不用說,這很難看,因爲我們失去了可組合性,而且這看起來很糟糕,因爲我們把主程序的責任硬塞到庫中——所有這些都是爲了解決一些可見性問題。從 API 設計的角度來看,這不是一個好的權衡。

更糟糕的是,這種方法不適用於 db_logger。你可以想象公開了 init_postgres() 和 init_sqlite() 公共函數 (同樣,這不利於可組合性),它們在其中創建數據庫對象。我嘗試過這麼做,但我在集成測試中遇到了一些噩夢般的問題,因爲我必須跨異步運行時邊界處理生命週期和異步任務 (Drop being sync 是…… 麻煩的)。

這個麻煩的結果是,我最終不得不放棄這個 “解決方案”,花了幾個令人撓頭的早晨來尋找替代方案——這是一件好事,因爲從設計的角度來看,這些“做所有事情的功能” 真的很糟糕。

好的解決方案:newtype

我不知道爲什麼我花了這麼長時間才得出使用新類型來隱藏 Trati 的結論。我想我太沉迷於讓上述特定的解決方案發揮作用了,這阻止了我看到另一種方法。回想起來,這聽起來微不足道,但事實就是這樣。

解決可見性問題的想法是引入一個新的具體類型,它將 Trait 包裝爲單個成員。然後,這個具體類型是 public 的,trait(以及它的所有依賴項) 可以保持私有。

對於我們的 db_logger 案例研究,我們所要做的就是引入一個新的類型,就像這樣:

#[derive(Clone)]
pub struct Connection(Arc<dyn Db + Send + Sync + 'static>);

注意 Connection 是如何包裝 Db Trait 的,但是現在,Trait 是 Struct 的實現細節,不必是 public 的。還要注意這是如何隱藏 Db 實例的複雜性的:Arc 和所有 trait 邊界現在都隱藏在 struct 中,不會污染公共 API。

有了這個,我們可以用一些工廠方法來更新我們的 Db 的具體實現:

/// Factory to connect to a PostgreSQL database.
pub fn connect_lazy(opts: ConnectionOptions) -> Connection {
    Connection(Arc::from(PostgresDb::connect_lazy(opts, None)))
}
/// Factory to connect to a SQLite database.
pub async fn connect(opts: ConnectionOptions) -> Result<Connection> {
    SqliteDb::connect(opts).await.map(|db| Connection(Arc::from(db)))
}

最後,我們的調用者代碼可以輕鬆地設置日誌記錄器:

let conn = if (use_real_db) {
    postgres::connect_lazy(...)
} else {
    sqlite::connect(...)
};
db_logger::init(conn);

瞧,通過使用 newtype 將 trait 隱藏到一個 struct 中,trait 及其所有內部依賴類型可以再次變爲私有。而且,正如預期的那樣,編譯器現在可以找出未使用的代碼。

本文翻譯自:

https://jmmv.dev/2022/04/rust-traits-and-dependency-injection.html

coding 到燈火闌珊 專注於技術分享,包括 Rust、Golang、分佈式架構、雲原生等。

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