Как создать HTTP-фреймворк «Hello World!» на Rust

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

С недавних пор я изучаю Rust и пока выполняла только базовые упражнения из Rust Book. Но вот наконец нашлось свободное время, я решила выйти из зоны комфорта и сделать что-то сумасшедшее: написать код для бэкенд-фреймворка http-сервера с системой маршрутизации и пошаговое руководство для его создания.

До этого я знала не весь Rust, но сейчас намного больше: новыми навыками овладеваю без колебаний. Имея опыт программирования на TypeScript, Ruby, C++ и почти ежедневный на JavaScript и Python, уже более или менее знакома со всеми необходимыми концепциями высокого уровня. Заметила, что у Rust активная, дружная база пользователей и невероятная документация, лучшая из увиденных мной. То есть справочной информации предостаточно.

Сначала устанавливаем Rust и дополнительно Docker.

Добавляем в среду Rust Analyzer и другие интересные/полезные расширения VS Code.

Начинаем.

#1: Rust за 60 секунд

…или сколько вам потребуется времени, чтобы это прочитать

Если уже знакомы с Rust и просто пишете код для фреймворка, пропустите эту часть. Рассмотрим синтаксис этого кода.

Типы

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

А еще системой определения типов Rust тип переменной часто выводится без ее явного указания, отчего код короче и удобнее для восприятия. В целом система типов  —  одна из сильных сторон Rust, благодаря которой код разработчиков безопаснее и удобнее в сопровождении.

В Rust проверка типов выполняется во время определения:

// «struct» на Rust очень похожа на объект JavaScript или словарь Python. Если вы никогда не использовали статически типизированный язык, 
// то заметите: первые четыре строки кода ниже часто не
// видны в JavaScript/Python, если только не задействовать модуль «typing» на Python
// или Typescript. Нужно указать Rust, какими типами данных
// заполняется структура

// Определяем новую структуру «Person»
// Сообщаем Rust, что «person» — это структура со
// свойствами «name» и «age», и присваиваем им типы
// «string» и «u32» соответственно, последний — это 32-битное целое число без знака.
struct Person {
name: String,
age: u32,
}

// Создаем новый экземпляр структуры «Person»
let alice = Person {
name: String::from("Alice"),
age: 30,
};

// Получаем доступ к полям структуры «Person»
// «println!» — это аналог «print()» на Python и
// «console.log()» на JavaScript. Здесь, чтобы вставить значения переменной в выводимую строку, используем версию Rust строковой интерполяции.
println!("{} is {} years old.", alice.name, alice.age);

Вот используемая далее функция для добавления во фреймворк маршрута:

// Определяем общую функцию «add_route», которой принимается изменяемая 
// ссылка на «self», строковые срезы «method» и «path»,
// указатель функции «handler».
pub fn add_route(&mut self, method: &str, path: &str, handler: fn(&mut TcpStream, &str)) {

// Создаем новый экземпляр структуры «Route» с полями «method», «path» и
// «handler», инициализируемый передаваемыми в функцию значениями.
let route = Route {
method: method.to_string(),
path: path.to_string(),
handler,
};

// Добавляем вновь созданный экземпляр «Route» в вектор «self.routes». Вектор
// — расширяемый тип данных, похожий на массив JavaScript
self.routes.push(route);
}

Надеюсь, что-то из этого вам знакомо.

#2: Приступим

В терминальной программе запускаем:

cargo new <your project name>

Cargo  —  диспетчер пакетов на Rust, аналогичный NPM в JavaScript. Создаете проект с cargo впервые? Посмотрите на структуру папок: на верхнем уровне имеются файлы Cargo.toml и Cargo.lock, аналоги package.json и package-lock.json в проекте на Node. В Cargo.toml вводится информация о проекте, а также список зависимостей. Открываем файл main.rs в папке src. Это стандартная точка входа для программы на Rust, похожая на index.js или __init__.py.

#3: main.rs

