咦,到現在都還沒讓主角Tauri上場,(Tauri:我不只是路過的啊,往前站了不只一點點)。
我們先把tauri當成前端來使用,所以試著用rust寫一個http Client,tauri文件裡有提到reqwest,也算rust裡滿有名的http client之一,既然tauri都使用它了,我們要寫在tauri裡面,也跟著搭便車使用吧,直接在Cargo.toml把 reqwest
加進來:
@@ Cargo.toml @@
[workspace.dependencies]
dotenvy = { version = "0.15" }
rand = { version = "0.8" }
+reqwest = { version = "0.11" }
@@ app/src-tauri/Cargo.toml @@
[dependencies]
+reqwest = { workspace = true }
tauri = { version = "1.4", features = ["shell-open"] }
+serde = { workspace = true }
-serde = { version = "1.0", features = ["derive"] }
+serde_json = { workspace = true }
-serde_json = "1.0"
看到tauri裡我們當初安裝的template也預設有裝serde
和serde_json
,順便把它改成跟workspace同版本。
加好了來試一下吧,我們直接看reqwest的範例,reqwest需要tokio,我們workspace已經有了,補到tauri的Cargo.toml裡就好,另外範例的reqwest有開json功能,我們也開起來:
@@ Cargo.toml @@
rand = { version = "0.8" }
+reqwest = { version = "0.11", features = ["json"] }
-reqwest = { version = "0.11" }
@@ app/src-tauri/Cargo.toml @@
serde_json = { workspace = true }
+tokio = { workspace = true }
我們照樣照句,在main裡照著旁邊的範例寫起來:
// app/src-tauri/src/main.rs
use std::collections::HashMap;
#[tauri::command]
async fn http_test() -> Result<(), Box<dyn std::error::Error>> {
let resp = reqwest::get("https://httpbin.org/ip")
.await?
.json::<HashMap<String, String>>()
.await?;
println!("{:#?}", resp);
Ok(())
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
greet,
http_test, // 這裡要把我們設的command加進來,前端才能呼叫
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
又開始要解任務了,這裡說放到tauri裡的command不認我們fn的簽章,看一下官方文件的說明,command回傳的型別要實作serde::Serialize
這個trait,並且他也提供範例,我們直接照著做一個Error:
// app/src-tauri/src/main.rs
#[derive(Debug, thiserror::Error)]
enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
}
// we must manually implement serde::Serialize
impl serde::Serialize for Error {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
#[tauri::command]
async fn http_test() -> Result<(), Error> { // 改成上面的Error
記得要在tauri裡裝thisError
tauri = { version = "1.4", features = ["shell-open"] }
+thiserror = { workspace = true }
serde = { workspace = true }
這個之前有發生過,就是我們在做Error mapping的去套From這個trait,不過reqwest提供的範例好像可以照樣造句,我們試試看:
@@ app/src-tauri/src/main.rs @@
enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
+ #[error(transparent)]
+ Http(#[from] reqwest::Error),
}
編譯通過了,我們來看一下,從前端測試一下,不過前端可能要先做一個按鈕去觸發,我們一樣在about裡poc,之前放的Greet還留著舊的呼叫tauri範例,就照著複刻一組:
<!-- app/src/lib/Greet.svelte -->
<script lang="ts">
import { invoke } from '@tauri-apps/api/tauri';
// ...略
const test_api = async () => {
const res = await invoke('http_test');
console.log(res);
}
</script>
<button
class="bg-blue-400 hover:bg-blue-500 text-white font-bold py-2 px-4 rounded"
on:click={test_api}> 測試tauri reqwest
</button>
按下去看看:
有印出東西來,看起來work了。
開始重組我們的tauri結構,先把error移走,然後新增api client檔案:
app/src-tauri/src/
├── error.rs
├── main.rs
└── tic_tac_toe
├── mod.rs
└── rest_api.rs
// app/src-tauri/src/main.rs
mod error;
// app/src-tauri/src/error.rs
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Http(#[from] reqwest::Error),
}
impl serde::Serialize for Error {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
BJ4,再來刪掉剛剛的test並建立CRUD的API:
@@ app/src-tauri/src/main.rs @@
+mod tic_tac_toe;
-use std::collections::HashMap;
-use error::Error;
-#[tauri::command]
-async fn http_test() -> Result<(), Error> {
- let resp = reqwest::get("https://httpbin.org/ip")
- .await?
- .json::<HashMap<String, String>>()
- .await?;
- println!("{:#?}", resp);
- Ok(())
-}
...
greet,
- http_test,
])
api 要引用核心的Game
結構體,所以引入core的引用,有了上次撞名的經驗,這次直接先取用不同名字:
@@ app/src-tauri/Cargo.toml @@
[dependencies]
+my-core = { path = "../../core", package = "core" }
之後可能會加其他東西,先把rest_api獨立拆出一個rs檔案
// app/src-tauri/src/tic_tac_toe/mod.rs
pub mod rest_api;
先照著剛剛的api改一下,先實作兩個get_game
和new_game
,get照剛剛範例使用reqwest::get方法即可,但post要用reqwest::Client,我們看以下怎麼寫的:
// app/src-tauri/src/tic_tac_toe/rest_api.rs
use crate::error::Error;
use my_core::tic_tac_toe::Game;
#[tauri::command] // 要給 WebView 呼叫,要加這個
pub async fn get_game(id: usize) -> Result<Game, Error> {
let url = format!("http://localhost:3030/tic_tac_toe/{}", id);
let game = reqwest::get(url) // url 需要串網址放id,使用format組字串
.await? // 這裡會得到 reqwest::Response 的結構體
.json::<Game>() // 把Response用json反序列化為Game結構體
.await?; // 解析Json是Future<完畢取得結果
Ok(game) // 以上?會把Error外拋,若沒有,這裡會回傳game結構體
}
#[tauri::command]
pub async fn new_game() -> Result<(isize, Game), Error> {
let url = format!("http://localhost:3030/tic_tac_toe");
let client = reqwest::Client::new(); // 建立 client
let game = client.post(url) // 設定post及網址
.send() // 送出request要求
.await? // await取得 Result<Response, Error>
.json::<(isize, Game)>() // 維持原API的輸出讓前端API介面一致
.await?; // Result<(isize, Game)>,
Ok(game)
}
在 rust 裡非同步的使用, await 其實是接在 Future trait後面,而async會把回傳值用
impl Future<T...>
包起來,await會等待Future得到結果才會繼續往下處理,在等待的過程(透過tokio runtime的協調,不會造成整個程式hang住)。
這裡先專注在把後端(前端的後端)寫對後,再來處理前端(前端的前端),所以還是要到前端呼叫一下,先在main補上tauri command的註冊:
// app/src-tauri/src/main.rs
use tic_tac_toe::rest_api::{get_game, new_game};
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
greet,
get_game, new_game, // 這裡加入我們剛剛寫的api client
])
// app/src/lib/Greet.svelte
<script lang="ts">
const get_game = async (id) => {
const res = await invoke('get_game', {id});
console.log(res);
}
const new_game = async () => {
const res = await invoke('new_game');
console.log(res);
}
</script>
<button
class="bg-blue-400 hover:bg-blue-500 text-white font-bold py-2 px-4 rounded"
on:click={() => get_game(1)}>
Get Game
</button>
<button
class="bg-blue-400 hover:bg-blue-500 text-white font-bold py-2 px-4 rounded"
on:click={() => new_game()}>
NEW Game
</button>
接著到我們Tauri裡的about頁面,開啟開發人員視窗,按一下這兩個按鈕
開啟開發人員工具,檢視是否有正確輸入呼叫API後的資料
看起來是ok的,記得小步完成時,git 先 stage起來或進行commit後再繼續往下。
我們把剛剛get_game的數字調成一個不存在的數,結果就理所當然的報錯。
@@ app/src/lib/Greet.svelte @@
+ on:click={() => get_game(1111)}>
- on:click={() => get_game(1)}>
官方有說明前端呼叫Tauri的錯誤要怎麼處理,rust 的 Error回傳到前端會是Promise的reject,記得之前建議要去看的鐵道模型嗎:
圖片來源:https://fsharpforfunandprofit.com/posts/recipe-part2/
原圖有點小,我放大一下:
我知道很多朋友沒有時間去看,在這裡簡單扼要說明一下:原圖中的三個function,上面的通道是正確的路徑,下面的通道是錯誤的路徑,所以每個function接到前手的輸入後,如果是錯誤,就直接把錯誤交給下一棒(以我們的案例可能直接傳或做error mapping後再傳),如果正確輸入就依處理後的結果,如果正確就繼續走上面的通道,如果執行出現錯誤就會轉到下面的通道。
我們在tauri裡的錯誤要配合後端web api傳遞,api回傳的資料是JSON,已經是序列化後的資料,所以我們在這裡序要再進行序列化為rust的物件,我們加上一個錯誤類別:
// app/src-tauri/src/error.rs
#[derive(serde::Serialize, serde::Deserialize)] // 要能轉json
pub struct ErrorResponse { // 配合後端api的錯誤訊息建立
message: String,
details: Option<String>,
}
impl From<reqwest::Error> for ErrorResponse { // 加這個才能用語法糖?來簡化寫法
fn from(value: reqwest::Error) -> Self {
ErrorResponse {
message: "主機連線失敗".into(),
details: Some(value.to_string()),
}
}
}
修改tauri command 的錯誤處理:
// app/src-tauri/src/tic_tac_toe/rest_api.rs
use crate::error::{Error, ErrorResponse};
#[tauri::command]
pub async fn get_game(id: usize) -> Result<Game, ErrorResponse> {
let url = format!("http://localhost:3030/tic_tac_toe/{}", id);
let game = reqwest::get(url).await?; // Response
if game.status().is_success() { // 2xx的代碼代表成功
let game = game.json::<Game>().await?;
Ok(game) // 成功回傳 game 物件
} else {
let error = game.json::<ErrorResponse>().await?;
Err(error) // 失敗則解析後端api回傳的body訊息再傳給前端js
}
}
調整typescript處理呼叫API的錯誤訊息:
// app/src/lib/Greet.svelte
const get_game = async (id) => {
try {
const res = await invoke('get_game', {id});
console.log(res); // await執行成功,印出正確結果
} catch (e) {
console.log(e); // 執行失敗,接到error印出
}
}
以上把前後端對應的錯誤處理一併寫好,再試試看:
現在正確和錯誤都可以正確解析了,繼續加api:
// app/src-tauri/src/tic_tac_toe/rest_api.rs
#[tauri::command]
pub async fn play_game(id: usize, num: usize) -> Result<Game, ErrorResponse> {
let url = format!("http://localhost:3030/tic_tac_toe/{id}/{num}");
let client = reqwest::Client::new();
let game = client.put(url).send().await?;
if game.status().is_success() {
let game = game.json::<Game>().await?;
Ok(game)
} else {
let error = game.json::<ErrorResponse>().await?;
Err(error)
}
}
寫完後發現後半段的if
區塊都是重覆的代碼,能不能縮減呢,先抽fn如下:
async fn unwrap_game(game: reqwest::Response) -> Result<Game, ErrorResponse>
{
if game.status().is_success() {
let game = game.json::<Game>().await?;
Ok(game) // 這裡沒分號,表回傳值
} else {
let error = game.json::<ErrorResponse>().await?;
Err(error) // 這裡沒分號,表回傳值
} // 這裡沒分號,整個 if 的結果就是這一整個fn的回傳值
}
不過這個fn只有play_game
和get_game
可以使用,new_game
的類別是(isize, Game)
不一樣,無法使用,但是又跟這很像,能不能有方法一起用呢,有的,就是泛型,我們使用泛型來改寫這個fn,泛型的語法是用<T>
:
// <T> 要加在 function 名稱後,隨後可使用在fn的參數類、回傳類,或fn裡面使用的到的類
async fn unwrap_game<T>(game: reqwest::Response) -> Result<T, ErrorResponse>
{
if game.status().is_success() {
let game = game.json::<T>().await?; // 這裡使用 T
Ok(game)
} else {
let error = game.json::<ErrorResponse>().await?;
Err(error)
}
}
這裡出現了錯誤,因為我們在內文中使用json::<T>
,所以這裡要求我們設的T
類別必需要符合json
方法裡的T限制,我們可以用where
條件來寫泛型的限制:
async fn unwrap_game<T>(game: reqwest::Response) -> Result<T, ErrorResponse>
where T: serde::de::DeserializeOwned
調整完最後的結果如下:
// app/src-tauri/src/tic_tac_toe/rest_api.rs
use crate::error::ErrorResponse;
use my_core::tic_tac_toe::Game;
#[tauri::command]
pub async fn new_game() -> Result<(isize, Game), ErrorResponse> {
let url = format!("http://localhost:3030/tic_tac_toe");
let client = reqwest::Client::new();
let game = client.post(url).send().await?;
unwrap_game(game).await
}
#[tauri::command]
pub async fn get_game(id: usize) -> Result<Game, ErrorResponse> {
let url = format!("http://localhost:3030/tic_tac_toe/{}", id);
let game = reqwest::get(url).await?;
unwrap_game(game).await
}
#[tauri::command]
pub async fn play_game(id: usize, num: usize) -> Result<Game, ErrorResponse> {
let url = format!("http://localhost:3030/tic_tac_toe/{id}/{num}");
let client = reqwest::Client::new();
let game = client.put(url).send().await?;
unwrap_game(game).await
}
#[tauri::command]
pub async fn delete_game(id: usize) -> Result<(), ErrorResponse> {
let url = format!("http://localhost:3030/tic_tac_toe/{id}");
let client = reqwest::Client::new();
client.delete(url).send().await?.text().await?;
Ok(())
}
async fn unwrap_game<T>(game: reqwest::Response) -> Result<T, ErrorResponse>
where T: serde::de::DeserializeOwned
{
if game.status().is_success() {
let game = game.json::<T>().await?;
Ok(game)
} else {
let error = game.json::<ErrorResponse>().await?;
Err(error)
}
}
// app/src-tauri/src/main.rs
use tic_tac_toe::rest_api::{get_game, new_game, play_game, delete_game};
// ...略
.invoke_handler(tauri::generate_handler![
// ...
get_game, new_game, play_game, delete_game,
泛型方法的呼叫是mymethod::<T>(params)
,但上面我們在呼叫使用的時候,都寫一樣也沒特別寫明T的類別,這是因為rust會自動判斷回傳的類別,去推斷T要放什麼,而幫我們放上去。要注意編譯器推斷和動態產生是兩件不同的事情,rust其實是在編譯的時候,依情境自動幫我們補上缺漏的程式碼如下:
unwrap_game::<(isize, Game)>(game).await
unwrap_game::<Game>(game).await
API好了,那我們來改寫前端,接接看剛剛寫好的tauri api,首先在api裡追加呼叫tauri的部分:
// app/src/api/tic_tac_toe.ts
import { invoke } from "@tauri-apps/api/tauri";
const getGameTauri = async (id: number): Promise<GameSet> => {
try {
const game = await invoke('get_game', { id }); // { id:id } 縮寫
return [id, game as Game]; // 組 GameSet
} catch (e) { // 補捉rust的Err(e)
return Promise.reject(e);
}
};
const newGameTauri = async (): Promise<GameSet> => {
try {
const gameSet = await invoke('new_game'); // 無參數
return gameSet as GameSet;
} catch (e) {
return Promise.reject(e);
}
};
const playGameTauri = async (id: number, num: number): Promise<GameSet> => {
try {
const game = await invoke('play_game', { id, num }); // 兩個參數
return [id, game as Game];
} catch (e) {
return Promise.reject(e);
}
};
const deleteGameTauri = async (id: number): Promise<void> => {
try {
await invoke('delete_game', { id });
} catch (e) {
return Promise.reject(e);
}
};
export const ticTacToeApiTauri: TicTacToeApi = { // 實現與http同樣介面
deleteGame: deleteGameTauri,
getGame: getGameTauri,
newGame: newGameTauri,
play: playGameTauri,
};
與之前寫http api呼叫很像,只是這裡統一改成呼叫tauri的api,透過tauri提供的invoke
呼叫我們在rust裡寫好的程式,呼叫的名稱是和rust裡的fn同名,如果有參數,則使用json
物件的方式傳遞,如{ name : 'hello', age: 3 }
,要注意的是傳遞的參數必需要能被轉型成該rust的類別,不然會發生恐慌。
這邊我們套用相同的interface,如此就可以透過不同的執行環境進行抽換,比如瀏覽器端就使用http呼叫,tauri app就呼叫tauri api。在api統一接口的部分我們再定義一個tauri的api:
@@ app/src/api/index.ts @@
+import { ticTacToeApiTauri } from './tic_tac_toe';
-import { ticTacToeApi } from './tic_tac_toe';
...
export const api: Api = {
+ ticTacToe: ticTacToeApiTauri,
- ticTacToe: ticTacToeApi,
};
定義好了之後到我們的遊戲頁面:
這裡import api
改成我們剛剛寫好的tauriApi
,然後在本檔案裡別成alias成原本的名稱api,所以下面的代碼都不需要動,就完成抽換了(有沒有感受到interface的威力了,請愛用interface),實測如下,可正常操作遊戲,新遊戲與刪除。
什麼,有人不相信已經換成呼叫tauri的command了,那麼可以試著開啟瀏覽器開發工具,看看網路是不是都沒有http request/response了,空空如也,這下總該相信了吧。
不過當我們要執行「跳至第N筆」的時候就報錯了:
出現一個undefined,怎麼會這樣呢,打開console看看:
怎麼也跟著空空如也,什麼訊息都沒有是要怎麼偵錯啊 (╯°Д°)╯ ┻━┻ (╯°O°),換個招式(有人說程式設計師要十八般武藝樣樣精通,等練到了一定程度,還可以通靈喔 ^.< ),我們在goto
裡加上console.log試試看:
@@ app/src/routes/tic_tac_toe/+page.svelte @@
const goto = async (id: number) => {
error = null;
try {
gameSet = await api.ticTacToe.getGame(id);
} catch (e) {
+ console.log(e);
error = e as ErrorResponse;
}
這次有訊息了,而且很清楚,原來是在呼叫tauri裡的get_game
時,id
參數只能接收usize
,但我們傳遞的是string
"2"
。(怎麼這麼笨不會自己轉,學學javascript好嗎)。就說rust是相對比較嚴格且安全的語言,所以我們只能配合修改。如果是初學者的話,說明一下這裡接收到網頁端的輸入,基本上格式都是字串,如果想要數字的話要進行轉型。
畫面中會出現undefined
是因為我們把收到的錯誤直接指派給error = e as ErrorResponse;
,但它不是我們在rust包好的ErrorResponse
,所以沒有message
和details
欄位,才造成剛剛畫面上無法正確顯示。那我們是不是要再把這個錯誤訊息包起來給用戶端呢?不過在這邊我不太想包這個錯誤,為什麼呢?因為這個錯誤訊息本來就不是要給玩家看的呀,是給我們這些苦力程式設計師看的,這麼嚴重的問題怎麼可以上線(?),所以我們的工作就是確保把這類的問題修好。
i.e. panic 主要給開發人員看的,要給user看的Error會另外包一個Error型別處理。
有沒有體會到rust的好了,型別錯錯直接告訴,不會等程式在跑的時候時不時就噴undefined。
其實還有一種方法,就是把這個input
的type設定成"number",但整個會變的很醜有點不太好接受(明明現在畫面也沒多好看(?)。所以我們用另一種方式,把svelte的雙向綁定改成兩個單向綁定。(有點像在vue裡要自己實作v-model也是拆成@update
和:
)
加上type為number會出現上下的小三角
@@ app/src/routes/tic_tac_toe/+page.svelte @@
+value={id} on:input={(e) => {id = Number(e.target.value)}}
-bind:value={id}
value={id}
:單向綁定id
這個變數,當id的值發生變化時,會自動更新我們input
元件的value
,這裡是往input
元件內的綁定。on:input
:當觸發input
元件的輸入事件時,會呼叫其指定的function,這裡的e是輸入的事件InputEvent,我們直接在這裡寫Inline程式,把id設定為修改事件的value,這裡是從input元件往外的綁定。測試可以正常跳動了,只是一開始undefined怪怪的,我們先賦予它一個初始值:
@@ app/src/routes/tic_tac_toe/+page.svelte @@
+let id: number = 1;
-let id: number;
在tauri裡面我們試著跑CRUD,基本上都沒問題了,我們用瀏覽器開一下:
什麼是window.__TAURI_IPC__ is not a function
,IPC是inter-process-commuication,這個__TAURI_IPC__
主要是tauri拿來呼叫rust程式使用的一個接口,使用類似JSON-RPC的格式調用呼叫與回應。
既然是呼叫rust程式的,那麼在瀏覽器裡面當然呼叫不到,所以自然而然地報錯。我們可以透過不同的環境來選不同的api,假設今天跑在瀏覽器,就用我們一開始做的ky
呼叫http api,如果跑在tauri,就呼叫tauri IPC,具體設法如下:
export const httpApi: Api = { // 把之前 api rename 成 httpApi 區別 tauri
ticTacToe: ticTacToeApi,
};
export const tauriApi: Api = { // 這部分沒有動
ticTacToe: ticTacToeApiTauri,
};
export const api: Api = window.__TAURI_IPC__ ? tauriApi : httpApi;
最後輸入的api,會依執行環境,判斷如果存在Tauri IPC就呼叫tauri,不然就呼叫ky的rest api。好了,瀏覽器跟tauri兩個版本目前都可以使用了。
有沒有在次感受到抽介面的好處!
此系列文章會儘可能同時兼用不同的技術達到相類似的效果,讓大家在現實的案例中可以有更多的選擇:比如我們用js 呼叫http api最簡單,如果要構建前後端分離可逕行採用;如果今天我們不想把js暴露給前端,那麼可以使用tauri包成桌面版應用。tauri發佈時可以設定要不要關閉devtool,所以對用戶來說就不能按F12偷看js代碼了。
那包在tauri裡我們也可以用http呼叫啊,為什麼透過tauri的rust還多繞一層?其實像一開始我們http api遇到的CORS policy,因為後端也是我們寫所以可以在後端的回應追加處理,如果遇到後端是別人寫那麼直接用rust http client就不用走CORS。或者是有些後端如果採用https可能會有憑證信任的問題,但在tauri裡我們的httpClient是用rust寫的,可以依我們的需求設定憑證的規則,相對比較彈性,甚至我們之後也可以使用gRPC等不同的後端通訊技術。
使用tauri另外還有個優勢,可以參考邦友寫的瀏覽器的安全模型,裡面有一句重點:「瀏覽器不給你的,你拿不到,拿不到就是拿不到」,所以今天我們使用tauri開發的桌面版應用,可以把tauri當作是前端的後端,既然是後端,那麼後端可以做的事理論上它都可以做(?)。而就畫面顯示的部分(前端的前端),仍然應用現存既有的前端框量,方便快速建構使用者介面(UI)。
不是說要把後端塞進前端嗎,這樣講好像也太混(?),好啦這裡只是先預告一下,希望後面可以補完 XDDD。
本系列專案源始碼放置於 https://github.com/kenstt/demo-app