什麼是gRPC?
我好像只看得懂高效能、開源,然後RPC又是什麼?先不著急google,往下拉就有答案 XDD
原來RPC 是 遠端程序呼叫的意思,應該可以很容易類比,一般我們在程式內要呼叫其他程式,就會直接呼叫沒有問題(通常都在同一個執行檔,或是呼叫得到的dll, jar檔之類的)。可是現在前後端分離了,我們希望把核心邏輯集中管理在後端,或是拆微服務時需要跨服務通訊,這時候我們 其實可以用REST API就好了 可能會想問看看有沒有其他方案可以選擇,gRPC是個選項。
關於gRPC和REST的比較可以看一下微軟的說法或Amozon的說法,其實和REST API分別會有不同的適用場景,技術的選擇還是一樣或取或捨。gRPC可以使用Stream 串流,REST API比較方便開發/測試(開發者Friendly),gRPC比較機器Friendly (?);REST基本上語義相對清晰,JSON也滿容易人工判讀的,gRPC傳輸是二進制(binary),基本上就要有一些工具才能調試;但相對的JSON就很胖,所以如果遇到我們要節流的情境,就可以考慮選用gRPC。
什麼,有人說他網路吃到飽才不在意流量,(那請他出國開啟國際漫遊上網就知道)。有些遇到服務全球的電商或是平台,可能要考慮很多地區的資料傳輸都是按流量計費的,所以怎麼幫用戶節省傳輸流量也是至關重要的一環,或者是如果使用雲服務,那麼網路流量的收費也會影響。這邊附帶一提滿有趣的漸近式圖片載入,也是為了在頻寬有限的情況下達到比較好的使用者體驗。
至於怎麼實作呢,因為傳輸是使用二進制資料格式,所以對於client端及server端要約定好相同的介面標準,作為雙方溝通的依據,如此才能正確的編譯/解譯。而gRPC把其專屬的介面定義成Protocol Buffer,或簡稱為ProtoBuf
。基本上大部分常見的程式語言都有相對應的支援,所以gRPC可以橫跨不同的平台或程式語言,只要依循相同的規格即可相互通訊。
我們先依照規格文件製作一份我們的game.proto
// proto/game.proto
syntax = "proto3"; // 使用的protobuf版本,可參考官網使用說明
package game; // 命名空間的概念,避免與其他同名物件衝突
service TicTacToe { // 定義服務類別
rpc GetGame (IdRequest) returns (Game); // 程序名稱(參數類別)->(回傳類別)
rpc Play (PlayRequest) returns (Game);
rpc NewGame (EmptyRequest) returns (GameSet);
rpc DeleteGame (IdRequest) returns (EmptyResponse);
}
message EmptyRequest {} // 無內容欄位的類別,在此例提供無參數方法
message EmptyResponse {} // 無內容欄位的類別,在此例提供空回傳資料
message IdRequest { // 傳遞Id的請求物件
uint32 id = 1; // 類別應該很明顯,欄位名稱id,1代表順序
}
message PlayRequest { // play方法的請求物件
uint32 id = 1; // 第一個欄位是id
uint32 num = 2; // 第二個欄位是格號
}
enum Symbol { // enum的個數上線依程式語言而定
O = 0; // enum的值不是順序,是int值
X = 1; // rust 我們使用的prost會翻成 i32 的格式
None = 2; // 配合protobuf我們把None也設為一個選項
}
message Game { // 遊戲棋局物件
repeated Symbol cells = 1; // repeated 表示可重覆,就是Array或list
bool is_over = 2; // boolean 值
optional Symbol winner = 3; // optional表欄位選填,rust會翻成Option<T>
repeated uint32 won_line = 4;
}
message GameSet { // 搭配棋局Id的物件
uint32 id = 1; // 局號
Game game = 2; // 遊戲內容
}
message GameSetList { // 所有棋局的物件
repeated GameSet games = 1; // 清單
}
rpc(就是定義呼叫服務的方法)必需要填入輸入的參數與輸出的類別,而我們之前寫的service有些是無輸入參數,有些是無輸出類別的,所以我們做了一個EmtpyRequest和EmptyResponse,不過這不是很好的慣例,在這篇stakoverflow上的回答,指出針對每個方法使用獨立的request物件,若未來有修改(追加欄位、修改參數等),可保持一定的向後相容,所以建議大家實際上在使用還是斟酌使用。(不要學我一樣偷懶)
如果gRPC的方法提供給很多的用戶端使用,隨著 技術債的一直堆疊 陸續修改規格,可能會遇到棄用欄位的情形,官方建議不要重複使用該等編號,並把相關編號註記為保留,避免出現一些非預期的後果。
在rust裡面要使用gRPC,我們可以使用tonic套件,一樣,最好的學習就是Ctrl+C/Ctrl+V,所以按慣例到hello world取用,但是這次好像沒那麼簡單 (就能找到聊得來的伴🎵)。這個範例和我們之前看過的相比,好像似乎彷彿有稍微難一點點,尤其是對於沒接觸過gRPC的朋友。相當於直接越級打怪了,我們還是按部就班,慢慢累積經驗值,先從乖乖看導覽文件的說明開始一步步做起:
在專案根目錄下建立proto的資料夾,因為我們在client/server的專案都會參考到,所以獨立一個目錄放置,然後再裡面建立helloworld.proto檔(如果是不同的程式語言實作,也要複製相同的proto檔案,確保雙方溝通的資料規格是一致的):
proto
└── helloworld.proto
// proto/helloworld.proto
syntax = "proto3";
package helloworld;
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
上一節我們看過proto檔,所以應該不陌生了,接下來照著導覽文件的下一步設置專案依賴:
@@ Cargo.toml @@
[workspace.dependencies]
+prost = { version = "0.12" }
+tonic = { version = "0.10" }
+tonic-build = { version = "0.10" }
@@ web/Cargo.toml @@
[dependencies]
+prost = { workspace = true }
+tonic = { workspace = true }
...
+[build-dependencies]
+tonic-build = { workspace = true }
@@ app/src-tauri/Cargo.toml @@
[build-dependencies]
tauri-build = { version = "1.4", features = [] }
+tonic-build = { workspace = true }
...
[dependencies]
+prost = { workspace = true }
+tonic = { workspace = true }
接著要加入build.rs
檔案,這是rust的建構腳本,可以在編譯我們的代碼前,先執行準備動作,以我們要實作gRPC來說,在建構腳本裡會幫我們解析protobuf檔案,並產生相對應的rust類別,我們看實際的案例比較清楚(注意build.rs檔案放在src外面,跟src同一層):
web
├── build.rs
// web/build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::compile_protos("../proto/helloworld.proto")?;
Ok(())
}
因為我們開workspace把client/server分不同的crates專案,所以要分別建立build.rs檔案,而tauri裡的專案已有這個檔案,我們只要加上編譯protobuf的部分就好:
// app/src-tauri/build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> { // 改加回傳result
// 加gRPC所需匯入proto檔
tonic_build::compile_protos("../../proto/helloworld.proto")?;
tauri_build::build(); // 保留原本tauri的部分
Ok(())
}
這時候如果我們建置web專案,在專案根目錄裡面有個target
資料夾,這個資料夾是rust建置的產出資料夾,我們可以看到在target/debug/build/web-{hash}/out
裡面有一個helloworld.rs
檔案,這個就是build幫我們產生出來的檔案:
內容大致是長這樣:
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct HelloRequest {
#[prost(string, tag = "1")]
pub name: ::prost::alloc::string::String,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct HelloReply {
#[prost(string, tag = "1")]
pub message: ::prost::alloc::string::String,
}
// ... 略
順帶提一下,剛剛開啟的target資料夾,裡面放的是rust建置時產生的檔案,這個資料夾我們一開始就設在.gitignore
裡,所以會忽略不放入版控中,而我們每次建置時,rust會比較差異再建置有更動的crates,所以我們會看到一堆過時的資料夾,造成資料夾很巨大:
這時候可以使用 cargo clean來清理先前建置所產生的暫存檔,不過缺點是下次再建置的時候,就會像一開始剛裝好rust時,compiler要跑很久,但又不至於像第一次那麼久,因為我們第一次跑還需要逐一下載各相依套件的資料,所以這算是個小缺點,我們用空間來換時間(?),爭取rust寫好的程式在跑的時候,比較快。
以我們的demo-app為例,清理後再重新建置web和app的結果如下,少了很多了吧:
什麼,有人說3G還是很多,去查原X屋或PX家的SSD 1T現在約1,600 ~ 1,800,所以我就算30G,也才50塊左右,銅板價,一份早餐可能都不只(?)。喂怎麼又扯遠了,再拉回來我們看一下我們建置後的檔案:
怎麼感覺有點大(?),因為我們開發用,並沒有優化,我們可以用release跑看看:
~/demo-app$ cargo build --release -p web
~/demo-app$ cargo build --release -p app
可以看到最後的執行檔,web是11.7MB,app是18.4MB,好像還好對吧,順帶一提,tauri app在windows環境建置可以設定要不要含webview2,如果要包含的話可能會再多個一兩百MB。
另外附一下windows環境下建置的release版本,也大約都在10MB以內:
繼續follow我們剛剛的導覽文件,接下來做完server的部分,我們這次依然仿照之前https
的寫法,試著把gRPC server獨立為一個執行檔,只不過我們這次需要使用的是多檔案組合,不像上回https
單檔bin
:
web/src/bin
├── grpc
│ ├── hello_world.rs
│ └── main.rs
└── https.rs
@@ run.ps1 @@
...
+Write-Host "9) [web]: 執行gRPC Server"
...
+} elseif ($opt -eq 9) {
+ cargo watch -q -c -w ./web/ -w ./service/ -w ./core/ -x 'run -p web --bin grpc'
@@ run.sh @@
+echo 9: [web] run web api server with gRPC
...
+ elif [[ $VAR -eq 9 ]]
+ then
+ cargo watch -q -c -w ./web/ -w ./service/ -w ./core/ -x 'run -p web --bin grpc'
tonic的範例是把main和api寫在同一個檔案,不過考量我們之後會加新的gRPC服務,所以我們試著一開始就先把main
和hello_world
服務拆開:
// web/src/bin/grpc/hello_world.rs
tonic::include_proto!("helloworld"); // 透過巨集引入 build產生的rs檔
use tonic::{Request, Response, Status};
use greeter_server::Greeter; // build檔幫我們生成的trait
#[derive(Default)]
pub struct MyGreeter {} // 我們要實作服務的實體
#[tonic::async_trait]
impl Greeter for MyGreeter { // 實作proto裡的服務(service)
async fn say_hello( // 實作proto裡的程序(rpc)
&self,
request: Request<HelloRequest>, // trait裡依proto定義的簽章
) -> Result<Response<HelloReply>, Status> {
let reply = HelloReply { // proto裡定義的 message
message: format!("Hello {}!", request.into_inner().name),
}; // HelloRequest和HelloReply等struct
Ok(Response::new(reply)) // 是第一行巨集產生的,故逕行使用
}
}
// web/src/bin/grpc/main.rs
use tonic::{transport::Server};
use hello_world::{
greeter_server::GreeterServer, // build 幫我們產生的 gRPC Server
MyGreeter, // 我們實作的方法
};
mod hello_world;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = "[::1]:3032".parse().unwrap(); // 監聽的addr & port
let greeter = MyGreeter::default(); // 我們實作的服務
Server::builder()
.add_service(GreeterServer::new(greeter)) // 加入gRPC服務
.serve(addr)
.await?;
Ok(())
}
跑成功了,可是沒有多的訊息提示,不知道到底是不是真的成功,所以引入我們之前的寫的logger:
@@ web/src/bin/grpc/main.rs @@
async fn main() -> Result<(), Box<dyn std::error::Error>> {
+ let _logger = service::logger::Logger::builder()
+ .add_package("grpc")
+ .add_package("tonic")
+ .build();
let addr = "[::1]:3032".parse().unwrap();
let greeter = MyGreeter::default();
+ tracing::info!("GreeterServer listening on {}", addr);
@@ web/src/bin/grpc/hello_world.rs @@
async fn say_hello(
...
+ tracing::debug!("Got a request from {:?}", request.remote_addr());
之前有把logger抽出來優化,所以現在需要用到就可以很方便地引入,不需要每次重寫。執行結果如下:
看起來正確執行,咱往客戶端部分邁進。
依導覽文件拆出我們的gRPC client:
// app/src-tauri/src/hello_grpc.rs
tonic::include_proto!("helloworld"); // 匯入 build產生的rs檔
use greeter_client::GreeterClient; // 使用proto的Client物件
pub async fn say_hello() {
let mut client = GreeterClient::connect("http://[::1]:3032")
.await
.unwrap(); // 連線到server端
let request = tonic::Request::new(HelloRequest {
name: "Tonic".into(), // 準備請求內容
});
let response = client.say_hello(request)
.await
.unwrap(); // 呼叫並取得回應
tracing::debug!("RESPONSE={:#?}", response); // 輸出紀錄回應結果
}
// app/src-tauri/src/main.rs
mod hello_grpc;
use crate::hello_grpc::say_hello;
// ...略
#[tokio::main] // 把原本的 main 改成 async main
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// ... 略
say_hello().await; // 記得放 logger啟動之後
// ... 略
Ok(())
}
啟動前端程式,可以看到正確的呼叫,並印出呼叫gRPC後,從後端取得回傳的結果,這邊要注意app/src-tauri/.env
裡要設定LOG_LEVEL=debug
才會印出debug等級的日誌:
so far,我們完成了gRPC的Hello world,下一篇我們再來將我們的遊戲服務套用gRPC。