[學習筆記] 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)

[踩雷筆記] GitHub Action ssh-keyscan not found 問題修復

前情提要

GitHub Action marsen/[email protected] 之前運行正常,但最近開始失敗,錯誤訊息:

1
/usr/app/entrypoint.sh: 9: /usr/app/entrypoint.sh: ssh-keyscan: not found

問題分析

初步檢查

檢查 entrypoint.sh 第9行:

1
ssh-keyscan -t rsa github.com >> /root/.ssh/known_hosts

檢查 Dockerfile 套件安裝:

1
2
FROM node:20-buster-slim
RUN apt-get install -y git openssh-client

明明有安裝 openssh-client,為什麼找不到 ssh-keyscan

根本原因

Debian Buster 生命週期結束

  • node:20-buster-slim 基於 Debian 10 (Buster)
  • Debian 10 於 2024年8月達到 End of Life
  • 套件庫不再維護,套件結構可能變化

OpenSSH 套件重組

  • 2024年 OpenSSH 多個版本發布 (9.7, 9.9)
  • ssh-keyscan 可能從 openssh-client 移到其他套件

Docker 映像自動更新

  • Docker Hub 會自動重建映像
  • 新版本移除不必要工具以減少攻擊面

解決方案

快速修復(不推薦)

1
RUN apt-get install -y git openssh-client openssh-server

雖然可行,但安裝 SSH 伺服器會增加攻擊面。

正確解法

升級基底映像到支援版本:

1
2
3
4
5
# 從過期版本
FROM node:20-buster-slim

# 升級到安全版本
FROM node:20-bookworm-slim

完整的 Dockerfile 修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
FROM node:20-bookworm-slim

LABEL version="1.0.12"
LABEL repository="https://github.com/marsen/hexo-action"
LABEL homepage="https://blog.marsen.me"
LABEL maintainer="marsen.lin <[email protected]>"

WORKDIR /usr/app

COPY entrypoint.sh /usr/app/entrypoint.sh
COPY sync_deploy_history.js /usr/app/sync_deploy_history.js

# 安全且最佳化的套件安裝
RUN apt-get update > /dev/null && \
apt-get install -y git openssh-client > /dev/null && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
chmod +x /usr/app/entrypoint.sh

ENTRYPOINT ["/usr/app/entrypoint.sh"]

關鍵改進

  1. 基底映像升級:buster-slim → bookworm-slim

    • Debian 12 取代已 EOL 的 Debian 10
    • 更好的安全性和長期支援
  2. 最小權限原則:只安裝 openssh-client

    • 包含所需的 ssh-keyscan 工具
    • 不安裝 SSH 伺服器,減少攻擊面
  3. Docker 最佳實踐

    • 清理 apt 快取減少映像大小
    • 使用 && 合併 RUN 指令減少層數

預防措施

  • 使用具體版本標籤而非 latest
  • 定期檢查基底映像的生命週期
  • 建立自動化測試驗證依賴可用性

小結

這次問題的核心是基底映像過期導致的連鎖反應。在容器化開發中,外部依賴的變化往往會影響既有系統。解決方案不是快速修復,而是從根本上升級到安全的長期支援版本。

除錯關鍵思路:分析「為什麼之前可以,現在不行」,往往能找到外部環境變化的線索。

(fin)

[實作筆記] Ollama 容器化調研筆記

前情提要

最近在搞 AI 模型的本地部署,並試著將 Ollama 容器化。
經過一段時間的調研和實測,想記錄一下目前的進度和對幾個主流方案的看法。

目標

  • 找到穩定的 Ollama 容器化方案
  • 評估各種新興工具的可用性
  • 為未來的產品開發做技術預研

Ollama 簡介

Ollama 是一個開源工具,讓你在本地電腦上輕鬆運行大型語言模型(如 Llama、CodeLlama、Mistral 等)。
安裝簡單,一行指令就能下載和運行各種 AI 模型,無需複雜設定。支援 macOS、Linux 和 Windows。

1
2
#  安裝後直接用
ollama run llama3.2

類似 Docker 的概念,但專門為 AI 模型設計。
還提供 REST API:

1
2
3
# 啟動後自動開啟 API server (預設 port 11434)
curl http://localhost:11434/api/generate \
-d '{"model": "llama3.2", "prompt": "Hello"}'

沒有 Ollama 的話,你需要:

  • 手動下載模型檔案(通常好幾 GB)
  • 處理不同模型格式(GGUF、GGML 等)
  • 設定 GPU 加速環境
  • 寫代碼載入模型到記憶體
  • 處理 tokenization 和 inference
  • 自己包 API server

Ollama 把這些都幫你搞定了。

選擇

目前採用:Ollama Container + 指令初始化

經過評估,選擇 ollama container 方案最穩定且彈性也最高,

只要需要透過指令的方法來就可以來取模型。

基本用法:

1
2
3
4
5
# 啟動 ollama container
docker run -d --name ollama -p 11434:11434 ollama/ollama

# 下載模型
docker exec -it ollama ollama pull llama3.2

Docker Model Runner:值得關注但暫不採用

DMR 在 2025 年 3月隨 Docker Desktop 4.40 推出 Beta 版,

看起來很有潛力,但目前不打算用在產品上。

主因如下

  • 平台支援階段性:最初只支援 Apple Silicon (M1-M4),5月的 Docker Desktop 4.41 才加入 Windows NVIDIA GPU 支援
  • 實驗階段:工具變化快速,預期會持續改進,對產品開發風險較高
  • Linux 支援:目前在 Linux (包含 WSL2) 上支援 Docker CE,但整體生態還在發展中

會持續觀察,最新的 Docker Desktop 4.42 甚至支援了 Windows Qualcomm 晶片,發展很快。

Podman AI Lab:概念不錯但有使用限制

Podman AI Lab 提供一份精選的開源 AI 模型清單,概念上跟 DMR 類似,但實際使用上有些考量。

現況 支援 GGUF、PyTorch、TensorFlow 等常見格式

提供精選的 recipe 目錄,幫助導航 AI 使用案例和模型

最近與 RamaLama 整合,簡化本地 AI 模型執行

但採用精選模型清單的策略,可能不包含所有想要的模型

相對於 Ollama 的廣泛模型支援,選擇較為有限

不過隨著 RamaLama 能從任何來源簡化 AI 模型的本地服務,未來可能會更靈活。

一些要注意的小問題

Ollama Container 常見坑

  1. GPU 支援:記得加 --gpus all 或用 docker-compose 設定
  2. 模型路徑:預設在 /root/.ollama,要持久化記得 mount volume
  3. 網路設定:如果在 k8s 環境,注意 service 的 port 設定

Docker Model Runner 評估心得

  • 目前主要在 macOS Apple Silicon 上比較穩定
  • Linux 支援還在完善中
  • Windows 支援是最近才加的,需要更多實測

技術選型思考

這次調研讓我想到幾個點:

  1. 穩定性 > 新功能:對產品開發來說,穩定性永遠是第一考量
  2. 生態完整性:單一工具再好,生態不完善就是硬傷
  3. 維護成本:新技術通常需要更多維護工作

下一步

短期計畫:

  1. 把 Ollama container 的初始化流程自動化
  2. 持續追蹤 DMR 發展
  3. 建立技術方案評估標準

中期目標:

  • 等 DMR 穩定後再評估導入
  • 研究其他容器化方案
  • 整理最佳實踐文件

小結

AI 基礎設施變化很快,容器化技術也在演進。目前沒有完美方案,但這個調研過程很有價值。

技術選擇是動態平衡的過程,要在穩定性、功能性、維護成本間找平衡點。

會持續關注這領域的發展,有新發現再分享。

(fin)

[生活筆記] 2025 常用工具整理 - AI 篇

前情提要

最近 AI 工具如雨後春筍般冒出,整理一些目前在用或值得關注的工具,主要分為對話、開發、專業應用三大類。

對話與通用 AI

Claude

  • Claude Chat
  • Anthropic 開發的 AI 助手,擅長寫作、分析、程式開發
  • 支援多模態輸入(文字、圖片、文件)
  • 免費版有使用限制,付費版更穩定

Grok

  • Grok
  • xAI 推出的對話式 AI
  • 風格比較直接犀利,會吐槽(特色)
  • 需要 X Premium 會員才能使用

