Создание API на Rust с помощью Rocket.rs и Diesel.rs в соответствии с чистой архитектурой

Создание API на Rust с помощью Rocket.rs и Diesel.rs в соответствии с чистой архитектурой

3 декабря 2022 г.

В этом руководстве я покажу вам процесс создания простого CRUD API с нуля в Rust с использованием Rocket.rs. Я покажу вам, как создавать миграции и получать доступ к базе данных PostgreSQL с помощью Diesel.rs, а также подключать все к внешнему интерфейсу React + Typescript. При создании проекта мы будем следовать чистой архитектуре, хотя я не буду слишком много говорить о том, что это такое, поскольку это не является предметом внимания данного руководства.

В этом руководстве предполагается:

  • У вас уже есть установка базы данных PostgreSQL
  • У вас установлена ​​последняя версия Rust (в этом руководстве используется версия 1.65.0)
  • У вас есть базовое понимание концепций Rust и синтаксиса языка

Теперь, когда с этим разобрались, давайте начнем!


Построение архитектуры проекта

Первым шагом является настройка архитектуры приложения. Начните с создания всеобъемлющего проекта Rust:

cargo new rust-blog
cd rust-blog

После этого удалите папку src, так как она нам не понадобится. Следующее, что мы собираемся сделать, это создать новый проект для каждого слоя в модели чистой архитектуры. Наша архитектура будет такой, что:

* Уровень API будет обрабатывать запросы API и действовать как наш обработчик маршрута. * Уровень приложения будет обрабатывать логику запросов API. * Уровень предметной области будет содержать модели и схемы наших баз данных. * Уровень инфраструктуры будет содержать наши миграции и подключения к базе данных. * Общий слой будет содержать любые другие модели, которые потребуются нашему проекту, например структуры ответов.

cargo new api --lib
cargo new application --lib
cargo new domain --lib
cargo new infrastructure --lib
cargo new shared --lib

К концу этого наш проект должен выглядеть примерно так:

.
├── Cargo.lock
├── Cargo.toml
├── api
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── application
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── domain
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── infrastructure
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
└── shared
    ├── Cargo.toml
    └── src
        └── lib.rs

Теперь мы собираемся связать все эти проекты в файле Cargo.toml верхнего уровня. Удалите все внутри файла и введите следующее:

[workspace]
members = [
  "api",
  "domain",
  "infrastructure",
  "application",
  "shared",
]

Хороший! Это большая часть наших шаблонов, теперь мы можем приступить к настоящей забаве.


Миграции

Поскольку мы используем Diesel.rs в качестве менеджера базы данных, нам потребуется установить инструмент CLI. В Diesel CLI есть несколько зависимостей, которые необходимо установить заранее в зависимости от того, какую базу данных вы планируете использовать:

  • libpq для PostgreSQL
  • libmysqlclient для Mysql
  • libsqlite3 для SQLite

Для этого проекта мы будем использовать PostgreSQL. Это означает, что нам нужен только libpq в качестве зависимости. Пожалуйста, обратитесь к документации для каждой необходимой зависимости, чтобы узнать, как установить ее в вашей операционной системе.

После установки libpq мы можем выполнить следующую команду для установки Diesel CLI:

cargo install diesel_cli --no-default-features --features postgres

Установив его, давайте настроим строку подключения к нашей базе данных. В каталоге проекта верхнего уровня выполните следующую команду с данными о ваших подключениях:

echo DATABASE_URL=postgres://username:password@localhost/blog > .env

Теперь мы можем использовать Diesel CLI, чтобы сделать всю тяжелую работу за нас. Перейдите в папку инфраструктуры и выполните следующую команду:

diesel setup

Это создаст несколько вещей:

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

С помощью инструмента Diesel CLI мы можем создать новую миграцию для выполнения первоначальной настройки нашей таблицы сообщений.

diesel migration generate create_posts

