[實作筆記] 命令式程式碼重構到函數式 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)

[實作筆記] AI Agent 實作 Web Search API:從設計到部署的完整記錄

前情提要

最近購買 Claude Code,在此同時也有試用 Github Copilot / Gitlab Duo / Gemini / Amazon Q

剛好手上有需求,是簡單的 API 串接,但是我們的系統架構有一些只有團隊知道的 Know How。

我想試試用 AI Agent 來協助我處理這些開發,需求簡單明確,但是技術細節並不少,

如果是新進 RD(即使有開發經驗)也不見得能掌握得很好,我來試試看 AI Agent 能作什麼程度。

這篇文章記錄我在基於 Clean Architecture 的 Node.js API 系統中實作 Web Search 功能的完整過程,包含架構設計、權限管理、錯誤處理等細節。

系統架構背景

專案採用 Clean Architecture 搭配 依賴注入 (Inversify),技術棧包含:

  • TypeScript + Express.js
  • TypeORM (資料庫 ORM)
  • Inversify (依賴注入容器)
  • JWT (身份驗證)
  • Zod (參數驗證)

架構分為四個主要層級:

1
2
3
4
5
src/
├── adapters/ # 外部介面層 - 控制器
├── domain/ # 領域層 - 核心業務邏輯
├── infrastructure/ # 基礎設施層 - 外部依賴實作
└── useCases/ # 應用層 - 業務用例

需求分析與設計決策

API 規格定義

  • 端點: GET /api/v1/websearch?keyword=關鍵字
  • 輸入: Query 參數 { keyword: string }
  • 輸出: { data: [{ title: string, url: string, description: string }] }
  • 結果數: 10 筆
  • 權限: 所有 API 都需要權限檢查(我們之前開發好的 RBAC 權限系統 ),一個功能對應一個權限,為此我需要新增 web_search 權限

關鍵設計決策

1. 控制器選擇

決定將功能放在 BasicController 而非 ProjController,因為這是通用功能而非特定業務邏輯。

| 這裡我在需求上有明確告知 AI,實作上也沒有問題

2. 依賴關係
嚴格遵循 Clean Architecture 的依賴規則:

| 也有寫入 CLAUDE.md 的開發準則中,但 AI 會常常忘記這件事

1
Controller → UseCase → Service → External API

3. 環境變數策略

將 Google API 設定為非必要欄位,未設定時警告但不中斷其他服務。

| AI 提供很好的建議並快速完成開發

4. 錯誤處理設計

  • 503: API 未設定
  • 502: API 呼叫失敗
  • 400: 參數錯誤 (Zod 驗證)

實作步驟詳解

Phase 1: 環境設定擴充

首先擴充環境變數設定,新增 Google Search API 相關設定:

1
2
3
4
5
6
# .env.example
# Google Search API Configuration
GOOGLE_API_KEY=your_google_api_key
GOOGLE_SEARCH_ENGINE_ID=your_search_engine_id
GOOGLE_SEARCH_API_URL=https://www.googleapis.com/customsearch/v1
GOOGLE_SEARCH_RESULTS_COUNT=10

修改 envConfigService.ts
新增 getOptional() 方法支援可選配置:

1
2
3
public getOptional(key: string): string | undefined {
return process.env[key]
}

Phase 2: 權限系統整合

建立資料庫遷移檔案,新增 web_search 權限:

1
2
3
4
5
6
7
INSERT INTO permissions (name, description) VALUES 
('web_search', 'Web Search API access permission');

-- 分配給 admin 和 member 角色
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE r.name IN ('admin', 'member') AND p.name = 'web_search';

Phase 3: 核心架構實作

1. 定義領域介面

src/domain/interfaces/webSearchService.ts

1
2
3
4
5
6
7
8
9
export interface WebSearchService {
search(keyword: string): Promise<WebSearchResult[]>
}

export interface WebSearchResult {
title: string
url: string
description: string
}

2. Google Search Service 實作

