thiserror hay anyhow: ranh giới nằm ở ranh giới thư viện
Mọi cuộc bàn luận về xử lý lỗi trong Rust rốt cuộc đều đẻ ra câu “dùng thiserror cho thư viện và
anyhow cho ứng dụng”. Câu đó đúng và vô ích, vì phần lớn code chẳng rõ ràng thuộc bên nào.
Phiên bản tôi dùng bây giờ sắc hơn: bên gọi có làm được điều gì khác đi tùy theo đây là lỗi nào không?
Nếu có, họ cần những kiểu riêng biệt để so khớp — thiserror. Nếu không, họ sẽ ghi log rồi bỏ cuộc,
và tất cả những gì họ cần là một thông điệp tử tế — anyhow.
Mỗi cái cho bạn thứ gì
thiserror là một macro derive để dựng enum lỗi của riêng bạn. Nó viết hộ phần cài đặt Display và
Error cùng các phép chuyển đổi From, và tạo ra một kiểu thuần không tốn gì lúc chạy:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("không tìm thấy file cấu hình tại {path}")]
NotFound { path: PathBuf },
#[error("TOML không hợp lệ ở dòng {line}")]
Malformed { line: usize, #[source] source: toml::de::Error },
#[error(transparent)]
Io(#[from] std::io::Error),
}
Bên gọi so khớp được:
match load_config(&path) {
Err(ConfigError::NotFound { .. }) => Config::default(), // cứu được
Err(e) => return Err(e),
Ok(config) => config,
}
anyhow đi hướng ngược lại. anyhow::Error là một kiểu đóng hộp duy nhất chứa bất kỳ lỗi nào, cộng
thêm một chuỗi các chuỗi ngữ cảnh:
use anyhow::{Context, Result};
fn start() -> Result<()> {
let config = load_config(&path)
.context("đang nạp cấu hình")?;
let db = connect(&config.database_url)
.with_context(|| format!("đang kết nối tới {}", config.database_url))?;
Ok(())
}
Khi cái đó hỏng, thông điệp là một câu chuyện chứ không phải một triệu chứng:
Error: đang kết nối tới postgres://localhost/app
Caused by:
0: connection refused
1: os error 61
Toán tử ? tự động chuyển bất cứ thứ gì cài đặt Error thành anyhow::Error, và đó là lý do code
dùng anyhow có rất ít phần xử lý lỗi trong đó.
Thói quen .context() mới là cái lợi thật
Tôi muốn lập luận rằng lợi ích thực tế lớn nhất của anyhow chẳng liên quan gì đến cái kiểu dữ liệu.
Nó nằm ở chỗ .context() rẻ đến mức bạn thật sự chịu viết nó.
Một lỗi ghi No such file or directory (os error 2) thì gần như vô dụng. Đúng lỗi đó với ba tầng
ngữ cảnh cho bạn biết file nào, thao tác nào, và nó thuộc về request nào. Tôi thêm ngữ cảnh ở mọi ?
vượt qua một ranh giới có ý nghĩa, và việc gỡ lỗi trong môi trường thật đi từ đoán mò sang đọc hiểu.
Mẹo
Hãy dùng with_context(|| …) thay vì context(…) khi thông điệp cần định dạng. context() tính
tham số của nó ngay lập tức, nên dựng một String ở đó tốn một lần cấp phát ở mọi lời gọi, kể cả
những lời gọi thành công. Bản dùng closure chỉ chạy trên nhánh lỗi.
Ranh giới thật sự nằm ở đâu
Cách phân chia thư viện với ứng dụng đổ vỡ vì một ứng dụng có những module nội bộ hành xử như thư viện. Câu hỏi vẫn luôn dùng được:
thiserror khi bên gọi rẽ nhánh dựa trên lỗi. Một bộ phân tích cú pháp trả về “sai định dạng ở
dòng 12” so với “phiên bản không được hỗ trợ” — bên gọi hiện giao diện khác nhau. Một lớp mạng phân
biệt “mất mạng” với “chưa xác thực” — một cái thử lại, một cái đăng xuất bạn ra.
anyhow khi bên gọi chỉ chuyển lỗi đi tiếp. main, các handler request, các lệnh CLI, bất cứ
thứ gì mà nhánh lỗi là “ghi log rồi trả về khác không”.
Trong thực tế, một dịch vụ rốt cuộc dùng cả hai: các enum thiserror trong những module mô hình hóa
các kiểu hỏng thật, và anyhow ở tầng ghép chúng lại.
// module nghiệp vụ: bên gọi quan tâm
#[derive(Error, Debug)]
pub enum AuthError {
#[error("thông tin đăng nhập không đúng")]
InvalidCredentials,
#[error("tài khoản bị khóa tới {0}")]
Locked(DateTime<Utc>),
}
// handler: bên gọi không quan tâm
async fn login(form: Form<Login>) -> anyhow::Result<Response> {
let session = auth::login(&form.email, &form.password)
.await
.context("đăng nhập")?;
Ok(session.into_response())
}
Đừng đưa anyhow vào một API công khai
Đây là quy tắc cứng duy nhất. anyhow::Error trong chữ ký công khai của một thư viện lấy đi khả năng
xử lý bất cứ thứ gì cụ thể của bên gọi — họ nhận được một thông điệp và không gì khác. Nó cũng lôi
anyhow vào cây phụ thuộc của họ dù họ có muốn hay không.
Trình biên dịch sẽ không ngăn bạn và nó sẽ khiến thư viện của bạn khó chịu khi dùng. Nếu một hàm công khai có thể hỏng theo những cách mà bên gọi có thể xử lý khác nhau, hãy cho họ một kiểu dữ liệu.
Hai điều đáng biết
anyhow đòi Send + Sync + 'static. Phần lớn lỗi thỏa mãn điều này. Những lỗi giữ một kiểu
không Send thì không, và thông báo lỗi khi nó hỏng thì nói về các ràng buộc trait chứ không nói về
lỗi của bạn, khá là rối trí ở lần đầu.
Backtrace miễn phí nhưng cần bật lên. anyhow tự động chụp một cái nếu phần hỗ trợ backtrace của
thư viện chuẩn có sẵn; hãy đặt RUST_BACKTRACE=1 để thấy nó. Trên một máy chủ, đây là khác biệt giữa
“có gì đó hỏng trong module auth” và một dòng chính xác.
Thứ tôi chốt lại
Với các dịch vụ của riêng tôi: thiserror trong từng module nghiệp vụ, anyhow từ tầng handler trở
lên, .context() trên mọi ? vượt qua ranh giới module, và anyhow::bail! cho những lỗi kiểm tra
hợp lệ lẻ tẻ không đáng có một biến thể riêng.
Tổ hợp đó mất khoảng một ngày để áp lên cả codebase và tự hoàn vốn ngay lần đầu có thứ gì đó hỏng lúc ba giờ sáng còn log thì nói đúng chính xác chuyện gì đã xảy ra.