iT邦幫忙

2023 iThome 鐵人賽

DAY 7
0

這篇終於要開始寫後端API了,以下是搜尋到的一些rust的web框架,好奇的人可以點進去看看:

rust 後端選擇

要用哪一個呢,只好擲筊了(?)。喂不是啦,就每個都看一下來比較:rocket似乎最早最多星星,但有點久沒有更新了(?);actix看起來也很多星星,發佈的版次也很多;axum是tokio團隊維護的,應該相對會穩定很多;salvo很多東西都做好了,用起來也很簡單直覺,重點是有中文;warp聽說是被FP啟發,所以目標是簡單,用起來很FP(?)。一些基本比較如下:

package salvo axum warp actix rocket
star 1.9k 12.3k 8.5k 18.6k 21.5k
Release 17 118 33 410 54
Last Release 2023, Sep 2023, Aug 2023, Apr 2023, Feb 2022, Jul
First Release 2021, Jul 2018, Aug 2017, Oct 2016, Dec
download: All 936k 20,625k 10,913k 13,455k 3,592k
download: Recent 160k 5,765k 1,428k 1,760k 386k

上表是依最後發佈日遞減排序的,看起來應該毫無懸念地選擇axum,但warp看起來滿有趣的,salvo看起來很友善,怎麼辦我有選擇性障礙。好吧我拿三張紙分別寫上去,用吹風機看哪張飛最遠就用誰,好啦不要鬧了。

以下先進行簡單的評比,剛剛在瀏覽各框架的文件時好像有看到一個web frameworks benchmark,我們自己來試試看好了,直接把wrk安裝起來,再把salvowarpaxum分別git clone下來,然後分別使用他們寫好的hello world來測試:

~/axum/examples/hello-world$ cargo run
~/warp$ cargo run --example hello
~/salvo/examples/hello$ cargo run

再另開一個視窗執行wrk比較一下:

~$ wrk http://192.168.0.25:5800  # salvo
Running 10s test @ http://192.168.0.25:5800
  2 threads and 10 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    73.13us  233.15us  10.82ms   99.48%
    Req/Sec    75.94k     5.59k  118.24k    94.03%
  1518566 requests in 10.10s, 185.37MB read
Requests/sec: 150364.64
Transfer/sec:     18.36MB
~$ wrk http://192.168.0.25:3030  # warp
Running 10s test @ http://192.168.0.25:3030
  2 threads and 10 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    60.90us  260.83us  13.21ms   99.72%
    Req/Sec    85.12k     3.74k   90.11k    91.58%
  1710215 requests in 10.10s, 212.03MB read
Requests/sec: 169337.67
Transfer/sec:     20.99MB
~$ wrk http://192.168.0.25:3000  # axum
Running 10s test @ http://192.168.0.25:3000
  2 threads and 10 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    74.51us  190.18us   8.33ms   99.52%
    Req/Sec    72.04k     3.25k   77.04k    83.17%
  1447743 requests in 10.10s, 190.53MB read
Requests/sec: 143348.97
Transfer/sec:     18.87MB

看起來warp似乎略勝一籌,好吧,那我們就來試試看用warp實作後面的web api,看起來warp的文件是相對比較少的,(是個訓練自己的機會?)如果我們用順利這個做出來,之後要切換其它的框架應該也不是問題(O)(有儉入奢易的概念,e.g.從jQuery到Vue就回不去了)。

我不會承認我先寫好結論再去找資料湊成我想要的結果。

Hello Warp

直接依照 warp 的Hello範例寫起來:

記得先加上cargo watch指令到 run.shrun.ps1中:

  • run.sh:
#!/bin/bash
# ..略
echo 3: [web] run web api server
# .. 略
elif [[ $VAR -eq 3 ]]
  then
  cargo watch -q -c -w ./web -w ./service -w ./core -x 'run -p web'
fi
  • run.ps1:
# .. 略
Write-Host "3) [web]: 執行 WebApi Server"
# ..略
} elseif ($opt -eq 3) {
    cargo watch -q -c -w ./web -w ./service -w ./core -x 'run -p web'
}

然後我們先把我們的web專案加上warp的參考:

@@ Cargo.toml @@
 [workspace.dependencies]
 rand = { version = "0.8" }
 thiserror = { version = "1.0" }
