สร้าง API ใน Rust ด้วย Rocket.rs และ Diesel.rs (สถาปัตยกรรมสะอาด)

Dec 02 2022
สร้างแอปพลิเคชันบล็อกอย่างง่ายโดยใช้ Rust + Rocket.rs + Diesel.rs ตามสถาปัตยกรรมสะอาด
ในคู่มือนี้ ฉันจะแนะนำคุณตลอดกระบวนการสร้าง CRUD API อย่างง่ายตั้งแต่เริ่มต้นใน Rust โดยใช้ Rocket.rs

ในคู่มือนี้ ฉันจะแนะนำคุณตลอดกระบวนการสร้าง CRUD API อย่างง่ายตั้งแต่เริ่มต้นใน Rust โดยใช้ Rocket.rs ฉันจะแสดงวิธีสร้างการโยกย้ายและเข้าถึงฐานข้อมูล PostgreSQL โดยใช้ Diesel.rs และเชื่อมต่อทุกอย่างจนถึงส่วนหน้าของ React + Typescript เมื่อสร้างโครงการ เราจะปฏิบัติตาม Clean Architecture แม้ว่าฉันจะไม่พูดมากเกินไปเกี่ยวกับสิ่งที่เป็นอยู่ เนื่องจากไม่ใช่จุดเน้นของคู่มือนี้

คู่มือนี้ถือว่า:

  • คุณมีการตั้งค่าฐานข้อมูล PostgreSQL แล้ว
  • คุณมี Rust เวอร์ชันล่าสุด (คู่มือนี้ใช้ v1.65.0)
  • คุณมีความเข้าใจขั้นพื้นฐานเกี่ยวกับแนวคิดของสนิมและไวยากรณ์ของภาษา

สร้างสถาปัตยกรรมโครงการ

ขั้นตอนแรกคือการตั้งค่าสถาปัตยกรรมของแอปพลิเคชัน เริ่มต้นด้วยการสร้างโครงการ Rust ที่ครอบคลุม:

cargo new rust-blog
cd rust-blog

  • API Layer จะจัดการคำขอ 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สำหรับ PostgreSQL
  • libmysqlclientสำหรับ Mysql
  • libsqlite3สำหรับ 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!()
}

  1. GET /api/ (ใช้เพื่อแสดงรายการโพสต์ทั้งหมด)
  2. GET /api/post/<post_id> (ใช้เพื่อแสดงรายการโพสต์โดย id)

ด้วยตัวจัดการคำขอ templated ให้เขียนตรรกะที่จำเป็นสำหรับเส้นทาง ภายใน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

รูปภาพ: GET / ทำงานตามที่ตั้งใจไว้
รูปภาพ: POST /new_post ทำงานตามที่ตั้งใจไว้

จดหมายสองฉบับสุดท้าย

การดำเนินการสองขั้นตอนสุดท้ายที่เราต้องการคือการอัปเดตและการลบ เราจะดำเนินการอัปเดตผ่าน "เผยแพร่" โพสต์และลบโดย เอ่อ... ลบโพสต์

เช่นเดียวกับจดหมายสองฉบับที่ผ่านมา มาสร้างตัวจัดการของเรากัน

// 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 อีกทางเลือกหนึ่ง คือมี กล่องภายนอกเพื่อให้ฟังก์ชันนี้

สุดท้าย สามารถสร้าง UI ส่วนหน้าควบคู่ไปกับ Rust API ได้ ภายในไดเร็กทอรีรูท คุณจะสร้างโปรเจ็กต์ใหม่ที่เรียกweb_uiโดยใช้ภาษาส่วนหน้าที่คุณเลือก จากนั้น สิ่งที่คุณต้องทำคือเรียกใช้ทั้งสองโปรเจ็กต์แยกกัน โดยเรียก Rust API จากไคลเอ็นต์ส่วนหน้าของคุณ นี่คือการใช้งานส่วนหน้าของฉันเพื่อเป็นแรงบันดาลใจ:

รูป: การใช้งาน front-end UI ของฉัน

บทสรุป

วุ้ย ช่างเป็นการเดินทาง เราไม่เพียงแต่ได้เรียนรู้วิธีใช้ Rocket.rs และ Diesel.rs เท่านั้น แต่เราได้เรียนรู้วิธีใช้ร่วมกันเพื่อสร้างบล็อก API ใน Rust นอกจากนั้น เราได้สร้างฟรอนต์เอนด์สำหรับมันและจัดแพ็คเกจทั้งหมดเข้าด้วยกันในไฟล์โปรเจ็กต์เดียวตามด้วย Clean Architecture

รหัสทั้งหมดพร้อมกับการใช้งานส่วนหน้าของฉันสามารถพบได้ที่นี่:https://github.com/BrookJeynes/blog-rust

ฉันหวังว่าพวกคุณจะได้เรียนรู้อะไรมากมายในวันนี้ และลองลงมือทำด้วยตัวเองและสร้างสรรค์สิ่งใหม่ๆ! ตรวจสอบให้แน่ใจว่าได้ติดดาวที่เก็บ Githubและแจ้งให้เราทราบว่าฉันควรพูดถึงอะไรต่อไปหรือข้อเสนอแนะใด ๆ ที่คุณมี

ขอบคุณที่อ่าน
- บรู๊ค ❤

อ้างอิง

ลัง:

  • ดีเซล.rs
  • Rocket.rs
  • Serde-rs/serde
  • Serde-rs/json
  • Diesel.rs — เริ่มต้นใช้งาน
  • Rocket.rs — เริ่มต้นใช้งาน
  • Crud แบบง่ายบนสนิม (ด้วย Rocket.rs และ Diesel.rs) (ใช้แพ็คเกจที่เลิกใช้แล้วสำหรับรุ่นสนิมปัจจุบัน (v1.65.0) )
  • มี โพสต์Stackoverflowมากเกินไป ที่จะพูดถึง