到目前為止,我們已經知道一個路由處理程序的函數可以選擇不回傳任何值,或是選擇回傳一個字串或一個字串切片。在這篇文章中,將會介紹Rocket框架所提供的多種不同的型別,來讓我們的路由處理程序能夠回應(respond)更多類型的資料。



response::Responder

先來複習一下上一章所提到的Rocket的路由處理程序的基本架構,如下:

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

在這個架構中,要回應的資料型別可以使用任何實作了rocket這個crate提供的response::Responder特性的型別。Rocket框架預設已經替&strString&[u8]Vec<u8>File等Rust與標準函式庫內建的型別實作了response::Responder特性。另外,Rocket還在response模組中另外內建了contentstatusstream模組以及Redirect型別,來處理常用的回應資料類型。

由於HTTP回應並不只是把資料放進HTTP主體中,我們總是還需要針對資料類型、資料大小以及資料的快取來撰寫HTTP回應的標頭。而Rocket框架顯然是把HTTP標頭的寫入動作都交由response::Responder特性來解決了,我們無法直接從路由處理程序的架構得知它回應的HTTP標頭到底會有哪些。所以說,在使用一個陌生的response::Responder前,最好先熟悉一下該型別會如何處理HTTP回應中的標頭。

不回傳或是回傳()

在Rust程式語言中,不回傳資料的函數其實就是回傳()這個型別的值。如果我們的路徑處理程序不想要在HTTP回應中回傳任何多餘的資料,可以替路徑處理程序定義不需回傳資料或是回傳值型別為()的函數。

例如:

#[delete("/resources")]
fn handler() {
    println!("Resources are deleted!");
}

或:

#[delete("/resources")]
fn handler() -> () {
    println!("Resources are deleted!");
}

如此一來,HTTP回應中的標頭欄位只會有基本的六項:

  • Server:Rocket框架預設會自動在每個HTTP回應中添加這個欄位,並且將值設定為Rocket。如果想要改寫這個欄位,可以藉由設定Rocket框架來達成,這部份會在之後的章節說明。
  • Content-Length:一個HTTP回應中基本上都應該要有這個欄位。這個欄位的值為HTTP主體的資料大小,單位為位元組(bytes)。如果HTTP主體沒有資料,這個欄位的值應被設為0
  • Date:這個欄位的值為此HTTP回應在伺服器上的建立時間,值的格式可參考RFC 7231

回傳&'static str&str

當一個函數回傳值的型別為參考時,還記得《Rust學習之路》系列文章中的泛型、特性和生命周期中介紹的編譯器推論函數的參數和回傳值的生命周期的規則嗎?若要回傳生命周期為'static的字串切片,記得要在回傳值型別中明確地把'static寫出來。

例如:

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

而如果是要回傳可以被編譯器直接推論出生命周期的參考,則函數的回傳值型別可以省略不寫生命周期。

例如:

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
}

當路由處理程序的函數是回傳字串切片時,HTTP回應中的標頭欄位除了上面提到的三個基本欄位外,還會多出Content-Type欄位,且該欄位的值會被設定為text/plain; charset=utf-8,也就是以UTF-8編碼的純文字資料。所以,使用字串切片是無法正常回傳HTML網頁的哦!

回傳String

例如:

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

當路由處理程序的函數是回傳字串時,HTTP回應中的標頭欄位除了上面提到的三個基本欄位外,還會多出Content-Type欄位,且該欄位的值會被設定為text/plain; charset=utf-8,也就是以UTF-8編碼的純文字資料。所以,使用字串也是無法正常回傳HTML網頁的哦!

回傳&'static [u8]&[u8]

因為生命周期限制的緣故,路由處理程序的函數幾乎不會回傳&[u8],所以這邊就不示例了。不過如果是&'static [u8],還是會有機會用到的。可以搭配Rust程式語言內建的include_bytes巨集,使路由處理程序直接在HTTP回應的主體中提供某個檔案的資料內容,且這個檔案會在程式編譯階段時,也跟著被編進程式執行檔中。

