既然可以從extractor
取得request中的訂閱訊息了,那接下來是不是只要持久化到資料庫就好了呢?事實上不盡然,以目前的extractor為例:
#[derive(Deserialize, Debug, ToSchema)]
pub struct NewSubscriber {
/// Subscriber Email
pub email: Option<String>,
/// Subscriber Name
pub name: Option<String>,
}
實際上,我們無法只透過string來保證傳入的參數符合email的規則,因此我們在使用前通常要做一定的檢查:
pub async fn subscribe(Form(data): Form<NewSubscriber>) -> StatusCode {
data.email
//// 如果此欄位為Some則驗證內容
.map(|email| valid_email(email))
.map(|valid_result| {
//// 驗證成功回傳OK
if valid_result {
StatusCode::OK
} else {
StatusCode::BAD_REQUEST
}
})
//// 如果欄位為None則直接回傳Bad Request
.unwrap_or_else(|| StatusCode::BAD_REQUEST)
}
在簡單的應用程式中這麼做是可以接受的,但當系統變得龐大時,這樣的作法容易造成相同的檢查散落在應用程式的各個地方,尤其當這些驗證規則變化,就要花費大量的時間盤點,然後盤點總是會漏的...。這種作法也會降低系統模組化的可行性,作為一個可復用的模組,我們需要假設使用方傳遞了錯誤的資料,所以不僅僅是處裡函數的入口,在每個方法的入口其實都要各自驗證。那麼有沒有辦法保證系統中傳遞的資料都是有效的呢?或許我麼可以把腦筋動到資料生成上,透過一些手段確保只有正確的資料會被產生出來,事實上解決這個問題就是在解決一個常見的程式碼壞味道-基本型別偏執。
首先我們先新增一個資料夾作為domain
模組並且加上mod.rs
,先加入一個subscriber_email.rs
:
//// subscriber_email.rs
#[derive(Debug, Clone)]
pub struct SubscriberEmail(String);
這邊使用SubscriberEmail這個型別來表示訂閱者的email,接下來要確保資料生成的正確性,我們幫它實做一個parse
的工廠方法,作用有點像是建構式,並將驗證做在裡面:
//// subscriber_email.rs
//// 請先安裝validator套件簡化email驗證
use validator::validate_email;
#[derive(Debug, Clone)]
pub struct SubscriberEmail(String);
impl SubscriberEmail {
pub fn parse(email: String) -> Result<Self, String> {
if validate_email(&email) {
Ok(Self(email))
} else {
Err(format!("{} is not a valid subscriber email.", email))
}
}
}
只要透過parse
產生的SubscriberEmail
,就可以保證符合email的規格。
接下來我們為SubscriberEmail
撰寫單元測試,確保parse方法的正確性。rust中的單元測試很特別,官方建議把單元測試與被測的方法寫在同一個檔案中,因為這樣還可以測試模組內的私有方法,有些人可能覺的私有方法不須測試,但rust把這個選擇權交給開發者自己決定。在開始之前先安裝開發的依賴,以這個方式安裝的套件在release的時候不會被編譯:
cargo add --dev claims
claims
是一個協助撰寫測試的套件,然後我們就可以開始寫測試了:
//// 這是一個測試模組
#[cfg(test)]
mod tests {
use claims::assert_err;
//// 也可以用super的方式找到上一層模組的內容
use crate::domain::SubscriberEmail;
//// 在測試方法上標注
#[test]
fn empty_string_is_rejected() {
let email = " ".to_string();
assert_err!(SubscriberEmail::parse(email));
}
#[test]
fn email_missing_at_symbol_is_rejected() {
let email = "sefsefewe.com".to_string();
assert_err!(SubscriberEmail::parse(email));
}
#[test]
fn email_missing_subject_is_rejected() {
let email = "@domain.com".to_string();
assert_err!(SubscriberEmail::parse(email));
}
}
接下來只要運行cargo test
就可以看到測試結果了。把SubscriberName
也建立好後我們就可以組出訂閱者的結構了
pub struct Subscriber {
pub email: SubscriberEmail,
pub name: SubscriberName,
}
完整的Subscriber
可以參考這裡。
//// subscription.rs
pub async fn subscribe(Form(data): Form<NewSubscriber>) -> StatusCode {
//// 這邊可以熟悉一下rust中使用鏈式處裡錯誤的方法
Subscriber::try_from(data)
.map(|_| StatusCode::OK)
//// 如果拆箱失敗直接回傳Bad Request
.unwrap_or_else(|| StatusCode::BAD_REQUEST)
}
impl TryFrom<NewSubscriber> for Subscriber {
type Error = String;
fn try_from(value: NewSubscriber) -> Result<Self, String> {
let name = SubscriberName::parse(value.name.unwrap_or_default())?;
let email = SubscriberEmail::parse(value.email.unwrap_or_default())?;
Ok(Subscriber { email, name })
}
}
在這邊try_from
是rust中用來轉型的trait,稍微注意一下parse失敗的話會回傳Error("錯誤訊息"),再透過?
語法糖將錯誤往上傳遞,這就是rust的錯誤處裡方式,將錯誤以返回值傳遞,由上層調用邏輯決定處裡方式。
我覺的今天的內容,除了rust的使用方法以外,更重要的是透過解決基本型別偏執來達到系統上的穩定!