Tận dụng phong cách xử trí lỗi của Rust trong lập trình web

791

Bài viết được sự cho phép của tác giả Nguyễn Hồng Quân

Gần đây, tôi chuyển đổi website này sang viết bằng Rust và rất tâm đắc với phong cách xử trí lỗi (error handling) của Rust, khi ứng dụng vào việc viết web. tôi sẽ trình bày tại sao.

Trước Rust, hầu hết các ngôn ngữ lập trình tôi kinh qua đều dùng phong cách xử trí lỗi là exception handling. Một hàm đang chạy nửa chừng, nếu gặp lỗi sẽ bắn ra một exception và dừng ngay tại đó. Hàm nào gọi nó bên ngoài sẽ dùng cấu trúc try ... excepttry ... catch để phòng bị, bắt những exception này và có hướng xử trí tương ứng khi exception xảy ra. Cách làm này có ưu điểm là không cần nghĩ nhiều, giúp lập trình viên làm nhanh, cho ra sản phẩm lẹ. Tuy nhiên nó có nhược điểm là nhìn vào signature (mô tả kiểu dữ liệu đầu vào và đầu ra) của một hàm, không có cách nào biết được hàm đó có thể bắn ra những exception nào. Rust thì khác, những lỗi nào có thể xảy ra sẽ buộc phải khai báo trong signature của hàm. Ví dụ nhìn signature của hàm dùng để parse một chuỗi thành số nguyên:

fn from_str(src: &str) -> Result<i8, ParseIntError>

ta biết ngay nếu thất bại (ví dụ parse chuỗi “z” thành số) thì hàm sẽ trả về một giá trị thuộc kiểu ParseIntError, từ đó giúp ta viết code cho phần xử trí lỗi nhanh hơn, tiện hơn, tự tin hơn (không sợ bỏ sót).

Ghi chú nhỏ: Nói một cách chính xác thì những hàm có kiểu trả về Result như trên không trả về giá trị mong muốn một cách trực tiếp, mà bọc trong một enum kiểu Result để chỉ thị trạng thái thành công hay thất bại. Ví dụ với hàm parse phía trên, nếu ta gọi i8::from_str("1") thì nhận được Ok(1), nếu gọi i8::from_str("z") thì nhận được Err(e) với e thuộc kiểu ParseIntError.

Đọc tới đây, người chưa viết Rust chắc sẽ thắc mắc: “Ủa, vậy là không làm nhanh được nhỉ. Ví dụ với phong cách exception, khi tôi đang định nghĩa một hàm cho người khác sử dụng, và gọi nhiều lớp hàm con, nếu một trong những hàm con bắn ra exception, và tôi muốn chuyển tiếp exception đó ra ngoài, thì tôi chẳng cần phải viết thêm code gì cả, cứ để mặc. Còn với Rust thì cứ phải kiểm tra giá trị trả về của hàm con, rồi dùng return Err(e) để trả ra bên ngoài”. Vâng, đúng là như vậy, nhưng Rust có một cú pháp để làm việc ấy một cách rất ngắn gọn, đó là dùng toán tử ?. Ví dụ xem đoạn code sau trong một sản phẩm khác của tôi (Duri):

fn main() -> eyre::Result<()> {
    let opts = Opts::parse();
    let l = opts.verbose.log_level_filter();
    Logger::try_with_str(l.as_str())?.start()?;
    color_eyre::install()?;
    // More code
}

Các bước Logger::try_with_str()logger.start() đều có thể xảy ra lỗi (ví dụ tên log level không đúng, thiết bị console chưa sẵn sàng v.v…). Khi ấy toán tử ? sẽ truyền những lỗi ấy lên hàm main() và ngừng hàm main ngay tại bước xảy ra lỗi. Chưa dừng tại đó, toán tử & còn có một tính năng thú vị nữa là tự động chuyển đổi kiểu lỗi, nếu lỗi trả về bởi hàm con khác kiểu với kiểu mà hàm cha mong muốn. Cũng ở ví dụ trên, hàm Logger::start() trả về lỗi FlexiLoggerError trong khi hàm main mong đợi lỗi thuộc kiểu eyre::Report và toán tử & sẽ làm thêm bước chuyển đổi. Tất nhiên, chuyển đổi như thế nào thì & không tự “chế” được mà phải nhờ vào việc một trong hai kiểu (nguồn và đích) kia phải implement trait From hoặc Into.

  Viết Reminder Parser dùng Rust

  Rust và Lập trình Web

Thế thì những điều kia liên quan gì tới web? Tôi nóng ruột quá!

Vâng, quay lại với chuyện lập trình web. Để sinh ra response trả về cho người dùng, server có thể phải làm các bước sau, và bước nào cũng có thể gặp lỗi:

  • Kiểm tra xem người dùng đã login chưa. Có thể gặp lỗi khi đang đọc session, từ Redis hay từ file (ví dụ Redis sập hay ổ đĩa bị lỗi).
  • Lôi dữ liệu từ database lên. Có thể gặp lỗi khi hệ thống database cấu hình không đúng, schema không khớp, hay bị treo.
  • Tính toán, xử lý dữ liệu trước khi trả về cho người dùng. Có thể gặp lỗi vì dữ liệu có định dạng không như mong muốn.
  • Dùng template để render dữ liệu. Có thể gặp lỗi vì đường dẫn file bị sai, hay code của template không phù hợp với dữ liệu.

