Модуль 5.1 — Rust для веб-серверов
Упражнение 5.1.1: Lettuce Crop
В этом упражнении мы напишем простой веб-сервер на axum, который позволяет пользователям загружать изображения и обрезать их. Вы научитесь отдавать статические HTML-страницы вместе с их таблицами стилей и изображениями, а также обрабатывать POST-запросы с данными формы в формате multipart, чтобы принимать загруженные изображения.
5.1.1.A Hello axum
В exercises/5-rust-for-web/1-rust-for-web/1-lettuce-crop уже настроено начало нашего веб-сервера. Сейчас он отдаёт «Hello, world!» только на GET-запросы к главной странице. Запустите программу и откройте в браузере http://[::]:7000/, чтобы проверить, что всё работает.
Адрес http://[::]:7000/ — это неуказанный (wildcard) адрес для IPv6. Если вы хотите использовать IPv4, откройте http://0.0.0.0:7000/. Если сервер нужен только на localhost, используйте http://127.0.0.1:7000/ или http://[::1]:7000/.
В main.rs вы увидите Router, который отдаёт «Hello, world!». Можно связать несколько маршрутов, чтобы обслуживать несколько конечных точек. Попробуйте добавить второй маршрут, который обрабатывает GET-запросы к другой странице (например, /hello).
5.1.1.B Отдача статических файлов
Пока наш веб-сервер отдаёт только статические строки. Чтобы отдавать статические HTML-документы, CSS-таблицы стилей, изображения и другие файлы, используем файловый сервер ServeDir из tower_http. Его можно добавить в роутер как fallback-сервис, который обработает любой запрос, не совпавший ни с одним из заданных маршрутов.
Добавьте в роутер fallback_service с ServeDir, который отдаёт файлы из папки assets.
Если теперь открыть http://0.0.0.0:7000/index.html, вы должны увидеть страницу Lettuce Crop с нужным оформлением и изображением салата.
По умолчанию ServeDir автоматически добавляет index.html, если запрошен путь к каталогу. Значит, если удалить маршрут «Hello, world!» для /, страница Lettuce Crop появится и на главной странице сайта!
5.1.1.C POST-запросы и динамические ответы
На странице Lettuce Crop есть HTML-форма, которая при отправке делает POST-запрос на /crop:
<form action="/crop" method="post" enctype="multipart/form-data">
POST-запросы могут содержать дополнительные данные, которые отправляются серверу. В нашем случае вместе с запросом отправляются данные формы: изображение и значение максимального размера.
Если выбрать изображение и нажать кнопку обрезки, браузер перенаправит вас на /crop, которого пока не существует. Если открыть инструменты разработчика браузера (правый клик → Inspect или ctrl+shift+i) и перейти на вкладку Network, вы увидите POST-запрос, который сейчас возвращает статус 405 (Method Not Allowed). Маршрут /crop пока обрабатывается fallback-сервисом, который не принимает POST-запросы. Если открыть http://0.0.0.0:7000/crop напрямую, минуя форму, браузер отправит обычный GET-запрос, и вернётся статус 404 (Not Found).
Добавьте в роутер маршрут для /crop, который будет обрабатывать POST-запросы из формы. Маршрут задаётся так же, как и для GET-запросов, только вместо get используйте post.
Вместо статической строки для ответа на запрос можно использовать функцию. Определите следующую функцию и передайте её в метод post для /crop:
#![allow(unused)]
fn main() {
async fn crop_image() -> String {
format!("Hi! This is still a work in progress. {}", 42)
}
}
5.1.1.D Обработка загруженных файлов (multipart form data)
Как же получить данные формы из POST-запроса? В axum для получения информации о запросе (заголовков, путей, параметров запроса) используются экстракторы. Обычно для получения отправленной формы применяют экстрактор Form. Однако поскольку пользователь должен иметь возможность загрузить изображение, мы используем кодировку multipart form data, которая задаётся атрибутом enctype в HTML-теге form.
Чтобы извлечь multipart-данные формы в axum, используется экстрактор Multipart. В отличие от Form, экстрактор Multipart не разбирает данные автоматически в удобную структуру. Вместо этого нужно вручную пройтись по полям и десериализовать нужные данные.
Добавьте mut multipart: Multipart параметром в функцию crop_image, чтобы извлечь multipart-данные формы. Затем используйте следующий цикл, чтобы вывести все поля, которые были переданы в POST-запросе:
#![allow(unused)]
fn main() {
while let Some(field) = multipart.next_field().await.unwrap() {
let name = field.name().unwrap().to_string();
let bytes = field.bytes().await.unwrap();
println!("{name}: {} bytes long", bytes.len());
}
}
После отправки формы вы должны увидеть поле image с данными изображения и поле max_size, которое соответствует полю ввода максимального размера в форме.
Теперь разберём два поля формы:
-
Поле
imageсодержит байты, из которых состоит изображение. Чтобы прочитать данные изображения, используемImageReaderиз крейтаimage:#![allow(unused)] fn main() { ImageReader::new(Cursor::new(bytes)).with_guessed_format().unwrap().decode() }Это вернёт
DynamicImage, который может быть изображением одного из различных форматов. С помощью крейтаimageмы сможем обрезать и масштабировать это изображение. -
Поле
max_sizeсодержит число в виде обычного текста. Текст можно получить черезfield.text()вместоfield.bytes(), а затем распарсить в число с помощью.parse(). Пусть это будетu32.
Реализацию логики, которая разбирает эти два поля и превращает их в DynamicImage и u32, которые можно будет использовать после цикла по всем полям, мы оставляем вам.
Чтобы проверить, что всё работает, замените строку, которую возвращает crop_image, на следующую:
#![allow(unused)]
fn main() {
format!("Image size: {}x{}\nMax size: {}", image.width(), image.height(), max_size)
}
5.1.1.E Отправка обрезанного изображения в ответе
Обрежем DynamicImage до квадрата с помощью следующего кода:
#![allow(unused)]
fn main() {
let size = min(min(image.width(), image.height()), max_size);
let image = image.resize_to_fill(size, size, imageops::FilterType::Triangle);
}
Размер обрезанного квадратного изображения — это минимум из ширины, высоты и заданного максимального размера. Метод resize_to_fill обрежет и масштабирует изображение до нужного размера, аккуратно выровняв его по центру.
Теперь, когда изображение обрезано, его нужно вернуть клиенту. Кодируем изображение обратно в формат с помощью write_to; обрезанные изображения мы решили возвращать в формате WebP:
#![allow(unused)]
fn main() {
let mut image_buffer = Vec::new();
image
.write_to(&mut BufWriter::new(Cursor::new(&mut image_buffer)), ImageFormat::WebP)
.unwrap();
}
Чтобы отправить эти байты клиенту как изображение, нужно создать ответ с правильным заголовком типа содержимого и буфером изображения в качестве тела. Измените crop_image, чтобы она возвращала Response вместо String, и создайте ответ с помощью Response::builder(). Установите заголовок «content-type» в соответствии с выбранным форматом изображения (например, image/webp для WebP), а тело ответа сформируйте из буфера изображения с помощью Body::from.
Теперь, если отправить изображение на сайт, оно должно вернуться обрезанным до квадрата!
5.1.1.F Обработка ошибок и валидация входных данных
Сейчас обработчик, вероятно, содержит много .unwrap(), которые могут вызвать панику. К счастью, axum перехватывает такие паники в нашем обработчике и продолжает работать после вывода сообщения о панике. Однако в этом случае пользователь не получит от axum никакого корректного ответа. Чтобы сообщить клиенту, что пошло не так, можно реализовать более аккуратную обработку ошибок.
Изменим функцию crop_image, чтобы она возвращала Result<Response, (StatusCode, &'static str)>. Так мы сможем возвращать ошибки, состоящие из HTTP-кода статуса и статической строки.
Например, пусть пользователь загрузит повреждённое изображение. Тогда метод .decode() у ImageReader вернёт ошибку, и .unwrap() вызовет панику. Заменим .unwrap() на .map_err, который сообщает пользователю, что запрос некорректен:
.map_err(|_| (StatusCode::BAD_REQUEST, "Error: Could not decode image"))?
Аналогично вы можете добавить подходящую обработку ошибок и в других местах, возвращая соответствующие HTTP-коды статуса.
Сейчас размер обрезанного изображения задаётся как минимум из ширины и высоты исходного изображения и значения max_size. В HTML-форме значение max_size ограничено числом 2048. Однако данным от клиента нельзя доверять: HTML и JavaScript, которые выполняются на устройстве клиента, легко изменить, а клиент может отправить и изменённые HTTP-запросы. Поэтому также вернём StatusCode::BAD_REQUEST, если max_size больше 2048.
По умолчанию для тела запроса установлен лимит 2 МБ. Если пользователь отправит изображение больше этого лимита, вызов .bytes() для поля multipart вернёт ошибку. В таком случае можно вернуть StatusCode::PAYLOAD_TOO_LARGE. Если вы хотите принимать большие изображения, задайте больший лимит с помощью DefaultBodyLimit.
5.1.1.G Отдача файлов из памяти (бонус)
Сейчас статические файлы отдаются из папки assets. Вместо этого мы можем упаковать эти файлы в бинарник с помощью memory-serve. Это удобно: все файлы попадают в один бинарник, а кроме того, это может повысить производительность!
После добавления memory-serve в проект командой cargo add memory-serve можно определить роутер для памяти так:
#![allow(unused)]
fn main() {
let memory_router = MemoryServe::new(load_assets!("assets"))
.index_file(Some("/index.html"))
.into_router();
}
Теперь этот роутер можно использовать как fallback-сервис вместо ServeDir.
Если собрать проект в режиме release (cargo build --release), вы увидите, что файлы из папки assets включены в бинарник!
Упражнение 5.1.2: Pastebin
В этом упражнении нужно написать простой веб-сервер pastebin. Веб-сервер снова будет построен на axum. Для этого упражнения проект нужно настроить самостоятельно.
- Данные хранятся в памяти. Бонус, если вы используете базу данных или
sqlite, но сначала добейтесь, чтобы приложение работало без неё. - Реализуйте маршрут, на который можно отправить POST-запрос с простым текстом. Он сохраняет текст вместе со свежесгенерированным UUID, который возвращается в ответе. Для генерации UUID можно использовать крейт
uuid. - Реализуйте маршрут, на который можно отправить GET-запрос с UUID. Он возвращает простой текст, соответствующий UUID, или ошибку 404, если такого текста нет.
- Реализуйте маршрут, на который можно отправить DELETE-запрос с UUID. Он удаляет текст, соответствующий этому UUID.