Создание однопоточного веб-сервера

Beginner

This tutorial is from open-source community. Access the source code

Введение

Добро пожаловать в Building a Single-Threaded Web Server. Этот лаба является частью Rust Book. Вы можете практиковать свои навыки Rust в LabEx.

В этом лабе мы построим однопоточный веб-сервер, который использует протоколы HTTP и TCP для обработки запросов клиентов и предоставления ответов.

Построение однопоточного веб-сервера

Начнем с того, чтобы сделать работающим однопоточный веб-сервер. Перед тем, как начать, давайте рассмотрим краткое обзоры протоколов, участвующих в построении веб-серверов. Подробности этих протоколов выходят за рамки этой книги, но краткий обзор даст вам необходимую информацию.

Два основных протокола, участвующих в веб-серверах, - это Hypertext Transfer Protocol (HTTP) и Transmission Control Protocol (TCP). Оба протокола являются request-response протоколами, то есть клиент инициализирует запросы, а сервер слушает запросы и предоставляет ответ клиенту. Содержание этих запросов и ответов определяется протоколами.

TCP - это более низкоуровневый протокол, который описывает детали того, как информация передается с одного сервера на другой, но не определяет, что это за информация. HTTP строится поверх TCP, определяя содержание запросов и ответов. Технически возможно использовать HTTP с другими протоколами, но в подавляющем большинстве случаев HTTP передает свои данные по TCP. Мы будем работать с необработанными байтами TCP и HTTP запросов и ответов.

Слушаем TCP-соединение

Наше веб-серверу нужно слушать TCP-соединение, поэтому это будет первый этап нашей работы. Стандартная библиотека предоставляет модуль std::net, который позволяет нам это сделать. Создадим новый проект в обычном стиле:

$ cargo new hello
     Создан бинарный (приложение) проект `hello`
$ cd hello

Теперь вставьте код из Листинга 20-1 в src/main.rs, чтобы начать. Этот код будет слушать локальный адрес 127.0.0.1:7878 на входящие TCP-потоки. Когда он получает входящий поток, он выведет Connection established!.

Имя файла: src/main.rs

use std::net::TcpListener;

fn main() {
  1 let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

  2 for stream in listener.incoming() {
      3 let stream = stream.unwrap();

      4 println!("Connection established!");
    }
}

Листинг 20-1: Слушаем входящие потоки и выводим сообщение, когда получаем поток

С помощью TcpListener мы можем слушать TCP-соединения по адресу 127.0.0.1:7878 [1]. В этом адресе часть перед двоеточием - это IP-адрес, представляющий ваш компьютер (это одинаково на каждом компьютере и не относится конкретно к компьютеру авторов), а 7878 - это порт. Мы выбрали этот порт по двум причинам: HTTP обычно не принимается на этом порту, поэтому наш сервер不大 вероятен столкнуться с каким-либо другим веб-сервером, который может быть запущен на вашем компьютере, и 7878 - это rust, набранное на телефоне.

Функция bind в этом случае работает так же, как функция new, то есть она возвращает новый экземпляр TcpListener. Функция называется bind, потому что в сети подключение к порту для прослушивания называется "биндингом к порту".

Функция bind возвращает Result<T, E>, что означает, что возможно, что биндинг может неудачно завершиться. Например, подключение к порту 80 требует административных прав (нелокальные пользователи могут слушать только порты выше 1023), поэтому, если мы попытаемся подключиться к порту 80, не имея административных прав, биндинг не сработает. Кроме того, биндинг не сработает, если мы запустим два экземпляра нашей программы и будем пытаться слушать один и тот же порт. Поскольку мы пишем простой сервер только для учебных целей, мы не будем беспокоиться о обработке таких ошибок; вместо этого мы используем unwrap, чтобы остановить программу, если ошибки возникают.

Метод incoming на TcpListener возвращает итератор, который дает нам последовательность потоков [2] (более конкретно, потоки типа TcpStream). Один поток представляет собой открытое соединение между клиентом и сервером. Соединение - это название для всего процесса запросов и ответов, в котором клиент подключается к серверу, сервер генерирует ответ и сервер закрывает соединение. Таким образом, мы будем читать из TcpStream, чтобы увидеть, что клиент отправил, и затем записывать наш ответ в поток, чтобы отправить данные обратно клиенту. В целом, этот цикл for будет последовательно обрабатывать каждое соединение и создавать серию потоков для обработки.

На данный момент наша обработка потока заключается в вызове unwrap, чтобы завершить программу, если в потоке есть ошибки [3]; если ошибок нет, программа выводит сообщение [4]. В следующем листинге мы добавим больше функциональности для успешного случая. Причина, по которой мы можем получить ошибки от метода incoming, когда клиент подключается к серверу, заключается в том, что мы на самом деле не итерируемся по соединениям. Вместо этого мы итерируемся по попыткам подключения. Соединение может не быть успешным по ряду причин, многие из которых зависят от операционной системы. Например, многие операционные системы имеют ограничение на количество одновременно открытых соединений, которое они могут поддерживать; новые попытки подключения за пределами этого количества будут вызывать ошибку, пока не будут закрыты некоторые открытые соединения.

