在 Web 開發中,錯誤處理是不可或缺的一環。本文將根據 Actix Web 框架,探討如何在 Rust 中實作完善的錯誤處理機制,確保服務穩定性並提升使用者經驗。我們將從 Rust 的錯誤處理基礎出發,逐步引導讀者理解 Result 型別和問號運算子的應用,接著探討如何定義和使用自定義錯誤型別,最後結合 Actix Web 框架,展示如何在處理函式中有效地處理錯誤,並向客戶端傳回適切的 HTTP 狀態碼和錯誤訊息。
錯誤處理
本章涵蓋了以下主題:
- 設定專案結構
- 在 Rust 和 Actix Web 中處理錯誤
- 定義自定義錯誤處理器
- 為三個 API 進行錯誤處理
在前一章中,我們編寫了透過 API 發布和檢索課程的程式碼,但我們演示和測試的都是順利執行的情況。然而,在現實世界中,可能會發生許多型別的故障。資料函式庫伺服器可能不可用,請求中提供的導師 ID 可能無效,可能發生 Web 伺服器錯誤等。確保我們的 Web 服務能夠檢測錯誤、優雅地處理錯誤,並向傳送 API 請求的使用者或客戶端傳送有意義的錯誤訊息非常重要。這是透過錯誤處理來實作的,這是本章的重點。錯誤處理不僅對於我們的 Web 服務的穩定性很重要,而且對於提供良好的使用者經驗也很重要。
圖 5.1 總結了我們在本章中將採用的錯誤處理方法。我們將為我們的 Web 服務新增自定義錯誤處理,統一應用程式中可能遇到的不同型別的錯誤。每當有無效請求或伺服器程式碼發生意外故障時,客戶端將收到有意義且適當的 HTTP 狀態碼和錯誤訊息。為了實作這一點,我們將結合使用 Rust 的核心錯誤處理功能和 Actix 提供功能,同時為我們的應用程式自定義錯誤處理。
圖 5.1 在 Rust 中統一錯誤處理
5.1 設定專案結構
我們將使用前一章構建的程式碼作為新增錯誤處理的起點。如果您一直在跟隨操作,可以從第 4 章開始使用自己的程式碼。或者,從 GitHub(https://github.com/peshwar9/rust-servers-services-apps)克隆儲存函式庫,並使用第 4 章中的迭代 3 程式碼作為起點。
我們將在本章中構建迭代 4 的程式碼,因此首先轉到專案根目錄(ezytutors/tutor-db),並在 src 下建立一個名為 iter4 的新資料夾。
本文的程式碼將按如下方式組織(參見圖 5.2):
- src/bin/iter4.rs:main() 函式
- src/iter4/routes.rs:包含路由
- src/iter4/handlers.rs:處理函式
- src/iter4/models.rs:表示課程的資料結構和實用方法
- src/iter4/state.rs:包含注入到應用程式執行緒中的依賴項的應用程式狀態
- src/iter4/db_access.rs:與資料庫存取相關的程式碼,從處理函式中分離出來以提高模組化
- src/iter4/errors.rs:自定義錯誤資料結構和相關的錯誤處理函式
圖 5.2 第 5 章的專案結構
與第 4 章相比,我們不會更改 routes.rs、models.rs 或 state.rs 的原始程式碼。對於 handlers.rs 和 db_access.rs,我們將從第 4 章的程式碼開始,但我們將修改這些檔案以包含自定義錯誤處理。errors.rs 是我們將要新增的新原始檔。
讓我們按照以下步驟為本章建立資料函式庫表的新版本:
- 修改前一章的 database.sql 指令碼,使其如下所示:
/* 如果表已存在,則刪除它 */
drop table if exists ezy_course_c5;
/* 建立表 */
/* 注意:不要在最後一個欄位後面加逗號 */
create table ezy_course_c5 (
course_id serial primary key,
tutor_id INT not null,
course_name varchar(140) not null,
posted_time TIMESTAMP default now()
);
/* 載入測試用的種子資料 */
insert into ezy_course_c5 (course_id, tutor_id, course_name, posted_time)
values (1, 1, '第一門課程', '2021-03-17 05:40:00');
insert into ezy_course_c5 (course_id, tutor_id, course_name, posted_time)
values (2, 1, '第二門課程', '2021-03-18 05:45:00');
注意,與上一章相比,我們在此指令碼中進行的主要更改是將表的名稱從 ezy_course_c4 更改為 ezy_course_c5。
- 從命令列執行以下命令以建立表並載入範例資料:
psql -U <使用者名稱> -d ezytutors < database.sql
確保提供正確的 database.sql 檔案路徑,並在提示時輸入密碼。
- 一旦建立了表,我們就需要為資料函式庫使用者授予對此新表的許可權。從終端命令列執行以下命令:
psql -U <使用者名稱> -d ezytutors // 登入到 psql shell
GRANT ALL PRIVILEGES ON TABLE ezy_course_c5 TO <使用者名稱>;
\q // 離開 psql shell
用您自己的使用者名稱替換 <使用者名稱>,然後執行這些命令。
- 編寫 main() 函式:從前一章複製 src/bin/iter3.rs 到您本章的專案目錄下,命名為 src/bin/iter4.rs,並將對 iter3 的參照替換為 iter4。iter4.rs 的最終程式碼應如下所示:
use actix_web::{web, App, HttpServer};
use dotenv::dotenv;
use sqlx::postgres::PgPool;
use std::env;
use std::io;
use std::sync::Mutex;
#[path = "../iter4/db_access.rs"]
mod db_access;
#[path = "../iter4/errors.rs"]
mod errors;
#[path = "../iter4/handlers.rs"]
mod handlers;
#[path = "../iter4/models.rs"]
mod models;
#[path = "../iter4/routes.rs"]
mod routes;
#[path = "../iter4/state.rs"]
mod state;
use routes::*;
use state::AppState;
#[actix_rt::main]
async fn main() -> io::Result<()> {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file");
let db_pool = PgPool::connect(&database_url).await.unwrap();
// 建構 App 狀態
let shared_data = web::Data::new(AppState {
health_check_response: "I'm good. You've already asked me ".to_string(),
visit_count: Mutex::new(0),
db: db_pool,
});
// 建構 app 並組態路由
let app = move || {
App::new()
.app_data(shared_data.clone())
.configure(general_routes)
.configure(course_routes)
};
// ...
}
程式碼解密:
此段程式碼主要負責初始化應用程式並設定必要的組態,包括資料函式庫連線、分享應用狀態以及路由組態。
dotenv().ok();:載入環境變數。let database_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file");:從環境變數中取得資料函式庫 URL。let db_pool = PgPool::connect(&database_url).await.unwrap();:建立與 PostgreSQL 資料函式庫的連線池。let shared_data = web::Data::new(AppState { ... });:建立分享應用狀態,包括健康檢查回應、存取計數以及資料函式庫連線池。let app = move || { ... };:定義一個閉包,用於組態 Actix-web 應用,包括設定分享資料和路由組態。
這個 main 函式是整個應用的入口,負責初始化所有必要的元件,為應用執行提供基礎設施。
Rust 中的錯誤處理基礎與自定義錯誤型別
在軟體開發過程中,錯誤處理是一項至關重要的技術。Rust 採用了一種與多數程式語言不同的錯誤處理機制,本章節將探討 Rust 中的基本錯誤處理方式,並進一步介紹如何為 Web 服務設計自定義的錯誤處理。
Rust 的錯誤處理機制
大多數程式語言採用兩種錯誤處理方式之一:例外處理或傳回值。Rust 選擇了後者,這與 Java、Python 和 JavaScript 等語言不同。Rust 將錯誤處理視為其可靠性保證的關鍵,因此鼓勵程式設計師明確處理錯誤,而不是丟擲異常。
Result 列舉型別
Rust 中可能失敗的函式會傳回一個 Result 列舉型別,其定義如下:
enum Result<T, E> {
Ok(T),
Err(E),
}
一個 Rust 函式的簽名將包含一個 Result<T, E> 型別的傳回值,其中 T 是成功情況下的傳回值型別,而 E 是失敗情況下的錯誤值型別。Result 型別本質上表示一個計算或函式可能傳回兩種結果之一:成功時的 T 值或失敗時的 E 錯誤。
範例:將字串解析為整數並計算平方
use std::num::ParseIntError;
fn square(val: &str) -> Result<i32, ParseIntError> {
match val.parse::<i32>() {
Ok(num) => Ok(i32::pow(num, 2)),
Err(e) => Err(e),
}
}
fn main() {
println!("{:?}", square("2"));
println!("{:?}", square("INVALID"));
}
這段程式碼定義了一個 square 函式,它嘗試將輸入字串解析為整數,計算其平方,並傳回結果。如果解析失敗,則傳回 ParseIntError。
執行結果如下:
Ok(4)
Err(ParseIntError { kind: InvalidDigit })
使用 ? 運算子簡化錯誤處理
Rust 提供了一個特殊的 ? 運算子來減少錯誤處理的冗餘程式碼。上述範例可以簡化為:
use std::num::ParseIntError;
fn square(val: &str) -> Result<i32, ParseIntError> {
let num = val.parse::<i32>()?;
Ok(i32::pow(num, 2))
}
? 運算子嘗試解封裝 Result 值中的整數並將其儲存在 num 變數中。如果失敗,它會接收來自 parse() 方法的錯誤,中止 square 函式,並將 ParseIntError 傳播給呼叫者。
自定義錯誤型別
當函式可能傳回多種型別的錯誤時,自定義錯誤型別就變得非常有用。例如,在下面的範例中,square 函式不僅嘗試解析字串,還嘗試開啟一個檔案並寫入結果。這引入了兩種可能的錯誤來源:ParseIntError 和 std::io::Error。
use std::fs::File;
use std::io::Write;
use std::num::ParseIntError;
fn square(val: &str) -> Result<i32, ParseIntError> {
let num = val.parse::<i32>()?;
let mut f = File::open("fictionalfile.txt")?;
let string_to_write = format!("Square of {} is {}", num, i32::pow(num, 2));
f.write_all(string_to_write.as_bytes())?;
Ok(i32::pow(num, 2))
}
編譯上述程式碼會產生一個錯誤,因為 File::open 和 write_all 方法傳回的 Result 包含 std::io::Error 型別的錯誤,而函式簽名只指定了 ParseIntError。
定義自定義錯誤列舉
為瞭解決這個問題,我們可以定義一個自定義的錯誤列舉,抽象出多種可能的錯誤型別。
use std::fmt;
use std::fs::File;
use std::io::Write;
#[derive(Debug)]
pub enum MyError {
ParseError,
IOError,
}
impl std::error::Error for MyError {}
impl fmt::Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
MyError::ParseError => write!(f, "Parse Error"),
MyError::IOError => write!(f, "IO Error"),
}
}
}
fn square(val: &str) -> Result<i32, MyError> {
let num = val.parse::<i32>().map_err(|_| MyError::ParseError)?;
let mut f = File::open("fictionalfile.txt").map_err(|_| MyError::IOError)?;
let string_to_write = format!("Square of {:?} is {:?}", num, i32::pow(num, 2));
f.write_all(string_to_write.as_bytes()).map_err(|_| MyError::IOError)?;
Ok(i32::pow(num, 2))
}
fn main() {
let result = square("INVALID");
match result {
Ok(res) => println!("Result is {:?}", res),
Err(e) => println!("Error in parsing: {:?}", e),
};
}
程式碼說明
- 自定義錯誤列舉:定義了一個名為
MyError的列舉,包含兩個可能的錯誤變體:ParseError和IOError。 - 實作
std::error::Error特性:按照慣例,Rust 中的錯誤型別實作了標準函式庫中的Error特性。 - 實作
fmt::Display特性:為了提供人類可讀的錯誤訊息,需要實作Display特性。 - 使用自定義錯誤型別:修改了
square函式以傳回Result<i32, MyError>,並使用map_err將不同型別的錯誤對映到自定義的MyError列舉變體。
透過使用自定義錯誤型別,我們能夠有效地封裝和管理多種型別的錯誤,使程式碼更加清晰和健壯。