Ради краткости будем использовать main.rs как экземпляр сервера, но на самом деле обычно он определяется в файле server.rs. Вместо того, что находится в main.rs, введите следующее:

// Импортируем из файла «router.rs» модуль «Router».
// Его еще нет, поэтому средство контроля качества кода наверняка
// «ругается». Сейчас мы это исправим.
mod router;
// Импортируем из стандартной библиотеки необходимые модули ввода-вывода и сетевые модули.
use std::io::prelude::*;
use std::net::TcpListener;
// Импортируем из файла «router.rs» структуру «Router».
use router::Router;

// Определяем функцию «main», которой запустится сервер.
fn main() {
// Создаем новый экземпляр «Router».
let mut router = Router::new();

// Добавляем в маршрутизатор новый маршрут для пути «/» с
// функцией «handler», в которой возвращается ответ «Hello, world!»
// Эта функция определяется странновато,
// похожа на анонимную функцию «замыкания»,
// как в Express.js
router.add_route("GET", "/", |stream, _| {
let response = "HTTP/1.1 200 OK\r\n\r\nHello, world!";

// Записываем строку ответа в TCP-поток.
stream.write(response.as_bytes()).unwrap();

// Чтобы ответ отправлялся сразу, очищаем TCP-поток
// с помощью «flush»
stream.flush().unwrap();
});

// Создаем новый TCP-прослушиватель для прослушивания входящих подключений
// на порту 8080.
let listener = TcpListener::bind("0.0.0.0:8080").unwrap();

// Выводим на консоль сообщение с указанием, что порт
// сейчас прослушивается сервером.
println!("Server listening on port 8080");

// Перебираем входящие подключения от клиентов.
for stream in listener.incoming() {

// Применяем «unwrap» на входящем потоке и создаем изменяемую ссылку на него.
let mut stream = stream.unwrap();

// Создаем новый буфер для хранения входящих данных от клиента.
let mut buffer = [0; 1024];

// Считываем в буфер данные от клиента.
stream.read(&mut buffer).unwrap();

// Преобразуем байты буфера в строку UTF-8.
let request = String::from_utf8_lossy(&buffer[..]);

// Делим строку запроса на строки, собираем их в
// вектор строковых срезов.
let request_lines: Vec<&str> = request.lines().collect();

// Извлекаем первую строку запроса.
let request_line = request_lines[0];

// Делим строку запроса на части: «method», «path», HTTP-версия.
// Собираем их в вектор строковых срезов.
let request_parts: Vec<&str> = request_line.split(" ").collect();

// Извлекаем из частей запроса HTTP-метод, например «GET», «POST» и т. д.
let method = request_parts.get(0).unwrap_or(&"");

// Извлекаем из частей запроса путь запроса, например «/index.html».
let path = request_parts.get(1).unwrap_or(&"");

// Обрабатываем входящий запрос методом «handle_request»
// экземпляра «Router».
router.handle_request(&mut stream, method, path);
}
}

В этом коде определяется программа на Rust для запуска HTTP-сервера. Разберем подробно.

  • Начинаем с импорта необходимых модулей: Router из файла router.rs, ввода-вывода и сетевых из стандартной библиотеки.
  • Определяем точку входа для программы  —  функцию main, внутри нее создаем новый экземпляр Router и добавляем к нему новый маршрут для пути «/» с функцией «handler», в которой возвращается ответ «Hello, world!».
  • Затем создаем TCP-прослушиватель для прослушивания входящих подключений на порту 8080 и выводим на консоль сообщение с указанием, что порт сейчас прослушивается сервером. Так из простого кода вы создали собственный веб-сокет.
  • Перебираем входящие подключения от клиентов, для каждого подключения разворачиваем входящий поток и…

#4: router.rs

Создаем в папке src другой файл router.rs, открываем его в редакторе и вводим следующее:

// Импорт необходимых библиотек
use std::io::Write;
use std::net::TcpStream;

// Определение общей структуры «Router» с полем «routes», то есть вектором структуры «Route»
pub struct Router {
routes: Vec<Route>,
}

