一個Web框架最基本的功能除了提供HTTP伺服器之外,還需要進行HTTP請求(request)的路由(routing)。Rocket在這方面套用了Rust程式語言提供的程序式巨集功能,使我們可以很簡單地使用getputpostdeleteheadpatchoptions等屬性來快速做出屬於該HTTP請求方法的路由處理程序(handler)。另外,我們也可以在撰寫屬性的同時,去對請求中的網址路徑(path)、網址查詢字串(query)、以及HTTP主體(body)選擇要使用的處理程序,以及進行資料型別的匹配。此外,Rocket也會負責檢查開發者撰寫的這些規則有沒有發生衝突,確保程式在編譯完成並啟動Rocket後,可以正確無誤地提供路由處理的服務。



上一章我們所撰寫的Hello World程式,當時使用的路由處理程序只有以下這個:

#[get("/")]
fn index() -> &'static str {
    "Hello, world!"
}

Rocket的路由處理程序的最基本架構如下:

#[HTTP請求方法("路徑")]
fn handler() { 處理請求的程式區塊 }

注意,上面的架構,handler不會在回應(respond)請求時,於HTTP主體中傳入任何資料。如果想要回傳資料,就得像剛才提到的Hello World程式的寫法一樣,替路由處理程序的函數加上回傳型別。如下:

#[HTTP請求方法("路徑")]
fn handler() -> 要回應的資料型別 { 處理請求的程式區塊 }

HTTP請求方法

HTTP請求方法的部份可以使用getputpostdeleteheadpatchoptions等屬性,分別對應著HTTP不同的請求方法。

Rocket也會自動幫助開發者進行常見的擴充。像是使用get屬性的話,Rocket也會自動實作head的功能。再來由於網頁表單只能夠送出GET或是POST請求,所以若是遇到內容類型(Content-Type)為application/x-www-form-urlencoded的POST請求時,Rocket就會去看這個請求的主體中的第一個欄位是否是_method,並且去檢查這個_method欄位中的值是否為PUT或是DELETE(大小寫可忽略),如果是的話,Rocket就會改以使用put或是delete屬性的路由處理程序來處理這個POST請求。

路徑

在屬性中所撰寫的路徑,除了可以寫固定(靜態)路徑之外,還可以使用變數來匹配路徑的任意組成部份(component),接著就可以在路由處理程序的函數參數中,替這個變數指定一個型別,使Rocket可以自動對其進行型別轉換與匹配。

例如:

#[get("/<name>")]
fn handler(name: String) -> String {
    name
}

以上這個路由處理程序,可以讓Rocket在處理路徑為/xxx的GET請求時,直接以路徑中的xxx來回應。

路徑中的變數,除了可以使用String型別之外,Rust內建的其它基本資料型別(如i8u16f32bool等)也都可以使用。這些型別稱為「參數守衛」(Parameter Guard),用來匹配路徑中的變數。如果想要將自己實作的型別當作參數守衛來使用,就必須實作rocket這個crate所提供的request::FromParam特性。

如果想要用一個變數來代替多個路徑的組成部份,則路徑在屬性中的寫法就會需要在變數名稱後面加上兩個半形句點..,且用來進行型別轉換與匹配的型別,就不能夠使用參數守衛,而是要使用「區段守衛」(Segments guard)。此外,區段守衛對應的變數,必須要寫在路徑的最尾端。

Rust標準函式庫提供的PathBuf可以直接作為區段守衛來使用,例如:

use std::path::PathBuf;

#[get("/<name..>")]
fn handler(name: PathBuf) -> String {
    name.to_str().unwrap().to_string()
}

如果想要將自己實作的型別當作區段守衛來使用,就必須實作rocket這個crate所提供的request::FromSegments特性。不過通常我們不會自己實作區段守衛啦!PathBuf就有很好的效能和安全性了。

路由的順序與轉遞(Forwarding)

同一種路徑可以撰寫多個不同的路由處理程序。

例如:

#[get("/<name>")]
fn handler_1(name: String) -> String {
    name
}