Diesel CLI создаст новую миграцию с именем, похожим на 2022–11–18–090125_create_posts. Первая часть — это дата создания миграции с уникальным кодом, за которым следует имя миграции. Внутри этой папки миграции будет два файла: up.sql, сообщающий Diesel CLI, что следует применить при миграции, и down.sql, сообщающий Diesel CLI, как отменить миграцию. .

Теперь давайте напишем SQL для миграции.

-- up.sql

CREATE TABLE posts (
  id SERIAL PRIMARY KEY,
  title VARCHAR NOT NULL,
  body TEXT NOT NULL,
  genre VARCHAR NOT NULL,
  published BOOLEAN NOT NULL DEFAULT false
)

-- down.sql

DROP TABLE posts

Используя Diesel CLI, мы можем применить новую миграцию, которую мы только что создали.

diesel migration run

Дополнительную информацию о выполнении миграции с помощью Diesel.rs см. в официальном руководстве по началу работы здесь.


Создание соединения

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

# infrastructure/Cargo.toml

[package]
name = "infrastructure"
version = "0.1.0"
edition = "2021"

[dependencies]
diesel = { version = "2.0.0", features = ["postgres"] }
dotenvy = "0.15"

// infrastructure/src/lib.rs

use diesel::pg::PgConnection;
use diesel::prelude::*;
use dotenvy::dotenv;
use std::env;

pub fn establish_connection() -> PgConnection {
    dotenv().ok();

    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set.");

    PgConnection::establish(&database_url).unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
}

Когда наше соединение установлено, нам нужно создать некоторые модели для нашей базы данных, а именно Post и NewPost.


Модели и схемы

Начните с перехода в domain и добавления следующих модулей в lib.rs.

// domain/src/lib.rs

pub mod models;
pub mod schema;

Мы будем использовать models для определения структур, которые будет использовать наша база данных и код, а schema будет автоматически сгенерирован Diesel CLI. Когда мы создали нашу миграцию, файл schema.rs был создан в инфраструктуре. Просто переместите его в domain/src. Если по какой-то причине schema.rs не был сгенерирован, вы можете запустить diesel print-schema в терминале, чтобы просмотреть схему.

# domain/Cargo.toml

[package]
name = "domain"
version = "0.1.0"
edition = "2021"

[dependencies]
rocket = { version = "0.5.0-rc.2", features = ["json"] }
diesel = { version = "2.0.0", features = ["postgres"] }
serde = { version = "1.0.147", features = ["derive"] }

// domain/src/models.rs

use crate::schema::posts;
use diesel::prelude::*;
use rocket::serde::{Deserialize, Serialize};
use std::cmp::{Ord, Eq, PartialOrd, PartialEq};

// Queryable will generate the code needed to load the struct from an SQL statement
#[derive(Queryable, Serialize, Ord, Eq, PartialEq, PartialOrd)]
pub struct Post {
    pub id: i32,
    pub title: String,
    pub body: String,
    pub genre: String,
    pub published: bool,
}

#[derive(Insertable, Deserialize)]
#[serde(crate = "rocket::serde")]
#[diesel(table_name = posts)]
pub struct NewPost {
    pub title: String,
    pub body: String,
    pub genre: String,
}

// domain/src/schema.rs

// @generated automatically by Diesel CLI.

diesel::table! {
    posts (id) {
        id -> Int4,
        title -> Varchar,
        body -> Text,
        genre -> Varchar,
        published -> Bool,
    }
}

Код в schema.rs может немного отличаться для вас, но концепция остается неизменной. Этот файл будет обновляться всякий раз, когда мы запускаем или отменяем миграцию. Важно отметить, что порядок полей в нашей структуре Post и в таблице posts должен совпадать.

Помимо определения моделей баз данных, давайте создадим модель для структурирования того, как будут форматироваться наши ответы API. Перейдите к shared/src и создайте новый файл response_models.rs.

# shared/Cargo.toml

[package]
name = "shared"
version = "0.1.0"
edition = "2021"

