大家對於如何使用gRPC,應該有些概念了,其實就跟restAPI呼叫很像,只是要多寫很多東西,然後很多方法或結構體也不知道去哪找(?)。以現階段來說,我用RustRover或VS Code對於自動產生的code有時候都還不夠聰明,或是有時無法即時更新,所以可提示或自動完成的部分相對比較少,常常需要手動寫。這時候有copilot其實會好很多,大概五六成七八成可以對,再修修改改就好了,以下我們一起照著前一篇刻好的game.proto,試著完成前後端gRPC的通訊。
我們一樣先試著完成後端的部分,先打開一個終端畫面,把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看會不會掃到(誤)
接下來要加入我們的應用層TicTacToeGrpcService
,tonic雖然也有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
結構體:
補完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))
}
接下來就是見證奇蹟的時刻,噢不是,是見一堆紅色錯誤的時刻:
出現的2個錯誤都是mapping的問題:
Status
,Game
要mapping到gRPC產生出來的Game
。From<T>
trait 的使用我們之前使用From trait的時候,有符合rust裡的規定:
而現在我們要實作的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文件指出我們應該要儘可能使用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結構體,咦,那核心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。
第一個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端的部分。
先在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;
確認我們client的channel寫對了,就可以繼續往下補完我們的CRUD。
首先加入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())
}
},
}
}
}
苦工做完了,接下來是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';
測試依然是沒有問題:
網頁版也沒有問題:
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會再慢一點點)。
Debian 12 intel i7-4790 4 Core RAM 16GB
win 10 intel i7-9700 8 Core RAM 32GB
Debian 12 intel i7-4790 4 Core RAM 16GB
win 10 intel i7-9700 8 Core RAM 32GB
Debian 12 intel i7-4790 4 Core RAM 16GB
win 10 intel i7-9700 8 Core RAM 32GB
Debian 12 intel i7-4790 4 Core RAM 16GB
win 10 intel i7-9700 8 Core RAM 32GB
其中REST API,不知道為什麼,在windows環境下第一次會慢很多,我們略過第一筆不看,但總的來說,gRPC 還是比REST快,只是在linux環境下差異比較不明顯,如果資料量大的話,可能也許會有不同的結果。
這樣比較下來,得出不專業的結論,gRPC真的比較快,傳輸量我一時找不到比較好的方法測試,不過可想而知,如果我們傳的資料量多的話,JSON格式會很胖,除了一直重覆欄位名稱,而且資料都轉為字串了。gRPC就照著proto定義的順序放資料,在解析的時候才把欄位加上,所以傳輸量推測依不同的資料結構會有不同的節省幅度。
而離線版本的部分,無論是使用wasm或是寫在tauri裡的rust,因為都不需要進行網路的傳輸,所以還是比較快的。結論就是儘可能把後端往前端裡塞就對了(無誤)。
gRPC 就我找到的資料,對於瀏覽器來說不是很友善,找到的一個solution是grpc-web,但它是透過proxy方式(中文說明),示意圖如下:
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。