Xem thêm các việc làm Web developer jobs hấp dẫn trên TopDev

Tất cả những lỗi trên, nếu không được bắt và xử trí thì nhẹ sẽ sinh ra nội dung mù mờ khó hiểu (người dùng chỉ thấy trang trắng với dòng chữ “500 Internal Server Error” hoặc “Connection Reset”) hoặc nặng thì sẽ sập luôn cả ứng dụng server. Nếu có bắt lỗi thì ta sẽ có hành xử tốt hơn:

  • Hiện thông báo để cáo lỗi người dùng, và giữ được giao diện chuẩn của trang web.
  • Ghi log để giúp lập trình viên điều tra nguyên nhân gây ra lỗi, để sửa code cho đúng.
  • Giữ cho server không bị sập, tiếp tục phục vụ các request khác.

Để đạt được điều trên thì ta nên thiết kế hàm handler (trả về response tương ứng với request) dưới dạng một hàm trả về Result, trong đó Ok tương ứng với response thành công, và Err tương ứng với response báo lỗi, kết hợp với toán tử ? để viết code ngắn hơn.

Thật may là đa số các framework web của Rust đều hỗ trợ các handler kiểu này. Dưới đây tôi sẽ minh họa với framework Axum.

Ví dụ source code của hàm dùng để trả về bài viết mà bạn đang xem:

pub async fn show_post(
	// Params...
) -> AxumResult<Html<String>> {
	// Code...
	let post = get_detailed_post_by_slug(slug, &db)
        .await
        .map_err(PageError::EdgeDBQueryError)?
        .ok_or((StatusCode::NOT_FOUND, "No post at this URL"))?;
	// Code...
}

Tôi khai báo kiểu trả về của hàm là AxumResult<Html<String>>, đây là cách viết tắt, dùng type alias, thực ra nó sẽ là Result<Html<String>, ErrorResponse>. Tức là nếu xảy ra lỗi, hàm cần trả về giá trị thuộc kiểu axum::response::ErrorResponse, để Axum tạo ra response cho người dùng.

Lưu ý rằng hàm handler trong Axum không nhất thiết phải có kiểu trả về Result<T, ErrorResponse>, nó được phép có kiểu trả về Response hay impl IntoResponse nữa. Trong bài này tôi tập trung vào <Result<T, ErrorResponse> để vừa tận dụng được toán tử ? cho code ngắn hơn và để luồng code trông tường minh, rạch ròi được trạng thái bình thường / lỗi.

Xem lại ví dụ show_post phía trên, tại dòng code:

let post = get_detailed_post_by_slug(slug, &db)
	.await
	.map_err(PageError::EdgeDBQueryError)?
	.ok_or((StatusCode::NOT_FOUND, "No post at this URL"))?;

tôi đã hai lần dùng ? để kiểm tra và trả về lỗi.

Đầu tiên, hàm get_detailed_post_by_slug có signature là:

async fn get_detailed_post_by_slug(slug: String, client: &Client) -> Result<Option<DetailedBlogPost>, edgedb_tokio::Error>

Đây là hàm lấy dữ liệu từ EdgeDB nên khi gặp lỗi, nó sẽ trả về lỗi thuộc kiểu edgedb_tokio::Error. Tuy nhiên edgedb_tokio::Error không được implement trait IntoResponse nên ta không dùng trực tiếp nó làm giá trị trả về cho handler show_post được. Ta sẽ dùng method map_err() để chuyển đổi nó qua kiểu lỗi trung gian PageError, là kiểu mà ta tự định nghĩa và thêm implement trait IntoResponse. Việc ứng dụng kiểu lỗi trung gian này sẽ được trình bày sau.

Tiếp đến, nếu việc giao tiếp với EdgeDB thành công, hàm get_detailed_post_by_slug sẽ trả về dữ liệu kiểu Option<DetailedBlogPost>, tức là sẽ lấy được DetailedBlogPost hoặc không. Option là kiểu dữ liệu đặc biệt của Rust, bên cạnh Result, mà những người đến từ các ngôn ngữ lập trình hướng đối tượng truyền thống sẽ ngạc nhiên. Trong Rust không có kiểu dữ liệu null / None / nil như các ngôn ngữ phổ biến khác. Thay vào đó, để diễn tả được sự thiếu vắng dữ liệu, Rust dùng Option, là một enum với hai mặt Some và None. Cụ thể, nếu hàm get_detailed_post_by_slug lấy được dữ liệu, nó trả về Some(DetailedBlogPost) và nếu không có dữ liệu, nó trả về None. Giống như ResultOption bao trùm dữ liệu thực nên bạn buộc phải ứng xử với trạng thái “thành công / lỗi”, “có dữ liệu / không có dữ liệu” rồi mới lấy được giá trị thực. Đây là cách Rust khiến bạn lập trình cẩn thận, kỹ càng hơn và tránh được bug.

