路由器

什麼是路由

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 處理的。

pathmethod 是兩個最為常用的過濾器。path 用於匹配路徑資訊;method 用於匹配請求的 Method,比如:GET、POST、PATCH 等。

我們可以使用 andor 連接路由的過濾器:

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);

這裡的 getpatchdelete 都是 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 結構體方法概覽

類別方法描述
創建/訪問new()創建新路由器
routers()/routers_mut()獲取子路由器引用/可變引用
hoops()/hoops_mut()獲取中介軟體引用/可變引用
filters()/filters_mut()獲取過濾器引用/可變引用
路由組織unshift()在開頭插入子路由器
insert()在指定位置插入子路由器
push()添加子路由器
append()添加多個子路由器
then()自定義路由器鏈配置
中介軟體with_hoop()/hoop()創建/添加中介軟體
with_hoop_when()/hoop_when()創建/添加條件中介軟體
路徑過濾with_path()/path()創建/添加路徑過濾器
with_filter()/filter()創建/添加過濾器
with_filter_fn()/filter_fn()創建/添加函數過濾器
網絡過濾scheme()添加協議過濾器
host()/with_host()添加/創建主機過濾器
`port()