Bỏ qua để đến nội dung

Xử lý lỗi trong Rust với anyhow và thiserror

Ở các trang Result và Lỗi tự định nghĩa, bạn đã tự tay viết kiểu lỗi: một enum, một impl Display, có thể thêm một impl From để ? chạy được. Đó là cách học đúng đắn. Nhưng trong một dự án thật, bạn sẽ phải viết đi viết lại cùng một đống code lặp lại (boilerplate).

Hai crate nhỏ, cùng một tác giả, loại bỏ đống code lặp lại đó. Chúng giải quyết hai vấn đề khác nhau:

  • thiserror giúp bạn định nghĩa kiểu lỗi. Bạn vẫn viết enum, nhưng một #[derive] sẽ viết Display, Error và From giúp bạn.
  • anyhow giúp bạn truyền lỗi đi. Nó cho bạn một kiểu lỗi chứa được bất kỳ lỗi nào, cùng những cách dễ dàng để thêm một thông điệp như “trong lúc nạp file cấu hình”.

Chọn cái nào tuỳ vào ai là người xử lý lỗi.

Một thư viện (library) là code người khác gọi. Nơi gọi có thể muốn phản ứng khác nhau với từng loại thất bại: thử lại khi hết thời gian chờ, báo lỗi biểu mẫu khi đầu vào sai. Vì vậy thư viện nên trả về một enum chính xác mà nơi gọi có thể match. Đó là thiserror.

Một ứng dụng (application, tức main.rs của bạn) thường là trạm cuối. Phần lớn lỗi kết thúc thành một thông báo cho con người đọc, có thể kèm mã thoát. Bạn hiếm khi match trên chúng; bạn muốn chúng dễ truyền lên bằng ? và dễ đọc. Đó là anyhow.

Quy tắc nhanh: thiserror cho code người khác gọi, anyhow cho tầng trên cùng của chương trình. Nhiều dự án dùng cả hai, như bạn sẽ thấy ở cuối trang này.

Terminal window
cargo add thiserror anyhow
[dependencies]
anyhow = "1"
thiserror = "2"

Mới làm quen với Cargo.toml? Xem Cargo và crate. Cả hai crate đều có sẵn trong Rust Playground, nên mọi nút “Try it Yourself” trên trang này đều chạy được.

Đặt #[derive(Debug, Error)] lên enum, rồi cho mỗi biến thể một thông báo #[error("...")]. Thông báo đó trở thành văn bản Display của biến thể. Bên trong nó, {0} là trường đầu tiên của tuple, còn {balance} là một trường có tên:

use thiserror::Error;
#[derive(Debug, Error)]
enum BankError {
#[error("không tìm thấy tài khoản `{0}`")]
NotFound(String),
#[error("không đủ tiền: số dư là {balance}, muốn rút {amount}")]
InsufficientFunds { balance: u32, amount: u32 },
#[error("số tiền phải lớn hơn 0")]
ZeroAmount,
}
fn main() {
let errors = vec![
BankError::NotFound(String::from("alice")),
BankError::InsufficientFunds { balance: 50, amount: 80 },
BankError::ZeroAmount,
];
for e in &errors {
println!("{e}");
}
println!("{:?}", errors[1]);
}

Kết quả:

không tìm thấy tài khoản `alice`
không đủ tiền: số dư là 50, muốn rút 80
số tiền phải lớn hơn 0
InsufficientFunds { balance: 50, amount: 80 }
▶ Tự chạy thử

Đây chính là enum ở trang Lỗi tự định nghĩa, nhưng cả khối impl Display đã biến mất. Không có gì bị giấu lúc chạy: thiserror chỉ viết đúng đoạn code mà lẽ ra bạn tự viết tay. Nơi gọi thấy một enum bình thường.

Lỗi thường có một nguyên nhân: “cổng không hợp lệ” bởi vì “invalid digit found in string” (có ký tự không phải chữ số). Trait Error chuẩn có phương thức .source() cho việc này. thiserror điền nó giúp bạn.

