Membangun API di Rust dengan Rocket.rs dan Diesel.rs (Arsitektur Bersih)

Dec 02 2022
Membangun aplikasi blogging sederhana menggunakan Rust + Rocket.rs + Diesel.rs mengikuti Clean Architecture.
Dalam panduan ini saya akan memandu Anda melalui proses membangun API CRUD sederhana dari awal di Rust menggunakan Rocket.rs.

Dalam panduan ini saya akan memandu Anda melalui proses membangun API CRUD sederhana dari awal di Rust menggunakan Rocket.rs. Saya akan menunjukkan cara membuat migrasi dan mengakses database PostgreSQL menggunakan Diesel.rs dan menghubungkan semuanya ke front-end React + TypeScript. Saat membangun proyek, kami akan mengikuti Clean Architecture, meskipun saya tidak akan berbicara terlalu banyak tentang apa itu karena itu bukan fokus dari panduan ini.

Panduan ini mengasumsikan:

  • Anda sudah memiliki pengaturan database PostgreSQL
  • Anda memiliki Rust versi terbaru (panduan ini menggunakan v1.65.0)
  • Anda memiliki pemahaman dasar hingga layak tentang konsep Rust dan sintaks bahasa

Membangun arsitektur proyek

Langkah pertama adalah mengatur arsitektur aplikasi. Mulailah dengan membuat proyek Rust menyeluruh:

cargo new rust-blog
cd rust-blog

  • Lapisan API akan menangani permintaan API dan bertindak sebagai penangan rute kami.
  • Lapisan aplikasi akan menangani logika di balik permintaan API.
  • Lapisan domain akan menyimpan model dan skema basis data kami.
  • Lapisan infrastruktur akan menahan migrasi dan koneksi basis data kami.
  • Lapisan bersama akan menampung model lain yang dibutuhkan proyek kami seperti struktur respons.
  • 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",
    ]
    

Karena kita menggunakan Diesel.rs sebagai pengelola database, kita perlu menginstal alat CLI . Diesel CLI memiliki beberapa dependensi yang perlu diinstal terlebih dahulu tergantung pada database apa yang Anda rencanakan untuk digunakan:

  • libpquntuk PostgreSQL
  • libmysqlclientuntuk Mysql
  • libsqlite3untuk SQlite

Dengan libpqterinstal, sekarang kita dapat menjalankan perintah berikut untuk menginstal Diesel CLI:

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

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

diesel setup

  • Folder migrasi digunakan untuk menyimpan semua migrasi
  • Migrasi kosong yang dapat kita gunakan untuk mengelola skema database kita.

diesel migration generate create_posts

Sekarang, mari kita lanjutkan dan menulis beberapa SQL untuk migrasi.

-- 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

Membuat koneksi

Dengan rangkaian migrasi pertama kita selesai dan arsitektur proyek kita ditata, mari kita menulis beberapa kode Rust untuk menghubungkan aplikasi kita ke database.

# 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))
}

Model dan Skema

Mulailah dengan domainmembuka dan menambahkan modul berikut ke 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,
    }
}

Selain mendefinisikan model database, mari buat model untuk menyusun bagaimana tanggapan API kita akan diformat. Arahkan ke shared/srcdan buat file baru 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,
}

Menyiapkan Rocket.rs

Wow! Itu banyak penyiapan hanya untuk database kami, hanya agar kami semua up-to-date, inilah yang seharusnya terlihat seperti struktur proyek saat ini:

.
├── 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

Arahkan ke apidan impor dependensi berikut:

# 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,
        ])
}

Buat file baru yang dipanggil dan post_handler.rstulis srckode templat berikut:

// 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. DAPATKAN /api/ (digunakan untuk mendaftar semua posting)
  2. DAPATKAN /api/post/<post_id> (digunakan untuk membuat daftar posting berdasarkan id)

Dengan template penangan permintaan, mari tulis logika yang diperlukan untuk rute. Di dalam application, buat folder baru bernama post. Folder ini akan berisi file untuk setiap logika rute kita.

# 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);
            }
        }
    }
}

Dengan logika untuk rute kita tertulis, mari kembali ke post handler kita untuk menyelesaikan dua rute GET kita.

// 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())
}

Mari kita ubah itu.

Membuat Postingan

Seperti sebelumnya, kita akan mulai dengan mem-template handler rute. Ini akan menjadi permintaan POST yang akan menerima data 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!
        ])
}

Pengujian CR__

Dengan dua dari empat huruf kita diimplementasikan, mari kita coba uji coba kecil-kecilan. Arahkan kembali ke direktori root dan jalankan aplikasi.

cargo run

Angka: DAPATKAN / berfungsi sebagaimana dimaksud
Gambar: POST /new_post berfungsi sebagaimana mestinya

Dua surat terakhir

Dua operasi terakhir yang kita butuhkan adalah memperbarui dan menghapus. Kami akan mengimplementasikan pembaruan melalui "memublikasikan" sebuah postingan dan menghapusnya dengan, ya… menghapus postingan.

Seperti dengan dua huruf sebelumnya, mari buat penangan kita.

// 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!
        ])
}

Proyek Anda sekarang akan terlihat seperti berikut ini:

.
├── 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

Ada beberapa hal yang bisa diperbaiki jika melihat aplikasi secara keseluruhan.

Pertama, kapan pun kami ingin menggunakan database, kami membuka koneksi baru. Ini bisa menjadi mahal dan intensif sumber daya ketika dalam skala yang lebih besar. Salah satu cara untuk memperbaikinya adalah dengan menggunakan kumpulan koneksi, Rocket.rs menyertakan dukungan bawaan untuk R2D2 , pengendali kumpulan koneksi untuk Rust.

Kedua, Diesel.rs tidak asinkron — ini tidak terlalu menjadi masalah pada skala ini. Namun, ini bisa menjadi masalah yang lebih besar untuk aplikasi yang lebih besar. Pada saat penulisan, tidak ada implementasi asinkron dari tim resmi di belakang Diesel.rs. Sebagai alternatif, peti eksternal tersedia untuk menyediakan fungsionalitas ini.

Terakhir, UI front-end dapat dibuat bersama dengan Rust API. Di dalam direktori root Anda akan membuat proyek baru yang disebut web_uimenggunakan bahasa pilihan front-end Anda. Yang perlu Anda lakukan hanyalah menjalankan kedua proyek secara terpisah, memanggil Rust API dari klien front-end Anda. Inilah implementasi front-end saya untuk beberapa inspirasi:

Gambar: Implementasi UI front-end saya

Kesimpulan

Fiuh! Perjalanan yang luar biasa. Kami tidak hanya belajar cara menggunakan Rocket.rs dan Diesel.rs, tetapi kami juga belajar cara menggunakannya bersama untuk membuat API blog di Rust. Bersamaan dengan itu, kami telah membuat front-end untuknya dan mengemasnya bersama-sama dalam satu file proyek mengikuti Clean Architecture.

Semua kode beserta penerapan front-end saya dapat ditemukan di sini:https://github.com/BrookJeynes/blog-rust

Saya harap kalian belajar banyak hari ini, dan berikan prosesnya sendiri dan buat sesuatu yang baru! Pastikan untuk memberi bintang pada repositori Github dan beri tahu saya apa yang harus saya bahas selanjutnya atau umpan balik apa pun yang Anda miliki.

Terima kasih sudah membaca,
- Brook ❤

Referensi

Peti:

  • Diesel.rs
  • Rocket.rs
  • Serde-rs/serde
  • Serde-rs/json