iT邦幫忙

2023 iThome 鐵人賽

DAY 20
0
Software Development

前端? 後端? 摻在一起做成全端就好了系列 第 20

20 gRPC初探:Hello world from rust tonic

  • 分享至 

  • xImage
  •  

gRPC 簡介

什麼是gRPC?

gRPC官網首頁

我好像只看得懂高效能、開源,然後RPC又是什麼?先不著急google,往下拉就有答案 XDD

gRPC說明

原來RPC 是 遠端程序呼叫的意思,應該可以很容易類比,一般我們在程式內要呼叫其他程式,就會直接呼叫沒有問題(通常都在同一個執行檔,或是呼叫得到的dll, jar檔之類的)。可是現在前後端分離了,我們希望把核心邏輯集中管理在後端,或是拆微服務時需要跨服務通訊,這時候我們 其實可以用REST API就好了 可能會想問看看有沒有其他方案可以選擇,gRPC是個選項。

關於gRPC和REST的比較可以看一下微軟的說法Amozon的說法,其實和REST API分別會有不同的適用場景,技術的選擇還是一樣或取或捨。gRPC可以使用Stream 串流,REST API比較方便開發/測試(開發者Friendly),gRPC比較機器Friendly (?);REST基本上語義相對清晰,JSON也滿容易人工判讀的,gRPC傳輸是二進制(binary),基本上就要有一些工具才能調試;但相對的JSON就很胖,所以如果遇到我們要節流的情境,就可以考慮選用gRPC。

什麼,有人說他網路吃到飽才不在意流量,(那請他出國開啟國際漫遊上網就知道)。有些遇到服務全球的電商或是平台,可能要考慮很多地區的資料傳輸都是按流量計費的,所以怎麼幫用戶節省傳輸流量也是至關重要的一環,或者是如果使用雲服務,那麼網路流量的收費也會影響。這邊附帶一提滿有趣的漸近式圖片載入,也是為了在頻寬有限的情況下達到比較好的使用者體驗。

Protobuf 簡介

至於怎麼實作呢,因為傳輸是使用二進制資料格式,所以對於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的方法提供給很多的用戶端使用,隨著 技術債的一直堆疊 陸續修改規格,可能會遇到棄用欄位的情形,官方建議不要重複使用該等編號,並把相關編號註記為保留,避免出現一些非預期的後果

tonic: rust的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(())                                                        
}

rust 的 target 資料夾

這時候如果我們建置web專案,在專案根目錄裡面有個target資料夾,這個資料夾是rust建置的產出資料夾,我們可以看到在target/debug/build/web-{hash}/out裡面有一個helloworld.rs檔案,這個就是build幫我們產生出來的檔案:

rust的target資料夾示例

內容大致是長這樣:

#[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,所以我們會看到一堆過時的資料夾,造成資料夾很巨大:
target資料夾大小

這時候可以使用 cargo clean來清理先前建置所產生的暫存檔,不過缺點是下次再建置的時候,就會像一開始剛裝好rust時,compiler要跑很久,但又不至於像第一次那麼久,因為我們第一次跑還需要逐一下載各相依套件的資料,所以這算是個小缺點,我們用空間來換時間(?),爭取rust寫好的程式在跑的時候,比較快。

以我們的demo-app為例,清理後再重新建置web和app的結果如下,少了很多了吧:

clean後重建的target資料夾

什麼,有人說3G還是很多,去查原X屋或PX家的SSD 1T現在約1,600 ~ 1,800,所以我就算30G,也才50塊左右,銅板價,一份早餐可能都不只(?)。喂怎麼又扯遠了,再拉回來我們看一下我們建置後的檔案:

rust的target資料夾結構

怎麼感覺有點大(?),因為我們開發用,並沒有優化,我們可以用release跑看看:

~/demo-app$ cargo build --release -p web
~/demo-app$ cargo build --release -p app

rust建置release版本的資料夾結構

可以看到最後的執行檔,web是11.7MB,app是18.4MB,好像還好對吧,順帶一提,tauri app在windows環境建置可以設定要不要含webview2,如果要包含的話可能會再多個一兩百MB。

另外附一下windows環境下建置的release版本,也大約都在10MB以內:

windows版本的建置結果執行檔

gRPC server

繼續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服務,所以我們試著一開始就先把mainhello_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(())
}

完成gRPC後執行server端終端畫面

跑成功了,可是沒有多的訊息提示,不知道到底是不是真的成功,所以引入我們之前的寫的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 server執行成功

看起來正確執行,咱往客戶端部分邁進。

gRPC client

導覽文件拆出我們的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等級的日誌:

gRPC成功執行client端

so far,我們完成了gRPC的Hello world,下一篇我們再來將我們的遊戲服務套用gRPC。


上一篇
19 再探 WebAssembly 及 rust closure
下一篇
21 CRUD w/ rust gRPC
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言