Создание однопоточного веб-сервера
Мы начнем с того, что заставим работать однопоточный веб-сервер. Прежде чем приступить, давайте быстро рассмотрим протоколы, участвующие в создании веб-серверов. Подробности этих протоколов выходят за рамки этой книги, но краткий обзор даст вам необходимую информацию.
Два основных протокола, участвующих в работе веб-серверов, — это 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
Теперь введите код из листинга 21-1 в src/main.rs, чтобы начать. Этот код
будет слушать локальный адрес 127.0.0.1:7878 для входящих TCP-потоков. Когда
он получит входящий поток, то напечатает 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). Один поток
представляет открытое соединение между клиентом и сервером. Соединение — это
название всего процесса запроса и ответа, в котором клиент подключается к
серверу, сервер создает ответ, а затем сервер закрывает соединение. Поэтому мы
будем читать из 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
Первая строка — это строка запроса, которая содержит информацию о том, что
запрашивает клиент. Первая часть строки запроса указывает используемый метод,
такой как 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:, являются
заголовками. У запросов GET нет тела.
Попробуйте сделать запрос из другого браузера или запросить другой адрес, например 127.0.0.1:7878/test, чтобы увидеть, как изменятся данные запроса.
Теперь, когда мы знаем, что запрашивает браузер, отправим обратно какие-нибудь данные!
Запись ответа
Мы реализуем отправку данных в ответ на запрос клиента. Ответы имеют следующий формат:
HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
Первая строка — это строка состояния, которая содержит версию 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, которая содержит данные
сообщения об успехе. Затем мы вызываем as_bytes для response, чтобы
преобразовать строковые данные в байты. Метод 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},
};
// --snip--
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();
}
Мы добавили fs в инструкцию use, чтобы ввести модуль файловой системы
стандартной библиотеки в область видимости. Код чтения содержимого файла в
строку должен выглядеть знакомо; мы использовали его, когда читали содержимое
файла для нашего проекта ввода-вывода в листинге 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);
}
}
// --snip--
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 {
// some other request
}
}
Мы будем смотреть только на первую строку 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.
Теперь добавим код из листинга 21-7 в блок else, чтобы возвращать ответ с
кодом состояния 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();
// --snip--
} 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, какой захотите, или пример 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);
}
}
// --snip--
fn handle_connection(mut stream: TcpStream) {
// --snip--
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 возвращают только соответствующие значения для
строки состояния и имени файла в кортеже; затем мы используем деструктуризацию,
чтобы присвоить эти два значения status_line и filename с помощью шаблона
в инструкции let, как обсуждалось в главе 19.
Ранее дублированный код теперь находится вне блоков if и else и использует
переменные status_line и filename. Благодаря этому легче увидеть различие
между двумя случаями, и это означает, что у нас есть только одно место, где
нужно обновить код, если мы захотим изменить то, как работает чтение файла и
запись ответа. Поведение кода из листинга 21-9 будет таким же, как в листинге
21-7.
Отлично! Теперь у нас есть простой веб-сервер примерно на 40 строках кода Rust, который отвечает на один запрос страницей с содержимым, а на все остальные запросы отвечает ответом 404.
Сейчас наш сервер работает в одном потоке, а значит, может обслуживать только один запрос за раз. Давайте рассмотрим, как это может стать проблемой, смоделировав несколько медленных запросов. Затем мы исправим это так, чтобы наш сервер мог обрабатывать несколько запросов одновременно.