Gemini (Google)

  • Gemini
  • Google 最新多模態 AI 助手,支援文字、圖片、程式碼等多種輸入
  • 已整合進 Google Workspace、Android、Chrome 等生態系

Microsoft Copilot

  • Copilot
  • 微軟全家桶 AI 助手,整合於 Windows 11、Edge、Office 365
  • 支援 Word、Excel、PowerPoint 直接 AI 協作

Perplexity AI

  • Perplexity
  • 強調即時網路搜尋與問答,支援多語言、引用來源
  • 適合知識查詢、研究輔助

Notion AI

  • Notion AI
  • 整合於 Notion 筆記/知識管理平台的 AI 助手
  • 支援自動摘要、重寫、腦力激盪等功能

ChatGPT (OpenAI)

  • ChatGPT
  • OpenAI 的對話式 AI,支援 GPT-4/4o,應用於對話、程式、資料分析等
  • 免費/付費版皆有

Kimi Chat(Moonshot AI)

  • Kimi Chat
  • 中文社群討論度高,支援長文閱讀、PDF 分析

Phind

  • Phind
  • 專為工程師設計的 AI 搜尋與問答平台,程式碼解釋、技術文件查找很強

程式開發工具

程式碼生成與協助

  • OpenAI Codex

    • 雲端軟體工程代理,可同時處理多項開發任務
    • 支援功能開發、bug 修復、重構等
  • GitHub Copilot

    • 直接在編輯器中提供程式碼建議
    • 可生成整行或整個函數
    • 需付費訂閱
  • Cursor

    • AI 程式碼編輯器
    • 專注於智能程式開發體驗
    • 支援自然語言與程式碼互動
  • Claude Code

  • AugmentCode

    • AI 驅動的程式碼搜尋與理解平台
    • 支援跨專案、跨語言的程式碼查詢、摘要與自動解釋
    • 適合大型專案維護、協作與知識傳承

應用開發

  • Firebase Studio

    • Google 的應用開發平台
    • 整合多種後端服務
  • DeepWiki

    • AI 驅動的知識搜尋與摘要工具
    • 支援多語言、快速整理主題重點,適合研究、學習、資料彙整
    • 可用於技術、學術、產業等多種領域

雜七雜八

法律專業 — Harvey AI

  • 專門為法律行業設計的 AI 助手
  • 強調安全性和合規性(重要)

文件研究 — NotebookLM

  • Google 的筆記和研究工具
  • 可以分析和總結文件
  • 支援多種文件格式上傳

NVIDIA Container Toolkit

  • NVIDIA 的容器化工具包
  • 主要用於 GPU 加速的容器應用

參考

小結

目前 AI 工具生態系統發展迅速,各家都在搶佔不同的應用場景。
對話類工具已經相當成熟,程式開發輔助工具也越來越實用,專業領域的 AI 應用則剛起步但潛力巨大。
對我個人而言重建自已的工作流程整合 AI 協助已經刻不容緩

(fin)

[實作筆記] 用 GitLab CI/CD 實現自動打 Tag、Build 與 Push Docker Image 到 Registry

前情提要

在專案自動化部署流程中,讓 CI/CD pipeline 自動產生遞增 tag、build Docker image 並推送到 GitLab Container Registry,是現代 DevOps 的常見需求。

這篇文章記錄我在 GitLab CI/CD 上實作這一流程的經驗。

目標

  • 自動產生遞增 tag(如 ver.1.0.1)
  • 自動 build 並 push Docker image,image tag 與 git tag 同步
  • 支援多專案共用同一份 CI 設定
  • 僅 main 分支觸發