例如:

#[get("/file")]
fn handler() -> &'static [u8] {
    include_bytes!("/path/to/file")
}

如果您對單檔執行有興趣,可以延伸閱讀這篇文章

當路由處理程序的函數是回傳u8陣列切片時,HTTP回應中的標頭欄位除了上面提到的三個基本欄位外,還會多出Content-Type欄位,且該欄位的值會被設定為application/octet-stream,也就是不知道是什麼格式的資料串流啦!通常我們會對u8陣列切片再包一層實作了response::Responder的型別,用來覆寫Content-Type欄位,甚至還會順便添加更多標頭欄位(如快取相關的欄位)。

回傳Vec<u8>

例如:

use rand::RngCore;

#[get("/salt")]
fn handler() -> Vec<u8> {
    let mut result = vec![0u8; 16];

    rand::thread_rng().fill_bytes(&mut result);

    result
}

當路由處理程序的函數是回傳Vec<u8>時,HTTP回應中的標頭欄位除了上面提到的三個基本欄位外,還會多出Content-Type欄位,且該欄位的值會被設定為application/octet-stream,也就是不知道是什麼格式的資料串流啦!通常我們會對Vec<u8>再包一層實作了response::Responder的型別,用來覆寫Content-Type欄位,甚至還會順便添加更多標頭欄位(如快取相關的欄位)。

回傳File

例如:

use std::fs::File;
    
#[get("/file")]
fn handler() -> File {
    File::open("/path/to/file").unwrap()
}

當路由處理程序的函數是回傳File時,HTTP回應中的標頭欄位只會有上面提到的三個基本欄位。

雖然Rust標準函式庫的File結構體也可以直接被Rocket回應,但我們最好還是使用Rocket提供的非同步I/O模組(rocket::tokio)來操作I/O,避免阻塞(blocking)。例如以上程式改寫成以下這樣會更好:

use rocket::tokio::fs::File;

#[get("/file")]
async fn handler() -> File {
    File::open("/path/to/file").await.unwrap()
}

回傳response::content::*

當我們想要控制回傳的HTTP回應的Content-Type標頭欄位時,可以使用response::content模組下的結構體來包裹要回傳的資料。

結構體與Content-Type欄位值的對應關係如下表:

結構體名稱 Content-Type欄位值
RawCss text/css
RawHtml text/html
RawJavaScript application/javascript
RawJson application/json
RawMsgPack application/msgpack
RawText text/plain
RawXml text/xml

例如:

#[get("/")]
fn handler() -> RawHtml<&'static str> {
    RawHtml(
        r#"<html>
    <head>
        <meta charset=UTF-8>
        <title>My Website</title>
    </head>
    <body>
        Welcome!
    </body>
</html>"#,
    )
}

如果要使用上表沒有的Content-Type欄位值,或者想要回應浮動的Content-Type標頭欄位時,可以使用http::ContentType結構體再搭配我們原本要使用的response::Responder型別,用(ContentType, Responder)這樣的方式將它們組合成元祖,Rocket框架會自動將其變成新的response::Responder型別。這樣說可能還是很模糊,直接看以下例子比較快:

use rocket::http::ContentType;

#[get("/")]
fn handler() -> (ContentType, &'static [u8]) {
    (ContentType::PDF, include_bytes!("/path/to/file"))
}

http::ContentType已經內建了許多常用副檔名的MIME類型。如果想要自行填入MIME類型的話,程式可以這樣寫:

use rocket::http::ContentType;

#[get("/")]
fn handler() -> (ContentType, &'static [u8]) {
    (ContentType::new("application", "x-person"), include_bytes!("../Cargo.toml"))
}

另外,http::ContentType的參考型別,也就是&http::ContentType,也可以當作請求守衛來用哦!用來取得HTTP請求的Content-Type標頭欄位。

回傳fs::NamedFile