src/infrastructure/services/googleSearchService.ts

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
41
42
43
@injectable()
export class GoogleSearchService implements WebSearchService {
constructor(
@inject(TYPES.HttpClient) private readonly httpClient: IHttpClient,
@inject(TYPES.EnvConfigService) private readonly envConfig: IEnvConfigService
) {}

async search(keyword: string): Promise<WebSearchResult[]> {
const apiKey = this.envConfig.getOptional('GOOGLE_API_KEY')
const searchEngineId = this.envConfig.getOptional('GOOGLE_SEARCH_ENGINE_ID')

if (!apiKey || !searchEngineId) {
throw new AppError('Google Search API 未設定', 503)
}

const params = {
key: apiKey,
cx: searchEngineId,
q: keyword,
num: this.envConfig.get('GOOGLE_SEARCH_RESULTS_COUNT', '10')
}

try {
const response = await this.httpClient.get(
this.envConfig.get('GOOGLE_SEARCH_API_URL'),
{ params }
)
return this.transformGoogleResponse(response.data)
} catch (error) {
throw new AppError('搜尋服務暫時無法使用', 502)
}
}

private transformGoogleResponse(data: any): WebSearchResult[] {
if (!data.items) return []

return data.items.map((item: any) => ({
title: item.title,
url: item.link,
description: item.snippet || ''
}))
}
}

3. UseCase 層實作

src/useCases/ExecuteWebSearch.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@injectable()
export class ExecuteWebSearch implements IUseCase<ExecuteWebSearchDTO, WebSearchResponseDTO> {
constructor(
@inject(TYPES.WebSearchService) private readonly webSearchService: WebSearchService
) {}

async execute(input: ExecuteWebSearchDTO): Promise<WebSearchResponseDTO> {
const results = await this.webSearchService.search(input.keyword)
return { data: results }
}
}

// DTO 定義
export interface ExecuteWebSearchDTO {
keyword: string
}

export interface WebSearchResponseDTO {
data: WebSearchResult[]
}

4. Controller 層實作

src/adapters/controllers/basicController.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { z } from 'zod'

// Zod 驗證 schema
const webSearchQuerySchema = z.object({
keyword: z.string().min(1, 'keyword 參數為必填且不能為空字串')
})

async webSearch(req: Request, res: Response): Promise<void> {
// 使用 Zod 進行參數驗證
const { keyword } = webSearchQuerySchema.parse(req.query)

const input: ExecuteWebSearchDTO = { keyword }
const result = await this.executeWebSearch.execute(input)

res.status(200).json(result)
}

5. 路由設定

src/infrastructure/routes/basicRouter.ts

1
2
3
4
5
// 註冊路由,先執行權限檢查,再執行業務邏輯
r.get(`${basePath}/websearch`,
auth.user('web_search'), // 權限中介軟體
asyncWrapper(basicController.webSearch.bind(basicController))
)

Phase 4: 依賴注入設定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// inversify.config.ts
// 註冊 UseCase
container.bind<IUseCase<ExecuteWebSearchDTO, WebSearchResponseDTO>>(
TYPES.ExecuteWebSearch
).to(ExecuteWebSearch)

// 註冊 Service
container.bind<WebSearchService>(
TYPES.WebSearchService
).to(GoogleSearchService)

// 更新 TYPES 常數
export const TYPES = {
// ... existing types
ExecuteWebSearch: Symbol.for('ExecuteWebSearch'),
WebSearchService: Symbol.for('WebSearchService'),
}

測試與驗證

準備測試環境 – 取得測試 Token

1
2
3
4
curl -X 'POST' 'http://localhost:4578/api/v1/auth/login' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"account": "test_act", "password": "test_pwd"}'

測試案例設計

| AI 會自動幫我跑 End To End 測試(本來是 RD 用curl 或 postman 進行的工作)
| 案例的設計上也很細心,很多 RD 是只測試 Happy Case 的

正常功能測試(happy case)

1
2
3
curl -X GET "http://localhost:4578/api/v1/websearch?keyword=nodejs" \
-H "Authorization: Bearer <token>"
# 預期: Google 搜尋結果 JSON

權限檢查失敗測試

1
2
curl -X GET "http://localhost:4578/api/v1/websearch?keyword=test"
# 預期: {"error":"Authentication invalid"}

參數驗證測試

1
2
3
4
5
6
7
8
9
# 缺少參數
curl -X GET "http://localhost:4578/api/v1/websearch" \
-H "Authorization: Bearer <token>"
# 預期: Zod 驗證錯誤,400 status

# 空參數
curl -X GET "http://localhost:4578/api/v1/websearch?keyword=" \
-H "Authorization: Bearer <token>"
# 預期: {"error":[{...,"message":"keyword 參數為必填且不能為空字串"}]}

錯誤處理機制

Zod 驗證整合

專案已內建 Zod 在 req/res 檢查參數與錯誤處理有很好的表現,是團隊的開發工具之一
但要提醒 AI 不然他會手刻一個錯誤處理給你(刻得也不差就是了)

1
2
3
4
5
6
7
// errorHandler.ts 已支援
{
type: ZodError,
status: 400,
log: 'Zod validation error',
getMsg: (err: ZodError) => err.errors,
}

❌ 錯誤方式:AI 手刻錯誤處理

1
2
3
4
if (!keyword || typeof keyword !== 'string') {
res.status(400).json({ error: 'keyword 參數為必填且必須是字串' })
return
}

✅ 正確方式:使用 Zod

1
const { keyword } = webSearchQuerySchema.parse(req.query)

使用 Zod 的好處:

  • 統一的錯誤格式
  • 自動整合到全域錯誤處理器
  • 型別安全保證

中介軟體執行順序

重構算是這次需求的大目標,只要提示詞寫得夠好 AI 可以提供很完整的路由列表
而且重構的狀態也很正確,不過我的專案不大只有 30 隻左右的 API 參加價值可能不高

1
請求 → auth.user('web_search') → asyncWrapper → zod.parse() → 業務邏輯

這個順序確保:

  1. 先檢查身份權限
  2. 再進行參數驗證
  3. 最後執行業務邏輯

Google Custom Search API 整合要點

商業需求的主邏輯,我一行程式沒寫只與 AI 互動就完成了這個功能,包含點對點的測試
不過在零信任原則下,還是請其他 RD 再作一次 Code Review 與完整測試

必要參數說明

  • key: Google API Key
  • cx: Custom Search Engine ID
  • q: 搜尋關鍵字
  • num: 結果數量

API 回應轉換

Google API 回應結構:

1
2
3
4
5
6
7
8
9
{
"items": [
{
"title": "搜尋結果標題",
"link": "https://example.com",
"snippet": "搜尋結果摘要"
}
]
}

轉換為系統格式:

1
2
3
4
5
6
7
8
9
{
"data": [
{
"title": "搜尋結果標題",
"url": "https://example.com",
"description": "搜尋結果摘要"
}
]
}

小結

這次實作讓我深度體驗了 AI 與優良架構在實際專案中的運用。幾個關鍵收穫:

架構優勢

  • 清晰的分層讓職責分明,測試容易
  • 依賴注入讓元件可抽換,符合開放封閉原則
  • 統一的錯誤處理機制,維護成本低

上面是原本的優勢,加上 AI 判讀後,可以高效產生 Clean Code

不太確定不良代碼會有什麼結果,很幸運是我不在那種環境之中

現在三個圈圈是有交集的,好又快又便宜(產出/單位時間),

依照 AI 帶來的生產力與現有的 RD 相比,其實是很便宜的選擇。

良好的設計結合工具(AI)是提昇效率的手段,

3 個人可以當 7 個人用。至於為什麼是3個人,有機會再說了。

(fin)

[實作筆記] EAI 機器瀏覽器安裝問題與解決方案

前情提要

最近拿到一個地端資源 EAI-I233 開發地端 AI 應用。
過程中遇到了安裝瀏覽器(firefox)無法正常運作的問題。
經過查找資料,發現這是 Jetson Orin Nano 上的已知問題,不是個案。
記錄一下如何透過 Flatpak 安裝 Chromium 在 EAI 機器上,以解決瀏覽器問題。

問題描述

在 Jetson Orin Nano 等 EAI 設備上,透過 snap 安裝的 Chromium 瀏覽器會出現無法正常啟動或運行不穩定的狀況。
這個問題並非個案,在 NVIDIA 開發者論壇上有許多用戶回報類似問題。

解決方案

移除現有的 snap 版本 Chromium

首先移除透過 snap 安裝的 Chromium:

1
sudo snap remove chromium

安裝與設定 Flatpak

更新套件管理器並安裝 Flatpak:

1
2
sudo apt update
sudo apt install flatpak

添加 Flathub 儲存庫:

1
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo

重開機並安裝 Chromium

重新啟動機器後,透過 Flatpak 安裝 Chromium:

1
2
# 重開機後執行
flatpak install flathub org.chromium.Chromium

為什麼要用 Flatpak?

相比於 snap,Flatpak 在 ARM 架構的設備上有更好的相容性。
特別是在 Jetson 系列開發板上,Flatpak 能夠提供更穩定的套件執行環境,避免因為 snap 沙盒機制與硬體驅動互動時產生的問題。

一些要注意的小問題

重開機是必要的
在安裝完 Flatpak 後,務必重開機再安裝 Chromium,確保所有相依服務正確啟動。

權限問題
如果遇到權限問題,確認當前使用者已加入相關群組:

1
sudo usermod -a -G sudo $USER

參考資料

小結

透過 Flatpak 安裝 Chromium 是目前在 EAI 設備上最穩定的解決方案。
雖然需要額外的設定步驟,但能夠有效解決 snap 版本在 ARM 架構上的相容性問題。
這個解決方案不僅適用於 Jetson Orin Nano,也可以應用到其他類似的 ARM 架構開發板上。
有機會拿到別的機器再來試試。

(fin)

[學習筆記] Express.js middleware auth 的業界標準(res.locals)

前言

在 Express.js 認證系統中,常見的做法是在每個需要驗證的路由中直接解析 JWT token。
但有一個更好的實作方式:使用中介軟體將認證結果存放在 res.locals 中。
這不僅是官方建議的做法,也避免了重複解析 token 的效能問題。

TL;DR

使用 res.locals 處理認證是 Express.js 的標準實作:

  • 避免在每個路由重複解析 JWT token
  • Auth.js、Passport.js 等主流函式庫都採用這種模式
  • Express.js 官方文檔明確支持這種用法

直接解析 JWT 的缺點

重複工作的效能問題

如果每個路由都直接解析 JWT,會造成不必要的重複運算:

1
2
3
4
5
6
7
8
9
10
11
12
// ❌ 在每個路由重複解析
app.get('/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
const user = jwt.verify(token, JWT_SECRET) // 重複解析
res.json({ user })
})

app.get('/orders', (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
const user = jwt.verify(token, JWT_SECRET) // 又解析一次
res.json({ orders: getUserOrders(user.id) })
})

程式碼重複與維護困難

每個需要認證的路由都要寫類似的 token 驗證邏輯,違反了 DRY 原則,也增加了維護成本。

錯誤處理不一致

不同路由可能有不同的 token 驗證錯誤處理方式,造成 API 回應不一致。

什麼是 res.locals?

根據 Express.js 官方文檔,res.locals 是一個物件,用來存放請求範圍內的區域變數,這些變數只在當前請求-回應週期中可用。

1
2
3
4
5
6
// Express.js 官方範例
app.use((req, res, next) => {
res.locals.user = req.user
res.locals.authenticated = !req.user
next()
})

主流函式庫的標準實作

Auth.js 官方範例

Auth.js 官方文檔明確展示了使用 res.locals 的標準模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { getSession } from "@auth/express"

export function authSession(req, res, next) {
res.locals.session = await getSession(req)
next()
}

app.use(authSession)

// 在路由中使用
app.get("/", (req, res) => {
const { session } = res.locals
res.render("index", { user: session?.user })
})

Passport.js 的實作模式

Passport.js 在認證成功時會設置 req.user 屬性,許多開發者會將此資訊複製到 res.locals 以便在視圖中使用:

1
2
3
4
5
6
// 認證中介軟體
app.use((req, res, next) => {
res.locals.user = req.isAuthenticated() ? req.user : null
res.locals.isAuthenticated = req.isAuthenticated()
next()
})

為什麼這是好實作?

1. 官方支持的標準

Express.js 官方文檔說明 res.locals 就是用來存放請求級別的資訊,如認證用戶、用戶設定等。這不是 hack 或 workaround,而是設計用途。

2. 生態系統共識

主流的認證函式庫都採用類似模式:

  • Auth.js 直接在文檔中展示 res.locals.session
  • Passport.js 社群普遍使用 res.locals.user
  • 許多教學都展示將認證狀態存放在 res.locals 的模式

3. 分離關注點

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 認證中介軟體 - 只負責認證
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1]

if (token) {
const decoded = jwt.verify(token, JWT_SECRET)
res.locals.user = decoded
}

next()
}

// 路由處理器 - 只負責業務邏輯
app.get('/profile', authMiddleware, (req, res) => {
const { user } = res.locals
if (!user) {
return res.status(401).json({ error: 'Unauthorized' })
}
res.json({ profile: user })
})

4. 視圖整合優勢

在使用模板引擎時,res.locals 的資料會自動傳遞給視圖:

1
2
3
4
5
6
7
8
// 中介軟體設定
app.use((req, res, next) => {
res.locals.currentUser = req.user
next()
})

// 在 EJS/Pug 模板中直接使用
// <%= currentUser.name %>

常見的反模式

避免的做法

有些開發者認為過度使用 res.locals 會讓除錯變困難,但這通常是因為:

  1. 濫用中介軟體模式 - 把業務邏輯也放在中介軟體裡
  2. 過度耦合 - 多個中介軟體互相依賴 res.locals 的順序

正確的使用原則

中介軟體應該用於所有 HTTP 請求共通的事項,且不包含業務邏輯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ✅ 好的做法 - 純粹的認證檢查
function authenticate(req, res, next) {
const token = getTokenFromRequest(req)
const user = validateToken(token)
res.locals.user = user
next()
}

// ✅ 好的做法 - 授權檢查
function requireAuth(req, res, next) {
if (!res.locals.user) {
return res.status(401).json({ error: 'Unauthorized' })
}
next()
}

// ❌ 避免的做法 - 在中介軟體中處理業務邏輯
function badMiddleware(req, res, next) {
const user = res.locals.user
const orders = getUserOrders(user.id) // 業務邏輯不應該在這裡
res.locals.orders = orders
next()
}

實務上的最佳實作

標準認證中介軟體

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
const jwt = require('jsonwebtoken')

function authMiddleware(req, res, next) {
try {
const token = req.headers.authorization?.replace('Bearer ', '')

if (token) {
const decoded = jwt.verify(token, process.env.JWT_SECRET)
res.locals.user = decoded
res.locals.isAuthenticated = true
} else {
res.locals.user = null
res.locals.isAuthenticated = false
}

next()
} catch (error) {
res.locals.user = null
res.locals.isAuthenticated = false
next()
}
}

// 授權檢查中介軟體
function requireAuth(req, res, next) {
if (!res.locals.isAuthenticated) {
return res.status(401).json({ error: 'Authentication required' })
}
next()
}

// 使用方式
app.use(authMiddleware)
app.get('/profile', requireAuth, (req, res) => {
const { user } = res.locals
res.json({ user })
})

與 req 自定義屬性的比較

雖然也可以使用 req.userreq.data 等自定義屬性,但 res.locals 有幾個優勢:

  1. 語意清晰 - 明確表示這是給視圖用的資料
  2. 自動傳遞 - 模板引擎會自動取用 res.locals 的資料
  3. 標準化 - 社群共識,維護性更好

結論

res.locals 在認證系統中的使用確實是標準實作:

  1. 官方支持 - Express.js 和 Auth.js 官方文檔都展示這種用法
  2. 生態系統標準 - Passport.js 等主流函式庫採用相同模式
  3. 效能考量 - 避免重複解析 JWT 或查詢資料庫
  4. 開發體驗 - 控制器可以直接存取用戶資訊

所以下次有人說使用 res.locals 不是好實作時,可以拿出這些官方文檔來證明這確實是被廣泛接受的標準做法。

參考資料

(fin)

Please enable JavaScript to view the LikeCoin. :P