Реализация своего АПИ на Rust с помощью Tokio и Wrap
Сейчас вам поведаю о создании своего API сервиса на основе Warp и Tokio.
Это является перевод данной статьи.
Структура
Прежде чем приступить к написанию кода, нужно немного продумать структуру API. Это поможет определить нужные эндпоинты, контроллеры и способы хранения данных.
Роуты
Для своего апи я определил два роута
/customers - GET -> получить список пользователей - POST -> создать нового пользователя и добавить инфомацию в хранилище /customers/{guid} - GET -> получить информацию о пользователе - POST -> обновить информацию о пользователе - DELETE -> удалить пользователя из хранилища
Обработчики
На основе маршрутов мне потребовалось определить несколько обработчиков
list_customers -> вернуть список пользователей create_customer -> создать нового пользователя и добавить его в базу данных get_customer -> вернуть информацию о конкретном пользователе update_customer -> обновить информацию о пользователе delete_customer -> удалить пользователя из базы данных
База данныхчто
Для примера я буду использовать in-memory хранилище.
Чтобы сгенерировать необходимый набор данных я воспользовался mockaroo.
Кроме того, модуль для работы с бд должен уметь инициализировать хранилище после запуска сервера.
Зависимости
- Warp — Фреймворк для создания веб-сервера на Расте.
- Tokio — Асинхронная среда выполнения для Раста.
- Serde — Библиотека для де/сериализации данных в типизированные данные Раста.
Реализация
Модели
Первое, что я хочу сделать, это определить свою модель пользователя, а также добавить некоторую структуру в код.
В main.rs
определите новый модуль с именем models
следующим образом:
mod models; fn main() { /* Логика */ }
Затем создать новый файл с именем models.rs
и добавьте следующее:
pub struct Customer { pub guid: String, pub first_name: String, pub last_name: String, pub email: String, pub address: String, }
Так как я разрабатываю API, эта структура данных должна иметь возможность сериализации и десериализации JSON. Я также хочу иметь возможность копировать структуру в хранилище данных и из него, не беспокоясь о проверке заимствования.
Для этого я добавлю оператор drive для структуры пользователя, чтобы использовать пару макросов из библиотеки Serde
и пару из Rust. Сейчас models.rs выглядит так:
База данных
База данных для этого API будет являться in-memory(то есть находиться в оперативной памяти и после отключения сервера очистится), которая будет являться вектором структуры Customer
. Но хранилище должно быть общим для всех маршрутов, поэтому я буду использовать смарт поинтеры[0] Раста вместе с Mutex[1], чтобы обеспечить безопасность потоков.
Во-первых, добавим в main.rs
новый модуль db:
mod db; mod models; fn main() { /* Логика */ }
Теперь создадим новый файл db.rs
.
В этом файле нужно сделать несколько вещей, но первое, что нужно сделать, это определить, как будет выглядеть хранилище данных.
Наше простое хранилище это лишь вектор структур Customer
, но его необходимо обернуть в thread safe ссылку, чтобы иметь возможность использовать несколько ссылок на хранилище данных в нескольких асинхронных обработчиках.
Добавим примерно такое в db.rs
:
use std::sync::Arc; use tokio::sync::Mutex; use crate::models::Customer; pub type Db = Arc<Mutex<Vec<Customer>>>;
Теперь, когда мы определили структуру хранилища данных, нам нужен способ его инициализации. Инициализация хранилища данных имеет два результата: либо пустое хранилище данных, либо хранилище данных, наполненное данными из файла.
Инициализация пустого хранилища довольно проста:
pub fn init_db() -> Db { Arc::new(Mutex::new(Vec::new())) }
Но чтобы получить данные из файла, нам придется добавить еще одну зависимость:
- serde_json — Для чтения необработанного JSON
serde_json = "1.0"
Теперь мы можем изменить db.rs
следующим образом:
use std::fs::File; use serde_json::from_reader; pub fn init_db() -> Db { let file = File::open("./data/customers.json"); match file => { Ok(json) => { let customers = from_reader(json).unwrap(); Arc::new(Mutex::new(customers)) }, Err(_) => { Arc::new(Mutex::new(Vec::new())) } } }
Эта функция пытается прочитать файл ./data/customers.json
. В случае успеха функция возвращает хранилище данных, загруженное данными клиента, в противном случае она возвращает пустой вектор.
db.rs
должен выглядеть примерно так:
Обработчики
На текущем этапе мы имеем модели и настройку базы данных. Теперь у нас нужда в общем связывающем инструменте. Тут в дело вступают обработчики.
Создадим файл handlers.rs
и определим его, как модуль в файле main.rs:
mod handlers;
Добавим в handlers.rs несколько импортов:
use std::convert::Infallible; use warp; use crate::models::Customer; use crate::db::Db;
Этот кусок позволяет обращаться к примитивам Customer
и Db
, которые мы определили ранее, из модуля обработчиков. Также тут мы импортируем модуль warp
и перечисление Infallible
, которое является типом ошибок, что никогда не могут произойти.
Список пользователей
Обработчик list_customers
вобработчик принимает на вход ссылку на хранилище и возвращает Result
, который оборачивает JSON-ответ.
pub async fn list_customers(db: Db) -> Result<impl warp::Reply, Infallible> { // ... }
В теле функции нужно реализовать получение данных из хранилища и сериализация их в JSON объект. Для удобства wrap
предоставляет функцию, которая преобразовывает вектор в JSON объект.
Строчка let customers = db.lock().await;
дожидается блокировки выполнения задачи, чтобы можно было безопасно обратиться к хранилищу.
Строка let customers: Vec<Customer> = customers.clone()
клонирует вектор из MutexGuard.
Последняя строка Ok(warp::reply::json(&customers))
оборачивает вектор в JSON объект и возвращает его.
Создание пользователя
Обработчик create_customer
принимает на вход объект Customer
и ссылку на хранилище. В случае успешного создания возвращает статус код CREATED
, в ином случае BAD_REQUEST.
Но прежде нужно обновить импорты wrap
'a в файле handlers.rs
.
Заменяем строчку use wrap;
на;
use warp::{self, http::StatusCode};
Данное изменение позволит использовать перечисление StatusCode
, как респонс.
По аналогии с list_customers
определяем обработчик create_customer
с подобным содержанием:
Получение пользователя
Обработчик get_customer на вход берет guid определенного пользователя и ссылку на базу данных и возвращает JSON-объект, если пользователь найден, иначе вернет заглушку в виде дефолтного объекта пользователя.
Перед этим добавим еще один макрос в структуру пользователя:
По аналогии с другими обработчиками добавляет что-то подобное в код:
pub async fn get_customer(guid: String, db: Db) -> Result<Box<dyn warp::Reply>, Infallible> { }
Возвращаемый примитив немного отличный от других, потому что на выходе мы получаем либо StatusCode, либо JSON-объект. Так как оба этих примитива реализуют warp::Reply, то мы можем использовать динамическое приведение через dyn.
Обновить пользователя
Обработчик update_customer
на вход принимает guid
пользователя, измененный объект Customer
и ссылку на бд. Возвращает OK
, если получилось изменить пользователя, NOT_FOUND
в случае, если нет пользователя в хранилище.
Удаление пользователя
Обработчик delete_customer
на вход принимает guid
пользователя, которого нужно удалить и ссылку на хранилище.
Если удалось удалить пользователя из базы данных, то функция вернет NO_CONTENT
, иначе NOT_FOUND
.
Роутинг
Теперь у нас все обработчики собраны, теперь присвоим их соответствующим роутам.
В main.rs
определим еще один модуль:
mod routes;
Теперь создадим файл routes.rs
:
use std::convert::Infallible; use warp::{self, Filter}; use crate::db::Db; use crate::handlers; use crate::models::Customer;
Сначала нам нужна вспомогательная функция для передачи ссылки на хранилище данных в обработчики из маршрутов.
fn with_db(db: Db) -> impl Filter<Extract = (Db,), Error = Infallible> { warp::any().map(move || db.clone()) }
Функция позволяет инжектить хранилище в роут и передавать его обработчику. Filter это трейт, который предоставляет функциональность для подбора маршрутов, что являются результатом одного или нескольких методов Filter.
GET /customers
Функция возвращает тип, реализующий трейт Filter
. Extract
используется, когда происходит совпадение, и возвращается значение Extract
.
По сути, функция определяет маршрут, который соответствует запрошенному пути «/customers» и является GET запросом.
Кроме того, чтобы сохранить некоторую работу в будущем, я реализую еще одну функцию, которая будет служить оболочкой для всех маршрутов пользователей. Позже будет легче, когда мы соединим все вместе.
pub fn customer_routes(db: Db) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone { customers_list(db.clone()) }
POST /customers
Этот маршрут добавит нового клиента в хранилище данных, если он еще не существует.
Одна вещь, которую нужно добавить, прежде чем мы реализуем функцию для маршрута, — это вспомогательная функция для извлечения JSON из тела POST запроса.
fn json_body() -> impl Filter<Extract = (Customer,), Error = warp::Rejection> + Clone { warp::body::content_length_limit(1024 * 16) .and(warp::body::json()) }
Функция будет очень похожа на customers_list
, за исключением обработчика. Добавьте в route.rs
следующее:
Эта функция определяет маршрут, который соответствует пути «/customers» и является POST запросом. Затем JSON из POST запроса и ссылка на хранилище извлекаются и передаются обработчику.
GET /customers/{guid}
Этот маршрут попытается получить одного клиента из хранилища данных.
В этой функции мы используем макрос path!
из wrap
, который позволяет передавать путь с переменной.
Это определяет маршрут, который будет соответствовать «customers/{какое-то значение}» и GET запросу. Затем он извлекает хранилище и передает его обработчику.
Одна вещь, которую следует учитывать для маршрутов, заключается в том, что наиболее конкретный маршрут должен быть проверен первым, иначе маршрут может не совпасть.
Например, если вспомогательная функция для маршрутов обновлена таким образом:
pub fn customer_routes( db: Db, ) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone { customers_list(db.clone()) .or(create_customer(db.clone())) .or(get_customer(db.clone())) }
Маршрут get_customer никогда не будет совпадать, потому что они имеют общий корневой путь — «/customers», что означает, что маршрут списка клиентов будет соответствовать «/customers» и «/customers/{guid}».
Чтобы устранить проблему, упорядочите маршрут так, чтобы наиболее точное совпадение было первым. Как это:
pub fn customer_routes( db: Db, ) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone { get_customer(db.clone()) .or(customers_list(db.clone())) .or(create_customer(db.clone())) }
PUT /customers/{guid}
Этот маршрут попытается обновить клиента, если он существует, и вернуть код состояния OK
, в противном случае возвращается код состояния NOT_FOUND
.
По аналогии с созданием определяем обработчик:
Затем обновим обертку над маршрутом пользователя:
pub fn customer_routes( db: Db, ) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone { get_customer(db.clone()) .or(update_customer(db.clone())) .or(create_customer(db.clone())) .or(customers_list(db)) }
DELETE /customers/{guid}
Последний маршрут просто удаляет клиента из хранилища данных, если он соответствует заданному guid
, а затем возвращает код состояния NO_CONTENT
, в противном случае возвращается код состояния NOT_FOUND
.
Затем обновим оболочку маршрута клиента. После добавления всех маршрутов обертка должна выглядеть так:
На этом все маршруты заканчиваются. Теперь мы можем перейти к связыванию всего вместе.
Функция main
Файл main.rs
собирает все части кода воедино. Он инициализирует хранилище данных, получает все маршруты и запускает сервер. Это также довольно короткий файл, поэтому я просто покажу его целиком:
Мы уже видели первые несколько строк, так что давайте пройдемся по основной функции.
Атрибут функции #[tokio::main]
устанавливает точку входа для среды выполнения tokio
. Это позволяет нам объявить основную функцию как асинхронную.
Первые две строки main
— это просто вызовы функций из наших модулей. Первый инициализирует хранилище данных, а второй получает оболочку маршрутов наших клиентов.
В последней строке используется warp::server
для создания сервера, а затем run
для запуска сервера на указанном хосте и порте. Мы используем ключевое слово await
, чтобы выполнить код до тех пор, пока функция запуска не завершится.