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

Lập trình mạng trong Rust: server TCP và client HTTP

Gần như mọi chương trình bạn dùng đều nói chuyện với một máy tính khác: ứng dụng chat, trò chơi, tiện ích thời tiết. Bên dưới, chúng mở một kết nối (connection), gửi vài byte và đọc câu trả lời. Rust làm việc này rất giỏi. Nó nhanh, dùng ít bộ nhớ, và trình biên dịch bắt được nhiều lỗi vốn hay làm code mạng crash lúc 3 giờ sáng.

Trên trang này, bạn sẽ viết một server và client TCP nhỏ, phục vụ nhiều client cùng lúc (trước tiên bằng luồng, sau đó bằng tokio bất đồng bộ), rồi gọi những HTTP API thật bằng reqwest. Các ví dụ TCP chạy ngay tại đây trên Rust Playground. Các ví dụ reqwest cần internet, nên bạn chạy chúng trên máy mình bằng cargo run. Tất cả đều đã được build và chạy, và kết quả hiển thị là kết quả thật.

  • Dịch vụ mạng và proxy. Cloudflare đã xây Pingora (tiếng Anh), framework mã nguồn mở cho proxy của họ, bằng Rust. Service mesh Linkerd (tiếng Anh) cũng dùng một proxy viết bằng Rust.
  • Web API và back end, với axum chạy trên nền tokio. Xem Dự án: xây dựng Web API.
  • Client và công cụ gọi API: một công cụ dòng lệnh gọi GitHub, một con bot, một script kiểm tra xem các trang web của bạn còn hoạt động không.
  • Giao thức riêng: một game server, một chat server, một thiết bị gửi số đo cảm biến qua UDP.

Chỗ khó hơn: Rust bất đồng bộ đòi hỏi thời gian học thật sự, và lần build đầu với các crate mạng mất một hai phút. Với một script 10 dòng gọi một API, Python có thể viết nhanh hơn. Rust đáng công khi chương trình phải nhanh, chạy liên tục hàng tháng, hoặc xử lý nhiều kết nối.

Crate Dùng để làm gì Kiểu Chọn nó khi
std::net Socket TCP và UDP, có sẵn trong Rust blocking (chặn) Học, công cụ nhỏ, vài kết nối. Không cần phụ thuộc.
tokio Runtime bất đồng bộ với TCP, UDP, timer bất đồng bộ async Nhiều kết nối cùng lúc. Phần lớn crate mạng xây trên nó.
reqwest HTTP client dễ dùng (JSON, timeout, TLS) async (có tùy chọn blocking) Bạn gọi web API. Lựa chọn đầu tiên thường gặp.
ureq HTTP client nhỏ, đơn giản blocking Bạn hoàn toàn không muốn dùng async hay tokio.
hyper Bộ máy HTTP cấp thấp nằm dưới reqwest và axum async Bạn đang xây một framework hoặc cần toàn quyền kiểm soát.
axum Web framework cho HTTP server async Bạn đang viết một web API. Xem dự án axum.

Tất cả đều đã trưởng thành và được dùng rộng rãi. Một lộ trình tốt: học các ý tưởng với std::net, rồi chuyển sang tokio, reqwest và axum.

Server và client TCP trong cùng một chương trình

Phần tiêu đề “Server và client TCP trong cùng một chương trình”

TCP cho bạn một luồng (stream) byte giữa hai chương trình, giống như một cuộc gọi điện thoại. Server lắng nghe (listen) trên một địa chỉ và một cổng (port). Client kết nối (connect) tới nó. Sau đó cả hai bên đều có thể đọc và ghi.

Bình thường server và client là hai chương trình. Ở đây ta đặt cả hai vào một chương trình để nó chạy được trên Playground: server chạy trong một luồng (thread), còn main là client.