// Определение общей структуры «Route» с полями «method», «path», «handler»
pub struct Route {
method: String,
path: String,
handler: fn(&mut TcpStream, &str),
}

// Реализация структуры «Router»
impl Router {
// Определение новой функции, в которой возвращается новый экземпляр структуры «Router»
pub fn new() -> Self {
Router { routes: vec![] }
}

// Определение функции для добавления в структуру «Router» нового маршрута
pub fn add_route(&mut self, method: &str, path: &str, handler: fn(&mut TcpStream, &str)) {

// Создаем новый экземпляр структуры «Route» с переданными параметрами «method», «path», «handler»
let route = Route {
method: method.to_string(),
path: path.to_string(),
handler,
};

// Добавляем новый маршрут в вектор «routes» структуры «Router»
self.routes.push(route);
}

// Определение функции для обработки запросов к серверу
pub fn handle_request(&self, stream: &mut TcpStream, method: &str, path: &str) {
// В векторе «routes» структуры «Router» перебираем все маршруты
for route in &self.routes {

// Проверяем соответствие маршрута и запроса по «method» и «path»
if route.method == method && route.path == path {

// Если соответствие имеется, вызываем функцию «handler» с параметрами «stream» и «path»
(route.handler)(stream, path);
return;
}
}

// Если соответствия нет, отправляется ответ «404»

let response = "HTTP/1.1 404 NOT FOUND\r\n\r\n";

// stream.write(response.as_bytes()).unwrap();: этой строкой
// кода в TCP-поток записывается ответ. В методе «write()»
// возвращается тип «Result»:
// успешный результат «Ок» или ошибка «Err». На типе «Result»
// вызывается метод «unwrap()»: распакуется базовое
// значение «Ок» или, если имеется ошибка, случится паника. Если имеем
// записываемую в TCP-поток ошибку, программа «запаникует»
// и завершится.

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

// stream.flush().unwrap();: Этой строкой кода поток очищается,
// обеспечивается отправка всех данных клиенту. Как и в «write()»,
// в методе «flush()» возвращается тип «Result», распаковываемый
// методом «unwrap()»: если имеется ошибка,
// которой очищается поток, программа «запаникует» и завершится.

stream.flush().unwrap();
}
}

В этом коде определяется структура Router, в которой хранится вектор структур Route. В структуре Route содержится информация о методе HTTP, пути URL-адреса и функции «handler» для каждого маршрута, а в структуре Router  —  методы для добавления нового маршрута и обработки входящих HTTP-запросов.

В методе Router::new() создается новый экземпляр структуры Router с пустым вектором routes, в который с помощью метода Router::add_route() добавляется новый маршрут с указанными методом HTTP, путем URL-адреса и функцией «handler». В методе Router::handle_request() принимается входящий HTTP-запрос и сопоставляется с маршрутом в векторе routes: если маршрут найден, вызывается связанная с ним функция «handler» с параметрами HTTP «stream» и «path». Если нет, клиенту отправляется ответ «404».

В Rust &str  —  это строковый срез, аналогичный ссылке на строку. Методом to_string() он преобразуется в строку String. Ключевым словом fn определяется указатель функции. В методе Router::add_route() у параметра handler тип fn(&mut TcpStream, &str)  —  это функция, которой в качестве параметров принимаются изменяемая ссылка на TcpStream и строковый срез. Методами stream.write() и stream.flush() клиенту отправляется ответ по TCP-подключению.

Теперь средство контроля качества кода не должно «ругаться», программа запустится. Мы ей поможем.

#5: Тестирование GET-маршрута «hello world»

Открываем терминал на верхнем уровне проекта и вводим команду:

cargo run

Этой командой код скомпилируется в двоичный файл и затем запустится. Появятся автоматически созданные компилятором папки и файлы, в которых содержатся эти двоичные файлы.

Теперь вводим в браузере localhost:8080 или отправляем запрос через, например, Postman. Если все хорошо, в месте ожидаемого ответа появится «hello world». Опечатки проверяем средством контроля качества кода, на Rust они обычно даже исправляются.

