Перейти к содержимому

Сети в Rust: TCP-серверы и HTTP-клиенты

Почти каждая программа, которой ты пользуешься, общается с другим компьютером: мессенджер, игра, виджет погоды. Внутри они открывают соединение, отправляют несколько байтов и читают ответ. Rust очень хорош в этом. Он быстрый, расходует мало памяти, а компилятор ловит многие ошибки, из-за которых сетевой код падает в три часа ночи.

На этой странице ты напишешь маленький TCP-сервер и клиент, научишься обслуживать несколько клиентов одновременно (сначала с потоками, потом с асинхронным tokio), а затем будешь вызывать настоящие HTTP API через reqwest. Примеры с TCP запускаются прямо здесь, в Rust Playground. Примерам с reqwest нужен интернет, поэтому их ты запустишь у себя через cargo run. Каждый из них был собран и запущен, и показанный вывод — настоящий.

  • Сетевые сервисы и прокси. Cloudflare написала на Rust Pingora (на английском) — свой фреймворк с открытым кодом для прокси. Сервисная сетка Linkerd (на английском) тоже использует прокси на Rust.
  • Веб-API и бэкенды — на axum поверх tokio. См. Проект: веб-API.
  • Клиенты API и утилиты: программа командной строки, которая обращается к GitHub, бот, скрипт, проверяющий, работают ли твои сайты.
  • Собственные протоколы: игровой сервер, чат-сервер, устройство, которое отправляет показания датчиков по UDP.

Где сложнее: у асинхронного Rust заметный порог входа, а первая сборка с сетевыми крейтами занимает минуту-другую. Для скрипта на 10 строк, который вызывает один API, Python может оказаться быстрее в написании. Rust окупается, когда программа должна быть быстрой, работать месяцами или держать много соединений.

Крейт Для чего Стиль Когда выбирать
std::net Сокеты TCP и UDP, встроены в Rust блокирующий Для учёбы, маленьких утилит, нескольких соединений. Без зависимостей.
tokio Асинхронная среда выполнения: async TCP, UDP, таймеры async Много соединений одновременно. На нём построено большинство сетевых крейтов.
reqwest Удобный HTTP-клиент (JSON, тайм-ауты, TLS) async (есть и блокирующий режим) Ты вызываешь веб-API. Обычно первый выбор.
ureq Маленький простой HTTP-клиент блокирующий Не хочешь ни async, ни tokio.
hyper Низкоуровневый HTTP-движок под reqwest и axum async Пишешь фреймворк или нужен полный контроль.
axum Веб-фреймворк для HTTP-серверов async Пишешь веб-API. См. проект на axum.

Все они зрелые и широко используются (документация у всех на английском). Хороший путь: понять идеи на std::net, а потом перейти на tokio, reqwest и axum.

TCP даёт поток байтов между двумя программами, как телефонный звонок. Сервер слушает адрес и порт. Клиент подключается к нему. После этого обе стороны могут читать и писать.

Обычно сервер и клиент — это две разные программы. Здесь мы поместили обоих в одну, чтобы она запускалась в Playground: сервер работает в потоке, а main — это клиент.

use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn main() -> std::io::Result<()> {
// Порт 0 значит «любой свободный порт». Потом спрашиваем, какой достался.
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()?;
let mut name = String::new();
BufReader::new(&stream).read_line(&mut name)?;
writeln!(&stream, "Привет, {}!", name.trim())?;
Ok(())
});
// Клиент: подключиться, отправить одну строку, прочитать ответ.
let stream = TcpStream::connect(addr)?;
writeln!(&stream, "Ferris")?;
let mut reply = String::new();
BufReader::new(&stream).read_line(&mut reply)?;
print!("клиент получил: {reply}");
server.join().unwrap()?;
println!("сервер завершил работу");
Ok(())
}

Вывод:

клиент получил: Привет, Ferris!
сервер завершил работу
▶ Попробуй сам

