這篇終於要開始寫後端API了,以下是搜尋到的一些rust的web框架,好奇的人可以點進去看看:
要用哪一個呢,只好擲筊了(?)。喂不是啦,就每個都看一下來比較: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安裝起來,再把salvo、warp、axum分別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就回不去了)。
我不會承認我先寫好結論再去找資料湊成我想要的結果。
直接依照 warp 的Hello範例寫起來:
記得先加上cargo watch指令到 run.sh
或 run.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;
}
結果立馬報錯如下:
這裡有兩個訊息,第一個是找不到crate tokio,在rust的世界你一定不能不知道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應該也跑完了:
咦怎麼沒訊息,看訊息好像正在跑(running),我們開一下web看看,剛剛main裡面有寫到port是3030
,所以我們用瀏覽器連上 http://localhost:3030 看看:
正確開啟,那剛剛的第2個錯誤是什麼?往上拉回去看的話,就是字面上的意思:rust裡的main function不能用async
,所以剛剛我們的fn main
上面有寫一行外掛:#[tokio::main]
,原來是tokio幫我們把main變async了。
接著開始寫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的快去補課一下,或是,這裡有個小遊戲,在遊戲裡面你要扮演作業系統(OS),來控制每一個進程(Process),這個小遊戲可以很好的理解OS的運作,記得先看How to play
再開始玩,不然可能會不曉得在幹麻。
簡單說程式在跑有分CPU bound跟IO 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的所有權的三條規則:
因為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()
}
看起來都很正常(?),但跑出來一堆問題:
編譯器的紅字說「value borrowed here after move」,這裡有兩個關鍵字move以及borrow,呼應提到的所有權
String::from("hello, World!")
產生的字串物件,擁有者是s
這個變數,而s
變數不像其他語言在整個main
裡都有效,關鍵在第3行,這個字串要傳遞給fn length
使用的時候,所有權發生改變。fn length
後,剛剛那包字串物件因為只能有一個所有者,因而"Hello, World"
的所有權只能交棒出去:移轉(Move) 給fn length
的參數,所以所有權變成了x
變數所有,接著下一條規則:fn length
後就會被拋棄,所以"Hello, World"這個字串就被回收(Drop)了,而剛剛的s
變數在第三行就把所有權給出去了,所以s
在第三行跑完就被丟棄(Drop)了,不是null,是對第三行以後的程式碼來說,s
就像從來沒有存在過,所以編譯器才說s
被丟棄了。編譯器建議的
.clone()
是指我們明確指複製一份s
出去,以保留s
的所有權不被移轉。
那藍字寫的type String未實作Copy
trait是什麼意思,先簡單理解數字類(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前先講 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迴圈中,第一輪的時候,data所有權就移交給 c 了,第一輪結束後,c被拋棄(drop)掉,第二輪開始 data就不存在了,自然編譯不給過。
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個線程發生競爭條件的例子,就像去看畫展,其他觀眾觀看不影響我們同一時間一起觀看,所以不需要排隊一個人看完再輪下一個人,那就很沒有效率。
anyhow 是不是能幫忙 OCP,否則只是用個物件卻得要了解他會丟啥錯誤,不太合理
我的見解是,定義不同的Error 類別,提供後續不同的處理方式,通常用anyhow的時候就是不處理錯誤,直接把Error訊息回報給使用者。
就像C#裡不會所有錯誤都丟Exception,或直接catch Exception,雖然沒有規定說不定,但是這樣不會讓程式比較好維護,會再指定特定的例外比如NullReferenceException。