Có một thời gian mình viết Rust mà chỗ nào cũng .unwrap(). Test xanh hết, tới lúc deploy gặp file config sai đường dẫn một cái là service panic rồi restart liên tục. Lúc đó mình mới ngộ ra: Rust không ép mình xử lý lỗi, nó chỉ ép mình nhìn thấy lỗi. Nhìn thấy rồi mà vẫn unwrap bừa thì lỗi là ở mình.
Result không phải thủ tục, nó là tài liệu
Chữ ký hàm trong Rust nói thẳng cho bạn biết nó có thể thất bại:
fn doc_config(path: &str) -> Result<Config, ConfigError> {
let raw = std::fs::read_to_string(path)?;
let cfg: Config = toml::from_str(&raw)?;
Ok(cfg)
}
Đọc chữ ký là biết ngay: hàm này đọc file, có thể lỗi I/O, có thể lỗi parse. Không cần đọc docs, không cần đoán. So với việc một hàm Java ném exception ngầm, hay hàm Go trả (Config, error) rồi có người quên check — ở đây compiler làm chứng thay bạn.
Dấu ? thực ra làm gì
Nhiều bạn dùng ? mỗi ngày mà không biết nó expand thành gì. Nó gần tương đương đoạn này:
let raw = match std::fs::read_to_string(path) {
Ok(v) => v,
Err(e) => return Err(ConfigError::from(e)),
};
Hai điểm quan trọng, và cũng là hai chỗ hay bị hiểu sai:
- Nó
returnsớm, nhường luôn phần còn lại của hàm cho caller. - Nó gọi
From::fromđể chuyển kiểu lỗi. Nên nếu hàm bạn trảResult<_, ConfigError>mà bên trong gọi hàm trảstd::io::Error, bạn cầnimpl From<std::io::Error> for ConfigError.thiserrorsinh đoạn impl đó gần như miễn phí:
#[derive(thiserror::Error, Debug)]
enum ConfigError {
#[error("không đọc được file: {0}")]
Io(#[from] std::io::Error),
#[error("config sai định dạng: {0}")]
Parse(#[from] toml::de::Error),
}
Library thì thiserror, app thì anyhow
Quy tắc mình theo khá đơn giản: code dùng lại — library, crate nội bộ — thì dùng thiserror, khai báo error type rõ ràng để người gọi còn match mà xử lý riêng từng loại. Còn binary, CLI, service thì dùng anyhow, vì mình chỉ cần lỗi kèm đủ ngữ cảnh để đọc log là hiểu chuyện gì xảy ra.
use anyhow::{Context, Result};
fn load(path: &Path) -> Result<Config> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("đọc config tại {} thất bại", path.display()))?;
let cfg = toml::from_str(&raw)
.with_context(|| format!("parse config tại {} thất bại", path.display()))?;
Ok(cfg)
}
Log in ra sẽ là đọc config tại /etc/app/prod.toml thất bại: No such file or directory. So với called Result::unwrap() on an Err value: Os { code: 2, kind: NotFound } thì đúng là một trời một vực, nhất là lúc 3 giờ sáng.
unwrap/expect khi nào còn chấp nhận được
Không phải cứ thấy unwrap là có tội. Mình vẫn dùng nó trong mấy trường hợp:
- Trong test và example — panic ở đây là hành vi đúng, mình muốn test đỏ ngay lập tức.
- Khi lỗi là bất khả thi về mặt logic, kiểu parse một chuỗi hằng số do mình vừa viết ra.
- Lúc khởi động, thiếu config là phải chết ngay. Sai thì chết sớm còn hơn chạy nửa vời rồi lỗi rải rác về sau.
Nhưng nếu đã unwrap thì dùng expect("lý do") thay vì unwrap(). Khi panic xảy ra, message của expect là thứ duy nhất bạn có trong log. expect("REDIS_URL chưa được set") đọc phát hiểu liền, còn unwrap() thì chỉ có dòng stack trace vô nghĩa.
Lỗi người dùng nhập thì đừng panic
Đây là chỗ mình từng làm sai. API phải trả về 400 kèm message rõ ràng khi JSON sai field, chứ không được unwrap() rồi để cả process chết theo một request. Với web framework như axum, chỉ cần implement IntoResponse cho error type:
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, msg) = match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "không tìm thấy"),
ApiError::BadInput(m) => (StatusCode::BAD_REQUEST, m),
ApiError::Internal(e) => {
tracing::error!("lỗi nội bộ: {e:#}");
(StatusCode::INTERNAL_SERVER_ERROR, "có lỗi xảy ra")
}
};
(status, msg).into_response()
}
}
Một nguyên tắc nhỏ mà mình thấy cứu được nhiều buổi tối: chi tiết lỗi đi vào log, thông điệp trả cho người dùng thì ngắn và an toàn. Đừng để lộ đường dẫn file hay câu query ra ngoài.
Kết
Rust không khó vì borrow checker. Nó khó vì buộc mình phải quyết định rõ ràng: lỗi này là bất khả thi, hay là chuyện chắc chắn sẽ xảy ra lúc hệ thống đang cao điểm. Quyết định xong rồi thì code tự nhiên sạch, và mấy lần service restart lúc nửa đêm cũng biến mất theo.
📋 Phụ lục thuật ngữ
| Thuật ngữ | Nghĩa |
|---|---|
Result<T, E> |
Kiểu trả về biểu diễn thành công Ok(T) hoặc thất bại Err(E) — Rust không có exception |
panic! |
Dừng chương trình không phục hồi; dùng cho lỗi lập trình, không dùng cho lỗi nghiệp vụ |
Dấu ? |
Return sớm khi gặp Err, tự chuyển kiểu lỗi qua trait From — hàm gọn mà không mất lỗi |
thiserror |
Crate sinh impl Error/From cho enum error tự định nghĩa — hợp cho library |
anyhow |
Crate error động, có context() để thêm ngữ cảnh — hợp cho binary và service |
IntoResponse |
Trait của axum cho phép error type tự chuyển thành HTTP response |