實作

  1. 變數抽象化,支援多專案共用

    首先,將專案名稱、群組路徑等資訊抽成變數,方便不同專案複用:

    1
    2
    3
    4
    variables:
    PROJECT_NAME: my-proj
    GROUP_PATH: my-group
    REGISTRY_PATH: registry.gitlab.com/$GROUP_PATH/$PROJECT_NAME
  2. 多階段 Dockerfile 精簡 image

    使用 multi-stage build,確保 production image 只包含必要檔案與 production dependencies:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    FROM node:23-alpine AS builder
    WORKDIR /app
    COPY package*.json ./
    RUN npm install
    COPY . .
    RUN npm run build

    FROM node:23-alpine AS production
    WORKDIR /app
    COPY package*.json ./
    RUN npm install --production
    COPY --from=builder /app/dist ./dist
    ENV NODE_ENV=production
    EXPOSE 3000
    CMD ["npm", "start"]

    建議搭配 .dockerignore 避免多餘檔案進入 image。

  3. GitLab CI/CD Pipeline 設定只在 main 分支執行

    1
    2
    3
    4
    rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
    when: always
    - when: never
  4. 自動產生遞增 tag

    1. 採用 major.minor.patch 的版本規則,啟始版號為 ver.1.0.0
    2. 先同步 remote tag,避免本地殘留影響
    3. 取得最大 patch 號,自動 +1
    4. 檢查 tag 是否已存在,確保唯一
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    script:
    - git fetch --prune --tags
    - git tag -l | xargs -n 1 git tag -d
    - git fetch --tags
    - |
    prefix="ver."
    max_patch=$(git tag -l "${prefix}[0-9]*.[0-9]*.[0-9]*" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -n1 | awk -F. '{print $3}')
    if [ -z "$max_patch" ]; then
    new_tag="${prefix}1.0.0"
    else
    major=$(git tag -l "${prefix}[0-9]*.[0-9]*.[0-9]*" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -n1 | cut -d. -f1)
    minor=$(git tag -l "${prefix}[0-9]*.[0-9]*.[0-9]*" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -n1 | cut -d. -f2)
    patch=$((max_patch + 1))
    new_tag="${prefix}${major}.${minor}.${patch}"
    fi
    # 確保 tag 唯一
    while git rev-parse "$new_tag" >/dev/null 2>&1; do
    patch=$((patch + 1))
    new_tag="${prefix}${major}.${minor}.${patch}"
    done
    echo "new tag is $new_tag"
    echo "NEW_TAG=$new_tag" >> build.env
    - source build.env
  5. 自動打 tag 並 push

    1
    2
    3
    4
    5
    - git config --global user.email "[email protected]"
    - git config --global user.name "gitlab-runner"
    - git remote set-url origin https://gitlab-runner:[email protected]/$GITLAB_REPO.git
    - git tag "$NEW_TAG"
    - git push origin "$NEW_TAG"
  6. Loging、Build & Push Docker image

    1
    2
    3
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
    - docker build -t $REGISTRY_PATH:$NEW_TAG .
    - docker push $REGISTRY_PATH:$NEW_TAG
  7. Docker-in-Docker 設定

    DOCKER_TLS_CERTDIR='' 是為了讓 dind 關閉 TLS,讓 CI job 可以直接用明文 TCP 連線 docker daemon,避免 TLS 憑證錯誤。

    1
    2
    3
    4
    5
    services:
    - docker:dind
    variables:
    DOCKER_HOST: tcp://docker:2375/
    DOCKER_TLS_CERTDIR: ''

    可以參考本文

  8. 一些要注意的小問題

  • DOCKER_TLS_CERTDIR 不能不設定。

  • tag 跳號或重複?
    請務必先刪除本地 tag 再 fetch remote tag。

  • 無法 push tag?
    請確認 Deploy Token/PAT 權限,且 remote url 正確。

  • docker build 失敗,daemon 連不到?
    請檢查 gitlab runner 是否有 privileged mode,且 dind 有啟動。
    /etc/gitlab-runner/config.toml

    1
    2
    3
    4
    5
    6
    [[runners]]
    name = "docker-runner"
    executor = "docker"
    [runners.docker]
    privileged = true
    ...

小結

完整 .gitlab-ci.yml 範例如下,

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
44
45
46
47
48
49
stages:
- build
variables:
PROJECT_NAME: my-proj
GROUP_PATH: my-group
REGISTRY_PATH: registry.gitlab.com/$GROUP_PATH/$PROJECT_NAME

