iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
Rust

Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計系列 第 24

(Day24) Rust 錯誤處理進階:thiserror、anyhow 與邊界策略

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250917/20124462KA2M7PfuNm.png

Rust 逼我成為更好的工程師 錯誤處理進階:thiserror、anyhow 與邊界策略

(Day10) 我們理解了 ResultOption 的基礎概念。

今天我們要深入探討:如何在大型專案中建立完整的錯誤處理策略

關鍵問題是:函式庫 (Library) 與應用程式 (Application) 的錯誤處理需求完全不同

兩種錯誤處理方式

函式庫:精準的錯誤型別

函式庫的使用者需要:

  1. 精確判斷錯誤類型:知道發生了什麼
  2. 程式化處理:根據不同錯誤採取不同行動
  3. 向上傳播:把錯誤資訊完整傳遞給呼叫者
// 函式庫應該提供精確的錯誤型別
pub enum DatabaseError {
    ConnectionFailed(String),
    QueryFailed(String),
    RecordNotFound,
    DuplicateKey(String),
}

pub fn query_user(id: u64) -> Result<User, DatabaseError> {
    // 使用者可以精確匹配錯誤類型
    match execute_query(id) {
        Ok(user) => Ok(user),
        Err(e) => Err(DatabaseError::QueryFailed(e.to_string())),
    }
}

應用程式:聚合的錯誤處理

應用程式的需求是:

  1. 快速開發:不想為每個錯誤寫轉換
  2. 錯誤語境:知道錯誤發生在哪個操作
  3. 使用者友善:提供有意義的錯誤訊息
// 應用程式可以用更簡單的方式
fn process_request(id: u64) -> Result<Response> {
    let user = query_user(id)
        .context("查詢使用者失敗")?;
    
    let profile = fetch_profile(user.id)
        .context("取得個人資料失敗")?;
    
    Ok(Response { user, profile })
}

thiserror:為函式庫設計的錯誤型別

基本使用

use thiserror::Error;

#[derive(Error, Debug)]
pub enum DataStoreError {
    #[error("資料庫連線失敗: {0}")]
    ConnectionFailed(String),
    
    #[error("查詢失敗: {query}")]
    QueryFailed { query: String },
    
    #[error("找不到記錄 (id: {0})")]
    RecordNotFound(u64),
    
    #[error("重複的鍵值: {0}")]
    DuplicateKey(String),
    
    #[error(transparent)]
    IoError(#[from] std::io::Error),
}

關鍵特性