На что обратить внимание:

  • 127.0.0.1 значит «только этот компьютер». Используй 0.0.0.0, если до сервера должны достучаться другие машины.
  • TCP понятия не имеет, где заканчивается одно сообщение. Мы используем простое правило: одно сообщение = одна строка. writeln! работает с потоком так же, как println! с экраном: добавляет \n. А read_line читает до него.
  • Любой сетевой вызов может закончиться неудачей, поэтому каждый возвращает Result, и мы пишем ?.
  • &stream подходит и для чтения, и для записи, потому что &TcpStream реализует и Read, и Write.

Настоящий сервер не должен заставлять клиента номер 2 ждать, пока закончит клиент номер 1. Классический ответ: один поток на соединение. Цикл принимает клиента, передаёт его новому потоку и сразу возвращается к accept.

Этот сервер считает слова в каждой полученной строке. Три клиента подключаются одновременно:

use std::io::{BufRead, BufReader, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::thread;
// Сторона сервера: на каждую строку отвечаем числом слов в ней.
fn handle_client(stream: TcpStream) -> std::io::Result<()> {
for line in BufReader::new(&stream).lines() {
let words = line?.split_whitespace().count();
writeln!(&stream, "слов: {words}")?;
}
Ok(()) // клиент закрыл соединение
}
// Сторона клиента: отправить одну строку и вернуть ответ.
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)); // один поток на клиента
}
});
// Три клиента одновременно, каждый в своём потоке.
let clients = ["привет всем", "мне нравится учить язык rust", "до скорой встречи"]
.map(|msg| thread::spawn(move || ask(addr, msg)));
// Ждём их по порядку, чтобы вывод всегда шёл в одном и том же порядке.
for c in clients {
println!("{}", c.join().unwrap()?);
}
Ok(())
}

Вывод:

"привет всем" -> слов: 2
"мне нравится учить язык rust" -> слов: 5
"до скорой встречи" -> слов: 3
▶ Попробуй сам

Когда клиент уничтожает свой TcpStream, соединение закрывается, lines() заканчивается, и поток этого клиента завершается. Цикл accept не заканчивается никогда — для сервера это нормально. Здесь программа просто останавливается, когда возвращается main. Этот подход простой и хорошо работает для десятков или сотен клиентов. Но когда их тысячи, все эти потоки съедают много памяти. Тут и пригождается async.

С tokio каждый клиент — это задача (task), а не поток. Задача крошечная, поэтому один сервер может держать многие тысячи соединений. Сравни с версией на потоках выше: std::net превращается в tokio::net, thread::spawn — в tokio::spawn, и везде, где программа ждёт сеть, добавляется .await.

use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
// Тот же сервер подсчёта слов, но каждый клиент — дешёвая задача, а не поток.
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}\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)); // одна задача на клиента
}
});
// Запускаем трёх клиентов сразу, потом ждём каждого по порядку.
let clients = ["привет всем", "мне нравится учить язык rust", "до скорой встречи"]
.map(|msg| tokio::spawn(ask(addr, msg)));
for c in clients {
println!("{}", c.await.unwrap()?);
}
Ok(())
}

Вывод:

"привет всем" -> слов: 2
"мне нравится учить язык rust" -> слов: 5
"до скорой встречи" -> слов: 3
▶ Попробуй сам

into_split() даёт половину для чтения и половину для записи, так что чтение и запись могут жить в разных переменных. Как и раньше, цикл accept работает бесконечно. Когда main возвращается, tokio останавливает все задачи.

В UDP нет соединения. Ты отправляешь отдельные сообщения (датаграммы) на адрес, и каждое приходит целиком или не приходит вовсе. Ни порядок, ни доставка не гарантируются. UDP используют в играх, видеозвонках, DNS и датчиках — там, где скорость важнее потерянного пакета. В std::net это один тип, UdpSocket:

let socket = UdpSocket::bind("127.0.0.1:0")?;
socket.send_to(b"temp=21.5", "127.0.0.1:9000")?; // одно сообщение на один адрес
let mut buf = [0u8; 1500];
let (n, from) = socket.recv_from(&mut buf)?; // ждёт одно сообщение от кого угодно