#[get("/<id>")]
fn handler_2(id: i32) -> String {
    format!("id = {id}")
}

當我們把以上兩個路由處理程序都註冊給Rocket後,在使用Rocket結構實體的launch方法來啟動Rocket時,Rocket會檢查出路由設定發生衝突了,因為/<name>/<id>是一模一樣的匹配樣本(pattern)。

此時我們可以替這兩個路由處理程序手動設定優先順序,避免發生衝突。

添加優先順序的方式為,在HTTP請求方法的屬性參數中,於路徑後面,再多加一個rank參數,來指定一個大於等於0的整數值作為優先順序,寫法如下:

#[HTTP請求方法("路徑", rank = 優先順序的值)]
fn handler() -> 要回應的資料型別 { 處理請求的程式區塊 }

優先順序的值,數值愈大,表示優先權愈低。再回到剛才的兩個路由處理程序的例子,由於i32可表示的資料範圍比String小,因此handler_2處理程序的優先權應該要設得比handler_1處理程序的優先權還要高。

於是,我們將程式改寫如下:

#[get("/<name>", rank = 2)]
fn handler_1(name: String) -> String {
    name
}

#[get("/<id>", rank = 1)]
fn handler_2(id: i32) -> String {
    format!("id = {id}")
}

當有/xxx的GET請求進來時,會先以handler_2處理程序來進行型別轉換與匹配,如果因xxx無法轉換為i32型別而匹配失敗了,就會將該請求轉遞給下一個,也就是handler_1處理程序來進行型別轉換與匹配。

在我們不明確使用rank來指定優先順序的值時,Rocket會根據路徑的匹配樣本類型,來決定該路由處理程序的優先順序的預設值。這部份會在這篇文章之後再詳細說明。

網址查詢字串

網址查詢字串,也就是網址中問號?後面的部份也可以被寫進路由處理程序的路徑匹配樣本中。例如將Hello World程式使用的路由處理程序改成以下這樣:

#[get("/?hello")]
fn index() -> &'static str {
    "Hello, world!"
}

當Rocket接收到的GET請求的網址是/時,且網址查詢字串中一定要有hello,才會在網頁上印出Hello, world!。如果GET請求中的網址查詢字串是?hello=xxx,匹配將會失敗。如果網址查詢字串還有其它的欄位,例如?hello&a&b=1,是可以匹配成功的!

如果有多個網址查詢字串的欄位要進行匹配,可以直接寫在路徑的匹配樣本中,用&來分隔。例如:

#[get("/?hello&a&b=1")]
fn index() -> &'static str {
    "Hello, world!"
}

網址查詢字串中的欄位順序可以忽略。如以上的路由處理程序,無論遇到?hello&a&b=1還是?a&hello&b=1,都可以成功匹配!

另外,網址查詢字串中的欄位也可以利用變數來進行型別的匹配與轉換。例如:

#[get("/?hello&<id>")]
fn index(id: i32) -> String {
    format!("Hello, world! id = {id}")
}

以上這個路由處理程序,可以讓Rocket在處理路徑為/的GET請求時,去檢查其網址查詢字串中有無helloid欄位,且id欄位的值必須要可以轉成i32型別。

網址查詢字串中的變數,除了可以使用i32型別之外,String和Rust內建的其它基本資料型別(如i8u16f32bool等)也都可以使用。如果想要將自己實作的型別也拿來匹配網址查詢字串,就必須實作rocket這個crate所提供的form::FromFormField特性。

另外,網址查詢字串也有類似路徑的區段守衛的機制,稱為「查詢守衛」(Query Guard)。我們可以使用一個變數來代替多個網址查詢字串的欄位,在屬性中的寫法就會需要在變數名稱後面加上兩個半形句點..,且用來進行型別轉換與匹配的型別,就不能夠單純使用有實作form::FromFormField特性的型別,而是要使用實作form::FromForm特性的型別。此外,查詢守衛對應的變數,必須要寫在網址查詢字串的最尾端。