[dependencies]
domain = { path = "../domain" }

rocket = { version = "0.5.0-rc.2", features = ["json"] }
serde = { version = "1.0.147", features = ["derive"] }

// shared/src/lib.rs

pub mod response_models;

// shared/src/response_models.rs

use domain::models::Post;
use rocket::serde::Serialize;

#[derive(Serialize)]
pub enum ResponseBody {
    Message(String),
    Post(Post),
    Posts(Vec<Post>)
}

#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
pub struct Response {
    pub body: ResponseBody,
}

Перечисление ResponseBody будет использоваться для определения того, какие типы данных могут быть возвращены из нашего API, а структура Response определяет, как будет структурирован ответ.


Настройка Rocket.rs

Вау! Это было много настроек только для нашей базы данных, просто чтобы мы были в курсе, вот как должна выглядеть структура проекта в настоящее время:

.
├── Cargo.lock
├── Cargo.toml
├── api
│   └── ...
├── application
│   └── ...
├── domain
│   ├── Cargo.toml
│   └── src
│       ├── lib.rs
│       └── models.rs
├── infrastructure
│   ├── Cargo.toml
│   ├── migrations
│   │   └── 2022–11–18–090125_create_posts
│   │       ├── up.sql
│   │       └── down.sql
│   └── src
│     ├── lib.rs
│     └── schema.rs
└── shared
    ├── Cargo.toml
    └── src
        ├── lib.rs
        └── response_models.rs

Выполнив основную часть настройки базы данных, давайте начнем настройку части API для проекта.

Перейдите к api и импортируйте следующие зависимости:

# api/Cargo.toml

[package]
name = "api"
version = "0.1.0"
edition = "2021"

[dependencies]
domain = { path = "../domain" }
application = { path = "../application" }
shared = { path = "../shared" }

rocket = { version = "0.5.0-rc.2", features = ["json"] }
serde_json = "1.0.88"

Установив наши зависимости и ссылки на другие папки, давайте создадим папку bin для хранения main.rs.

.
└── api
    ├── Cargo.toml
    └── src
        ├── bin
        │   └── main.rs
        └── lib.rs

main.rs будет точкой входа нашего API, здесь мы определим маршруты, которые планируем использовать. Мы начнем с определения одного маршрута по мере создания приложения.

// api/src/lib.rs

pub mod post_handler;

// api/src/bin/main.rs

#[macro_use] extern crate rocket;
use api::post_handler;

#[launch]
fn rocket() -> _ {
    rocket::build()
        .mount("/api", routes![
            post_handler::list_posts_handler, 
            post_handler::list_post_handler,
        ])
}

Мы собираемся использовать post_handler.rs для определения самих маршрутов. Чтобы избежать постоянных ошибок от нашего LSP, мы будем использовать макрос todo!(), чтобы сообщить Rust, что эти функции/маршруты не завершены.

Создайте новый файл с именем post_handler.rs в src и напишите следующий код шаблона:

// api/src/post_handler.rs

use shared::response_models::{Response, ResponseBody};
use application::post::{read};
use domain::models::{Post};
use rocket::{get};
use rocket::response::status::{NotFound};
use rocket::serde::json::Json;

#[get("/")]
pub fn list_posts_handler() -> String {
    todo!()
}

#[get("/post/<post_id>")]
pub fn list_post_handler(post_id: i32) -> Result<String, NotFound<String>> {
    todo!()
}

Здесь мы определяем два запроса:

  1. GET /api/ (используется для отображения всех сообщений)
  2. GET /api/post/ (используется для отображения сообщения по идентификатору)


Обработка логики API

С шаблонами обработчиков запросов давайте напишем логику, необходимую для маршрутов. Внутри application создайте новую папку с именем post. Эта папка будет содержать файл для каждой логики маршрутов.

# application/Cargo.toml

[package]
name = "application"
version = "0.1.0"
edition = "2021"

