Исходный код — в репозитории. Чтобы извлечь из этого руководства максимум, читайте код построчно с комментариями — без них и без копипаста вводите его в сниппетах, таким же образом добавляйте комментарии. Закрепить прочитанное проще, просматривая каждый файл с разных ракурсов.
С недавних пор я изучаю 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» и примеры оттуда.
Читайте также:
- Как создать API-шлюз в Rust посредством библиотеки Hyper
- Комбинаторы парсеров: от parsimmon до nom (Typescript → Rust)
- Тестирование производительности: rust/warp против go/fasthttp
Читайте нас в Telegram, VK и Дзен
Перевод статьи Noraa July Stoke: Tutorial: Build A Hello World HTTP Framework In Rust