iT邦幫忙

2023 iThome 鐵人賽

DAY 21
0

大家對於如何使用gRPC,應該有些概念了,其實就跟restAPI呼叫很像,只是要多寫很多東西,然後很多方法或結構體也不知道去哪找(?)。以現階段來說,我用RustRoverVS Code對於自動產生的code有時候都還不夠聰明,或是有時無法即時更新,所以可提示或自動完成的部分相對比較少,常常需要手動寫。這時候有copilot其實會好很多,大概五六成七八成可以對,再修修改改就好了,以下我們一起照著前一篇刻好的game.proto,試著完成前後端gRPC的通訊。

gRPC server 後端 game api

我們一樣先試著完成後端的部分,先打開一個終端畫面,把batch檔裡的gRPC server跑起來,接著一邊開發一邊驗證,跑起來後。我們先在build.rs裡加入代碼(忘了寫的話,其實也會提醒,只是訊息的判讀上需要經驗的累積 XDD),讓build幫我們產生gRPC對應的rust程式碼,接著在grpc的main裡,掛一個tic_tac_toe的mod檔案。

@@ web/build.rs @@
 fn main() -> Result<(), Box<dyn std::error::Error>> {
     tonic_build::compile_protos("../proto/helloworld.proto")?;
+    tonic_build::compile_protos("../proto/game.proto")?;
     Ok(())

@@ web/src/bin/grpc/main.rs @@
+mod tic_tac_toe;

目前檔案架構如下:

web/src/bin
├── grpc
│   ├── hello_world.rs
│   ├── main.rs
│   └── tic_tac_toe.rs
└── https.rs

接著來寫我們的gRPC server端程式,首先要實作proto定義的rpc程序,如果ide順利掃到的話,可以使用自動完成:
提示自動實作成員

選擇要自動實作的成員

代碼架構如下:

// web/src/bin/grpc/tic_tac_toe.rs
tonic::include_proto!("game");                // 引入自動建立的代碼

use tonic::{Request, Response, Status};       // tonic的請求/回應/狀態
use tic_tac_toe_server::{TicTacToe};          // 自動代碼

pub struct TicTacToeGrpcService {}            // 用來實作介面的結構體

#[tonic::async_trait]                         // rust原生不支援async trait
impl TicTacToe for TicTacToeGrpcService {
    async fn get_game(
        &self, request: Request<IdRequest>,
    ) -> Result<Response<Game>, Status> {
        let id = request
    }

    async fn play(
        &self, request: Request<PlayRequest>,
    ) -> Result<Response<Game>, Status> {
        todo!()
    }

    async fn new_game(
        &self, request: Request<EmptyRequest>,
    ) -> Result<Response<GameSet>, Status> {
        todo!()
    }

    async fn delete_game(
        &self, request: Request<IdRequest>,
    ) -> Result<Response<EmptyResponse>, Status> {
        todo!()
    }
}

如果不確定有什麼可以用,可以到target/debug/build/web-{hash}/out/game.rs 查看,寫多大概就知道套件取命的慣例,或是一直重開IDE看會不會掃到(誤)

接下來要加入我們的應用層TicTacToeGrpcServicetonic雖然也有middleware可以使用,不過因為我們的InMemory服務只會在這個TicTacToeGrpcService裡使用,所以似乎不需要動用到middleware,我們直接加入:

// web/src/bin/grpc/tic_tac_toe.rs
use service::tic_tac_toe::{InMemoryTicTacToeService, TicTacToeService};

pub struct TicTacToeGrpcService {
    service: InMemoryTicTacToeService,
}

好了可以開始寫第一個api,可以看到使用into_inner()可以取得IdRequest結構體:
顯示IDE對於gRPC request的方法提示

補完get的fn內容:

// web/src/bin/grpc/tic_tac_toe.rs
async fn get_game(
    &self, request: Request<IdRequest>
)    -> Result<Response<Game>, Status> {
    let id = request.into_inner().id as usize;
    let game = self.service.get(id)?;
    Ok(Response::new(game))
}

接下來就是見證奇蹟的時刻,噢不是,是見一堆紅色錯誤的時刻:
初步完成編譯後出現無法mapping的訊息

出現的2個錯誤都是mapping的問題:

  • 第一個是我們service裡的錯誤要mapping到gRPC的Status
  • 第二個是我們core裡的Game要mapping到gRPC產生出來的Game

rust From<T> trait 的使用

我們之前使用From trait的時候,有符合rust裡的規定

  • 要麻該trait為我們crate裡定義的,
  • 要麻被impl的對象(struct或enum)為我們crate裡定義的對象。

而現在我們要實作的trait和物件很明顯不是我們定義的:

  • from trait是標準庫裡定義的
  • 轉換的目標Status是tonic套件定義的
  • 而轉換的來源類別Error為service的專案定義的(我們現在在web crate)

所以我們不能實作2個不在我們crate裡定義的物件,在這裡需要透過一個中間商,就是先前的AppError,我們原本就有實作從server的Error 轉換為AppError,我們只要再實作AppError轉換為tonic的Status就可以,一個方式是:

impl Into<Status> for AppError {
    fn into(self) -> Status {
        // ... 略
    }
}

這個Into trait其實是 From trait的同樣功能不同寫法。比如我要把source轉換成target

  • 一是可以 let target = source.into()
  • 二是可以 let target = Target::From(source)

在rust裡面,我們可以不用2個都實作,我們只要實作 A from B,就會自動會得到B into A的方法。但反之則不會(實作Into就只有Into,不會免費得到From),可以參考官方文件說明:
rust的Into trait說明

Rust文件指出我們應該要儘可能使用From trait,而Into只有在舊版本無法對外部參考carte實作impl from的時候使用(就是說不能impl From<內部> for 外部,因為內部被包在From裡,而From又是標準庫,會被當作外部)。我們目前用的rust版本沒有這個問題,所以我們直接用比較好的慣例寫法,以下為用from寫的代碼:

// web/src/error.rs
use tonic::{Code, Status};

impl From<AppError> for Status {
    fn from(value: AppError) -> Self {
        match value {
            AppError::UserFriendly(e, m) => Status::with_details(Code::Aborted, e, m.into()),
            AppError::BadRequest(s) => Status::unavailable(s),
            AppError::NotFound(s) => Status::not_found(s),
            AppError::Unauthorized => Status::unauthenticated(""),
            AppError::InternalServerError => Status::unknown(""),
        }
    }
}

剛好tonic裡的Status就有包含message欄位與details欄位(英雄所見略同?),可以直接對應我們先前使用的錯誤訊息欄位,我們就直接把UserFriendly訊息對應到message及details欄位,至於其他錯誤,就找Status比較接近的類別使用,做好mapping後,就可以先把修改我們剛剛api裡處理error的部分,我們透過map_err先把service::Error 轉換為AppError,而後面的?就會自動幫我們呼叫並轉換成Status

// web/src/bin/grpc/tic_tac_toe.rs
use web::error::AppError;
// 略...
let game = self.service.get(id).map_err(AppError::from)?;

mapping Game to Game

接下來做苦工的時候,來mapping Game結構體,咦,那核心Game和gRPC的Game我們能實作嗎,我們一開頭的巨集tonic::include_proto!("game");,其實就是把target裡的 rs檔案綁定進來,所以build.rs產生的Game struct是在我們這個檔案裡,而且還是同一個mod,所以可以直接使用,而core裡的Game會當外部套件的結構體。不過在這裡名稱衝突,所以我們給一個別名:

// web/src/bin/grpc/tic_tac_toe.rs
type CoreGame = my_core::tic_tac_toe::Game;        // 別名:識別core裡的Game
type CoreSymbol = my_core::tic_tac_toe::Symbol;    // 別名:識別core裡的Symbol

impl From<CoreGame> for Game {                     // 實作From trait
    fn from(game: CoreGame) -> Self {
        Self {
            cells: game.cells.iter()              // gRPC 的 cell 是 Vec<i32>
                .map(|&x| match x {               // map CoreGame to grpc
                    None => 2,                    // gRPC enum 的 2
                    Some(sym) => match sym {
                        CoreSymbol::O => 0,       // gRPC enum 的 0
                        CoreSymbol::X => 1,       // gRPC enum 的 1
                    },
                }).collect()
            ,
            is_over: game.is_over,                // boolean沒什麼問題
            winner: match game.winner {           // 類別為 Option<i32>
                None => None,                     // optional可為None
                Some(sym) => match sym {
                    CoreSymbol::O => Some(0),     // Option 的值要用 Some 包起來
                    CoreSymbol::X => Some(1),
                }
            },
            won_line: match game.won_line {       // 類別為 Vec<u32>
                None => vec![],                   // 給空白清單
                Some(x) => x.into(),              // 利用into幫我們把Array轉Vec
            },
        }
    }
}

以上是因為要配合gRPC傳輸的格式,在後端web專案裡是要把我們core裡的Game,轉為gRPC的Game再傳輸到用戶端,調整我們Game的mapping後,後續可以著手調整api。

gRPC api in back-end web

第一個api修改如下:

// web/src/bin/grpc/tic_tac_toe.rs
async fn get_game(
    &self, request: Request<IdRequest>
) -> Result<Response<Game>, Status> {
    let id = request.into_inner().id;
    let game = self.service.get(id).map_err(AppError::from)?;
    Ok(Response::new(game.into())) // 剛剛實作 gRPC from coreGame,這裡使用 into
}

看compiler正確執行,接著就把其他方法也一併補上:

// web/src/bin/grpc/tic_tac_toe.rs
async fn play(
    &self, request: Request<PlayRequest>,
) -> Result<Response<Game>, Status> {
    let req = request.into_inner();     // play 的請求有兩個參數
    let game = self.service.play(
        req.id.try_into().unwrap(),
        req.num.try_into().unwrap(),
    ).map_err(AppError::from)?;
    Ok(Response::new(game.into()))
}

async fn new_game(
    &self, _request: Request<EmptyRequest>,
) -> Result<Response<GameSet>, Status> {
    let (id, game) = self.service.new_game().map_err(AppError::from)?;
    let game_set = GameSet {        // gRPC產的結構體GameSet
        id: id.try_into().unwrap(),       // id: id的縮寫
        game: Some(game.into()),    // 一樣用 into ,配合類別要放在Some裡
    };
    Ok(Response::new(game_set))
}

async fn delete_game(
    &self, request: Request<IdRequest>,
) -> Result<Response<EmptyResponse>, Status> {
    let id = request.into_inner().id;
    self.service
        .delete(id.try_into().unwrap())
        .map_err(AppError::from)?;
    Ok(Response::new(EmptyResponse::default()))   // 無回傳值我們給空白物件
}

API方法都實作好了,我們把相關的服務註冊到main裡,記得先把我們結構體的預設值初始化方法補上:

// web/src/bin/grpc/tic_tac_toe.rs
impl Default for TicTacToeGrpcService {
    fn default() -> Self {
        Self {
            service: InMemoryTicTacToeService::new()
        }
    }
}
// web/src/bin/grpc/main.rs
use tic_tac_toe::{tic_tac_toe_server::TicTacToeServer, TicTacToeGrpcService};
// ...略
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // ...略
    let tic_tac_toe = TicTacToeGrpcService::default();    // 實作的服務類別

    Server::builder()
        .add_service(GreeterServer::new(greeter))
        .add_service(TicTacToeServer::new(tic_tac_toe))   // gRPC服務註冊
    // ...略

server端的gRPC實作完成,接下來我們換client端的部分。

gRPC client 端 加connection pool

先在build裡加proto的解析:

 @@ app/src-tauri/build.rs @@
 fn main() -> Result<(), Box<dyn std::error::Error>> {
   tonic_build::compile_protos("../../proto/helloworld.proto")?;
+  tonic_build::compile_protos("../../proto/game.proto")?;
   tauri_build::build();
   Ok(())
 }

接著開始寫連線的客戶端,還記得day14提過的連線池概念嗎,在gRPC的用戶端,我們可以使用tonic的channel,先前加好程式共用的Context,我們再補上gRPC的用戶端:

// app/src-tauri/src/context.rs
use tonic::transport::Channel;

pub struct Context {
    // ... 略
    channel: Channel,       // gRPC 用戶端物件
}

impl Context {
    pub fn load() -> Self {    
        // ... 略
        let grpc_url = env::var("GRPC_BASE_URL")            // 讀取環境變數
            .unwrap_or("http://[::1]:3032/".to_string());   // 預設值
        let channel: Channel = Channel::from_shared(grpc_url)
            .expect("需要設定正確的grpc url")
            .connect_lazy();  // 程式執行到這裡不主動連線,待下次需要用到才連線

        Self {
            // ... 略
            channel,
        }
    }

    pub fn channel(&self) -> Channel { self.channel.clone() }   // getter
}

expect 類似 unwrap,只是差別在取得Ok的資料時如果錯誤,則顯示該訊息,以方便除錯時更容易得知問題以處理。

補上環境變數參數檔設定:

# app/src-tauri/example.env
GRPC_BASE_URL=http://localhost:3032
# app/src-tauri/.env
GRPC_BASE_URL=http://localhost:3032

接著先修改之前寫的hello,改接剛剛設好的連線pool,並把server回傳結果傳出:

// app/src-tauri/src/hello_grpc.rs
tonic::include_proto!("helloworld");

use tonic::transport::Channel;
use greeter_client::GreeterClient;

pub async fn say_hello(
    channel: Channel, name: &str
) -> String {
    let mut client = GreeterClient::new(channel);
    let request = tonic::Request::new(HelloRequest {
        name: name.to_string(),
    });
    let response = client.say_hello(request).await.unwrap();
    response.into_inner().message
}
@@ app/src-tauri/src/main.rs @@
+println!("{}", say_hello(context.channel(), "tonic").await);
-say_hello().await;

執行hello tonic結果

確認我們client的channel寫對了,就可以繼續往下補完我們的CRUD。

proto model mapping

首先加入grpc mod:

// app/src-tauri/src/tic_tac_toe/mod.rs
pub mod grpc;

有了server的經驗,我們已經知道要做entity的mapping和error的mapping:

// app/src-tauri/src/error.rs
use tonic::Status;

impl From<Status> for ErrorResponse {        // 錯誤的mapping
    fn from(value: Status) -> Self {
        ErrorResponse {
            message: value.message().into(),    // Status.meesage為 &str
            details: Some(std::str::from_utf8(value.details())
                .unwrap_or_default()    // details是 &[u8]
                .to_string()),
        }
    }
}
// app/src-tauri/src/tic_tac_toe/grpc.rs
tonic::include_proto!("game");        // 引入 proto 產生的rust資料

type CoreGame = my_core::tic_tac_toe::Game;        // 避免與gRPC的Game名稱衝突
type CoreSymbol = my_core::tic_tac_toe::Symbol;    // 避免與gRPC的Symbol名稱衝突

impl From<Game> for CoreGame {                     // mapping
    fn from(value: Game) -> Self {
        CoreGame {
            cells: value.cells                     // Vec<i32>
                .iter()
                .map(|x| match x {                
                    0 => Some(CoreSymbol::O),      // gRPC symbol enum O = 0
                    1 => Some(CoreSymbol::X),      // gRPC symbol enum X = 1
                    _ => None
                })
                .collect::<Vec<_>>()          // Vec<Option<Symbol>, Global>
                .try_into()              // Result<[Option<...>;9], Vec<..>>
                .unwrap(),
            is_over: value.is_over,
            winner: match value.winner {        // Option<i32>
                None => None,
                Some(x) => match x {
                    0 => Some(CoreSymbol::O),   // i32 轉 rust enum
                    1 => Some(CoreSymbol::X),   // i32 轉 rust enum
                    _ => None
                }
            },
            won_line: {
                if value.won_line.len() == 0 {  // Vec<u32>
                    None
                } else {
                    Some(value.won_line.try_into().unwrap())
                }
            },
        }
    }
}

gRPC CRUD api call @ client

苦工做完了,接下來是API的部分:

// app/src-tauri/src/tic_tac_toe/grpc.rs
use tauri::State;
use crate::context::Context;
use crate::error::ErrorResponse;
use tic_tac_toe_client::TicTacToeClient;        // 使用proto產出的 Client

#[tauri::command]
pub async fn new_game_grpc(ctx: State<'_, Context>)     // 注入Context
    -> Result<(u32, CoreGame), ErrorResponse> {
    let channel = ctx.channel();                        // 取得連線池channel
    let mut client = TicTacToeClient::new(channel);     // 客戶端連線
    let request = tonic::Request::new(EmptyRequest {}); // 準備無參數請求內容
    let game_set: GameSet = client.new_game(request)    // 發送請求
        .await?.into_inner();                           // gRPC的GameSet物件
    Ok((
        game_set.id,
        game_set.game.unwrap().into()                   // 利用剛剛的From轉置
        ))    
}

編譯通過代表我們的語法寫的應該沒問題,繼續把剩下的呼叫api指令也一起寫完:

// app/src-tauri/src/tic_tac_toe/grpc.rs
#[tauri::command]
pub async fn get_game_grpc(id: u32, ctx: State<'_, Context>)
    -> Result<CoreGame, ErrorResponse> {
    let mut client = TicTacToeClient::new(ctx.channel());
    let request = tonic::Request::new(IdRequest { id });
    Ok(client.get_game(request).await?.into_inner().into())
}

#[tauri::command]
pub async fn play_game_grpc(id: u32, num: u32, ctx: State<'_, Context>)
    -> Result<CoreGame, ErrorResponse> {
    let mut client = TicTacToeClient::new(ctx.channel());
    let request = tonic::Request::new(PlayRequest { id, num });
    Ok(client.play(request).await?.into_inner().into())
}

#[tauri::command]
pub async fn delete_game_grpc(id: u32, ctx: State<'_, Context>) 
    -> Result<(), ErrorResponse> {
    let mut client = TicTacToeClient::new(ctx.channel());
    let request = tonic::Request::new(IdRequest { id });
    let _ = client.delete_game(request).await?;
    Ok(())
}

寫好指令後記得到main裡註冊:

// app/src-tauri/src/main.rs
use tic_tac_toe::grpc::{get_game_grpc, new_game_grpc, play_game_grpc, delete_game_grpc};

async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // ... 略
    tauri::Builder::default()
        // ... 略
        .invoke_handler(tauri::generate_handler![
            // ... 略
            get_game_grpc, new_game_grpc, play_game_grpc, delete_game_grpc,

寫好了怎麼測呢,我們把我們當時tauri裡呼叫rest api改為呼叫 gRPC

@@ app/src/api/tic_tac_toe.ts @@
+let method: string = isOffline ? 'get_game_e' : 'get_game_grpc';
-let method: string = isOffline ? 'get_game_e' : 'get_game';
...
+let method: string = isOffline ? 'new_game_e' : 'new_game_grpc';
-let method: string = isOffline ? 'new_game_e' : 'new_game';
...
+let method: string = isOffline ? 'play_game_e' : 'play_game_grpc';
-let method: string = isOffline ? 'play_game_e' : 'play_game';
...
+let method: string = isOffline ? 'delete_game_e' : 'delete_game_grpc';
-let method: string = isOffline ? 'delete_game_e' : 'delete_game';

測試依然是沒有問題:
測試改gRPC後的tic_tac_toe(app版))

網頁版也沒有問題:
測試改gRPC後的tic_tac_toe(網頁版)

P.S. 網頁版走的不是gRPC,還是之前的rest api,所以需要開啟後端 web api 服務才能跑的動。

效能比較

這麼辛苦好容易把gRPC寫完串接起來了,那麼到底有沒有像gRPC宣稱的效能比較好呢,我們實測一下看看,順便把之前離線㑷的樣態也一起加入比較:

我們把碼錶寫在前端畫面的js裡量測從畫面呼叫api到處理完耗費多少時間:

// app/src/routes/tic_tac_toe/+page.svelte
const newGame = async () => {
  let now = performance.now();
  gameSet = isOffline ? await api.ticTacToeOffline.newGame() :   await api.ticTacToe.newGame();
  console.log(`took ${performance.now() - now} ms`);  // 測量經過時間 performance
  error = null;
};

這邊使用JavsScript的performance來跑,據說他不用取系統時間,可以用來簡單測試,我們就順便試一下各種情境,以下REST使用http就好先不用ssl(理論上使用https會再慢一點點)。

chrome rest api

效能測試:chrome rest api @Debian
Debian 12 intel i7-4790 4 Core RAM 16GB

效能測試:chrome rest api @win10
win 10 intel i7-9700 8 Core RAM 32GB

chrome wasm

效能測試:chrome wasm @Debian
Debian 12 intel i7-4790 4 Core RAM 16GB
效能測試:chrome wasm @win10
win 10 intel i7-9700 8 Core RAM 32GB

tauri gRPC

效能測試:tauri gRPC @debian
Debian 12 intel i7-4790 4 Core RAM 16GB
效能測試:tauri gRPC @win10
win 10 intel i7-9700 8 Core RAM 32GB

tauri embeded

效能測試:tauri embeded @debian
Debian 12 intel i7-4790 4 Core RAM 16GB
效能測試:tauri embeded @win10
win 10 intel i7-9700 8 Core RAM 32GB

測試小結

其中REST API,不知道為什麼,在windows環境下第一次會慢很多,我們略過第一筆不看,但總的來說,gRPC 還是比REST快,只是在linux環境下差異比較不明顯,如果資料量大的話,可能也許會有不同的結果。

這樣比較下來,得出不專業的結論,gRPC真的比較快,傳輸量我一時找不到比較好的方法測試,不過可想而知,如果我們傳的資料量多的話,JSON格式會很胖,除了一直重覆欄位名稱,而且資料都轉為字串了。gRPC就照著proto定義的順序放資料,在解析的時候才把欄位加上,所以傳輸量推測依不同的資料結構會有不同的節省幅度。

而離線版本的部分,無論是使用wasm或是寫在tauri裡的rust,因為都不需要進行網路的傳輸,所以還是比較快的。結論就是儘可能把後端往前端裡塞就對了(無誤)。

gRPC-WEB

gRPC 就我找到的資料,對於瀏覽器來說不是很友善,找到的一個solution是grpc-web,但它是透過proxy方式(中文說明),示意圖如下:

gRPC-WEB 架構圖

From: https://blog.envoyproxy.io/envoy-and-grpc-web-a-fresh-new-alternative-to-rest-6504ce7eb880

看起來目前web環境無法使用原生gRPC,那麼web版本除了REST API外,還有沒有其他選擇呢,其實有的,接下來打算使用websocket來試試,讓SPA前端可以有機會接另一條連線通道,不過websocket不像REST APi是以請求/回應為基礎,而是連線後一直保持著連線,隨時可進行雙向即時通信,因為連續是持續著,所以在帶入websocket之前,我們可能需要先了解一下rust裡如何處理多執行緒,所以先預告下一篇要講async runtime。


上一篇
20 gRPC初探:Hello world from rust tonic
下一篇
22 是 await 我加了await:rust async runtime ー tokio
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言