Создание API на Rust с помощью Rocket.rs и Diesel.rs (Чистая архитектура)
В этом руководстве я проведу вас через процесс создания простого CRUD API с нуля в Rust с использованием Rocket.rs. Я покажу вам, как создавать миграции и получать доступ к базе данных PostgreSQL с помощью Diesel.rs, а также подключать все к внешнему интерфейсу React + Typescript. При создании проекта мы будем следовать чистой архитектуре, хотя я не буду слишком много говорить о том, что это такое, поскольку это не является предметом внимания данного руководства.
Это руководство предполагает:
- У вас уже есть настроенная база данных PostgreSQL
- У вас установлена последняя версия Rust (в этом руководстве используется версия 1.65.0).
- У вас есть базовое понимание концепций Rust и синтаксиса языка.
Построение архитектуры проекта
Первым шагом является настройка архитектуры приложения. Начните с создания всеобъемлющего проекта Rust:
cargo new rust-blog
cd rust-blog
- Уровень 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
[workspace]
members = [
"api",
"domain",
"infrastructure",
"application",
"shared",
]
Поскольку мы используем Diesel.rs в качестве менеджера базы данных, нам потребуется установить инструмент CLI . Diesel CLI имеет несколько зависимостей, которые необходимо установить заранее в зависимости от того, какую базу данных вы планируете использовать:
libpq
для PostgreSQLlibmysqlclient
для Mysqllibsqlite3
для SQlite
После libpq
установки мы можем запустить следующую команду для установки Diesel CLI:
cargo install diesel_cli --no-default-features --features postgres
echo DATABASE_URL=postgres://username:password@localhost/blog > .env
diesel setup
- Папка миграции, используемая для хранения всех миграций.
- Пустая миграция, которую мы можем использовать для управления схемой нашей базы данных.
diesel migration generate create_posts
Теперь давайте продолжим и напишем 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 migration run
Создание соединения
Закончив наш первый набор миграций и изложив архитектуру нашего проекта, давайте, наконец, напишем немного кода на 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))
}
Модели и схемы
Начните с перехода domain
и добавления следующих модулей в lib.rs
.
// domain/src/lib.rs
pub mod models;
pub mod 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,
}
}
Помимо определения моделей баз данных, давайте создадим модель для структурирования того, как будут форматироваться наши ответы 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,
}
Настройка 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/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"
.
└── api
├── Cargo.toml
└── src
├── bin
│ └── main.rs
└── lib.rs
// 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
и 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!()
}
- GET /api/ (используется для перечисления всех сообщений)
- GET /api/post/<post_id> (используется для отображения сообщения по идентификатору)
Создав шаблоны обработчиков запросов, давайте напишем логику, необходимую для маршрутов. Внутри 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);
}
}
}
}
Когда логика для нашего маршрута написана, давайте вернемся к нашему обработчику сообщений, чтобы закончить наши два маршрута 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)
}
// 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!
])
}
CR__ Тестирование
Реализовав две из четырех букв, давайте проведем небольшой тест. Вернитесь в корневой каталог и запустите приложение.
cargo run


Последние две буквы
Последние две операции, которые нам нужны, это обновление и удаление. Мы реализуем обновление через «публикацию» поста и удаление, ну… удаление поста.
Как и в случае с предыдущими двумя письмами, давайте создадим наши обработчики.
// 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!
])
}
Теперь ваш проект должен выглядеть примерно так:
.
├── 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 , обработчик пула соединений для Rust.
Во-вторых, Diesel.rs не является асинхронным — в таком масштабе это не слишком большая проблема. Тем не менее, это может стать более серьезной проблемой для более крупных приложений. На момент написания статьи официальной командой Diesel.rs не было реализовано асинхронной реализации . В качестве альтернативы для обеспечения этой функциональности доступен внешний крейт .
Наконец, интерфейсный интерфейс может быть создан вместе с Rust API. Внутри корневого каталога вы должны создать новый проект с именем, web_ui
используя выбранный вами интерфейсный язык. Все, что вам нужно будет сделать, это запустить оба проекта по отдельности, вызвав Rust API из вашего внешнего клиента. Вот моя реализация внешнего интерфейса для вдохновения:

Вывод
Фу! Какое путешествие. Мы не только научились использовать Rocket.rs и Diesel.rs, но и научились использовать их вместе для создания API для ведения блогов на Rust. Наряду с этим мы создали для него внешний интерфейс и упаковали все вместе в один файл проекта в соответствии с чистой архитектурой.
Весь код вместе с моей реализацией внешнего интерфейса можно найти здесь:https://github.com/BrookJeynes/blog-rust
Я надеюсь, что вы, ребята, многому научились сегодня, и сами попробуете этот процесс и создадите что-то новое! Обязательно отметьте репозиторий Github звездочкой и дайте мне знать, что мне следует рассказать дальше, или какие-либо отзывы, которые у вас есть.
Спасибо за прочтение,
- Брук ❤
использованная литература
Ящики:
- Дизель.рс
- Ракета.рс
- Serde-rs / серде
- Serde-rs/json
- Diesel.rs — Начало работы
- Rocket.rs — Начало работы
- Простой хлам на Rust (с Rocket.rs и Diesel.rs) (использует устаревшие пакеты для текущей версии ржавчины (v1.65.0) )
- Слишком много сообщений Stackoverflow , чтобы их упоминать