Rust 實務專案與最佳實踐 – 錯誤處理策略
簡介
在任何實務專案中,錯誤處理都是維護程式品質與使用者體驗的關鍵。Rust 以「安全」為設計核心,提供了編譯期保證的錯誤處理機制,使得程式在執行時不會因未處理的例外而崩潰。相較於傳統的例外拋出(exception)模型,Rust 採用 Result 與 Option 兩大列舉型別,讓錯誤成為類型系統的一部份,必須被顯式處理或傳遞。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶領讀者建立 可靠且易維護 的錯誤處理策略,適用於從小型腳本到大型服務端應用的各種情境。
核心概念
1. Result<T, E> 與 Option<T>
Result<T, E>表示可能成功(Ok(T))或失敗(Err(E))的運算。Option<T>則是有值或無值的情況(Some(T)/None),常用於不存在的情況,而非錯誤。
fn divide(dividend: f64, divisor: f64) -> Result<f64, &'static str> {
if divisor == 0.0 {
Err("除數不能為零")
} else {
Ok(dividend / divisor)
}
}
2. ? 運算子:錯誤傳遞的糖衣
? 會在遇到 Err 時自動返回,讓錯誤向上層傳遞,寫起來比手動 match 更簡潔。
use std::fs::File;
use std::io::{self, Read};
fn read_file(path: &str) -> io::Result<String> {
let mut s = String::new();
let mut f = File::open(path)?; // 若開檔失敗,直接返回 Err
f.read_to_string(&mut s)?; // 同上
Ok(s)
}
Tip:
?只能在返回Result、Option或Try的函式內使用。
3. 自訂錯誤型別
在大型專案中,單純使用字串或 std::io::Error 會讓錯誤資訊難以分類。可以透過 enum 定義領域專屬的錯誤,並實作 std::error::Error 與 Display。
use std::fmt;
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error),
Parse(toml::de::Error),
MissingField(&'static str),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Io(e) => write!(f, "IO 錯誤: {}", e),
ConfigError::Parse(e) => write!(f, "設定檔解析失敗: {}", e),
ConfigError::MissingField(fld) => write!(f, "缺少必要欄位: {}", fld),
}
}
}
impl std::error::Error for ConfigError {}
impl From<std::io::Error> for ConfigError {
fn from(e: std::io::Error) -> Self { ConfigError::Io(e) }
}
impl From<toml::de::Error> for ConfigError {
fn from(e: toml::de::Error) -> Self { ConfigError::Parse(e) }
}
4. thiserror 與 anyhow:快速建立錯誤類別
- thiserror:編譯期生成
Error實作,適合庫(library)開發者。 - anyhow:提供動態錯誤 (
anyhow::Error) 與Context,適合應用層(application)快速捕捉與附加訊息。
// Cargo.toml
// thiserror = "1.0"
// anyhow = "1.0"
use thiserror::Error;
use anyhow::{Context, Result};
#[derive(Error, Debug)]
enum DbError {
#[error("資料庫連線失敗: {0}")]
Connection(#[from] sqlx::Error),
#[error("查詢錯誤: {0}")]
Query(#[from] sqlx::Error),
}
async fn fetch_user(id: i32) -> Result<User> {
let pool = sqlx::PgPool::connect("postgres://...").await?;
let row = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.with_context(|| format!("取得 id 為 {} 的使用者失敗", id))?;
Ok(row)
}
5. panic! vs. 可恢復錯誤
panic!用於不可恢復的錯誤(如程式邏輯錯誤、違反不變條件)。在產品環境應盡量避免,或使用std::panic::catch_unwind包住。- 可恢復錯誤(
Result)則是預期可能失敗的情況,應在呼叫端妥善處理。
fn get_first(vec: &[i32]) -> i32 {
// 這裡若 vec 為空則會 panic,因為我們認為這是程式錯誤
*vec.first().expect("向量不應為空")
}
程式碼範例
範例 1:簡易 CLI 讀檔與錯誤回報
use std::env;
use std::fs;
use std::process;
fn main() {
// 取得第一個參數作為檔案路徑
let path = env::args().nth(1).expect("請提供檔案路徑");
match run(&path) {
Ok(content) => println!("檔案內容:\n{}", content),
Err(e) => {
eprintln!("錯誤: {}", e);
process::exit(1);
}
}
}
fn run(path: &str) -> Result<String, std::io::Error> {
let data = fs::read_to_string(path)?; // 使用 ? 傳遞錯誤
Ok(data)
}
範例 2:使用 thiserror 建立領域錯誤,並在 main 中統一處理
use thiserror::Error;
use std::fs;
use std::num::ParseIntError;
#[derive(Error, Debug)]
enum AppError {
#[error("讀檔失敗: {0}")]
Io(#[from] std::io::Error),
#[error("字串轉整數失敗: {0}")]
Parse(#[from] ParseIntError),
#[error("設定檔缺少欄位: {0}")]
MissingField(&'static str),
}
fn load_config(path: &str) -> Result<u32, AppError> {
let txt = fs::read_to_string(path)?; // Io 錯誤自動轉換
let num: u32 = txt.trim().parse()?; // Parse 錯誤自動轉換
if num == 0 {
Err(AppError::MissingField("port"))
} else {
Ok(num)
}
}
fn main() {
match load_config("config.txt") {
Ok(port) => println!("服務將在 {} 埠啟動", port),
Err(e) => eprintln!("設定錯誤: {}", e),
}
}
範例 3:anyhow + Context 提供額外除錯資訊
use anyhow::{Context, Result};
use std::fs;
fn read_json(path: &str) -> Result<serde_json::Value> {
let raw = fs::read_to_string(path)
.with_context(|| format!("讀取 JSON 檔案失敗: {}", path))?;
let json: serde_json::Value = serde_json::from_str(&raw)
.with_context(|| "JSON 解析失敗".to_string())?;
Ok(json)
}
fn main() -> Result<()> {
let data = read_json("data.json")?;
println!("JSON 內容: {:#}", data);
Ok(())
}
範例 4:自訂 Error 與 From 實作的鏈結
use std::fmt;
use std::io;
#[derive(Debug)]
enum NetworkError {
Io(io::Error),
InvalidResponse(String),
}
impl fmt::Display for NetworkError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
NetworkError::Io(e) => write!(f, "IO 錯誤: {}", e),
NetworkError::InvalidResponse(msg) => write!(f, "回應不合法: {}", msg),
}
}
}
impl std::error::Error for NetworkError {}
impl From<io::Error> for NetworkError {
fn from(e: io::Error) -> Self { NetworkError::Io(e) }
}
// 假設有一個簡易的 HTTP GET
fn http_get(url: &str) -> Result<String, NetworkError> {
// 這裡僅示意,實際會使用 reqwest 等 crate
if url.starts_with("http") {
Ok("fake response".to_string())
} else {
Err(NetworkError::InvalidResponse(url.to_string()))
}
}
範例 5:panic! 與 catch_unwind 的結合(不建議在一般業務邏輯使用)
use std::panic;
fn dangerous_operation(x: i32) -> i32 {
if x == 0 {
panic!("除以零的危險操作");
}
10 / x
}
fn main() {
let result = panic::catch_unwind(|| dangerous_operation(0));
match result {
Ok(v) => println!("結果: {}", v),
Err(_) => eprintln!("捕獲到 panic,已安全處理"),
}
}
常見陷阱與最佳實踐
| 陷阱 | 為何會發生 | 建議的做法 |
|---|---|---|
忽略 Result |
使用 let _ = foo(); 或直接拋棄返回值,使錯誤被吞掉。 |
必須使用 ?、match 或 unwrap_or_else 處理,或在測試階段使用 unwrap 以便快速定位。 |
過度使用 unwrap/expect |
在生產環境中若條件不成立會直接 panic!,導致服務中斷。 |
僅在測試、原型或確定不會失敗的情況下使用。 |
| 自訂錯誤過於複雜 | 把所有可能的錯誤都堆進同一個 enum,導致 match 分支過長。 |
按模組/領域切分錯誤型別,使用 thiserror 讓程式碼保持簡潔。 |
忘記在 async 函式中使用 ? |
Result 仍需 await,否則會得到 Future<Result<...>>。 |
確保 await 與 ? 同時使用,例如 foo().await?; |
| 錯誤資訊不夠具體 | 只回傳 Err(()) 或簡單字串,難以追蹤根因。 |
使用 anyhow::Context 或自訂錯誤的 Display,加入檔案路徑、參數值、呼叫堆疊等資訊。 |
最佳實踐總結
- 預期失敗 →
Result,不可能失敗 →panic!。 - 錯誤向上傳遞:在函式簽名使用
Result<_, MyError>,讓呼叫端決定如何回應。 - 使用
?讓錯誤傳遞保持簡潔。 - 分層錯誤:底層使用具體錯誤(
io::Error、serde_json::Error),上層轉換為領域錯誤。 - 加入 Context:使用
anyhow::Context或手動format!提供額外診斷資訊。
實際應用場景
1. Web 服務的請求處理
在 actix-web 或 warp 中,每個 handler 必須返回 Result<impl Responder, actix_web::Error>。透過 ? 直接把資料庫、IO、JSON 解析等錯誤向上層傳遞,框架會自動轉換為 HTTP 5xx 或自訂的錯誤回應。
async fn get_user(path: web::Path<u32>) -> Result<HttpResponse, actix_web::Error> {
let id = path.into_inner();
let user = db::fetch_user(id).await?; // db::fetch_user 回傳 Result<User, DbError>
Ok(HttpResponse::Ok().json(user))
}
2. CLI 工具的參數驗證
CLI 常需要檢查檔案是否存在、參數是否合法。使用 Result 結合 clap 的自訂驗證函式,可在解析階段即返回清晰的錯誤訊息。
fn validate_path(p: &str) -> Result<(), String> {
if std::path::Path::new(p).exists() {
Ok(())
} else {
Err(format!("路徑 {} 不存在", p))
}
}
3. 背景工作(Background Job)與重試機制
在長時間執行的任務中,常需要 捕獲錯誤、記錄、重試。使用 anyhow 可把多種錯誤統一為 anyhow::Error,再配合 tokio-retry 等 crate 實作重試邏輯。
async fn process_job(job_id: u64) -> anyhow::Result<()> {
retry::retry(Fixed::from_millis(500).take(3), || async {
let data = fetch_remote(job_id).await?;
store_locally(data).await?;
Ok(())
})
.await?;
Ok(())
}
總結
Rust 的錯誤處理設計讓 安全性與可讀性 同時得到保障。透過 Result、Option、?、自訂錯誤型別以及 thiserror / anyhow 等輔助庫,我們可以:
- 在編譯期捕捉未處理的錯誤,避免執行時意外崩潰。
- 提供豐富的錯誤上下文,讓除錯與日誌更具資訊量。
- 在不同層級(庫、應用、系統)維持一致的錯誤傳遞策略,提升程式碼的可維護性。
實務上,遵循「預期失敗使用 Result、不可恢復失敗使用 panic!」的原則,搭配 適當的錯誤分層與 Context,即可在大型 Rust 專案中建立穩健、易於診斷的錯誤處理機制。祝開發順利,寫出更安全、更可靠的 Rust 程式!