如果我們想要讓Rocket提供靜態檔案(如圖片檔、CSS、JS等等的靜態資源檔),可以讓路由處理程序的函數回傳fs::NamedFile

例如:

use rocket::fs::NamedFile;
    
#[get("/file")]
async fn handler() -> NamedFile {
    NamedFile::open("/path/to/file").await.unwrap()
}

當路由處理程序的函數是回傳fs::NamedFile時,HTTP回應中的標頭欄位除了上面提到的三個基本欄位外,還會自動加入Content-Type欄位。這個Content-Type欄位的值會根據fs::NamedFile開啟的檔案類型(依照檔案副檔名)而自動作適當的變化。

如果想要提供檔案系統中某個目錄下的所有檔案,可以讓fs::NamedFile與區段守衛搭配使用。

例如:

use std::path::{Path, PathBuf};

use rocket::fs::NamedFile;

#[get("/file/<path..>")]
async fn handler(path: PathBuf) -> NamedFile {
    NamedFile::open(Path::new("static/").join(path)).await.unwrap()
}

不過,對於這方面的功能,Rocket框架還有提供一種更好用的作法,會在以後的章節來介紹。

回傳response::stream::*

下面是一個說明在Rocket框架中,產生串流(stream)和讀取串流的基本程式寫法:

use rocket::{futures::stream::Stream, response::stream::stream};

fn f(stream: impl Stream<Item = u8>) -> impl Stream<Item = String> {
    stream! {
        for s in ["hi", "there"]{
            yield s.to_string();
        }

        for await n in stream {
            yield format!("n: {n}");
        }
    }
}

use rocket::futures::stream::{self, StreamExt};

let stream = f(stream::iter([3, 7, 11]));
let strings = stream.collect::<Vec<String>>().await;

assert_eq!(strings, ["hi", "there", "n: 3", "n: 7", "n: 11"]);

response::stream有針對不同類型的資料提供不同的response::Responder型別,這邊只介紹比較會用到的response::stream::ReaderStream結構體,它可以把某個甚至是多個有實作AsyncRead特性的型別的實體,直接當作HTTP回應主體的資料來源。程式撰寫方式如下:

use rocket::{response::stream::ReaderStream, tokio::fs::File};

#[get("/reader/stream")]
fn stream() -> ReaderStream![File] {
    ReaderStream! {
        for path in ["safe/path", "another/safe/path"] {
            if let Ok(file) = File::open(path).await {
                yield file;
            }
        }
    }
}

當路由處理程序的函數是回傳response::stream::ReaderStream時,HTTP回應中的標頭欄位中的三個基本欄位,會缺少Content-Length這一項,因為AsyncRead特性只能讓我們從頭到尾讀取某段資料,無法讓我們在讀取前事先得知接下來要讀取的資料總大小。此外,標頭欄位中還會多出一個Transfer-Encoding欄位,且欄位的值會是chunked

回傳response::Redirect

response::Redirect結構體可以實現客戶端的轉址(redirection)功能。它提供了以下幾個關聯函數:

  • moved:HTTP回應的狀態碼為301,屬於永久性的轉址。有些網頁瀏覽器或是HTTP客戶端對於POST、PUT或是DELETE等帶有HTTP主體的請求方法,可能會無法自動再使用相同請求方法來重新傳送HTTP主體,所以Rokcet框架比較建議使用permanent關聯函數。
  • found:HTTP回應的狀態碼為302,屬於暫時性的轉址。有些網頁瀏覽器或是HTTP客戶端對於POST、PUT或是DELETE等帶有HTTP主體的請求方法,可能會無法自動再使用相同請求方法來重新傳送HTTP主體,所以Rokcet框架比較建議使用temporary或是to關聯函數。
  • to:HTTP回應的狀態碼為303,屬於暫時性的轉址。永遠使用GET請求方法來進行轉址。
  • temporary:HTTP回應的狀態碼為307,屬於暫時性的轉址。永遠使用與前次相同的HTTP請求方法來進行轉址,可以正常重新傳送HTTP主體。
  • permanent:HTTP回應的狀態碼為308,屬於永久性的轉址。永遠使用與前次相同的HTTP請求方法來進行轉址,可以正常重新傳送HTTP主體。