use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn main() -> std::io::Result<()> {
// Cổng 0 nghĩa là “cổng trống bất kỳ”. Sau đó ta hỏi xem mình được cổng nào.
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
// Server: nhận một client, đọc một dòng, trả lời bằng một dòng.
let server = thread::spawn(move || -> std::io::Result<()> {
let (stream, _) = listener.accept()?;
let mut name = String::new();
BufReader::new(&stream).read_line(&mut name)?;
writeln!(&stream, "Xin chào, {}!", name.trim())?;
Ok(())
});
// Client: kết nối, gửi một dòng, đọc câu trả lời.
let stream = TcpStream::connect(addr)?;
writeln!(&stream, "Ferris")?;
let mut reply = String::new();
BufReader::new(&stream).read_line(&mut reply)?;
print!("client nhận được: {reply}");
server.join().unwrap()?;
println!("server đã xong");
Ok(())
}

Kết quả:

client nhận được: Xin chào, Ferris!
server đã xong
▶ Tự chạy thử

Vài điều cần để ý:

  • 127.0.0.1 nghĩa là “chỉ máy tính này”. Hãy dùng 0.0.0.0 khi các máy khác cần truy cập server của bạn.
  • TCP không biết một thông điệp kết thúc ở đâu. Ta dùng một quy tắc đơn giản: một thông điệp = một dòng. writeln! hoạt động trên một stream giống như println! trên màn hình: nó thêm \n. read_line đọc tới ký tự đó.
  • Mọi lời gọi mạng đều có thể thất bại, nên mỗi lời gọi trả về một Result và ta dùng ?.
  • &stream dùng được cho cả đọc lẫn ghi, vì &TcpStream cài đặt cả Read và Write.

Một server thật không được bắt client số 2 phải chờ tới khi client số 1 xong việc. Câu trả lời kinh điển: mỗi kết nối một luồng. Vòng lặp nhận một client, giao nó cho một luồng mới, rồi quay lại ngay accept.

Server này đếm số từ trong mỗi dòng nó nhận được. Ba client kết nối cùng lúc:

use std::io::{BufRead, BufReader, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::thread;
// Phía server: trả lời mỗi dòng bằng số từ trong dòng đó.
fn handle_client(stream: TcpStream) -> std::io::Result<()> {
for line in BufReader::new(&stream).lines() {
let words = line?.split_whitespace().count();
writeln!(&stream, "{words} từ")?;
}
Ok(()) // client đã đóng kết nối
}
// Phía client: gửi một dòng, trả về câu trả lời.
fn ask(addr: SocketAddr, msg: &str) -> std::io::Result<String> {
let stream = TcpStream::connect(addr)?;
writeln!(&stream, "{msg}")?;
let mut reply = String::new();
BufReader::new(&stream).read_line(&mut reply)?;
Ok(format!("{msg:?} -> {}", reply.trim()))
}
fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
thread::spawn(move || {
for stream in listener.incoming() {
let stream = stream.unwrap();
thread::spawn(move || handle_client(stream)); // mỗi client một luồng
}
});
// Ba client cùng lúc, mỗi client trong luồng riêng.
let clients = ["hello there", "rust is fun to learn", "see you soon"]
.map(|msg| thread::spawn(move || ask(addr, msg)));
// Chờ chúng theo thứ tự, để thứ tự kết quả lúc nào cũng như nhau.
for c in clients {
println!("{}", c.join().unwrap()?);
}
Ok(())
}

Kết quả:

"hello there" -> 2 từ
"rust is fun to learn" -> 5 từ
"see you soon" -> 3 từ
▶ Tự chạy thử

Các câu gửi đi được giữ bằng tiếng Anh để số từ khớp với kết quả. Khi một client drop TcpStream của nó, kết nối đóng lại, lines() kết thúc, và luồng của client đó xong việc. Vòng lặp accept không bao giờ kết thúc, điều này là bình thường với một server. Ở đây chương trình chỉ đơn giản dừng lại khi main trả về. Mẫu này đơn giản và chạy tốt với hàng chục hay hàng trăm client. Với hàng nghìn client, chừng ấy luồng tốn rất nhiều bộ nhớ. Đó là lúc bất đồng bộ phát huy tác dụng.

