Однопоточный веб-сервер
Начнём с того, что заставим работать однопоточный веб-сервер. Но сначала коротко посмотрим на протоколы, на которых строятся веб-серверы. Подробности этих протоколов выходят за рамки книги, но краткий обзор даст нужные сведения.
Два основных протокола, с которыми работают веб-серверы, — протокол передачи гипертекста (Hypertext Transfer Protocol, HTTP) и протокол управления передачей (Transmission Control Protocol, TCP). Оба протокола работают по схеме «запрос — ответ»: клиент отправляет запросы, а сервер слушает их и отвечает клиенту. Содержимое запросов и ответов определяют сами протоколы.
TCP — протокол нижнего уровня: он описывает, как информация попадает с одного сервера на другой, но не говорит, что это за информация. HTTP надстраивается над TCP и определяет содержимое запросов и ответов. Технически HTTP можно использовать и поверх других протоколов, но в подавляющем большинстве случаев HTTP передаёт данные по TCP. Мы будем работать с «сырыми» байтами запросов и ответов TCP и HTTP.
Слушаем TCP-соединение
Веб-сервер должен слушать TCP-соединение — с этого и начнём. В стандартной
библиотеке для этого есть модуль std::net. Создадим новый проект как обычно:
$ cargo new hello
Created binary (application) `hello` project
$ cd hello
Для начала введите в src/main.rs код из листинга 21-1. Он будет слушать
входящие TCP-потоки на локальном адресе 127.0.0.1:7878. Получив входящий
поток, он выведет Connection established!.
use std::net::TcpListener;
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
println!("Connection established!");
}
}
С помощью TcpListener мы слушаем TCP-соединения на адресе 127.0.0.1:7878.
Часть адреса до двоеточия — IP-адрес, обозначающий ваш компьютер (он одинаков
на любом компьютере и не означает конкретно компьютер авторов), а 7878 —
порт. Этот порт мы выбрали по двум причинам: для HTTP он обычно не
используется, так что наш сервер вряд ли будет конфликтовать с другим
веб-сервером на вашей машине; а ещё 7878 — это слово rust, набранное на
телефонной клавиатуре.
Функция bind здесь работает как функция new: возвращает новый экземпляр
TcpListener. Называется она bind (привязать), потому что в сетевом
программировании подключение к порту, чтобы его слушать, называют «привязкой к
порту».
Функция bind возвращает Result<T, E>: привязка может не удаться — например,
если запустить два экземпляра нашей программы, и две программы будут слушать
один и тот же порт. Мы пишем простой сервер для учёбы, так что обрабатывать
такие ошибки не будем, а просто остановим программу через unwrap.
Метод incoming у TcpListener возвращает итератор, который выдаёт
последовательность потоков (точнее, потоков типа TcpStream). Один поток
(stream) — это открытое соединение между клиентом и сервером. Соединение
(connection) — это весь процесс запроса и ответа: клиент подключается к
серверу, сервер формирует ответ и закрывает соединение. Поэтому мы будем
читать из TcpStream, чтобы узнать, что прислал клиент, а затем писать в поток
ответ, чтобы отправить данные обратно клиенту. В целом этот цикл for по
очереди обрабатывает каждое соединение и выдаёт нам последовательность потоков.
Пока что вся обработка потока сводится к вызову unwrap, который завершит
программу, если у потока есть ошибки; если ошибок нет, программа выводит
сообщение. В следующем листинге мы добавим больше функциональности для
успешного случая. А ошибки от метода incoming при подключении клиента могут
приходить потому, что на самом деле мы перебираем не соединения, а попытки
соединения. Соединение может не состояться по многим причинам, и многие из них
зависят от операционной системы. Например, во многих ОС есть ограничение на
число одновременно открытых соединений; новые попытки сверх этого числа будут
давать ошибку, пока часть открытых соединений не закроется.
Попробуем запустить этот код! Выполните в терминале cargo run и откройте в
браузере 127.0.0.1:7878. Браузер покажет сообщение об ошибке вроде «Connection
reset» («Соединение сброшено»), потому что сервер пока ничего не отправляет. Но в
терминале вы увидите несколько сообщений, выведенных, когда браузер
подключался к серверу!
Running `target/debug/hello`
Connection established!
Connection established!
Connection established!
Иногда на один запрос браузера выводится несколько сообщений. Возможно, браузер запрашивает не только страницу, но и другие ресурсы — например, иконку favicon.ico, которая показывается во вкладке браузера.
А может быть, браузер пытается подключиться к серверу несколько раз, потому что
сервер ничего не отвечает. Когда в конце цикла stream выходит из области
видимости и уничтожается, соединение закрывается — это часть реализации drop.
Браузеры иногда реагируют на закрытые соединения повторными попытками: вдруг
проблема временная.
Ещё браузеры иногда открывают к серверу несколько соединений, ничего по ним не отправляя, — чтобы, если запросы всё-таки понадобятся, они выполнились быстрее. В этом случае наш сервер увидит каждое соединение, независимо от того, были ли по нему запросы. Так поступают, например, многие версии браузеров на основе Chrome; эту оптимизацию можно отключить, открыв приватное окно или воспользовавшись другим браузером.
Главное — мы успешно получили дескриптор TCP-соединения!
Закончив работу с очередной версией кода, не забывайте останавливать программу
сочетанием ctrl-C. А после каждого набора изменений
перезапускайте её командой cargo run, чтобы работал самый свежий код.
Читаем запрос
Реализуем чтение запроса от браузера! Чтобы разделить ответственность —
сначала получить соединение, потом что-то с ним сделать, — заведём новую
функцию для обработки соединений. В этой функции handle_connection мы будем
читать данные из TCP-потока и выводить их, чтобы видеть, что отправляет
браузер. Измените код, как в листинге 21-2.
use std::{
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
println!("Request: {http_request:#?}");
}
TcpStream и выводим данныеМы подключаем std::io::BufReader и std::io::prelude, чтобы получить доступ к
трейтам и типам для чтения из потока и записи в него. В цикле for в функции
main вместо вывода сообщения о соединении теперь вызывается новая функция
handle_connection, которой передаётся stream.
В функции handle_connection мы создаём новый экземпляр BufReader, который
оборачивает ссылку на stream. BufReader добавляет буферизацию, управляя за
нас вызовами методов трейта std::io::Read.
Мы создаём переменную http_request, в которую собираем строки запроса,
присланного браузером. Аннотацией типа Vec<_> мы указываем, что хотим собрать
эти строки в вектор.
BufReader реализует трейт std::io::BufRead, в котором есть метод lines.
Метод lines возвращает итератор по Result<String, std::io::Error>, разделяя
поток данных всякий раз, когда встречает байт перевода строки. Чтобы получить
каждую String, мы через map вызываем unwrap для каждого Result. Result
может оказаться ошибкой, если данные — некорректный UTF-8 или если при чтении из
потока возникла проблема. Опять же, программа для продакшена должна
обрабатывать такие ошибки аккуратнее, но мы для простоты останавливаем
программу при ошибке.
Конец HTTP-запроса браузер обозначает двумя переводами строки подряд. Поэтому, чтобы получить из потока один запрос, мы берём строки, пока не встретим пустую. Собрав строки в вектор, выводим их в «красивом» отладочном формате, чтобы посмотреть, какие указания браузер отправляет нашему серверу.
Попробуем этот код! Запустите программу и снова сделайте запрос в браузере. Браузер по-прежнему покажет страницу с ошибкой, но вывод программы в терминале теперь будет примерно таким:
$ cargo run
Compiling hello v0.1.0 (file:///projects/hello)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
Running `target/debug/hello`
Request: [
"GET / HTTP/1.1",
"Host: 127.0.0.1:7878",
"User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language: en-US,en;q=0.5",
"Accept-Encoding: gzip, deflate, br",
"DNT: 1",
"Connection: keep-alive",
"Upgrade-Insecure-Requests: 1",
"Sec-Fetch-Dest: document",
"Sec-Fetch-Mode: navigate",
"Sec-Fetch-Site: none",
"Sec-Fetch-User: ?1",
"Cache-Control: max-age=0",
]
В зависимости от браузера вывод может немного отличаться. Теперь, когда мы
выводим данные запроса, видно, почему на один запрос браузера приходит
несколько соединений: посмотрите на путь после GET в первой строке запроса.
Если все повторные соединения запрашивают /, значит, браузер раз за разом
пытается получить /, потому что не получает ответа от нашей программы.
Разберём эти данные, чтобы понять, чего браузер хочет от нашей программы.
HTTP-запрос поближе
HTTP — текстовый протокол, и запрос в нём выглядит так:
Method Request-URI HTTP-Version CRLF
headers CRLF
message-body
Первая строка — строка запроса (request line): в ней сведения о том, что
запрашивает клиент. Первая часть строки запроса — метод, например GET или
POST; он описывает, как клиент делает запрос. Наш клиент отправил запрос
GET, то есть просит выдать информацию.
Следующая часть строки запроса — /: это унифицированный идентификатор ресурса (uniform resource identifier, URI), который запрашивает клиент. URI — почти, но не совсем то же самое, что унифицированный указатель ресурса (uniform resource locator, URL). Разница между URI и URL для нашей главы не важна, но в спецификации HTTP используется термин URI, так что здесь можно мысленно читать URL вместо URI.
Последняя часть — версия HTTP, которую использует клиент, а затем строка
запроса заканчивается последовательностью CRLF. (CRLF — это carriage return
и line feed, «возврат каретки» и «перевод строки», — термины ещё со времён
пишущих машинок!) Последовательность CRLF можно записать и как \r\n, где \r
— возврат каретки, а \n — перевод строки. Последовательность CRLF отделяет
строку запроса от остальных данных запроса. Заметьте: при выводе CRLF мы видим
начало новой строки, а не \r\n.
Глядя на строку запроса, которую мы получили, видим: метод — GET, URI запроса
— /, версия — HTTP/1.1.
После строки запроса все оставшиеся строки, начиная с Host:, — заголовки
(headers). У запросов GET тела нет.
Попробуйте сделать запрос из другого браузера или запросить другой адрес, например 127.0.0.1:7878/test, и посмотрите, как меняются данные запроса.
Теперь, когда мы знаем, чего хочет браузер, отправим ему какие-нибудь данные!
Пишем ответ
Реализуем отправку данных в ответ на запрос клиента. Ответ имеет такой формат:
HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
Первая строка — строка состояния (status line): в ней версия HTTP, использованная в ответе, числовой код состояния, кратко описывающий результат запроса, и поясняющая фраза — текстовое описание кода состояния. После последовательности CRLF идут заголовки, ещё одна последовательность CRLF и тело ответа.
Вот пример ответа с версией HTTP 1.1, кодом состояния 200, поясняющей фразой OK, без заголовков и без тела:
HTTP/1.1 200 OK\r\n\r\n
Код состояния 200 — стандартный ответ об успехе. Этот текст — крошечный
успешный HTTP-ответ. Запишем его в поток в ответ на успешный запрос! Уберите из
функции handle_connection println!, который выводил данные запроса, и
замените его кодом из листинга 21-3.
use std::{
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
let response = "HTTP/1.1 200 OK\r\n\r\n";
stream.write_all(response.as_bytes()).unwrap();
}
Первая новая строка определяет переменную response с данными сообщения об
успехе. Затем мы вызываем у response метод as_bytes, чтобы превратить строку
в байты. Метод write_all у stream принимает &[u8] и отправляет эти байты
прямо в соединение. Операция write_all может не сработать, поэтому, как и
раньше, на любой ошибке вызываем unwrap. В настоящем приложении здесь,
конечно, нужна обработка ошибок.
С этими изменениями запустим код и сделаем запрос. В терминал мы больше ничего не выводим, так что, кроме вывода Cargo, ничего не увидим. Открыв в браузере 127.0.0.1:7878, вы получите пустую страницу вместо ошибки. Вы только что вручную написали получение HTTP-запроса и отправку ответа!
Возвращаем настоящий HTML
Реализуем возврат чего-то большего, чем пустая страница. Создайте новый файл hello.html в корне папки проекта, а не в папке src. HTML может быть любым; один из вариантов — в листинге 21-4.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Hello!</h1>
<p>Hi from Rust</p>
</body>
</html>
Это минимальный документ HTML5 с заголовком и немного текста. Чтобы сервер
возвращал его при получении запроса, изменим handle_connection, как в
листинге 21-5: прочитаем HTML-файл, добавим его в ответ как тело и отправим.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
// --пропуск--
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
Мы добавили в инструкцию use модуль fs, чтобы подключить модуль файловой
системы стандартной библиотеки. Код чтения содержимого файла в строку должен
быть вам знаком: мы пользовались им, когда читали файл в проекте с
вводом-выводом в листинге 12-4.
Затем с помощью format! добавляем содержимое файла в тело успешного ответа.
Чтобы HTTP-ответ был корректным, добавляем заголовок Content-Length с
размером тела ответа — в нашем случае с размером hello.html.
Запустите этот код через cargo run и откройте в браузере 127.0.0.1:7878 — вы
увидите отрисованный HTML!
Сейчас мы игнорируем данные запроса в http_request и просто безусловно
отправляем содержимое HTML-файла. Значит, если запросить в браузере
127.0.0.1:7878/something-else, вы всё равно получите тот же HTML. Пока наш
сервер очень ограничен и не делает того, что делает большинство веб-серверов.
Мы хотим выбирать ответ в зависимости от запроса и отправлять HTML-файл только
на корректный запрос к /.
Проверяем запрос и отвечаем выборочно
Сейчас наш веб-сервер возвращает HTML из файла, что бы ни запросил клиент.
Добавим проверку: если браузер запрашивает /, вернём HTML-файл, а на любой
другой запрос — ошибку. Для этого нужно изменить handle_connection, как в
листинге 21-6. Новый код сравнивает содержимое полученного запроса с тем, как
выглядит запрос к /, и с помощью блоков if и else обрабатывает запросы
по-разному.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
// --пропуск--
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
if request_line == "GET / HTTP/1.1" {
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
} else {
// какой-то другой запрос
}
}
Нас интересует только первая строка HTTP-запроса, поэтому, вместо того чтобы
читать весь запрос в вектор, мы вызываем next и получаем первый элемент
итератора. Первый unwrap разбирается с Option и останавливает программу,
если в итераторе нет элементов. Второй unwrap обрабатывает Result и делает
то же, что unwrap в map из листинга 21-2.
Затем проверяем, совпадает ли request_line со строкой запроса GET к пути /.
Если да, блок if возвращает содержимое нашего HTML-файла.
Если request_line не совпадает с запросом GET к пути /, значит, пришёл
какой-то другой запрос. Код в блоке else, отвечающий на все остальные
запросы, мы добавим чуть позже.
Запустите код и запросите 127.0.0.1:7878 — вы получите HTML из hello.html. А на любой другой запрос, например 127.0.0.1:7878/something-else, получите ошибку соединения — как при запуске кода из листингов 21-1 и 21-2.
Теперь добавим в блок else код из листинга 21-7, который возвращает ответ с
кодом состояния 404 — он означает, что запрошенное содержимое не найдено. Ещё
вернём HTML-страницу, которую браузер покажет пользователю.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
if request_line == "GET / HTTP/1.1" {
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
// --пропуск--
} else {
let status_line = "HTTP/1.1 404 NOT FOUND";
let contents = fs::read_to_string("404.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
}
}
Здесь в строке состояния ответа код 404 и поясняющая фраза NOT FOUND. Телом
ответа будет HTML из файла 404.html. Для страницы ошибки нужно создать файл
404.html рядом с hello.html; опять же, HTML может быть любым — или
воспользуйтесь примером из листинга 21-8.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Oops!</h1>
<p>Sorry, I don't know what you're asking for.</p>
</body>
</html>
С этими изменениями снова запустите сервер. Запрос 127.0.0.1:7878 должен возвращать содержимое hello.html, а любой другой запрос, например 127.0.0.1:7878/foo, — HTML ошибки из 404.html.
Рефакторинг
Сейчас в блоках if и else много повторов: оба читают файлы и пишут их
содержимое в поток. Различаются только строка состояния и имя файла. Сделаем
код лаконичнее: вынесем различия в отдельные строки if и else, которые
присваивают переменным значения строки состояния и имени файла, а затем
безусловно используем эти переменные в коде, который читает файл и пишет
ответ. В листинге 21-9 — код после замены больших блоков if и else.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
// --пропуск--
fn handle_connection(mut stream: TcpStream) {
// --пропуск--
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
("HTTP/1.1 200 OK", "hello.html")
} else {
("HTTP/1.1 404 NOT FOUND", "404.html")
};
let contents = fs::read_to_string(filename).unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
if и else, чтобы в них остался только код, который различается в двух случаяхТеперь блоки if и else только возвращают подходящие значения строки
состояния и имени файла в виде кортежа. Затем с помощью образца в инструкции
let мы деструктурируем кортеж и присваиваем эти два значения переменным
status_line и filename, как обсуждалось в главе 19.
Код, который раньше дублировался, теперь вне блоков if и else и использует
переменные status_line и filename. Так легче увидеть разницу между двумя
случаями, а если понадобится изменить чтение файла и запись ответа, править
придётся только одно место. Поведение кода из листинга 21-9 такое же, как в
листинге 21-7.
Здорово! Теперь у нас есть простой веб-сервер примерно из 40 строк кода на Rust: на один запрос он отвечает страницей с содержимым, а на все остальные — ответом 404.
Сейчас сервер работает в одном потоке, то есть обслуживает только один запрос за раз. Сымитируем несколько медленных запросов и посмотрим, почему это может быть проблемой. А потом исправим, чтобы сервер мог обрабатывать несколько запросов одновременно.