Теперь поместим проект в контейнер Docker.

#6: DOCKER

На верхнем уровне файловой структуры приложения создаем файл dockerfile, помещаем в него код:

# 1. Указываем Docker на использование официального образа Rust
FROM rust:latest

# 2. Копируем файлы на компьютере в образ Docker
COPY ./ ./

# Создаем программу для выпуска
RUN cargo build --release

# Запускаем двоичный файл, сгенерированный командой «cargo build».
# Если этой строкой кода во время сборки образа выбрасывается ошибка,
# смотрим в файл проекта: имя двоичного файла в
# каталоге «release» должно быть «main»; если другое, меняем
# в CMD «["./target/release/<binary-file-name>"]» на
«["./target/release/main"]».

Теперь, находясь в каталоге одного уровня с dockerfile, вводим в терминале команду:

# Не потеряйте точку на конце

$ docker build -t rs-demo-image .

Так из только что сделанного dockerfile образ создается и копируется в каталог образов Docker.

Запускаем все еще одной командой терминала:

# Слева — порт хоста.
# Справа — виртуальный порт в Docker.

$ docker run -p 8080:8080 --name rs-demo-container rs-demo-image

Так создается контейнер rs-demo-container, он привязывается к виртуальному порту 8080 Docker, сопоставляемому с портом 8080 локального компьютера, и в него помещается созданный на предыдущем этапе образ rs-demo-image.

Теперь вводим в браузере localhost:8080, должен появиться ответ «hello world».

#7: Задачи на будущее

Доработать контейнер Docker

Вот отличная статья (на английском языке) для другой программы Rust, но с идентичными этапами Docker  —  нужно только поменять имена образов/контейнеров.

Добавить логи

Попробуйте макрос println! для вывода чего-либо о получаемых вами запросах, хотя бы обращений к серверу при отправке на него запроса.

Разделение обязанностей

Тем же шаблоном, что и для модуля router.rs, создайте модуль server.rs и реализуйте логику, найденную в main.rs как модуле, затем импортируйте его в main и используйте.

Вот часть кода для переноса в серверный модуль:

// Создаем новый TCP-прослушиватель для прослушивания входящих подключений на порту 8080.
let listener = TcpListener::bind("0.0.0.0:8080").unwrap();
// Выводим на консоль сообщение с указанием, что порт сейчас прослушивается сервером.
println!("Server listening on port 8080");

for stream in listener.incoming() {
// Применяем «unwrap» на входящем потоке и создаем изменяемую ссылку на него.
let mut stream = stream.unwrap();
// Создаем новый буфер для хранения входящих данных от клиента.
let mut buffer = [0; 1024];
// Считываем в буфер данные от клиента.
stream.read(&mut buffer).unwrap();

// Преобразуем байты буфера в строку UTF-8.
let request = String::from_utf8_lossy(&buffer[..]);

// Делим строку запроса на строки, собираем их в вектор строковых срезов.
let request_lines: Vec<&str> = request.lines().collect();
// Извлекаем первую строку запроса.
let request_line = request_lines[0];
// Делим строку запроса на части: «method», «path», HTTP-версия. Собираем их в вектор строковых срезов.
let request_parts: Vec<&str> = request_line.split(" ").collect();
// Извлекаем из частей запроса HTTP-метод, например «GET», «POST» и т. д.
let method = request_parts[0];
// Извлекаем из частей запроса путь запроса, например «/index.html».
let path = request_parts[1];

// Обрабатываем входящий запрос методом «handle_request» экземпляра «Router».
router.handle_request(&mut stream, method, path);

Дополнительные материалы

Рекомендую одно из лучших руководств «Язык программирования Rust» и примеры оттуда.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Noraa July Stoke: Tutorial: Build A Hello World HTTP Framework In Rust

Предыдущая статьяПростой прием для молниеносных запросов LIKE и ILIKE
Следующая статьяТоп-6 инструментов и фреймворков для искусственного интеллекта