Создание однопоточного веб-сервера
Начнём с однопоточного веб-сервера. Перед тем, как начать, давайте сделаем краткий обзор протоколов, задействованных при создании веб-серверов. Детальное описание этих протоколов выходит за рамки этой книги, но краткий обзор даст вам необходимую информацию.
Двумя основными протоколами, используемыми в веб-серверах, являются протокол передачи гипертекста (HTTP - Hypertext Transfer Protocol) и Протокол управления передачей (TCP - Transmission Control Protocol). Оба протокола являются протоколами типа запрос-ответ (request-response), то есть клиент инициирует запросы, а сервер слушает эти запросы и предоставляет ответ клиенту. Содержимое этих запросов и ответов определяется протоколами.
TCP - это протокол нижнего уровня, который описывает детали того, как информация передаётся от одного сервера к другому, но не определяет, что это за информация. HTTP строится поверх TCP, определяя содержимое запросов и ответов. Технически возможно использовать HTTP с другими протоколами, но в подавляющем большинстве случаев HTTP отправляет свои данные поверх TCP. Мы будем работать с необработанными байтами в TCP и запросами и ответами в HTTP.
Прослушивание TCP соединения
Нашему веб-серверу необходимо прослушивать TCP-соединение, так что это первая часть, над которой мы будем работать. Стандартная библиотека предлагает для этого модуль std::net
. Сделаем новый проект обычным способом:
$ cargo new hello
Created binary (application) `hello` project
$ cd hello
Дл начала добавьте код из листинга 20-1 в файл src/main.rs. Этот код будет прослушивать входящие TCP потоки по адресу 127.0.0.1:7878
. Когда сервер примет входящий поток, он напечатает Connection established!
("Соединение установлено!").
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch20-web-server/listing-20-01/src/main.rs}} }
Используя TcpListener
мы можем слушать TCP соединения к адресу 127.0.0.1:7878
. В адресе, в его части перед двоеточием, сначала идёт IP-адрес, относящийся к вашему компьютеру (он одинаковый на каждом компьютере и не представляет конкретный компьютер автора), а часть 7878
является портом. Мы выбрали этот порт по двум причинам: HTTP обычно не используется на этом порту, поэтому маловероятно, что наш сервер будет конфликтовать с каким-нибудь другим сервером, который может выполняться на вашей машине, и ещё 7878 - это слово rust, набранное на телефоне.
Функция bind
в этом сценарии работает так же, как функция new
, поскольку она возвращает новый экземпляр TcpListener
. Причина, по которой функция называется bind
заключается в том, что в сетевой терминологии подключение к порту для прослушивания называется «привязка к порту» (“binding to a port”).
Функция 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
. Браузеры иногда обрабатывают закрытые соединения, повторяя попытки, потому что проблема может быть временной. Важным фактором является то, что мы успешно получили дескриптор TCP-соединения!
Не забудьте остановить программу, нажав ctrl-c, когда вы закончите выполнение определённой версии кода. Затем перезапустите программу, вызвав команду cargo run
, после того, как вы внесли какой-либо набор изменений, чтобы убедиться, что выполняется самая свежая версия кода.
Чтение запросов
Реализуем функционал чтения запроса из браузера! Чтобы разделить части, связанные с получением соединения и последующим действием с ним, мы запустим новую функцию для обработки соединения. В этой новой функции handle_connection
мы будем читать данные из потока TCP и распечатывать их, чтобы мы могли видеть данные, отправленные из браузера. Измените код, чтобы он выглядел как в листинге 20-2.
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch20-web-server/listing-20-02/src/main.rs}} }
Мы добавляем 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-адрес здесь.
Последняя часть - это версия 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!
который печатал данные запроса и заменял их кодом из Листинга 20-3.
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch20-web-server/listing-20-03/src/main.rs:here}} }
Первый перевод строки определяет переменную response
, которая содержит данные сообщения об успешном выполнении. Затем мы вызываем as_bytes
в нашем response
, чтобы преобразовать строковые данные в байты. Метод write_all
в stream
принимает тип &[u8]
и отправляет эти байты непосредственно получателю. Поскольку операция write_all
может завершиться с ошибкой, мы, как и ранее, используем unwrap
на любом потенциально ошибочном результате. И опять, в реальном приложении здесь вам нужно было бы добавить обработку ошибок.
После этих изменений давайте запустим наш код и сделаем запрос. Мы больше не печатаем никаких данных в терминал, поэтому мы не увидим никакого вывода, кроме сообщений от Cargo. Когда вы загрузите 127.0.0.1:7878 в веб-браузере, вы должны получить пустую страницу вместо ошибки. Вы только что вручную написали код получения HTTP-запроса и отправки ответа на него!
Возвращение реального HTML
Давайте реализуем функционал чего-нибудь большего, чем просто пустой страницы. Создайте новый файл hello.html в корне каталога вашего проекта, а не в каталоге src . Вы можете ввести любой HTML-код, который вам заблагорассудится; В листинге 20-4 показан один из вариантов.
Файл: hello.html
{{#include ../listings/ch20-web-server/listing-20-05/hello.html}}
Это простейший HTML5-документ с заголовком и каким-то текстом. Чтобы сервер возвращал его в ответ на полученный запрос, мы изменим handle_connection
, как показано в листинге 20-5, чтобы считать HTML-файл, добавить его в ответ в качестве тела и отправить.
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch20-web-server/listing-20-05/src/main.rs:here}} }
Мы добавили элемент fs
в инструкцию use
, чтобы включить в область видимости модуль файловой системы стандартной библиотеки. Код для чтения содержимого файла в строку должен выглядеть знакомым для вас; мы использовали его в главе 12, когда читали содержимое файла для нашего проекта ввода-вывода в листинге 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
, как показано в листинге 20-6. Новый код проверяет соответствует ли требуемый запросом ресурс с идентификатором /, и содержит блоки if
и else
, чтобы иначе обрабатывать другие запросы.
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch20-web-server/listing-20-06/src/main.rs:here}} }
Мы будем рассматривать только первую строку HTTP-запроса, поэтому вместо того, чтобы читать весь запрос в вектор, мы вызываем next
, чтобы получить первый элемент из итератора. Первый вызов unwrap
заботится об обработке Option
и останавливает программу, если в итераторе нет элементов. Второй unwrap
обрабатывает Result
и имеет тот же эффект, что и unwrap
, который был в map
, добавленном в листинге 20-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 , вы получите ошибку соединения, подобную той, которую вы видели при запуске кода из Листинга 20-1 и Листинга 20-2.
Теперь давайте добавим код из листинга 20-7 в блок else
чтобы вернуть ответ с кодом состояния 404, который сигнализирует о том, что контент для запроса не найден. Мы также вернём HTML-код для страницы, отображаемой в браузере, с указанием ответа конечному пользователю.
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch20-web-server/listing-20-07/src/main.rs:here}} }
Здесь ответ имеет строку состояния с кодом 404 и фразу причины NOT FOUND
. Тело ответа будет HTML из файла 404.html. Вам нужно создать файл 404.html рядом с hello.html для этой страницы ошибки; снова не стесняйтесь использовать любой HTML код или пример HTML кода в листинге 20-8.
Файл: 404.html
{{#include ../listings/ch20-web-server/listing-20-07/404.html}}
С этими изменениями снова запустите сервер. Запрос на 127.0.0.1:7878 должен возвращать содержимое hello.html, и любой другой запрос, как 127.0.0.1:7878/foo, должен возвращать сообщение об ошибке HTML от 404.html.
Рефакторинг
На текущий момент блоки if
и else
во многом повторяются: они оба читают файлы и записывают содержимое файлов в поток. Разница лишь в строке состояния и имени файла. Давайте сделаем код более лаконичным, вынеся эти отличия в отдельные блоки if
и else
, в которых переменным будут присвоены значения строки состояния и имени файла; далее эти переменные мы сможем использовать в коде для чтения файла и создания ответа. В листинге 20-9 показан код после изменения объёмных блоков if
и else
.
Файл: src/main.rs
#![allow(unused)] fn main() { {{#rustdoc_include ../listings/ch20-web-server/listing-20-09/src/main.rs:here}} }
Теперь блоки if
и else
возвращают только соответствующие значения для строки состояния и имени файла в кортеже. Затем мы используем деструктурирование, чтобы присвоить эти два значения status_line
и filename
используя шаблон в инструкции let
, как обсуждалось в главе 18.
Ранее дублированный код теперь находится вне блоков if
и else
и использует переменные status_line
и filename
. Это позволяет легче увидеть разницу между этими двумя случаями и означает, что у нас есть только одно место для обновления кода, если захотим изменить работу чтения файлов и записи ответов. Поведение кода в листинге 20-9 будет таким же, как и в 20-8.
Потрясающие! Теперь у нас есть простой веб-сервер примерно на 40 строках кода Rust, который отвечает на один запрос страницей с контентом и отвечает на все остальные запросы ответом 404.
В настоящее время наш сервер работает в одном потоке, что означает, что он может обслуживать только один запрос за раз. Давайте разберёмся, почему это может быть проблемой, сымитировав несколько медленных запросов. Затем мы исправим ситуацию так, чтобы наш сервер мог обрабатывать несколько запросов одновременно.