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

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

Два основных протокола, задействованных в веб-серверах, - это протокол передачи гипертекста (HTTP) и протокол управления передачей (TCP). Оба протокола являются протоколами типа запрос-ответ, что означает, что клиент инициирует запросы, а сервер слушает запросы и предоставляет ответ клиенту. Содержание этих запросов и ответов определяется протоколами.

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, чтобы начать. Этот код будет использовать адрес 127.0.0.1:7878 для входящих TCP-потоков. Когда он получит входящее соединение, он напечатает 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!");
    }
}

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

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

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

Функция bind возвращает Result<T, E> , который указывает, что привязка может завершиться ошибкой. Например, для подключения к порту 80 требуются права администратора (не администраторы могут прослушивать только порты выше 1024), поэтому, если мы попытаемся подключиться к порту 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

use std::io::prelude::*;
use std::net::TcpListener;
use std::net::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 mut buffer = [0; 1024];

    stream.read(&mut buffer).unwrap();

    println!("Request: {}", String::from_utf8_lossy(&buffer[..]));
}

Листинг 20-2: Чтение из потока TcpStream и печать данных

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

В функции handle_connection мы сделали параметр stream изменяемым. Причина в том, что экземпляр TcpStream отслеживает, какие данные он нам возвращает. Он может прочитать больше данных, чем мы запрашивали, и сохранить их для следующего раза, когда мы запросим данные. Следовательно, он должен быть mut поскольку его внутреннее состояние может измениться; Обычно мы думаем, что «чтение» не требует мутации, но в этом случае нам нужно ключевое слово mut.

Далее нам нужно фактически прочитать данные из потока. Мы делаем это в два этапа: во-первых, мы объявляем buffer в стеке для хранения считываемых данных. Мы сделали буфер размером 1024 байта, что достаточно для хранения данных базового запроса и достаточно для наших целей в этой главе. Если бы мы хотели обрабатывать запросы произвольного размера, управление буфером должно было бы быть более сложным; пока делаем проще. Мы передаём буфер в stream.read , который считывает байты из TcpStream и помещает их в буфер.

Во-вторых, мы конвертируем байты из буфера в строку и печатаем эту строку. Функция String::from_utf8_lossy принимает &[u8] и создаёт из неё String. Названия «lossy» (с потерями) в её имени указывает на поведение этой функции. Когда она видит недопустимую последовательность UTF-8: она заменяет недопустимую последовательность на символ , символ замены U+FFFD REPLACEMENT CHARACTER. Вы могли видеть заменяющие символы в буфере, который не заполнен данными из запроса.

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

$ 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 (Windows NT 10.0; WOW64; rv:52.0) Gecko/20100101
Firefox/52.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Upgrade-Insecure-Requests: 1
������������������������������������

В зависимости от вашего браузера результат может немного отличаться. Теперь, когда мы печатаем данные запроса, мы можем понять, почему мы получаем несколько подключений из одного запроса браузера, посмотрев на путь после Request: 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

use std::io::prelude::*;
use std::net::TcpListener;
use std::net::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 mut buffer = [0; 1024];

    stream.read(&mut buffer).unwrap();

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

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}

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

Первая новая строка определяет переменную response которая содержит данные сообщения об успешном выполнении. Затем мы вызываем as_bytes в нашем response чтобы преобразовать строковые данные в байты. Метод write в stream принимает &[u8] и отправляет эти байты напрямую по соединению.

Поскольку операция write может завершиться неудачно, мы, как и раньше, используем unwrap для любого результата ошибки. Опять же, в реальном приложении вы бы добавили сюда обработку ошибок. Наконец, flush подождёт и предотвратит продолжение программы, пока все байты не будут записаны в соединение; TcpStream содержит внутренний буфер для минимизации обращений к базовой операционной системе.

Сделав этим изменения давайте запустим код и сделаем запрос. Мы больше не выводим в терминал любые данные, поэтому мы не увидим ничего, кроме вывода из 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::fs;
// --snip--

use std::io::prelude::*;
use std::net::TcpListener;
use std::net::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 mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();

    let contents = fs::read_to_string("hello.html").unwrap();

    let response = format!(
        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
        contents.len(),
        contents
    );

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}

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

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

