上一篇實作了tauri中間人的角色,承上(Svelte)啟下(Web api),我們先整裝一下再出發:
第一個問題是compiler判斷 window.__TAURI_IPC__
恆存在,是因為在compiler的環境有定義,但我們打包成前端SPA的話,在瀏覽器的執行環境其實會找不到。像我們昨天在測會報錯一樣,這裡直接在該行前面加// @ts-ignore
忽略。
第二個是我們在Greet驗證呼叫tauri api的試驗未follow TypeScript的型別限制,我們已實作完,這邊直接移除我們POC的代碼。
最後兩個也是TypeScript的型別檢核,但我們在標籤語法裡好像只能放JavaScript,無法加TypeScript的type,那這裡就往上抽一個function吧:
<script>
// 略
const onInput = (e: Event) => {
const target = e.target as HTMLInputElement;
id = Number(target.value);
};
</script>
<input
value={id} on:input={onInput}
/>
往上提function大家都會,問題是type要放什麼,一開始從console印出來的Type是InputEvent
,如果照放的話TypeScript一直報錯不給過,這裡的解法是依stack overflow的解法處理。
我們在tauri裡寫http_client
時並沒有把baseUrl
抽出來,一回生二回熟,我們這次就知道怎麼加.env
了,直接加起來。
因為
.env
會被 git 忽略,所以要下載原始碼照著做的朋友,可以仿照example.env內容,自己在本地端再建立.env
檔案
@@ app/src-tauri/Cargo.toml @@
[dependencies]
my-core = { path = "../../core", package = "core" }
+dotenvy = { workspace = true }
@@ app/src-tauri/.env @@
+API_BASE_URL=http://localhost:3030/
設好後就可以改寫baseurl了:
let url : Url = env::var("API_BASE_URL")
.unwrap_or("http://localhost:3030/".to_string())
.parse()
.unwrap();
let url = url.join(&format!("tic_tac_toe/{}", id)).unwrap();
NOTE: rust套件Url的join方法裡 有提到網址末端的/
是重要的,base url會以最後的 /
為baseUrl,以下舉例方便理解:
http://localhost:3030/api/
join hello
=> http://localhost:3030/api/hello
http://localhost:3030/api
join hello
=> http://localhost:3030/hello
http://localhost:3030/api/
join /hello
=> http://localhost:3030/hello
雖然我們改成取用環境變數,但好像還是有點問題,一來是改寫了反而變很長,二來是我們會用到的頻率很高,不像之前在後端web
設定log level
只會在程式啟動時讀取一次。所以我們可能需要再往上提一層,找個地方存放baseurl
,並在整個程式裡共用。tauri提供Manager來管理State,就像Vue的Pinia,或axum的extractor,或C#的IOption。
另外還有一項東西也要放state,我們一併處理,就是http client。啊不就呼叫api而已嗎,因為http在建立連線時,底層要先進行三向交握(3 way handshake),這個往返會消耗時間,另外如果是在主機端,可能會遇到埠號耗盡的議題,微軟對於HttpClient的指導方針也有提到連線池(Connection Pool)的說法,查了一下我們用的reqwest::Client有提到「The Client holds a connection pool internally」,太好了,我們不用自己刻,直接拿起來用就好。
首先加一個context物件放我們的程式所需的state狀態物件:
// app/src-tauri/src/main.rs
mod context;
use crate::context::Context;
在context中加入我們要管理的物件,包括API base url和http client:
// app/src-tauri/src/context.rs
use std::env;
use reqwest::{Client, Url};
pub struct Context {
base_url : Url, // base Url
http_client: Client, // 有連線池的http client
}
impl Context {
pub fn load() -> Self {
let base_url = env::var("API_BASE_URL")
.unwrap_or("http://localhost:3030/".to_string());
let base_url = Url::parse(&base_url).unwrap();
let http_client = Client::new();
Self {
base_url,
http_client,
}
}
pub fn base_url(&self) -> &Url { // getter的概念
&self.base_url
}
pub fn http_client(&self) -> &Client { // getter
&self.http_client
}
}
接著在main裡註冊(注入)我們的context:
// app/src-tauri/src/main.rs
fn main() {
dotenvy::dotenv().ok(); // 讀取環境變數.env
let context = Context::load(); // 初始化app共享物件
tauri::Builder::default()
.manage(context) // 註冊為tauri的狀態物件
.invoke_handler(tauri::generate_handler![
// ...略
實際在我們寫的command的方法,直接在參數加入一項 state: State<T>
,就可以注入剛剛註冊的泛型狀態類別,直接看範例:
@@ app/src-tauri/src/tic_tac_toe/rest_api.rs @@
+use tauri::State;
+use crate::context::Context;
#[tauri::command]
+pub async fn new_game(ctx: State<Context>) -> Result<(isize, Game), ErrorResponse> {
-pub async fn new_game() -> Result<(isize, Game), ErrorResponse> {
+ let url = ctx.base_url().join("tic_tac_toe").unwrap();
- let url = format!("http://localhost:3030/tic_tac_toe");
- let client = reqwest::Client::new();
+ let game = ctx.http_client().post(url).send().await?;
- let game = client.post(url).send().await?;
unwrap_game(game).await
}
寫出來出現一個小問題:
上面說State<Context>
這個參數需要加生命週期參數,這是什麼呢?首先要知道一撇'
(單引號)開頭的變數,是來判斷生命週期使用的,詳細看補充資料,這裡編譯器提示在前面加一個'_
就好了,我們就比照辦理:
+pub async fn new_game(ctx: State<'_, Context>) -> ...
-pub async fn new_game(ctx: State<Context>) -> ...
照著改編譯通過了,跑起來也沒問題,所以我們繼續繼把剩下的方法也改成這個寫法:
#[tauri::command]
pub async fn get_game(id: usize, ctx: State<'_, Context>) -> Result<Game, ErrorResponse> {
let url = ctx.base_url().join(&format!("tic_tac_toe/{}", id)).unwrap();
let game = ctx.http_client().get(url).send().await?;
unwrap_game(game).await
}
#[tauri::command]
pub async fn play_game(id: usize, num: usize, ctx: State<'_, Context>) -> Result<Game, ErrorResponse> {
let url = ctx.base_url().join(&format!("tic_tac_toe/{}/{}", id, num)).unwrap();
let game = ctx.http_client().put(url).send().await?;
unwrap_game(game).await
}
#[tauri::command]
pub async fn delete_game(id: usize, ctx: State<'_, Context>) -> Result<(), ErrorResponse> {
let url = ctx.base_url().join(&format!("tic_tac_toe/{}", id)).unwrap();
ctx.http_client().delete(url).send().await?.text().await?;
Ok(())
}
command中呼叫api的變數這樣就改好了(?),好像沒改很多,那是我們有提早改,如果API上百個要改就累了,所以有些smell早早發現就快點進行優化。
我們剛剛說使用連線池比較好,不過要怎麼計算逝去的時光呢,其實可以參考rust cookbook裡的範例:
// rust cookbook 範例
use std::time::{Duration, Instant};
fn main() {
let start = Instant::now();
expensive_function();
let duration = start.elapsed();
println!("Time elapsed in expensive_function() is: {:?}", duration);
}
範例程式寫的很清楚(所以好的命名真的很重要),我們抓一個方法來測一下,就用get_game
吧,來實測看看,是不是使用connection pool真的有比較快:
@@ app/src-tauri/src/tic_tac_toe/rest_api.rs @@
pub async fn get_game(id: usize, ctx: State<'_, Context>) -> Result<Game, ErrorResponse> {
+ let start = Instant::now();
let url = ctx.base_url().join(&format!("tic_tac_toe/{}", id)).unwrap();
let game = ctx.http_client().get(url).send().await?;
+ // let game = reqwest::Client::new().get(url).send().await?;
+ let duration = start.elapsed();
+ println!("Time elapsed in expensive_function() is: {:?}", duration);
unwrap_game(game).await
}
切換兩個let game
的寫法,比較使用pool和不使用pool:
不使用連線池:
WIN11 AMD Ryzen 5950X 16 Core RAM 32GB
使用連線池:
WIN11 AMD Ryzen 5950X 16 Core RAM 32GB
可以看到雖然只是連自己電腦localhost,本地開發連本地電腦,就差這麼多了。第一次連線大約需要300ms,如果我們用連線池,後續的連線可以快約100~200倍。什麼,有人說300ms
貶眼就沒了,沒錯是對的,這裡還有統計數據說眨眼平均約0.1-0.4秒。但是,我們看一下這張圖:
這圖取自Summer。桑莫。夏天 2023 WebConf Taiwan的講稿,中間FID提到對使用者提供的反饋,需要在100ms以下,不然使用者會不耐煩 (USER在你後面 他非常火) 。所以還是不要小看這幾百豪秒的時間,重點是我們也沒有花很多力氣去做演算法的調整優化,不就改兩三行程式而已(劃錯重點?)。重點是在建構系統架構的過程,有很多的眉眉角角要學習,學到了就是經驗的累積,手上工具越多,在面臨取捨時,也比較能知道該如何選擇。
另外附帶一提,後來連線池的測試,在linux底下跑的結果跟WIN11有段落差,還不小心跑出一個mu(μ)符號,這是微秒的意思,也順便提供給大家參考一下:
沒有連線池:
Debian 12 intel i7-4790 4 Core RAM 16GB
有連線池:
Debian 12 intel i7-4790 4 Core RAM 16GB
還記得剛剛的生命週期變數'_
嗎,生命週期的說明需要一些篇幅,下一篇再來和大家探討,這裡先整理一下我們小專案開發至今的一些想法和脈絡。
目前我們專案架構約略如下圖:
左半邊前端的svelte與後端web和大家以往熟悉的前後端分離沒什麼太大區別;而右半邊的tauri夾在前後端之間,所以定位是什麼?我覺得在此可以用不同的角度去解釋,如果我們把業務邏輯編譯進tauri裡,那麼Tauri就是後端的概念;如果業務核心邏輯還是放置於web端,那麼Tauri此時就是前端的角色。
筆者在工作上曾遇到前端有比較複雜的計算情境,因為是屬於業務邏輯所以放在後端,但因會有頻繁呼叫的需求,所以衍生一些效能或UI/UX設計上面的困擾。也曾直接用TypeScript再複刻一套邏輯,可是這樣好像又違反了業務領域分層的概念(?),一直苦無比較好的解法。
而在接觸rust之後,除了驚豔其簡潔的語法,強大的enum設計,且rust自帶輕易編譯成WebAssembly的能力,或許是個更能最佳實踐領域驅動設計的技術。我們一樣可以把領域邏輯寫在 core(domain layer) 層,但編譯完可以包進tauri供前端調用(桌面應用),或是包進wasm供前端(瀏覽器應用)程式呼叫。進而在體現業務分層之餘,又能帶來額外節省頻寬,效能損耗等效益。
所以才起心動念參與此次鐵人賽,希望在這系列,能實踐一些技術選型的可能,一邊也和大家分享rust語言的優勢及特性,讓大家未來在技術選型時能多一些不同的選擇。
等等,有人說把程式碼放到前端,這樣不就是DDD領域驅動設計裡面的反模式SmartUI嗎。有看過把SQL寫在UI裡串接input元件的嗎?這種UI才是「真。Smart」。DDD分層(或是乾淨架構分層)我覺得一個很重要的概念是跨層間相依性的處理,核心領域層理論上應該對資料庫或是畫面結構保持無知狀態。
SmartUI寫在畫面中的Function或View或Controller,可能同時會知道(資料庫的長相、Entity的長相、UI有哪些元件、業務處理的邏輯、業務檢核的邏輯)等,或是把UI元件的值傳進Entity的Method裡,這明顯是反模式。
而我的想法是保持核心領域邏輯的完整性,只是編譯出來的執行檔(bin, jar, dll, wasm, ...)順便複製一份放在UI端,所以相依性還是保持著領域核心不依賴外部IO。
Tauri核心是rust寫的大家應該都已經知道,tauri官網說tauri本身主要是個協調者的角色,使用WRY套件TAO處理作業系統層的工作。
行動端的開發有興趣的話可以看一下別人怎麼玩的:
Tauri架構如下:
Tauri處理好跨平台的抽象後,其間不同的WebView都透過Tauri核心進行溝通,使用Inter-Process Communication(IPC)進行通訊,其中Command的型式我們已經使用,未來會再介紹Event的使用方式。Tauri這樣設計避免了不同視窗間共享記憶體,而是傳遞訊息進行交換,是相對比較安全的設計方式。
本系列專案源始碼放置於 https://github.com/kenstt/demo-app