[dependencies]
domain = { path = "../domain" }
infrastructure = { path = "../infrastructure" }
shared = { path = "../shared" }

diesel = { version = "2.0.0", features = ["postgres"] }
serde_json = "1.0.88"
rocket = { version = "0.5.0-rc.2", features = ["json"] }

// application/src/lib.rs

pub mod post;

// application/src/post/mod.rs

pub mod read;

// application/src/post/read.rs

use domain::models::Post;
use shared::response_models::{Response, ResponseBody};
use infrastructure::establish_connection;
use diesel::prelude::*;
use rocket::response::status::NotFound;

pub fn list_post(post_id: i32) -> Result<Post, NotFound<String>> {
    use domain::schema::posts;

    match posts::table.find(post_id).first::<Post>(&mut establish_connection()) {
        Ok(post) => Ok(post),
        Err(err) => match err {
            diesel::result::Error::NotFound => {
                let response = Response { body: ResponseBody::Message(format!("Error selecting post with id {} - {}", post_id, err))};
                return Err(NotFound(serde_json::to_string(&response).unwrap()));
            },
            _ => {
                panic!("Database error - {}", err);
            }        
        }
    }
}

pub fn list_posts() -> Vec<Post> {
    use domain::schema::posts;

    match posts::table.select(posts::all_columns).load::<Post>(&mut establish_connection()) {
        Ok(mut posts) => {
            posts.sort();
            posts
        },
        Err(err) => match err {
            _ => {
                panic!("Database error - {}", err);
            }
        }
    }
}

Важно отметить, что при использовании Rocket.rs макрос panic!() вернет 500 InternalServerError и не приведет к сбою вашей программы.< /p>

Когда логика для нашего маршрута написана, давайте вернемся к нашему обработчику сообщений, чтобы закончить наши два маршрута GET.

// api/src/post_handler.rs

// ...

#[get("/")]
pub fn list_posts_handler() -> String {
    // 👇 New function body!
    let posts: Vec<Post> = read::list_posts();
    let response = Response { body: ResponseBody::Posts(posts) };

    serde_json::to_string(&response).unwrap()
}

#[get("/post/<post_id>")]
pub fn list_post_handler(post_id: i32) -> Result<String, NotFound<String>> {
    // 👇 New function body!
    let post = read::list_post(post_id)?;
    let response = Response { body: ResponseBody::Post(post) };

    Ok(serde_json::to_string(&response).unwrap())
}

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

Давайте это изменим.


Создание сообщений

Как и раньше, мы начнем с создания шаблона обработчика маршрута. Это будет запрос POST, который будет принимать данные JSON.

// api/src/post_handler.rs

use shared::response_models::{Response, ResponseBody};
use application::post::{create, read}; // 👈 New!
use domain::models::{Post, NewPost}; // 👈 New! 
use rocket::{get, post}; // 👈 New! 
use rocket::response::status::{NotFound, Created}; // 👈 New! 
use rocket::serde::json::Json;

// ...

#[post("/new_post", format = "application/json", data = "<post>")]
pub fn create_post_handler(post: Json<NewPost>) -> Created<String> {
    create::create_post(post)
}

После этого мы можем начать реализацию функции create_post().

// application/src/post/mod.rs

pub mod read;
pub mod create; // 👈 New!

// application/src/post/create.rs

use domain::models::{Post, NewPost};
use shared::response_models::{Response, ResponseBody};
use infrastructure::establish_connection;
use diesel::prelude::*;
use rocket::response::status::Created;
use rocket::serde::json::Json;

pub fn create_post(post: Json<NewPost>) -> Created<String> {
    use domain::schema::posts;

    let post = post.into_inner();

    match diesel::insert_into(posts::table).values(&post).get_result::<Post>(&mut establish_connection()) {
        Ok(post) => {
            let response = Response { body: ResponseBody::Post(post) };
            Created::new("").tagged_body(serde_json::to_string(&response).unwrap())
        },
        Err(err) => match err {
            _ => {
                panic!("Database error - {}", err);
            }
        }
    }
}