Quay lại hàm show_post, nếu người dùng truy cập vào một URL không có thật, không tương ứng với bài post nào, thì ta nên trả về lỗi 404 Not Found. Tình huống “URL không có thật” đó tương ứng với lúc get_detailed_post_by_slug trả về None, bởi vậy ta có đoạn .ok_or((StatusCode::NOT_FOUND, "No post at this URL"))? là để trả về response với status 404 (Not Found) nếu get_detailed_post_by_slug trả về None.

Nếu dùng một trình soạn thảo có hỗ trợ inlay hint, bạn có thể thấy được giá trị trả về của từng đoạn trong một dòng code, và thấy được tác dụng của ? tại từng đoạn, vừa khiến hàm trở về sớm trong điều kiện không mong muốn, vừa “bóc vỏ” ResultOption để lấy giá trị thực trong điều kiện thành công:

Tận dụng phong cách xử trí lỗi của Rust trong lập trình web

Trong một lần giao tiếp với người dùng, ứng dụng web phải kết tập dữ liệu từ nhiều nguồn, qua nhiều bước xử lý. Mỗi bước đều có thể phát sinh lỗi, nhưng thường thì những lỗi đó đều không thể dùng trực tiếp làm giá trị trả về cho handler được, vì chúng là của các thư viện khác và không implement trait IntoResponse. Khi đó ta sẽ định nghĩa một kiểu lỗi riêng làm trung gian, sẽ được chuyển đổi từ các kiểu lỗi của các thư viện khác, và có implement trait IntoResponse để có thể tạo ra response. Ví dụ khi xây dựng website này thì tôi tạo hai kiểu lỗi: PageError dùng cho các trang bên ngoài, trả về HTML, và ApiError dành cho các đầu API, trả về JSON:

#[derive(Debug, thiserror::Error)]
pub enum PageError {
    #[error(transparent)]
    EdgeDBQueryError(#[from] edgedb_errors::Error),
    #[error(transparent)]
    JinjaError(#[from] minijinja::Error),
    #[error("Permission denied")]
    PermissionDenied(String),
}

impl IntoResponse for PageError {
    fn into_response(self) -> Response {
        // Code to create response for each case of error
    }
}

#[derive(Debug, thiserror::Error)]
pub enum ApiError {
    #[error(transparent)]
    PathRejection(#[from] PathRejection),
    #[error(transparent)]
    JsonRejection(#[from] JsonRejection),
    #[error(transparent)]
    JsonExtractionError(#[from] serde_json::Error),
    #[error(transparent)]
    EdgeDBQueryError(#[from] edgedb_errors::Error),
    #[error("{0} not found")]
    ObjectNotFound(String),
    #[error("Please login")]
    Unauthorized,
    #[error("Error logging in")]
    LoginError(String),
    #[error("Not enough data")]
    NotEnoughData,
    #[error(transparent)]
    ValidationErrors(#[from] validify::ValidationErrors),
    #[error("Other error: {0}")]
    Other(String),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> axum::response::Response {
        // Code to create response for each case of error
    }
}

Ở đây, tôi dùng thư viện thiserror để giúp viết code chuyển đổi ngắn gọn hơn. Ngoài ra, kiểu lỗi trung gian này nên ở dạng enum để có thể có cách lưu trữ thông tin riêng cho từng trường hợp lỗi, và tạo ra response khác nhau cho từng trường hợp (ví dụ cùng là lỗi nhưng có lúc ta trả về 500, có lúc ta trả về 422).

Thông tin thêm, có thể bạn để ý rằng, mặc dù trên signature của hàm tôi kí hiệu kiểu dữ liệu trả về là ErrorResponse nhưng nhiều lúc trong thân hàm tôi lại viết giá trị trả về là:

(StatusCode::NOT_FOUND, "No post at this URL")
// or
StatusCode::SERVICE_UNAVAILABLE

Đó là nhờ cơ chế trait và toán tử ? của Rust. Bạn có thể xem tài liệu của Axum để biết những kiểu dữ liệu nào có thể được tự động biến đổi thành ErrorResponse. Ví dụ biểu thức tuple sau

(StatusCode::NOT_FOUND, "No post at this URL")

là tương ứng với dòng sau trong tài liệu:

Tận dụng phong cách xử trí lỗi của Rust trong lập trình web

và đến lượt IntoResponse được implement trait From:

Tận dụng phong cách xử trí lỗi của Rust trong lập trình web

nên tuple đó có thể được chuyển đổi tự động.

Như vậy, tôi đã trình bày xong một trường hợp ứng dụng phong cách error handling của Rust. Khi lập trình website này, nhờ luật chặt chẽ của Rust mà tôi tránh được khá nhiều bug, điều dễ xảy ra với các ngôn ngữ khác, từ đó tiết kiệm được thời gian test đi test lại. Đây cũng là một trong những lý do tôi chọn Rust làm ngôn ngữ tiếp theo để đầu tư (sau Python), thay vì chọn Go như khá nhiều đồng nghiệp khác.

Bài viết gốc được đăng tải tại quan.hoabinh.vn

Có thể bạn quan tâm: