יום 8 - פיתוח שרת REST API ב Rust

03/01/2023

אחרי שכתבנו כלים לשורת הפקודה ואפילו משחק, אנחנו עולים היום לשלב האחרון במדריך ונכנסים לעולם פיתוח ווב עם שרת REST API נוצץ וחלוד.

1. מה אנחנו בונים

הספריה warp של Rust מוגדרת בתיעוד שלה בתור פריימוורק סופר פשוט לפיתוח שרתי רשת ב warp speed. בשביל להבין איך עובדים איתה ולהחליט האם Rust תוכל להחליף את node.js נלך לכתוב שני שרתי API: השרת הראשון יחזיר טקסט פשוט והשרת השני כבר ינהל מאגר של "פתקים", יאפשר ליצור פתק חדש ולקבל רשימה של כל הפתקים בתור JSON.

2. קוד השרת הראשון

רגע, שתי תוכניות ביום אחד? לא אמרנו ש rust כולל רק קובץ main.rs אחד ופונקציית main אחת? איך בכלל מתחילים לכתוב שתי תוכניות באותו פרויקט? נו, אני שמח ששאלתם. בקובץ Cargo.toml אני יכול להגדיר את כל התוכניות בפרויקט שלי. כן ברירת המחדל היא תוכנית אחת שהמקור שלה הוא הקובץ main.rs, אבל אין בעיה לגוון. הנה שני הבלוקים שהוספתי לקובץ בפרויקט היום:

[[bin]]
name = "helloworld"
path = "src/simple_server.rs"

[[bin]]
name = "notes"
path = "src/notes_server.rs"

ואגב מבחינת תלויות התקנתי את warp וכמה חברים. זה ההמשך של אותו קובץ:

[dependencies]
tokio = { version = "1", features = ["full"] }
warp = "0.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

עכשיו אנחנו מוכנים לצלול לקוד של השרת הראשון. הוא לא ארוך אבל כן כולל כמה רעיונות ראסטיים חדשים:

use warp::Filter;

#[tokio::main]
async fn main() {
    // GET /hello/warp => 200 OK with body "Hello, warp!"  
    let hello = warp::path!("hello" / String)
        .map(|name| format!("Hello, {}!", name));

    let sum = warp::path!("sum" / u32 / u32)
        .map(|a, b| format!("{}", a + b));

    let api = 
        hello
        .or(sum);

    warp::serve(api)
        .run(([127, 0, 0, 1], 3030))
        .await;
}

המאפיין החדש הראשון הוא הגדרת ה main בתור tokio::main. טוקיו היא ספריה שמספקת דרך לכתוב קוד אסינכרוני בתוך ראסט. מבחינת ה API שלנו, אנחנו רואים את הקריאה ל await בסוף הקוד שאומרת שהפונקציה תחזור רק כשהפעולה האסינכרונית warp::serve תסתיים, ושבזמן שהפעולה warp::serve מתבצעת יכולות לרוץ קריאות אסינכרוניות נוספות. עיקר הקסם קורה בתוך אותו warp::serve - שם warp מאזין לפורט שהעברנו ומטפל בכל הלקוחות שמגיעים בצורה אסינכרונית. יום אחד יהיה מעניין לקרוא את קוד המקור שלהם, אבל אני עדיין לא שם.

מבחינת שימוש הקוד לא מרגיש לגמרי מוזר. הקריאה הזו מגדירה נתיב ב API:

let hello = warp::path!("hello" / String)
  .map(|name| format!("Hello, {}!", name));

הפרמטר ל path מגדיר את הנתיב ובמקום להשתמש בתחביר המחרוזת עם נקודותיים בפנים שאנחנו מכירים מ express, כלומר hello/:name, הם בחרו להשתמש באופרטור / כדי לחבר פרמטרים של הנתיב, כאשר החלק הקבוע הוא מחרוזת (לדוגמה המילה hello) והחלק המשתנה הוא טיפוס נתונים לדוגמה String. מה שיותר מוזר הוא ההמשך - אנחנו רגילים ב Express שהפרמטר השני לפונקציה שיוצרת נתיב הוא פונקציה שמטפלת בלקוחות שמגיעים לאותו נתיב. פה במקום להעביר "פונקציית טיפול" אני מקבל משהו שאני מפעיל עליו map כדי למשוך ממנו את הפרמטר של הנתיב ולהחזיר תוצאה.

בעצם שיטת העבודה של warp מבוססת על "הרכבה" - אפשר לדמיין כל נתיב בתור מסדרון שבהתחלה שלו יש דלת (זאת הפונקציה path!, שנקראת בשם כללי Filter), ואם בקשה עוברת את הדלת היא ממשיכה לפעולות נוספות של אותו הנתיב. בשרת הפתקים נראה שימוש מעניין בארכיטקטורה זו, למרות שעכשיו היא באמת נראית קצת מוזרה.

הבלוק השני שמגדיר נתיב הוא:

let sum = warp::path!("sum" / u32 / u32)
    .map(|a, b| format!("{}", a + b));

נתיב דומה באקספרס היה עשוי להיראות כך:

// express.js style code (JavaScript)
router.get('/sum/:a/:b', (req, res, next) => {
    const { a, b } = req.params;
    res.send(`${a + b}`);
});

החלק השלישי של הקוד מחבר את שני הנתיבים למשתנה אחד בשם api, זה המשתנה שעוד מעט נעביר ל serve:

let api = hello.or(sum);

מאוד מעניין! הפקודה or מחברת את הנתיב hello עם הנתיב sum. אם אנחנו מדמיינים כל נתיב בתור "מסדרון" שבקשה יכולה להיכנס אליו, אז פקודת or לוקחת כמה מסדרונות כאלה ומחברת אותם ל API אחד. כל פעם שה API יקבל בקשה הוא ינסה לבדוק אם היא מתאימה ל hello, ואם לא הוא ינסה לבדוק אם היא מתאימה ל sum.

הפקודה האחרונה מפעילה את השרת עם המשתנה שיצרנו:

warp::serve(api).run(([127, 0, 0, 1], 3030)).await;

בגלל שיש שתי נקודות כניסה לפרויקט, אני צריך בהפעלה של השרת להגיד ל cargo את מי אני מפעיל ואת זה עושים עם:

$ cargo run --bin helloworld

כאשר המילה helloworld זה השם של נקודת הכניסה כמו שהופיע בקובץ Cargo.toml.

הפעילו את השרת ותוכלו לגלוש לנתיבים כמו:

http://localhost:3030/hello/ynon
http://localhost:3030/sum/10/20

ולקבל מראסט את התוצאות.

3. קוד השרת השני

הקובץ השני כבר לוקח אותנו הרבה יותר רחוק לתוך wrap, למרות שהוא כולל בסך הכל שני נתיבים: נתיב אחד יוצר פתק חדש והשני מחזיר רשימה של פתקים. אבל בואו נתחיל בהתחלה. אגיד לכם את האמת מאוד רציתי לכתוב קוד קצר בסגנון Node.JS ו Express. ניסיתי לקחת את הקוד מהדמו הראשון ופשוט להוסיף אליו מערך של פתקים ושכל נתיב יעשה משהו עם המערך הזה, אבל ראסט לא אהב את הכיוון. הסיבה היא שוב מודל הבעלות על מידע בראסט, שבגללו הקומפיילר של ראסט חייב להיות משוכנע שמערך הפתקים יחיה מספיק זמן כדי שקוד הטיפול בנתיבים יוכל תמיד לגשת אליו. סתם לייצר משתנה ב main לא מספיק טוב, כי ה Closures שבונים את הנתיבים רוצים להשתלט על המערך.

הפיתרון הוא לא לשלוח לכל Closure את מערך הפתקים עצמו אלא רק Reference למערך, ואת המערך עצמו לשמור בתור Smart Pointer, כך שכל עוד יש מישהו שמתיחס לאותו מערך פתקים הוא יישאר בחיים. מצביע חכם שבטוח לשימוש אסינכרוני בין Thread-ים נקרא בראסט Arc, ולכן הגדרת מערך הפתקים הפכה להיות:

mod db {
    use serde::{Deserialize, Serialize};
    use std::sync::Arc;
    use tokio::sync::Mutex;


    #[derive(Clone, Debug, Default, Deserialize, Serialize)]
    pub struct Note {
        id: String,
        title: String,
        text: String
    }

    pub fn init() -> Arc<Mutex<Vec<Note>>> {
        return Arc::new(Mutex::new(vec![
            Note { id: String::from("1"), title: String::from("yay"), text: String::from("it's a first note") },
            Note { id: String::from("2"), title: String::from("yay"), text: String::from("it's a second note") },
            Note { id: String::from("3"), title: String::from("yay"), text: String::from("it's a third note") },
        ]));
    }
}

הדבר השני שהפרדתי במעבר לאפליקציה גדולה יותר היה החיבור בין ה api לבין הקוד שמטפל בבקשה, כאשר api זה הפילטר שבודק אם בקשה מתאימה לנתיב מסוים ומפענח ממנה את הפרמטרים, אבל את קוד הטיפול בבקשה עצמו (נקרא Handler) כתבתי בפונקציה נפרדת. זה עזר לראסט עם כל שכפולי ה Pointer-ים ועזר לי בארגון של הקוד. לכן המודול הבא למימוש יהיה handlers שמגדיר את שתי הפונקציות ב API:

mod handlers {
    use std::convert::Infallible;
    use std::sync::Arc;
    use tokio::sync::Mutex;
    use warp::http::StatusCode;
    use super::db::Note;

    pub async fn list_notes(notes: Arc<Mutex<Vec<Note>>>) -> Result<impl warp::Reply, Infallible> {
        let notes = notes.lock().await;
        let notes: Vec<Note> = notes.clone().into_iter().collect();
        Ok(warp::reply::json(&notes))
    }

    pub async fn create_note(new_note: Note, notes: Arc<Mutex<Vec<Note>>>) -> Result<impl warp::Reply, Infallible> {
        let mut notes = notes.lock().await;
        notes.push(new_note);

        Ok(StatusCode::CREATED)
    }
}

כל אחת מהן מקבלת את מערך הפתקים ומחזירה Result, שזה משהו שאפשר לשלוח ללקוח. הפונקציה create_note מקבלת גם את פרטי הפתק החדש. את המבנה של Mutex ו Arc לקחתי מהדוגמאות של wrap, והוא המבנה היחיד איתו הצלחתי לקמפל את התוכנית, למרות שאני לא בטוח למה צריך להשתמש ב Mutex בתוכנית אסינכרונית (אולי זה רלוונטי למקרים יותר מורכבים בהם יש כמה קריאות ל await בתוך אותו handler).

החלק האחרון בקוד הוא המודול api שמגדיר את הנתיבים וסוגי המידע:

mod api {
    use super::db::Note;
    use std::sync::Arc;
    use tokio::sync::Mutex;
    use warp::Filter;
    use super::handlers;

    pub fn routes(notes: Arc<Mutex<Vec<Note>>>) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
        list_notes(notes.clone())
        .or(create_note(notes.clone()))
    }

    pub fn list_notes(notes: Arc<Mutex<Vec<Note>>>) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
        warp::path!("notes")
        .and(warp::get())
        .and(warp::any().map(move || notes.clone()))
        .and_then(handlers::list_notes)
    }

    pub fn create_note(notes: Arc<Mutex<Vec<Note>>>) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
        warp::path!("notes")
        .and(warp::post())
        .and(warp::body::content_length_limit(1024 * 16).and(warp::body::json()))
        .and(warp::any().map(move || notes.clone()))
        .and_then(handlers::create_note)
    }
}

הפעם הוספתי גם קריאות לפונקציה and בתוך כל נתיב, כדי להרכיב עוד פונקציונאליות בתוך אותו מסדרון. נקרא את list_notes כדוגמה:

  1. הפונקציה מגדירה את נקודת הכניסה לנתיב - notes.
  2. בעזרת and היא מוסיפה עוד תנאי - הבקשה צריכה להיות get.
  3. בעזרת and נוסף היא מפעילה פקודה (אפשר לחשוב על זה כמו Middleware באקספרס). הפקודה "משתלטת" על אוביקט הפתקים שקיבלה ומשכפלת אותו.
  4. בסוף אני מפעיל את handlers::list_notes שמקבל בתור פרמטר את התוצאה של ה map הקודם, כלומר את אוביקט הפתקים המשוכפל. למעשה אין העתקה של האוביקט כי מה שמשוכפל זה ה Pointer החכם אליו.

הפונקציה routes הראשית של ה API לוקחת את notes ומייצרת שני מצביעים חדשים ומשוכפלים אליו, ומעבירה אחד לכל פונקציית API. כך כל פונקציה יכולה לקחת בעלות על העותק שלה של ה Pointer ל notes, ובזכות השימוש ב Arc המידע עצמו יימחק רק כשכל המצביעים האלה יימחקו.

התוכנית מסתיימת בבלוק main שמפעיל את השרת:

#[tokio::main]
async fn main() {
    let notes = db::init();
    let api = api::routes(notes);

    warp::serve(api)
        .run(([127, 0, 0, 1], 3030))
        .await;
}

סך הכל כשאנחנו מקפידים על כתיבה מסודרת של התוכנית הקוד לא יוצא נורא. בתוכניות גדולות יותר נרצה לפצל את הקוד לכמה קבצים וכל mod יהיה בקובץ משלו.

4. תרגילים להרחבה

חושבים שהבנתם איך לכתוב שרת REST ב Rust? בואו ננסה כמה תרגילי הרחבה-

  1. הוסיפו עוד שלושה נתיבים כדי שאפשר יהיה למחוק פתק, לעדכן פתק ולהציג פרטי פתק בודד.

  2. הוסיפו תמיכה ב Pagination, כך שהפונקציה שמחזירה רשימה של פתקים תוכל לקבל גם מספר עמוד וכמה תוצאות בעמוד, ותחזיר רק את הפתקים הרלוונטים.

  3. הפרידו את הקוד למספר קבצים כך שכל mod יהיה בקובץ משלו.

  4. המודול sqlite מאפשר לעבוד עם בסיס נתונים SQLite מתוך קוד ראסט. העבירו את הפתקים לקובץ בסיס נתונים של SQLite.