Попробуем запустить этот код! Вызовите cargo run в терминале, а затем загрузите 127.0.0.1:7878 в веб-браузере. Браузер должен показать сообщение об ошибке, похожее на "Connection reset", потому что сервер в настоящее время не отправляет никаких данных обратно. Но когда вы смотрите в терминал, вы должны увидеть несколько сообщений, которые были напечатаны, когда браузер подключался к серверу!

     Запуск `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

1 use std::{
    io::{prelude::*, BufReader},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

      2 handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
  3 let buf_reader = BufReader::new(&mut stream);
  4 let http_request: Vec<_> = buf_reader
      5.lines()
      6.map(|result| result.unwrap())
      7.take_while(|line|!line.is_empty())
       .collect();

  8 println!("Request: {:#?}", http_request);
}

Листинг 20-2: Чтение из TcpStream и вывод данных

Мы подключаем std::io::prelude и std::io::BufReader для доступа к трейтам и типам, которые позволяют нам читать из и записывать в поток [1]. В цикле for в функции main вместо вывода сообщения о том, что мы установили соединение, мы теперь вызываем новую функцию handle_connection и передаем ей stream [2].

В функции handle_connection мы создаем новый экземпляр BufReader, который оборачивает изменяемую ссылку на stream [3]. BufReader добавляет буферизацию, управляя вызовами методов трейта std::io::Read для нас.

Мы создаем переменную с именем http_request, чтобы собрать строки запроса, который отправляет браузер на наш сервер. Мы указываем, что хотим собрать эти строки в вектор, добавив аннотацию типа Vec<_> [4].

BufReader реализует трейт std::io::BufRead, который предоставляет метод lines [5]. Метод lines возвращает итератор Result<String, std::io::Error> путем разделения потока данных при встрече байта новой строки. Чтобы получить каждую String, мы применяем map и unwrap к каждому Result [6]. Result может быть ошибкой, если данные не являются допустимой UTF-8 или если при чтении из потока возникла проблема. Опять же, в продакшн-программе эти ошибки должны быть обработаны более элегантно, но мы выбираем остановить программу в случае ошибки для простоты.

Браузер сигнализирует о конце HTTP-запроса, отправив два символа новой строки подряд, поэтому, чтобы получить один запрос из потока, мы берем строки, пока не получим строку, которая является пустой строкой [7]. Как только мы собрали строки в вектор, мы выводим их с помощью красивой отладочной форматирования [8], чтобы можно было посмотреть на инструкции, которые отправляет веб-браузер на наш сервер.

Попробуем этот код! Запустите программу и снова сделайте запрос в веб-браузере. Обратите внимание, что мы по-прежнему получим страницу ошибки в браузере, но вывод нашей программы в терминале теперь будет похож на этот:

$ 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 - это протокол, основанный на тексте, и запрос имеет такой формат:

Метод Request-URI HTTP-версия CRLF
заголовки CRLF
тело сообщения

Первая строка - это строка запроса, которая содержит информацию о том, что запрашивает клиент. Первая часть строки запроса указывает на метод, используемый, например, 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-версия Код-статуса Фраза-причины CRLF
заголовки CRLF
тело сообщения

Первая строка - это строка статуса, которая содержит версию 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

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&mut stream);
    let http_request: Vec<_> = buf_reader
     .lines()
     .map(|result| result.unwrap())
     .take_while(|line|!line.is_empty())
     .collect();

  1 let response = "HTTP/1.1 200 OK\r\n\r\n";

  2 stream.write_all(response.3 as_bytes()).unwrap();
}

Листинг 20-3: Отправка минимального успешного HTTP-ответа в поток

Первая новая строка определяет переменную response, которая хранит данные об успешном сообщении [1]. Затем мы вызываем as_bytes для нашей response, чтобы преобразовать строковые данные в байты [3]. Метод write_all на stream принимает &[u8] и отправляет эти байты непосредственно по соединению [2]. Поскольку операция write_all может завершиться неудачно, мы используем unwrap для любых ошибочных результатов, как и раньше. Опять же, в реальном приложении вы бы добавили обработку ошибок здесь.

После этих изменений запустим наш код и сделаем запрос. Теперь мы не выводим никаких данных в терминал, поэтому мы не увидим никакого вывода, кроме вывода от Cargo. Когда вы загрузите 127.0.0.1:7878 в веб-браузере, вы должны получить пустую страницу вместо ошибки. Вы только что вручную написали получение HTTP-запроса и отправку ответа!

Возвращение настоящего HTML

Реализуем функциональность для возврата более чем пустой страницы. Создайте новый файл hello.html в корне каталога вашего проекта, а не в каталоге src. Вы можете ввести любой HTML, который хотите; Листинг 20-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>

Листинг 20-4: Пример HTML-файла для возврата в ответе

Это минимальный HTML5-документ с заголовком и некоторым текстом. Чтобы вернуть его сервера при получении запроса, мы изменим handle_connection, как показано в Листинге 20-5, чтобы прочитать HTML-файл, добавить его в ответ в качестве тела и отправить.

Имя файла: src/main.rs

use std::{
  1 fs,
    io::{prelude::*, BufReader},
    net::{TcpListener, TcpStream},
};
--snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&mut 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();

  2 let response = format!(
        "{status_line}\r\n\
         Content-Length: {length}\r\n\r\n\
         {contents}"
    );

    stream.write_all(response.as_bytes()).unwrap();
}

Листинг 20-5: Отправка содержимого hello.html в качестве тела ответа

Мы добавили fs в инструкцию use, чтобы подключить модуль файловой системы стандартной библиотеки [1]. Код для чтения содержимого файла в строку должен выглядеть знакомым; мы использовали его, когда читали содержимое файла для нашего проекта по вводу-выводу в Листинге 12-4.

Далее мы используем format!, чтобы добавить содержимое файла в качестве тела успешного ответа [2]. Чтобы обеспечить валидный 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

--snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&mut stream);
  1 let request_line = buf_reader
      .lines()
      .next()
      .unwrap()
      .unwrap();

  2 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\n\
             Content-Length: {length}\r\n\r\n\
             {contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
  3 } else {
        // some other request
    }
}

Листинг 20-6: Обработка запросов к / по-разному от других запросов

Мы будем рассматривать только первую строку HTTP-запроса, поэтому вместо чтения всего запроса в вектор мы вызываем next, чтобы получить первый элемент из итератора [1]. Первый unwrap обрабатывает Option и останавливает программу, если у итератора нет элементов. Второй unwrap обрабатывает Result и имеет то же самое действие, что и unwrap, добавленный в map в Листинге 20-2.

Далее мы проверяем request_line, чтобы понять, равно ли оно строке запроса на GET-запрос к пути / [2]. Если это так, блок if возвращает содержимое нашего HTML-файла.

Если request_line не равно GET-запросу к пути /, это означает, что мы получили какой-то другой запрос. Вскоре мы добавим код в блок else [3], чтобы ответить на все другие запросы.

Запустите этот код сейчас и запросите 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

--snip--
} else {
  1 let status_line = "HTTP/1.1 404 NOT FOUND";
  2 let contents = fs::read_to_string("404.html").unwrap();
    let length = contents.len();

    let response = format!(
        "{status_line}\r\n\
         Content-Length: {length}\r\n\r\n
         {contents}"
    );

    stream.write_all(response.as_bytes()).unwrap();
}

Листинг 20-7: Ответ с кодом статуса 404 и страницей ошибки, если был запрошен что-то другое, кроме /

Здесь наш ответ имеет строку статуса с кодом статуса 404 и фразой-причиной NOT FOUND [1]. Тело ответа будет содержать HTML из файла 404.html [1]. Вам нужно создать файл 404.html рядом с hello.html для страницы ошибки; снова вы можете использовать любой HTML, который хотите, или использовать пример HTML из Листинга 20-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>

Листинг 20-8: Пример содержимого страницы, которая будет возвращена при любом ответе 404

После этих изменений запустите свой сервер снова. Запрос 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

--snip--

fn handle_connection(mut stream: TcpStream) {
    --snip--

    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\n\
         Content-Length: {length}\r\n\r\n\
         {contents}"
    );

    stream.write_all(response.as_bytes()).unwrap();
}

Листинг 20-9: Рефакторинг блоков if и else, чтобы они содержали только код, различающийся между двумя случаями

Теперь блоки if и else возвращают только соответствующие значения для строки статуса и имени файла в кортеже; затем мы используем деструктуризацию, чтобы присвоить эти два значения status_line и filename с использованием шаблона в инструкции let, как обсуждалось в главе 18.

Ранее дублированный код теперь находится вне блоков if и else и использует переменные status_line и filename. Это делает легче увидеть разницу между двумя случаями, и это означает, что у нас есть только одно место для обновления кода, если мы хотим изменить, как работает чтение файла и запись ответа. Поведение кода в Листинге 20-9 будет тем же, что и в Листинге 20-8.

Отлично! Теперь у нас есть простой веб-сервер на примерно 40 строках кода на Rust, который отвечает на один запрос страницей с содержимым и на все другие запросы ответом 404.

В настоящее время наш сервер работает в одном потоке, что означает, что он может обслуживать только один запрос за раз. Давайте рассмотрим, как это может стать проблемой, имитируя некоторые медленные запросы. Затем мы исправим это, чтобы наш сервер мог обрабатывать несколько запросов одновременно.

Резюме

Поздравляем! Вы завершили лабораторную работу по созданию однопоточного веб-сервера. Вы можете практиковаться в других лабораторных работах в LabEx, чтобы улучшить свои навыки.