Последнее, что нам нужно сделать, это зарегистрировать маршрут, чтобы его можно было использовать.

// api/src/bin/main.rs

#[macro_use] extern crate rocket;
use api::post_handler;

#[launch]
fn rocket() -> _ {
    rocket::build()
        .mount("/api", routes![
            post_handler::list_posts_handler, 
            post_handler::list_post_handler,
            post_handler::create_post_handler, // 👈 New!
        ])
}

Теперь это сделано, давайте, наконец, протестируем API с некоторыми данными!


CR__ Тестирование

Выполнив две из четырех букв, давайте проведем небольшой тест. Вернитесь в корневой каталог и запустите приложение.

cargo run

После создания проекта откройте свой любимый инструмент тестирования API и проверьте, работают ли маршруты должным образом.


Последние две буквы

Последние две операции, которые нам нужны, — это обновление и удаление. Мы реализуем обновление через «публикацию» сообщения и удаление, ну… удаление сообщения.

Как и в случае с предыдущими двумя письмами, давайте создадим наши обработчики.

// api/src/post_handler.rs

use shared::response_models::{Response, ResponseBody};
use application::post::{create, read, publish, delete}; // 👈 New!
use domain::models::{Post, NewPost};
use rocket::{get, post};
use rocket::response::status::{NotFound, Created};
use rocket::serde::json::Json;

// ...

#[get("/publish/<post_id>")]
pub fn publish_post_handler(post_id: i32) -> Result<String, NotFound<String>> {
    let post = publish::publish_post(post_id)?; 
    let response = Response { body: ResponseBody::Post(post) };

    Ok(serde_json::to_string(&response).unwrap())
}

#[get("/delete/<post_id>")]
pub fn delete_post_handler(post_id: i32) -> Result<String, NotFound<String>> {
    let posts = delete::delete_post(post_id)?;
    let response = Response { body: ResponseBody::Posts(posts) };

    Ok(serde_json::to_string(&response).unwrap())
}

И реализовать для них логику.

// application/src/post/mod.rs

pub mod create;
pub mod read;
pub mod publish; // 👈 New!
pub mod delete; // 👈 New!

// application/src/post/publish.rs

use domain::models::Post;
use shared::response_models::{Response, ResponseBody};
use infrastructure::establish_connection;
use rocket::response::status::NotFound;
use diesel::prelude::*;

pub fn publish_post(post_id: i32) -> Result<Post, NotFound<String>> {
    use domain::schema::posts::dsl::*;

    match diesel::update(posts.find(post_id)).set(published.eq(true)).get_result::<Post>(&mut establish_connection()) {
        Ok(post) => Ok(post),
        Err(err) => match err {
            diesel::result::Error::NotFound => {
                let response = Response { body: ResponseBody::Message(format!("Error publishing post with id {} - {}", post_id, err))};
                return Err(NotFound(serde_json::to_string(&response).unwrap()));
            },
            _ => {
                panic!("Database error - {}", err);
            }        
        }
    }
}

// application/src/post/delete.rs

use shared::response_models::{Response, ResponseBody};
use infrastructure::establish_connection;
use diesel::prelude::*;
use rocket::response::status::NotFound;
use domain::models::Post;

