路由器
什麼是路由
Router 定義了一個 HTTP 請求會被哪些中介軟體和 Handler 處理。這是 Salvo 中最基礎也是最核心的功能。
Router 內部實際上是由一系列過濾器(Filter)組成。當請求到來時,路由器會按添加的順序,由上往下依次測試自身及其子孫項目是否能夠匹配請求。如果匹配成功,則依次執行路由器及其子孫路由器形成的整個鏈上的中介軟體。如果處理過程中,Response 的狀態被設置為錯誤(4XX、5XX)或跳轉(3XX),則後續中介軟體和 Handler 都會被跳過。你也可以手動調用 ctrl.skip_rest() 來跳過後續的中介軟體和 Handler。
在匹配的過程中,存在一個 URL 路徑資訊,可以將其視為一個在匹配過程中需要被 Filter 完全消耗的對象。如果在某個 Router 中所有的 Filter 都匹配成功,並且這個 URL 路徑資訊已經被完全消耗掉,則會被認為是「匹配成功」。
例如:
Router::with_path("articles").get(list_articles).post(create_article);
實際上等同於:
Router::new()
// PathFilter 可以過濾請求路徑,只有請求路徑裡包含 articles 片段時才會匹配成功,
// 否則匹配失敗。例如:/articles/123 是匹配成功的,而 /articles_list/123
// 雖然裡面包含了 articles,但是因為後面還有 _list,是匹配不成功的。
.filter(PathFilter::new("articles"))
// 在 root 匹配成功的情況下,如果請求的 method 是 get,則內部的子路由可以匹配成功,
// 並且由 list_articles 處理請求。
.push(Router::new().filter(filters::get()).handle(list_articles))
// 在 root 匹配成功的情況下,如果請求的 method 是 post,則內部的子路由可以匹配成功,
// 並且由 create_article 處理請求。
.push(Router::new().filter(filters::post()).handle(create_article));
如果訪問 GET /articles/ 會認為匹配成功,並執行 list_articles。但是如果訪問的是 GET /articles/123,則匹配路由失敗並返回 404 錯誤。這是因為 Router::with_path("articles") 僅僅只消耗掉了 URL 路徑資訊中的 /articles,還有 /123 部分未被消耗掉,因此認為匹配失敗。要想能夠匹配成功,路由可以改成:
Router::with_path("articles/{**}").get(list_articles).post(create_article);
這裡的 {**} 會匹配任何多餘的路徑,所以它能夠匹配 GET /articles/123 並執行 list_articles。
扁平式定義
我們可以用扁平式的風格定義路由:
Router::with_path("writers").get(list_writers).post(create_writer);
Router::with_path("writers/{id}").get(show_writer).patch(edit_writer).delete(delete_writer);
Router::with_path("writers/{id}/articles").get(list_writer_articles);
樹狀式定義
我們也可以把路由定義成樹狀,這也是推薦的定義方式:
Router::with_path("writers")
.get(list_writers)
.post(create_writer)
.push(
Router::with_path("{id}")
.get(show_writer)
.patch(edit_writer)
.delete(delete_writer)
.push(Router::with_path("articles").get(list_writer_articles)),
);
這種形式的定義對於複雜項目,可以讓 Router 的定義變得層次清晰簡單。
在 Router 中有許多方法調用後會返回自身(Self),以便於鏈式書寫程式碼。有時候,你需要根據某些條件決定如何路由,路由系統也提供了 then 函數,也很容易使用:
Router::new()
.push(
Router::with_path("articles")
.get(list_articles)
.push(Router::with_path("{id}").get(show_article))
.then(|router|{
if admin_mode() {
router.post(create_article).push(
Router::with_path("{id}").patch(update_article).delete(delete_writer)
)
} else {
router
}
}),
);
該範例代表僅僅當伺服器在 admin_mode 時,才會添加建立文章、編輯刪除文章等路由。
從路由中獲取參數
在上面的程式碼中,{id} 定義了一個參數。我們可以透過 Request 實例獲取到它的值:
#[handler]
async fn show_writer(req: &mut Request) {
let id = req.param::<i64>("id").unwrap();
}
{id} 匹配了路徑中的一個片段。正常情況下文章的 id 只是一個數字,此時我們可以使用正規表示法限制 id 的匹配規則,r"{id|\d+}"。
對於這種數字類型,還有一種更簡單的方法是使用 <id:num>,具體寫法為:
{id:num},匹配任意多個數字字元;
{id:num[10]},只匹配固定特定數量的數字字元,這裡的 10 代表匹配僅僅匹配 10 個數字字元;
{id:num(..10)},代表匹配 1 到 9 個數字字元;
{id:num(3..10)},代表匹配 3 到 9 個數字字元;
{id:num(..=10)},代表匹配 1 到 10 個數字字元;
{id:num(3..=10)},代表匹配 3 到 10 個數字字元;
{id:num(10..)},代表匹配至少 10 個數字字元。
還可以透過 {**}、{*+} 或者 {*?} 匹配所有剩餘的路徑片段。為了程式碼易讀性強些,也可以添加適合的名字,讓路徑語意更清晰,例如:{**file_path}。
{**}:代表萬用字元匹配的部分可以是空字串,例如路徑是 /files/{**rest_path},會匹配 /files、/files/abc.txt、/files/dir/abc.txt;
{*+}:代表萬用字元匹配的部分必須存在,不能匹配到空字串,例如路徑是 /files/{*+rest_path},不會匹配 /files 但是會匹配 /files/abc.txt、/files/dir/abc.txt;
{*?}:代表萬用字元匹配的部分可以是空字串,但是只能包含一個路徑片段,例如路徑是 /files/{*?rest_path},不會匹配 /files/dir/abc.txt 但是會匹配 /files、/files/abc.txt;
允許組合使用多個表示式匹配同一個路徑片段,例如 /articles/article_{id:num}/、/images/{name}.{ext}。
添加中介軟體
可以透過路由上的 hoop 函數添加中介軟體:
Router::new()
.hoop(check_authed)
.path("writers")
.get(list_writers)
.post(create_writer)
.push(
Router::with_path("{id}")
.get(show_writer)
.patch(edit_writer)
.delete(delete_writer)
.push(Router::with_path("articles").get(list_writer_articles)),
);
在這個例子中,根路由使用 check_authed 檢查目前使用者是否已經登入。所有子孫路由都會受此中介軟體影響。
如果使用者只是瀏覽 writer 的資訊和文章,我們更希望他們無需登入即可瀏覽。我們可以把路由定義成這個樣子:
Router::new()
.push(
Router::new()
.hoop(check_authed)
.path("writers")
.post(create_writer)
.push(Router::with_path("{id}").patch(edit_writer).delete(delete_writer)),
)
.push(
Router::with_path("writers").get(list_writers).push(
Router::with_path("{id}")
.get(show_writer)
.push(Router::with_path("articles").get(list_writer_articles)),
),
);
儘管有兩個路由都有相同的路徑定義 path("articles"),它們依然可以被添加到同一個父路由裡。
過濾器
Router 內部都是透過過濾器來確定路由是否匹配。過濾器支援使用 or 或者 and 做基本邏輯運算。一個路由可以包含多個過濾器,當所有的過濾器都匹配成功時,路由匹配成功。
網站的路徑資訊是一個樹狀結構,這個樹狀結構並不等同於組織路由的樹狀結構。網站的一個路徑可能對應多個路由節點。例如,在 articles/ 這個路徑下的某些內容需要登入才可以查看,而某些又不需要登入。我們可以把需要登入查看的子路徑組織到一個包含登入驗證的中介軟體的路由下面。不需要登入驗證的組織到另一個沒有登入驗證的路由下面:
Router::new()
.push(
Router::with_path("articles")
.get(list_articles)
.push(Router::new().path("{id}").get(show_article)),
)
.push(
Router::with_path("articles")
.hoop(auth_check)
.post(list_articles)
.push(Router::new().path("{id}").patch(edit_article).delete(delete_article)),
);
路由是使用過濾器過濾請求並且發送給對應的中介軟體和 Handler 處理的。
path 和 method 是兩個最為常用的過濾器。path 用於匹配路徑資訊;method 用於匹配請求的 Method,例如:GET、POST、PATCH 等。
我們可以使用 and、or 連接路由的過濾器:
Router::with_filter(filters::path("hello").and(filters::get()));
路徑過濾器
基於請求路徑的過濾器是使用最頻繁的。路徑過濾器中可以定義參數,例如:
Router::with_path("articles/{id}").get(show_article);
Router::with_path("files/{**rest_path}").get(serve_file)
在 Handler 中,可以透過 Request 物件的 get_param 函數獲取:
#[handler]
pub async fn show_article(req: &mut Request) {
let article_id = req.param::<i64>("id");
}
#[handler]
pub async fn serve_file(req: &mut Request) {
let rest_path = req.param::<i64>("rest_path");
}
Method 過濾器
根據 HTTP 請求的 Method 過濾請求,例如:
Router::new().get(show_article).patch(update_article).delete(delete_article);
這裡的 get、patch、delete 都是 Method 過濾器。實際等價於:
use salvo::routing::filter;
let mut root_router = Router::new();
let show_router = Router::with_filter(filters::get()).handle(show_article);
let update_router = Router::with_filter(filters::patch()).handle(update_article);
let delete_router = Router::with_filter(filters::get()).handle(delete_article);
Router::new().push(show_router).push(update_router).push(delete_router);
自訂 Wisp
對於某些經常出現的匹配表示式,我們可以透過 PathFilter::register_wisp_regex 或者 PathFilter::register_wisp_builder 命名一個簡短的名稱。舉例來說,GUID 格式在路徑中經常出現,正常寫法是每次需要匹配時都這樣:
Router::with_path("/articles/<id:/[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}/>");
Router::with_path("/users/<id:/[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}/>");
每次都要寫這複雜的正規表示式會很容易出錯,程式碼也不美觀,可以這麼做:
use salvo::routing::filter::PathFilter;
#[tokio::main]
async fn main() {
let guid = regex::Regex::new("[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}").unwrap();
PathFilter::register_wisp_regex("guid", guid);
Router::new()
.push(Router::with_path("/articles/{id:guid}").get(show_article))
.push(Router::with_path("/users/{id:guid}").get(show_user));
}
僅僅只需要註冊一次,以後就可以直接透過 {id:guid} 這樣的簡單寫法匹配 GUID,簡化程式碼的書寫。
以前學習的是Controller類的web框架 如何理解Router?
路由設計的 Web 框架(如 Salvo)與傳統 MVC 或 Controller 設計框架的主要區別在於:
-
靈活性:路由設計允許更靈活地定義請求處理流程,可以精確控制每個路徑的處理邏輯。例如,在 Salvo 中你可以直接定義特定路徑的處理函數:
Router::with_path("articles").get(list_articles).post(create_article);
而在 Controller 設計中,通常需要先定義一個控制器類別,然後在類別中定義多個方法來處理不同的請求:
@Controller
public class ArticleController {
@GetMapping("/articles")
public List<Article> listArticles() { /* ... */ }
@PostMapping("/articles")
public Article createArticle(@RequestBody Article article) { /* ... */ }
}
-
中介軟體整合:路由框架通常提供更簡潔的中介軟體整合方式,可以針對特定路由應用中介軟體。Salvo 的中介軟體可以精確應用到特定路由:
Router::new()
.push(
Router::with_path("admin/articles")
.hoop(admin_auth_middleware) // 僅對管理員路由應用認證中介軟體
.get(list_all_articles)
.post(create_article),
)
.push(
Router::with_path("articles") // 公開路由無需認證
.get(list_public_articles),
);
-
程式碼組織:路由設計傾向於基於功能或 API 端點組織程式碼,而非按 MVC 的模型-視圖-控制器分層。
路由設計鼓勵按照 API 端點功能組織程式碼:
// user_routes.rs - 使用者相關路由和處理邏輯
pub fn user_routes() -> Router {
Router::with_path("users")
.get(list_users)
.post(create_user)
.push(Router::with_path("{id}").get(get_user).delete(delete_user))
}
// article_routes.rs - 文章相關路由和處理邏輯
pub fn article_routes() -> Router {
Router::with_path("articles")
.get(list_articles)
.post(create_article)
}
// 在主應用中組合路由
let router = Router::new()
.push(user_routes())
.push(article_routes());
-
輕量級:通常路由設計更輕量,減少了框架強制的概念和約束。你可以只引入需要的元件,不必遵循嚴格的框架結構。
路由設計讓 API 開發更加直觀,尤其適合建構現代的微服務和 RESTful API。在 Salvo 這樣的框架中,路由是核心概念,它直接反映了 API 的結構和行為,使程式碼更易於理解和維護。相比之下,傳統 Controller 設計往往需要更多的配置和約定來實現相同的功能。
Router 結構體方法概覽