這些關聯方法可以傳入一個可作為Uri使用的型別的值。雖然直接使用字串或是字串切片也是可以的,例如:

use rocket::response::Redirect;

#[get("/old?<id>")]
fn handler(id: i32) -> Redirect {
    Redirect::temporary(format!("/new?id={id}&from_old=true"))
}

#[get("/new?<id>&<from_old>")]
fn handler_new(id: i32, from_old: Option<bool>) -> String {
    let mut s =
        if from_old.unwrap_or(false) { String::from("Hi, old friend. ") } else { String::new() };

    use std::fmt::Write;

    s.write_fmt(format_args!("Your id is {id}.")).unwrap();

    s
}

但是Rocket既然號稱要做到「型別安全」,網址的組成當然也要是安全的才行。所以Rocket框架還提供了一個有用的巨集──uri!

我們可以將以上程式用uri!巨集改寫如下:

use rocket::response::Redirect;

#[get("/old?<id>")]
fn handler(id: i32) -> Redirect {
    Redirect::temporary(uri!(handler_new(id = id, from_old = Some(true))))
}

#[get("/new?<id>&<from_old>")]
fn handler_new(id: i32, from_old: Option<bool>) -> String {
    let mut s =
        if from_old.unwrap_or(false) { String::from("Hi, old friend. ") } else { String::new() };

    use std::fmt::Write;

    s.write_fmt(format_args!("Your id is {id}.")).unwrap();

    s
}

uri!巨集可以在程式編譯階段,去檢查要串接的Uri是否符合指定的路由處理程序所定義的匹配方式。如此一來就不用擔心日後修改路由處理程序的API時,會忘記把原本連到舊API的網址也一併改過來而造成程式在執行階段出錯了!另外,網址查詢字串中,有些欄位是可以省略的,如果要在uri!巨集中省略某個可省略的網址查詢字串的欄位,那就把該欄位的值設定為_吧!如下:

uri!(handler_new(id = id, from_old = _))

uri!巨集固然好用,只是比較可惜的是,uri!巨集只適用於串接出來的Uri是連到目前這個Rocket應用程式本身所能提供路由服務的某個路徑(換句話說就是目前使用Rocket框架的程式專案中,已存在的路由處理程序)。如果是要連到其它的Web應用程式,或是外部網站的話,就不能使用uri!巨集了。

可以在替我們的型別加上FromForm這一個derive屬性的參數時,也順便加上UriDisplayQuery參數。如此一來這個型別也能夠直接使用在uri!巨集中。

例如:

use rocket::response::Redirect;

#[derive(FromForm, UriDisplayQuery)]
struct User {
    id:    i32,
    name:  Option<String>,
    email: Option<String>,
}

#[get("/old?<user..>")]
fn handler(user: User) -> Redirect {
    Redirect::temporary(uri!(handler_new(from_old = Some(true), user = user)))
}

#[get("/new?<from_old>&<user..>")]
fn handler_new(from_old: Option<bool>, user: User) -> String {
    let mut s =
        if from_old.unwrap_or(false) { String::from("Hi, old friend. ") } else { String::new() };

    use std::fmt::Write;

    s.write_fmt(format_args!("Your id is {}.", user.id)).unwrap();

    s
}

此外,如果路由處理程序的註冊路徑不是/的話,可以在uri!巨集的第一個參數中,插入指定的註冊路徑,例如:

uri!("/path", handler_new(id = id, from_old = _))

回傳response::status::*http::Status

通常如果路由處理程序的函數不回傳資料或是有正常回傳資料的話,HTTP回應的狀態碼會使用200。如果要進行更改,可以利用response::status這個模組底下的結構體來包裹原本要回傳的資料。

