iT邦幫忙

2023 iThome 鐵人賽

DAY 12
0
Software Development

前端? 後端? 摻在一起做成全端就好了系列 第 12

12 讓前端再好一點點 let Svelte co-work with rust

  • 分享至 

  • xImage
  •  

前一篇把前端完成了,本篇來優化一下內容。

讓code儘可能的乾淨

一般開發都是先求有,再求好,功能完成了之後,先來清理一下,看一下 我們之前寫的code有多髒 package.jsonscript有什麼,看起來sveltekit已經幫我們設好 checklint,比較一下check主要是在檢查svelte的寫法,lint就是在提示一致的code style,而這裡還有提供format自動幫我們修正style:

"scripts": {
    "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
    "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
    "lint": "prettier --plugin-search-dir . --check . && e .",
    "format": "prettier --plugin-search-dir . --write .",

先執行check看看:

~/demo-app/app$ pnpm check
> app@0.0.0 check /home/demo-app/app
> svelte-kit sync && svelte-check --tsconfig ./tsconfig.json


====================================
Loading svelte-check in workspace: /home/demo-app/app
Getting Svelte diagnostics...

// 略,訊息有點長,以下分段描述

====================================
svelte-check found 6 errors and 0 warnings in 2 files
 ELIFECYCLE  Command failed with exit code 1.

第一段訊息,是指我們之前寫在Greet.svelte裡面的程式不符typescript的要求:

/home/demo-app/app/src/lib/Greet.svelte:12:7
Error: Variable 'data' implicitly has type 'any[]' in some locations where its type cannot be determined. (ts)

  let data = [];
  const callMyApi = async () => {


/home/demo-app/app/src/lib/Greet.svelte:29:12
Error: Variable 'data' implicitly has an 'any[]' type. (ts)
  <ul>
    {#each data as item}
      <li>{item.employee_name}</li>

這裡相關呼叫example api的代碼不需要了,直接revert我們之前加範例api的commit即可。再繼續往下看:

/home/demo-app/app/src/routes/tic_tac_toe/+page.svelte:18:17
Error: 'e' is of type 'unknown'. (ts)
    } catch (e) {
      let msg = e.message;
      if (e.details) {


/home/demo-app/app/src/routes/tic_tac_toe/+page.svelte:19:11
Error: 'e' is of type 'unknown'. (ts)
      let msg = e.message;
      if (e.details) {
        msg += `: ${e.details}`;


/home/demo-app/app/src/routes/tic_tac_toe/+page.svelte:20:21
Error: 'e' is of type 'unknown'. (ts)
      if (e.details) {
        msg += `: ${e.details}`;
      }


/home/demo-app/app/src/routes/tic_tac_toe/+page.svelte:59:31
Error: 'this' implicitly has type 'any' because it does not have a type annotation. (ts)
      class="h-32 text-9xl text-amber-500 border-2 border-amber-500 rounded-md"
      on:click={playGame.bind(this, index+1)}
    >{symbol ?? ' '}</button>

前三個指出我們API回傳的錯誤Error是unknown類,到model裡為Error設定一個類別:

// app/src/model/tic_tac_toe.ts
export type ErrorResponse = {
  message: string;
  details?: string;
}
// app/src/routes/tic_tac_toe/+page.svelte
import type {ErrorResponse} from "../../model/tic_tac_toe";
// ...略
} catch (e: unknown) {
      let err = e as ErrorResponse;
      let msg = err.message;
      if (err.details) {
        msg += `: ${err.details}`;

而最後一項的on:click事件,因為傳的this是any,而在html裡無法嵌入typescript,只好抽出來另外寫:

/// ...略
<button
    on:click={() => playGame(index+1)}
>

執行 check 沒有問題的結果

我們把pnpm checkd 的問題都修完了。接下來換跑lint了,可是似乎跳了很多不相關的東西:

~/demo-app/app$ pnpm lint

執行lint列出很多warn

原來是因為svelte不認得rust tauri的東西,我們讓它 閉嘴 忽略tauri的部分,另外還有dist是放建置結果檔的,也一起忽略,我們把app/.prettierignoreapp/.eslintignore都加入下面兩行:

+src-tauri
+dist

再重新執行lint結果如下:

pnpm lint結果,剩14個檔案

檢測到的問題少很多了,倒數第二行有提到說是不是忘了執行Prettier,記得剛剛script裡的format可以幫我們執行寫入,我們先看一下Prettier的設定app/.prettierrc,對照一下官方的說明,我還是習慣前面用空白(當然還是要看團隊),而且加trailingComma,調整設定之後就可以執行format

"useTabs": false,
"trailingComma": "es5",

小提示:為什麼優先選擇 tab 而不是 space 做程式碼縮排

執行完pnpm format後,應該沒問題了吧。再執行pnpm lint後反而出現以下告警:

eslint提示 prefer-const

依上面提示把把api裡的let都改成const。(寫rust太習慣let)

eslint提示不要用Symbol當type

我們用到了TypeScript的保留字Symbol,避免衝突我們換個名字,改成GameSymbol再重執行就沒有錯誤了,有了這些工具輔助,我寫code都100分呢 (大誤。

追加需求

雖然遊戲中連成一線有訊息提示,但感覺不是很直觀,現在希望能提供玩家更一目了然的方式。一個方式是直接用TypeScript自幹for去寫判斷,但這樣好像有點累,效益不彰。為什麼呢?因為我們當時在做後端的時候,要判斷是否有玩家勝出就寫過判斷式了,沒必要再重寫一次。

以上是抽共用的想法,而對應到Clean Architecture的概念,就是我們的核心邏輯,應該儘量確保放在核心,避免I/O影響我們的核心邏輯。Uncle Bob說Web和資料庫都是細節,所以我們儘量不要把核心的東西放到外圍。Clean Architecture分層的概念很重要,我還是把圖放上來對照看一下好了:

Clean Architecture洋蔥圖

簡單說一下:核心的業務(商業)邏輯(Business Logic)放中心,I/O往外提取,這也是前後端分離的想法之一。後端的語言或邏輯相對是穩定的,但前端或輸入輸入設備時常在更迭。我們的領域核心不該被外圍的東西所影響,而是外圍來配合,因為中間是老大,是賺錢的所在(所以講話比較大聲?)。

舉個例子,像銀行的存款系統,以前早期只能臨櫃辦理,靠櫃台的正妹姊姊們操作終端機(UI1),幫我們存提款,再打印到存摺。後來,要加個ATM(UI2)就重寫一套,要加個網銀(UI3)就再寫一套,要加個行動應用APP(UI4)就再寫一套。但是UI應該只是操作介面,背後最底層的商業邏輯(存提款)是不變的,或是相對不容易變的,所以不應該受到UI影響而改變。

依照這個思維,我們檢驗贏家以及連線,是屬於核心邏輯,應該寫在後端domain layer。雖然有人可能覺得,這麼簡單的邏輯我還要follow架構好麻煩,問題是聚沙成塔,這邊一點點那邊一點點,後來就變成大泥球了(?) ,你終究要變成大泥球的,何不一開始就

趁著這個核心邏輯簡單,我們可以拿這個例子實作練練手,讓大家有一些些感覺,平常在處理真實問題的時候,可以多注意一下分層隔離,(三個月後的自己會感謝三個月前的自己 XDD),開發就是可以思考各種方案才有趣不是嗎 XDD。

調整後端Rust加入連線的形式

我們先拉回到後端,在Game的結構體裡加一個欄位:

@@ core/src/tic_tac_toe.rs @@
 pub struct Game {
 ...
+    /// 贏的連線,如果沒有則為`None`,如果有則為九宮格位置(1~9)
+    pub won_line: Option<[usize; 3]>,
 }

編譯器立馬提示我們:

rust提示Self要實作新增的欄位won_line

在impl Default trait裡要加這個欄位:

@@ core/src/tic_tac_toe.rs @@
 fn default() -> Self {
     Self {
 ...
+        won_line: None,
     }

編譯通過,接下來就是要改核心邏輯了,還好我們先前有寫測試,(所以可以放心的改,改壞了有git可以還原)。另外藉機帶大家體驗一下TDD測試驅動開發,顧名思義,就是使用測試案例來驅動我們的開發流程,以這個案例而言,我們先寫測試驗證預期的結果:

// core/src/tic_tac_toe.rs 
#[test]
fn test_check_winner() {
    let mut game = Game::default();
    game.cells = [
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::O),
        Some(Symbol::X), Some(Symbol::X), None,
        None, None, None,
    ];
    assert_eq!(game.check_winner(), Some(Symbol::O));
    assert_eq!(game.won_line, Some([1, 2, 3]));        // 加這行

    game.cells = [
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::X),
        Some(Symbol::X), Some(Symbol::X), None,
        None, None, None,
    ];
    assert_eq!(game.check_winner(), None);
    assert_eq!(game.won_line, None);                   // 加這行

    game.cells = [
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::X),
        Some(Symbol::X), Some(Symbol::X), Some(Symbol::O),
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::X),
    ];
    assert_eq!(game.check_winner(), None);
    assert_eq!(game.won_line, None);                   // 加這行

    game.cells = [
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::X),
        Some(Symbol::X), Some(Symbol::X), Some(Symbol::O),
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::O),
    ];
    assert_eq!(game.check_winner(), Some(Symbol::O));
    assert_eq!(game.won_line, Some([7, 8, 9]));        // 加這行

    game.cells = [
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::X),
        Some(Symbol::X), Some(Symbol::X), Some(Symbol::O),
        Some(Symbol::O), Some(Symbol::X), Some(Symbol::O),
    ];
    assert_eq!(game.check_winner(), None);
    assert_eq!(game.won_line, None);                   // 加這行

    game.cells = [
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::X),
        Some(Symbol::X), Some(Symbol::X), Some(Symbol::O),
        Some(Symbol::X), Some(Symbol::X), Some(Symbol::O),
    ];
    assert_eq!(game.check_winner(), Some(Symbol::X));
    assert_eq!(game.won_line, Some([3, 5, 7]));        // 加這行
}

