返回文章列表

Rust Actix Web 錯誤處理實戰

本文探討 Rust Actix Web 框架中的錯誤處理機制,涵蓋自定義錯誤型別、處理函式以及 HTTP 狀態碼的應用,演示如何構建穩健的 Web 服務,並提供友好的錯誤訊息給客戶端。

Web 開發 後端開發

在 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 是我們將要新增的新原始檔。

讓我們按照以下步驟為本章建立資料函式庫表的新版本:

  1. 修改前一章的 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。

  1. 從命令列執行以下命令以建立表並載入範例資料:
psql -U <使用者名稱> -d ezytutors < database.sql

確保提供正確的 database.sql 檔案路徑,並在提示時輸入密碼。

  1. 一旦建立了表,我們就需要為資料函式庫使用者授予對此新表的許可權。從終端命令列執行以下命令:
psql -U <使用者名稱> -d ezytutors // 登入到 psql shell
GRANT ALL PRIVILEGES ON TABLE ezy_course_c5 TO <使用者名稱>;
\q // 離開 psql shell

用您自己的使用者名稱替換 <使用者名稱>,然後執行這些命令。

  1. 編寫 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)
    };
    // ...
}

程式碼解密:

此段程式碼主要負責初始化應用程式並設定必要的組態,包括資料函式庫連線、分享應用狀態以及路由組態。

  1. dotenv().ok();:載入環境變數。
  2. let database_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file");:從環境變數中取得資料函式庫 URL。
  3. let db_pool = PgPool::connect(&database_url).await.unwrap();:建立與 PostgreSQL 資料函式庫的連線池。
  4. let shared_data = web::Data::new(AppState { ... });:建立分享應用狀態,包括健康檢查回應、存取計數以及資料函式庫連線池。
  5. 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 函式不僅嘗試解析字串,還嘗試開啟一個檔案並寫入結果。這引入了兩種可能的錯誤來源:ParseIntErrorstd::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::openwrite_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),
    };
}

程式碼說明

  1. 自定義錯誤列舉:定義了一個名為 MyError 的列舉,包含兩個可能的錯誤變體:ParseErrorIOError
  2. 實作 std::error::Error 特性:按照慣例,Rust 中的錯誤型別實作了標準函式庫中的 Error 特性。
  3. 實作 fmt::Display 特性:為了提供人類可讀的錯誤訊息,需要實作 Display 特性。
  4. 使用自定義錯誤型別:修改了 square 函式以傳回 Result<i32, MyError>,並使用 map_err 將不同型別的錯誤對映到自定義的 MyError 列舉變體。

透過使用自定義錯誤型別,我們能夠有效地封裝和管理多種型別的錯誤,使程式碼更加清晰和健壯。