tokio::net::UdpSocket работает так же, только с .await.

В сети постоянно что-то ломается: никто не слушает порт, отваливается Wi-Fi, сервер зависает. Два правила помогают программе оставаться здоровой: обрабатывай каждую ошибку и никогда не жди вечно. По умолчанию чтение ждёт бесконечно. Это исправляет set_read_timeout:

use std::io::{ErrorKind, Read};
use std::net::{TcpListener, TcpStream};
use std::time::Duration;
fn main() -> std::io::Result<()> {
// 1. Никто не слушает: подключение сразу не удаётся.
let free_port = TcpListener::bind("127.0.0.1:0")?.local_addr()?; // уничтожен = закрыт
match TcpStream::connect(free_port) {
Ok(_) => println!("подключились?!"),
Err(e) => println!("не удалось подключиться: {:?}", e.kind()),
}
// 2. Сервер, который принимает соединение, но никогда не отвечает.
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}"),
// Linux сообщает WouldBlock, Windows — TimedOut.
Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {
println!("чтение прервано по тайм-ауту через 200 мс")
}
Err(e) => println!("другая ошибка: {e}"),
}
Ok(())
}

Вывод:

не удалось подключиться: ConnectionRefused
чтение прервано по тайм-ауту через 200 мс
▶ Попробуй сам

e.kind() даёт ErrorKind (на английском), по которому можно сделать match. Для медленных подключений есть ещё TcpStream::connect_timeout. В tokio любую future можно обернуть в tokio::time::timeout:

let result = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await;
// Err(_) = слишком долго; Ok(inner) = чтение завершилось (inner — его собственный Result)

HTTP — это язык веба, и он работает поверх TCP. HTTP можно писать вручную поверх TcpStream, но клиентский крейт берёт детали на себя: HTTPS, перенаправления, заголовки, сжатие. Этим примерам нужны интернет и крейт reqwest, поэтому они не запускаются в Playground. Запусти их у себя:

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

Мы будем использовать JSONPlaceholder (на английском) — бесплатный ненастоящий API для тренировки. Первая сборка скачивает и компилирует довольно много крейтов, так что дай ей минутку.

reqwest::get(url).await?.text().await? даёт тело ответа в виде String. Но для JSON есть способ лучше: с feature json reqwest использует serde, чтобы сразу превратить ответ в твою структуру, а твою структуру — в тело запроса:

use serde::{Deserialize, Serialize};
// Читаются только перечисленные поля. Лишние поля JSON (вроде "userId") игнорируются.
#[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 -> твоя структура
let todo: Todo = client
.get("https://jsonplaceholder.typicode.com/todos/1")
.send()
.await?
.json()
.await?;
println!("#{}: {} (done: {})", todo.id, todo.title, todo.completed);
// POST: твоя структура -> JSON (этот тестовый API отвечает, но на самом деле ничего не сохраняет)
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
}

Этот код и его настоящий вывод мы оставили без изменений, поэтому строки в нём на английском (а заголовки задач в JSONPlaceholder — на псевдолатыни).

Создай один Client и используй его повторно. Он держит соединения открытыми и переиспользует их, поэтому следующие запросы идут быстрее. .json(&new) к тому же сам ставит заголовок Content-Type: application/json.

Не хочешь async? ureq — маленький блокирующий клиент: ureq::get(url).call()?.body_mut().read_to_string()? даёт тело ответа в виде String, и tokio не нужен.

У reqwest по умолчанию нет тайм-аута, так что зависший сервер может заставить программу ждать вечно. Задай его у клиента. И ещё: 404 Not Found для reqwest — не ошибка. Запрос прошёл, просто ответ был «нет». Вызови error_for_status(), чтобы ответы 4xx и 5xx тоже превращались в ошибки:

