[生活筆記] 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)

[實作筆記] PaddleOCR ONNX 模型發布到 Hugging Face Hub

前情提要

專案需要使用 PaddleOCR 進行文字辨識,但原始模型檔案需要從 PaddlePaddle 框架轉換成 ONNX 格式才能在不同環境中使用。

轉換完成後,面臨一個選擇:這些模型檔案要怎麼發布?放在專案內部?還是公開分享?

經過一番討論,決定將轉換後的 ONNX 模型公開發布到 Hugging Face Hub,一方面解決內部使用需求,另一方面也讓社群受益。

這篇記錄整個發布流程的實作過程與踩雷經驗。

目標

  • 將 5 個 PaddleOCR ONNX 模型檔案 (208MB) 發布到 Hugging Face
  • 建立完整的使用文檔與授權聲明
  • 提供簡單的下載方式給其他開發者
  • 確保版權合規 (原始模型為 Apache 2.0 授權)

模型檔案清單

轉換完成的檔案包含:

  • PP-OCRv5_server_det_infer.onnx (84MB) - 文字檢測
  • PP-OCRv5_server_rec_infer.onnx (81MB) - 文字識別
  • UVDoc_infer.onnx (30MB) - 文檔矯正
  • PP-LCNet_x1_0_doc_ori_infer.onnx (6.5MB) - 文檔方向
  • PP-LCNet_x1_0_textline_ori_infer.onnx (6.5MB) - 文字方向
  • PP-OCRv5_server_rec_infer.yml (145KB) - 配置檔案

平台選擇:為什麼是 Hugging Face?

本來考慮幾個選項:

  • GitLab:現有平台,整合容易
  • GitHub:開發者友好,但大檔案處理麻煩
  • Hugging Face:AI 模型專業平台

最終選擇 Hugging Face 的原因:

  • ✅ 完全免費,50GB 額度綽綽有餘
  • ✅ 原生支援 ONNX 格式
  • ✅ 內建 Git LFS,大檔案處理無痛
  • ✅ 全球 CDN,下載速度快
  • ✅ 社群友好,可能吸引貢獻者

實作過程

Phase 1: 準備階段

首先建立 Hugging Face 帳號並設定環境:

1
2
3
4
5
# 安裝 HF CLI
uv add huggingface_hub

# 設定 Token (需要 Write 權限)
export HF_TOKEN="your_token_here"

建立模型 Repository:

1
2
3
4
5
6
7
8
9
10
11
12
13
from huggingface_hub import create_repo, whoami

# 驗證登入
user = whoami()
print(f"登入用戶: {user['name']}")

# 建立 repository
repo_url = create_repo(
repo_id="paddleocr-test",
repo_type="model",
private=False
)
print(f"Repository 建立成功: {repo_url}")

Phase 2: 上傳模型檔案

1
2
export HF_TOKEN=your_token
hf upload marsena/paddleocr-test ./model_cache/paddleocr_onnx/ --repo-type=model

實際上傳時間約 3 分鐘,比預期快很多。

Phase 3: 撰寫文檔

Hugging Face 的 README.md 比一般 GitHub 專案更重要,因為它就是模型的門面。

撰寫重點:

  1. 清晰的模型說明:每個檔案的用途
  2. 具體的使用範例:Python 程式碼示範
  3. 完整的授權聲明:避免版權爭議
  4. 技術細節:系統需求、相容性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# PaddleOCR ONNX Models

本倉庫提供 PaddleOCR v5 的 ONNX 格式模型檔案...

## 快速開始

```python
from huggingface_hub import hf_hub_download

# 下載檢測模型
det_model = hf_hub_download(
repo_id="marsena/paddleocr-test",
filename="PP-OCRv5_server_det_infer.onnx"
)

需要注意 YAML metadata 的格式問題,HF 會檢查語法:

1
2
3
4
5
6
7
8
---
tags:
- onnx
- ocr
- paddleocr
- computer-vision
license: apache-2.0
---

Phase 4: 測試驗證

上傳完成後務必測試下載功能:

1
2
3
4
5
6
# 測試單檔下載
hf download marsena/paddleocr-test PP-OCRv5_server_det_infer.onnx

# 測試整包下載
hf download marsena/paddleocr-test --local-dir ./test_download \
--exclude "README.md" --exclude ".gitattributes"

一些要注意的小問題

HF Token 權限設定

  • Read:只能下載
  • Write:可以上傳模型
  • Admin:可以刪除 repo

建立 token 時記得選擇 Write 權限。

Git LFS 檔案大小限制

Hugging Face 對單檔大小有限制:

  • 一般檔案:< 10MB
  • LFS 檔案:< 50GB

我們的最大檔案 84MB,自動走 LFS 沒問題。

背景上傳的重要性

大檔案上傳可能需要 10-30 分鐘,SSH 連線容易斷開。務必使用 nohupscreen

README 的 YAML 語法檢查

Hugging Face 會檢查 YAML metadata 語法,格式錯誤會有警告(但不影響功能)。

使用方式

發布完成後,其他開發者可以這樣使用:

1
2
3
4
5
# 下載所有模型到專案目錄
hf download marsena/paddleocr-test \
--local-dir ./model_cache/paddleocr_onnx \
--exclude "README.md" \
--exclude ".gitattributes"

或在 Python 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from huggingface_hub import hf_hub_download
import os

model_files = [
"PP-OCRv5_server_det_infer.onnx",
"PP-OCRv5_server_rec_infer.onnx",
"UVDoc_infer.onnx",
"PP-LCNet_x1_0_doc_ori_infer.onnx",
"PP-LCNet_x1_0_textline_ori_infer.onnx",
"PP-OCRv5_server_rec_infer.yml"
]

cache_dir = "model_cache/paddleocr_onnx"
os.makedirs(cache_dir, exist_ok=True)

for file in model_files:
hf_hub_download(
repo_id="marsena/paddleocr-test",
filename=file,
local_dir=cache_dir
)

小結

整個發布流程花費約 5-8 小時:

  • 準備和上傳:2 小時
  • 文檔撰寫:3 小時
  • 系統整合:2 小時
  • 測試驗證:1 小時

Hugging Face Hub 確實是發布 AI 模型的好選擇,特別是:

  1. 無痛 LFS:大檔案處理完全自動化
  2. 全球加速:下載速度比自建服務快
  3. 社群生態:容易被發現和使用
  4. 版本控制:Git-based,開發者熟悉

如果你也有 ONNX 模型需要分享,不妨考慮 Hugging Face Hub。畢竟專業的事交給專業的平台來做,我們專注在模型本身就好。

參考連結

(fin)

[實作筆記] 建立私有 Python Package Registry - 以 ONNX Runtime 為例

前情提要

在開發 AI 應用時,我們遇到一些特殊的依賴管理需求,需要私有 Registry

在這個專案中,我們遇到了幾個挑戰:

  1. 平台特化需求:Jetson 平台需要特製的 onnxruntime-gpu 版本,PyPI 上沒有現成的
  2. 版本一致性:確保所有環境(開發、測試、生產)使用完全相同的依賴版本
  3. 安全性考量:避免依賴外部不穩定的來源,降低供應鏈攻擊風險
  4. 內部套件分發:團隊開發的內部工具需要有管道分發

私有 Registry 運作流程

私有 Package Registry 的運作包含三個階段:

  1. 套件發布階段
    開發者 → 推送程式碼 → GitLab CI/CD → 構建套件 → 推送至 Package Registry

  2. 套件管理階段
    Package Registry ← 特製版本套件(如 onnxruntime-gpu for Jetson)
             ← 內部工具套件
             ← 安全審核過的第三方套件

  3. 套件使用階段
    專案 pyproject.toml → 指定私有來源 → 安裝依賴 → 取得正確版本

這樣確保所有環境都使用一致且可控的套件版本。

實作步驟

發佈套件到 GitLab Package Registry

在設定和使用私有 Registry 之前,我們需要先將套件上傳到 GitLab Package Registry。

使用 twine 上傳套件

twine 是 Python 官方推薦的套件上傳工具,會自動從 wheel 檔案中提取 metadata:

1
2
3
4
5
6
7
8
# 安裝 twine
pip install twine

# 上傳到 GitLab PyPI registry
twine upload --repository-url https://gitlab.com/api/v4/projects/{PROJECT_ID}/packages/pypi \
--username gitlab+deploy-token-{TOKEN_ID} \
--password {TOKEN} \
your_package.whl

實際範例(以 ONNX Runtime 為例):

1
2
3
4
twine upload --repository-url https://gitlab.com/api/v4/projects/70410750/packages/pypi \
--username gitlab+deploy-token-9097451 \
--password gldt-tQVwLxzydZBhn3WWnwtt \
onnxruntime_gpu-1.23.0-cp310-cp310-linux_aarch64.whl

設定 GitLab Package Registry

首先,在 GitLab 專案中啟用 Package Registry,並建立 Deploy Token:

在 GitLab 專案設定中建立 Deploy Token, Settings → Repository → Deploy Tokens

權限:read_package_registry, write_package_registry

記下 token ID 和 token 值,格式如下:

  • Token ID: deploy-token-{ID}

  • Token: {TOKEN}

取得 Project ID

有三種方式可以找到 GitLab Project ID:

從專案首頁:

進入你的 GitLab 專案首頁
Project ID 會顯示在專案名稱下方
例如:Project ID: 12345678

從專案設定頁面:

進入 Settings → General
在最上方的 “General project settings” 區塊
可以看到 Project ID

從 GitLab API:

如果你在 CI/CD pipeline 中,可以直接使用環境變數 $CI_PROJECT_ID
這個變數會自動帶入當前專案的 ID

配置專案的依賴管理

pyproject.toml 中設定私有 registry:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 定義私有 index
[[tool.uv.index]]
name = "onnx"
url = "https://gitlab+deploy-token-{TOKEN_ID}:{TOKEN}@gitlab.com/api/v4/projects/{PROJECT_ID}/packages/pypi/simple"
default = false

# 指定套件來源
[tool.uv.sources]
onnxruntime-gpu = { index = "onnx" }

# 多平台依賴策略
dependencies = [
# macOS ARM64 使用 CPU 版本
"onnxruntime==1.19.0; sys_platform == 'darwin' and platform_machine == 'arm64'",
# Jetson 使用私有倉庫的 GPU 版本
"onnxruntime-gpu; sys_platform == 'linux' and platform_machine == 'aarch64'",
]

這裡的關鍵是使用條件依賴,根據不同平台安裝不同版本的套件。

開發者使用私有 Registry

根據專案的安全策略,RD 有幾種方式存取私有 registry:

選項 1:Token 內嵌在 pyproject.toml(簡單但不安全)

如果 pyproject.toml 中已經包含完整的認證 URL:

1
2
3
4
5
# 直接安裝依賴
uv sync

# 或使用 pip
pip install -e .

選項 2:使用環境變數(推薦)

pyproject.toml 中使用佔位符:

1
url = "https://deploy-token-{TOKEN_ID}:${GITLAB_TOKEN}@gitlab.com/api/v4/projects/{PROJECT_ID}/packages/pypi/simple"

RD 需要設定環境變數:

1
2
export GITLAB_TOKEN=your_deploy_token
uv sync

選項 3:使用認證檔案

設定 pip 或 uv 的認證檔案:

1
2
3
4
5
# 建立 pip 配置
cat > ~/.pip/pip.conf <<EOF
[global]
extra-index-url = https://deploy-token-{TOKEN_ID}:{TOKEN}@gitlab.com/api/v4/projects/{PROJECT_ID}/packages/pypi/simple
EOF

選項 4:企業內網存取

如果使用企業內網或 VPN:

1
2
# 連接 VPN 後直接使用
uv sync

CI/CD 中存取私有 Registry

專案在 GitLab CI/CD 中需要存取私有 Registry 時,有以下幾種方式:

方法 1:使用 CI_JOB_TOKEN(推薦)

1
2
3
4
5
6
7
8
9
10
# .gitlab-ci.yml
variables:
PIP_INDEX_URL: https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/api/v4/projects/${REGISTRY_PROJECT_ID}/packages/pypi/simple
PIP_EXTRA_INDEX_URL: https://pypi.org/simple

build:
stage: build
script:
- pip install -e .
- # 其他構建步驟

方法 2:使用 Deploy Token

在 CI/CD 變數中設定 GITLAB_DEPLOY_TOKEN

1
2
3
4
5
build:
stage: build
script:
- export PIP_INDEX_URL="https://deploy-token-{TOKEN_ID}:${GITLAB_DEPLOY_TOKEN}@gitlab.com/api/v4/projects/${REGISTRY_PROJECT_ID}/packages/pypi/simple"
- pip install -e .

方法 3:配合 uv 使用

1
2
3
4
5
6
7
build:
stage: build
before_script:
- export GITLAB_TOKEN=${CI_JOB_TOKEN}
script:
- uv sync
- # 其他構建步驟

常見問題與解決方案

Deploy Token 權限不足?

確保 Deploy Token 有 read_package_registrywrite_package_registry 權限。

套件版本衝突?

使用 uv 的 resolution markers 功能,明確指定版本解析策略。

Registry URL 格式錯誤?

GitLab PyPI registry URL 格式為:

1
https://gitlab.com/api/v4/projects/{PROJECT_ID}/packages/pypi/simple

注意將 {PROJECT_ID} 替換為實際的專案 ID。

小結

透過建立私有 Package Registry,我們成功解決了:

  • 特製版本管理:Jetson 平台的特殊 ONNX Runtime 版本需求
  • 依賴一致性:所有環境使用相同版本的套件
  • 安全性提升:減少對外部來源的依賴
  • 存取便利性:開發者和 CI/CD 都能輕鬆存取私有套件

這個架構不僅適用於 ONNX Runtime,也可以擴展到其他需要特殊管理的依賴套件。

(fin)

[實作筆記] 怎麼建立一個網站?(四) - 自訂網域 EMail 收寄信(使用 Cloudflare 與 Brevo)

前情提要

四年前「怎麼建立一個網站?(四) - 自訂網域 EMail」已過時了,
當時透過 Google Domain 設定了自己的網域信箱,那種擁有 [email protected] 的專業度真的是滿到溢出來。

但是,現實總是殘酷的。

2023 年 Google Domain 宣布停止營運,我選擇轉移到了 Cloudflare。

更悲劇的是,Gmail 的應用程式密碼也在安全性考量下越來越不被推薦使用。

Google 對 Gmail SMTP 的使用限制越來越嚴格。

在開啟 2FA 的情況下,只能使用應用程式密碼,但是 Google 本身也不推薦這樣使用密碼。

問題分析

現有問題:

  • Google Domain 停止服務,Cloudflare 雖然接管了域名,但沒有提供類似的信箱轉發服務
  • Gmail 無法繼續使用 Email Forwarding 的密碼方式不再被推薦使用,我也沒有查到替代方案

需求:

  • 能夠使用 [email protected] 收信,已完成(在 Cloudflare 設定 Email Forwarding)
  • 能夠以 [email protected] 寄信,而且不會被標記為可疑郵件
  • 免費或便宜的解決方案
  • 設定要簡單快速

技術選擇的思考

經過一番調研,我把目標鎖定在幾個主流的 SMTP 服務:

  1. Brevo (原 Sendinblue):免費額度每天 300 封信,付費方案 $25/月
  2. Mailjet:免費額度每天 200 封信,付費方案 $17/月
  3. SendGrid:免費額度每天 100 封信
  4. Amazon SES:按量計費,超便宜但設定複雜

綜合考量下,我選擇 Brevo 的理由:

  • 價格最划算:免費額度最高(300封/天),對個人使用綽綽有餘
  • 功能完整:不只是 SMTP,還有完整的郵件行銷功能
  • 設定超快:API 整合簡單,文件齊全
  • 信譽良好:歐洲公司,GDPR 合規,信件到達率高

備案是 Mailjet,但既然 Brevo 實際使用很順利,我就沒有去試了。

實作過程

Step 1: 註冊 Brevo 帳號

前往 Brevo 官網 註冊免費帳號,過程很簡單,不需要信用卡。

可以用 Google 帳號註冊,但是填寫一些資料,總體來說並不冗長繁瑣。

Step 2: 設定 Sending Domain

登入後台,進入「Senders & IP」→「Domains」,添加你的域名(如 marsen.me)。

Brevo 會要求你在 DNS 域名設定中添加幾筆記錄(我是使用 Cloudflare)來驗證域名擁有權:

設定過程也很傻瓜點擊跟著操作就會引導你登入 Cloudflare 的後台,不保証其他域名商有這麼方便,

會在 Cloudflare DNS 管理中添加:2 筆 CNAME 與 2 筆 TXT 記錄,應該是讓 Brevo 驗証網域所有權的。

DNS 生效後,Brevo 就會驗證通過。

Step 3: 設定收信

設定 Email Forwarding 這部分我直接在 Cloudflare 設定:

「Email」→「Email Routing」→「Routes」

添加轉發規則:

從:[email protected] 到我的 gmail

這樣就能收到寄往自訂域名的信了。

Step 4: 設定 Gmail 使用 Brevo SMTP

進入 Gmail 設定 > 帳戶和匯入 > 新增另一個電子郵件地址:

  • 名稱:Marsen
  • 電子郵件地址:[email protected]
  • SMTP 伺服器:smtp-relay.brevo.com
  • 通訊埠:587
  • 使用者名稱:你的 Brevo 帳號 email
  • 密碼:去 Brevo 後台「Account Settings」→「SMTP & API」生成的 SMTP Key
  • 設定完成後,Gmail 會寄驗證信,確認後收到的信才不會有警告。

實測結果

設定完成實測:

  • ✅ 收信正常:寄到 [email protected] 的信都能在 Gmail 收到
  • ✅ 寄信正常:從 Gmail 可以選擇用 [email protected] 寄信
  • ✅ 信譽良好:收件者不會看到「未驗證」警告

整個設定過程不到 20 分鐘。

一些要注意的小問題

DNS 生效時間
SPF、DKIM 記錄可能需要幾個小時才會完全生效,不要急著測試。

但是我實測約幾分鐘就生效了。

SMTP Key 不是密碼
Brevo 的 SMTP Key 是專門給 API 和 SMTP 用的,不是你登入密碼。

免費額度限制
每天 300 封信對我個人使用很夠,但如果你要大量寄信,記得升級付費方案。

參考

系列文章

(fin)

[架構筆記] Clean Architecture 分層職責的反思

前情提要

最近在 Code Review 時遇到一個有趣的題目:

檔案上傳功能中,檔案編碼修復應該放在 Middleware 還是 UseCase?

這個問題引發了我對 Clean Architecture 分層職責的重新思考,與團隊背後的設計哲學。

問題背景

在一個採用 Clean Architecture 的專案中,我們有兩個檔案上傳功能:

  • 知識庫檔案上傳 - 支援 PDF、Word、圖片等多種格式
  • 合約檔案上傳 - 原始合約支援 PDF/Word,簽署後合約只支援 PDF

當前系統架構包含:

  • Middleware - Express 中介軟體層
  • Controller - HTTP 請求處理層
  • UseCase - 業務邏輯層
  • Service - 基礎設施服務層

核心問題:分層職責如何劃分?

以下邏輯應該放在哪一層?

  1. 檔案編碼修復 - 解決中文檔名亂碼問題 (latin1 → utf8)
  2. 檔案類型驗證 - 檢查 MIME type 是否符合業務需求
  3. 檔案大小限制 - 根據不同業務場景設定不同大小限制
  4. JWT Token 驗證 - 檢查使用者身份
  5. 檔案內容解析 - 提取 PDF/Word 文件內容

分析思路 — 職責角度

Middleware 的職責:偏向基礎設施

控制進出流程 → 例:API 請求進來先檢查 Token

過濾與轉換資料 → 例:將日期字串轉成標準格式

保護系統邊界 → 例:攔截未授權的存取

以下不舉例:

  • 跨領域技術問題 (認證、編碼、錯誤處理)
  • HTTP 協議相關 (請求解析、回應格式)
  • 基礎設施關注點 (日誌、監控、安全)
  • 與業務無關的技術細節

Use Case 的職責:偏向商業邏輯

驅動核心行為 → 例:建立一筆訂單流程

執行業務規則 → 例:檢查庫存是否足夠

協調內外資源 → 例:呼叫付款服務並更新資料庫

以下不舉例:

  • 特定業務場景的規則 (不同業務有不同檔案限制)
  • 領域知識驗證 (合約標題、內容檢查)
  • 業務流程邏輯 (檔案處理、資料儲存)
  • 動態配置的業務參數 (從環境變數讀取的業務限制)

實際案例分析

檔案編碼問題

技術問題 → 整個系統一體適用,選 Middleware ✅

細節分析:

  • 這是 HTTP 上傳過程中的技術問題,不是業務邏輯
  • 所有檔案上傳都需要這個處理,跨多個 UseCase
  • 屬於請求處理層面的責任

檔案類型限制

業務邏輯 → 不同的上傳任務有不同限制,屬商業邏輯,選 UseCase ✅

細節分析:

  • 不同業務場景有不同的檔案類型限制
  • 這是領域知識,需要業務邏輯判斷
  • 可能隨著業務需求變化而調整

這頭給你想

如果檔案大小限制要動態調整呢?

總結

好的架構設計不是憑感覺,而是要有明確的原則:

  • Middleware = 技術基礎設施,處理 HTTP 層面的問題
  • UseCase = 業務邏輯驗證,處理領域相關的規則

這種分層不只讓程式碼更好維護,也讓測試更容易撰寫,更符合單一職責原則。

下次在設計架構時,不妨問問自己:

  • 這個邏輯是技術問題還是業務問題?
  • 這個規則會因為業務需求變化嗎?
  • 這個處理邏輯需要在多個地方重複嗎?

Clean Architecture 的精神就在於:讓業務邏輯獨立於技術細節

(fin)

[學習筆記] Node.js 檔案操作 mkdir 的正確姿勢

前情提要

在 code review 中 RD 寫了以下的程式

1
2
3
if (!await this.exists(fullPath)) {
await fs.promises.mkdir(fullPath, { recursive: true })
}

看似合理的「檢查然後執行」(Check-Then-Act)模式可能導致的併發問題。

假設兩個請求同時上傳檔案到同一個不存在的目錄:

1
2
3
4
5
時間線:
T1: 請求A 執行 this.exists(fullPath) → 返回 false (目錄不存在)
T2: 請求B 執行 this.exists(fullPath) → 返回 false (目錄不存在)
T3: 請求A 執行 mkdir(fullPath) → 成功建立目錄
T4: 請求B 執行 mkdir(fullPath) → 可能拋出 EEXIST 錯誤(fs.promises.mkdir 已在底層排除這個問題)

雖然使用了 recursive: true,那前面的檢查很可能是不必要的行為。

為什麼會這樣?

問題的根源在於時間窗口。兩個步驟之間存在時間差,而這個時間差就是競態條件的溫床:

1
2
3
4
5
// ❌ 有時間窗口的寫法
if (!await this.exists(fullPath)) { // 步驟1: 檢查
// 👆 這裡到下面之間就是危險的時間窗口
await fs.promises.mkdir(fullPath, { recursive: true }) // 步驟2: 執行
}

解決方案

方法1: 直接使用 mkdir(推薦)

1
2
3
4
5
6
7
8
async uploadFile(file: UploadedFile, pathToStore?: `/${string}`): Promise<string> {
const fullPath = `${this.uploadDir}${pathToStore ?? ''}`
// 直接建立目錄,recursive: true 會自動處理已存在的情況
await fs.promises.mkdir(fullPath, { recursive: true })
const uploadPath = path.join(fullPath, file.originalName)
await fs.promises.writeFile(uploadPath, file.buffer)
return uploadPath
}

為什麼 recursive: true 已經足夠

根據 Node.js 官方文件,fs.promises.mkdir(path, { recursive: true }) 具有以下特性:

  1. 自動建立父目錄:如果父目錄不存在會自動建立
  2. 處理已存在目錄:當 recursivetrue 時,如果目錄已存在不會拋出錯誤
  3. 簡化錯誤處理:避免了手動檢查目錄是否存在的需要

官方範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { mkdir } from 'node:fs';

// Create ./tmp/a/apple, regardless of whether ./tmp and ./tmp/a exist.
mkdir('./tmp/a/apple', { recursive: true }, (err) => {
if (err) throw err;
});

這就像餐廳點餐的差別:

```typescript
// ❌ 競態條件版本(不好的做法)
if (餐廳沒有準備我要的餐) { // 檢查
請廚師準備這道餐 // 執行
}
// 問題:兩個客人可能同時檢查到「沒有」,然後都要求準備

// ✅ 直接執行版本(好的做法)
請廚師準備這道餐,如果已經有了就不用重複準備
// 廚師會自己判斷是否需要準備,避免重複工作

效能優勢

修復後還有意外的效能提升:

1
2
3
4
5
6
// 修復前:2次系統調用
await this.exists(fullPath) // 系統調用1: stat()
await fs.promises.mkdir() // 系統調用2: mkdir()

// 修復後:1次系統調用
await fs.promises.mkdir(fullPath, { recursive: true }) // 系統調用1: mkdir()

經驗教訓

  1. 避免 Check-Then-Act 模式:這是併發程式設計的經典陷阱,不過這次案例,執行的底層實作已處理好,所以不會有問題
  2. 信任系統調用:現代 API 通常已經考慮了併發場景
  3. 簡單就是美:移除不必要的檢查邏輯,程式碼更簡潔也更安全

參考

(fin)

Please enable JavaScript to view the LikeCoin. :P