Với tokio, mỗi client là một task thay vì một luồng. Một task rất nhỏ, nên một server có thể giữ hàng nghìn kết nối. Hãy so sánh với phiên bản dùng luồng ở trên: std::net thành tokio::net, thread::spawn thành tokio::spawn, và bạn thêm .await ở mọi chỗ chương trình chờ mạng.

use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
// Cùng server đếm từ, nhưng mỗi client là một task rẻ, không phải một luồng.
async fn handle_client(stream: TcpStream) -> std::io::Result<()> {
let (read_half, mut write_half) = stream.into_split();
let mut lines = BufReader::new(read_half).lines();
while let Some(line) = lines.next_line().await? {
let words = line.split_whitespace().count();
write_half.write_all(format!("{words} từ\n").as_bytes()).await?;
}
Ok(())
}
async fn ask(addr: std::net::SocketAddr, msg: &str) -> std::io::Result<String> {
let mut stream = TcpStream::connect(addr).await?;
stream.write_all(format!("{msg}\n").as_bytes()).await?;
let mut reply = String::new();
BufReader::new(stream).read_line(&mut reply).await?;
Ok(format!("{msg:?} -> {}", reply.trim()))
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(handle_client(stream)); // mỗi client một task
}
});
// Khởi động ba client cùng lúc, rồi chờ từng cái theo thứ tự.
let clients = ["hello there", "rust is fun to learn", "see you soon"]
.map(|msg| tokio::spawn(ask(addr, msg)));
for c in clients {
println!("{}", c.await.unwrap()?);
}
Ok(())
}

Kết quả:

"hello there" -> 2 từ
"rust is fun to learn" -> 5 từ
"see you soon" -> 3 từ
▶ Tự chạy thử

into_split() cho bạn một nửa để đọc và một nửa để ghi, để việc đọc và ghi có thể nằm ở các biến khác nhau. Như trước, vòng lặp accept chạy mãi mãi. Khi main trả về, tokio dừng mọi task.

UDP không có kết nối. Bạn gửi từng thông điệp đơn lẻ (datagram) tới một địa chỉ, và mỗi thông điệp hoặc tới nơi nguyên vẹn, hoặc không tới. Không có đảm bảo về thứ tự hay việc giao nhận. Nó được dùng cho trò chơi, gọi video, DNS và cảm biến, nơi tốc độ quan trọng hơn một gói tin bị mất. Trong std::net, nó chỉ là một kiểu, UdpSocket:

let socket = UdpSocket::bind("127.0.0.1:0")?;
socket.send_to(b"temp=21.5", "127.0.0.1:9000")?; // một thông điệp, gửi tới một địa chỉ
let mut buf = [0u8; 1500];
let (n, from) = socket.recv_from(&mut buf)?; // chờ một thông điệp từ bất kỳ ai

tokio::net::UdpSocket hoạt động tương tự, với .await.

Trên mạng, chuyện trục trặc xảy ra liên tục: không ai lắng nghe, Wi-Fi rớt, một server bị treo. Hai quy tắc giữ cho chương trình của bạn khỏe mạnh: xử lý mọi lỗi, và không bao giờ chờ mãi mãi. Mặc định, một lần đọc sẽ chờ mãi mãi. set_read_timeout khắc phục điều đó:

use std::io::{ErrorKind, Read};
use std::net::{TcpListener, TcpStream};
use std::time::Duration;
fn main() -> std::io::Result<()> {
// 1. Không ai lắng nghe: kết nối thất bại ngay lập tức.
let free_port = TcpListener::bind("127.0.0.1:0")?.local_addr()?; // bị drop = đã đóng
match TcpStream::connect(free_port) {
Ok(_) => println!("đã kết nối?!"),
Err(e) => println!("kết nối thất bại: {:?}", e.kind()),
}
// 2. Một server chấp nhận kết nối nhưng không bao giờ trả lời.
let silent = TcpListener::bind("127.0.0.1:0")?;
let mut stream = TcpStream::connect(silent.local_addr()?)?;
stream.set_read_timeout(Some(Duration::from_millis(200)))?;
let mut buf = [0u8; 64];
match stream.read(&mut buf) {
Ok(n) => println!("đã đọc {n} byte"),
// Linux báo WouldBlock, Windows báo TimedOut.
Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {
println!("đọc quá thời gian chờ sau 200 ms")
}
Err(e) => println!("lỗi khác: {e}"),
}
Ok(())
}

Kết quả:

kết nối thất bại: ConnectionRefused
đọc quá thời gian chờ sau 200 ms
▶ Tự chạy thử

e.kind() cho bạn một ErrorKind (tiếng Anh) để bạn có thể match. Còn có TcpStream::connect_timeout cho những lần kết nối chậm. Trong tokio, hãy bọc bất kỳ future nào bằng tokio::time::timeout:

let result = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await;
// Err(_) = mất quá lâu; Ok(inner) = lần đọc đã xong (inner là Result riêng của nó)

HTTP là ngôn ngữ của web, và nó chạy trên nền TCP. Bạn có thể tự viết HTTP bằng tay qua một TcpStream, nhưng một crate client sẽ lo mọi chi tiết giúp bạn: HTTPS, chuyển hướng, header, nén. Các ví dụ này cần truy cập internet và crate reqwest, nên chúng không chạy được trên Playground. Hãy chạy chúng trên máy của bạn:

Terminal window
cargo new api-client
cd api-client
cargo add reqwest --features json
cargo add tokio --features full
cargo add serde --features derive
[dependencies]
reqwest = { version = "0.13", features = ["json"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

Ta sẽ dùng JSONPlaceholder (tiếng Anh), một API giả miễn phí làm ra để luyện tập. Lần build đầu tải về và biên dịch khá nhiều crate, nên hãy đợi một chút.

reqwest::get(url).await?.text().await? cho bạn phần thân (body) dưới dạng String. Nhưng với JSON thì có cách tốt hơn: với feature json, reqwest dùng serde để biến câu trả lời thẳng thành struct của bạn, và biến struct của bạn thành phần thân của request:

use serde::{Deserialize, Serialize};
// Chỉ các trường bạn liệt kê mới được đọc. Các trường JSON thừa (như "userId") bị bỏ qua.
#[derive(Deserialize)]
struct Todo {
id: u32,
title: String,
completed: bool,
}
#[derive(Serialize)]
struct NewTodo {
title: String,
completed: bool,
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let client = reqwest::Client::new();
// GET: JSON -> struct của bạn
let todo: Todo = client
.get("https://jsonplaceholder.typicode.com/todos/1")
.send()
.await?
.json()
.await?;
println!("#{}: {} (done: {})", todo.id, todo.title, todo.completed);
// POST: struct của bạn -> JSON (API thử nghiệm này trả lời, nhưng không lưu thật)
let new = NewTodo { title: "Learn reqwest".to_string(), completed: false };
let response = client
.post("https://jsonplaceholder.typicode.com/todos")
.json(&new)
.send()
.await?;
println!("status: {}", response.status());
println!("{}", response.text().await?);
Ok(())
}
#1: delectus aut autem (done: false)
status: 201 Created
{
"title": "Learn reqwest",
"completed": false,
"id": 201
}

Hãy tạo một Client và dùng lại nó. Nó giữ các kết nối mở và tái sử dụng chúng, giúp các request sau nhanh hơn. .json(&new) cũng tự đặt header Content-Type: application/json cho bạn.

Không muốn dùng async? ureq là một client blocking nhỏ gọn: ureq::get(url).call()?.body_mut().read_to_string()? cho bạn phần thân dưới dạng String, không cần tokio.

reqwest mặc định không có timeout, nên một server bị treo có thể khiến chương trình của bạn chờ mãi mãi. Hãy đặt timeout trên client. Cũng cần lưu ý: với reqwest, 404 Not Found không phải là lỗi. Request đã thành công, chỉ là câu trả lời là “không”. Hãy gọi error_for_status() để biến các câu trả lời 4xx và 5xx thành lỗi:

use std::time::Duration;
// Biến cả câu trả lời 4xx/5xx thành lỗi, không chỉ các sự cố mạng.
async fn fetch(client: &reqwest::Client, url: &str) -> Result<String, reqwest::Error> {
let response = client.get(url).send().await?.error_for_status()?;
response.text().await
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
// Không có timeout, một request tới server bị treo có thể chờ mãi mãi.
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()?;
// Một server cục bộ chấp nhận kết nối nhưng không bao giờ trả lời.
let silent = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let stuck = format!("http://{}/", silent.local_addr().unwrap());
let urls = [
"https://jsonplaceholder.typicode.com/todos/1",
"https://jsonplaceholder.typicode.com/todos/999999",
"http://127.0.0.1:1/",
&stuck,
];
for url in urls {
match fetch(&client, url).await {
Ok(body) => println!("ok, {} bytes", body.len()),
Err(e) if e.is_status() => println!("server said {}", e.status().unwrap()),
Err(e) if e.is_connect() => println!("could not connect: {e}"),
Err(e) if e.is_timeout() => println!("gave up after 2 seconds"),
Err(e) => println!("other error: {e}"),
}
}
Ok(())
}
ok, 83 bytes
server said 404 Not Found
could not connect: error sending request for url (http://127.0.0.1:1/)
gave up after 2 seconds

Các chuỗi in ra trong hai ví dụ reqwest được giữ bằng tiếng Anh, vì kết quả ở trên là kết quả thật của đúng đoạn code đó. is_status(), is_connect() và is_timeout() cho bạn biết loại thất bại nào đã xảy ra, để bạn quyết định nên làm gì: thử lại sau, hiện một thông báo, hay bỏ cuộc. Trong một chương trình lớn hơn, anyhow giúp bạn dễ dàng thêm ngữ cảnh cho những lỗi này.

  • Hai bên chờ nhau. Client gọi read_line, nhưng server cũng đang đọc, vì client quên gửi \n. Không ai gửi gì, và chương trình bị treo. Hãy thống nhất chỗ kết thúc một thông điệp (ký tự xuống dòng là quy tắc dễ nhất).
  • Đọc “tới cuối” quá sớm. read_to_string chờ tới khi phía bên kia đóng kết nối. Trên một kết nối vẫn mở, nó không bao giờ trả về. Thay vào đó, hãy đọc từng dòng.
  • Gọi hàm chặn (blocking) bên trong code bất đồng bộ. Dùng std::net hay std::thread::sleep bên trong một task tokio sẽ chặn cả một worker thread. Trong code bất đồng bộ, hãy dùng tokio::net và tokio::time::sleep.
  • Quên timeout. Test của bạn chạy ổn trên mạng nội bộ nhanh. Rồi một server chậm làm đơ mọi thứ. Hãy đặt timeout ngay từ ngày đầu.

Viết một server đọc các dòng như add 2 3 hay mul 4 5 và trả lời bằng kết quả. Với một dòng sai, hãy trả lời bằng một thông báo ngắn dạng lỗi: .... Kiểm tra nó từ main với một kết nối và bốn câu hỏi. Gợi ý: tách dòng bằng split_whitespace(), phân tích các số bằng parse::<i64>(), và trả lời bằng writeln!.

Xem lời giải
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn calculate(line: &str) -> String {
let parts: Vec<&str> = line.split_whitespace().collect();
let [op, a, b] = parts[..] else {
return "lỗi: hãy dùng <op> <a> <b>".to_string();
};
let (Ok(a), Ok(b)) = (a.parse::<i64>(), b.parse::<i64>()) else {
return "lỗi: không phải là số".to_string();
};
match op {
"add" => (a + b).to_string(),
"mul" => (a * b).to_string(),
_ => format!("lỗi: không biết phép tính {op}"),
}
}
fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
let server = thread::spawn(move || -> std::io::Result<()> {
let (stream, _) = listener.accept()?;
for line in BufReader::new(&stream).lines() {
writeln!(&stream, "{}", calculate(&line?))?;
}
Ok(())
});
let stream = TcpStream::connect(addr)?;
let mut replies = BufReader::new(&stream);
for question in ["add 2 3", "mul 4 5", "add 2 x", "div 8 2"] {
writeln!(&stream, "{question}")?;
let mut answer = String::new();
replies.read_line(&mut answer)?;
println!("{question} = {}", answer.trim());
}
drop(stream); // đóng kết nối sẽ kết thúc vòng lặp của server
server.join().unwrap()
}

Kết quả:

add 2 3 = 5
mul 4 5 = 20
add 2 x = lỗi: không phải là số
div 8 2 = lỗi: không biết phép tính div
▶ Tự chạy thử

Giữ logic trong một hàm calculate bình thường nghĩa là bạn có thể kiểm thử nó mà không cần mạng gì cả. Các dòng let ... else trả về sớm khi đầu vào không có đúng hình dạng.

2. Đếm các việc đã xong (trên máy của bạn)

Phần tiêu đề “2. Đếm các việc đã xong (trên máy của bạn)”

Dùng reqwest, tải https://jsonplaceholder.typicode.com/todos?userId=1. Nó trả về một mảng JSON các việc cần làm (to-do). Hãy in ra có bao nhiêu việc, bao nhiêu việc đã hoàn thành, và tiêu đề của việc đầu tiên chưa hoàn thành. Gợi ý: deserialize thành Vec<Todo>, rồi dùng các phương thức của iterator như filter và find.

Xem lời giải
use serde::Deserialize;
#[derive(Deserialize)]
struct Todo {
title: String,
completed: bool,
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let todos: Vec<Todo> = reqwest::get("https://jsonplaceholder.typicode.com/todos?userId=1")
.await?
.error_for_status()?
.json()
.await?;
let done = todos.iter().filter(|t| t.completed).count();
println!("user 1 has {} to-dos, {done} completed", todos.len());
let first_open = todos.iter().find(|t| !t.completed).unwrap();
println!("first open one: {}", first_open.title);
Ok(())
}
user 1 has 20 to-dos, 11 completed
first open one: delectus aut autem

Một mảng JSON tương ứng với một Vec, và serde điền từng phần tử giúp bạn. Struct của bạn chỉ cần liệt kê những trường bạn cần.

  • std::net cho bạn TCP blocking (TcpListener, TcpStream) và UDP (UdpSocket) mà không cần thêm crate nào.
  • Bind vào cổng 0 để nhận một cổng trống bất kỳ, và đọc lại nó bằng local_addr().
  • TCP là một luồng byte: hãy thống nhất chỗ kết thúc một thông điệp, ví dụ mỗi dòng một thông điệp.
  • Phục vụ nhiều client bằng mỗi kết nối một luồng, hoặc bằng task tokio khi bạn cần hàng nghìn kết nối.
  • reqwest (async) hoặc ureq (blocking) gọi HTTP API, và serde biến JSON thành struct của bạn.
  • Luôn đặt timeout, và dùng error_for_status() để một lỗi 404 không trông giống thành công.

Kiểm tra nhanh

1. Bind một TcpListener vào 127.0.0.1:0 có tác dụng gì?

2. Vì sao các ví dụ trên trang này gửi \n ở cuối mỗi thông điệp?

3. reqwest nhận được câu trả lời 404 Not Found. send().await trả về gì?

4. Server tokio của bạn xử lý mỗi client bằng std::net và std::thread::sleep. Vấn đề là gì?

Điểm: 0 / 4