Redes em Rust: servidores TCP e clientes HTTP
Quase todo programa que você usa conversa com outro computador: um app de chat, um jogo, um widget de previsão do tempo. Por baixo dos panos, eles abrem uma conexão, mandam alguns bytes e leem a resposta. O Rust é muito bom nisso. É rápido, gasta pouca memória, e o compilador pega muitos dos bugs que fazem código de rede quebrar às 3 da manhã.
Nesta página você vai escrever um pequeno servidor e um cliente TCP, atender vários clientes ao mesmo tempo (primeiro com threads, depois com tokio e async) e, por fim, chamar APIs HTTP de verdade com o reqwest. Os exemplos TCP rodam aqui mesmo, no Rust Playground. Os exemplos com reqwest precisam de internet, então você roda esses no seu computador com cargo run. Todos eles foram compilados e executados, e a saída mostrada é real.
O que você pode construir
Seção intitulada “O que você pode construir”- Serviços de rede e proxies. A Cloudflare escreveu em Rust o Pingora (em inglês), seu framework open source para proxies. O service mesh Linkerd (em inglês) também usa um proxy escrito em Rust.
- APIs web e back-ends, com axum em cima do tokio. Veja o Projeto: API web.
- Clientes de API e ferramentas: uma ferramenta de linha de comando que chama o GitHub, um bot, um script que confere se os seus sites estão no ar.
- Protocolos próprios: um servidor de jogo, um servidor de chat, um dispositivo que manda leituras de sensores por UDP.
Onde é mais difícil: async em Rust tem uma curva de aprendizado de verdade, e a primeira compilação com crates de rede leva um ou dois minutos. Para um script de 10 linhas que chama uma única API, talvez o Python seja mais rápido de escrever. O Rust compensa quando o programa precisa ser rápido, rodar por meses ou aguentar muitas conexões.
Qual crate usar?
Seção intitulada “Qual crate usar?”| Crate | Para que serve | Estilo | Escolha quando |
|---|---|---|---|
std::net |
Sockets TCP e UDP, já embutidos no Rust | bloqueante | Para aprender, ferramentas pequenas, poucas conexões. Sem dependências. |
| tokio | Runtime async com TCP, UDP e timers async | async | Muitas conexões ao mesmo tempo. A maioria dos crates de rede é construída em cima dele. |
| reqwest | Cliente HTTP amigável (JSON, timeouts, TLS) | async (bloqueante opcional) | Você chama APIs web. A primeira escolha mais comum. |
| ureq | Cliente HTTP pequeno e simples | bloqueante | Você não quer nada de async nem de tokio. |
| hyper | Motor HTTP de baixo nível por baixo do reqwest e do axum | async | Você está criando um framework ou precisa de controle total. |
| axum | Framework web para servidores HTTP | async | Você está escrevendo uma API web. Veja o projeto com axum. |
Todos eles são maduros e muito usados. Um bom caminho: aprenda as ideias com std::net e depois passe para tokio, reqwest e axum.
Um servidor e um cliente TCP no mesmo programa
Seção intitulada “Um servidor e um cliente TCP no mesmo programa”O TCP te dá um stream (fluxo) de bytes entre dois programas, como uma ligação telefônica. O servidor fica escutando (listen) em um endereço e uma porta. O cliente se conecta a ele. Depois disso, os dois lados podem ler e escrever.
Normalmente, servidor e cliente são dois programas separados. Aqui colocamos os dois num programa só, para ele rodar no Playground: o servidor roda numa thread, e o main é o cliente.
use std::io::{BufRead, BufReader, Write};use std::net::{TcpListener, TcpStream};use std::thread;
fn main() -> std::io::Result<()> { // Porta 0 significa "qualquer porta livre". Depois perguntamos qual veio. let listener = TcpListener::bind("127.0.0.1:0")?; let addr = listener.local_addr()?;
// O servidor: aceita um cliente, lê uma linha e responde com uma linha. 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, "Olá, {}!", name.trim())?; Ok(()) });
// O cliente: conecta, manda uma linha e lê a resposta. let stream = TcpStream::connect(addr)?; writeln!(&stream, "Ferris")?; let mut reply = String::new(); BufReader::new(&stream).read_line(&mut reply)?; print!("o cliente recebeu: {reply}");
server.join().unwrap()?; println!("o servidor terminou"); Ok(())}Saída:
o cliente recebeu: Olá, Ferris!o servidor terminouAlgumas coisas para reparar:
127.0.0.1quer dizer “só este computador”. Use0.0.0.0quando outras máquinas precisarem alcançar o seu servidor.- O TCP não faz ideia de onde uma mensagem termina. Usamos uma regra simples: uma mensagem = uma linha. O
writeln!funciona num stream do mesmo jeito que oprintln!funciona na tela: ele acrescenta o\n. Oread_linelê até chegar nele. - Qualquer chamada de rede pode falhar, então cada uma devolve um
Resulte usamos o?. - O
&streamserve para ler e para escrever, porque&TcpStreamimplementa tantoReadquantoWrite.
Vários clientes com threads
Seção intitulada “Vários clientes com threads”Um servidor de verdade não pode deixar o cliente número 2 esperando até o cliente número 1 terminar. A resposta clássica: uma thread por conexão. O laço aceita um cliente, passa esse cliente para uma thread nova e volta direto para o accept.
Este servidor conta as palavras de cada linha que recebe. Três clientes se conectam ao mesmo tempo:
use std::io::{BufRead, BufReader, Write};use std::net::{SocketAddr, TcpListener, TcpStream};use std::thread;
// Lado do servidor: responde cada linha com a quantidade de palavras.fn handle_client(stream: TcpStream) -> std::io::Result<()> { for line in BufReader::new(&stream).lines() { let words = line?.split_whitespace().count(); writeln!(&stream, "{words} palavras")?; } Ok(()) // o cliente fechou a conexão}
// Lado do cliente: manda uma linha e devolve a resposta.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)); // uma thread por cliente } });
// Três clientes ao mesmo tempo, cada um na sua própria thread. let clients = ["olá pessoal", "aprender rust é muito divertido", "até mais tarde"] .map(|msg| thread::spawn(move || ask(addr, msg))); // Espera por eles em ordem, para a saída sair sempre na mesma ordem. for c in clients { println!("{}", c.join().unwrap()?); } Ok(())}Saída:
"olá pessoal" -> 2 palavras"aprender rust é muito divertido" -> 5 palavras"até mais tarde" -> 3 palavrasQuando um cliente descarta (drop) o seu TcpStream, a conexão fecha, o lines() termina e a thread daquele cliente acaba. O laço de accept nunca termina, e isso é normal para um servidor. Aqui o programa simplesmente para quando o main retorna. Esse padrão é simples e funciona bem para dezenas ou centenas de clientes. Com milhares, todas essas threads custam muita memória. É aí que entra o async.
O mesmo servidor com tokio
Seção intitulada “O mesmo servidor com tokio”Com o tokio, cada cliente vira uma task (tarefa) em vez de uma thread. Uma task é minúscula, então um único servidor aguenta muitos milhares de conexões. Compare com a versão com threads lá de cima: std::net vira tokio::net, thread::spawn vira tokio::spawn, e você acrescenta .await em todo lugar onde o programa espera pela rede.
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};use tokio::net::{TcpListener, TcpStream};
// O mesmo servidor que conta palavras, mas cada cliente é uma task barata, não uma 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} palavras\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)); // uma task por cliente } });
// Inicia três clientes de uma vez e depois espera cada um, em ordem. let clients = ["olá pessoal", "aprender rust é muito divertido", "até mais tarde"] .map(|msg| tokio::spawn(ask(addr, msg))); for c in clients { println!("{}", c.await.unwrap()?); } Ok(())}Saída:
"olá pessoal" -> 2 palavras"aprender rust é muito divertido" -> 5 palavras"até mais tarde" -> 3 palavrasO into_split() te dá uma metade de leitura e uma metade de escrita, para que a leitura e a escrita possam ficar em variáveis diferentes. Como antes, o laço de accept roda para sempre. Quando o main retorna, o tokio para todas as tasks.
UDP em um minuto
Seção intitulada “UDP em um minuto”O UDP não tem conexão. Você manda mensagens avulsas (datagramas) para um endereço, e cada uma chega inteira ou não chega. Não há garantia de ordem nem de entrega. Ele é usado em jogos, chamadas de vídeo, DNS e sensores, onde a velocidade importa mais do que um pacote perdido. No std::net é um tipo só, o UdpSocket:
let socket = UdpSocket::bind("127.0.0.1:0")?;socket.send_to(b"temp=21.5", "127.0.0.1:9000")?; // uma mensagem, enviada para um endereço
let mut buf = [0u8; 1500];let (n, from) = socket.recv_from(&mut buf)?; // espera uma mensagem de qualquer umO tokio::net::UdpSocket funciona do mesmo jeito, com .await.
Timeouts e erros de rede
Seção intitulada “Timeouts e erros de rede”Numa rede, as coisas dão errado o tempo todo: ninguém está escutando, o Wi-Fi cai, um servidor trava. Duas regras mantêm o seu programa saudável: trate todo erro e nunca espere para sempre. Por padrão, uma leitura espera para sempre. O set_read_timeout resolve isso:
use std::io::{ErrorKind, Read};use std::net::{TcpListener, TcpStream};use std::time::Duration;
fn main() -> std::io::Result<()> { // 1. Ninguém está escutando: a conexão falha na hora. let free_port = TcpListener::bind("127.0.0.1:0")?.local_addr()?; // descartado = fechado match TcpStream::connect(free_port) { Ok(_) => println!("conectou?!"), Err(e) => println!("falha ao conectar: {:?}", e.kind()), }
// 2. Um servidor que aceita, mas nunca responde. 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!("li {n} bytes"), // O Linux informa WouldBlock, o Windows informa TimedOut. Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => { println!("a leitura desistiu depois de 200 ms") } Err(e) => println!("outro erro: {e}"), } Ok(())}Saída:
falha ao conectar: ConnectionRefuseda leitura desistiu depois de 200 msO e.kind() te dá um ErrorKind (em inglês) que você pode usar num match. ConnectionRefused (conexão recusada) é o nome que o próprio Rust dá a esse tipo de erro, por isso aparece em inglês. Também existe o TcpStream::connect_timeout para conexões lentas. No tokio, envolva qualquer future com tokio::time::timeout:
let result = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await;// Err(_) = demorou demais; Ok(inner) = a leitura terminou (inner é o Result dela)HTTP e JSON com reqwest
Seção intitulada “HTTP e JSON com reqwest”O HTTP é a língua da web, e ele roda em cima do TCP. Você poderia escrever HTTP na mão sobre um TcpStream, mas um crate de cliente cuida dos detalhes para você: HTTPS, redirecionamentos, cabeçalhos, compressão. Estes exemplos precisam de acesso à internet e do crate reqwest, então eles não rodam no Playground. Rode no seu computador:
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"] }Vamos usar o JSONPlaceholder (em inglês), uma API falsa e gratuita feita para treinar. A primeira compilação baixa e compila muitos crates, então tenha um minuto de paciência.
GET e POST com JSON
Seção intitulada “GET e POST com JSON”reqwest::get(url).await?.text().await? te dá o corpo da resposta como uma String. Mas para JSON existe algo melhor: com a feature json, o reqwest usa o serde para transformar a resposta direto na sua struct, e para transformar a sua struct no corpo da requisição:
use serde::{Deserialize, Serialize};
// Só os campos que você lista são lidos. Campos extras do JSON (como "userId") são ignorados.#[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 -> sua 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: sua struct -> JSON (esta API de teste responde, mas não salva de verdade) 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}Crie um único Client e reutilize. Ele mantém as conexões abertas e as reaproveita, o que deixa as próximas requisições mais rápidas. O .json(&new) também coloca o cabeçalho Content-Type: application/json para você. (O título “delectus aut autem” é só texto de exemplo em pseudolatim que vem da própria API.)
Não quer async? O ureq é um cliente pequeno e bloqueante: ureq::get(url).call()?.body_mut().read_to_string()? te dá o corpo como uma String, sem precisar de tokio.
Timeouts e erros com reqwest
Seção intitulada “Timeouts e erros com reqwest”O reqwest não tem timeout por padrão, então um servidor travado pode deixar o seu programa esperando para sempre. Defina um no cliente. Repare também: um 404 Not Found não é um erro para o reqwest. A requisição funcionou, a resposta é que foi “não”. Chame error_for_status() para transformar respostas 4xx e 5xx em erros:
use std::time::Duration;
// Transforma respostas 4xx/5xx em erros também, não só os problemas de rede.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> { // Sem timeout, uma requisição para um servidor travado pode esperar para sempre. let client = reqwest::Client::builder() .timeout(Duration::from_secs(2)) .build()?;
// Um servidor local que aceita conexões, mas nunca responde. 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() dizem que tipo de falha aconteceu, para você decidir o que fazer: tentar de novo mais tarde, mostrar uma mensagem ou desistir. Num programa maior, o anyhow facilita acrescentar contexto a esses erros. (As mensagens desses dois exemplos ficaram em inglês porque a saída mostrada é a de uma execução real desse código, com acesso à internet.)
Erros comuns
Seção intitulada “Erros comuns”- Os dois lados esperando um pelo outro. O cliente chama
read_line, mas o servidor também está lendo, porque o cliente esqueceu o\n. Ninguém nunca manda nada, e o programa trava. Combinem onde uma mensagem termina (uma quebra de linha é a regra mais fácil). - Ler “até o fim” cedo demais. O
read_to_stringespera até o outro lado fechar a conexão. Numa conexão que continua aberta, ele nunca retorna. Leia linha por linha. - Bloquear dentro de código async. Usar
std::netoustd::thread::sleepdentro de uma task do tokio bloqueia uma thread de trabalho inteira. Em código async, usetokio::netetokio::time::sleep. - Esquecer os timeouts. Seus testes passam numa rede local rápida. Aí um servidor lento congela tudo. Defina timeouts desde o primeiro dia.
Prática
Seção intitulada “Prática”1. Um servidor calculadora
Seção intitulada “1. Um servidor calculadora”Escreva um servidor que lê linhas como add 2 3 ou mul 4 5 e responde com o resultado. Para uma linha inválida, responda com uma mensagem curta erro: .... Teste a partir do main com uma conexão e quatro perguntas. Dica: quebre a linha com split_whitespace(), converta os números com parse::<i64>() e responda com writeln!.
Ver solução
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 "erro: use <op> <a> <b>".to_string(); }; let (Ok(a), Ok(b)) = (a.parse::<i64>(), b.parse::<i64>()) else { return "erro: não é um número".to_string(); }; match op { "add" => (a + b).to_string(), "mul" => (a * b).to_string(), _ => format!("erro: operação desconhecida {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); // fechar a conexão encerra o laço do servidor server.join().unwrap()}Saída:
add 2 3 = 5mul 4 5 = 20add 2 x = erro: não é um númerodiv 8 2 = erro: operação desconhecida divDeixar a lógica numa função comum, a calculate, significa que você pode testá-la sem rede nenhuma. As linhas com let ... else retornam mais cedo quando a entrada não tem o formato certo.
2. Contar as tarefas concluídas (na sua máquina)
Seção intitulada “2. Contar as tarefas concluídas (na sua máquina)”Usando o reqwest, busque https://jsonplaceholder.typicode.com/todos?userId=1. Ele devolve um array JSON de tarefas. Mostre quantas são, quantas estão concluídas e o título da primeira que ainda não está. Dica: desserialize para Vec<Todo> e depois use métodos de iterador como filter e find.
Ver solução
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 autemUm array JSON vira um Vec, e o serde preenche cada elemento para você. A sua struct só lista os campos de que você precisa.
- O
std::nette dá TCP bloqueante (TcpListener,TcpStream) e UDP (UdpSocket) sem nenhum crate extra. - Faça o bind na porta
0para pegar qualquer porta livre, e descubra qual foi comlocal_addr(). - O TCP é um fluxo de bytes: combine onde uma mensagem termina, por exemplo uma mensagem por linha.
- Atenda muitos clientes com uma thread por conexão, ou com tasks do tokio quando precisar de milhares.
- O reqwest (async) ou o ureq (bloqueante) chamam APIs HTTP, e o serde transforma o JSON nas suas structs.
- Sempre defina timeouts, e use
error_for_status()para que um 404 não pareça sucesso.
Teste o que aprendeu
Seção intitulada “Teste o que aprendeu”Teste o que aprendeu
1. O que acontece ao fazer o bind de um TcpListener em 127.0.0.1:0?
2. Por que os exemplos desta página mandam um \n no fim de cada mensagem?
3. O reqwest recebe uma resposta 404 Not Found. O que o send().await devolve?
4. O seu servidor tokio atende cada cliente com std::net e std::thread::sleep. Qual é o problema?
Pontuação: 0 / 4
Para saber mais
Seção intitulada “Para saber mais”- std::net — biblioteca padrão do Rust (em inglês):
TcpListener,TcpStream,UdpSockete endereços, com exemplos. - Building a Single-Threaded Web Server — The Rust Book (em inglês): o projeto final do livro constrói um servidor HTTP com
std::nete depois acrescenta um pool de threads. - Tutorial do Tokio (em inglês): TCP async passo a passo, de criar tasks até delimitar mensagens.
- reqwest — docs.rs (em inglês): o cliente, requisições, JSON, timeouts e o módulo
blocking. - Web clients — Rust Cookbook (em inglês): receitas curtas para requisições HTTP, APIs e downloads.