#[from] làm hai việc. Nó đánh dấu trường đó là nguồn lỗi (source), và viết một impl From, nhờ đó ? tự động chuyển lỗi bên trong thành biến thể của bạn:

use std::error::Error; // đưa phương thức .source() vào phạm vi
use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]
enum ConfigError {
#[error("thiếu `=` trong dòng {0:?}")]
NoEquals(String),
#[error("cổng không phải là số hợp lệ")]
BadPort(#[from] ParseIntError),
}
fn parse_port(line: &str) -> Result<u16, ConfigError> {
let (_, value) = line
.split_once('=')
.ok_or_else(|| ConfigError::NoEquals(line.to_string()))?;
let port = value.trim().parse::<u16>()?; // ParseIntError trở thành ConfigError::BadPort
Ok(port)
}
fn main() {
for line in ["port = 8080", "port 8080", "port = tám mươi"] {
match parse_port(line) {
Ok(port) => println!("ok: {port}"),
Err(e) => {
println!("lỗi: {e}");
if let Some(cause) = e.source() {
println!(" nguyên nhân: {cause}");
}
}
}
}
}

Kết quả:

ok: 8080
lỗi: thiếu `=` trong dòng "port 8080"
lỗi: cổng không phải là số hợp lệ
nguyên nhân: invalid digit found in string
▶ Tự chạy thử

Dòng “invalid digit found in string” là thông báo tiếng Anh của chính thư viện chuẩn, nên nó không đổi.

Đôi khi bạn muốn có nguồn lỗi nhưng không muốn From tự động, vì bạn cần thêm dữ liệu, như số dòng. Khi đó hãy dùng #[source] trên trường, rồi tự dựng lỗi bằng .map_err(). (Một trường đặt tên đúng là source cũng tự động được coi là nguồn lỗi.) thiserror cũng dùng được với struct, không chỉ enum:

use std::error::Error;
use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]
#[error("dòng {line} không chứa một con số")]
struct LineError {
line: usize,
#[source]
cause: ParseIntError,
}
fn sum_lines(text: &str) -> Result<i32, LineError> {
let mut total = 0;
for (i, row) in text.lines().enumerate() {
let n: i32 = row
.trim()
.parse()
.map_err(|cause| LineError { line: i + 1, cause })?;
total += n;
}
Ok(total)
}
fn main() {
println!("tổng: {}", sum_lines("10\n20\n30").unwrap());
let err = sum_lines("10\nhai mươi\n30").unwrap_err();
println!("{err}");
println!("nguyên nhân: {}", err.source().unwrap());
}

Kết quả:

tổng: 60
dòng 2 không chứa một con số
nguyên nhân: invalid digit found in string
▶ Tự chạy thử

anyhow::Result<T> là cách viết tắt của Result<T, anyhow::Error>. Một anyhow::Error chứa được bất kỳ kiểu lỗi nào cài đặt trait Error chuẩn. Vì vậy ? dùng được với tất cả, kể cả khi một hàm gặp nhiều kiểu lỗi khác nhau:

use anyhow::Result;
// Hai kiểu lỗi khác nhau: ParseIntError và ParseFloatError.
// anyhow::Result nhận cả hai qua `?`.
fn total_price(quantity: &str, price: &str) -> Result<f64> {
let quantity: u32 = quantity.trim().parse()?;
let price: f64 = price.trim().parse()?;
Ok(quantity as f64 * price)
}
fn main() {
for (quantity, price) in [("3", "2.50"), ("ba", "2.50"), ("3", "rẻ")] {
match total_price(quantity, price) {
Ok(total) => println!("tổng: {total}"),
Err(e) => println!("lỗi: {e}"),
}
}
}

Kết quả:

tổng: 7.5
lỗi: invalid digit found in string
lỗi: invalid float literal
▶ Tự chạy thử

Không có anyhow, bạn sẽ cần một enum riêng với hai biến thể, hoặc một Box<dyn Error>. anyhow giống như một Box<dyn Error> dễ chịu hơn. Nhưng hãy để ý vấn đề: “invalid digit found in string” (ký tự không phải chữ số) không cho biết giá trị nào bị sai. Đó là lúc cần đến ngữ cảnh (context).

Import trait Context, và mọi Result sẽ có thêm hai phương thức:

  • .context("thông điệp") bọc lỗi bằng một thông điệp cố định.
  • .with_context(|| format!(...)) nhận một closure. Nó chỉ dựng thông điệp khi thật sự có lỗi, nên hãy dùng nó khi thông điệp cần format!.

Mỗi lớp ngữ cảnh thêm một mắt xích vào chuỗi lỗi (error chain). Cách bạn in lỗi quyết định bạn thấy được bao nhiêu phần của chuỗi:

use anyhow::{Context, Result};
fn parse_port(text: &str) -> Result<u16> {
let port: u16 = text
.parse()
.with_context(|| format!("`{text}` không phải là cổng hợp lệ"))?;
Ok(port)
}
fn load_settings(port_text: &str) -> Result<u16> {
let port = parse_port(port_text).context("không nạp được thiết lập")?;
Ok(port)
}
fn main() {
let err = load_settings("99999").unwrap_err();
println!("{err}"); // chỉ thông điệp ngoài cùng
println!("{err:#}"); // cả chuỗi trên một dòng
for (i, cause) in err.chain().enumerate() {
println!("{i}: {cause}");
}
}

Kết quả:

không nạp được thiết lập
không nạp được thiết lập: `99999` không phải là cổng hợp lệ: number too large to fit in target type
0: không nạp được thiết lập
1: `99999` không phải là cổng hợp lệ
2: number too large to fit in target type
▶ Tự chạy thử

Mắt xích cuối, “number too large to fit in target type” (số quá lớn so với kiểu đích), là thông báo tiếng Anh của thư viện chuẩn.

Định dạng thứ ba là {:?}. Nó in ra một báo cáo nhiều dòng, đúng thứ bạn thấy khi main trả về một lỗi:

println!("{err:?}");
không nạp được thiết lập
Caused by:
0: `99999` không phải là cổng hợp lệ
1: number too large to fit in target type

Dòng “Caused by:” (nguyên nhân) do anyhow in ra bằng tiếng Anh. Nếu biến môi trường RUST_BACKTRACE=1 được đặt, {:?} còn thêm một stack backtrace cho thấy lỗi được tạo ra ở đâu. Xem Biến môi trường và tiến trình.

Một cách dùng thực tế điển hình là đọc file, khi thông báo của hệ điều hành (“No such file or directory”) không bao giờ nói đó là file nào:

let text = std::fs::read_to_string(path)
.with_context(|| format!("không đọc được file cấu hình {path}"))?;

Đôi khi chẳng có lỗi gốc nào cả. Bạn chỉ đơn giản quyết định rằng có gì đó không ổn. anyhow có ba macro cho việc này:

  • bail!("...") trả về một lỗi ngay lập tức. Nó là viết tắt của return Err(anyhow!("...")).
  • ensure!(condition, "...") trả về một lỗi nếu điều kiện là false.
  • anyhow!("...") tạo một giá trị lỗi mà không trả về.

Cả ba đều nhận thông điệp theo kiểu format!.

use anyhow::{anyhow, bail, ensure, Result};
fn withdraw(balance: u32, amount: u32) -> Result<u32> {
ensure!(amount > 0, "số tiền phải lớn hơn 0");
if amount > balance {
bail!("không đủ tiền: số dư là {balance}, muốn rút {amount}");
}
Ok(balance - amount)
}
fn find_user(id: u32) -> Result<&'static str> {
match id {
1 => Ok("Ferris"),
_ => Err(anyhow!("không có người dùng nào có id {id}")),
}
}
fn main() {
println!("{:?}", withdraw(100, 30).ok());
for (balance, amount) in [(100, 0), (100, 250)] {
if let Err(e) = withdraw(balance, amount) {
println!("lỗi: {e}");
}
}
println!("{}", find_user(1).unwrap());
println!("lỗi: {}", find_user(7).unwrap_err());
}

Kết quả:

Some(70)
lỗi: số tiền phải lớn hơn 0
lỗi: không đủ tiền: số dư là 100, muốn rút 250
Ferris
lỗi: không có người dùng nào có id 7
▶ Tự chạy thử

Chỗ phổ biến nhất bạn gặp anyhow là đây: main trả về anyhow::Result<()>, và bạn dùng ? ở khắp nơi. Nếu một lỗi lên tới main, Rust in nó bằng {:?} ra stderr và thoát với mã 1:

use anyhow::{Context, Result};
fn main() -> Result<()> {
let text = "bốn mươi hai";
let n: i32 = text
.parse()
.with_context(|| format!("không đọc được {text:?} thành một con số"))?;
println!("{n}");
Ok(())
}
▶ Tự chạy thử

Không có gì được in ra stdout. Trên stderr bạn sẽ thấy (các chữ Error: và Caused by: do Rust và anyhow in bằng tiếng Anh):

Error: không đọc được "bốn mươi hai" thành một con số
Caused by:
invalid digit found in string

Đây là hình dạng thường gặp của một dự án thật. Module inventory đóng vai một thư viện: nó trả về một InventoryError chính xác, làm bằng thiserror. Code ứng dụng dùng anyhow, thêm ngữ cảnh, và chỉ nhìn vào bên trong lỗi ở những chỗ nó thật sự quan tâm. downcast_ref hỏi một anyhow::Error rằng “bên trong bạn có phải là kiểu này không?”, và vẫn chạy được dù đã thêm ngữ cảnh:

// ----- phần "thư viện": lỗi chính xác với thiserror -----
mod inventory {
use thiserror::Error;
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("không có mặt hàng `{0}`")]
UnknownItem(String),
#[error("chỉ còn {available}, không lấy được {wanted}")]
NotEnough { available: u32, wanted: u32 },
}
pub struct Store {
pub apples: u32,
}
impl Store {
pub fn take(&mut self, item: &str, wanted: u32) -> Result<(), InventoryError> {
if item != "táo" {
return Err(InventoryError::UnknownItem(item.to_string()));
}
if wanted > self.apples {
return Err(InventoryError::NotEnough { available: self.apples, wanted });
}
self.apples -= wanted;
Ok(())
}
}
}
// ----- phần "ứng dụng": anyhow + ngữ cảnh -----
use anyhow::{Context, Result};
use inventory::{InventoryError, Store};
fn process_order(store: &mut Store, id: u32, item: &str, amount: u32) -> Result<()> {
store
.take(item, amount)
.with_context(|| format!("đơn hàng #{id} thất bại"))?;
println!("đơn hàng #{id} đã xong");
Ok(())
}
fn main() {
let mut store = Store { apples: 5 };
let orders = [(1, "táo", 2), (2, "mận", 1), (3, "táo", 9)];
for (id, item, amount) in orders {
if let Err(e) = process_order(&mut store, id, item, amount) {
println!("lỗi: {e:#}");
// Ta vẫn nhìn được vào bên trong để lấy kiểu thiserror gốc:
if let Some(InventoryError::NotEnough { available, .. }) =
e.downcast_ref::<InventoryError>()
{
println!(" gợi ý: hãy đặt {available} hoặc ít hơn");
}
}
}
println!("táo còn lại: {}", store.apples);
}

Kết quả:

đơn hàng #1 đã xong
lỗi: đơn hàng #2 thất bại: không có mặt hàng `mận`
lỗi: đơn hàng #3 thất bại: chỉ còn 3, không lấy được 9
gợi ý: hãy đặt 3 hoặc ít hơn
táo còn lại: 3
▶ Tự chạy thử

Quên import Context. .context() là phương thức của trait anyhow::Context, mà phương thức của trait chỉ dùng được khi trait nằm trong phạm vi:

use anyhow::Result;
fn parse(text: &str) -> Result<i32> {
let n: i32 = text.parse().context("not a number")?;
Ok(n)
}
error[E0599]: no method named `context` found for enum `Result<T, E>` in the current scope
--> src/main.rs:4:31
|
4 | let n: i32 = text.parse().context("not a number")?;
| ^^^^^^^
|
= help: items from traits can only be used if the trait is in scope
help: trait `Context` which provides `context` is implemented but not in scope; perhaps you want to import it
|
1 + use anyhow::Context;
|

Rustc báo không tìm thấy phương thức context, vì trait Context chưa nằm trong phạm vi, và gợi ý import nó. (Thông điệp "not a number" được để tiếng Anh vì dòng đó xuất hiện nguyên văn trong thông báo lỗi.) Cách sửa: use anyhow::{Context, Result};.

Lặp lại thông báo của nguồn lỗi trong thông báo của bạn. Với #[from], lỗi bên trong đã là nguồn lỗi. Nếu bạn còn đặt {0} vào thông báo, một báo cáo đầy đủ sẽ in nó hai lần:

#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("cổng không hợp lệ: {0}")] // chứa văn bản của nguồn lỗi...
BadPort(#[from] ParseIntError), // ...và đây cũng là nguồn lỗi
}

In bằng {:#} qua anyhow, đoạn này cho ra cổng không hợp lệ: invalid digit found in string: invalid digit found in string. Cách sửa: hoặc viết #[error("cổng không hợp lệ")] và để chuỗi lỗi hiện nguyên nhân, hoặc giữ {0} và đừng đánh dấu trường đó là nguồn lỗi. Chọn một trong hai.

In bằng {} và mất nguyên nhân. println!("{e}") trên một anyhow::Error chỉ hiện thông điệp ngoài cùng, như “không nạp được thiết lập”. Khi in lỗi cho con người đọc, hãy dùng {:#} (một dòng) hoặc {:?} (báo cáo đầy đủ).

Dùng anyhow trong API công khai của thư viện. Nếu thư viện của bạn trả về anyhow::Result, nơi gọi không match được trên lỗi của bạn. Họ sẽ phải đoán kiểu bằng downcast_ref. Hãy cho các hàm công khai một enum thiserror, và để dành anyhow cho ứng dụng, test và ví dụ.

  • thiserror dùng để định nghĩa lỗi: #[derive(Error)] cộng một thông báo #[error("...")] cho mỗi biến thể thay thế các impl Display và impl Error viết tay.
  • #[from] cho ? chuyển lỗi bên trong thành biến thể của bạn và ghi nó làm nguồn lỗi. #[source] ghi nguồn lỗi mà không có phép chuyển đổi.
  • anyhow dùng để truyền lỗi lên: anyhow::Result<T> nhận mọi lỗi qua ?.
  • .context() và .with_context() thêm một lớp dễ đọc vào chuỗi lỗi. In chuỗi lỗi bằng {:#} (một dòng) hoặc {:?} (báo cáo).
  • bail! trả về lỗi ngay, ensure! trả về lỗi nếu điều kiện sai, còn anyhow! tạo ra một lỗi.
  • Cách bố trí thường gặp: thiserror trong code thư viện, anyhow trong main.

Kiểm tra nhanh

1. Bạn đang viết một thư viện, và nơi gọi cần phản ứng khác nhau với lỗi hết thời gian chờ và lỗi đầu vào sai. Crate nào phù hợp nhất?

2. #[from] trên trường của một biến thể làm gì?

3. Định dạng nào in một anyhow::Error cùng cả chuỗi lỗi trên một dòng?

4. Khi nào nên dùng .with_context(|| ...) thay cho .context(...)?

5. ensure!(amount > 0, "số tiền phải dương") làm gì khi amount bằng 0?

Điểm: 0 / 5