iT邦幫忙

2023 iThome 鐵人賽

DAY 14
0

上一篇實作了tauri中間人的角色,承上(Svelte)啟下(Web api),我們先整裝一下再出發:

pnpm check

執行pnpm check

第一個問題是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 整裝

我們在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,就像VuePinia,或axum的extractor,或C#的IOption

另外還有一項東西也要放state,我們一併處理,就是http client。啊不就呼叫api而已嗎,因為http在建立連線時,底層要先進行三向交握(3 way handshake),這個往返會消耗時間,另外如果是在主機端,可能會遇到埠號耗盡的議題,微軟對於HttpClient的指導方針也有提到連線池(Connection Pool)的說法,查了一下我們用的reqwest::Client有提到「The Client holds a connection pool internally」,太好了,我們不用自己刻,直接拿起來用就好。

Tauri State Management

首先加一個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
 }

寫出來出現一個小問題:
rust編譯器提醒需要加生命週期變數'_

上面說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早早發現就快點進行優化。

http connection pool 效能測試

我們剛剛說使用連線池比較好,不過要怎麼計算逝去的時光呢,其實可以參考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測試沒有連線池連線結果
WIN11 AMD Ryzen 5950X 16 Core RAM 32GB

使用連線池:

WIN11測試使用連線池連線結果
WIN11 AMD Ryzen 5950X 16 Core RAM 32GB

可以看到雖然只是連自己電腦localhost,本地開發連本地電腦,就差這麼多了。第一次連線大約需要300ms,如果我們用連線池,後續的連線可以快約100~200倍。什麼,有人說300ms貶眼就沒了,沒錯是對的,這裡還有統計數據說眨眼平均約0.1-0.4秒。但是,我們看一下這張圖:
Core Web Vitals
這圖取自Summer。桑莫。夏天 2023 WebConf Taiwan的講稿,中間FID提到對使用者提供的反饋,需要在100ms以下,不然使用者會不耐煩 (USER在你後面 他非常火) 。所以還是不要小看這幾百豪秒的時間,重點是我們也沒有花很多力氣去做演算法的調整優化,不就改兩三行程式而已(劃錯重點?)。重點是在建構系統架構的過程,有很多的眉眉角角要學習,學到了就是經驗的累積,手上工具越多,在面臨取捨時,也比較能知道該如何選擇。

另外附帶一提,後來連線池的測試,在linux底下跑的結果跟WIN11有段落差,還不小心跑出一個mu(μ)符號,這是微秒的意思,也順便提供給大家參考一下:

沒有連線池:

linx測試沒有連線池連線結果
Debian 12 intel i7-4790 4 Core RAM 16GB

有連線池:

linx測試使用連線池連線結果
Debian 12 intel i7-4790 4 Core RAM 16GB

還記得剛剛的生命週期變數'_嗎,生命週期的說明需要一些篇幅,下一篇再來和大家探討,這裡先整理一下我們小專案開發至今的一些想法和脈絡。

Tauri架構說明

目前我們專案架構約略如下圖:
tauri專案簡易架構圖

左半邊前端的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

Tauri核心是rust寫的大家應該都已經知道,tauri官網說tauri本身主要是個協調者的角色,使用WRY套件TAO處理作業系統層的工作。

  • WRY:跨平台的 WebView 渲染器接口,依不同平台使用不同的WebView,Linux環境使用WebKitGTK,macOS環境使用WKWebView,而Windows環境使用Microsoft Edge WebView2
  • TAO:跨平台圖形介面(視窗)處理,支援Windows, macOS, Linux, iOS and Android等作業系統。

行動端的開發有興趣的話可以看一下別人怎麼玩的:

Tauri架構如下:
Tauri核心與WebView通訊架構圖
Tauri處理好跨平台的抽象後,其間不同的WebView都透過Tauri核心進行溝通,使用Inter-Process Communication(IPC)進行通訊,其中Command的型式我們已經使用,未來會再介紹Event的使用方式。Tauri這樣設計避免了不同視窗間共享記憶體,而是傳遞訊息進行交換,是相對比較安全的設計方式。

參考資料

本系列專案源始碼放置於 https://github.com/kenstt/demo-app


上一篇
13 Tauri 該你上場了 - rust 桌面應用
下一篇
15 rust 生命週期變數
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言