Le réseau en Rust : serveurs TCP et clients HTTP
Presque tous les programmes que vous utilisez parlent à un autre ordinateur : une messagerie, un jeu, un widget météo. En coulisses, ils ouvrent une connexion, envoient quelques octets et lisent la réponse. Rust est très doué pour ça. Il est rapide, consomme peu de mémoire, et le compilateur attrape beaucoup des bugs qui font planter le code réseau à 3 heures du matin.
Sur cette page, vous allez écrire un petit serveur et un petit client TCP, servir plusieurs clients à la fois (d’abord avec des threads, puis avec tokio en async), puis appeler de vraies API HTTP avec reqwest. Les exemples TCP tournent directement ici, dans le Playground Rust. Les exemples reqwest ont besoin d’Internet : vous les lancerez donc sur votre propre ordinateur avec cargo run. Chacun d’eux a été compilé et exécuté, et la sortie affichée est réelle.
Ce que vous pouvez construire
Section intitulée « Ce que vous pouvez construire »- Des services réseau et des proxys. Cloudflare a écrit en Rust Pingora (en anglais), son framework open source pour les proxys. Le service mesh Linkerd (en anglais) utilise lui aussi un proxy écrit en Rust.
- Des API web et des back ends (la partie serveur), avec axum par-dessus tokio. Voir Projet : API web avec Axum.
- Des clients d’API et des outils : un outil en ligne de commande qui appelle GitHub, un bot, un script qui vérifie que vos sites répondent.
- Des protocoles maison : un serveur de jeu, un serveur de chat, un appareil qui envoie des mesures de capteurs en UDP.
Là où c’est plus difficile : l’async en Rust demande un vrai temps d’apprentissage, et la première compilation avec des crates réseau prend une minute ou deux. Pour un script de 10 lignes qui appelle une seule API, Python sera peut-être plus rapide à écrire. Rust devient payant quand le programme doit être rapide, tourner pendant des mois ou gérer beaucoup de connexions.
Quelle crate choisir ?
Section intitulée « Quelle crate choisir ? »| Crate | À quoi elle sert | Style | Choisissez-la quand |
|---|---|---|---|
std::net |
Sockets TCP et UDP, intégrés à Rust | bloquant | Pour apprendre, pour de petits outils, pour quelques connexions. Aucune dépendance. |
| tokio | Runtime async avec TCP, UDP et minuteries async | async | Beaucoup de connexions à la fois. La plupart des crates réseau s’appuient dessus. |
| reqwest | Client HTTP agréable (JSON, timeouts, TLS) | async (bloquant en option) | Vous appelez des API web. Le premier choix habituel. |
| ureq | Client HTTP petit et simple | bloquant | Vous ne voulez ni async ni tokio. |
| hyper | Moteur HTTP bas niveau, sous reqwest et axum | async | Vous construisez un framework ou voulez un contrôle total. |
| axum | Framework web pour serveurs HTTP | async | Vous écrivez une API web. Voir le projet axum. |
Toutes ces crates sont mûres et très utilisées. Un bon parcours : apprendre les idées avec std::net, puis passer à tokio, reqwest et axum.
Un serveur et un client TCP dans un seul programme
Section intitulée « Un serveur et un client TCP dans un seul programme »TCP vous donne un flux (stream) d’octets entre deux programmes, un peu comme un appel téléphonique. Le serveur écoute sur une adresse et un port. Le client s’y connecte. Ensuite, les deux côtés peuvent lire et écrire.
D’habitude, le serveur et le client sont deux programmes séparés. Ici, on les met dans un seul programme pour qu’il tourne dans le Playground : le serveur tourne dans un thread, et main joue le client.
use std::io::{BufRead, BufReader, Write};use std::net::{TcpListener, TcpStream};use std::thread;
fn main() -> std::io::Result<()> { // Le port 0 veut dire « n'importe quel port libre ». Ensuite, on demande lequel on a eu. let listener = TcpListener::bind("127.0.0.1:0")?; let addr = listener.local_addr()?;
// Le serveur : accepte un client, lit une ligne, répond par une ligne. 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, "Bonjour, {} !", name.trim())?; Ok(()) });
// Le client : se connecte, envoie une ligne, lit la réponse. let stream = TcpStream::connect(addr)?; writeln!(&stream, "Ferris")?; let mut reply = String::new(); BufReader::new(&stream).read_line(&mut reply)?; print!("le client a reçu : {reply}");
server.join().unwrap()?; println!("le serveur a terminé"); Ok(())}Sortie :
le client a reçu : Bonjour, Ferris !le serveur a terminéQuelques points à remarquer :
127.0.0.1veut dire « cet ordinateur uniquement ». Utilisez0.0.0.0quand d’autres machines doivent pouvoir joindre votre serveur.- TCP ne sait pas où un message se termine. On utilise une règle simple : un message = une ligne.
writeln!fonctionne sur un stream commeprintln!sur l’écran : il ajoute le\n.read_linelit jusqu’à lui. - Chaque appel réseau peut échouer, donc chacun renvoie un
Resultet on utilise?. &streamsert à lire et à écrire, car&TcpStreamimplémente à la foisReadetWrite.
Plusieurs clients avec des threads
Section intitulée « Plusieurs clients avec des threads »Un vrai serveur ne doit pas faire attendre le client numéro 2 jusqu’à ce que le client numéro 1 ait fini. La réponse classique : un thread par connexion. La boucle accepte un client, le confie à un nouveau thread, et retourne aussitôt à accept.
Ce serveur compte les mots de chaque ligne qu’il reçoit. Trois clients se connectent en même temps :
use std::io::{BufRead, BufReader, Write};use std::net::{SocketAddr, TcpListener, TcpStream};use std::thread;
// Côté serveur : répond à chaque ligne avec son nombre de mots.fn handle_client(stream: TcpStream) -> std::io::Result<()> { for line in BufReader::new(&stream).lines() { let words = line?.split_whitespace().count(); writeln!(&stream, "{words} mots")?; } Ok(()) // le client a fermé la connexion}
// Côté client : envoie une ligne, renvoie la réponse.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 par client } });
// Trois clients en même temps, chacun dans son propre thread. let clients = ["salut tout le monde", "rust est amusant à apprendre", "à bientôt"] .map(|msg| thread::spawn(move || ask(addr, msg))); // On les attend dans l'ordre, pour que la sortie soit toujours la même. for c in clients { println!("{}", c.join().unwrap()?); } Ok(())}Sortie :
"salut tout le monde" -> 4 mots"rust est amusant à apprendre" -> 5 mots"à bientôt" -> 2 motsQuand un client lâche (drop) son TcpStream, la connexion se ferme, lines() s’arrête, et le thread de ce client se termine. La boucle d’acceptation, elle, ne s’arrête jamais, ce qui est normal pour un serveur. Ici, le programme s’arrête simplement quand main se termine. Ce modèle est simple et fonctionne bien pour des dizaines ou des centaines de clients. Avec des milliers, tous ces threads coûtent beaucoup de mémoire. C’est là qu’intervient l’async.
Le même serveur avec tokio
Section intitulée « Le même serveur avec tokio »Avec tokio, chaque client est une tâche (task) au lieu d’un thread. Une tâche est minuscule, donc un seul serveur peut tenir plusieurs milliers de connexions. Comparez avec la version à threads ci-dessus : std::net devient tokio::net, thread::spawn devient tokio::spawn, et vous ajoutez .await partout où le programme attend le réseau.
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};use tokio::net::{TcpListener, TcpStream};
// Le même serveur qui compte les mots, mais chaque client est une tâche légère, pas 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} mots\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)); // une tâche par client } });
// On lance trois clients d'un coup, puis on attend chacun dans l'ordre. let clients = ["salut tout le monde", "rust est amusant à apprendre", "à bientôt"] .map(|msg| tokio::spawn(ask(addr, msg))); for c in clients { println!("{}", c.await.unwrap()?); } Ok(())}Sortie :
"salut tout le monde" -> 4 mots"rust est amusant à apprendre" -> 5 mots"à bientôt" -> 2 motsinto_split() vous donne une moitié pour lire et une moitié pour écrire, pour que la lecture et l’écriture puissent vivre dans des variables différentes. Comme avant, la boucle d’acceptation tourne indéfiniment. Quand main se termine, tokio arrête toutes les tâches.
UDP en une minute
Section intitulée « UDP en une minute »UDP n’a pas de connexion. Vous envoyez des messages isolés (des datagrammes) à une adresse, et chacun arrive en entier ou pas du tout. Ni l’ordre ni la livraison ne sont garantis. On s’en sert pour les jeux, les appels vidéo, le DNS et les capteurs, là où la vitesse compte plus qu’un paquet perdu. Dans std::net, c’est un seul type, UdpSocket :
let socket = UdpSocket::bind("127.0.0.1:0")?;socket.send_to(b"temp=21.5", "127.0.0.1:9000")?; // un message, envoyé à une adresse
let mut buf = [0u8; 1500];let (n, from) = socket.recv_from(&mut buf)?; // attend un message, de n'importe quitokio::net::UdpSocket fonctionne de la même façon, avec .await.
Timeouts et erreurs réseau
Section intitulée « Timeouts et erreurs réseau »Sur un réseau, les choses tournent mal en permanence : personne n’écoute, le Wi-Fi décroche, un serveur se fige. Deux règles gardent votre programme en bonne santé : traiter chaque erreur, et ne jamais attendre indéfiniment. Par défaut, une lecture attend indéfiniment. set_read_timeout règle ce problème :
use std::io::{ErrorKind, Read};use std::net::{TcpListener, TcpStream};use std::time::Duration;
fn main() -> std::io::Result<()> { // 1. Personne n'écoute : la connexion échoue tout de suite. let free_port = TcpListener::bind("127.0.0.1:0")?.local_addr()?; // supprimé = fermé match TcpStream::connect(free_port) { Ok(_) => println!("connecté ?!"), Err(e) => println!("échec de la connexion : {:?}", e.kind()), }
// 2. Un serveur qui accepte mais ne répond jamais. 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!("{n} octets lus"), // Linux signale WouldBlock, Windows signale TimedOut. Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => { println!("lecture abandonnée après 200 ms") } Err(e) => println!("autre erreur : {e}"), } Ok(())}Sortie :
échec de la connexion : ConnectionRefusedlecture abandonnée après 200 msConnectionRefused est le nom de la variante d’erreur affiché par Rust : la connexion a été refusée, car personne n’écoutait sur ce port. e.kind() vous donne un ErrorKind (en anglais) sur lequel vous pouvez faire un match. Il existe aussi TcpStream::connect_timeout pour les connexions lentes. Avec tokio, enveloppez n’importe quel future dans tokio::time::timeout :
let result = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await;// Err(_) = trop long ; Ok(inner) = la lecture s'est terminée (inner est son propre Result)HTTP et JSON avec reqwest
Section intitulée « HTTP et JSON avec reqwest »HTTP est la langue du web, et il fonctionne par-dessus TCP. Vous pourriez écrire du HTTP à la main sur un TcpStream, mais une crate cliente gère les détails à votre place : HTTPS, redirections, en-têtes, compression. Ces exemples ont besoin d’un accès à Internet et de la crate reqwest, donc ils ne tournent pas dans le Playground. Lancez-les sur votre ordinateur :
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"] }Nous allons utiliser JSONPlaceholder (en anglais), une fausse API gratuite faite pour s’entraîner. La première compilation télécharge et compile pas mal de crates : laissez-lui une minute.
GET et POST en JSON
Section intitulée « GET et POST en JSON »reqwest::get(url).await?.text().await? vous donne le corps de la réponse sous forme de String. Mais pour du JSON, il y a mieux : avec la feature json, reqwest utilise serde pour transformer la réponse directement en votre struct, et pour transformer votre struct en corps de requête :
use serde::{Deserialize, Serialize};
// Seuls les champs listés sont lus. Les champs JSON en plus (comme "userId") sont ignorés.#[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 -> votre 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 : votre struct -> JSON (cette API de test répond, mais n'enregistre rien pour de vrai) 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}(Les titres de JSONPlaceholder sont du faux latin, c’est normal.) Créez un seul Client et réutilisez-le. Il garde les connexions ouvertes et les réutilise, ce qui accélère les requêtes suivantes. .json(&new) ajoute aussi pour vous l’en-tête Content-Type: application/json.
Vous ne voulez pas d’async ? ureq est un petit client bloquant : ureq::get(url).call()?.body_mut().read_to_string()? vous donne le corps sous forme de String, sans tokio.
Timeouts et erreurs avec reqwest
Section intitulée « Timeouts et erreurs avec reqwest »reqwest n’a aucun timeout par défaut : un serveur bloqué peut donc faire attendre votre programme indéfiniment. Fixez-en un sur le client. Autre point important : un 404 Not Found n’est pas une erreur pour reqwest. La requête a fonctionné, la réponse était simplement « non ». Appelez error_for_status() pour transformer les réponses 4xx et 5xx en erreurs :
use std::time::Duration;
// Transforme aussi les réponses 4xx/5xx en erreurs, pas seulement les problèmes réseau.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> { // Sans timeout, une requête vers un serveur bloqué peut attendre indéfiniment. let client = reqwest::Client::builder() .timeout(Duration::from_secs(2)) .build()?;
// Un serveur local qui accepte les connexions mais ne répond jamais. 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 secondsDans l’ordre : la première adresse répond (83 octets), la deuxième renvoie 404, la troisième refuse la connexion, et la quatrième n’a jamais répondu avant la fin des 2 secondes. is_status(), is_connect() et is_timeout() vous disent quel genre d’échec s’est produit, pour que vous puissiez décider quoi faire : réessayer plus tard, afficher un message ou abandonner. Dans un programme plus gros, anyhow permet d’ajouter facilement du contexte à ces erreurs.
Erreurs courantes
Section intitulée « Erreurs courantes »- Les deux côtés s’attendent l’un l’autre. Le client appelle
read_line, mais le serveur est lui aussi en train de lire, parce que le client a oublié le\n. Personne n’envoie jamais rien, et le programme se fige. Mettez-vous d’accord sur l’endroit où un message se termine (un retour à la ligne est la règle la plus simple). - Lire « jusqu’à la fin » trop tôt.
read_to_stringattend que l’autre côté ferme la connexion. Sur une connexion qui reste ouverte, il ne rend jamais la main. Lisez plutôt ligne par ligne. - Bloquer dans du code async. Utiliser
std::netoustd::thread::sleepdans une tâche tokio bloque tout un thread de travail (worker). En code async, utiliseztokio::netettokio::time::sleep. - Oublier les timeouts. Vos tests passent sur un réseau local rapide. Puis un seul serveur lent fige tout. Mettez des timeouts dès le premier jour.
Exercices
Section intitulée « Exercices »1. Un serveur calculatrice
Section intitulée « 1. Un serveur calculatrice »Écrivez un serveur qui lit des lignes comme add 2 3 ou mul 4 5 et répond avec le résultat. Pour une ligne incorrecte, répondez par un court message erreur : .... Testez-le depuis main avec une seule connexion et quatre questions. Indice : découpez la ligne avec split_whitespace(), convertissez les nombres avec parse::<i64>(), et répondez avec writeln!.
Voir la solution
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 "erreur : utilisez <op> <a> <b>".to_string(); }; let (Ok(a), Ok(b)) = (a.parse::<i64>(), b.parse::<i64>()) else { return "erreur : ce n'est pas un nombre".to_string(); }; match op { "add" => (a + b).to_string(), "mul" => (a * b).to_string(), _ => format!("erreur : opération inconnue {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); // fermer la connexion met fin à la boucle du serveur server.join().unwrap()}Sortie :
add 2 3 = 5mul 4 5 = 20add 2 x = erreur : ce n'est pas un nombrediv 8 2 = erreur : opération inconnue divGarder la logique dans une simple fonction calculate vous permet de la tester sans aucun réseau. Les lignes let ... else sortent tout de suite quand l’entrée n’a pas la bonne forme.
2. Compter les tâches terminées (sur votre machine)
Section intitulée « 2. Compter les tâches terminées (sur votre machine) »Avec reqwest, récupérez https://jsonplaceholder.typicode.com/todos?userId=1. Cette adresse renvoie un tableau JSON de tâches. Affichez combien il y en a, combien sont terminées, et le titre de la première qui ne l’est pas. Indice : désérialisez dans un Vec<Todo>, puis utilisez des méthodes d’itérateur comme filter et find.
Voir la solution
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 autemUn tableau JSON correspond à un Vec, et serde remplit chaque élément pour vous. Votre struct ne liste que les champs dont vous avez besoin.
Récapitulatif
Section intitulée « Récapitulatif »std::netvous donne du TCP bloquant (TcpListener,TcpStream) et de l’UDP (UdpSocket) sans crate supplémentaire.- Liez-vous (bind) au port
0pour obtenir n’importe quel port libre, et relisez-le aveclocal_addr(). - TCP est un flux d’octets : mettez-vous d’accord sur l’endroit où un message se termine, par exemple un message par ligne.
- Servez plusieurs clients avec un thread par connexion, ou avec des tâches tokio quand il vous en faut des milliers.
- reqwest (async) ou ureq (bloquant) appellent des API HTTP, et serde transforme le JSON en vos structs.
- Mettez toujours des timeouts, et utilisez
error_for_status()pour qu’un 404 ne ressemble pas à un succès.
Petit quiz
Section intitulée « Petit quiz »Petit quiz
1. Que fait un TcpListener lié (bind) à 127.0.0.1:0 ?
2. Pourquoi les exemples de cette page envoient-ils un \n à la fin de chaque message ?
3. reqwest reçoit une réponse 404 Not Found. Que renvoie send().await ?
4. Votre serveur tokio traite chaque client avec std::net et std::thread::sleep. Quel est le problème ?
Score : 0 / 4
Pour aller plus loin
Section intitulée « Pour aller plus loin »- std::net — la bibliothèque standard de Rust (en anglais) :
TcpListener,TcpStream,UdpSocketet les adresses, avec des exemples. - Building a Single-Threaded Web Server — The Rust Book (en anglais) : le projet final du Book construit un serveur HTTP sur
std::net, puis ajoute un pool de threads. - Tokio tutorial (en anglais) : le TCP async pas à pas, du lancement de tâches au découpage des messages.
- reqwest — docs.rs (en anglais) : le client, les requêtes, le JSON, les timeouts et le module
blocking. - Web clients — Rust Cookbook (en anglais) : de courtes recettes pour les requêtes HTTP, les API et les téléchargements.