Перейти к содержанию

Понять rust, создав HTTP-сервер

Posted on:22 июня 2023 г. at 08:17

Example Dynamic OG Image link

Введение

Rust - высокоэффективный язык программирования, стоящий на самом любимом языке 7 лет подряд. Это связано с тем, что он является производительным, безопасным для памяти и очень гибким. Сегодня мы узнаем о создании HTTP-сервера на rust и изучим несколько концепций языка.

Начнем, убедитесь, что в вашей системе установлен rust, и давайте создадим новый проект.

cargo new my_web_server && cd my_web_server

Построение TCP-сервера

Создание TCP-сервера

Протокол HTTP работает поверх протокола TCP уровня L4 (сетевой уровень). При создании сервера фактически создается сервер TCP, и данные отправляются в формате HTTP, т.е. TCP указывает, как происходит связь, а HTTP указывает схему данных, которые должны быть отправлены по этому соединению.

Откройте main.rs в любимой среде IDE и добавьте в нее следующий код:

#[allow(unused)]
use std::{net::{TcpListener}, io::Read};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:8477").unwrap();
    println!("Сервер прослушивает порт 8477");
    for stream in listener.incoming() {
        let stream = stream.unwrap();
        println!("Подключение произошло");
    }
}

Давайте перейдем к разбору кода.

`#[allow(unused)]`

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

`TcpListener::bind("127.0.0.1:8477").unwrap()`

В основном bind возвращает функцию в перечислении типа Result , которое является способом выразить, что вышеупомянутая функция может или ответить паникой или результатом (здесь TcpListener), и мы хотим отказаться продолжать выполнение при ошибке, в другом случае продолжить программу.

Это имеет смысл использовать, если возникновение исключения может привести к остановке программы и она не сможет продолжать выполнение дальше, есть лучшие способы обработки такого исключения.

println!("Сервер прослушивает порт 8477");

В имени функции println есть восклицательный знак, потому что она не является функцией и является макросом.

for stream in listener.incoming() {

Метод incoming(), возвращает итератор потоков (в частности, потоков с типом, определяемым, например, TcpStream. Один поток представляет открытое соединение между клиентом и сервером, так что здесь мы в основном обходим по каждому соединению через цикл for.

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

let stream = stream.unwrap();

Поток переменных цикла возвращает Result, который необходимо развернуть. (Это не элегантный способ справиться с этим, так как один сбой соединения тут, разрушит все приложение, что не должно произойти продакшине).

Кроме того, обратите внимание, что имя переменного потока совпадает с именем потока переменной цикла, который называется затенением в rust. Это также присутствует в других языках, как javascript.

Протестируем код, выполнив запрос:

curl localhost:8477

Вы, вероятно, должны получить что-то вроде Connection reset by peer Это потому, что мы ничего не делаем с соединением.

Создание обработчика соединений

Создание функции с именем handle_connection для данных потока:

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&mut stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|res| res.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();
    println!("Request: {:#?}", http_request);
}

О этой функции много можно рассказать, во-первых, ключевое слово mut сообщает rust, что handle_connection внесет изменения в поток (записав в него, чего мы сейчас не делаем).

Во второй строке мы создаем новый буфер с изменяемой ссылкой на тот же самый поток, то есть это ссылка кучи на тот же самый объект потока. (Больше пустяков дальше)

У нас есть некоторые простые буферные операции перед печатью самого вектора с помощью {:#?} это основной способ распечатать вектор в rust, вы также можете использовать {:?}, который распечатает все значения в одной строке.

Использование обработчика для потоков соединений

for stream in listener.incoming() {
    let stream = stream.unwrap();
    handle_connection(stream); // <- использовать здесь
    println!("Подключение произошло");
}

Одно важное примечание - Объект потока теперь принадлежит функции handle_connection, что означает, что на него больше нельзя ссылаться в цикле for. Если просто попытаться добавить handle_connection(stream); второй раз это даст вам ошибку во времени компиляции, указывающую, что поток переменных перемещен. Это очень мощный функционал rust, поскольку он упрощает очистку памяти.

Создание HTTP-соединения

Как мы узнали, HTTP - это просто способ определения структуры данных, передаваемых по TCP-соединению. Для получения дополнительной информации см. полное описание в разделе «Request For Comments».

Создание статуса ответа

Если бы вы сделали запрос с URL на локальном хосте (или открыли в браузере), вы бы не получили ответ, потому что мы фактически не отвечаем клиенту, вызывающему наш сервер.

Добавим это после handle_connection функции:

let response = "HTTP/1.1 200 OK\r\n\r\n";
stream.write_all(response.as_bytes()).unwrap();

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

Обратите внимание, что\r\n присутствует в переменной ответа два раза, это стандартный разделитель строк, определенный в протоколе, часто называемый CRLF.

Давайте отправим html

Создайте файл с именем response.html. Создайте его в корне приложения, а не внутри src/

<!DOCTYPE html>
<html lang="ru">
  <head>
    <meta charset="utf-8" />
    <title>Привет!</title>
  </head>
  <body>
    <h1>Привет!</h1>
    <p>Мое имя ведант</p>
  </body>
</html>

Замените последние 2 строки в функции handle_connection на эти:

let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("response.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();

fs - модуль, используемый для работы с файловой системой в rust.

Мы используем format! добавляя содержимое файла в качестве тела ответа об успешном выполнении вместе с длиной содержимого, требуемой протоколом.

Если открыть localhost:8477 в браузере, должно визуализировать HTML-файл.

Обратите внимание, что если вы также откроете какой-либо маршрут в браузере localhost:8477/vedant например, это вернет вам тот же результат. Это не желаемый результат, так как мы хотим управлять ответом на основании того, по какому маршруту сделан запрос GET.

Добавление маршрутов

let req_line = buf_reader.lines().next().unwrap().unwrap();
if req_line == "GET / HTTP/1.1" {
    let status_line = "HTTP/1.1 200 OK";
    let contents = fs::read_to_string("response.html").expect("Не удалось открыть файл");
    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();
}

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

Если мы сделаем это, соединение останется открытым для любого маршрута, который не является корнем, и будет не иметь никакого ответа, ни успеха, ни неудачи. Давайте добавим 404 для других маршрутов.

Создание файла 404.html:

<!DOCTYPE html>
<html lang="ru">
  <head>
    <meta charset="utf-8">
    <title>Привет!</title>
  </head>
  <body>
    <h1>Ну нет!</h1>
    <p>Думаю, вы заблудилисьr.</p>
  </body>
</html>

Добавить блок else в функцию handle_request:

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();
}

Теперь любой маршрут, не являющийся корневым, приведет к ошибкам с содержимым 404.

Рефакторинг кода

Мы можем переделать вышеуказанную функцию, чтобы бы она была более краткой.

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

    let (status_line, filename) = if req_line == "GET / HTTP/1.1" {
        ("HTTP/1.1 200 OK", "response.html")
    } else {
        ("HTTP/1.1 200 OK", "404.html")
    };

    let contents = fs::read_to_string(filename).expect("Не удалось открыть файл");
    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();
}

Здесь значения status_line и имя файла получаются в условии, вроде троичного оператора (но это не точно).