August 10, 2022

Реализация своего АПИ на 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

Добавим его в Cargo.toml:

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, чтобы выполнить код до тех пор, пока функция запуска не завершится.