Pular para o conteúdo

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.

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

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.

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 terminou
▶ Experimente você mesmo

Algumas coisas para reparar:

  • 127.0.0.1 quer dizer “só este computador”. Use 0.0.0.0 quando 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 o println! funciona na tela: ele acrescenta o \n. O read_line lê até chegar nele.
  • Qualquer chamada de rede pode falhar, então cada uma devolve um Result e usamos o ?.
  • O &stream serve para ler e para escrever, porque &TcpStream implementa tanto Read quanto Write.

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 palavras
▶ Experimente você mesmo

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

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 palavras
▶ Experimente você mesmo

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

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 um

O tokio::net::UdpSocket funciona do mesmo jeito, com .await.

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: ConnectionRefused
a leitura desistiu depois de 200 ms
▶ Experimente você mesmo

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

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:

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

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.

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.

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 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() 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.)

  • 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_string espera 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::net ou std::thread::sleep dentro de uma task do tokio bloqueia uma thread de trabalho inteira. Em código async, use tokio::net e tokio::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.

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 = 5
mul 4 5 = 20
add 2 x = erro: não é um número
div 8 2 = erro: operação desconhecida div
▶ Experimente você mesmo

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

Um 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::net te dá TCP bloqueante (TcpListener, TcpStream) e UDP (UdpSocket) sem nenhum crate extra.
  • Faça o bind na porta 0 para pegar qualquer porta livre, e descubra qual foi com local_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

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