Bỏ qua để đến nội dung

Dự án: xây dựng Web API với Axum bằng Rust

Trong dự án này, bạn sẽ xây một web server thật: một API quản lý việc cần làm (to-do API) nhỏ. Các chương trình khác (một trang web, một ứng dụng điện thoại, hay chỉ là curl trong terminal) gửi cho nó các request HTTP, và nó trả lời bằng JSON. Đây chính là loại thứ đang vận hành phần “back end” của hầu hết ứng dụng bạn dùng.

Ta sẽ dùng axum, một web framework phổ biến, thân thiện do đội Tokio làm ra. Nó nằm trên nền Rust bất đồng bộ và serde, nên dự án này kết nối nhiều thứ bạn đã học: struct, Result, con trỏ thông minh và state dùng chung giữa các luồng. Nếu bạn đã làm Dự án: danh sách việc cần làm, bạn sẽ nhận ra ý tưởng này. Lần này danh sách việc cần làm sống đằng sau một địa chỉ web.

Khi xong, server của bạn sẽ trả lời các request sau:

Method và đường dẫn Nó làm gì Mã trạng thái khi thành công
GET /todos Liệt kê mọi việc cần làm 200 OK
GET /todos/{id} Lấy một việc cần làm 200 OK, hoặc 404 Not Found
POST /todos Tạo một việc cần làm từ JSON 201 Created
DELETE /todos/{id} Xóa một việc cần làm 204 No Content, hoặc 404 Not Found

Một việc cần làm trông như thế này ở dạng JSON:

{"id":1,"title":"Buy milk","done":false}

Một lưu ý quan trọng: web server chờ request mãi mãi và cần một cổng mạng (port), nên nó không chạy được trên Rust Playground. Code server trên trang này dành cho máy của bạn với cargo run. Mọi bước dưới đây đều đã được biên dịch và chạy, và các response hiển thị là thật. Vì vậy các tiêu đề việc cần làm và mọi kết quả server được giữ nguyên tiếng Anh. Chỗ nào một phần của chương trình có thể chạy trên Playground, bạn sẽ thấy nút “Try it Yourself”.

Tạo một project mới và thêm bốn crate:

Terminal window
cargo new todo-api
cd todo-api
cargo add [email protected] tokio --features tokio/full
cargo add serde --features derive
cargo add serde_json

Giờ Cargo.toml của bạn có đại loại như sau (số phiên bản cụ thể có thể mới hơn):

[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

Mỗi crate làm gì:

  • axum lo phần HTTP: nó đọc request và gửi response.
  • tokio là runtime bất đồng bộ chạy mọi thứ. Xem Async/Await.
  • serde và serde_json biến struct của bạn thành JSON và ngược lại. Xem JSON với Serde.

Bạn cũng cần curl để gửi request. Nó có sẵn trên macOS, Linux, và Windows 10 trở lên. (Trên Windows, hãy gõ curl.exe trong PowerShell, vì curl trơn ở đó mang nghĩa khác.)

Thay src/main.rs bằng đoạn này:

use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(hello));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello() -> &'static str {
"Hello, world!"
}

Ba mảnh làm việc cùng nhau ở đây:

  • Một handler là một async fn trả lời một request. hello trả về một ít chữ, và axum gửi nó lại làm response.
  • Router nối các đường dẫn với handler. .route("/", get(hello)) nghĩa là “khi ai đó gửi một request GET tới /, hãy gọi hello”.
  • Listener mở cổng 3000 trên chính máy của bạn (127.0.0.1), và axum::serve bắt đầu trả lời request. Nó chạy cho đến khi bạn dừng nó.

Chạy bằng cargo run. Chương trình in một dòng rồi chờ:

Listening on http://127.0.0.1:3000

Cứ để nó chạy, mở một terminal thứ hai, và gửi cho nó một request:

Terminal window
curl http://127.0.0.1:3000/
Hello, world!

Server không thêm ký tự xuống dòng sau chữ, nên dấu nhắc lệnh có thể hiện ngay sau Hello, world!. Chuyện đó bình thường. Trên trang này, mỗi response được hiển thị trên một dòng riêng. Bạn cũng có thể mở http://127.0.0.1:3000/ trong trình duyệt. Nhấn Ctrl + C trong terminal thứ nhất để dừng server. Mỗi lần sửa code, bạn cần dừng và khởi động lại nó.