我們不太會直接使用impl關鍵字來替我們的型別實作form::FromForm特性,而是會直接替我們的型別加上FromForm這一個derive屬性的參數。這個程序式巨集會直接使用結構體的欄位名稱和型別來匹配網址查詢字串中的欄位和其對應的資料,欄位的型別必須要實作form::FromFormField特性。

如以下程式:

#[derive(FromForm)]
struct User {
    id: i32,
    name: String,
}

#[get("/?hello&<user..>")]
fn index(user: User) -> String {
    format!("Hello, world! id = {}, name = {}", user.id, user.name)
}

以上這個路由處理程序,可以讓Rocket在處理路徑為/的GET請求時,去檢查其網址查詢字串中有無helloidname欄位,且id欄位的值必須要可以轉成i32型別,name欄位的值必須要可以轉成String型別。此外,除了剛才提到的欄位,如果請求中的網址查詢字串還有其它的欄位,這個路由處理程序也是可以成功匹配的。

如果要避免匹配請求中的網址查詢字串還有其它的欄位,要用form::Strict<T>型別來包裹我們用程序式巨集來實作form::FromForm特性的型別T。程式如下:

use rocket::form::Strict;

#[derive(FromForm)]
struct User {
    id: i32,
    name: String,
}

#[get("/?hello&<user..>")]
fn index(user: Strict<User>) -> String {
    format!("Hello, world! id = {}, name = {}", user.id, user.name)
}

有時候我們可能會希望Rocket不要直接使用結構體中的欄位名稱來對應網址查詢字串中的欄位名稱,Rocket允許開發者針對結構體中的某個欄位,透過程序式巨集來手動指定要對應到哪個網址查詢字串中的欄位。例如將以上例子的User結構體改為:

#[derive(FromForm)]
struct User {
    #[field(name = "user_id")]
    id: i32,
    name: String,
}

以上程式,我們將User結構體的id欄位加上field屬性,並用name參數來指定要其對應到網址查詢字串中的user_id欄位。

忽略組成部份

我們也可以用<_>將網址中不需要交給處理程序處理的組成部份給忽略掉。如以下程式:

#[get("/foo/<_>/bar")]
fn foo_bar() -> &'static str {
    "Foo _____ bar!"
}

#[get("/<_..>")]
fn everything() -> &'static str {
    "Hey, you're here."
}

路由處理程序的優先順序的預設值

下表是Rocket根據每個路由處理程序的路徑匹配樣本所預設的優先順序值:

路徑類型 查詢類型 Rank值 路徑匹配樣本範例
完全靜態 完全靜態 -12 /a/hello?world=true
完全靜態 動態 -11 /a/hello?world=true&<name>
完全靜態 完全動態 -10 /a/hello?<world..>
完全靜態 -9 /a/hello
動態 完全靜態 -8 /a/<hello>?world=true
動態 動態 -7 /a/<hello>?world=true&<name>
動態 完全動態 -6 /a/<hello>?<world..>
動態 -5 /a/<hello>
完全動態 完全靜態 -4 /<hello>?world=true
完全動態 動態 -3 /<hello>?world=true&<name>
完全動態 完全動態 -2 /<hello>?<world..>
完全動態 -1 /<hello>

HTTP請求中的其它資料來源

HTTP請求中的資料可透過請求的網址路徑(params)、網址的查詢字串(query)、HTTP的標頭(headers)、HTTP的Cookie和HTTP的主體(body)中來取得。在上面的文章中,我們已經知道如何取得來自路徑和網址查詢字串的資料,在這個小節中,會再繼續探討剩下的部份。

標頭

使用Rocket取得HTTP標頭的資料屬於相對比較低階的操作,稍微麻煩了一點。我們會需要實作rocket這個crate所提供的request::FromRequest特性,使我們的型別能夠當作「請求守衛」(Request Guard)來使用。

一個最基本的request::FromRequest特性的實作方式如下:

use rocket::request::{FromRequest, Outcome, Request};

struct MyStruct;

#[rocket::async_trait]
impl<'r> FromRequest<'r> for MyStruct {
    type Error = ();

    async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
        Outcome::Success(MyStruct)
    }
}

我們可以在from_request這個關聯函數中,利用request參數,來取得這個HTTP請求中的所有資料。也就是說,如果想要追求程式效能的話,先前提到的什麼參數守衛、區段守衛、查詢守衛都可以不要使用,全都改用一個請求守衛來完成。不過在這篇文章中只會介紹如何用request::Request結構實體來取得HTTP標頭的資料。

request::Request結構實體提供了一個headers方法,會回傳一個以Map作為資料結構的結構實體的不可變參考(&http::HeaderMap<'r>),這個結構體的用法就跟標準函式庫的HashMap差不多。Map結構所儲存的值一律使用型別為字串切片&str的鍵值來查詢(大小寫可忽略),查詢到的值會是一個迭代器(因為HTTP標頭的欄位可能會有重複的現象)。

舉個例子來說明,如果我們要取得請求中的Authorization標頭欄位,並將欄位的值交給請求守衛的結構實體來儲存,以便在路由處理程序中使用。程式實作如下:

use rocket::request::{FromRequest, Outcome, Request};

struct MyGuard<'a> {
    authorization: Option<&'a str>,
}

#[rocket::async_trait]
impl<'r> FromRequest<'r> for MyGuard<'r> {
    type Error = ();

    async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
        let authorization = request.headers().get("authorization").next();

        Outcome::Success(MyGuard {
            authorization,
        })
    }
}

#[get("/auth")]
fn handler(guard: MyGuard) -> &str {
    guard.authorization.unwrap_or("")
}

如果想要強制讓接收的HTTP請求中一定要有Authorization標頭欄位的話,可以將以上程式修改如下:

use rocket::{
    http::Status,
    request::{FromRequest, Outcome, Request},
};

struct MyGuard<'a> {
    authorization: &'a str,
}

#[rocket::async_trait]
impl<'r> FromRequest<'r> for MyGuard<'r> {
    type Error = ();

    async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
        let authorization = request.headers().get("authorization").next();

        match authorization {
            Some(authorization) => Outcome::Success(MyGuard {
                authorization,
            }),
            None => Outcome::Forward(Status::Unauthorized),
        }
    }
}

#[get("/auth")]
fn handler(guard: MyGuard) -> &str {
    guard.authorization
}

請求守衛如果是回傳OutcomeForward變體,它會把請求交給下一個路由處理程序繼續匹配,如果不能匹配成功,其所帶的http::Status結構實體會被用來觸發Rocket的錯誤捕獲者(Error Catcher)機制。這個部份會在之後的章節進行更詳細的說明。

Cookie

Rocket支援Cookie和基於Cookie的會話(Cookie-based Session)。我們可以直接使用rocket這個crate所提供的http::CookieJar結構體,當作請求守衛來使用。這個http::CookieJar結構體可以對Cookie進行新增(修改)、查詢、刪除等操作。

例如以下程式,可以取得Cookie中的message這個欄位的資料。

use rocket::http::CookieJar;

#[get("/cookie/message")]
fn handler(cookies: &CookieJar<'_>) -> String {
    cookies.get("message").map(|c| c.value()).unwrap_or("").to_string()
}

例如以下程式,可以設定Cookie中的message這個欄位的資料。

use rocket::http::{Cookie, CookieJar};

#[put("/cookie/message")]
fn handler(cookies: &CookieJar<'_>) {
    cookies.add(Cookie::new("message", "Hello!"));
}

例如以下程式,可以移除Cookie中的message這個欄位。

use rocket::http::{Cookie, CookieJar};

#[delete("/cookie/message")]
fn handler(cookies: &CookieJar<'_>) {
    cookies.remove(Cookie::named("message"));
}

如果要使用加密的Cookie,也就是基於Cookie的會話,首先要啟用rocketsecrets特色,然後把剛才介紹的http::CookieJar結構實體的getaddremove方法都加上_private這個後綴就行了。啟用secrets特色的Cargo.toml檔案寫法如下:

[dependencies]
rocket = { version = "0.5.0", features = ["secrets"] }