build-image:
image: docker:latest
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375/
DOCKER_TLS_CERTDIR: ''
stage: build
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: always
- when: never
script:
- git fetch --prune --tags
- git tag -l | xargs -n 1 git tag -d
- git fetch --tags
- |
prefix="ver."
max_patch=$(git tag -l "${prefix}[0-9]*.[0-9]*.[0-9]*" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -n1 | awk -F. '{print $3}')
if [ -z "$max_patch" ]; then
new_tag="${prefix}1.0.0"
else
major=$(git tag -l "${prefix}[0-9]*.[0-9]*.[0-9]*" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -n1 | cut -d. -f1)
minor=$(git tag -l "${prefix}[0-9]*.[0-9]*.[0-9]*" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -n1 | cut -d. -f2)
patch=$((max_patch + 1))
new_tag="${prefix}${major}.${minor}.${patch}"
fi
while git rev-parse "$new_tag" >/dev/null 2>&1; do
patch=$((patch + 1))
new_tag="${prefix}${major}.${minor}.${patch}"
done
echo "new tag is $new_tag"
echo "NEW_TAG=$new_tag" >> build.env
- source build.env
- git config --global user.email "[email protected]"
- git config --global user.name "rag-cicd"
- git remote set-url origin https://rag-cicd:[email protected]/$GITLAB_REPO.git
- git tag "$NEW_TAG"
- git push origin "$NEW_TAG"
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
- docker build -t $REGISTRY_PATH:$NEW_TAG .
- docker push $REGISTRY_PATH:$NEW_TAG

這樣設定後,每次 main 分支有 commit,CI/CD 就會自動產生新 tag、build 並推送對應版本的 Docker image 讓大家共用。

(fin)

[實作筆記] GCP Cloud Run 私有化部署:透過 Load Balancer 實現內網存取與 IP 限制

前情提要

我用 cloud run 建立一個 api 並且有 webhook 的功能
並且希望提供一個對外網址給客戶,而不是 *.run.app 結尾的網址。

在某些場景下,我們不希望 GCP Cloud Run 對外暴露,

例如內部 API Gateway 呼叫、CI/CD 系統內部流程、或僅提供給 VPC 內特定服務存取的微服務。

這次的例子是希望加上 IP 的防護,只允許特定的 IP 呼叫,這時候就會希望:

  • Cloud Run 不對外公開(Private)

  • 僅允許 內網 IP 或 Internal Load Balancer 存取

看似簡單,但 GCP Cloud Run 原生是無伺服器架構,預設就是「公開網址」,要讓它只對內部可見,需要搭配一些 GCP 網路元件操作。這篇筆記會帶你逐步設定,並避開一些常見坑洞。

實作記錄

  1. 將 Cloud Run 設為「不公開」
    預設 Cloud Run 是公開的,要改為「只有授權的主體可以存取」:

    1
    2
    3
    4
    5
    6
    gcloud run services update YOUR_SERVICE \
    --ingress internal-and-cloud-load-balancing \
    --allow-unauthenticated \
    --region YOUR_REGION \
    --project YOUR_PROJECT

    這樣只有內部的網路才能呼叫 Cloud Run。

    也可以透過 GUI 設定,選擇你的 Cloud Run >

    Networking > Ingress 選 internal 勾選 Allow Traffic from external Application Load Balancers

    可以參考

  2. 建立 Serverless NEG(Network Endpoint Group)
    可以用 GUI 建立 Loading Blancer >

    Edit > Backend configuration >

    下拉選單選 Create backend service >

    Backend Type 選 Serverless network endpoint group

    因為是 Cloud Run

  3. 設定 Loading Blancer 的 Backend Service”
    LB > Edit backend service

    Regions 選擇 Cloud Run 所在的 Region

    預設會有一組 Security Policy

    Cloud Armor policies > 找到這組 Policy 可以作更細緻的設定,Ex:指定 Ip 呼叫

  4. Loading Blancer URL Map
    這是一個額外的設定,情境是原本我已有一組 Domain 與 Backend Service

    並且已經設定在 LB 上面,在想要共用 Domain 的情況下,我需要設定 URL Map

    保留一組 Path 讓流量打向 Cloud Run,但仍保留其他 API 可以用

    LB > Edit > Routing rules > Mode 選擇 Advanced host and path rule

    找到指定的 Domain,設定參考如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    defaultService: projects/my-gcp-proj/global/backendServices/my-api
    name: path-matcher-7
    pathRules:
    - paths:
    - /api/v1/*
    service: projects/my-gcp-proj/global/backendServices/my-api
    - paths:
    - /cloud-run-app/*
    service: projects/my-gcp-proj/global/backendServices/my-cloud-run-app
    routeAction:
    urlRewrite:
    pathPrefixRewrite: /

    以上設定簡單說明如下,

    • /api/v1/*:導向 my-api,保留原始路徑。
    • /cloud-run-app/*:導向 my-cloud-run-app,並將路徑重寫為去除 /cloud-run-app 前綴。
    • 其他路徑:預設導向 my-api。

    這樣設定可讓多個服務共用同一個負載平衡器並支援乾淨的 URL 管理。

參考

(fin)

[實作筆記] 我的 Vim 設定

我的 Vim 快捷鍵設定總表,IdeaVim 與 JetBrain


快捷鍵 模式 功能說明 IdeaVim 設定寫法
zcc n 清除所有自訂映射 nmap zcc :mapclear!<CR>
zso n 重新載入主 vimrc nmap zso :source ~/_ideavimrc<CR>
zf n/i 跳到定義(Goto Declaration) nmap zf :action GotoDeclaration<CR>
imap zf <Esc>:action GotoDeclaration<CR>
zrc, zo n/i 格式化程式碼(Reformat Code) nmap zrc :action ReformatCode<CR>
imap zrc <Esc>:action ReformatCode<CR>
nmap zo :action ReformatCode<CR>
zk n/i 返回(Back) nmap zk :action Back<CR>
imap zk <Esc>:action Back<CR>
zj n/i AceAction(自訂動作) nmap zj :action AceAction<CR>
imap zj <Esc>:action AceAction<CR>
ztt n/i 複製區塊並格式化(自訂巨集) nmap ztt y?[F<CR>/{<CR>%o<Esc>p:action ReformatCode<CR>
imap ztt <Esc>y?[F<CR>/{<CR>%o<Esc>p:action ReformatCode<CR>
zn n/i 跳到上一個錯誤(Goto Previous Error) nmap zn :action GotoPreviousError<CR>
imap zn <Esc>:action GotoPreviousError<CR>
zrr n/i 重新命名(Rename Element) nmap zrr :action RenameElement<CR>
imap zrr <Esc>:action RenameElement<CR>
zra n/i 執行所有單元測試 nmap zra :action RiderUnitTestRunSolutionAction<CR>
imap zra <Esc>:action RiderUnitTestRunSolutionAction<CR>
zrm n/v 萃取方法(Extract Method) nmap zrm :action ExtractMethod<CR>
vmap zrm :action ExtractMethod<CR>
zri n Inline(內聯變數/方法) map zri :action Inline<CR><Esc>
zrp n Introduce Parameter(引入參數) map zrp :action IntroduceParameter<CR>
zrv n Introduce Variable(引入變數) map zrv :action IntroduceVariable<CR>
zrf n Introduce Field(引入欄位) map zrf :action IntroduceField<CR>
,m n 顯示檔案結構 nmap ,m :action FileStructurePopup<CR>
zrt n 最近檔案 nmap zrt :action RecentFiles<CR>
zgc n 開啟 Commit 工具視窗 nmap zgc :action ActivateCommitToolWindow<CR>
zx n 快速重構選單 nmap zx :action Refactorings.QuickListPopupAction <CR>
zae n/i 插入 Assert.Equal(,); nmap zae aAssert.Equal(,);<Esc>T(i
imap zae Assert.Equal(,);<Esc>T(i
zrs n 新增 class 並格式化(自訂巨集) nmap zrs "add? class<CR>2w"bywjopublic <Esc>"bpa()<CR>{<Esc>"apo}<Esc>:action ReformatCode<CR>
z; n/i 行尾加分號 nmap z; $a;<Esc>
imap z; <Esc>$a;
,p, ,P n 黏貼暫存區 0 的內容 nmap ,p "0p
nmap ,P "0P
z,p, z,P i 插入暫存區 0 的內容 imap z,p <Esc>"0pa
imap z,P <Esc>"0Pa
z, n 選取括號內內容(vi)) :nmap z, vi)
z. n 選取大括號內內容(vi}) :nmap z. vi}
v 連續多次離開選取模式 :vmap <Esc> <Esc><Esc><Esc>
jj i 退出插入模式 :imap jj <Esc>
n 在普通模式下插入 Backspace :nmap <BS> a<BS>
zh n/i 跳到行首 :nmap zh ^
:imap zh <Esc>^i
zl n/i 跳到行尾 :nmap zl $
:imap zl <End>
hc n Ctrl+C :nmap hc ^C
zb n/v 單字取代 :nmap zb bcw
:vmap zb <Esc>bcw
zd i 刪除當前行 :imap zd <Esc>dd
j, k n 軟換行移動 nmap j gj
nmap k gk
qq n 強制關閉檔案 nmap qq ZQ
zq n 儲存並關閉檔案 nmap zq :wq<CR>
i 刪除當前行 :imap <C-x> <Esc>dd
i 全選 :imap <C-a> <Esc>ma<CR>ggVG
,i, ,a n 替換字元 :map ,i <Esc>r
:map ,a <Esc>r

說明:

  • n:普通模式(normal mode)
  • i:插入模式(insert mode)
  • v:視覺模式(visual mode)
  • <CR> 代表 Enter 鍵,<Esc> 代表 Esc 鍵

以下是我將 JetBrains(IdeaVim)Vim 快捷鍵移植到 VSCode(VSCodeVim)的對應表,
並說明哪些已成功移植、哪些無法移植及原因:


JetBrains 快捷鍵 VSCode 設定 (before) VSCode 指令 (commands) 功能說明 可否移植 備註/原因
zrr [“z”,”r”,”r”] editor.action.rename 重新命名 完全支援
zf [“z”,”f”] editor.action.revealDefinition 跳到定義 完全支援
zrc/zo [“z”,”r”,”c”]/[“z”,”o”] editor.action.formatDocument 格式化程式碼 完全支援(你已設 zrc,zo 也可加)
zn [“z”,”n”] editor.action.marker.prev 跳到上一個錯誤 完全支援
zrt [“z”,”r”,”t”] workbench.action.openRecent 最近檔案 完全支援
,m [“,”,”m”] workbench.action.gotoSymbol 檔案結構/大綱 完全支援
zx [“z”,”x”] editor.action.refactor 顯示重構選單 完全支援
zgc [“z”,”g”,”c”] workbench.view.scm 開啟 Source Control 面板 完全支援
zh [“z”,”h”] cursorHome 跳到行首 完全支援
zl [“z”,”l”] cursorEnd 跳到行尾 完全支援
zrv [“z”,”r”,”v”] editor.action.codeAction.extract.variable 萃取變數 ⚠️ VSCodeVim 有選取限制,部分情境下無法觸發
zrm [“z”,”r”,”m”] editor.action.codeAction.extract.method 萃取方法 ⚠️ VSCodeVim 有選取限制,部分情境下無法觸發
zri - - Inline(內聯變數/方法) VSCode 沒有公開指令 ID,無法 keybinding
zrp - - Introduce Parameter VSCode 沒有公開指令 ID,無法 keybinding
zrf - - Introduce Field VSCode 沒有公開指令 ID,無法 keybinding
ztt - - 巨集/多步驟自動化 VSCode 不支援 Vim 巨集或多步驟自動化
zso - - 重新載入 vimrc VSCodeVim 不支援 :source 或重新載入 vimrc
zra [“z”,”r”,”a”] testing.runAll 執行所有單元測試 需安裝 VSCode 官方 Testing 功能,已可 keybinding

補充說明

  • ✅:完全支援,已可用於 VSCodeVim。
  • ⚠️:VSCodeVim 有技術限制(如需選取內容,觸發不一定成功)。
  • ❌:VSCode/VSCodeVim 無對應指令或功能,無法移植。
  • 你可以持續用 VSCode 內建的 Cmd+. 或右鍵選單補足無法 keybinding 的重構功能。
  • zra:VSCode 1.59 以上內建 Testing 功能,指令為 testing.runAll,可直接 keybinding,無需額外外掛。

總結:
我已成功將大部分常用 JetBrains Vim 快捷鍵移植到 VSCode,
僅少數 JetBrains 專屬重構、巨集、vimrc 相關功能因 VSCode 限制無法移植。

(fin)