Salta ai contenuti

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.

  • 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.

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.

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 terminato
▶ Provalo tu

Alcune cose da notare:

  • 127.0.0.1 significa «solo questo computer». Usa 0.0.0.0 quando 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 come println! funziona sullo schermo: aggiunge il \n. read_line legge fino a lì.
  • Ogni chiamata di rete può fallire, quindi ognuna restituisce un Result e usiamo ?.
  • &stream funziona sia per leggere sia per scrivere, perché &TcpStream implementa sia Read sia Write.

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 parole
▶ Provalo tu

Quando 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.

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 parole
▶ Provalo tu

into_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 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 chiunque

tokio::net::UdpSocket funziona allo stesso modo, con .await.

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: ConnectionRefused
lettura scaduta dopo 200 ms
▶ Provalo tu

e.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 è 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:

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"] }

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.

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.

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 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

is_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.

  • 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_string aspetta 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::net o std::thread::sleep dentro un task di tokio blocca un intero thread di lavoro. Nel codice asincrono usa tokio::net e tokio::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.

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 = 5
mul 4 5 = 20
add 2 x = errore: non è un numero
div 8 2 = errore: operazione sconosciuta div
▶ Provalo tu

Tenere 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.

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 completed
first open one: delectus aut autem

L’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.

  • std::net ti dà TCP bloccante (TcpListener, TcpStream) e UDP (UdpSocket) senza crate aggiuntivi.
  • Fai il bind sulla porta 0 per ottenere una porta libera qualsiasi, e leggila con local_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

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