API thường trả lời bằng JSON. Trong axum, bạn bọc bất kỳ giá trị nào implement Serialize của serde trong Json(...), và axum biến nó thành JSON và đặt đúng header content-type:

use axum::{routing::get, Json, Router};
use serde::Serialize;
#[derive(Serialize)]
struct Todo {
id: u32,
title: String,
done: bool,
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(hello))
.route("/example", get(example_todo));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello() -> &'static str {
"Hello, world!"
}
async fn example_todo() -> Json<Todo> {
let todo = Todo {
id: 1,
title: String::from("Learn axum"),
done: false,
};
Json(todo)
}

Cờ -i bảo curl hiện thêm dòng trạng thái và các header:

Terminal window
curl -i http://127.0.0.1:3000/example
HTTP/1.1 200 OK
content-type: application/json
content-length: 42
{"id":1,"title":"Learn axum","done":false}

(curl còn in một header date, ta bỏ nó đi trên trang này.) Các khóa JSON chính là tên các field của struct. Bạn có thể kiểm tra hình dạng JSON đó trên Playground mà không cần server, vì đó chỉ là serde thuần. Đây cũng là struct NewTodo mà ta sẽ dùng ở bước 4 cho dữ liệu gửi đến:

use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize)]
struct Todo {
id: u32,
title: String,
done: bool,
}
#[derive(Debug, Deserialize)]
struct NewTodo {
title: String,
}
fn main() {
let todo = Todo { id: 1, title: String::from("Learn axum"), done: false };
println!("{}", serde_json::to_string(&todo).unwrap());
let list = vec![todo.clone(), Todo { id: 2, title: String::from("Buy milk"), done: true }];
println!("{}", serde_json::to_string(&list).unwrap());
let input: NewTodo = serde_json::from_str(r#"{"title":"Walk the dog"}"#).unwrap();
println!("{:?}", input);
let bad = serde_json::from_str::<NewTodo>(r#"{"name":"Walk the dog"}"#);
println!("{}", bad.unwrap_err());
}

Kết quả:

{"id":1,"title":"Learn axum","done":false}
[{"id":1,"title":"Learn axum","done":false},{"id":2,"title":"Buy milk","done":true}]
NewTodo { title: "Walk the dog" }
missing field `title` at line 1 column 23
▶ Tự chạy thử

Dòng cuối là thông báo lỗi của serde: thiếu field title ở dòng 1, cột 23. Một Vec<Todo> trở thành một mảng JSON. Đó chính xác là thứ GET /todos sẽ trả về.

Trong một đường dẫn như /todos/2, số 2 thay đổi theo từng request. Trong axum 0.8, bạn đánh dấu phần đó của route bằng dấu ngoặc nhọn, {id}, và đọc nó trong handler bằng extractor Path:

use axum::{extract::Path, routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/hello/{name}", get(hello_name))
.route("/add/{a}/{b}", get(add));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello_name(Path(name): Path<String>) -> String {
format!("Hello, {name}!")
}
async fn add(Path((a, b)): Path<(i32, i32)>) -> String {
format!("{a} + {b} = {}", a + b)
}

Extractor là một tham số của handler, rút ra một mảnh từ request. Path(name): Path<String> là một mẫu (pattern): nó mở lớp Path và đưa bạn chuỗi String bên trong. Với hai tham số, bạn nhận được một tuple.

Terminal window
curl http://127.0.0.1:3000/hello/Ana
curl http://127.0.0.1:3000/add/2/3
Hello, Ana!
2 + 3 = 5

Đây là một bất ngờ thú vị. Bạn đã nói a và b là i32, nên axum kiểm tra điều đó giúp bạn. Hãy gửi thứ gì đó không phải là số:

Terminal window
curl -i http://127.0.0.1:3000/add/2/x
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
content-length: 68
Invalid URL: Cannot parse value at index 1 with value `x` to a `i32`

axum báo URL không hợp lệ: không parse được giá trị x thành i32. Handler của bạn thậm chí còn chưa chạy. Các kiểu trong chữ ký hàm chính là phần kiểm tra dữ liệu.

Để tạo một việc cần làm, client gửi JSON trong body của request. Extractor Json đọc body và deserialize nó thành struct của bạn. Client chỉ gửi tiêu đề, nên ta dùng một struct NewTodo riêng cho dữ liệu vào:

use axum::{routing::post, Json, Router};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct Todo {
id: u32,
title: String,
done: bool,
}
#[derive(Deserialize)]
struct NewTodo {
title: String,
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/echo", post(echo_todo));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn echo_todo(Json(input): Json<NewTodo>) -> Json<Todo> {
let todo = Todo {
id: 1,
title: input.title,
done: false,
};
Json(todo)
}

Dùng post(...) thay vì get(...) trong route. Với curl, -X POST đặt method, -H thêm một header, và -d gửi body:

Terminal window
curl -X POST http://127.0.0.1:3000/echo -H "Content-Type: application/json" -d '{"title":"Buy milk"}'
{"id":1,"title":"Buy milk","done":false}

Một lần nữa, axum kiểm tra dữ liệu vào trước khi code của bạn chạy. Nếu JSON thiếu title, bạn nhận 422 Unprocessable Entity:

Terminal window
curl -i -X POST http://127.0.0.1:3000/echo -H "Content-Type: application/json" -d '{"name":"Buy milk"}'
HTTP/1.1 422 Unprocessable Entity
content-type: text/plain; charset=utf-8
content-length: 99
Failed to deserialize the JSON body into the target type: missing field `title` at line 1 column 19

Thông báo nghĩa là không deserialize được body JSON thành kiểu đích vì thiếu field title. Còn nếu bạn quên header Content-Type, bạn nhận 415 Unsupported Media Type với thông báo Expected request with `Content-Type: application/json` . Header đó là thứ hay bị quên nhất khi test bằng curl.

Bước 5: State dùng chung cho một to-do API thật

Phần tiêu đề “Bước 5: State dùng chung cho một to-do API thật”

Đến giờ, mỗi handler tự bịa ra dữ liệu của nó. Một API thật cần ghi nhớ các việc cần làm giữa các request. Các request có thể đến cùng lúc và được xử lý trên các luồng khác nhau, nên danh sách phải an toàn khi dùng chung. Đó là nhiệm vụ của cặp đôi bạn đã gặp ở trang Đồng thời:

  • Mutex<Vec<Todo>> bảo đảm mỗi lúc chỉ một request thay đổi danh sách.
  • Arc<...> cho nhiều handler cùng sở hữu một con trỏ tới chính danh sách đó (xem Con trỏ thông minh).

Ta đặt cho kiểu một cái tên ngắn bằng type Db = Arc<Mutex<Vec<Todo>>>;, giao nó cho router bằng .with_state(db), và đọc nó trong handler bằng extractor State. axum clone Arc (rẻ: nó chỉ sao chép một con trỏ) cho mỗi request.

Đây là chương trình hoàn chỉnh. Thay src/main.rs bằng nó:

use std::sync::{Arc, Mutex};
use axum::{
extract::{Path, State},
http::StatusCode,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize)]
struct Todo {
id: u32,
title: String,
done: bool,
}
#[derive(Deserialize)]
struct NewTodo {
title: String,
}
// "Cơ sở dữ liệu": một danh sách việc cần làm mà mọi request đều dùng chung.
type Db = Arc<Mutex<Vec<Todo>>>;
#[tokio::main]
async fn main() {
let db: Db = Arc::new(Mutex::new(Vec::new()));
let app = Router::new()
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/{id}", get(get_todo).delete(delete_todo))
.with_state(db);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
// GET /todos
async fn list_todos(State(db): State<Db>) -> Json<Vec<Todo>> {
let todos = db.lock().unwrap();
Json(todos.clone())
}
// GET /todos/{id}
async fn get_todo(State(db): State<Db>, Path(id): Path<u32>) -> Result<Json<Todo>, StatusCode> {
let todos = db.lock().unwrap();
match todos.iter().find(|todo| todo.id == id) {
Some(todo) => Ok(Json(todo.clone())),
None => Err(StatusCode::NOT_FOUND),
}
}
// POST /todos
async fn create_todo(State(db): State<Db>, Json(input): Json<NewTodo>) -> (StatusCode, Json<Todo>) {
let mut todos = db.lock().unwrap();
let id = todos.iter().map(|todo| todo.id).max().unwrap_or(0) + 1;
let todo = Todo {
id,
title: input.title,
done: false,
};
todos.push(todo.clone());
(StatusCode::CREATED, Json(todo))
}
// DELETE /todos/{id}
async fn delete_todo(State(db): State<Db>, Path(id): Path<u32>) -> StatusCode {
let mut todos = db.lock().unwrap();
let before = todos.len();
todos.retain(|todo| todo.id != id);
if todos.len() < before {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}

Hãy đi qua các ý tưởng mới:

  • Một đường dẫn, nhiều method. get(list_todos).post(create_todo) gửi GET /todos và POST /todos tới các handler khác nhau.
  • Mã trạng thái là giá trị trả về. StatusCode đến từ module http của axum. Một handler có thể trả về một Result: Ok(Json(todo)) thành 200 kèm JSON, còn Err(StatusCode::NOT_FOUND) thành một 404 rỗng. Một tuple (StatusCode::CREATED, Json(todo)) đặt trạng thái và body. Chỉ trả về StatusCode::NO_CONTENT (204) nghĩa là “xong rồi, không có gì để gửi lại”.
  • lock() và guard. db.lock().unwrap() chờ đến khi không request nào khác đang dùng danh sách, rồi đưa bạn một guard mà bạn đọc và thay đổi được như một Vec bình thường. Khóa được nhả ra khi guard ra khỏi phạm vi (scope) ở cuối hàm.
  • Id mới. Id tiếp theo bằng id lớn nhất trong danh sách cộng một, hoặc 1 nếu danh sách rỗng.
  • Xóa. retain giữ lại mọi việc cần làm có id không khớp. Nếu độ dài không đổi, nghĩa là không có gì bị xóa, nên ta trả về 404.

Vì sao dùng std::sync::Mutex mà không dùng loại bất đồng bộ? Nó ổn (và nhanh hơn) miễn là bạn không giữ khóa qua một .await. Không handler nào của ta làm vậy: chúng khóa, làm việc với danh sách, rồi trả về.

Logic danh sách và khóa không cần web server, nên bạn thử được trên Playground. Các hàm dưới đây làm cùng việc với các handler, nhưng không có axum:

use std::sync::{Arc, Mutex};
#[derive(Clone, Debug)]
struct Todo {
id: u32,
title: String,
done: bool,
}
type Db = Arc<Mutex<Vec<Todo>>>;
fn create(db: &Db, title: &str) -> Todo {
let mut todos = db.lock().unwrap();
let id = todos.iter().map(|todo| todo.id).max().unwrap_or(0) + 1;
let todo = Todo { id, title: title.to_string(), done: false };
todos.push(todo.clone());
todo
}
fn get(db: &Db, id: u32) -> Option<Todo> {
let todos = db.lock().unwrap();
todos.iter().find(|todo| todo.id == id).cloned()
}
fn delete(db: &Db, id: u32) -> bool {
let mut todos = db.lock().unwrap();
let before = todos.len();
todos.retain(|todo| todo.id != id);
todos.len() < before
}
fn main() {
let db: Db = Arc::new(Mutex::new(Vec::new()));
let second_handle = Arc::clone(&db); // việc axum làm cho mỗi request
println!("{:?}", create(&db, "Buy milk"));
println!("{:?}", create(&second_handle, "Learn axum"));
println!("{:?}", get(&db, 2));
println!("{:?}", get(&db, 99));
println!("đã xóa 1? {}", delete(&db, 1));
println!("xóa 1 lần nữa? {}", delete(&db, 1));
println!("còn lại: {}", db.lock().unwrap().len());
}

Kết quả:

Todo { id: 1, title: "Buy milk", done: false }
Todo { id: 2, title: "Learn axum", done: false }
Some(Todo { id: 2, title: "Learn axum", done: false })
None
đã xóa 1? true
xóa 1 lần nữa? false
còn lại: 1
▶ Tự chạy thử

Việc cần làm được tạo qua second_handle hiện ra khi bạn đọc qua db, vì cả hai Arc đều trỏ tới cùng một danh sách.

Khởi động server bằng cargo run, và trong terminal thứ hai, thử mọi endpoint. Lúc đầu danh sách rỗng:

Terminal window
curl http://127.0.0.1:3000/todos
[]

Tạo một việc cần làm. Để ý mã trạng thái 201 Created:

Terminal window
curl -i -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title":"Buy milk"}'
HTTP/1.1 201 Created
content-type: application/json
content-length: 40
{"id":1,"title":"Buy milk","done":false}

Tạo cái thứ hai và liệt kê tất cả:

Terminal window
curl -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title":"Learn axum"}'
curl http://127.0.0.1:3000/todos
{"id":2,"title":"Learn axum","done":false}
[{"id":1,"title":"Buy milk","done":false},{"id":2,"title":"Learn axum","done":false}]

Lấy một cái theo id, và hỏi một cái không tồn tại:

Terminal window
curl http://127.0.0.1:3000/todos/2
curl -i http://127.0.0.1:3000/todos/99
{"id":2,"title":"Learn axum","done":false}
HTTP/1.1 404 Not Found
content-length: 0

Xóa việc cần làm số 1, rồi thử xóa nó lần nữa:

Terminal window
curl -i -X DELETE http://127.0.0.1:3000/todos/1
curl -i -X DELETE http://127.0.0.1:3000/todos/1
HTTP/1.1 204 No Content
HTTP/1.1 404 Not Found
content-length: 0
Terminal window
curl http://127.0.0.1:3000/todos
[{"id":2,"title":"Learn axum","done":false}]

axum còn xử lý giúp bạn vài lỗi. Sai kiểu trong JSON cho ra một 422 giải thích vấn đề, và một method bạn chưa thiết lập cho đường dẫn cho ra 405 Method Not Allowed, kèm một header liệt kê những method dùng được:

Terminal window
curl -i -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title": 42}'
curl -i -X PUT http://127.0.0.1:3000/todos/2
HTTP/1.1 422 Unprocessable Entity
content-type: text/plain; charset=utf-8
content-length: 130
Failed to deserialize the JSON body into the target type: title: invalid type: integer `42`, expected a string at line 1 column 12
HTTP/1.1 405 Method Not Allowed
allow: GET,HEAD,DELETE
content-length: 0

Thông báo 422 nói rằng title có kiểu sai: là số nguyên 42 trong khi cần một chuỗi. Dừng server bằng Ctrl + C rồi khởi động lại: danh sách trống trơn. Dữ liệu chỉ sống trong bộ nhớ. Lưu nó lại là ý tưởng đầu tiên ở bên dưới.

Dùng cú pháp route cũ :id. Nhiều hướng dẫn trên mạng được viết cho axum 0.7, phiên bản dùng /todos/:id. Trong axum 0.8, cách viết này vẫn biên dịch được, nhưng chương trình panic ngay khi khởi động:

thread 'main' (12345) panicked at src/main.rs:32:10:
Path segments must not start with `:`. For capture groups, use `{capture}`. If you meant to literally match a segment starting with a colon, call `without_v07_checks` on the router.

Thông báo nghĩa là các đoạn đường dẫn không được bắt đầu bằng :, và với nhóm cần bắt giá trị thì hãy dùng {capture}. Cách sửa: viết /todos/{id}.

Đặt Json trước các extractor khác. Body của request chỉ đọc được một lần, nên extractor đọc nó (Json) phải là tham số cuối cùng. Nếu bạn viết create_todo(Json(input): Json<NewTodo>, State(db): State<Db>), thông báo lỗi không nói thẳng điều đó. Những dòng quan trọng là:

error[E0277]: the trait bound `fn(Json<NewTodo>, State<...>) -> ... {create_todo}: Handler<_, ...>` is not satisfied
|
31 | .route("/todos", get(list_todos).post(create_todo))
| ---- ^^^^^^^^^^^ unsatisfied trait bound
|
= note: Consider using `#[axum::debug_handler]` to improve the error message

Khi bạn thấy “Handler is not satisfied” (ràng buộc trait Handler không được thỏa), hãy kiểm tra thứ tự các tham số, và kiểm tra rằng mọi kiểu trả về đều là thứ axum biến được thành response.

Quên -H "Content-Type: application/json" trong curl. Server trả lời 415. Header này bắt buộc với extractor Json.

Quên khởi động lại. Server đang chạy là chương trình cũ. Dừng nó bằng Ctrl + C và cargo run lại sau mỗi lần sửa.

Khi API cơ bản đã chạy, đây là những bước tiếp theo hay. Hãy thử từng cái một.

  • Đánh dấu một việc cần làm là đã xong. Thêm PATCH /todos/{id} bằng .patch(...) ở route thứ hai. Tìm việc cần làm bằng iter_mut(), đặt done = true, và trả nó về.
  • Kiểm tra dữ liệu. Không nên cho phép tiêu đề rỗng. Một handler có thể trả về Result<(StatusCode, Json<Todo>), (StatusCode, String)>, và gửi lại Err((StatusCode::BAD_REQUEST, String::from("title must not be empty"))). Dùng title.trim().is_empty() để kiểm tra.
  • Id không bao giờ lặp lại. Với cách “id lớn nhất cộng một”, xóa việc cần làm mới nhất sẽ cho phép id của nó được dùng lại. Thay vào đó, hãy giữ một bộ đếm riêng trong state, ví dụ một struct có todos: Vec<Todo> và next_id: u32 bên trong Mutex.
  • Lưu trữ bằng file JSON. Derive thêm Deserialize cho Todo. Lúc khởi động, đọc todos.json bằng std::fs::read_to_string và serde_json::from_str (hoặc bắt đầu rỗng nếu file không tồn tại). Sau mỗi thay đổi, ghi danh sách trở lại bằng serde_json::to_string_pretty và std::fs::write. Các trang Đọc và ghi file và JSON với Serde có mọi thứ bạn cần. Sau này, bạn có thể thay file bằng một cơ sở dữ liệu thật như SQLite (xem Cơ sở dữ liệu).
  • Ghi log với tower-http. Chạy cargo add tower-http --features trace và cargo add tracing-subscriber, rồi thêm một layer ghi log. Giờ server in một dòng cho mỗi request, với method, đường dẫn, mã trạng thái và thời gian xử lý:
use tower_http::trace::TraceLayer;
// ở đầu main:
tracing_subscriber::fmt()
.with_max_level(tracing_subscriber::filter::LevelFilter::DEBUG)
.init();
// khi dựng router:
let app = Router::new()
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/{id}", get(get_todo).delete(delete_todo))
.layer(TraceLayer::new_for_http())
.with_state(db);
  • Ghi chú khi triển khai. 127.0.0.1 chỉ nhận request từ chính máy của bạn. Để nhận request từ máy khác (ví dụ bên trong Docker hoặc trên server), hãy bind vào 0.0.0.0. Nhiều dịch vụ hosting cho chương trình biết cổng cần dùng qua biến môi trường PORT, bạn đọc được nó bằng std::env::var (xem Biến môi trường và tiến trình). Build bằng cargo build --release để có server nhanh hơn nhiều. Kết quả là một file duy nhất trong target/release/ mà bạn có thể chép lên một server Linux, hoặc đặt vào một Docker image nhỏ. Với một trang công khai, hãy đặt nó sau một reverse proxy (như Caddy hay nginx) lo phần HTTPS.
  • Một handler là một async fn. Một router ánh xạ method và đường dẫn tới handler. axum::serve chạy nó trên một TcpListener của tokio.
  • Extractor rút dữ liệu từ request: Path cho các phần của URL (/todos/{id}), Json cho body (luôn đứng cuối), và State cho dữ liệu dùng chung.
  • Trả về Json(value) cho JSON, StatusCode cho một mã trạng thái, một tuple để đặt cả hai, hoặc một Result để chọn giữa thành công và một mã lỗi.
  • Arc<Mutex<Vec<Todo>>> chia sẻ an toàn một danh sách giữa mọi request. Khóa nó, dùng nó, và để guard bị drop. Đừng giữ nó qua .await.
  • Test API bằng curl: -i hiện mã trạng thái, -X đặt method, -H thêm header, và -d gửi body.

Kiểm tra nhanh

1. Trong axum 0.8, bạn viết một route có id trong đường dẫn như thế nào?

2. Một handler trả về (StatusCode::CREATED, Json(todo)). Client nhận được gì?

3. Vì sao danh sách dùng chung được bọc trong Arc<Mutex<...>>?

4. Extractor nào phải đứng cuối trong danh sách tham số của handler?

5. Bạn curl -X POST một ít JSON nhưng quên header Content-Type: application/json. axum trả lời gì?

Điểm: 0 / 5