Cơn đau đầu với ‘Result’ trong môi trường Production
Khi mới chuyển sang Rust, enum Result<T, E> mang lại cảm giác như một siêu năng lực. Nó buộc tôi phải đối mặt với các trường hợp thất bại ngay lập tức, một sự thay đổi mới mẻ so với logic ‘try-catch và cầu nguyện’ mà tôi từng dùng trong Python. Nhưng khi dự án lớn dần từ các script đơn giản thành microservices với Axum và Rust với hơn 50 module, tôi đã gặp bế tắc. Các function signature (chữ ký hàm) trở thành một mớ hỗn độn với các generic lồng nhau. Tôi dành nhiều thời gian để viết các triển khai From<OtherError> dài 30 dòng hơn là thực sự ra mắt tính năng mới.
Trong một môi trường production, việc xử lý lỗi lộn xộn không chỉ gây mất thẩm mỹ. Nếu bạn không cấu trúc lỗi đúng cách, cuối cùng bạn sẽ rơi vào cái bẫy sử dụng .unwrap() chỉ để làm trình biên dịch ‘im lặng’. Điều này biến file thực thi Rust ‘an toàn’ của bạn thành một rủi ro dễ gây crash. Để tránh điều này, tôi dựa vào hai crate tiêu chuẩn trong ngành: thiserror và anyhow. Chúng giúp giữ cho code sạch sẽ, giàu tính mô tả và — quan trọng nhất là — dễ debug vào lúc 2 giờ sáng khi một pod trên production gặp sự cố.
Chọn công cụ: thiserror đối đầu anyhow
Các lập trình viên Rust mới thường hỏi crate nào tốt hơn. Sự thật là chúng phục vụ các mục đích khác nhau. Cả hai đều được duy trì bởi David Tolnay, nhưng chúng giải quyết các phần khác nhau của bài toán xử lý lỗi.
thiserror: Định nghĩa hợp đồng (Contract)
Tôi sử dụng thiserror khi xây dựng các thư viện hoặc các module domain nội bộ. Đây là một procedural macro giúp tạo Error trait cho bạn. Hãy dùng nó khi bạn cần định nghĩa chính xác điều gì đã xảy ra để phía gọi hàm có thể phản ứng với các trường hợp cụ thể. Mục đích là tạo ra một API contract rõ ràng và có kiểu dữ liệu (typed).
anyhow: Xử lý luồng (Flow)
Tôi dành riêng anyhow cho cấp độ ứng dụng — như main.rs, các entry point của CLI, hoặc các request handler cấp cao. Nó được thiết kế cho các trường hợp bạn không quan tâm đến kiểu lỗi cụ thể; bạn chỉ cần báo cáo lỗi và thêm ngữ cảnh (context). Đây là công cụ tối thượng để xử lý hậu quả của một thất bại.
Thực hành: Xây dựng một Data Pipeline bền bỉ
Hãy xem xét một kịch bản phổ biến. Chúng ta cần một dịch vụ đọc cấu hình JSON, kết nối với cơ sở dữ liệu và xử lý hồ sơ người dùng. Mỗi bước — I/O, phân tách (parsing) và kết nối mạng — đều có thể thất bại theo những cách riêng.
Bước 1: Cấu hình
Đầu tiên, hãy cập nhật Cargo.toml của bạn với các dependency sau:
[dependencies]
thiserror = "1.0"
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Bước 2: Định nghĩa các Domain Error
Thay vì sử dụng các chuỗi generic, chúng ta sẽ định nghĩa một enum có cấu trúc cho module dữ liệu. Điều này cho các lập trình viên khác biết chính xác những lỗi nào có thể xảy ra.
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DataError {
#[error("Thiếu file cấu hình tại: {0}")]
NotFound(String),
#[error("Định dạng JSON trong file cấu hình bị sai")]
InvalidFormat(#[from] serde_json::Error),
#[error("Kết nối cơ sở dữ liệu hết hạn sau 30 giây: {0}")]
ConnectionError(String),
#[error("Phát hiện dữ liệu nội bộ bị hỏng")]
Unknown,
}
Attribute #[from] giúp tiết kiệm rất nhiều thời gian. Nó tự động tạo triển khai From<serde_json::Error>. Điều này cho phép toán tử ? tự động chuyển đổi lỗi JSON thành DataError tùy chỉnh của bạn. Bạn vừa tiết kiệm được 15 dòng code boilerplate.
Bước 3: Thêm ngữ cảnh với anyhow
Bây giờ, hãy triển khai logic. Chúng ta sẽ sử dụng anyhow ở đây vì nó cho phép đính kèm “ngữ cảnh” (context) vào lỗi. Đây là sự khác biệt giữa một log ghi “File not found” và một log cho bạn biết chính xác file nào bị thiếu và tại sao ứng dụng lại cố gắng đọc nó.
use anyhow::{Context, Result};
use std::fs;
fn load_config(path: &str) -> Result<String> {
let content = fs::read_to_string(path)
.with_context(|| format!("Lỗi nghiêm trọng: Không thể tải cấu hình từ {}", path))?;
Ok(content)
}
fn process_data() -> Result<()> {
let config = load_config("settings.json")?;
// Logic tiếp theo tại đây...
Ok(())
}
Nếu load_config thất bại, anyhow sẽ tạo ra một chuỗi lỗi chi tiết. Bạn sẽ thấy: “Lỗi nghiêm trọng: Không thể tải cấu hình từ settings.json: No such file or directory (os error 2)”. Mức độ chi tiết này là cứu cánh khi debug các hệ thống phân tán.
Quản lý lỗi lồng nhau
Một sai lầm thường gặp là làm mất nguyên nhân gây lỗi ban đầu khi bọc (wrap) các Result. Bằng cách triển khai Result Pattern và kết hợp cả hai crate, bạn có thể bảo toàn toàn bộ stack trace. Trong microservices, những bug gây ức chế nhất là các thông báo “Internal Server Error” che giấu một lỗi timeout đơn giản trong một dependency cấp dưới.
#[derive(Error, Debug)]
pub enum ApiError {
#[error("Lỗi dịch vụ cấp dưới (downstream)")]
External(#[source] anyhow::Error),
#[error("Xác thực đầu vào người dùng thất bại: {0}")]
Validation(String),
}
fn call_third_party_api() -> anyhow::Result<()> {
Err(anyhow::anyhow!("Lỗi Gateway Timeout"))
}
fn handle_request() -> Result<(), ApiError> {
call_third_party_api().map_err(ApiError::External)?;
Ok(())
}
Bằng cách sử dụng #[source], bạn báo cho Rust rằng anyhow::Error là nguyên nhân gốc rễ. Sau đó, bạn có thể sử dụng phương thức .chain() của anyhow để lặp qua từng lớp lỗi nhằm xem chính xác lỗi bắt đầu từ đâu.
Giữ cho Data Pipeline luôn bền bỉ
Khi xử lý một lô 1.000 bản ghi, bạn hiếm khi muốn một mục lỗi làm hỏng toàn bộ quá trình. Tôi thích kết hợp Result với iterator để tách biệt các thành công và thất bại. Điều này giúp pipeline tiếp tục chạy trong khi vẫn ghi lại các lỗi vào log.
let paths = vec!["prod.json", "staging.json", "broken.json"];
let (successes, failures): (Vec<_>, Vec<_>) = paths
.into_iter()
.map(|p| load_config(p))
.partition(Result::is_ok);
let valid_configs: Vec<String> = successes.into_iter().map(Result::unwrap).collect();
let errors: Vec<anyhow::Error> = failures.into_iter().map(Result::unwrap_err).collect();
Mô hình này đảm bảo worker thread của bạn không bị panic. Bạn xử lý dữ liệu hợp lệ và chuyển 5% lỗi vào một dead-letter queue hoặc một dịch vụ logging để kiểm tra sau.
Lời kết
Sự lựa chọn giữa thiserror và anyhow không phải là cái nào tốt hơn; mà là về ngữ cảnh. Nếu bạn đang viết một thư viện để người khác import, hãy dùng thiserror để cung cấp các lỗi có kiểu rõ ràng. Nếu bạn đang viết code ‘keo dính’ (glue code) cho một ứng dụng, hãy dùng anyhow để giữ cho mọi thứ gọn gàng và log của bạn giàu thông tin.
Việc áp dụng các công cụ này giúp codebase Rust trở nên dễ đọc hơn đáng kể. Bạn sẽ ngừng việc vật lộn với mối quan hệ của borrow checker với các lỗi và bắt đầu sử dụng hệ thống kiểu dữ liệu để xây dựng phần mềm đáng tin cậy hơn. Hãy thử thay thế một vài khối match bằng anyhow::Context ngay hôm nay — chính bạn trong tương lai sẽ cảm ơn điều đó khi bug tiếp theo xuất hiện.