+warp = { version = "0.3"}
@@ web/Cargo.toml @@
 [dependencies]
 core = { path = "../core" }
 service = { path = "../service" }
+warp = { workspace = true }

再來改我們的web/main.rs,直接從範例複製貼上:

use warp::Filter;

#[tokio::main]
async fn main() {
    // Match any request and return hello world!
    let routes = warp::any().map(|| "Hello, World!");

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

結果立馬報錯如下:

warp試跑錯誤訊息

這裡有兩個訊息,第一個是找不到crate tokio,在rust的世界你一定不能不知道tokio,我們先看一下tokio官網的說明:

tokio官網

第一句話就說Tokio是Rust裡 asynchronous(有人講非同步、有人講異步) 的 runtime(執行環境),簡單說就是去實現async,如果有人好奇tokio怎麼做的可以看這裡的原理版,我們先繼續,把tokio加上到我們的依賴套件裡:

@@ Cargo.toml @@
 thiserror = { version = "1.0" }
+tokio = { version = "1.29", features = ["full"] }
 warp = { version = "0.3"}
@@ web/Cargo.toml @@
 service = { path = "../service" }
+tokio = { workspace = true }
 warp = { workspace = true }

不過上面加的tokio怎麼多一個features的參數呢?其實我們在寫rust專案時,可以設定某些功能要不要啟用,就是所謂的feature,之後有機會再介紹,看名字應該可以猜出我們引用了tokio全功能,照著上面改好後,再回頭看console應該也跑完了:

warp跑起來

咦怎麼沒訊息,看訊息好像正在跑(running),我們開一下web看看,剛剛main裡面有寫到port3030,所以我們用瀏覽器連上 http://localhost:3030 看看:

browser開啟 hello warp

正確開啟,那剛剛的第2個錯誤是什麼?往上拉回去看的話,就是字面上的意思:rust裡的main function不能async,所以剛剛我們的fn main上面有寫一行外掛:#[tokio::main],原來是tokio幫我們把main變async了。

CRUD round 1

接著開始寫API了,不免俗地我們先來個CRUD吧,不過寫CRUD好像都要用到資料庫,呃我們先 偷懶跳過 簡單快速用InMemor去進行POC,之後再依實際需求抽換不同的資料庫就好了,而且Uncle Bob說資料庫是細節,所以我們不要過早關注它。

用習慣依賴注入(DI)的朋友應該都會先寫介面(interface),或是寫完再抽(?, anyway),rust裡類似介面的東西叫trait,我們先來寫一個trait,讓我們以後抽換DB比較容易(吧?)。另一方面,之前會多開一個service專案,概念上是想要提供一些共用性,比如說同時給後端web和前端tauri使用,所以web可以看做是service的一個實作版,到這裡看聽懂的話其實也不影響往下看程式,所以讓我們繼續看下去。

service/src/lib.rs中加入下面這行,並把之前試run的東西刪光,利用IDE產生tic_tac_toe.rs檔案。

// service/src/lib.rs
pub mod tic_tac_toe;
// service/src/tic_tac_toe.rs
use core::tic_tac_toe::Game;

pub enum Error {
    GameRules(String),
    GameOver,
    NotFound,
    Unknown,
}

pub trait TicTacToeService {
    fn new(&self) -> Result<(usize, Game), Error>;                // C
    fn get(&self, id: usize) -> Result<Game, Error>;              // R
    fn play(&self, id: usize, num: usize) -> Result<Game, Error>; // U
    fn delete(&self, id: usize) -> Result<(), Error>;             // D
}

建立一個 trait ,trait一樣有公開(pub)或私有(預設不寫pub就是私有),在這裡我們寫上4個方法,分別對應CRUD。注意所有fn的第一個參數要放&self,因為通常我們在實現這個trait時,(因為DI會把需要的東西餵給這個&self),後續會實作的具體的物件,包括存放DB連線物件或應用程式狀態等,甚至不同的實作版可以使用不同的DB,講的很抽象,看一下範例代碼比較能有感覺:

類比其他語言:第一個參數放self其實就變成instance methods,不放self就變class method或static method。

pub struct TicTacToeServicePostgres {    // 實作 Postgres DB 的 Service
    db: Postgres,                        // 內部 DB 物件 (細節)
}

impl TicTacToeService for TicTacToeServicePostgres {
    fn new(&self) -> Result<(usize, Game), Error> { todo!() }
    fn get(&self, id: usize) -> Result<Game, Error> { todo!() }
    fn play(&self, id: usize, num: usize) -> Result<Game, Error> { todo!() }
    fn delete(&self, id: usize) -> Result<(), Error> { todo!() }
}

pub struct TicTacToeServiceMySQL {      // 實作 MySQL DB 的 Service
    db: MySQL,   
}

impl TicTacToeService for TicTacToeServiceMySQL {
    fn new(&self) -> Result<(usize, Game), Error> { todo!() }
    fn get(&self, id: usize) -> Result<Game, Error> { todo!() }
    fn play(&self, id: usize, num: usize) -> Result<Game, Error> { todo!() }
    fn delete(&self, id: usize) -> Result<(), Error> { todo!() }
}

在main中約莫會長像這樣,可以依需求使用不同的service:

fn main() {
    let service = TicTacToeServiceMySQL { db: MySQL::new(); }     // 使用MySQL
    let service = TicTacToeServicePostgre { db: Postgre::new(); } // 使用Posgres
    let api = controller(service);              // 上面選擇不同實作不影響這裡的程式碼
}

有點概念的應該可以看出來DI就是在這裡決定要I什麼。

我們剛剛加的trait上面又自己定義了一個Error,rust裡沒有例外Exception,也沒有try ... catch,那要怎麼處理錯誤呢?答案就是用之前提到的enum Result,分別定義成功及失敗兩個情境,對應2種情境分別提供不同的回傳類別。

想了解類似這種Functional Programming的概念,我覺得Scott Wlaschin的許多演講講的很好,有空一定要看(呃,沒空也要看,對寫程式的思維提升滿有幫助的,就算不寫純FP,也很有幫助):

在rust裡有些人會用anyhow,把所有不同的Error都包在一起(感覺很偷懶?),而大部分看到的專案都會定義自己的Error,然後就要各種mapping,因為Result<A,B>裡的B Type只能放一種類別,但我們fn裡可能會用到x套件y套件z套件,每個噴的Type都不一樣,所以我們要mapping成同一個Type才可以return。

接下來我用這種比較複雜(自虐?)的方式演練,一方面帶大家一起練習(X),順便熟悉rust的語法,之後想偷懶的時候,直上anyhow就可以無痛接軌。

定義好trait後,我們來實作一個InMemory的服務,也可以作為我們POC測試用的Mock:

// service/src/tic_tac_toe.rs
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

struct InMemoryTicTacToeService {            // 定義InMemory服務
    games: Arc<Mutex<HashMap<usize, Game>>>, // 這什麼型別!?,請看下方補充教材
}

impl InMemoryTicTacToeService {              // 實作建構式,這次我們改用new
    fn new() -> Self {
        Self {
            games: Arc::new(Mutex::new(HashMap::new())),
        }
    }
}

impl TicTacToeService for InMemoryTicTacToeService { // 替InMemory實作Trait
    fn new(&self) -> Result<(usize, Game), Error> {
        todo!()       // 類似 C# 的 NotImplementedException
    }

    fn get(&self, id: usize) -> Result<Game, Error> {
        todo!()
    }

    fn play(&self, id: usize, num: usize) -> Result<Game, Error> {
        todo!()
    }

    fn delete(&self, id: usize) -> Result<(), Error> {
        todo!()
    }
}

在rust中HashMap就是Dictionary,或是key value pair的對應,如果 想耍帥酷炫一點 有效能需求的考量也可以用 BTreeMap

等等,先停一下,上面代碼明明沒幾行,但資訊量好像有點大。有很多關鍵概念如果不先弄清楚,後面會越變得越來越難以理解,所以我們先暫停一下,補充幾個知識點,下一篇再繼續往下寫扣。

補充資料

async/await

不熟async/await的快去補課一下,或是,這裡有個小遊戲,在遊戲裡面你要扮演作業系統(OS),來控制每一個進程(Process),這個小遊戲可以很好的理解OS的運作,記得先看How to play再開始玩,不然可能會不曉得在幹麻。

簡單說程式在跑有分CPU boundIO bound。CPU就像頭腦,思考很快,IO就像手腳,動作很慢,比如看書看完了要等手翻頁(I/O bound)才能看,這時候頭腦就只能發呆(idle)等書翻好,或是當正在解bug快解出來(CPU bound)的時候,可能口渴了要走去喝水這件事就會被暫停(往後延)。

另一個例子:假設美髮師是CPU,客人是Process,美髮師(CPU)一次只能處理一個客人(Process),而現在服務中的客人正在使用類似下圖機器(IO Bound: 不花CPU時間但要等待外部的事件),後面還有其他客人在等待。這時候是要讓idle(閒置)的CPU先擱置現在的工作去處理其他排隊的客人,還是繼續傻傻痴痴地等待,相信聰明的你已經有答案了。

燙髮機器

圖片來源:https://eason2002.nidbox.com/diary/read/9839113

所有權

在講這個之前要先講Rust的所有權的三條規則:

  1. Rust 中每個數值都有個擁有者(owner)
  2. 同時間只能有一個擁有者。
  3. 當擁有者離開作用域時,數值就會被丟棄(Drop)。

因為rust沒有自動GC機制,而是透過所有權作用域來幫程式設計師處理記憶體資源的回收,所以rust變數存活期間,與其他常見程式語言有很大的差別,我們看一下下面的代碼:

fn main() {
    let s: String = String::from("Hello, World!");
    let n: usize = length(s);
    println!("The length of '{}' is {}.", s, n);
}

fn length(x: String) -> usize {
    x.len()
}

看起來都很正常(?),但跑出來一堆問題:

rust所有權示範代碼訊息

編譯器的紅字說「value borrowed here after move」,這裡有兩個關鍵字move以及borrow,呼應提到的所有權

  • 規則一:每個數值都有個擁有者(Owner),在程式碼第2行String::from("hello, World!")產生的字串物件,擁有者是s這個變數,而s變數不像其他語言在整個main裡都有效,關鍵在第3行,這個字串要傳遞給fn length使用的時候,所有權發生改變。
  • 規則二:同時間只能有一個擁有者:在s把資料傳遞給fn length後,剛剛那包字串物件因為只能有一個所有者,因而"Hello, World"的所有權只能交棒出去:移轉(Move)fn length的參數,所以所有權變成了x變數所有,接著下一條規則:
  • 規則三:當擁有者離開作用域時,數值就會被丟棄:x在執行完fn length後就會被拋棄,所以"Hello, World"這個字串就被回收(Drop)了,而剛剛的s變數在第三行就把所有權給出去了,所以s在第三行跑完就被丟棄(Drop)了,不是null,是對第三行以後的程式碼來說,s就像從來沒有存在過,所以編譯器才說s被丟棄了。

編譯器建議的.clone()是指我們明確指複製一份s出去,以保留s的所有權不被移轉。

那藍字寫的type String未實作Copytrait是什麼意思,先簡單理解數字類(bool, int, uint)的簡單資料(rust預設有實作Copy trait),在相互傳值的時候,會被自動copy一份,所以不需要你特別指定說要複製(clone)。(原理版是有關乎記憶體Stack和Heap,以及安全性考量,有機會再講解)。上例我們依編譯器的提示使用clone()就可以解決:

fn main() {
    let s: String = String::from("Hello, World!");
    let n: usize = length(s.clone());  // 這裡加 clone() 手動複製一份
    println!("The length of '{}' is {}.", s, n);
}

fn length(x: String) -> usize {
    x.len()
}

Arc

了解完所有權才有機會知道 Arc 在幹嘛,講Arc前先講 Rc,如同剛剛說的因為rust變數一離開作用域就會被丟棄,而有時候我們可能需要讓很多人使用(?),所以Rust的智能指針(reference-counting pointer)就來幫忙管理Rc<T>包起來的變數,Rc會看現在有幾個人在用,多1個人用就+1(怎麼感覺好像在訂下午茶 XDD),用完的人依循所有權規則被丟棄(drop)就-1,如此一來,rust就可以知道什麼時候使用的人數歸0,然後就安心的把資料drop掉。

上一段的其實是程式/程序,不是真的人,只是這樣講應該比較好懂(?)

了解Rc之後,Arc是(Atomically Reference Counted),那個A是什麼單字不認識沒關係,有寫過資料庫交易Transaction就知道要嘛全部成功,要嘛全部取消,沒有成功一半的;如果沒寫過資料庫的話,就想像到ATM領錢,拿到現金和帳戶金額扣除一定是一起完成,不會有交易一半的情況,比如拿了錢但存款沒被扣,或是存款被扣了但錢沒拿到,(先不考慮有人很衰遇到ATM壞掉被卡鈔的情境)。

Arc是為了補足Rc在多線程下可能不夠安全,到時候發生扣款但沒拿到錢的情境就囧了,所以實際使用看情境,如果是單thread跑用Rc就可以了,畢竟多一個A就要多一些開銷成本。

有些(很愛挑毛病,愛到處嘴別人,四處踢館的朋友)舉一反三的同學可能會說,既然Rc用在單線程,那怎麼同時還會有很多人使用呢,考慮以下情境:

fn main() {
    let data = String::from("Hello");

    for i in 1..10 {
        let c = data;
    }
}

在for迴圈遇到被move變數

直接不讓過了,在其他程式語言很簡單的功能在這裡卻無法作用,因為在for迴圈中,第一輪的時候,data所有權就移交給 c 了,第一輪結束後,c被拋棄(drop)掉,第二輪開始 data就不存在了,自然編譯不給過。

Mutex

Rc和Arc主要都是在處理多人使用的共享,確保資料還有人要用的時候,不要被清除拋棄,但如果今天共享的資料是需要修改的怎麼辦,會有相互競爭的問題,我們拿大家常見的i++來舉例,假設A,B 兩線程同時要處理i++

i = 0
      a = i           // a 讀取 i    => a = 0
            b = i     // b 讀取 i    => b = 0
      a += 1          // a 處理 ++   => a = 1
            b += 1    // b 處理 ++   => b = 1
      i = a           // a 結果回寫   => i = 1
            i = b     // b 結果回寫   => i = 1

理論上從i = 0跑兩次 i++ 應該會變成i = 2,所以上面就是多線程讀寫不同步產生的問題。而Mutex就是把資料鎖住,1次只能1個人讀寫,這樣就可以確保資料不會被線程相互競爭而干擾了。

一切好像都很美好(?),欸可是剛剛不是說Arc是可以多人使用,現在又說Mutex只能一個人使用? (你搞的我好亂啊.jpg)。Arc是可以多人引用,但Mutex同時只能一人使用,不衝突啊(謎之音:我精得跟猴一樣,別想騙我)。就像去麥XX點餐(沒有業配),假設他只有一個櫃台,在處理客人點餐時,大家需要排隊(Arc),隊伍可以有很多人,但同時只能有1個人在點餐(交易, Mutex),櫃台要等到沒有人(count=0)才可以掛上暫停服務的牌子(drop)。

附帶一提,rust還有另一種鎖RwLock,看名字應該可以猜到,就是讀寫分開算,這樣就可以同時很多人讀,而當要寫的時候就要淨空,這可以用在資料平常都是以讀為主,偶爾更新的情境,可以有效避免大家沒有要修改卻要互相等待的情境,畢竟資料沒有要修改就不會發生剛剛2個線程發生競爭條件的例子,就像去看畫展,其他觀眾觀看不影響我們同一時間一起觀看,所以不需要排隊一個人看完再輪下一個人,那就很沒有效率。

參考資料


上一篇
06 好還要更好,讓 rust 乾淨一點
下一篇
08 說好的 rust CRUD 呢?怎麼還沒好
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
bendwarn
iT邦新手 5 級 ‧ 2024-01-29 14:05:18

anyhow 是不是能幫忙 OCP,否則只是用個物件卻得要了解他會丟啥錯誤,不太合理

我的見解是,定義不同的Error 類別,提供後續不同的處理方式,通常用anyhow的時候就是不處理錯誤,直接把Error訊息回報給使用者。

就像C#裡不會所有錯誤都丟Exception,或直接catch Exception,雖然沒有規定說不定,但是這樣不會讓程式比較好維護,會再指定特定的例外比如NullReferenceException。

我要留言

立即登入留言