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:
thiserrorgiúp bạn định nghĩa kiểu lỗi. Bạn vẫn viết enum, nhưng một#[derive]sẽ viếtDisplay,ErrorvàFromgiúp bạn.anyhowgiú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”.
Thư viện và ứng dụng
Phần tiêu đề “Thư viện và ứng dụng”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.
Cài đặt
Phần tiêu đề “Cài đặt”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.
thiserror: derive Error và viết thông báo
Phần tiêu đề “thiserror: derive Error và viết thông báo”Đặ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 80số tiền phải lớn hơn 0InsufficientFunds { balance: 50, amount: 80 }Đâ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.
thiserror: #[from] và #[source]
Phần tiêu đề “thiserror: #[from] và #[source]”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 viuse 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: 8080lỗ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 stringDò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: 60dòng 2 không chứa một con sốnguyên nhân: invalid digit found in stringanyhow: một kiểu Result cho mọi thứ
Phần tiêu đề “anyhow: một kiểu Result cho mọi 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.5lỗi: invalid digit found in stringlỗi: invalid float literalKhô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).
anyhow: thêm ngữ cảnh và in chuỗi lỗi
Phần tiêu đề “anyhow: thêm ngữ cảnh và in chuỗi lỗi”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ầnformat!.
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ậpkhô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 type0: không nạp được thiết lập1: `99999` không phải là cổng hợp lệ2: number too large to fit in target typeMắ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 typeDò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}"))?;anyhow: bail!, ensure! và anyhow!
Phần tiêu đề “anyhow: bail!, ensure! và anyhow!”Đô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ủareturn 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 0lỗi: không đủ tiền: số dư là 100, muốn rút 250Ferrislỗi: không có người dùng nào có id 7Trả về anyhow::Result từ main
Phần tiêu đề “Trả về anyhow::Result từ main”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(())}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 stringDùng cả hai cùng nhau
Phần tiêu đề “Dùng cả hai cùng nhau”Đâ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 đã xonglỗ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ơntáo còn lại: 3Lỗi thường gặp
Phần tiêu đề “Lỗi thường gặp”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 scopehelp: 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ụ.
Tóm tắt
Phần tiêu đề “Tóm tắt”- 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ácimpl Displayvàimpl Errorviế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ònanyhow!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
Phần tiêu đề “Kiểm tra nhanh”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
Đọc thêm
Phần tiêu đề “Đọc thêm”- anyhow documentation — docs.rs (tiếng Anh): toàn bộ API, gồm
Context,chain()và downcast. - thiserror documentation — docs.rs (tiếng Anh): mọi thuộc tính, như
#[error(transparent)]và các trường backtrace. - The Context trait — anyhow (tiếng Anh):
contextvàwith_contexthoạt động thế nào, kèm ví dụ. - Error Handling — The Rust Book (tiếng Anh): phần nền tảng mà cả hai crate xây dựng trên đó.
- std::error::Error (tiếng Anh): trait chuẩn đứng sau
.source()và chuỗi lỗi.