Сети в 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-сервер и клиент в одной программе
Заголовок раздела «TCP-сервер и клиент в одной программе»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
Заголовок раздела «Тот же сервер на tokio»С 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"до скорой встречи" -> слов: 3into_split() даёт половину для чтения и половину для записи, так что чтение и запись могут жить в разных переменных. Как и раньше, цикл accept работает бесконечно. Когда main возвращается, tokio останавливает все задачи.
UDP за одну минуту
Заголовок раздела «UDP за одну минуту»В 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 и JSON с reqwest
Заголовок раздела «HTTP и JSON с reqwest»HTTP — это язык веба, и он работает поверх TCP. HTTP можно писать вручную поверх TcpStream, но клиентский крейт берёт детали на себя: HTTPS, перенаправления, заголовки, сжатие. Этим примерам нужны интернет и крейт reqwest, поэтому они не запускаются в Playground. Запусти их у себя:
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"] }Мы будем использовать JSONPlaceholder (на английском) — бесплатный ненастоящий API для тренировки. Первая сборка скачивает и компилирует довольно много крейтов, так что дай ей минутку.
GET и POST с JSON
Заголовок раздела «GET и POST с JSON»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
Заголовок раздела «Тайм-ауты и ошибки в reqwest»У 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 bytesserver said 404 Not Foundcould 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. - Забыть про тайм-ауты. Тесты проходят в быстрой локальной сети. А потом один медленный сервер замораживает всё. Ставь тайм-ауты с первого дня.
Практика
Заголовок раздела «Практика»1. Сервер-калькулятор
Заголовок раздела «1. Сервер-калькулятор»Напиши сервер, который читает строки вроде 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 = 5mul 4 5 = 20add 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 completedfirst 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
Что почитать
Заголовок раздела «Что почитать»- std::net — стандартная библиотека Rust (на английском):
TcpListener,TcpStream,UdpSocketи адреса, с примерами. - Создание однопоточного веб-сервера — книга «Язык программирования Rust» (перевод на русский): в финальном проекте книги на
std::netстроится HTTP-сервер, а потом к нему добавляется пул потоков. - Tokio tutorial (на английском): асинхронный TCP шаг за шагом, от запуска задач до разбиения потока на сообщения.
- reqwest — docs.rs (на английском): клиент, запросы, JSON, тайм-ауты и модуль
blocking. - Web clients — Rust Cookbook (на английском): короткие рецепты для HTTP-запросов, API и загрузок.