Далее мы используем format! чтобы добавить содержимое файла в качестве тела ответа об успешном завершении. Чтобы гарантировать действительный HTTP-ответ, мы добавляем заголовок Content-Length который имеет размер тела нашего ответа, в данном случае размер hello.html .

Запустите этот код командой cargo run и загрузите 127.0.0.1:7878 в браузере; вы должны увидеть выведенный HTML в браузере!

В настоящее время мы игнорируем данные запроса в buffer и просто безоговорочно отправляем обратно содержимое HTML-файла. Это означает, что если вы попытаетесь запросить 127.0.0.1:7878/something-else в своём браузере, вы все равно получите тот же ответ HTML. Наш сервер очень ограничен, и это не то, что делает большинство веб-серверов. Мы хотим настроить наши ответы в зависимости от запроса и отправлять обратно HTML-файл только для правильно сформированного запроса в / .

Проверка запроса и выборочное возвращение ответа

Прямо сейчас наш веб-сервер вернёт HTML-код в файле независимо от того, что запросил клиент. Давайте добавим функциональность, чтобы проверять, запрашивает ли браузер / перед возвратом HTML-файла, и возвращать ошибку, если браузер запрашивает что-либо ещё. Для этого нам нужно изменить handle_connection , как показано в листинге 20-6. Этот новый код проверяет содержимое полученного запроса на соответствие тому, как мы знаем, что запрос на / выглядит как, и добавляет блоки if и else чтобы обрабатывать запросы по-разному.

Файл: src/main.rs

use std::fs;
use std::io::prelude::*;
use std::net::TcpListener;
use std::net::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 mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();

    let get = b"GET / HTTP/1.1\r\n";

    if buffer.starts_with(get) {
        let contents = fs::read_to_string("hello.html").unwrap();

        let response = format!(
            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
            contents.len(),
            contents
        );

        stream.write(response.as_bytes()).unwrap();
        stream.flush().unwrap();
    } else {
        // some other request
    }
}

Листинг 20-6: Сопоставление запроса и обработка запросов для корневого ресурса /, отличающимся от запросов других ресурсов

Сначала мы жёстко кодируем данные, соответствующие запросу /, в переменную get . Поскольку мы читаем необработанные байты в буфер, мы преобразуем get в байтовую строку, добавляя синтаксис байтовой строки b"" в начало данных содержимого. Затем мы проверяем, начинается ли buffer с байтов в get . Если это так, это означает, что мы получили правильно сформированный запрос к / , и это успешный случай, который мы обработаем в блоке if который возвращает содержимое нашего HTML-файла.

Если buffer не начинается с байтов в 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

use std::fs;
use std::io::prelude::*;
use std::net::TcpListener;
use std::net::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 mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();

    let get = b"GET / HTTP/1.1\r\n";

    if buffer.starts_with(get) {
        let contents = fs::read_to_string("hello.html").unwrap();

        let response = format!(
            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
            contents.len(),
            contents
        );

        stream.write(response.as_bytes()).unwrap();
        stream.flush().unwrap();
    // --snip--
    } else {
        let status_line = "HTTP/1.1 404 NOT FOUND";
        let contents = fs::read_to_string("404.html").unwrap();

        let response = format!(
            "{}\r\nContent-Length: {}\r\n\r\n{}",
            status_line,
            contents.len(),
            contents
        );

        stream.write(response.as_bytes()).unwrap();
        stream.flush().unwrap();
    }
}

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

Здесь ответ имеет строку состояния с кодом 404 и фразу причины NOT FOUND. Тело ответа будет HTML из файла 404.html. Вам нужно создать файл 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

use std::fs;
use std::io::prelude::*;
use std::net::TcpListener;
use std::net::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 mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();

    let get = b"GET / HTTP/1.1\r\n";

    let (status_line, filename) = if buffer.starts_with(get) {
        ("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 response = format!(
        "{}\r\nContent-Length: {}\r\n\r\n{}",
        status_line,
        contents.len(),
        contents
    );

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}

Листинг 20-9. Реорганизация блоков if и else чтобы они содержали только код, который отличается в двух случаях.

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

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

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

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