pub fn delete_post(post_id: i32) -> Result<Vec<Post>, NotFound<String>> {
    use domain::schema::posts::dsl::*;
    use domain::schema::posts;

    let response: Response;

    let num_deleted = match diesel::delete(posts.filter(id.eq(post_id))).execute(&mut establish_connection()) {
        Ok(count) => count,
        Err(err) => match err {
            diesel::result::Error::NotFound => {
                let response = Response { body: ResponseBody::Message(format!("Error deleting post with id {} - {}", post_id, err))};
                return Err(NotFound(serde_json::to_string(&response).unwrap()));
            },
            _ => {
                panic!("Database error - {}", err);
            }        
        }
    };

    if num_deleted > 0 {
        match posts::table.select(posts::all_columns).load::<Post>(&mut establish_connection()) {
            Ok(mut posts_) => {
                posts_.sort();
                Ok(posts_)
            },
            Err(err) => match err {
                _ => {
                    panic!("Database error - {}", err);
                }
            }
        }
    } else {
        response = Response { body: ResponseBody::Message(format!("Error - no post with id {}", post_id))};
        Err(NotFound(serde_json::to_string(&response).unwrap()))
    } 
}

И, наконец, зарегистрируйте наши новые маршруты.

// api/src/bin/main.rs

#[macro_use] extern crate rocket;
use api::post_handler;

#[launch]
fn rocket() -> _ {
    rocket::build()
        .mount("/api", routes![
            post_handler::list_posts_handler, 
            post_handler::list_post_handler,
            post_handler::create_post_handler,
            post_handler::publish_post_handler, // 👈 New!
            post_handler::delete_post_handler, // 👈 New!
        ])
}

Вот и все! Теперь у вас есть полностью функционирующий API, написанный на Rocket.rs, который подключается к базе данных PostgreSQL через Diesel.rs. Кроме того, приложение построено в соответствии с чистой архитектурой.

Теперь ваш проект должен выглядеть примерно так:

.
├── Cargo.lock
├── Cargo.toml
├── api
│   ├── Cargo.toml
│   └── src
│       ├── bin
│       │   └── main.rs
│       ├── lib.rs
│       └── post_handler.rs
├── application
│   ├── Cargo.toml
│   └── src
│       ├── lib.rs
│       └── post
│           ├── create.rs
│           ├── delete.rs
│           ├── mod.rs
│           ├── publish.rs
│           └── read.rs
├── domain
│   ├── Cargo.toml
│   └── src
│       ├── lib.rs
│       ├── models.rs
│       └── schema.rs
├── infrastructure
│   ├── Cargo.toml
│   ├── migrations
│   │   └── 2022–11–18–090125_create_posts
│   │       ├── up.sql
│   │       └── down.sql
│   └── src
│       └── lib.rs
└── shared
    ├── Cargo.toml
    └── src
        ├── lib.rs
        └── response_models.rs


Дальнейшие улучшения

Есть несколько вещей, которые можно было бы улучшить, если смотреть на приложение в целом.

Во-первых, всякий раз, когда мы хотим использовать базу данных, мы открываем новое соединение. Это может стать дорогостоящим и ресурсоемким в больших масштабах. Один из способов исправить это — использовать пул соединений. Rocket.rs включает встроенную поддержку R2D2, обработчика пула соединений для Ржавчина.

Во-вторых, Diesel.rs не является асинхронным — в таком масштабе это не слишком большая проблема. Тем не менее, это может стать более серьезной проблемой для более крупных приложений. На момент написания статьи у официальной команды Diesel не было асинхронной реализации. рупий В качестве альтернативы для обеспечения этой функциональности доступен внешний контейнер.

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


Заключение

Фу! Какое путешествие. Мы не только научились использовать Rocket.rs и Diesel.rs, но и научились использовать их вместе для создания API для ведения блогов на Rust. Наряду с этим мы создали для него внешний интерфейс и упаковали все вместе в один файл проекта в соответствии с чистой архитектурой.

Весь код вместе с моей реализацией внешнего интерфейса можно найти здесь: https://github.com/BrookJeynes/blog-rust< /а>

Я надеюсь, что вы, ребята, многому научились сегодня, и сами попробуете этот процесс и создадите что-то новое! Обязательно отметьте репозиторий Github звездочкой и дайте мне знать, что мне следует обсудить дальше, или любые ваши отзывы.

Спасибо за прочтение, н - Брук ❤


Ссылки

Ящики:

Другие руководства/документы:


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE