[實作筆記] Ghostty 打字出現 [O[27u 亂碼的原因與修正

前情提要

剛裝好 Ghostty,打字有時正常,有時畫面會出現像這樣的亂碼:

1
[O[27u

查了一下,原因跟 Ghostty 預設啟用的 Kitty Keyboard Protocol 有關。

原因

Ghostty 預設使用 TERM=xterm-ghostty,同時啟用 Kitty Keyboard Protocol(又叫 CSI u encoding)。

這個協定會把按鍵轉成擴展的 escape sequence,格式長這樣:

1
ESC [ <code> u

例如 Escape 鍵 → ESC[27u,搭上其他序列就變成你看到的 [O[27u

這本來是為了解決傳統終端機鍵碼不夠精確的問題,現代工具(Neovim、WezTerm、Kitty)都支援。

但如果你的 shell 或其他程式不支援這個協定,就不會解析這些 escape sequence,直接把它們當一般文字印出來,就是你看到的亂碼。

修正方法

改掉 TERM 設定,換成廣泛相容的 xterm-256color,讓 Ghostty 不送出這些擴展序列。

建立或編輯 ~/.config/ghostty/config,加入:

1
term = xterm-256color

完整設定檔範例:

1
2
3
4
# ~/.config/ghostty/config

# 使用廣泛相容的 TERM,避免 Kitty keyboard protocol 造成亂碼
term = xterm-256color

存檔後完全關閉 Ghostty 再重開(Cmd+Q,不只是關視窗),讓設定生效。

如果你有用 tmux

tmux 本身也可能會攔截或破壞這些 escape sequence,在 ~/.tmux.conf 加入:

1
2
set -g extended-keys on
set -as terminal-features 'xterm-ghostty:extkeys'

或直接關掉:

1
set -g extended-keys off

最簡單的做法還是先改 term = xterm-256color,大多數情況直接解決。

小結

Ghostty 預設啟用 Kitty Keyboard Protocol,對不支援的程式來說會直接吐出 [27u 這類亂碼。
改一行設定 term = xterm-256color 就搞定,完全關掉重開生效。

(fin)

[工具筆記] Terminal 工具怎麼選?Ghostty、iTerm2 與其他選手比較

前情提要

最近有人問我 Ghostty 跟 iTerm2 哪個比較好,
順便聊到還有哪些選手值得比較,就整理一下。

選手介紹

iTerm2

macOS 老牌 terminal,功能多到有點過頭。

split pane、tmux 整合、Triggers、Shell Integration、Python scripting API 全都有,
幾乎是 macOS 工程師的預設選項,做了很多年。

缺點是比較重,啟動慢一點,只能用在 macOS。

Ghostty

2024 底爆紅的新秀,現在已經是很多人的主力工具。

Rust 的精神,GPU 加速渲染,開箱即用,預設樣式就乾淨好看。
支援 Kitty keyboard protocol,Nerd Fonts 圖示正常顯示,光標移動順到不行。

設定是純文字檔,簡單幾行就搞定:

1
2
3
font-family = "JetBrainsMono Nerd Font"
font-size = 14
theme = "GruvboxDark"

macOS + Linux 都支援。

Warp

主打 AI 整合,內建 AI 指令輔助,Block-based UI,輸出可以區塊操作。

Rust 寫成,效能不錯。
最大的爭議是需要帳號登入,有隱私疑慮,這點讓不少人卻步。

Alacritty

效能派代表,極簡哲學。

刻意拿掉 Tab、split pane、滑鼠增強,理由是「這些交給 tmux 做就好」。
純文字設定(TOML),跨平台(macOS / Linux / Windows)。

缺點是預設偏素,要自己調設定,不適合「裝好就好看好用」的需求。

Kitty

GPU 加速,原生支援 split pane 和 tab。
Kitty keyboard protocol 就是它發明的,Ghostty 後來也採用。
macOS + Linux,設定有點多,學習曲線稍高。

WezTerm

GPU 加速,Lua 腳本設定,功能豐富。
內建 multiplexer,split、tab、session 管理全包,不需要 tmux。
跨平台(macOS / Linux / Windows)。


快速比較

iTerm2 Ghostty Warp Alacritty Kitty WezTerm
效能 普通 極快
跨平台 macOS only mac+Linux mac+Linux 全平台 mac+Linux 全平台
設定方式 GUI 文字 GUI TOML conf Lua
AI 功能
內建 multiplexer
Nerd Fonts 支援
成熟度

補充:tmux 是什麼

很多文章提到要配合 tmux 使用,稍微說明一下。

tmux 是 terminal multiplexer,在一個 terminal 視窗裡模擬多視窗和分割畫面。
最大的賣點是 session 保持,關掉視窗程式不會死,SSH 到遠端時特別實用。

1
2
3
4
5
6
7
8
9
┌─────────────────────────────┐
│ tmux session │
│ ┌──────────┬──────────┐ │
│ │ vim │ shell │ │
│ │ │ │ │
│ ├──────────┴──────────┤ │
│ │ npm run dev │ │
│ └─────────────────────┘ │
└─────────────────────────────┘

現代 terminal(Ghostty、WezTerm、iTerm2)內建 split 和 Tab,
一般使用不需要特別學 tmux,除非有 SSH session 保持的需求。


我的建議

需求很簡單,好看 + 圖示顯示 + 光標流暢,直接選 Ghostty

裝好之後指定一個 Nerd Font,10 分鐘搞定:

1
2
brew install --cask ghostty
brew install --cask font-jetbrains-mono-nerd-font

Nerd Fonts 常用選擇:

字型 特色
JetBrains Mono NF 易讀、開發者最愛
FiraCode NF ligature 好看
Hack NF 傳統乾淨

其他場景的建議:

  • 需要 AI 輔助 → Warp
  • 要配合 tmux、極致效能 → Alacritty
  • 要 Lua 腳本、功能完整 → WezTerm
  • 已經習慣、不想換 → iTerm2 繼續用也沒問題

小結

業界風向已經轉向 Ghostty,不是因為它功能最多,
而是它把該有的都做好了,多餘的不硬塞。

開箱好看、設定簡單、效能夠快,這樣就夠了。

(fin)

[實作筆記] Next.js Hydration 與隨機性:從問題到正確架構

前情提要

最近在開發 Next.js 專案時,有 A/B 測試的需求,結果遇到一個奇怪的錯誤:

1
Error: Hydration failed because the initial UI does not match what was rendered on the server.

追了一下,發現是 Math.random() 惹的禍。

問題根源

Next.js 的頁面渲染分兩個步驟:

第一步,Server 產生 HTML,送到瀏覽器,使用者馬上看到畫面,但還不能互動。

第二步,Client 載入 JavaScript,把事件綁上去,畫面變成可互動的。

第二步就叫 Hydration(注水)— 把靜態 HTML 注入互動能力,像幫乾燥的海綿注水。

Hydration 的核心規則:Server 產生的 HTML 和 Client 產生的 HTML 必須一模一樣。

問題在於 Math.random() 在 Server 和 Client 各自執行一次:

1
2
Server  跑 Math.random() → 0.3 → 選 Layout A → 產生 HTML
Client 跑 Math.random() → 0.7 → 選 Layout B → 跟 Server 的 HTML 對不上 → 爆炸

初步解法:繞開 SSR,在前端處理

要解決 mismatch,核心思路是讓 Math.random() 只在 Client 端跑一次。有兩種做法:

做法一:dynamic({ ssr: false })

直接告訴 Next.js 這個 component 跳過 SSR:

1
2
3
4
5
6
7
8
9
// ABComponent.tsx
'use client'
export default function ABComponent() {
const variant = Math.random() < 0.5 ? 'a' : 'b'
return <Layout variant={variant} />
}

// page.tsx
const ABComponent = dynamic(() => import('./ABComponent'), { ssr: false })

component 完全不在 Server 端跑,Math.random() 只在瀏覽器執行,沒有 mismatch。

做法二:useState + useEffect

讓 component 在 Server 和 Client 第一次都 render null,mount 後才隨機:

1
2
3
4
5
6
7
const [variant, setVariant] = useState<string | null>(null)

useEffect(() => {
setVariant(Math.random() < 0.5 ? 'a' : 'b')
}, [])

if (!variant) return null

兩邊第一次渲染結果一致,hydration 成功,mount 後才決定變體。

哪個比較好?

做法一(dynamic)比較好。 程式碼更簡單,意圖也更清楚:「這個 component 不需要 Server Side Render」。

做法二的 useState + useEffect 除了更繁瑣,還可能被部分嚴格的 lint rule 擋住,lint auto-fix 之後反而重新引入 hydration bug,修 A 破 B。

不過兩種做法有一個共同的致命問題:

同一個用戶每次重整都重新隨機,這次看 A、下次看 B,A/B 測試數據完全沒意義。

正確做法:Middleware 決定,Server Side 處理

讓 Server 在第一次請求時決定變體,存進 cookie,之後每次讀同一個值。

1
2
3
4
5
6
7
Middleware 執行 Math.random(),結果存進 cookie

Server Component 讀 cookie 取得變體值

以 prop 傳給 Client Component

Server render 和 Client hydration 讀到同一個 prop → 沒有 mismatch

middleware.ts

1
2
3
4
5
6
7
8
9
10
export function middleware(request: NextRequest) {
const response = NextResponse.next()

if (!request.cookies.get('ab-variant')) {
const variant = Math.random() < 0.5 ? 'a' : 'b'
response.cookies.set('ab-variant', variant, { httpOnly: true })
}

return response
}

page.tsx(Server Component)

1
2
3
4
5
6
import { cookies } from 'next/headers'

export default function Page() {
const variant = cookies().get('ab-variant')?.value ?? 'a'
return <Layout variant={variant} />
}

同一個用戶拿到同一個 cookie,每次看到同一個版本,A/B 測試數據才有意義。

小結

這次踩坑之後整理出一個原則:

隨機、時間、用戶身份這類「非確定性資料」,應該從 Server 端注入,Client 只負責呈現。

資料類型 不建議做法 建議做法
隨機值(A/B 測試) Client useState + Math.random() Middleware 寫 cookie,Server 讀取傳入
當前時間 Client Date.now() Server props 傳入
用戶身份 Client 讀 localStorage Server 讀 session cookie

下次再給我選的話,我會選用在 Server Side 決定隨機性,而不在前端。

(fin)

[生活筆記] 2026 台新 Richart Life 切換方案回饋最大化

前情提要

自從 2025 年 9 月台新把 @GoGo 卡、FlyGo 卡、玫瑰 Giving 卡、太陽/玫瑰卡這些信用卡整合成一張 Richart 卡之後,
就有點搞不清楚怎麼才能拿到最佳的回饋。
研究了一下,七個方案乍看之下有點眼花撩亂,但搞懂之後其實邏輯很清楚,稍稍記錄一下。

七大刷卡方案一覽

Richart 卡的核心玩法就是「切換刷」——在 Richart Life APP 裡面,每天可以切換一次方案,
當天所有消費都會依照你最終選定的方案來計算回饋。

以下是七個方案的整理:

方案名稱 適用場景 最高回饋率 綁定支付方式 注意事項
Pay著刷 台新Pay / LINE Pay 綁定支付 3.8% 台新Pay, LINE Pay 需用台新 Pay 或 LINE Pay 才能拿最高回饋
數趣刷 網購 / 電商平台 3.3% 一般刷卡 通路須屬於指定電商,PChome 儲值券會整筆排除
玩旅刷 海外消費 / 旅遊訂房 / 機票 3.3% 一般刷卡 海外交易需在方案指定範圍內
天天刷 超商 / 量販 / 交通 3.3% 一般刷卡 一般日常消費的好選擇
大筆刷 百貨 / OUTLET / 家居大額消費 3.3% 一般刷卡 百貨店中店(如 UNIQLO)不適用
好饗刷 餐飲 / 外送 / 演出活動 / 加油 3.3% 一般刷卡 餐飲娛樂類消費
假日刷 週末或國定假日不限通路 2.0% 一般刷卡 四大超商、繳稅費不適用

資料來源:台新 Richart 卡官方權益頁面假日刷活動說明

什麼時候切什麼方案?我的建議(待測試)

其實最關鍵的一點是:方案以當天最後一次切換為準
也就是說,你早上八點切了天天刷去家樂福,晚上又切成好饗刷去吃大餐,
那你早上那筆家樂福的消費就只會拿到 0.3% 的一般回饋,而不是天天刷的 3.3%。

所以我想到的做法是:盡量在一天快結束的時候,回顧當天消費,再決定切換哪個方案。

以下是我自己的日常切換邏輯:

平日(週一到週五)

  • 今天主要花在網購(蝦皮、momo 等) → 切「數趣刷」
  • 今天去量販店、搭捷運、去超商 → 切「天天刷」
  • 今天吃大餐、叫外送、去看電影 → 切「好饗刷」
  • 今天去百貨公司買了大件東西 → 切「大筆刷」
  • 今天有在海外消費或訂了機票飯店 → 切「玩旅刷」
  • 今天主要用台新 Pay 或 LINE Pay 付款 → 切「Pay著刷」(3.8% 最高)

假日(週末 + 國定假日)

假日的選擇就比較有趣了。如果你假日的消費剛好不屬於任何特定方案的指定通路,
那「假日刷」的 2% 不限通路就是你的好朋友。
但如果你假日去吃大餐(好饗刷 3.3%)或是逛百貨(大筆刷 3.3%),
那切特定方案反而比假日刷的 2% 更划算。

簡單判斷:假日消費有明確分類 → 切對應的 3.3% 方案;假日消費很雜 → 切假日刷 2%。

出國旅遊期間

出國期間果斷切「玩旅刷」就對了,海外消費 3.3% 很簡單直接。
另外如果有搭配 Richart Life APP 的領券活動,海外消費還有機會疊加到 5%。

幾個容易踩的坑

1. 支付工具的限制

這點很重要:街口支付、悠遊付、icash Pay、中油 Pay、全支付、全盈+PAY、OPEN 錢包這些第三方支付,
通通不適用七大權益的加碼回饋。

能拿到加碼回饋的支付方式:

  • 實體卡刷卡(含線上輸入卡號)
  • 台新 Pay
  • Apple Pay / Google 錢包 / Samsung Pay 綁定
  • LINE Pay(僅限 Pay著刷 和 假日刷 方案)

注意 LINE Pay 在其他五個方案(數趣刷、玩旅刷、天天刷、大筆刷、好饗刷)是不適用加碼的。

2. 四大超商在假日刷不回饋

假日刷雖然號稱「不限通路」,但四大超商和繳稅費是被排除的。
不過這不代表四大超商在所有方案都不給回饋,天天刷就有包含超商。

3. PChome 的坑

如果你在 PChome 24h 購物車裡面混了儲值金或電子票券,整筆訂單都不會拿到數趣刷 3.3% 回饋,
只剩下 0.3%。所以記得把一般商品和儲值/票券類商品分開結帳。

4. 回饋是點數,不是現金

Richart 卡的回饋是「台新 Point(信用卡)」,不是直接現金回饋。
而且從 2026/1/1 起,點數折抵帳單的比例從 100% 降到了 30%,
其他用途包括在 Richart Life APP 商城消費、兌換全家 Fa 點、LINE POINTS 等。
點數有效期限是兩年,記得定期使用。

5. 要設定台新帳戶自動扣繳(我已設定)

要享受最高回饋(LEVEL 2),需要設定台新帳戶自動扣繳信用卡帳款。
沒設定的話,加碼回饋最高只有 1.3%,差距不小。

小結

台新 Richart 卡的七大刷卡方案,核心邏輯就是「一天一切換,依當天消費選方案」。
回饋率在 2%~3.8% 之間,而且是無上限的(每期帳單消費上限為信用額度+30萬),
以一張卡能涵蓋的場景來說算是蠻全面的。

我自己覺得最需要注意的就是支付工具的限制和切換時機,
養成睡前花三秒鐘在 APP 切方案的習慣,基本上就能把回饋最大化了。

以上是基於 2026 年上半年的權益整理,台新的活動更新蠻頻繁的,
建議還是定期到官方頁面確認最新資訊。

(fin)

[生活筆記] 2025 T社面試心得 - 二面

前情提要

延續一面的紀錄,在完成 Python 基礎與演算法測驗後,很快地收到了二面的邀請。

二面同樣採用線上面試的形式,主要聚焦在演算法題目的實作與程式碼重構能力的考察,

並且會有主管線上監考。

面試流程

二面總時長約 90 分鐘,分為兩個部分:

  1. 演算法題目實作(約 60 分鐘)
  2. 程式碼重構討論(約 30 分鐘)

題目與解題

Q1: 最長不重複子字串

題目描述:給定一個字串 s,找出最長的不含重複字元的子字串長度。

解題思路

  • 使用 sliding window 的概念
  • strtmp 維護當前不重複的子字串
  • 當遇到重複字元時,清空 strtmp 重新開始
  • 持續追蹤最大長度

Q2: 股票交易最大利潤

題目描述:給定股票每日價格陣列 prices 和交易手續費 fee,求最大利潤。
可進行多次交易,但每次交易需支付一次手續費,且賣出後才能再買入。

Q2 解法評價與反思

坦白說,這題在本來想展示 TDD 的過程,

並在過程中覺察重複,並使用重構技巧完成解答。

但可能弄巧成拙,看起來只是把測試案例的答案硬編碼進去。

這個策略的問題

  • 優點:至少讓測試通過,展示我理解題意
  • 缺點:面試官會認為我不會動態規劃,或者時間管理有問題
  • ⚠️ 風險:如果面試官追加測試案例,立刻露餡

未來再用 TDD 來解上面兩題


Q3: 程式碼重構

題目背景

這是一個批次匯入資料的函式,會有效能與欄位合併的問題,需要重構:

原始程式碼,基於對面試公司的尊重,不予公佈

我的重構建議:

  1. config 敏感資料
  2. config MAX_RECORD_PER_ROUND
  3. 拆單一職責子方法(取資料/組 SQL/寫資料)
  4. SQL BULK
  5. 重命名參數

Q3.解法評價與反思

面試官更著重在使用 python 的語法使用
如下,

語法糖 用途 優勢
**dict 字典解包 簡化參數傳遞
with Context Manager 自動資源管理
f-string 字串格式化 可讀性高
sql.SQL() SQL 組裝 防止 SQL Injection
List Comprehension 列表生成 簡潔、快速
Generator 惰性求值 省記憶體
:= Walrus 賦值表達式 減少重複運算
@dataclass 資料類別 減少 boilerplate
@contextmanager 自訂 CM 封裝 setup/teardown
Type Hints 型別註記 IDE 支援、可讀性

面試心得與反思

從 Web 2.0 到 AI 時代,工作方式已經改變:

Web 2.0 時代:手寫演算法是基本功,熟悉語法是專業展現

AI 時代:演算法交給 AI,重點是整合工具。我的工作流程:TDD + AI → Review → 上線&維運

往前有需求分析與設計,往後有系統維運,之間交錯的成本控管/資安/流程優化,都是在開發之外的能力

但這次面試仍在考:能不能手寫 LeetCode、熟不熟 Python 語法、時間壓力下能不能寫出正確代碼。

角色認知的斷層

Senior Engineer:LeetCode 能力 = 技術能力

Tech Lead:系統思維 = 技術決策能力,整合能力 = 創造價值

這次面試用 Senior Engineer 的標準在評估 Tech Lead 的職位

我用 Tech Lead 這個角色,應徵 Senior Engineer。

雙向選擇

面試不只是公司選我,我也在選公司,可惜沒有給我問問題的機會:

  • 他們看重:LeetCode、語法(糖)熟悉度、coding speed
  • 我看重:系統設計討論、技術決策空間、團隊文化

還在用舊時代標準評估新時代角色的公司,或許不是我的理想選擇。

而我的多語言開發背景,可能也只是 jack of all trades master of none

參考資料

(fin)

[生活筆記] 2025 T社面試心得 - 一面

前言

Gap Year 期間持續在面試,這次面試的是一家做 Data Engineering 的新創公司 T 社。

一面是 Python 基礎能力測驗,用 Google Colab 進行,大概 1 小時。

結論:通過

一面: Python 基礎測驗

面試工具是 Google Colab,面試官給了 10 題 Python 題目,
涵蓋基礎語法、物件導向、函數式程式設計等概念。

原本的題目都是很制式的語法練習題,
但我刻意把它們轉換成「購物車」這個業務情境來解答。

為什麼要這樣做?

因為我想展現的不只是「我會寫 Python 語法」,
而是「我知道什麼時候該用什麼方法」。

題目 1: Bubble Sort

原始題目:

1
2
3
4
5
6
# Problem 1 - Bubble sort
def bubble_sort(sequence):
# Write your bubble sort code here.
pass

assert bubble_sort([5, 1, 3, 2, 4]) == [1, 2, 3, 4, 5]

經典排序演算法
測試: [5, 1, 3, 2, 4][1, 2, 3, 4, 5]

我的解答:

1
2
3
4
5
6
7
def bubble_sort(sequence):
n = len(sequence)
for i in range(n):
for j in range(0, n-i-1):
if sequence[j] > sequence[j+1]:
sequence[j], sequence[j+1] = sequence[j+1], sequence[j]
return sequence

題目 2: 找第二大值 (O(n) 複雜度)

原始題目:

1
2
3
4
5
6
7
8
# Problem 2 - Find second largest
def find_second_largest(sequence):
# Write your algorithm with O(n) time complexity here.
pass

assert find_second_largest([3, 3, 2, 1]) == 2
assert find_second_largest([3, 3, 3, 3, 3, 2, 2, 1]) == 2
assert find_second_largest([-1, 2, 3, 5, 3, 1, 2, 4]) == 4

要求線性時間
要處理重複值
測試: [3, 3, 2, 1]2

我的解答:

1
2
3
4
5
6
7
8
9
10
def find_second_largest(sequence):
largest = second_largest = float('-inf')

for num in sequence:
if num > largest:
second_largest = largest
largest = num
elif num != largest and num > second_largest:
second_largest = num
return second_largest

題目 3: 繼承 (Inheritance)

原始題目:

1
2
# Problem 3 - Inheritance
# Write some examples with inheritance code here.

實作商品類別
ProductDiscountProduct
我用購物車的商品折扣來展現繼承和多型

我的解答:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 父類別:商品
class Product:
def __init__(self, name, price):
self.name = name
self.price = price

def get_price(self):
return self.price

# 子類別:特價品
class DiscountProduct(Product):
def __init__(self, name, price, discount):
super().__init__(name, price)
self.discount = discount # 折扣,例如 0.8 代表 8 折

# 覆寫取得價格的方法
def get_price(self):
return self.price * self.discount

# 使用範例
products = [
Product("一般商品A", 1000),
DiscountProduct("特價商品B", 1000, 0.8)
]

for p in products:
print(p.name, "價格:", p.get_price())

題目 4: *args, **kwargs

原始題目:

1
2
# Problem 4 - *args, **kwargs
# Write some examples with *args, **kwargs here.

可變參數
我用購物車總價計算來展現(商品用 *args,運費用 **kwargs

我的解答:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 計算購物車總價
def calculate_cart(*args, **kwargs):
total = 0
# args 可以放任意數量的商品物件
for item in args:
total += item.get_price()

# kwargs 可以放額外參數,例如運費
shipping = kwargs.get("shipping", 0)
total += shipping

return total

# 使用範例
p1 = Product("商品A", 1000)
p2 = DiscountProduct("特價商品B", 1000, 0.8) # 8折
p3 = DiscountProduct("特價商品C", 500, 0.5) # 5折

total_price = calculate_cart(p1, p2, p3, shipping=100)
print("購物車總價:", total_price)

題目 5: Lambda 函式

原始題目:

1
2
# Problem 5 - lambda
# Write some examples using python lambda here.

函數式程式設計
我用 map() + lambda 計算折扣總價

我的解答:

1
2
3
4
5
6
7
8
9
# 以下使用 lambda 進行計算折扣後價格的加總
products = [
{"name": "商品A", "price": 1000, "discount": 1}, # 一般商品
{"name": "特價商品B", "price": 1000, "discount": 0.8},
{"name": "特價商品C", "price": 500, "discount": 0.5}
]

total_price = sum(list(map(lambda p: p["price"] * p["discount"], products)))
print("總價:", total_price)

題目 6: List Comprehension

原始題目:

1
2
# Problem 6 - comprehension
# Write some examples using python comprehension here.

Python 簡潔語法
我用篩選折扣後價格 > 500 的商品來展現

我的解答:

1
2
3
4
5
6
7
8
9
# 計算特價商品折扣後的價格,只保留大於500的商品
products = [
{"name": "商品A_一千", "price": 1000, "discount": 1},
{"name": "商品B_八百", "price": 1000, "discount": 0.8},
{"name": "商品C_二百五", "price": 500, "discount": 0.5}
]

discounted_over_500 = [p["name"] for p in products if p["price"] * p["discount"] > 500]
print(discounted_over_500)

題目 7: Decorator

原始題目:

1
2
# Problem 7 - decorator
# Write some examples using python decorator here.

裝飾器模式
我實作了「限制購物車總折扣不超過 200」的驗證邏輯

我的解答:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 檢查總折扣不能超過 200 decorator
def max_discount_decorator(func):
def wrapper(cart, *args):
# 計算目前總折扣
total_discount = sum(round((1 - p["discount"]) * p["price"]) for p in cart.items)
# 計算新加入商品折扣
new_discount = sum(round((1 - p["discount"]) * p["price"]) for p in args)
if total_discount + new_discount > 200:
print(f"不能加入商品,總折扣 {total_discount + new_discount} 超過 200")
return False
# 執行原方法
return func(cart, *args)
return wrapper

# 購物車類別
class Cart:
def __init__(self):
self.items = []

@max_discount_decorator
def add(self, *args):
self.items.extend(args)
print(f"加入 {len(args)} 個商品到購物車")

# 使用範例
cart = Cart()

p1 = {"name": "商品A_一千", "price": 1000, "discount": 0.9} # 折扣額 100
p2 = {"name": "商品B_五百", "price": 500, "discount": 0.8} # 折扣額 100
p3 = {"name": "商品C_三百", "price": 300, "discount": 0.5} # 折扣額 150

cart.add(p1, p2) # 可以加入,折扣總額 200
cart.add(p3) # 無法加入,折扣總額 200 + 150 = 350 > 200

題目 8: Generator

原始題目:

1
2
3
# Problem 8 - generator
# Write some examples using python generator here.
# Explain the benefit of generators here.

生成器
我用折扣價格計算來展現
好處是節省記憶體,適合大量資料

我的解答:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
products = [
{"name": "商品A", "price": 1000, "discount": 1},
{"name": "商品B", "price": 800, "discount": 0.8},
{"name": "商品C", "price": 500, "discount": 0.5}
]

def discounted_price_gen(products):
for p in products:
yield {"name": p["name"], "discounted_price": p["price"] * p["discount"]}

for item in discounted_price_gen(products):
print(item)

# 好處:
# - 節省記憶體:一次只產生一個值,不用把整個序列存在記憶體裡。
# - Lazy evaluation:只有真正取值時才計算,適合大資料或無限序列。

題目 9: Context Manager

原始題目:

1
2
# Problem 9 - context manager
# Write some examples using python context manager here.

with 語句
用檔案處理展現資源管理

我的解答:

1
2
with open("test.txt", "w") as f:
f.write("Hello world") # 離開區塊會自動關閉檔案

題目 10: Magic Methods

原始題目:

1
2
# Problem 10 - magic methods
# Write some examples using python magic methods here.

特殊方法 (__str__, __len__, __add__)
我實作購物車類別來展現

我的解答:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Cart:
def __init__(self):
self.items = []

def add(self, item):
self.items.append(item)

# 可以 print(cart) 顯示資訊
def __str__(self):
return f"購物車({len(self.items)} 件商品)"

# len(cart) 會回傳商品數量
def __len__(self):
return len(self.items)

# cart1 + cart2 合併購物車
def __add__(self, other):
new_cart = Cart()
new_cart.items = self.items + other.items
return new_cart

# 使用範例
cart1 = Cart()
cart1.add("商品A")
cart1.add("商品B")

cart2 = Cart()
cart2.add("商品C")

print(cart1) # 購物車(2 件商品)
print(len(cart2)) # 1 (只有 1 件商品)

cart3 = cart1 + cart2
print(cart3) # 購物車(3 件商品)

我的策略

原本題目都是純語法練習,
但我刻意想展現的是:

  1. 知道什麼時候該用什麼方法
  2. 將演算法概念應用到實際業務
  3. 不是只會背語法,而是理解背後的適用場景

(fin)

[SDD 與 Spec Kit]傳統瀑布式開發(1975-2000)

TL;DR

當軟體開發還是一門工程學

1970 年代,當軟體開發還是一門新興學科時,人們自然地從傳統工程領域借鑒經驗。

蓋房子需要藍圖、造橋需要設計圖,那麼開發軟體當然也需要完整的規格文件——這個看似合理的類比,開啟了長達數十年的瀑布式開發時代。

有趣的是,瀑布式開發 - Waterfall 這個名詞首次出現在1970年 Winston Royce 的論文中

但 Royce 其實只是點名在當時的主流方法,並且給這種方法一些批評與建議。

只是產業界選擇性地抓住了 Buzz Word ”Waterfall” 並將其奉為圭臬,而忽略了他的警告。

當然這也不是第一次業界扭曲軟體工程理論的原意。

瀑布模式的黃金年代

流程的確立

瀑布式建立起一套看似完美的流程:

需求分析 → 系統設計 → 實作 → 測試 → 部署 → 維護

就像接力賽跑,一棒接一棒,每個階段都有明確的交付物和把關者。

需求分析階段產出厚達數百頁的需求規格書(SRS, Software Requirements Specification),

系統設計階段則有高階設計(HLD, High-Level Design)和低階設計(LLD, Low-Level Design)文件。

這些文件不只是參考,而是具有合約效力的承諾。

當時的人相信,只要文件夠詳細,開發就是照著做而已。

甚至 UML 也在那個時代應運而生。

角色分工的確立

這個時期也確立了軟體開發的專業分工:

  • PM(專案經理)負責管理時程與資源,通常不懂技術
  • SA(系統分析師)負責理解業務需求,把業務語言翻譯成技術語言
  • SD(系統設計師)負責技術架構,畫一堆UML圖
  • PG(程式設計師)負責編碼實作,被視為「碼農」
  • QA(品質保證工程師)負責測試驗證,專門找碴

這種分工暗示著「思考」和「執行」是可以分離的。

會思考的人負責設計,會打字的人負責coding。

這種階級觀念到今天都還沒完全消失。

為什麼瀑布式曾經如此成功?

無法失敗的時代背景

在1980年代,一套企業系統可能要價數百萬美元,開發週期動輒2-3年。

IBM的System/360開發耗資50億美元(相當於今天的400億),這種規模的投資容不得失敗。

瀑布式提供了管理層最需要的東西:控制力(或許只是幻覺)。

每個階段都有文件、有簽核、有人負責。出事了可以調出文件來看是誰的錯。

這種「可究責性」讓高層睡得安心。

適合當時的技術限制

當時的開發環境是什麼樣子?

  • 沒有 Git,版本控制用 CVS/RCS 等集中式系統,或原始的複製資料夾
  • 早期沒有 IDE,主要用 vi 或 Emacs,90年代才有原始的 IDE
  • 編譯大型專案可能要等數小時
  • 測試主要靠人工,自動化測試工具原始且昂貴
  • 部署要半夜停機,通過實體媒介發布

在這種環境下,改一行code的成本可能是現在的100倍。

「想清楚再動手」不只是最佳實踐,而是唯一選擇。

[個人經驗補充區]

瀑布式的內在矛盾

文件與現實的落差

最大的問題在於:軟體是看不見、摸不著的。

建築師可以做模型,讓客戶看到未來的大樓長什麼樣子。

但軟體呢?畫了100張UML圖,客戶還是不知道系統用起來是什麼感覺。

更慘的是,需求文件寫著「系統應支援多使用者同時操作」,但什麼叫「多」?

10個?1000個?每個人心中都有不同的數字,直到系統上線當機才發現認知落差。

溝通的斷層

瀑布式假設資訊可以無損地傳遞,但現實是每一次傳遞都會失真,每一層次轉換都是風險:

客戶說:「我要一個簡單的購物網站」

PM聽成:「要有會員系統、商品管理、訂單處理、金流串接…」

SA寫成:「系統應提供使用者友善的介面以利商品選購」

SD設計成:「採用三層式架構搭配MVC模式」

PG實作成:「if-else地獄加上全域變數」

最後客戶看到成品:「這不是我要的!」

變更的成本

瀑布式最致命的假設是:需求是可以預先確定且不會改變的。

但現實是,客戶看到系統之前,根本不知道自己要什麼。

網路時代的衝擊

指 dot com 泡沫到 web 2.0 的時代。

產品生命週期從年變成月

競爭對手不是隔壁公司,而是地球另一端的車庫創業

當你的競爭對手每週更新,而你還在等三個月後的UAT(使用者驗收測試),市場早就不見了。

搶佔市場的思維,改變了軟體工程,敏捷成為主流,我們之後再說。

但某些領域仍然需要瀑布式:

  • 醫療設備軟體:FDA要求完整的文件追蹤,一個bug可能害死人
  • 航太系統:NASA的軟體開發依然遵循嚴格的階段審查
  • 金融核心系統:當你處理的是別人的錢,「快速失敗」不是選項

這些領域的共同點是:錯誤的成本遠大於延遲的成本。

更深層的影響是,瀑布式留下的不只是流程,更是一種思維模式。

即使號稱敏捷的團隊,實際上仍在執行小瀑布

下一篇,我們將探討敏捷如何試圖解決這些問題,以及為什麼「敏捷」為什麼也失敗。

參考

(fin)

[學習筆記] SDD 與 Spec Kit - 前言

TL;DR

簡介 SDD

What is Spec-Driven Development?
Spec-Driven Development flips the script on traditional software development.
For decades, code has been king — specifications were just scaffolding we built and discarded once the “real work” of coding began
Spec-Driven Development changes this: specifications become executable, directly generating working implementations rather than jus
guiding them. – spec-kit

什麼是規格驅動開發 ???
跟過往的開放方式什麼不同???
什麼原因讓它興起 ???

傳統開發流程(1990之前)

回顧軟體開發的歷史,個人電腦時代到網路時代(1975~1990)

大部份是傳統的瀑布式開發(Waterfall)流程是這樣的:

像是接力賽跑一樣一棒接一棒。

需求分析 → 系統設計 → 實作 → 測試 → 部署 → 維護

每個階段都有明確的交付物:

需求分析:產出需求規格書(SRS),對應的角色會有 PM(產品或專案經理)

系統設計:產出設計文件(HLD/LLD),對應的角色會有 SA/SD(系統架構師/系統設計師)

實作階段:產出程式碼,對應的角色會有 RD(工程師)

測試階段:產出測試報告,對應的角色會有 QA(測試工程師)

這個流程有幾個特點:

線性且不可逆 - 每個階段必須完成才能進入下一階段,很難回頭修改

文件導向 - 大量的文件作為階段間的交接依據

後期才見成果 - 要等到實作階段完成,才能看到實際的軟體

變更成本高 - 越晚發現問題,修改成本越高

在這個模式下,規格文件的角色是契約和藍圖。團隊花費大量時間撰寫詳細的規格,試圖在開始編碼前就想清楚所有細節。

但實務上,這些文件往往:

  • 寫完就過時,因為需求會變
  • 與實作脫節,因為開發時會發現新問題
  • 成為負擔,因為要花時間維護卻沒人看

敏捷開發流程(1990~至今)

敏捷開發(Agile)的出現,是對瀑布式開發的反思。它強調:

  • 個人與互動 > 流程與工具
  • 可用的軟體 > 詳盡的文件
  • 客戶合作 > 合約協商
  • 回應變化 > 遵循計劃

敏捷的典型流程(以 Scrum 為例):

Product Backlog → Sprint Planning → Daily Standup → Sprint Review → Sprint Retrospective

這是一個迭代循環,每個 Sprint(通常 2-4 週)都會產出可用的軟體增量。

  • Product Backlog: 對齊高層期望,主要角色 Stackholder/Product Owner
  • Sprint Planning: 對齊實作時程與方式,主要角色 Product Owner/Team
  • Daily Standup: 對齊每日進度,全角色參與
  • Sprint Review: 對齊結果,全角色參與
  • Sprint Retrospective: 對齊改善,全角色參與

雖然實施部分的Scrum 是可能的,但結果就不是Scrum 了

敏捷的特點:

  • 迭代且增量 - 小步快跑,頻繁交付
  • 溝通導向 - 強調面對面溝通勝過文件
  • 早期且持續交付 - 每個 Sprint 都有可展示的成果
  • 擁抱變化 - 將變更視為常態而非例外

在敏捷模式下,規格文件的角色變成了溝通工具和備忘錄。

User Story、Acceptance Criteria 這些輕量級的規格,主要是為了確保團隊理解一致。

程式碼本身成為了真相的來源 - “Code is the truth”。

在迭代過後,這些文件不再有意義。

這解決了瀑布式的一些問題,但也帶來新的挑戰:

  • 知識容易流失(口頭溝通沒有記錄,使用團隊記憶)
  • 技術債累積(快速迭代可能犧牲品質)
  • 規格與實作的差距依然存在
  • 實際上團隊/文化等因素,在跑的只是 Scurm-but/小瀑布/隕石
  • 商業化的行為已經扭曲原意,甚至作為 KOL 老王賣瓜的神主牌 (看看”敏捷社群”)
  • 在大型產品/專案仍然不足夠,因而衍生 SAFe/LeSS 等方法論
  • 淪為表面功夫

隕石開發流程(Alpha~Omega)

典型流程

老闆一句話 → 隨便硬幹出來 → 夠奴就加班 → 交付疊加態 → 塊逃啊

  • 老闆一句話:靈感一來就開幹,需求模糊。
  • 隨便硬幹出來:原型被誤當產品,直接上線。
  • 夠奴就加班:團隊燃燒工時與理智,一次衝到底。
  • 交付疊加態:交付內容沒人知,時程沒人知,測沒測沒人知。
  • 塊逃啊:事後檢討變成批鬥大會,成功不在你,但失敗一定有份。

特點與本質

高壓短命、方向即興、溝通崩壞、文件失效、驗收模糊。
看似混亂,其實是一種生存策略(實用主義):

  • PM 可保命:有 Demo、有交付,能交差。
  • 行銷能吹噓:原型當 MVP,上媒體先贏聲量。
  • 市場能行(騙)銷:先開賣、後補齊。
  • 老闆能交代:有成果、有故事,股東就安心。
  • RD 隨便作,洗個經驗跳糟加薪比較快

隕石開發不是技術問題,而是組織文化的選擇。

這些流程的異同、優劣與困境

瀑布、敏捷與隕石開發的差別,反映的是組織文化與生存策略。

瀑布式重規劃與控制,適合穩定需求與高風險專案,但變更成本高、回饋慢。

敏捷式重回饋與溝通,能快速交付並擁抱變化。

隕石式則靠爆發力求生存,短期有成效,不考慮長期品質與維運。

我的看法是新創成長到高原期時,就會從隕石走而敏捷,再大規模就會考慮瀑布/LeSS/SAFe,

而市場大部份都是中小企業,真的能成長成巨人者(amazon/spotify/netfilx),

一定有它的原因,開發流程應該隨著公司業務成長,形成自已的文化,沒有一定的好壞,也不是可以輕易模仿的。

工程師的自我要求

我個人是傾向 XP 的開發模式,但是它的缺點是學習門檻高,

對面試沒幫助,更多的公司寧願你寫白板題或刷 LeetCode。

團隊不願意投入,相關的 TDD/ATDD/BDD 或是測試左移更難導入小團隊之中。

高層如果沒有技術能力,無法識別好壞,就容易被唬弄,導致需求與實作有落差

AI SDD 流程(2024~未來?)

AI Agent 或 SDD 出現後能不能帶來改變?

或是可以帶來什麼幫助呢?

對工程師而言

  • 內建 TDD,但是工程師仍要學習其觀念
  • 降低學習門檻,可以用更 Clean 的方式實作代碼
  • 全權委託,但是工程師要對架構負責(變得更像 SA ?)
  • 淘汰劣質工程師,ex 轉職班、就業班、刷題仔,但是有的很會行銷自已,會被唬弄過去

對產品而言

  • 可以縮短想法到實作的距離
  • 保持一定的品質,特別是你的工程師都是來自x成、x匠時…
  • 自已開發,更高的回饋

問題,認知的差距,人只能賺到自已認知能力內的錢

之前有一個網紅行銷使用 Agent 開發賣課,犯了最低級的錯誤

這是因為他缺乏資安的認知; 所以原本不是開發的人員會需要快速的學習相關的知識(但是 AI 可以將門檻降低)

另外一些反思,那種工程師看不上眼的程式(普遍社群上的工程師都在嘲諷),

人家已經拿來行銷賣錢,或許工程師應該反向思考怎麼朝行銷/產品靠攏

也或許這個低級錯誤也是行銷手段的一環?

接下來, 我將會用一系列的文章來試作 SDD 並分享我的看法

參考

(fin)

[實作筆記] 命令式程式碼重構到函數式 Pipe 流水線

前言

最近有位小朋友在進行中文轉換規則的開發時,遇到了一些典型的程式碼異味。

這些異味提示著我們需要重構,你可以先挑戰看一下有沒有辦法識別。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 重構前的程式碼
apply(text: string): string {
const protectedChars = ['台']

// 1. 標記階段
let protectedText = text
protectedChars.forEach((char) => {
protectedText = protectedText.replaceAll(char, `<<<${char}>>>`)
})

// 2. 轉換階段
let result = this.baseConverter(protectedText)

// 3. 還原階段
protectedChars.forEach((char) => {
const convertedChar = this.baseConverter(char)
result = result.replaceAll(`<<<${convertedChar}>>>`, char)
})

// 4. 自訂轉換
result = this.customConverter(result)

return result
}

這篇文章記錄重構的過程,讓程式碼變得更加簡潔和易讀。

當然,這只是其中一種可能的重構方式,可能有別的解法,或是單純的接受它。

壞味道

原始的 ChineseConversionRule 中的 apply 方法存在以下問題:

  1. 過多中間變數textprotectedTextresult
  2. 命令式寫法:透過變數重新賦值來處理資料流
  3. 邏輯分散:標記保護字符和還原的邏輯內嵌在主方法中

過多的中間變數和命令式的寫法讓程式碼顯得冗長且不夠優雅。

protectedText、result、text 都是。

這裡的挑戰是要對原始的字串加工

在不破壞原本邏輯的情況下,保留擴充的彈性,並保持程式結構清晰、可維護。

可能有很多模式可以解決(Decorator / Template Method / Pipeline)

小朋友寫得也不差了,我們來試著讓它更好

重構過程

階段一:消除中間變數

第一步是消除不必要的中間變數,直接在同一個變數上操作:

可以看到修改後,只有 text 一個變數,還是傳入了的

越少變數,越不用花心思思考命名,減少認知負擔,而本質上這些冗餘的變數的確是同質可以刪除的

這是一種隱性的重複壞味道。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apply(text: string): string {
const protectedChars = ['台']

// 直接操作 text 變數,消除 protectedText 和 result
protectedChars.forEach((char) => {
text = text.replaceAll(char, `<<<${char}>>>`)
})

text = this.baseConverter(text)

protectedChars.forEach((char) => {
const convertedChar = this.baseConverter(char)
text = text.replaceAll(`<<<${convertedChar}>>>`, char)
})

return this.customConverter(text)
}

階段二:引入 Functional Programming

接下來導入函數式程式設計的概念,使用 pipe 模式來處理資料流:

這裡要先看懂 pipe,簡單理解它把一個初始值依序丟進多個函數,前一個輸出就是下一個的輸入。

可以看到一些明顯的壞味道,重複的 protectedChars,1 跟 3 本身是匿名函數,讀起來也沒那麼好理解,

這是重構必經之路,我們再往下走

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apply(text: string): string {
const protectedChars = ['台']

return this.pipe(
text,
// 1. 標記階段:將需要保護的字符標記為不轉換
(input) => protectedChars.reduce((acc, char) =>
acc.replaceAll(char, `<<<${char}>>>`), input),

// 2. 轉換階段:使用 OpenCC 進行轉換
this.baseConverter,

// 3. 還原階段:將標記的字符還原為原始字符
(input) => protectedChars.reduce((acc, char) => {
const convertedChar = this.baseConverter(char)
return acc.replaceAll(`<<<${convertedChar}>>>`, char)
}, input),

// 4. 使用自訂轉換器進行模糊字詞的修正
this.customConverter
)
}

// 自製的 pipe 函數
private pipe<T>(value: T, ...fns: Array<(arg: T) => T>): T {
return fns.reduce((acc, fn) => fn(acc), value)
}

階段三:職責分離

將標記和還原邏輯抽取成獨立的私有方法:

將重複出現的 protectedChars 提升為類別屬性:

也不需要把匿名函數寫一坨在 pipe 裡面,這時要煩惱的只有方法的名字要怎麼才達意

但至少我們還有註解。

更進一步可以簡化方法內的參數名,因為 scope 很小,不會有認知負擔

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
export class ChineseConversionRule implements IRule {
private baseConverter: ConvertText
private customConverter: ConvertText
private readonly protectedChars = ['台'] // 統一管理

// ... constructor

apply(text: string): string {
return this.pipe(
text,
// 1. 標記階段:將需要保護的字符標記為不轉換
this.markProtectedChars,

// 2. 轉換階段:使用 OpenCC 進行轉換
this.baseConverter,

// 3. 還原階段:將標記的字符還原為原始字符
this.restoreProtectedChars,

// 4. 使用自訂轉換器進行模糊字詞的修正
this.customConverter
)
}

/**
* 標記需要保護的字符
*/
private markProtectedChars = (input: string): string => {
return this.protectedChars.reduce((acc, c) => acc.replaceAll(c, `<<<${c}>>>`), input)
}

/**
* 還原被標記的保護字符
*/
private restoreProtectedChars = (input: string): string => {
return this.protectedChars.reduce((acc, c) => {
const convertedChar = this.baseConverter(c) // 例如:'台' -> '臺'
return acc.replaceAll(`<<<${convertedChar}>>>`, c)
}, input)
}

重構成果

  1. 簡潔性:主方法從 20 行縮減到 8 行
  2. 可讀性:資料流向清晰,從上到下一目了然
  3. 可測試性:每個步驟都是純函數,可以獨立測試
  4. 可維護性:職責分離,邏輯集中管理
  5. 函數式:無副作用,符合 FP 原則

效能考量

  • 測試結果顯示功能完全正常,262 個測試案例全數通過,這是個大前提,沒有測試沒有重構
  • 重構過程中沒有改變演算法複雜度
  • Pipe 函數本身的開銷微乎其微

關於 Pipe 的選擇

在重構過程中考慮過使用現成的函式庫:

  • Ramda:功能最完整的 FP 函式庫
  • Lodash/fp:輕量級選擇
  • fp-ts:型別安全的 FP 函式庫

最終選擇自製 pipe 函數的理由:

兩行程式碼,沒有多餘依賴,寫法完全貼合專案需求。

團隊看了就能用,不用再去學新的函式庫。

邏輯也很單純,後續維護起來相對輕鬆。

除非更大範圍的重複發生,不然不需要額外引用套件突增學習成本

1
2
3
private pipe<T>(value: T, ...fns: Array<(arg: T) => T>): T {
return fns.reduce((acc, fn) => fn(acc), value)
}

RD 反饋

這次重構讓我深刻體會到函數式程式設計的優雅之處:

  1. 資料即流水線:透過 pipe 讓資料在各個函數間流動
  2. 純函數的威力:每個步驟都可預測、可測試
  3. 組合勝過繼承:透過函數組合建構複雜邏輯
  4. 漸進式重構:一步步改善,降低風險

從命令式到函數式的重構不僅讓程式碼變得更優雅,也提升了整體的可維護性。

雖然函數式程式設計有一定的學習曲線,但一旦掌握了基本概念,就能寫出更簡潔、更易懂的程式碼。

重構的關鍵在於:小步快跑,持續改善。每一次小的改進都讓程式碼朝著更好的方向發展,這正是軟體工藝精神的體現。

小結

嗯,小朋友很會用 AI 寫作文呢。

(fin)

[實作筆記] Clean Architecture 思考:避免過度設計

前情提要

在實作強型別語言,經常會遇到一些僅次於命名的問題:

到底要建立多少層級的 DTO/型別?

什麼時候該抽象,什麼時候該保持簡單?

這篇文章記錄一個前端要求帶來的反思,如何在「架構純粹性」與「實用主義」之間找到平衡點?

問題場景

系統架構為 TypeScript 並實作 Clean Architecture。

我們有一個比對系統,Domain Entity 中的 statusCode 定義為 string | null

但前端要求 API 必須回傳 string 型別,允許空字串而不處理 null。

1
2
3
4
5
6
7
8
9
// Domain Entity
export class Comparison {
statusCode: string | null // 業務邏輯:可能為空
}

// 前端期望的 API 回應
{
"statusCode": "" // 必須是 string,不能是 null
}

解決方案演進

第一版:UseCase 層建立完整 DTO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 建立回應 DTO
export interface ComparisonResponseDTO {
id: string
statusCode: string // 轉換為 string
// ... 其他欄位
}

// 建立 UseCase 回應型別
export type GetComparisonListResDTO = IDataAndCount<ComparisonResponseDTO>

// UseCase 中處理轉換
private toResponseDTO(comparison: Comparison): ComparisonResponseDTO {
return {
// ...
statusCode: comparison.statusCode || '',
// ...
}
}

看起來很「Clean Architecture」,但真的有必要嗎?

關鍵觀點,有沒有可能過度設計了?

最終方案:Controller 邊界處理

1
2
3
4
5
6
7
8
9
10
11
12
// Controller 直接處理轉換
async getComparisonList(req: Request, res: Response): Promise<void> {
const { data, count } = await this.getComparisonListUseCase.execute(input)

res.status(200).json({
data: data.map(comparison => ({
...comparison,
statusCode: comparison.statusCode || ''
})),
pagination
})
}

避免型別地獄的原則

1. YAGNI 原則 (You Aren’t Gonna Need It)

不要為了「完整性」而建立無意義的型別包裝

或是建立過多的 Mapper 類別

1
2
3
4
5
// ❌ 過度抽象
export type GetComparisonListResDTO = IDataAndCount<ComparisonResponseDTO>

// ✅ 直接使用泛型
IUseCase<GetComparisonListReqDTO, IDataAndCount<ComparisonResponseDTO>>

2. 什麼時候需要抽象化?

我的判斷,重複的時候,例如,當 2 個以上的 API 需要相同的型別時,才考慮抽象

也不排除可以能轉換極度複雜,這時有一個 Mapper 的導入反而可以減輕負擔時才導入。

架構/Design Pattern 應該服務 RD 而不是折摩 RD

1
2
3
4
5
6
7
8
// 如果只有一個 API 使用,直接 inline
data.map(item => ({ ...item, statusCode: item.statusCode || '' }))

// 如果多個 API 都需要,才建立共用函數或型別
const transformComparison = (item: Comparison) => ({
...item,
statusCode: item.statusCode || ''
})

3. 複雜度評估

簡單的轉換邏輯不需要額外抽象:

1
2
3
4
5
6
7
// ✅ 簡單轉換,直接處理
statusCode: comparison.statusCode || ''

// ❌ 為簡單邏輯建立複雜抽象
private transformStatusCode(statusCode: string | null): string {
return statusCode || ''
}

實用主義 vs 理論完美

現代 TypeScript 最佳實踐

  1. 直接使用泛型 - 避免不必要的 type alias
  2. 減少型別層級 - 除非有明確的業務意義
  3. 保持簡潔 - 不為了「完整性」而建立無意義的包裝

架構決策的平衡點

考量因素 過度設計 適度設計 設計不足
型別數量 為每個 UseCase 建專屬 DTO 共用 + 泛型 沒有型別安全
轉換位置 每層都轉換 邊界處理 隨意放置
複雜度 型別地獄 恰到好處 難以維護

進階判斷:何時需要抽象,何時保持簡單?

判斷角度 適合抽象化的情境 適合保持簡單的情境
使用頻率 多個 UseCase 或 API 重複出現 → 建立共用型別/函數 僅在單一 Controller 出現一次 → inline 即可
業務語意 欄位轉換具有業務意義(例:狀態機的一環) → 抽象進 Domain/UseCase 純展示需求(例:null → "") → 邊界層處理
團隊規模與可讀性 大團隊、專案壽命長 → 適度抽象,降低未來重構風險 小團隊、短期專案 → 保持簡單,降低溝通成本
錯誤影響範圍 錯誤會影響商業邏輯(例:金額精度) → 上升到 UseCase/Domain 只影響 API 輸出表現(例:空字串 vs. null) → Controller 處理
演進空間 需求可能持續變化(欄位規則增多/不同前端需求) → 抽象留彈性 需求相對穩定 → inline 保持簡潔

小結

Clean Architecture 的精神是將商業邏輯集中在核心,邊界負責格式適配

不要被「層級完整性」綁架,重點是:

  1. Domain 保持純粹 - 反映真實的業務狀態
  2. UseCase 專注邏輯 - 編排業務流程,不處理格式
  3. Controller 處理邊界 - HTTP 格式轉換在此進行
  4. 實用主義優先 - 簡單的需求用簡單的方法

記住:改動越少越好,沒有必要就不要建一大堆型別

架構是為了解決問題,不是為了展示理論知識。當實用主義與理論衝突時,選擇能讓團隊更有生產力的方案。

(fin)

Please enable JavaScript to view the LikeCoin. :P