Создание однопоточного веб-сервера
Мы начнём с создания работающего однопоточного веб-сервера. Прежде чем начать, давайте кратко рассмотрим протоколы, задействованные в построении веб-серверов. Детали этих протоколов выходят за рамки этой книги, но краткий обзор даст вам необходимую информацию.
Два основных протокола, используемых в веб-серверах, — это 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>, что указывает на возможность сбоя при
привязке. Например, подключение к порту 80 требует прав администратора
(неадминистраторы могут прослушивать только порты выше 1023), поэтому если мы
попытаемся подключиться к порту 80, не будучи администратором, привязка не
сработает. Привязка также не сработает, например, если мы запустим два экземпляра
нашей программы и, следовательно, будем иметь две программы, прослушивающие
один и тот же порт. Поскольку мы пишем базовый сервер только для учебных целей,
мы не будем беспокоиться об обработке таких ошибок; вместо этого мы используем
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::prelude и std::io::BufReader в область видимости,
чтобы получить доступ к типаж и типам, которые позволяют нам читать из потока и
писать в него. В цикле 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 [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, что означает, что он запрашивает информацию.
Следующая часть строки запроса — это /, которая указывает унифицированный идентификатор ресурса (URI), который запрашивает клиент: URI почти, но не совсем, то же самое, что унифицированный локатор ресурса (URL). Разница между URI и URL не важна для наших целей в этой главе, но спецификация HTTP использует термин URI, поэтому мы можем мысленно заменить URL на URI здесь.
Последняя часть — это версия HTTP, которую использует клиент, а затем строка
запроса заканчивается последовательностью CRLF. (CRLF означает возврат каретки
и перевод строки, термины из эпохи печатных машинок!) Последовательность 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. Этот новый код проверяет содержимое полученного
запроса против того, как выглядит запрос GET к пути /, и добавляет блоки 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/foo, вы получите ошибку соединения, такую как те, которые вы видели при запуске кода в Листинге 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.
В настоящее время наш сервер работает в одном потоке, что означает, что он может обслуживать только один запрос за раз. Давайте рассмотрим, как это может быть проблемой, смоделировав некоторые медленные запросы. Затем мы исправим это, чтобы наш сервер мог обрабатывать несколько запросов одновременно.