Reti in Rust: server TCP e client HTTP
Quasi ogni programma che usi parla con un altro computer: un’app di chat, un gioco, un widget del meteo. Sotto il cofano aprono una connessione, mandano qualche byte e leggono la risposta. Rust è molto bravo in questo. È veloce, usa poca memoria, e il compilatore scova molti dei bug che fanno andare in crash il codice di rete alle 3 di notte.
In questa pagina scriverai un piccolo server e un piccolo client TCP, servirai più client contemporaneamente (prima con i thread, poi con tokio e la programmazione asincrona) e poi chiamerai vere API HTTP con reqwest. Gli esempi TCP girano direttamente qui, nel Rust Playground. Gli esempi con reqwest hanno bisogno di internet, quindi li esegui sul tuo computer con cargo run. Ognuno è stato compilato ed eseguito, e l’output mostrato è reale.
Cosa puoi costruire
Sezione intitolata “Cosa puoi costruire”- Servizi di rete e proxy. Cloudflare ha costruito in Rust Pingora (in inglese), il suo framework open source per i proxy. Anche la service mesh Linkerd (in inglese) usa un proxy scritto in Rust.
- API web e back end, con axum sopra tokio. Vedi Progetto: API web con Axum.
- Client di API e strumenti: uno strumento da riga di comando che chiama GitHub, un bot, uno script che controlla se i tuoi siti sono online.
- Protocolli personalizzati: un server di gioco, un server di chat, un dispositivo che manda letture di sensori via UDP.
Dove è più difficile: il Rust asincrono ha una vera curva di apprendimento, e la prima build con i crate di rete richiede un minuto o due. Per uno script di 10 righe che chiama una sola API, Python può essere più rapido da scrivere. Rust ripaga quando il programma deve essere veloce, girare per mesi o gestire tante connessioni.
Quale crate?
Sezione intitolata “Quale crate?”| Crate | A cosa serve | Stile | Sceglilo quando |
|---|---|---|---|
std::net |
Socket TCP e UDP, integrati in Rust | bloccante | Per imparare, piccoli strumenti, poche connessioni. Nessuna dipendenza. |
| tokio | Runtime asincrono con TCP, UDP e timer asincroni | async | Tante connessioni insieme. La maggior parte dei crate di rete si basa su di esso. |
| reqwest | Client HTTP comodo (JSON, timeout, TLS) | async (bloccante facoltativo) | Chiami API web. La prima scelta abituale. |
| ureq | Client HTTP piccolo e semplice | bloccante | Non vuoi affatto async né tokio. |
| hyper | Motore HTTP di basso livello sotto reqwest e axum | async | Stai costruendo un framework o ti serve il pieno controllo. |
| axum | Framework web per server HTTP | async | Stai scrivendo un’API web. Vedi il progetto con axum. |
Sono tutti maturi e molto usati (la documentazione è in inglese). Un buon percorso: impara le idee con std::net, poi passa a tokio, reqwest e axum.
Un server e un client TCP in un solo programma
Sezione intitolata “Un server e un client TCP in un solo programma”TCP ti dà un flusso (stream) di byte tra due programmi, come una telefonata. Il server ascolta su un indirizzo e una porta. Il client si connette a esso. Poi entrambi i lati possono leggere e scrivere.
Di solito server e client sono due programmi distinti. Qui li mettiamo entrambi in un solo programma, così gira nel Playground: il server gira in un thread, e main fa da client.
use std::io::{BufRead, BufReader, Write};use std::net::{TcpListener, TcpStream};use std::thread;
fn main() -> std::io::Result<()> { // La porta 0 significa «una porta libera qualsiasi». Poi chiediamo quale abbiamo ottenuto. let listener = TcpListener::bind("127.0.0.1:0")?; let addr = listener.local_addr()?;
// Il server: accetta un client, legge una riga, risponde con una riga. 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, "Ciao, {}!", name.trim())?; Ok(()) });
// Il client: si connette, manda una riga, legge la risposta. let stream = TcpStream::connect(addr)?; writeln!(&stream, "Ferris")?; let mut reply = String::new(); BufReader::new(&stream).read_line(&mut reply)?; print!("il client ha ricevuto: {reply}");
server.join().unwrap()?; println!("server terminato"); Ok(())}Output:
il client ha ricevuto: Ciao, Ferris!server terminatoAlcune cose da notare:
127.0.0.1significa «solo questo computer». Usa0.0.0.0quando altre macchine devono poter raggiungere il tuo server.- TCP non ha idea di dove finisca un messaggio. Usiamo una regola semplice: un messaggio = una riga.
writeln!funziona su uno stream comeprintln!funziona sullo schermo: aggiunge il\n.read_linelegge fino a lì. - Ogni chiamata di rete può fallire, quindi ognuna restituisce un
Resulte usiamo?. &streamfunziona sia per leggere sia per scrivere, perché&TcpStreamimplementa siaReadsiaWrite.
Tanti client con i thread
Sezione intitolata “Tanti client con i thread”Un server vero non deve far aspettare il client numero 2 finché il client numero 1 non ha finito. La risposta classica: un thread per connessione. Il ciclo accetta un client, lo passa a un nuovo thread e torna subito ad accept.
Questo server conta le parole di ogni riga che riceve. Tre client si connettono nello stesso momento:
use std::io::{BufRead, BufReader, Write};use std::net::{SocketAddr, TcpListener, TcpStream};use std::thread;
// Lato server: risponde a ogni riga con il numero delle sue parole.fn handle_client(stream: TcpStream) -> std::io::Result<()> { for line in BufReader::new(&stream).lines() { let words = line?.split_whitespace().count(); writeln!(&stream, "{words} parole")?; } Ok(()) // il client ha chiuso la connessione}
// Lato client: manda una riga, restituisce la risposta.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)); // un thread per client } });
// Tre client contemporaneamente, ognuno nel suo thread. let clients = ["ciao a tutti", "imparare rust è divertente", "a presto"] .map(|msg| thread::spawn(move || ask(addr, msg))); // Li aspettiamo in ordine, così l'ordine dell'output è sempre lo stesso. for c in clients { println!("{}", c.join().unwrap()?); } Ok(())}Output:
"ciao a tutti" -> 3 parole"imparare rust è divertente" -> 4 parole"a presto" -> 2 paroleQuando un client distrugge il suo TcpStream, la connessione si chiude, lines() finisce e il thread di quel client termina. Il ciclo di accettazione non finisce mai, ed è normale per un server. Qui il programma si ferma semplicemente quando main ritorna. Questo schema è semplice e funziona bene per decine o centinaia di client. Con migliaia, tutti quei thread costano parecchia memoria. È lì che entra in gioco la programmazione asincrona.
Lo stesso server con tokio
Sezione intitolata “Lo stesso server con tokio”Con tokio, ogni client è un task invece di un thread. Un task è minuscolo, quindi un solo server può reggere molte migliaia di connessioni. Confrontalo con la versione a thread qui sopra: std::net diventa tokio::net, thread::spawn diventa tokio::spawn, e aggiungi .await in ogni punto in cui il programma aspetta la rete.
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};use tokio::net::{TcpListener, TcpStream};
// Lo stesso server che conta le parole, ma ogni client è un task economico, non un thread.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} parole\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)); // un task per client } });
// Avvia tre client insieme, poi aspetta ciascuno in ordine. let clients = ["ciao a tutti", "imparare rust è divertente", "a presto"] .map(|msg| tokio::spawn(ask(addr, msg))); for c in clients { println!("{}", c.await.unwrap()?); } Ok(())}Output:
"ciao a tutti" -> 3 parole"imparare rust è divertente" -> 4 parole"a presto" -> 2 paroleinto_split() ti dà una metà per leggere e una per scrivere, così lettura e scrittura possono vivere in variabili diverse. Come prima, il ciclo di accettazione gira per sempre. Quando main ritorna, tokio ferma tutti i task.
UDP in un minuto
Sezione intitolata “UDP in un minuto”UDP non ha connessione. Mandi singoli messaggi (datagrammi) a un indirizzo, e ognuno arriva intero oppure non arriva affatto. Non ci sono garanzie sull’ordine né sulla consegna. Si usa per giochi, videochiamate, DNS e sensori, dove la velocità conta più di un pacchetto perso. In std::net è un solo tipo, UdpSocket:
let socket = UdpSocket::bind("127.0.0.1:0")?;socket.send_to(b"temp=21.5", "127.0.0.1:9000")?; // un messaggio, mandato a un indirizzo
let mut buf = [0u8; 1500];let (n, from) = socket.recv_from(&mut buf)?; // aspetta un messaggio da chiunquetokio::net::UdpSocket funziona allo stesso modo, con .await.
Timeout ed errori di rete
Sezione intitolata “Timeout ed errori di rete”In rete le cose vanno storte di continuo: nessuno è in ascolto, il Wi-Fi cade, un server si blocca. Due regole mantengono sano il tuo programma: gestisci ogni errore e non aspettare mai per sempre. Di default una lettura aspetta per sempre. set_read_timeout risolve il problema:
use std::io::{ErrorKind, Read};use std::net::{TcpListener, TcpStream};use std::time::Duration;
fn main() -> std::io::Result<()> { // 1. Nessuno è in ascolto: la connessione fallisce subito. let free_port = TcpListener::bind("127.0.0.1:0")?.local_addr()?; // distrutto = chiuso match TcpStream::connect(free_port) { Ok(_) => println!("connesso?!"), Err(e) => println!("connessione fallita: {:?}", e.kind()), }
// 2. Un server che accetta ma non risponde mai. 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!("letti {n} byte"), // Linux segnala WouldBlock, Windows segnala TimedOut. Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => { println!("lettura scaduta dopo 200 ms") } Err(e) => println!("altro errore: {e}"), } Ok(())}Output:
connessione fallita: ConnectionRefusedlettura scaduta dopo 200 mse.kind() ti dà un ErrorKind (in inglese) su cui puoi fare match. ConnectionRefused significa che la connessione è stata rifiutata. Esiste anche TcpStream::connect_timeout per le connessioni lente. In tokio, avvolgi qualsiasi future in tokio::time::timeout:
let result = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await;// Err(_) = ci ha messo troppo; Ok(inner) = la lettura è finita (inner è il suo Result)HTTP e JSON con reqwest
Sezione intitolata “HTTP e JSON con reqwest”HTTP è la lingua del web, e funziona sopra TCP. Potresti scrivere HTTP a mano su un TcpStream, ma un crate client si occupa per te dei dettagli: HTTPS, redirect, header, compressione. Questi esempi hanno bisogno dell’accesso a internet e del crate reqwest, quindi non girano nel Playground. Eseguili sul tuo computer:
cargo new api-clientcd api-clientcargo add reqwest --features jsoncargo add tokio --features fullcargo add serde --features derive[dependencies]reqwest = { version = "0.13", features = ["json"] }serde = { version = "1", features = ["derive"] }tokio = { version = "1", features = ["full"] }Useremo JSONPlaceholder, un’API finta e gratuita fatta per fare pratica. La prima build scarica e compila un bel po’ di crate, quindi concedile un minuto.
GET e POST di JSON
Sezione intitolata “GET e POST di JSON”reqwest::get(url).await?.text().await? ti dà il corpo della risposta come String. Ma per il JSON c’è di meglio: con la feature json, reqwest usa serde per trasformare la risposta direttamente nella tua struct, e la tua struct nel corpo di una richiesta. (Il codice e l’output restano quelli reali, in inglese; abbiamo tradotto solo i commenti.)
use serde::{Deserialize, Serialize};
// Vengono letti solo i campi che elenchi. I campi JSON in più (come "userId") vengono ignorati.#[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 -> la tua struct let todo: Todo = client .get("https://jsonplaceholder.typicode.com/todos/1") .send() .await? .json() .await?; println!("#{}: {} (done: {})", todo.id, todo.title, todo.completed);
// POST: la tua struct -> JSON (questa API di prova risponde, ma non salva davvero) 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}Crea un solo Client e riusalo. Tiene aperte le connessioni e le riusa, e questo rende più veloci le richieste successive. .json(&new) imposta anche l’header Content-Type: application/json al posto tuo.
Non vuoi la programmazione asincrona? ureq è un piccolo client bloccante: ureq::get(url).call()?.body_mut().read_to_string()? ti dà il corpo come String, senza bisogno di tokio.
Timeout ed errori con reqwest
Sezione intitolata “Timeout ed errori con reqwest”reqwest non ha timeout di default, quindi un server bloccato può far aspettare il tuo programma per sempre. Impostane uno sul client. Nota anche che un 404 Not Found non è un errore per reqwest. La richiesta è andata a buon fine, solo che la risposta è stata «no». Chiama error_for_status() per trasformare in errori le risposte 4xx e 5xx:
use std::time::Duration;
// Trasforma in errori anche le risposte 4xx/5xx, non solo i problemi di rete.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> { // Senza timeout, una richiesta a un server bloccato può aspettare per sempre. let client = reqwest::Client::builder() .timeout(Duration::from_secs(2)) .build()?;
// Un server locale che accetta connessioni ma non risponde mai. 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 bytesserver said 404 Not Foundcould not connect: error sending request for url (http://127.0.0.1:1/)gave up after 2 secondsis_status(), is_connect() e is_timeout() ti dicono che tipo di guasto è avvenuto: una risposta di errore dal server, una connessione impossibile o un timeout. Così puoi decidere cosa fare: riprovare più tardi, mostrare un messaggio o rinunciare. In un programma più grande, anyhow rende facile aggiungere contesto a questi errori.
Errori comuni
Sezione intitolata “Errori comuni”- I due lati si aspettano a vicenda. Il client chiama
read_line, ma anche il server sta leggendo, perché il client ha dimenticato il\n. Nessuno manda niente, e il programma si blocca. Mettetevi d’accordo su dove finisce un messaggio (un a capo è la regola più semplice). - Leggere «fino alla fine» troppo presto.
read_to_stringaspetta che l’altro lato chiuda la connessione. Su una connessione che resta aperta, non ritorna mai. Leggi invece riga per riga. - Bloccare dentro il codice asincrono. Usare
std::netostd::thread::sleepdentro un task di tokio blocca un intero thread di lavoro. Nel codice asincrono usatokio::netetokio::time::sleep. - Dimenticare i timeout. I tuoi test passano su una rete locale veloce. Poi un solo server lento blocca tutto. Imposta i timeout fin dal primo giorno.
Pratica
Sezione intitolata “Pratica”1. Un server calcolatrice
Sezione intitolata “1. Un server calcolatrice”Scrivi un server che legge righe come add 2 3 o mul 4 5 e risponde con il risultato. Per una riga sbagliata, risponde con un breve messaggio errore: .... Provalo da main con una connessione e quattro domande. Suggerimento: dividi la riga con split_whitespace(), converti i numeri con parse::<i64>() e rispondi con writeln!.
Mostra la soluzione
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 "errore: usa <op> <a> <b>".to_string(); }; let (Ok(a), Ok(b)) = (a.parse::<i64>(), b.parse::<i64>()) else { return "errore: non è un numero".to_string(); }; match op { "add" => (a + b).to_string(), "mul" => (a * b).to_string(), _ => format!("errore: operazione sconosciuta {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); // chiudere la connessione fa terminare il ciclo del server server.join().unwrap()}Output:
add 2 3 = 5mul 4 5 = 20add 2 x = errore: non è un numerodiv 8 2 = errore: operazione sconosciuta divTenere la logica in una semplice funzione calculate significa poterla testare senza alcuna rete. Le righe let ... else ritornano subito quando l’input non ha la forma giusta.
2. Contare i to-do completati (sul tuo computer)
Sezione intitolata “2. Contare i to-do completati (sul tuo computer)”Con reqwest, scarica https://jsonplaceholder.typicode.com/todos?userId=1. Restituisce un array JSON di to-do (cose da fare). Stampa quanti sono, quanti sono completati e il titolo del primo che non lo è. Suggerimento: deserializza in un Vec<Todo>, poi usa metodi degli iteratori come filter e find.
Mostra la soluzione
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 completedfirst open one: delectus aut autemL’utente 1 ha 20 to-do, di cui 11 completati. Un array JSON corrisponde a un Vec, e serde riempie ogni elemento per te. La tua struct elenca solo i campi che ti servono.
Riepilogo
Sezione intitolata “Riepilogo”std::netti dà TCP bloccante (TcpListener,TcpStream) e UDP (UdpSocket) senza crate aggiuntivi.- Fai il bind sulla porta
0per ottenere una porta libera qualsiasi, e leggila conlocal_addr(). - TCP è un flusso di byte: mettetevi d’accordo su dove finisce un messaggio, per esempio un messaggio per riga.
- Servi tanti client con un thread per connessione, oppure con i task di tokio quando te ne servono migliaia.
- reqwest (asincrono) o ureq (bloccante) chiamano le API HTTP, e serde trasforma il JSON nelle tue struct.
- Imposta sempre i timeout, e usa
error_for_status()così un 404 non sembra un successo.
Verifica veloce
Sezione intitolata “Verifica veloce”Verifica veloce
1. Cosa fa il bind di un TcpListener su 127.0.0.1:0?
2. Perché gli esempi di questa pagina mandano un \n alla fine di ogni messaggio?
3. reqwest riceve una risposta 404 Not Found. Cosa restituisce send().await?
4. Il tuo server tokio gestisce ogni client con std::net e std::thread::sleep. Qual è il problema?
Punteggio: 0 / 4
Per approfondire
Sezione intitolata “Per approfondire”- std::net — libreria standard di Rust (in inglese):
TcpListener,TcpStream,UdpSockete gli indirizzi, con esempi. - Building a Single-Threaded Web Server — The Rust Book (in inglese): il progetto finale del Book costruisce un server HTTP su
std::net, poi aggiunge un pool di thread. - Tutorial di Tokio (in inglese): TCP asincrono passo per passo, dall’avvio dei task alla suddivisione dei messaggi.
- reqwest — docs.rs (in inglese): il client, le richieste, il JSON, i timeout e il modulo
blocking. - Web clients — Rust Cookbook (in inglese): ricette brevi per richieste HTTP, API e download.