  1. 自動實作 Display#[error("...")] 定義錯誤訊息
  2. 自動轉換#[from] 自動實作 From trait
  3. 透明包裝#[transparent] 保留原始錯誤的 Display

實戰案例:設計函式庫錯誤

use thiserror::Error;
use std::io;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("設定檔不存在: {path}")]
    FileNotFound { path: String },
    
    #[error("設定檔格式錯誤: {0}")]
    ParseError(String),
    
    #[error("缺少必要欄位: {field}")]
    MissingField { field: String },
    
    #[error("IO 錯誤")]
    Io(#[from] io::Error),
    
    #[error("JSON 解析錯誤")]
    Json(#[from] serde_json::Error),
}

pub struct Config {
    pub host: String,
    pub port: u16,
}

impl Config {
    pub fn load(path: &str) -> Result<Self, ConfigError> {
        // 讀取檔案
        let content = std::fs::read_to_string(path)
            .map_err(|_| ConfigError::FileNotFound {
                path: path.to_string(),
            })?;
        
        // 解析 JSON
        let json: serde_json::Value = serde_json::from_str(&content)?;
        
        // 提取欄位
        let host = json["host"]
            .as_str()
            .ok_or_else(|| ConfigError::MissingField {
                field: "host".to_string(),
            })?
            .to_string();
        
        let port = json["port"]
            .as_u64()
            .ok_or_else(|| ConfigError::MissingField {
                field: "port".to_string(),
            })? as u16;
        
        Ok(Config { host, port })
    }
}

// 使用者可以精確處理錯誤
fn handle_config() {
    match Config::load("config.json") {
        Ok(config) => println!("載入成功: {}:{}", config.host, config.port),
        Err(ConfigError::FileNotFound { path }) => {
            println!("找不到設定檔: {}", path);
            // 建立預設設定
        }
        Err(ConfigError::MissingField { field }) => {
            println!("缺少欄位: {}", field);
            // 使用預設值
        }
        Err(e) => println!("其他錯誤: {}", e),
    }
}

anyhow:為應用程式設計的錯誤處理

基本使用

use anyhow::{Context, Result};

fn process_file(path: &str) -> Result<String> {
    let content = std::fs::read_to_string(path)
        .context("讀取檔案失敗")?;
    
    let parsed = parse_content(&content)
        .context("解析內容失敗")?;
    
    let result = transform_data(parsed)
        .context("轉換資料失敗")?;
    
    Ok(result)
}

// 錯誤訊息會自動堆疊
// Error: 轉換資料失敗
// Caused by:
//     0: 解析內容失敗
//     1: 讀取檔案失敗
//     2: No such file or directory (os error 2)

關鍵特性

  1. 自動轉換:任何實作 Error 的型別都能轉成 anyhow::Error
  2. 語境堆疊.context() 添加錯誤語境
  3. 簡化簽名:不需要定義自己的錯誤型別

實戰案例:應用程式錯誤處理

use anyhow::{Context, Result, bail, ensure};

struct User {
    id: u64,
    name: String,
    age: u32,
}

fn fetch_user(id: u64) -> Result<User> {
    if id == 0 {
        bail!("無效的使用者 ID: {}", id);
    }
    
    let user = database::query_user(id)
        .context(format!("查詢使用者失敗 (id: {})", id))?;
    
    ensure!(user.age >= 18, "使用者未滿 18 歲");
    
    Ok(user)
}

fn process_users(ids: &[u64]) -> Result<Vec<User>> {
    let mut users = Vec::new();
    
    for &id in ids {
        let user = fetch_user(id)
            .context(format!("處理使用者 {} 時發生錯誤", id))?;
        users.push(user);
    }
    
    Ok(users)
}

fn main() -> Result<()> {
    let ids = vec![1, 2, 3];
    let users = process_users(&ids)
        .context("批次處理使用者失敗")?;
    
    println!("成功處理 {} 個使用者", users.len());
    Ok(())
}

錯誤邊界策略

策略 1:函式庫用 thiserror,應用用 anyhow

// === 函式庫層 (library crate) ===
use thiserror::Error;

#[derive(Error, Debug)]
pub enum StorageError {
    #[error("儲存失敗: {0}")]
    WriteFailed(String),
    
    #[error("讀取失敗: {0}")]
    ReadFailed(String),
}

pub fn save_data(data: &str) -> Result<(), StorageError> {
    // 精確的錯誤型別
    Err(StorageError::WriteFailed("磁碟已滿".to_string()))
}

// === 應用層 (application crate) ===
use anyhow::{Context, Result};

fn handle_request() -> Result<()> {
    let data = "important data";
    
    // anyhow 自動轉換 StorageError
    save_data(data)
        .context("儲存請求資料失敗")?;
    
    Ok(())
}

策略 2:分層錯誤轉換

use thiserror::Error;

// 底層:資料庫錯誤
#[derive(Error, Debug)]
pub enum DbError {
    #[error("連線失敗")]
    ConnectionFailed,
    
    #[error("查詢失敗")]
    QueryFailed,
}

// 中層:業務邏輯錯誤
#[derive(Error, Debug)]
pub enum BusinessError {
    #[error("使用者不存在")]
    UserNotFound,
    
    #[error("權限不足")]
    PermissionDenied,
    
    #[error("資料庫錯誤")]
    Database(#[from] DbError),
}

// 上層:API 錯誤
#[derive(Error, Debug)]
pub enum ApiError {
    #[error("請求無效: {0}")]
    InvalidRequest(String),
    
    #[error("內部錯誤")]
    Internal(#[from] BusinessError),
}

// 每一層都可以精確處理自己關心的錯誤
fn api_handler(user_id: u64) -> Result<Response, ApiError> {
    let user = fetch_user(user_id)?;  // BusinessError -> ApiError
    Ok(Response { user })
}

策略 3:可恢復 vs 不可恢復

use anyhow::{Context, Result};

// 可恢復的錯誤:返回 Result
fn try_fetch_cache(key: &str) -> Result<Option<String>> {
    match cache::get(key) {
        Ok(value) => Ok(Some(value)),
        Err(e) if is_recoverable(&e) => {
            log::warn!("快取讀取失敗,將從資料庫讀取: {}", e);
            Ok(None)
        }
        Err(e) => Err(e).context("快取系統故障"),
    }
}

// 不可恢復的錯誤:直接 panic
fn load_critical_config() -> Config {
    Config::load("config.json")
        .expect("無法載入設定檔,程式無法繼續執行")
}

fn main() -> Result<()> {
    // 初始化階段:不可恢復
    let config = load_critical_config();
    
    // 執行階段:可恢復
    match try_fetch_cache("user:123") {
        Ok(Some(data)) => println!("從快取讀取: {}", data),
        Ok(None) => println!("快取未命中,從資料庫讀取"),
        Err(e) => println!("快取系統故障: {}", e),
    }
    
    Ok(())
}

錯誤的可觀測性

添加語境資訊

use anyhow::{Context, Result};
use std::collections::HashMap;

fn process_batch(items: &[Item]) -> Result<()> {
    let mut errors = HashMap::new();
    
    for (index, item) in items.iter().enumerate() {
        if let Err(e) = process_item(item)
            .context(format!("處理第 {} 個項目失敗", index + 1))
        {
            errors.insert(index, e);
        }
    }
    
    if !errors.is_empty() {
        log::error!("批次處理完成,{} 個項目失敗", errors.len());
        for (index, error) in errors {
            log::error!("項目 {}: {:?}", index, error);
        }
    }
    
    Ok(())
}

結構化日誌

use tracing::{error, info, instrument};
use anyhow::{Context, Result};

#[instrument(skip(data), fields(data_len = data.len()))]
fn save_to_database(id: u64, data: &[u8]) -> Result<()> {
    info!("開始儲存資料");
    
    database::write(id, data)
        .context("寫入資料庫失敗")
        .map_err(|e| {
            error!("資料庫寫入錯誤: {:?}", e);
            e
        })?;
    
    info!("資料儲存成功");
    Ok(())
}

自定義錯誤的最佳實踐

1. 錯誤應該是可序列化的

use serde::{Deserialize, Serialize};
use thiserror::Error;

#[derive(Error, Debug, Serialize, Deserialize)]
pub enum ApiError {
    #[error("找不到資源: {resource_type} (id: {id})")]
    NotFound {
        resource_type: String,
        id: u64,
    },
    
    #[error("驗證失敗: {message}")]
    ValidationError {
        message: String,
        field: String,
    },
}

// 可以轉成 JSON 回傳給前端
fn handle_api_error(error: ApiError) -> String {
    serde_json::to_string(&error).unwrap()
}

2. 錯誤應該包含足夠的除錯資訊

use thiserror::Error;
use std::backtrace::Backtrace;

#[derive(Error, Debug)]
pub enum DetailedError {
    #[error("操作失敗: {message}")]
    OperationFailed {
        message: String,
        operation: String,
        timestamp: u64,
        backtrace: Backtrace,
    },
}

impl DetailedError {
    pub fn new(message: impl Into<String>, operation: impl Into<String>) -> Self {
        DetailedError::OperationFailed {
            message: message.into(),
            operation: operation.into(),
            timestamp: current_timestamp(),
            backtrace: Backtrace::capture(),
        }
    }
}

3. 錯誤應該易於測試

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_error_conversion() {
        let io_error = std::io::Error::new(
            std::io::ErrorKind::NotFound,
            "file not found"
        );
        
        let config_error: ConfigError = io_error.into();
        
        match config_error {
            ConfigError::Io(_) => (),
            _ => panic!("錯誤轉換失敗"),
        }
    }
    
    #[test]
    fn test_error_message() {
        let error = ConfigError::MissingField {
            field: "host".to_string(),
        };
        
        assert_eq!(error.to_string(), "缺少必要欄位: host");
    }
}

總結:錯誤處理的設計原則

1. 選擇正確的工具

  • 函式庫:用 thiserror 提供精確的錯誤型別
  • 應用程式:用 anyhow 快速開發和添加語境

2. 分層處理錯誤

  • 底層:精確的錯誤型別
  • 中層:業務邏輯錯誤
  • 上層:使用者友善的錯誤訊息

3. 添加足夠的語境

  • 使用 .context() 說明錯誤發生的位置
  • 記錄關鍵參數和狀態
  • 保留錯誤鏈,方便追蹤

4. 區分可恢復與不可恢復

  • 可恢復:返回 Result,讓呼叫者決定
  • 不可恢復:使用 panic!.expect()

關鍵洞察:好的錯誤處理不是捕捉所有錯誤,而是在正確的層級用正確的方式處理正確的錯誤。

在下一篇中,我們將探討 模組與可見性,討論如何縮小 API 表面,封裝不變式。

相關連結與參考資源


上一篇
(Day23) Rust FFI 的邊界:不信任的世界與安全外殼
下一篇
(Day25) Rust 模組與可見性:縮小 API 表面,封裝不變式
系列文
Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言