Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Однопоточный веб-сервер

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

Два основных протокола, с которыми работают веб-серверы, — протокол передачи гипертекста (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

Для начала введите в src/main.rs код из листинга 21-1. Он будет слушать входящие 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>: привязка может не удаться — например, если запустить два экземпляра нашей программы, и две программы будут слушать один и тот же порт. Мы пишем простой сервер для учёбы, так что обрабатывать такие ошибки не будем, а просто остановим программу через unwrap.

Метод incoming у TcpListener возвращает итератор, который выдаёт последовательность потоков (точнее, потоков типа TcpStream). Один поток (stream) — это открытое соединение между клиентом и сервером. Соединение (connection) — это весь процесс запроса и ответа: клиент подключается к серверу, сервер формирует ответ и закрывает соединение. Поэтому мы будем читать из 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.

Файл: 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, которой передаётся 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

Первая строка — строка запроса (request line): в ней сведения о том, что запрашивает клиент. Первая часть строки запроса — метод, например 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:, — заголовки (headers). У запросов GET тела нет.

Попробуйте сделать запрос из другого браузера или запросить другой адрес, например 127.0.0.1:7878/test, и посмотрите, как меняются данные запроса.

Теперь, когда мы знаем, чего хочет браузер, отправим ему какие-нибудь данные!

Пишем ответ

Реализуем отправку данных в ответ на запрос клиента. Ответ имеет такой формат:

HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body

Первая строка — строка состояния (status line): в ней версия 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
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();
}
Листинг 21-3: Записываем в поток крошечный успешный HTTP-ответ

Первая новая строка определяет переменную response с данными сообщения об успехе. Затем мы вызываем у response метод as_bytes, чтобы превратить строку в байты. Метод write_all у stream принимает &[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
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 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 в качестве тела ответа

Мы добавили в инструкцию use модуль fs, чтобы подключить модуль файловой системы стандартной библиотеки. Код чтения содержимого файла в строку должен быть вам знаком: мы пользовались им, когда читали файл в проекте с вводом-выводом в листинге 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
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();
    } 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.

Теперь добавим в блок else код из листинга 21-7, который возвращает ответ с кодом состояния 404 — он означает, что запрошенное содержимое не найдено. Ещё вернём HTML-страницу, которую браузер покажет пользователю.

Файл: src/main.rs
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();
    // --пропуск--
    } 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 может быть любым — или воспользуйтесь примером из листинга 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
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();

    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 только возвращают подходящие значения строки состояния и имени файла в виде кортежа. Затем с помощью образца в инструкции let мы деструктурируем кортеж и присваиваем эти два значения переменным status_line и filename, как обсуждалось в главе 19.

Код, который раньше дублировался, теперь вне блоков if и else и использует переменные status_line и filename. Так легче увидеть разницу между двумя случаями, а если понадобится изменить чтение файла и запись ответа, править придётся только одно место. Поведение кода из листинга 21-9 такое же, как в листинге 21-7.

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

Сейчас сервер работает в одном потоке, то есть обслуживает только один запрос за раз. Сымитируем несколько медленных запросов и посмотрим, почему это может быть проблемой. А потом исправим, чтобы сервер мог обрабатывать несколько запросов одновременно.