另外,加密所使用的密鑰,在預設情況下是由Rocket自動產生,不過這部份其實是可以手動設定的(在正式部署的環境中,如果有用到基於Cookie的會話,一定要手動替每個Rocket應用程式的執行個體設定相同的密鑰),會在之後的章節做介紹。

還有一點要注意的是,在request::FromRequest特性的from_request這個關聯函數中,也是可以透過request::Request結構實體來操作http::CookieJar,只要使用其提供的cookies方法即可。

例如:

use rocket::request::{FromRequest, Outcome, Request};

struct Custom {
    name: String,
}

#[rocket::async_trait]
impl<'r> FromRequest<'r> for Custom {
    type Error = ();

    async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
        let cookies = request.cookies();

        let name = cookies.get("name").map(|c| c.value()).unwrap_or("").to_string();

        Outcome::Success(Custom { name })
    }
}

#[get("/cookie/message")]
fn handler(custom: Custom) -> String {
    custom.name.to_string()
}

主體

HTTP請求中的HTTP主體大致可分為以下三種類型:

  • application/x-www-form-urlencoded:網頁表單預設會用這種。
  • multipart/form-data:網頁表單如果需要上傳檔案資料的話,就會用這種。
  • 其它

Rocket框架本身只能支援application/x-www-form-urlencoded類型的主體,如果要處理不是application/x-www-form-urlencoded類型的主體,只能由開發者自行去處理主體中的原始資料(raw data),或是使用第三方的套件來處理,這個部份由於日後的變動性很大,在本系列文章中就不提了,有興趣者可以參考本站Rust分類中相關的單篇文章。

資料類型為application/x-www-form-urlencoded的主體,可以直接將有實作form::FromForm特性的型別包裹成form::Form<T>或是form::LenientForm<T>來匹配。因為form::Form<T>form::LenientForm<T>有實作data::FromData特性,可以當作「資料守衛」(Data Guard)來用。form::Form<T>限制請求主體中的資料欄位必須要剛好與T的欄位一致;form::LenientForm<T>則允許請求主體擁有T所沒有的資料欄位。

資料守衛用的法與參數守衛、區段守衛和查詢守衛不同,一個路由處理程序最多只能夠使用一個資料守衛,且加入資料守衛的方式為,在HTTP請求方法的屬性參數中,於路徑後面,再多加一個data參數,來指定資料守衛的變數名稱,寫法如下:

#[HTTP請求方法("路徑", data = "<資料守衛的變數名稱>")]
fn handler(資料守衛的變數名稱: 資料守衛的型別) -> 要回應的資料型別 { 處理請求的程式區塊 }

如以下程式:

use rocket::form::Form;

#[derive(FromForm)]
struct User {
    id:   i32,
    name: String,
}

#[post("/?hello", data = "<user>")]
fn index(user: Form<User>) -> String {
    format!("Hello, world! id = {}, name = {}", user.id, user.name)
}

以上這個路由處理程序,可以讓Rocket在處理路徑為/的POST請求時,去檢查其網址查詢字串中有無hello欄位,且HTTP主體中的資料類型必須為application/x-www-form-urlencoded,主體的資料中必須只能有idname欄位。此外,id欄位的值必須要可以轉成i32型別,name欄位的值必須要可以轉成String型別。

可省略的資料欄位

上面介紹的參數守衛、區段守衛和查詢守衛、資料守衛等實作了form::FromForm特性或form::FromFormField特性的型別都可以使用Option來進行包裹,使資料欄位變成可選的(optional),後者則可以允許當該資料欄位存在但資料匹配或是轉換失敗時的情形。

例如以下程式:

#[get("/add?<a>&<b>")]
fn handler(a: Option<i32>, b: Option<i32>) -> String {
    format!("Answer = {}", a.unwrap_or(0) + b.unwrap_or(0))
}

以上這個路由處理程序,可以讓Rocket在處理路徑為/add的GET請求時,去檢查其網址查詢字串中有無欄位a或欄位b,如果沒有的話,會直接把該欄位當作是None;如果有的話,該欄位的值必須要可以被轉成i32型別,然後使用Some這個Option的變體來包裹轉換後的i32數值。

