Создание однопоточного веб-сервера
Мы начнём с создания работающего однопоточного веб-сервера. Прежде чем мы начнём, давайте кратко рассмотрим протоколы, участвующие в создании веб-серверов. Детали этих протоколов выходят за рамки данной книги, но краткий обзор даст вам необходимую информацию.
Два основных протокола, используемых в веб-серверах — это 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. Этот код будет прослушивать входящие TCP-потоки по локальному адресу 127.0.0.1:7878. Когда он получает входящий поток, он выводит сообщение "Connection established!".
Файл: src/main.rs
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!"); } }
Листинг 21-1: Прослушивание входящих потоков и вывод сообщения при получении потока
Используя TcpListener, мы можем прослушивать TCP-подключения по адресу 127.0.0.1:7878. В этом адресе часть перед двоеточием — это IP-адрес, представляющий ваш компьютер (этот адрес одинаков на каждом компьютере и не относится конкретно к компьютеру авторов), а 7878 — это порт. Мы выбрали этот порт по двум причинам: HTTP обычно не принимается на этом порту, поэтому наш сервер вряд ли конфликтует с любым другим веб-сервером, который может быть запущен на вашей машине, и потому что 7878 набирается как "rust" на телефонной клавиатуре.
Функция bind в этом сценарии работает подобно функции new в том смысле, что она возвращает новый экземпляр TcpListener. Функция называется bind (привязать), потому что в сетевых технологиях подключение к порту для прослушивания известно как «привязка к порту».
Функция bind возвращает Result<T, E>, что указывает на возможность неудачи привязки, например, если мы запустили два экземпляра нашей программы и thus имеем две программы, прослушивающие один и тот же порт. Поскольку мы пишем базовый сервер только в учебных целях, мы не будем беспокоиться об обработке подобных ошибок; вместо этого мы используем unwrap, чтобы остановить программу при возникновении ошибок.
Метод incoming у TcpListener возвращает итератор, который предоставляет нам последовательность потоков (точнее, потоков типа TcpStream). Один поток представляет открытое соединение между клиентом и сервером. Соединение — это название полного процесса запроса и ответа, в котором клиент подключается к серверу, сервер генерирует ответ и сервер закрывает соединение. Таким образом, мы будем читать из TcpStream, чтобы увидеть, что отправил клиент, а затем записывать наш ответ в поток, чтобы отправить данные обратно клиенту. В целом, этот цикл for будет обрабатывать каждое соединение по очереди и производить серию потоков для обработки.
На данный момент наша обработка потока состоит в вызове unwrap для завершения программы, если в потоке есть ошибки; если ошибок нет, программа выводит сообщение. Мы добавим больше функциональности для случая успеха в следующем листинге. Причина, по которой мы можем получать ошибки от метода incoming при подключении клиента к серверу, заключается в том, что мы на самом деле итерируем не по соединениям, а по попыткам соединения. Соединение может не состояться по множеству причин, многие из которых специфичны для операционной системы. Например, многие операционные системы имеют ограничение на количество одновременных открытых соединений, которые они могут поддерживать; новые попытки соединения сверх этого числа будут производить ошибку, пока некоторые из открытых соединений не будут закрыты.
Давайте попробуем запустить этот код! Вызовите cargo run в терминале, а затем загрузите 127.0.0.1:7878 в веб-браузере. Браузер должен показать сообщение об ошибке, например «Сброс соединения», потому что сервер в настоящее время не отправляет обратно никаких данных. Но если вы посмотрите на ваш терминал, вы должны увидеть несколько сообщений, которые были напечатаны, когда браузер подключился к серверу!
Running `target/debug/hello`
Connection established!
Connection established!
Connection established!
Иногда вы будете видеть несколько сообщений, напечатанных для одного запроса браузера; причина может быть в том, что браузер делает запрос для страницы, а также запрос для других ресурсов, таких как иконка favicon.ico, которая появляется на вкладке браузера.
Также может быть, что браузер пытается подключиться к серверу несколько раз, потому что сервер не отвечает никакими данными. Когда stream выходит из области видимости и уничтожается в конце цикла, соединение закрывается как часть реализации деструктора. Браузеры иногда обрабатывают закрытые соединения повторными попытками, потому что проблема может быть временной.
Браузеры также иногда открывают несколько соединений с сервером без отправки каких-либо запросов, чтобы если они позже отправят запросы, эти запросы могли бы обрабатываться быстрее. Когда это происходит, наш сервер будет видеть каждое соединение, независимо от того, есть ли какие-либо запросы через это соединение. Многие версии браузеров на основе Chrome делают это, например; вы можете отключить эту оптимизацию, используя режим инкогнито или другой браузер.
Важный факт заключается в том, что мы успешно получили обработчик TCP-соединения!
Не забудьте остановить программу, нажав Ctrl-C, когда закончите запуск конкретной версии кода. Затем перезапустите программу, вызвав команду cargo run после каждого набора изменений кода, чтобы убедиться, что вы запускаете самую новую версию.
Чтение запроса
Давайте реализуем функциональность для чтения запроса из браузера! Чтобы разделить ответственность между получением соединения и выполнением действий с этим соединением, мы начнём новую функцию для обработки соединений. В этой новой функции handle_connection мы будем читать данные из TCP-потока и выводить их, чтобы мы могли видеть данные, отправляемые из браузера. Измените код, чтобы он выглядел как в Листинге 21-2.
Файл: src/main.rs
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:#?}"); }
Листинг 21-2: Чтение из TcpStream и вывод данных
Мы добавляем в область видимости std::io::BufReader и std::io::prelude, чтобы получить доступ к трейтам и типам, которые позволяют нам читать из потока и записывать в него. В цикле for в функции main вместо вывода сообщения о том, что мы установили соединение, мы теперь вызываем новую функцию handle_connection и передаём ей поток.
В функции handle_connection мы создаём новый экземпляр BufReader, который оборачивает ссылку на поток. BufReader добавляет буферизацию, управляя вызовами методов трейта std::io::Read за нас.
Мы создаём переменную http_request для сбора строк запроса, которые браузер отправляет на наш сервер. Мы указываем, что хотим собрать эти строки в вектор, добавляя аннотацию типа Vec<_>.
BufReader реализует трейт std::io::BufRead, который предоставляет метод lines. Метод lines возвращает итератор Result<String, std::io::Error>, разделяя поток данных при каждом обнаружении байта новой строки. Чтобы получить каждую String, мы преобразуем и разворачиваем каждый Result с помощью map и unwrap. 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 — это текстовый протокол, и запрос имеет следующий формат:
Метод URI-запроса Версия-HTTP CRLF
заголовки CRLF
тело-сообщения
Первая строка — это строка запроса, которая содержит информацию о том, что запрашивает клиент. Первая часть строки запроса указывает используемый метод, такой как GET или POST, который описывает, как клиент делает этот запрос. Наш клиент использовал GET-запрос, что означает, что он запрашивает информацию.
Следующая часть строки запроса — это /, который указывает унифицированный идентификатор ресурса (URI), который запрашивает клиент. URI почти, но не совсем, то же самое, что и унифицированный указатель ресурса (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 Код-статуса Пояснение-статуса CRLF
заголовки CRLF
тело-сообщения
Первая строка — это строка статуса, которая содержит версию HTTP, используемую в ответе, числовой код статуса, который суммирует результат запроса, и поясняющую фразу, которая предоставляет текстовое описание кода статуса. После последовательности CRLF идут любые заголовки, другая последовательность CRLF и тело ответа.
Вот пример ответа, который использует HTTP версию 1.1, имеет код статуса 200, поясняющую фразу OK, без заголовков и без тела:
HTTP/1.1 200 OK\r\n\r\n
Код статуса 200 — это стандартный успешный ответ. Этот текст представляет собой крошечный успешный HTTP-ответ. Давайте запишем это в поток как наш ответ на успешный запрос! Из функции handle_connection удалите println!, который выводил данные запроса, и замените его кодом из Листинга 21-3.
Файл: src/main.rs
#![allow(unused)] fn main() { 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(); } }
Листинг 21-3: Запись крошечного успешного HTTP-ответа в поток
Первая новая строка определяет переменную response, которая содержит данные сообщения об успехе. Затем мы вызываем as_bytes для нашего ответа, чтобы преобразовать строковые данные в байты. Метод write_all у потока принимает &[u8] и отправляет эти байты непосредственно через соединение. Поскольку операция write_all может завершиться неудачей, мы используем unwrap для любого результата с ошибкой, как и раньше. Опять же, в реальном приложении вы должны добавить обработку ошибок здесь.
С этими изменениями давайте запустим наш код и сделаем запрос. Мы больше не выводим никакие данные в терминал, поэтому мы не увидим никакого вывода, кроме вывода от Cargo. Когда вы загрузите 127.0.0.1:7878 в веб-браузере, вы должны получить пустую страницу вместо ошибки. Вы только что вручную закодировали получение HTTP-запроса и отправку ответа!
Возврат реального HTML
Давайте реализуем функциональность для возврата чего-то большего, чем просто пустая страница. Создайте новый файл hello.html в корневой директории вашего проекта, а не в директории src. Вы можете ввести любой HTML-код; Листинг 21-4 показывает один из возможных вариантов.
Файл: hello.html
<!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>
Листинг 21-4: Пример HTML-файла для возврата в ответе
Это минимальный HTML5-документ с заголовком и некоторым текстом. Чтобы возвращать его с сервера при получении запроса, мы изменим handle_connection, как показано в Листинге 21-5, чтобы прочитать HTML-файл, добавить его в ответ в качестве тела и отправить.
Файл: src/main.rs
#![allow(unused)] fn main() { use std::{ fs, io::{BufReader, prelude::*}, net::{TcpListener, TcpStream}, }; // --пропуск-- 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(); } }
Листинг 21-5: Отправка содержимого hello.html в качестве тела ответа
Мы добавили fs в оператор use, чтобы включить модуль файловой системы стандартной библиотеки в область видимости. Код для чтения содержимого файла в строку должен выглядеть знакомо; мы использовали его, когда читали содержимое файла для нашего I/O-проекта в Листинге 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 для обработки запросов по-разному.
Файл: src/main.rs
#![allow(unused)] fn main() { // --пропуск-- 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 { // какой-то другой запрос } } }
Листинг 21-6: Обработка запросов к / иначе, чем других запросов
Мы будем смотреть только на первую строку 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 для страницы, которая будет отображаться в браузере, указывая на ответ конечному пользователю.
Файл: src/main.rs
#![allow(unused)] fn main() { // --пропуск-- } 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(); } }
Листинг 21-7: Ответ с кодом статуса 404 и страницей ошибки, если запрашивается что-либо кроме /
Здесь наш ответ имеет строку статуса с кодом 404 и поясняющей фразой NOT FOUND. Тело ответа будет HTML из файла 404.html. Вам нужно будет создать файл 404.html рядом с hello.html для страницы ошибки; опять же, не стесняйтесь использовать любой HTML, который вы хотите, или используйте пример HTML из Листинга 21-8.
Файл: 404.html
<!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>
Листинг 21-8: Пример содержимого для страницы, возвращаемой с любым ответом 404
С этими изменениями снова запустите ваш сервер. Запрос 127.0.0.1:7878 должен возвращать содержимое hello.html, а любой другой запрос, например 127.0.0.1:7878/foo, должен возвращать HTML с ошибкой из 404.html.
Рефакторинг
На данный момент блоки if и else содержат много повторяющегося кода: они оба читают файлы и записывают содержимое файлов в поток. Единственные различия — это строка статуса и имя файла. Давайте сделаем код более лаконичным, вынеся эти различия в отдельные строки if и else, которые будут присваивать значения строки статуса и имени файла переменным; затем мы можем использовать эти переменные безусловно в коде для чтения файла и записи ответа. Листинг 21-9 показывает результирующий код после замены больших блоков if и else.
Файл: src/main.rs
#![allow(unused)] fn main() { // --пропуск-- fn handle_connection(mut stream: TcpStream) { // --пропуск-- 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(); } }
Листинг 21-9: Рефакторинг блоков if и else для содержания только кода, который различается между двумя случаями
Теперь блоки if и else только возвращают соответствующие значения для строки статуса и имени файла в кортеже; затем мы используем деструктуризацию, чтобы присвоить эти два значения status_line и filename с помощью шаблона в операторе let, как обсуждалось в Главе 19.
Ранее дублированный код теперь находится вне блоков if и else и использует переменные status_line и filename. Это упрощает понимание различий между двумя случаями и означает, что у нас есть только одно место для обновления кода, если мы хотим изменить работу чтения файлов и записи ответов. Поведение кода в Листинге 21-9 будет таким же, как в Листинге 21-7.
Отлично! Теперь у нас есть простой веб-сервер примерно в 40 строках кода на Rust, который отвечает на один запрос страницей с содержимым и отвечает на все другие запросы ответом 404.
В настоящее время наш сервер работает в одном потоке, что означает, что он может обрабатывать только один запрос за раз. Давайте рассмотрим, как это может стать проблемой, смоделировав несколько медленных запросов. Затем мы исправим это, чтобы наш сервер мог обрабатывать несколько запросов одновременно.