有人可能會覺得要把這個大fn依每個小case拆開,或者是說測check_winner跟測 won_line是兩件事,所以也要拆開,沒有錯,之前第十篇稍稍講到寫測試是一門藝術,怎麼測比較好有有時候顯而易見,有時候見人見智,我在後面提供一些參考資料給有興趣的人瞧瞧。這裡只是示範先抓個大概,暫時不糾結於這些需要更深入討論的東西 XDDD。

然後把之前的test 跑起來:

測試未通過畫面

人理所當然的忘記,喔不是,是理所當然的失敗了,難道這就是...傳說中的紅綠燈嗎,把一開始的紅燈修正綠燈,(然後你就可以接更多的需求) 就表示我們把程式寫對了(前提是測試程式要先對 XDDD),而且也沒改壞其他東西(其他測試也沒紅燈的前提)。所以有寫測試的好處就是不怕改,可以安心重構。

其實有另一個思維是在寫測試的時候,可以迫使自己用另一個角度去思考,看是不是還有一開始沒設想到的情境,或是從驗收的角度切入,有點在重新檢視設計的概念。而開始實作code的目的是讓測試通過,所以某種程度上避免寫扣時over-engineering,所以還沒習慣寫測試的,趕快開始學起來吧。

測試寫好接著開始寫本文,先看一下原本的代碼:

// core/src/tic_tac_toe.rs 
pub fn check_winner(&mut self) -> Option<Symbol> {
    // ... 略
    for idx in win_patterns.iter() {
        // ... 略
        match line {
            [Some(Symbol::O), Some(Symbol::O), Some(Symbol::O)]
                => return Some(Symbol::O),
            [Some(Symbol::X), Some(Symbol::X), Some(Symbol::X)]
                => return Some(Symbol::X),
            _ => (),
        }

注意一下:這裡的return是整個fn的return,不是把資料指派給 line的return。

rust的表達式可以是一個運算式,或是一個{ }block區塊,所以我們把 => 右邊的return包在區塊裡:

[Some(Symbol::O), Some(Symbol::O), Some(Symbol::O)] => {
    self.won_line = Some(*idx);    // 設定贏的連線
    return Some(Symbol::O);
}

這邊用到 *idx 前面的*是解引用我們在第8篇有遇過,先利用IDE的功能看一下idx的型別:

使用IDE檢視物件型別

而這裡使用的idx是一是陣列,類別使用usize,陣列大小為3,前面還有個&代表是借用(引用),而我們定義的won_line則是[usize;3],所以在這邊簡單解引用*取得裡面的值,就可以指派給我們的變數won_lone,我們先試著把資料拋出來看看:

測試執行失敗訊息

雖然還是錯誤,但錯誤訊息與剛剛不同,表示我們前進了一小步(?),看訊息有出現格號陣列,現在的問題是我們預期的格號是base 1,但程式索引是base 0,我們在拋出來的時候要把每個元素+1,這在TypeScript或C#之類很簡單啊,不就是一行的事情:

// TypeScript
a = [1, 2, 3];     
a.map(x => x + 1); // => [2, 3, 4]
// C#
var a = new List<int> { 1, 2, 3 }; 
a.Select(x => x + 1).ToList();     // => 2, 3, 4

rust不能落人後,也可以一行 XDD:

let won: [usize; 3] = idx.iter().map(|x| *x + 1).collect::<Vec<usize>>().try_into().unwrap();

等等,這一行的訊息含量有點大,還是先拆解開來逐項解說再繼續往下:

代碼範例:rust map 使用

let won: [usize; 3] = idx    // 定義won變數為類別usize,容量3的陣列
    .iter()                  // 將idx轉為一個迭代器,再接要迭代的工作
    .map(|x| *x + 1)         // 可使用filter, inspect, for_each等fn
    .collect::<Vec<usize>>() // 結果的收集器::<T>代表型別,這裡是Vec<usize>
    .try_into()              // 試著轉成won所定義的類別([usize;3])
    .unwrap();               // 解開轉換的結果Result,直取Ok結果的內容
  • Vec是Rust裡可以變大小的陣列(像C#裡的List或Java的ArrayList),與基本陣列[ ]的差異是,[ ]的大小固定;而Vec在開發時不用決定元素的數量,可在執行時runtime隨時增減。

開發時常要面臨取捨,Array快但不彈性(容量必需在編譯時已知;放Stack;快),Vec彈性但慢(容量在編譯時不知;放heap執行時再行調配;慢),大家依個別情況取用吧,當然不在意效能的情境也可以一路Vec到底 XDD。

硬要把map寫成一行後的結果如下:

[Some(Symbol::O), Some(Symbol::O), Some(Symbol::O)] => {
    let won: [usize; 3] = idx.iter().map(|x| *x + 1).collect::<Vec<usize>>().try_into().unwrap();
    self.won_line = Some(won);
    return Some(Symbol::O);
}
[Some(Symbol::X), Some(Symbol::X), Some(Symbol::X)] => {
    let won: [usize; 3] = idx.iter().map(|x| *x + 1).collect::<Vec<usize>>().try_into().unwrap();
    self.won_line = Some(won);
    return Some(Symbol::X);
}

測試失敗,無人勝出未正確顯示

改好後,剛剛的檢查通過了,這裡應該要顯示None,因為這案例無人勝出,這其實是測試寫的不好的瑕疵。如同剛剛說的所有情境包在同一個test裡,沒有逐案例跑,每個案例沒有重設,會留有上一局的渣渣。在我們寫的遊戲流程中是不可逆,不允許修改棋局的內容(只能追加),但我們在單元測試的代碼,是直接重置每個格子的內容,這裡我先偷吃步在check開始先設為None:

 pub fn check_winner(&mut self) -> Option<Symbol> {
+    self.won_line = None;
     let win_patterns = [

喔耶,測試通過了,我們寫好了!

等等,好像還有點問題,是說剛剛那串濃縮成一行的東西,寫了兩次,這樣好像不太好(?)。本篇一開始就說同樣的邏輯不應該在前端再寫一次,而現在在後端寫了兩次(打臉自己?)。我們來修改一下,好在這時候有rust的match神救援,咦我們不是已經用match了嗎?我是說把match拿來當expression使用:

let winner = match line {
    [Some(Symbol::O), Some(Symbol::O), Some(Symbol::O)] => {
        Some(Symbol::O)    // 去掉return 及分號
    }
    [Some(Symbol::X), Some(Symbol::X), Some(Symbol::X)] => {
        Some(Symbol::X)    // 去掉return 及分號
    }
    _ => None,             // 把() 改 None
};
if winner.is_some() {
    self.won_line = Some(idx.iter()
        .map(|x| *x + 1)
        .collect::<Vec<usize>>()
        .try_into()
        .unwrap());
    return winner;    // 已有winner,直接中斷比對,並回傳比對結果
}

這時候測試應該順利通過了,表示我們剛剛的重構沒有改壞。應該感受到了吧,測試的好處如影隨形。

幫前端套上連線提示效果

前端先改model,直接照著剛剛後端加欄位的方式加入:

@@ app/src/model/tic_tac_toe.ts @@
 export interface Game {
...
+  won_line?: Array<number>;
 }

 export const emptyGame = (): GameSet => [
...
+    won_line: [],
   },

接著可以調整一下css,只要是連線的格號,就 讓他生,讓他生、讓他生,欸不是又離題了 讓他變色,明顯一點就好:

 @@ app/src/routes/tic_tac_toe/+page.svelte @@
 <button
   class="h-32 text-9xl text-amber-500 border-2 border-amber-500 rounded-md hover:bg-amber-100 hover:text-white"
+  class:text-blue-500={gameSet[1].won_line?.includes(index + 1)}
+  class:bg-amber-100={gameSet[1].won_line?.includes(index + 1)}
   on:click={() => playGame(index + 1)}>{symbol ?? ' '}
  • svelte利用變數綁定class的語法是class:my-class={boolean}
  • 判斷won_line裡面是否包含點擊格子的格號的邏輯寫在後面的{}
  • 「?.」是Optional chaining」,就是前面是null就不往後執行,因為後端won_lineOptionNone會序列化成Jsonnull
  • includes用來判斷是否包含該元素。

寫好了,但是看不順眼自己寫的扣,一堆冗名很不乾淨(其實我是想介紹svelte的computed 欄位),svelte的reactive利用 $:宣告:

$: 變數 = 計算新值的表達式

把gameSet的欄位拉出來給個比較清楚的名字:

let gameSet = emptyGame();
$: wonLine = gameSet[1].won_line;
$: game = gameSet[1];
$: gameId = gameSet[0];
 const playGame = async (index: number) => {
   try {
+    gameSet = await api.ticTacToe.play(gameId, index);
-    gameSet = await api.ticTacToe.play(gameSet[0], index);
...
+局號:{gameId},
+  {#if game.winner}
+    遊戲結束,贏家:{game.winner}!
+  {:else if game.is_over && !game.winner}
-局號:{gameSet[0]},
-  {#if gameSet[1].winner}
-    遊戲結束,贏家:{gameSet[1].winner}!
-  {:else if gameSet[1].is_over && !gameSet[1].winner}
...
+  {#each game.cells as symbol, index}
-  {#each gameSet[1].cells as symbol, index}
...
+class:text-blue-500={wonLine?.includes(index + 1)}
+class:bg-amber-100={wonLine?.includes(index + 1)}

好了,我們實際運行一下,的確連線的部分比之前清楚多了:

連線示意圖

前端的環境變數:Vite的 .env

來補一下之前寫API的部分,有經驗的朋友當時可能會納悶,為什麼不設baseUrl。別急,先求有再求好,小步向前比較好抓問題。向沒經驗的朋友說明一下,通常我們會把baseUrl抽出來作為設定,因為會有各種環境(開發/測試/生產),總不能換個server的環境,就要改寫一份程式碼(別笑,真的有人這樣做 XDD)。

我們在抽baseUrl時,順便檢視一下剛剛用的fetch(),這是原生es有的語法,想知道和XMLHttpRequest(XMR)有什麼不一樣可以自行研究一下。而在我們專案裡可能需要自己包一個httpClient的套件, 我這裡有一批便宜的牛肉,啊不是, 是我這個有個輕巧的套件ky.js,我們一起來用看看吧。

以前比較常用的axios聽說比較胖(?),而且是base on XHR的,這次就想說換個套件試試看。

摘錄ky文件提到自己的優點:

ky的說明文件

官方提到比起fetch提供的好處有哪些,除了簡化api的使用,加了baseUrl的設定,另外還有Retry的機制可以使用。

照著以下步驟安裝起來:

~/demo-app/app$ pnpm install ky

然後建立一個ky的instance,放置共同的設定值:

app/
├── .env                // 加入這個檔案,放我們的設定
└── src/api/
    └── ky.ts           // 加入這個檔案,包一下ky
// app/src/api/ky.ts
import ky from 'ky';

export const httpClient = () => {
  let api = ky.create({
    prefixUrl: import.meta.env.VITE_API_BASE_URL, // 取用env變數
    throwHttpErrors: false, // 4XX及5XX ky會拋Error,但我們要拿body的訊息
  })
  // TODO: 之後要加Authorization可以在這邊擴充
  return api;
};
# app/.env
VITE_API_BASE_URL=http://localhost:3030

這邊依vite的環境變量設定,要注意的一點是變數要用VITE_開頭(剛剛測了半小時不行,後來發現官網有說明),不然前端會吃不到,接著改寫我們之前寫的api:

vite文件中設定環境變量的注意事項

@@ app/src/api/tic_tac_toe.ts @@
+import { httpClient } from "./ky";
...
+  const response = await httpClient().post('tic_tac_toe');
-  const response = await fetch('http://localhost:3030/tic_tac_toe', {
-    method: 'POST',
-  });
...
+  const response = await httpClient().put(`tic_tac_toe/${id}/${step}`);
-   const response = await fetch(`http://localhost:3030/tic_tac_toe/${id}/${step}`, {
-    method: 'PUT',
-  });
...
+  const response = await httpClient().get(`tic_tac_toe/${id}`);
-  const response = await fetch(`http://localhost:3030/tic_tac_toe/${id}`, {
-    method: 'GET',
-  });
...
+  const response = await httpClient().delete(`tic_tac_toe/${id}`);
-  const response = await fetch(`http://localhost:3030/tic_tac_toe/${id}`, {
-    method: 'DELETE',
-  });

保持好習慣,改完code記得pnpm checkpnpm fomrat

~/demo-app/app$ pnpm update

錯誤訊息:httpClient參照不到ky。
這邊說我們需要明確指派function的type:

@@ app/src/api/ky.ts @@
+import type { KyInstance } from '../../node_modules/ky/distribution/types/ky.js';

+export const httpClient = (): KyInstance => {
-export const httpClient = () => {

lint: unknow類別不能被指派

@@ app/src/api/tic_tac_toe.ts @@
+import type { Game, GameSet } from '../model/tic_tac_toe';
-import type { GameSet } from '../model/tic_tac_toe';
...
+const data = await response.json() as Game;
-const data = await response.json();
...
+const data = await response.json() as Game;
-const data = await response.json();

改好就告一個小段落了,然後有強迫症的話可以順便更新一下前端的package

pnpm update

什麼,這樣這篇就結束了(?),不是說CRUD,結果API只用到CU,那RD呢? (RD離職了) 相信簡單的Read/Delete大家可以自己試著做看看(?),原始碼為會補上完整CRUD的版本,雖然有點為做而做,但就當作練習吧,有興趣的再自行參閱。

參考資料

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


上一篇
11 使用 Svelte 復刻 井字遊戲 UI
下一篇
13 Tauri 該你上場了 - rust 桌面應用
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言