允許出錯的資料欄位

參數守衛可以使用Result<T, &str>進行包裹,如果參數資料可以轉成型別T,會使用Some(T)來儲存該欄位;若無法轉成型別T(T不能為String或是&str),則會使用Err(&str)來儲存該欄位。

例如以下程式:

#[get("/<a>")]
fn handler(a: Result<i32, &str>) -> String {
    format!("a = {a:?}")
}

查詢守衛、資料守衛等實作了form::FromForm特性或form::FromFormField特性的型別則要使用form::Result結構體來包裹。

例如以下程式:

use rocket::form;

#[derive(Debug, FromForm)]
struct User<'r> {
    id:   form::Result<'r, i32>,
    name: String,
}

#[get("/?<user..>")]
fn handler(user: form::Result<User>) -> String {
    format!("{user:#?}")
}

也可以再用Option包裹Result或是form::Result,就可以同時使資料欄位變成可選的,而且也允許它轉換失敗啦!

例如以下程式:

use rocket::form;

#[derive(Debug, FromForm)]
struct User<'r> {
    id:   Option<form::Result<'r, i32>>,
    name: String,
    age:  Option<i32>,
}

#[get("/<a>?<user..>")]
fn handler(a: Option<Result<i32, &str>>, user: form::Result<User>) -> String {
    format!("a = {a:?}\n{user:#?}")
}

允許出錯的請求守衛

請求守衛可以使用Option<T>來進行包裹。若request::FromRequest特性的from_request關聯函數是回傳OutcomeForward或是Error變體的話,那麼在經過匹配與轉換之後,Option的變體就會是None

請求守衛也可以使用Result<T, T::Error>來進行包裹。若request::FromRequest特性的from_request關聯函數是回傳OutcomeError變體的話,那麼在經過匹配與轉換之後,Result的變體就會是Err,且Err變體所包裹的值為Error變體所包裹的元組(tuple)的第二個值(第一個值是http::Status結構實體);但若request::FromRequest特性的from_request關聯函數是回傳OutcomeForward變體的話,則無法匹配成功。

所以我們也可以再用Option包裹Result,就可以同時在能知道OutcomeError變體所帶的第二個值是什麼的同時,也有能力處理當OutcomeForward時的情形。

request::FromRequest特性的from_request關聯函數是回傳OutcomeError變體,則當該型別作為請求守衛來使用時,如果請求守衛沒有使用Option或是Result來包裹,就無法使用下一個路由處理程序繼續匹配,而會直接以Error變體中帶的http::Status結構實體來觸發Rocket的錯誤捕獲者機制。這個部份會在之後的章節進行更詳細的說明。

非同步的處理程序

如果我們的處理程序有使用到非同步I/O函數,我們也可以將處理程序函數改用async fn關鍵字來宣告,如此一來就可以在該處理程序主體中使用await關鍵字。處理程序的實作有一點要特別需要注意的是,如果我們在處理程序中有使用到耗時較久的運算或其他功能,而偏偏它不是非同步的,此時最好將它交給tokio::task::spawn_blocking這個非同步函數處理,這個函數可以使用別的執行緒來執行傳入的閉包,避免長時間佔用到處理請求的執行緒,而影響其它請求的回應時間。

程式寫法如下:

#[get("/")]
async fn handler() -> &'static str {
    // ...

    let result = rocket::tokio::task::spawn_blocking(move || {
        // do something
    })
    .await
    .unwrap();

    // ...

    "Done!"
}

總結

在這個章節中,我們知道了如何替Rocket框架的路由處理程序撰寫包含網址查詢字串的路徑匹配樣本,也了解了路由處理程序的預設優先順序和手動設定優先順序的方式,然後也利用了參數守衛、區段守衛、查詢守衛、請求守衛以及資料守衛來取得HTTP請求中含有的資料。

在下一個章節中,我們要來真正地開始學習HTTP回應(response)方式,而不再只是使用String或是&str作為路由處理程序函數的回傳型別。

下一章:HTTP回應(Response)