use std::time::Duration;
// Превращает в ошибки и ответы 4xx/5xx, а не только сетевые проблемы.
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> {
// Без тайм-аута запрос к зависшему серверу может ждать вечно.
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()?;
// Локальный сервер, который принимает соединения, но никогда не отвечает.
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

Первый адрес ответил (83 байта), второй вернул 404, к третьему не удалось подключиться, а четвёртый не ответил за 2 секунды. is_status(), is_connect() и is_timeout() говорят, какого рода сбой произошёл, чтобы ты мог решить, что делать: повторить позже, показать сообщение или сдаться. В программе побольше anyhow помогает легко добавлять к таким ошибкам контекст.

  • Обе стороны ждут друг друга. Клиент вызывает read_line, но сервер тоже читает, потому что клиент забыл \n. Никто ничего не отправляет, и программа зависает. Договоритесь, где заканчивается сообщение (перевод строки — самое простое правило).
  • Слишком рано читать «до конца». read_to_string ждёт, пока другая сторона закроет соединение. На соединении, которое остаётся открытым, он никогда не вернётся. Читай построчно.
  • Блокирующие вызовы внутри async-кода. std::net или std::thread::sleep внутри задачи tokio блокируют целый рабочий поток. В async-коде используй tokio::net и tokio::time::sleep.
  • Забыть про тайм-ауты. Тесты проходят в быстрой локальной сети. А потом один медленный сервер замораживает всё. Ставь тайм-ауты с первого дня.

Напиши сервер, который читает строки вроде add 2 3 или mul 4 5 и отвечает результатом. На неправильную строку пусть отвечает коротким сообщением ошибка: .... Проверь его из main через одно соединение и четыре вопроса. Подсказка: раздели строку через split_whitespace(), разбери числа через parse::<i64>() и отвечай через writeln!.

Показать решение
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 "ошибка: пиши <op> <a> <b>".to_string();
};
let (Ok(a), Ok(b)) = (a.parse::<i64>(), b.parse::<i64>()) else {
return "ошибка: это не число".to_string();
};
match op {
"add" => (a + b).to_string(),
"mul" => (a * b).to_string(),
_ => format!("ошибка: неизвестная операция {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); // закрытие соединения завершает цикл сервера
server.join().unwrap()
}

Вывод:

add 2 3 = 5
mul 4 5 = 20
add 2 x = ошибка: это не число
div 8 2 = ошибка: неизвестная операция div
▶ Попробуй сам

Логика живёт в обычной функции calculate, поэтому её можно тестировать вообще без сети. Строки с let ... else выходят из функции раньше, если ввод не той формы.

2. Считаем выполненные задачи (на своём компьютере)

Заголовок раздела «2. Считаем выполненные задачи (на своём компьютере)»

С помощью reqwest загрузи https://jsonplaceholder.typicode.com/todos?userId=1. Этот адрес возвращает JSON-массив задач. Напечатай, сколько их всего, сколько выполнено и заголовок первой невыполненной. Подсказка: десериализуй в Vec<Todo>, а потом используй методы итераторов вроде filter и find.

Показать решение
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

У пользователя 1 — 20 задач, 11 из них выполнены. JSON-массив превращается в Vec, и serde сам заполняет каждый элемент. В структуре достаточно перечислить только нужные поля.

  • std::net даёт блокирующие TCP (TcpListener, TcpStream) и UDP (UdpSocket) без дополнительных крейтов.
  • Привяжись к порту 0, чтобы получить любой свободный порт, и узнай его через local_addr().
  • TCP — это поток байтов: договорись, где заканчивается сообщение, например одно сообщение на строку.
  • Обслуживай много клиентов по одному потоку на соединение или задачами tokio, когда их нужны тысячи.
  • reqwest (async) или ureq (блокирующий) вызывают HTTP API, а serde превращает JSON в твои структуры.
  • Всегда ставь тайм-ауты и используй error_for_status(), чтобы 404 не выглядел как успех.

Проверь себя

1. Что произойдёт, если привязать TcpListener к 127.0.0.1:0?

2. Зачем примеры на этой странице отправляют \n в конце каждого сообщения?

3. reqwest получает ответ 404 Not Found. Что вернёт send().await?

4. Твой сервер на tokio обслуживает каждого клиента через std::net и std::thread::sleep. В чём проблема?

Результат: 0 / 4