前言
我想把使用 Rust 開發Websocket 服務的文章寫成一個系列,前面寫了一遍如何使用 Axum 搭建一個Websocket 服務的文章,我們可以和前端demo頁面進行全雙工的 Websocket 消息傳輸,而且可以啟用 HTTP2 的同時啟用 TLS。
這時候問題來了,Axum Web 應用和 Java Spring Web 應用一樣,在 Axum 中如何依賴其他對象或資源呢。Websocket 服務也是Web服務,面對不同的連客戶端接請求,每個連接請求有著不同的后端邏輯。這些后端的邏輯Service如果在每個Websocket 連接處理器中去分別創建Service對象或者數據庫對象就會大大拉低服務性能,也會占用更多的內存。能不能像 Java Web 生態中 Spring 框架的單例Bean那樣去做依賴注入呢?
我的開發項目:RTMate
GitHub地址:https://github.com/BruceZhang54110/RTMate
首先什么是依賴注入?
首先什么是依賴注入?
在軟件工程中,依賴注入(dependency injection)的意思為,給予調用方它所需要的事物。“依賴”是指可被方法調用的事物。依賴注入形式下,調用方不再直接指使用“依賴”,取而代之是“注入” 。“注入”是指將“依賴”傳遞給調用方的過程。在“注入”之后,調用方才會調用該“依賴”。傳遞依賴給調用方,而不是讓讓調用方直接獲得依賴,這個是該設計的根本需求。在編程語言角度下,“調用方”為對象和類,“依賴”為變量。在提供服務的角度下,“調用方”為客戶端,“依賴”為服務。
以Java 語言為例,當 class A 使用 class B 的某些功能時,則表示 class A 具有 class B 依賴。在使用其他 class 的方法之前,我們首先需要創建那個 class 的對象(即 class A 需要創建一個 class B 實例)。
因此,將創建對象的任務轉移給其他 class,并直接使用依賴項的過程,被稱為“依賴注入”。
為什么需要依賴注入?
如果要在 Axum Websocket 服務中要保證創建的對象是單例的,并且可以有一個全局上下文,有一個bean的資源池,有連接請求需要處理時可以直接拿到全局的單例對象去操作,需要如何實現呢?這就是今天我想在這里討論的內容。
依賴注入的好處
- 避免在每個請求中重復創建昂貴的對象(如數據庫連接池、外部服務客戶端),從而降低服務性能開銷和內存占用。
- 確保某些服務或資源在整個應用生命周期中只有一個實例,統一管理狀態和行為。
- 方便模擬(mock)或替換依賴,進行單元測試。
- 依賴關系清晰,修改和重構更容易。
- 在不修改代碼的情況下替換不同實現。
- 集中管理依賴的生命周期和實例化。
Axum 簡介
Rust 生態中一個流行的 Web 框架,以其簡潔、高性能和對異步處理的良好支持而聞名。充分利用 tower 和 tower-http 的中間件、服務和工具生態系統。
在Rust Axum框架中,使用 Router(路由) 來創建接口, 和 Java類比的話,那就是Java Spring Web項目中Controller類中定義的接口。創建 Router 時就要指定這個接口對應的 handler 方法。
- 使用 Route 定義接口
- 使用 handler 定義調用接口要執行的方法
- 使用 Extractors 解析傳入的請求參數
- 在 handlers 之間共享 state
如何在 Axum 不同 handlers 之間共享單例對象
如何讓每個請求訪問共同一份數據
要實現Web應用的依賴注入,首先要保證注入的資源是單例的,是共享的。如何實現,答案就是使用Axum的在handlers 之間共享狀態的辦法。在 Axum 文檔中寫了四種 在handlers 之間共享狀態的方法:
- Using the
State
extractor:使用 State 提取器 - Using request extensions:使用請求擴展
- Using closure captures:使用閉包去捕獲
- Using task-local variables:使用任務局部變量
我們今天使用最常見的使用 State 提取器(axum::extract::State
)。作用是將應用級別的共享狀態(通常是一個結構體,其中包含各種單例服務)通過 Router
的 with_state
方法綁定,然后在處理器中通過 State<T>
提取。這個state 就是一個全局共享的狀態,用來管理整個應用的全局狀態和單例服務。
官網的簡寫代碼示例如下,struct AppState
定義我們想要全局依賴的內容,使用 Arc
創建原子引用計數的 shared_state
,再傳到 with_state
中。這樣在每個handler 中,都能拿到一個 state
在這里就是 State<Arc<AppState>>
,這就達到了多個handler 共享 AppState
的目的。
use axum::{ extract::State,routing::get,Router,
};
use std::sync::Arc;struct AppState {// ...
}let shared_state = Arc::new(AppState { /* ... */ });let app = Router::new().route("/", get(handler)).with_state(shared_state);async fn handler(State(state): State<Arc<AppState>>,
) {// ...
}
使用Rust為我們帶來兩種依賴注入方式
代碼來自于 Axum Github 代碼倉庫的依賴注入示例。我們定義一個 User Repo 用來查詢用戶和創建用戶。提供可擴展的 Trait UserRepo
。通過依賴注入模式,我們可以根據需要注入不同的 UserRepo。
use std::{collections::HashMap,sync::{Arc, Mutex},
};use axum::{extract::{Path, State},http::StatusCode,routing::{get, post},Json, Router,
};
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use uuid::Uuid;#[tokio::main]
async fn main() {tracing_subscriber::registry().with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| format!("{}=debug", env!("CARGO_CRATE_NAME")).into()),).with(tracing_subscriber::fmt::layer()).init();let user_repo = InMemoryUserRepo::default();let using_dyn = Router::new().route("/users/{id}", get(get_user_dyn)).route("/users", post(create_user_dyn)).with_state(AppStateDyn {user_repo: Arc::new(user_repo.clone()),});let using_generic = Router::new().route("/users/{id}", get(get_user_generic::<InMemoryUserRepo>)).route("/users", post(create_user_generic::<InMemoryUserRepo>)).with_state(AppStateGeneric { user_repo });let app = Router::new().nest("/dyn", using_dyn).nest("/generic", using_generic);let listener = TcpListener::bind("127.0.0.1:3000").await.unwrap();tracing::debug!("listening on {}", listener.local_addr().unwrap());axum::serve(listener, app).await.unwrap();
}#[derive(Clone)]
struct AppStateDyn {user_repo: Arc<dyn UserRepo>,
}#[derive(Clone)]
struct AppStateGeneric<T> {user_repo: T,
}#[derive(Debug, Serialize, Clone)]
struct User {id: Uuid,name: String,
}#[derive(Deserialize)]
struct UserParams {name: String,
}async fn create_user_dyn(State(state): State<AppStateDyn>,Json(params): Json<UserParams>,
) -> Json<User> {let user = User {id: Uuid::new_v4(),name: params.name,};state.user_repo.save_user(&user);Json(user)
}async fn get_user_dyn(State(state): State<AppStateDyn>,Path(id): Path<Uuid>,
) -> Result<Json<User>, StatusCode> {match state.user_repo.get_user(id) {Some(user) => Ok(Json(user)),None => Err(StatusCode::NOT_FOUND),}
}async fn create_user_generic<T>(State(state): State<AppStateGeneric<T>>,Json(params): Json<UserParams>,
) -> Json<User>
whereT: UserRepo,
{let user = User {id: Uuid::new_v4(),name: params.name,};state.user_repo.save_user(&user);Json(user)
}async fn get_user_generic<T>(State(state): State<AppStateGeneric<T>>,Path(id): Path<Uuid>,
) -> Result<Json<User>, StatusCode>
whereT: UserRepo,
{match state.user_repo.get_user(id) {Some(user) => Ok(Json(user)),None => Err(StatusCode::NOT_FOUND),}
}trait UserRepo: Send + Sync {fn get_user(&self, id: Uuid) -> Option<User>;fn save_user(&self, user: &User);
}#[derive(Debug, Clone, Default)]
struct InMemoryUserRepo {map: Arc<Mutex<HashMap<Uuid, User>>>,
}impl UserRepo for InMemoryUserRepo {fn get_user(&self, id: Uuid) -> Option<User> {self.map.lock().unwrap().get(&id).cloned()}fn save_user(&self, user: &User) {self.map.lock().unwrap().insert(user.id, user.clone());}
}
1使用 trait + 泛型
在編譯時,Rust 編譯器會為每個使用了該泛型函數的具體類型生成一個獨立的、優化的版本。這被稱為靜態分發,因為方法調用在編譯時就已經確定并硬編碼。
- 定義結構體
AppStateGeneric<T>
,它的字段user_repo
的類型是T
。 create_user_generic<T>
和get_user_generic<T>
函數,它們都是泛型函數,通過where T: UserRepo
約束T
必須實現UserRepo
trait。- 在
main
函數中,它們被實例化為create_user_generic::<InMemoryUserRepo>
和get_user_generic::<InMemoryUserRepo>
,這意味著編譯器會專門為InMemoryUserRepo
生成一個版本的函數。
優點: 零成本抽象。由于方法調用在編譯時就已經確定,運行時沒有額外的開銷,性能和直接調用具體類型的方法一樣快。
缺點: 靈活性較差。所有使用該泛型函數的類型必須在編譯時確定。這可能導致生成的代碼量增加,因為編譯器會為每個具體類型生成一個獨立的函數副本。
2. 使用 trait + 動態分發
Arc + dyn 來實現動態分發,使用 dyn Trait
(如 Arc<dyn UserRepo>
)來存儲一個指向實現了 UserRepo
trait 的任何具體類型的 trait 對象。在運行時,Rust 會通過虛函數表(vtable)來查找并調用正確的方法。這被稱為動態分發,因為方法調用是在運行時確定的。
-
AppStateDyn
結構體:它的user_repo
字段的類型是Arc<dyn UserRepo>
。這意味著它不關心具體是哪種UserRepo
實現,只要它實現了UserRepo
trait 即可。 -
create_user_dyn
和get_user_dyn
函數:它們接受AppStateDyn
作為狀態。方法調用如state.user_repo.save_user(&user)
發生時,會動態地調用InMemoryUserRepo
的save_user
方法。 -
優點: 靈活性強。你可以在運行時切換不同的
UserRepo
實現,而不需要改變函數簽名。例如,你可以很容易地將InMemoryUserRepo
換成PostgresUserRepo
或RedisUserRepo
,而這些 handler 函數(create_user_dyn
等)無需修改。缺點: 有輕微的性能開銷。因為需要在運行時通過 虛函數表(vtable )查找方法,這比直接調用具體類型的方法要慢一些。
為什么需要 Arc ?
在Rust 中Arc
是 Atomic Rc
的縮寫,顧名思義:原子化的 Rc<T>
智能指針。Rust 所有權機制要求一個值只能有一個所有者,但是當遇到需要多個所有者時,Rust 巧妙的使用引用計數的方式,允許一個數據資源在同一時刻擁有多個所有者。這種實現機制就是 Rc
和 Arc
,前者適用于單線程,后者是原子化實現的引用計數,因此是線程安全的,可以用于多線程中共享數據。
總結
結合Rust 強大的類型機制和內存所有權機制,讓我們同樣可以在 Rust Axum 中使用依賴注入的模式,實現高性能的數據共享。靜態分發(泛型)和動態分發(dyn
)在實踐中,這兩種模式并非非此即彼。你可以根據具體需求進行選擇:如果你的服務依賴非常穩定,且對性能要求嚴苛,請選擇泛型;如果你的應用需要更強的可擴展性和靈活性(比如在不同環境中切換數據庫連接),那么動態分發是更好的選擇。