本文 AI 產出,尚未審核

Rust 實務專案與最佳實踐 – 錯誤處理策略

簡介

在任何實務專案中,錯誤處理都是維護程式品質與使用者體驗的關鍵。Rust 以「安全」為設計核心,提供了編譯期保證的錯誤處理機制,使得程式在執行時不會因未處理的例外而崩潰。相較於傳統的例外拋出(exception)模型,Rust 採用 ResultOption 兩大列舉型別,讓錯誤成為類型系統的一部份,必須被顯式處理或傳遞。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶領讀者建立 可靠且易維護 的錯誤處理策略,適用於從小型腳本到大型服務端應用的各種情境。


核心概念

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? 只能在返回 ResultOptionTry 的函式內使用。

3. 自訂錯誤型別

在大型專案中,單純使用字串或 std::io::Error 會讓錯誤資訊難以分類。可以透過 enum 定義領域專屬的錯誤,並實作 std::error::ErrorDisplay

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. thiserroranyhow:快速建立錯誤類別

  • 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:自訂 ErrorFrom 實作的鏈結

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(); 或直接拋棄返回值,使錯誤被吞掉。 必須使用 ?matchunwrap_or_else 處理,或在測試階段使用 unwrap 以便快速定位。
過度使用 unwrap/expect 在生產環境中若條件不成立會直接 panic!,導致服務中斷。 僅在測試、原型確定不會失敗的情況下使用。
自訂錯誤過於複雜 把所有可能的錯誤都堆進同一個 enum,導致 match 分支過長。 模組/領域切分錯誤型別,使用 thiserror 讓程式碼保持簡潔。
忘記在 async 函式中使用 ? Result 仍需 await,否則會得到 Future<Result<...>> 確保 await? 同時使用,例如 foo().await?;
錯誤資訊不夠具體 只回傳 Err(()) 或簡單字串,難以追蹤根因。 使用 anyhow::Context 或自訂錯誤的 Display,加入檔案路徑、參數值、呼叫堆疊等資訊。

最佳實踐總結

  1. 預期失敗 → Result不可能失敗 → panic!
  2. 錯誤向上傳遞:在函式簽名使用 Result<_, MyError>,讓呼叫端決定如何回應。
  3. 使用 ? 讓錯誤傳遞保持簡潔。
  4. 分層錯誤:底層使用具體錯誤(io::Errorserde_json::Error),上層轉換為領域錯誤。
  5. 加入 Context:使用 anyhow::Context 或手動 format! 提供額外診斷資訊。

實際應用場景

1. Web 服務的請求處理

actix-webwarp 中,每個 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 的錯誤處理設計讓 安全性與可讀性 同時得到保障。透過 ResultOption?、自訂錯誤型別以及 thiserror / anyhow 等輔助庫,我們可以:

  • 在編譯期捕捉未處理的錯誤,避免執行時意外崩潰。
  • 提供豐富的錯誤上下文,讓除錯與日誌更具資訊量。
  • 在不同層級(庫、應用、系統)維持一致的錯誤傳遞策略,提升程式碼的可維護性。

實務上,遵循「預期失敗使用 Result、不可恢復失敗使用 panic!」的原則,搭配 適當的錯誤分層與 Context,即可在大型 Rust 專案中建立穩健、易於診斷的錯誤處理機制。祝開發順利,寫出更安全、更可靠的 Rust 程式!