前一篇把前端完成了,本篇來優化一下內容。
一般開發都是先求有,再求好,功能完成了之後,先來清理一下,看一下 我們之前寫的code有多髒 package.json
的script
有什麼,看起來sveltekit已經幫我們設好 check
及lint
,比較一下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)}
>
我們把pnpm checkd 的問題都修完了。接下來換跑lint了,可是似乎跳了很多不相關的東西:
~/demo-app/app$ pnpm lint
原來是因為svelte
不認得rust tauri的
東西,我們讓它 閉嘴 忽略tauri的部分,另外還有dist
是放建置結果檔的,也一起忽略,我們把app/.prettierignore
和app/.eslintignore
都加入下面兩行:
+src-tauri
+dist
再重新執行lint結果如下:
檢測到的問題少很多了,倒數第二行有提到說是不是忘了執行Prettier,,記得剛剛script裡的format可以幫我們執行寫入,我們先看一下Prettier的設定app/.prettierrc
,對照一下官方的說明,我還是習慣前面用空白(當然還是要看團隊),而且加trailingComma,調整設定之後就可以執行format
:
"useTabs": false,
"trailingComma": "es5",
執行完pnpm format
後,應該沒問題了吧。再執行pnpm lint
後反而出現以下告警:
依上面提示把把api裡的let都改成const。(寫rust太習慣let)
我們用到了TypeScript的保留字Symbol,避免衝突我們換個名字,改成GameSymbol
再重執行就沒有錯誤了,有了這些工具輔助,我寫code都100分呢 (大誤。
雖然遊戲中連成一線有訊息提示,但感覺不是很直觀,現在希望能提供玩家更一目了然的方式。一個方式是直接用TypeScript自幹for去寫判斷,但這樣好像有點累,效益不彰。為什麼呢?因為我們當時在做後端的時候,要判斷是否有玩家勝出就寫過判斷式了,沒必要再重寫一次。
以上是抽共用的想法,而對應到Clean Architecture的概念,就是我們的核心邏輯,應該儘量確保放在核心,避免I/O影響我們的核心邏輯。Uncle Bob說Web和資料庫都是細節,所以我們儘量不要把核心的東西放到外圍。Clean Architecture分層的概念很重要,我還是把圖放上來對照看一下好了:
簡單說一下:核心的業務(商業)邏輯(Business Logic)放中心,I/O往外提取,這也是前後端分離的想法之一。後端的語言或邏輯相對是穩定的,但前端或輸入輸入設備時常在更迭。我們的領域核心不該被外圍的東西所影響,而是外圍來配合,因為中間是老大,是賺錢的所在(所以講話比較大聲?)。
舉個例子,像銀行的存款系統,以前早期只能臨櫃辦理,靠櫃台的正妹姊姊們操作終端機(UI1),幫我們存提款,再打印到存摺。後來,要加個ATM(UI2)就重寫一套,要加個網銀(UI3)就再寫一套,要加個行動應用APP(UI4)就再寫一套。但是UI應該只是操作介面,背後最底層的商業邏輯(存提款)是不變的,或是相對不容易變的,所以不應該受到UI影響而改變。
依照這個思維,我們檢驗贏家以及連線,是屬於核心邏輯,應該寫在後端domain layer。雖然有人可能覺得,這麼簡單的邏輯我還要follow架構好麻煩,問題是聚沙成塔,這邊一點點那邊一點點,後來就變成大泥球了(?) ,你終究要變成大泥球的,何不一開始就 。
趁著這個核心邏輯簡單,我們可以拿這個例子實作練練手,讓大家有一些些感覺,平常在處理真實問題的時候,可以多注意一下分層隔離,(三個月後的自己會感謝三個月前的自己 XDD),開發就是可以思考各種方案才有趣不是嗎 XDD。
我們先拉回到後端,在Game
的結構體裡加一個欄位:
@@ core/src/tic_tac_toe.rs @@
pub struct Game {
...
+ /// 贏的連線,如果沒有則為`None`,如果有則為九宮格位置(1~9)
+ pub won_line: Option<[usize; 3]>,
}
編譯器立馬提示我們:
在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的型別:
而這裡使用的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();
等等,這一行的訊息含量有點大,還是先拆解開來逐項解說再繼續往下:
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結果的內容
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 ?? ' '}
class:my-class={boolean}
won_line
裡面是否包含點擊格子的格號的邏輯寫在後面的{}
裡null
就不往後執行,因為後端won_line
是Option
,None
會序列化成Json
的null
寫好了,但是看不順眼自己寫的扣,一堆冗名很不乾淨(其實我是想介紹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)}
好了,我們實際運行一下,的確連線的部分比之前清楚多了:
.env
來補一下之前寫API的部分,有經驗的朋友當時可能會納悶,為什麼不設baseUrl。別急,先求有再求好,小步向前比較好抓問題。向沒經驗的朋友說明一下,通常我們會把baseUrl抽出來作為設定,因為會有各種環境(開發/測試/生產),總不能換個server的環境,就要改寫一份程式碼(別笑,真的有人這樣做 XDD)。
我們在抽baseUrl時,順便檢視一下剛剛用的fetch()
,這是原生es有的語法,想知道和XMLHttpRequest(XMR)有什麼不一樣可以自行研究一下。而在我們專案裡可能需要自己包一個httpClient
的套件, 我這裡有一批便宜的牛肉,啊不是, 是我這個有個輕巧的套件ky.js,我們一起來用看看吧。
以前比較常用的axios聽說比較胖(?),而且是base on XHR的,這次就想說換個套件試試看。
摘錄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:
@@ 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 check
和 pnpm fomrat
~/demo-app/app$ pnpm update
這邊說我們需要明確指派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 = () => {
@@ 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