response::status模組所提供的結構體有以下這些:

  • Created:HTTP回應的狀態碼為201
  • Accepted:HTTP回應的狀態碼為202
  • NoContent:HTTP回應的狀態碼為204
  • BadRequest:HTTP回應的狀態碼為400
  • Unauthorized:HTTP回應的狀態碼為401
  • Forbidden:HTTP回應的狀態碼為403
  • NotFound:HTTP回應的狀態碼為404
  • Conflict:HTTP回應的狀態碼為409
  • Custom:HTTP回應的狀態碼根據傳入的http::Status結構實體來決定。

例如:

use rocket::response::status;
    
#[get("/404")]
fn handler() -> status::NotFound<&'static str> {
    status::NotFound("Sorry, I couldn't find it!")
}

或是:

use rocket::{http::Status, response::status};

#[get("/404")]
fn handler() -> status::Custom<&'static str> {
    status::Custom(Status::NotFound, "Sorry, I couldn't find it!")
}

如果我們只想利用HTTP回應的狀態碼來提示API的調用結果,可以直接讓路由處理程序的函數回傳http::Status結構實體。

例如:

use rocket::http::Status;
    
#[get("/404")]
fn handler() -> Status {
    Status::NotFound
}

這邊要注意的是,並不是每個http::Status結構實體都可以直接被路由處理程序的函數回傳,有些有著必須與其它資料搭配才有特殊功用的實體(如AcceptedSeeOther等),若直接拿來回傳,就會使Rocket框架自動將這些變體改為InternalServerError

讓路由處理程序的函數回傳http::Status的作法,如果使用的變體是用來表示客戶端或是伺服器端的「錯誤」的話(例如NotFound或是InternalServerError),就會觸發Rocket框架的錯誤捕獲者(Error Catcher)機制,而去使用Rocket在啟動前就先設定好的錯誤捕獲者,或是使用Rocket預設的錯誤處理方式,來協助處理HTTP回應。這部份會在之後的章節做詳細介紹。

實作我們自己的response::Responder結構體

如果我們想要自由地修改回應的HTTP標頭,就得建立一個專門的結構體為其實作response::Responder特性。

一個最基本的response::Responder特性的實作方式如下:

use rocket::{
    request::Request,
    response::{self, Responder, Response},
};

struct MyStruct;

impl<'r, 'o: 'r> Responder<'r, 'o> for MyStruct {
    fn respond_to(self, request: &'r Request<'_>) -> response::Result<'o> {
        let mut response = Response::build();

        // do something

        response.ok()
    }
}

response::Response結構體所提供的build關聯函數,可以在respond_to方法產生response::Builder結構實體,來手動設定要回傳的HTTP回應中該放的標頭欄位和主體。最後再使用response::Builder結構實體提供的ok方法,來嘗試建立出要回傳的response::Response結構實體。

例如:

use rocket::{
    http::{ContentType, Status},
    request::Request,
    response::{self, Responder, Response},
    tokio::{fs::File, io::AsyncRead},
};

struct MyStruct<R> {
    reader: R,
}

impl<'r, 'o: 'r, R: AsyncRead + Send + 'o> Responder<'r, 'o> for MyStruct<R> {
    fn respond_to(self, _: &'r Request<'_>) -> response::Result<'o> {
        let mut response = Response::build();

        response
            .status(Status::Ok)
            .header(ContentType::Binary)
            .raw_header("Content-Disposition", "attachment")
            .streamed_body(self.reader);

        response.ok()
    }
}

#[get("/download/<id>")]
async fn handler(id: i32) -> MyStruct<File> {
    todo!("do something")
}

response::ResponseBuilder結構實體所提供的sized_body方法和streamed_body方法,都可以用來設定HTTP回應中的主體內的資料,傳入的資料都必須要實作AsyncRead特性。我們也可以利用Cursor結構體來嘗試將記憶體中的u8陣列賦予AsyncRead(和AsyncSeek)特性,程式如下:

use std::io::Cursor;

use rocket::{
    http::{ContentType, Status},
    request::Request,
    response::{self, Responder, Response},
};

struct MyStruct {
    data: Vec<u8>,
}

impl<'r, 'o: 'r> Responder<'r, 'o> for MyStruct {
    fn respond_to(self, _: &'r Request<'_>) -> response::Result<'o> {
        let mut response = Response::build();

        response
            .status(Status::Ok)
            .header(ContentType::Binary)
            .raw_header("Content-Disposition", "attachment")
            .sized_body(self.data.len(), Cursor::new(self.data));

        response.ok()
    }
}

#[get("/download/<id>")]
async fn handler(id: i32) -> MyStruct {
    todo!("do something")
}

使用Option或是Result來包裹要回傳的型別

路由處理程序的函數所回傳的資料型別,可以使用Option或是Result來進行包裹。

若是使用Option來包裹,當回傳的Option變體為None時,等同於回傳了上面提過的http::StatusNotFound結構實體。

例如:

use std::path::{Path, PathBuf};

use rocket::fs::NamedFile;

#[get("/file/<path..>")]
async fn handler(path: PathBuf) -> Option<NamedFile> {
    NamedFile::open(Path::new("static/").join(path)).await.ok()
}

若是使用Result來包裹,Ok或是Err變體所包裹的資料型別都必須是有實作response::Responder特性的型別。把Result當作路由處理程序的函數回傳型別的功用,就像是將原先只有一種回傳型別的函數變成兩種(換句話說,原本這支API可以回傳一種HTTP回應,加上Result來包裹後就可以將其擴充為兩種)。

例如:

use std::path::{Path, PathBuf};

use rocket::{fs::NamedFile, response::status};

#[get("/file/<path..>")]
async fn handler(path: PathBuf) -> Result<NamedFile, status::NotFound<&'static str>> {
    NamedFile::open(Path::new("static/").join(path))
        .await
        .map_err(|_| status::NotFound("Cannot find this file."))
}

當然,我們也可以利用多層(巢狀)Result來包裹要拿來回傳的不同型別的資料,實現出能夠回傳多種HTTP回應的路由處理程序。

使用#[derive(Responder)]屬性來快速實作我們的response::Responder結構

使用#[derive(Responder)]屬性來實作response::Responder特性時,結構必須要有特定的欄位順序和型別。

直接看以下的程式碼:

use rocket::http::{Header, ContentType};

#[derive(Responder)]
#[response(status = 500, content_type = "json")]
struct MyResponder {
    inner: OtherResponder,
    // Override the Content-Type declared above.
    header: ContentType,
    more: Header<'static>,
    #[response(ignore)]
    unrelated: MyType,
}

以上程式,將回應的HTTP狀態碼設定為500,且Content-Type設定為application/json。在我們自訂的結構中,第一個欄位的型別必須要實作response::Responder特性,且必須要存在。而接下來的欄位型別則必須要實作Into<http::Header>特性,其則不一定要存在,但可以有很多個。

利用列舉可以更靈活地做出不同的HTTP回應。如下:

use rocket::http::{ContentType, Header, Status};
use rocket::fs::NamedFile;

#[derive(Responder)]
enum Error {
    #[response(status = 500, content_type = "json")]
    A(String),
    #[response(status = 404)]
    B(NamedFile, ContentType),
    C {
        inner: (Status, Option<String>),
        header: ContentType,
    }
}

總結

在這個章節中,我們學會了路由處理程序的函數的回傳值型別的用法。至目前為止,我們已經將一個Web框架所具備的基本功能(路由和回應HTTP請求)都學全了,但是我們還不知道如何配置Rocket應用程式,使其能夠運作在正式的伺服器環境中。所以下一章將會介紹Rocket的組態配置方式。

下一章:設定Rocket框架