# Day 33:Node.js CLI 專案架構重構:從零散到模組化 **Published by:** [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/) **Published on:** 2026-01-07 **URL:** https://paragraph.com/@gcake/day-33 ## Content 目標:建立可重用的 CLI 專案架構,實踐設計模式與測試策略一、重構目標原始問題❌ 每個功能重複程式碼(驗證邏輯、CLI 輸入處理)❌ 缺乏統一架構❌ 驗證邏輯混在業務邏輯中❌ 難以維護和擴展重構目標✅ 建立統一的 CLI 模板✅ 抽象驗證器為可重用模組✅ 分離業務邏輯和基礎設施✅ 保持核心邏輯純淨二、架構設計分層架構┌──────────────────────────────────────┐ │ CLI Layer (Config + Validators) │ ← 輸入驗證 ├──────────────────────────────────────┤ │ Processing Layer (processUserInputs)│ ← 流程處理 ├──────────────────────────────────────┤ │ Business Logic (Core Functions) │ ← 業務運算 └──────────────────────────────────────┘ 檔案結構project/ ├── features/ │ ├── feature1/ │ │ ├── config.js # CLI 配置(驗證器 + processor) │ │ ├── main.js # 程式入口(統一模板) │ │ ├── logic.js # 核心業務邏輯 │ │ └── logic.test.js # 單元測試 │ └── feature2/ │ └── ... │ ├── shared/ │ └── utils.js # 共用函式(可被多個功能重用) │ └── util/ ├── validators.js # 驗證器模組 ├── composeValidators.js # 驗證器組合 ├── processUserInputs.js # 處理使用者輸入 ├── runCliQuestions.js # CLI 問答流程 ├── askWithValidator.js # 帶驗證的問答 └── runCliSession.js # CLI Session 管理 三、核心設計模式1. Config-Driven 模式概念:將差異封裝在配置中,保持執行邏輯統一。// features/calculator/config.js export const questionConfigs = [ { name: "number", // 答案的 key prompt: "請輸入數字:", // CLI 提示 validator: composeValidators([...]), // 驗證器組合 processor: calculateResult, // 業務邏輯(選擇性) }, ]; 優點:✅ 聲明式配置,易讀易維護✅ 新增功能只需複製模板✅ 驗證邏輯可組合重用2. Validator Composition(驗證器組合)概念:將驗證器串接,像 Unix 管道一樣處理資料。// util/composeValidators.js export function composeValidators(validators) { return (value) => { let current = value; for (const validate of validators) { const result = validate(current); current = result.value ?? result; } return { value: current }; }; } 使用範例:validator: composeValidators([ ensureIntegerString, // 1. 確保是整數字串 toInt, // 2. 轉換為數字 isPositiveInt, // 3. 驗證是正整數 ]) 資料流:使用者輸入 "5" ↓ ensureIntegerString("5") → { value: "5" } ✅ ↓ toInt("5") → { value: 5 } ✅ ↓ isPositiveInt(5) → { value: 5 } ✅ ↓ 最終結果:{ value: 5 } 3. Processor Pattern(處理器模式)概念:在 Config 中指定核心邏輯,由框架自動執行。單欄位 Processor// config.js { name: "age", validator: composeValidators([...]), processor: calculateDiscount, // ← 接收單一值 } // 核心邏輯 function calculateDiscount(age) { return age < 18 ? 0.5 : 1.0; // 未成年享半價 } 多欄位 Processor// config.js { name: "operand2", validator: composeValidators([...]), needsAllFields: true, // ← 標記需要所有欄位 processor: ({ operand1, operand2 }) => calculate(operand1, operand2), } 框架自動處理:// util/processUserInputs.js if (config.needsAllFields) { // 傳入所有欄位 processedValues[config.name] = config.processor(extractedValues); } else { // 傳入單一值 processedValues[config.name] = config.processor(input); } 四、可重用模組1. 驗證器模組(util/validators.js)ensureIntegerString - 確保輸入是整數字串export function ensureIntegerString(input) { if (!/^-?\d+$/.test(input)) { throw new Error('請輸入整數'); } return { value: input }; } 測試案例:✅ "123" → { value: "123" }❌ "12.5" → Error❌ "abc" → ErrortoInt - 轉換為數字export function toInt(input) { const num = parseInt(input, 10); if (isNaN(num)) { throw new Error('轉換失敗'); } return { value: num }; } isNonNegativeInt - 驗證非負整數export function isNonNegativeInt(input) { const num = input.value ?? input; if (num < 0) { throw new Error('請輸入非負整數(0, 1, 2...)'); } return { value: num }; } 適用情境:年齡、數量、計數等不可為負的數值。isPositiveInt - 驗證正整數(支援範圍限制)export function isPositiveInt(input, min = 1, max = Infinity) { const num = input.value ?? input; if (num < min || num > max) { throw new Error(max === Infinity ? `請輸入大於等於 ${min} 的正整數` : `請輸入 ${min} 到 ${max} 之間的正整數`); } return { value: num }; } 使用範例:// 任意正整數 isPositiveInt(input) // >= 1 // 限制最小值 isPositiveInt(input, 10) // >= 10 // 限制範圍 isPositiveInt(input, 1, 100) // 1 到 100 適用情境:分數(1-100)、月份(1-12)、評分等級等。isAlphabetic - 驗證英文字母export function isAlphabetic(input) { const str = input.value ?? input; if (!/^[A-Za-z]+$/.test(str)) { throw new Error('請輸入英文字母'); } return { value: str }; } 適用情境:名字、代碼等純字母輸入。2. 處理流程模組(util/processUserInputs.js)功能:自動提取 { value: ... } 包裝執行 processor支援單欄位和多欄位模式export function processUserInputs(userInputRounds, questionConfigs) { return userInputRounds.map((roundInputs) => { const processedValues = {}; // 第一步:提取所有欄位的值 const extractedValues = {}; for (const config of questionConfigs) { const validated = roundInputs[config.name]; const input = validated && typeof validated === "object" && "value" in validated ? validated.value : validated; extractedValues[config.name] = input; } // 第二步:處理每個欄位 for (const config of questionConfigs) { const input = extractedValues[config.name]; if (config.processor) { if (config.needsAllFields) { processedValues[config.name] = config.processor(extractedValues); } else { processedValues[config.name] = config.processor(input); } } else { processedValues[config.name] = input; } } return processedValues; }); } 五、統一的 Main 模板有 CLI 輸入(標準模式)// features/calculator/main.js import { runCliQuestions } from '../../util/runCliQuestions.js'; import { processUserInputs } from '../../util/processUserInputs.js'; import { questionConfigs } from './config.js'; async function main() { console.log('=== Calculator ===\n'); const userInputRounds = await runCliQuestions(questionConfigs); const processedRounds = processUserInputs(userInputRounds, questionConfigs); const [firstRound] = processedRounds; console.log('\n結果:', firstRound.result); } main().catch((err) => { console.error('程式發生未預期錯誤:', err); process.exit(1); }); 無 CLI 輸入(固定資料模式)// features/processor/main.js import { processData } from './logic.js'; async function main() { console.log('=== Data Processor ===\n'); const data = [1, 2, 3, 4, 5]; // 固定資料 const result = processData(data); console.log(result); } main().catch((err) => { console.error('程式發生未預期錯誤:', err); process.exit(1); }); 六、實戰案例案例 1:年齡驗證與折扣計算(單欄位模式)1. Config 配置// features/discount/config.js import { ensureIntegerString, toInt, isNonNegativeInt } from "../../util/validators.js"; import { composeValidators } from "../../util/composeValidators.js"; import { calculateDiscount } from "./logic.js"; export const questionConfigs = [ { name: "age", prompt: "請輸入年齡:", validator: composeValidators([ ensureIntegerString, toInt, isNonNegativeInt, ]), processor: calculateDiscount, }, ]; 2. 核心邏輯// features/discount/logic.js export function calculateDiscount(age) { const FULL_PRICE = 100; const CHILD_AGE_LIMIT = 12; const SENIOR_AGE_LIMIT = 65; if (age <= CHILD_AGE_LIMIT || age >= SENIOR_AGE_LIMIT) { return FULL_PRICE * 0.5; // 兒童/長者享半價 } return FULL_PRICE; } 特點:✅ 假設輸入已驗證(不在函式內驗證)✅ 純函式(無副作用)✅ 使用常數提升可讀性3. 測試// features/discount/logic.test.js import { describe, test, expect } from "vitest"; import { calculateDiscount } from "./logic.js"; describe("calculateDiscount", () => { test("兒童(12 歲以下)享半價", () => { expect(calculateDiscount(0)).toBe(50); expect(calculateDiscount(12)).toBe(50); }); test("成人全價", () => { expect(calculateDiscount(13)).toBe(100); expect(calculateDiscount(64)).toBe(100); }); test("長者(65 歲以上)享半價", () => { expect(calculateDiscount(65)).toBe(50); expect(calculateDiscount(80)).toBe(50); }); }); 案例 2:數字比較(多欄位模式)1. Config 配置// features/compare/config.js import { ensureIntegerString, toInt } from "../../util/validators.js"; import { composeValidators } from "../../util/composeValidators.js"; import { compareNumbers } from "./logic.js"; export const questionConfigs = [ { name: "num1", prompt: "請輸入第一個數字:", validator: composeValidators([ ensureIntegerString, toInt, ]), }, { name: "num2", prompt: "請輸入第二個數字:", validator: composeValidators([ ensureIntegerString, toInt, ]), needsAllFields: true, // 🔧 標記需要多欄位 processor: ({ num1, num2 }) => compareNumbers(num1, num2), }, ]; 2. 核心邏輯// features/compare/logic.js export function compareNumbers(num1, num2) { if (num1 > num2) return "第一個數字較大"; if (num1 < num2) return "第二個數字較大"; return "兩數相等"; } 七、進階技術1. 模組重用場景:多個功能需要使用相同的演算法// shared/isPrime.js(共用模組) export function isPrime(num) { if (num <= 1) return false; if (num === 2) return true; if (num % 2 === 0) return false; const limit = Math.sqrt(num); for (let i = 3; i <= limit; i += 2) { if (num % i === 0) return false; } return true; } // features/primeChecker/logic.js import { isPrime } from "../../shared/isPrime.js"; export function checkPrime(num) { return isPrime(num) ? "是質數" : "不是質數"; } // features/primeFilter/logic.js import { isPrime } from "../../shared/isPrime.js"; export function filterPrimes(numbers) { return numbers.filter(num => isPrime(num)); } 依賴關係:shared/isPrime.js ← 定義共用邏輯 ↓ features/primeChecker/ ← 重用 features/primeFilter/ ← 重用 優點:✅ 避免程式碼重複(DRY 原則)✅ 統一邏輯✅ 易於維護(修改一處,全部生效)2. 解構賦值(Destructuring)概念:從物件中提取值,並賦值給變數。在迴圈中的應用// 資料結構 const users = [ { name: 'Alice', age: 30, city: 'Taipei' }, { name: 'Bob', age: 25, city: 'Tokyo' } ]; // 傳統寫法 users.forEach((user) => { console.log(`${user.name} 住在 ${user.city}`); }); // 解構寫法 ✅ users.forEach(({ name, city }) => { // ^^^^^^^^^^^^^^ // 直接按屬性名取值,建立變數 console.log(`${name} 住在 ${city}`); }); 運作原理:// forEach 傳入每個元素 const user = { name: 'Alice', age: 30, city: 'Taipei' }; // 參數解構等同於 const { name, city } = user; // 相當於 const name = user.name; const city = user.city; 關鍵規則:✅ 屬性名必須一致✅ 順序不重要(只看屬性名)✅ 可以只取部分屬性3. 商業邏輯的可讀性差異對比:// ❌ 難以理解(魔術數字) const total = quantity * 500 - 50 - Math.floor((quantity - 1) / 5) * 100; // ✅ 易於理解(自我解釋的程式碼) const pricePerUnit = 500; const firstDiscountAmount = 50; const bulkDiscountAmount = 100; const bulkDiscountThreshold = 5; const rawTotal = quantity * pricePerUnit; const firstDiscount = firstDiscountAmount; const bulkDiscountTimes = Math.floor((quantity - 1) / bulkDiscountThreshold); const bulkDiscount = bulkDiscountTimes * bulkDiscountAmount; return rawTotal - firstDiscount - bulkDiscount; 優點:✅ 常數名稱即業務規則✅ 計算步驟清晰✅ 易於修改規則4. 函式組合(Composition)三層架構範例:// 第一層:建立範圍(內部工具) function createRange(start, end) { const length = end - start + 1; return Array.from({ length }, (_, index) => start + index); } // 第二層:過濾符合條件的數字 export function filterEvenNumbers(start, end) { return createRange(start, end).filter(num => num % 2 === 0); } // 第三層:統計與彙整 export function analyzeNumbers(start, end) { const evens = filterEvenNumbers(start, end); return { count: evens.length, sum: evens.reduce((a, b) => a + b, 0), average: evens.reduce((a, b) => a + b, 0) / evens.length }; } 特點:✅ 由簡單函式組合成複雜功能✅ 每個函式職責單一✅ 易於測試(可獨立測試每層)八、關鍵技術決策1. 為什麼不在核心邏輯中加驗證?錯誤設計:// ❌ 違反 SRP export function calculateDiscount(age) { if (typeof age !== 'number') { throw new Error('請輸入數字'); // ← 驗證器的責任 } if (age < 0) { throw new Error('年齡不可為負'); // ← 驗證器的責任 } return age < 18 ? 0.5 : 1.0; } 正確設計:// ✅ 核心邏輯假設輸入已驗證 export function calculateDiscount(age) { return age < 18 ? 0.5 : 1.0; } // 驗證在 Config 中 validator: composeValidators([ ensureIntegerString, toInt, isNonNegativeInt, ]) 理由:✅ 符合 SRP(單一職責原則)✅ 避免驗證邏輯重複✅ 核心邏輯保持純淨✅ 測試更簡單2. console.log vs console.error差異:方法用途輸出到顏色console.log()正常訊息stdout白色/預設console.error()錯誤訊息stderr紅色console.warn()警告訊息stderr黃色實務應用:# 分離正常和錯誤輸出 node main.js > output.txt 2> error.txt # stdout → output.txt # stderr → error.txt 決策:main().catch((err) => { console.error('程式發生未預期錯誤:', err); // ← 使用 error process.exit(1); // 回傳錯誤退出碼 }); 理由:✅ 符合 Unix/Node.js 標準✅ 方便 CI/CD 工具識別錯誤✅ 支援 shell 重導向分離輸出3. camelCase vs UPPER_SNAKE_CASE(命名規範)決策:統一使用 camelCase(符合 Airbnb Style Guide)// ✅ 推薦:camelCase(函式內部常數) const pricePerUnit = 500; const discountRate = 0.1; const maxQuantity = 100; // ❌ 不推薦:UPPER_SNAKE_CASE(除非是匯出的全域配置) const PRICE_PER_UNIT = 500; 例外:匯出的配置常數// ✅ 可接受(匯出的全域常數) export const API_BASE_URL = 'https://api.example.com'; export const MAX_RETRY_ATTEMPTS = 3; export const DEFAULT_TIMEOUT = 5000; 4. 測試策略:分層測試原則:不在核心邏輯測試中測試驗證// ✅ 驗證器的測試(util/validators.test.js) describe("isNonNegativeInt", () => { test("拒絕負數", () => { expect(() => isNonNegativeInt({ value: -1 })).toThrow(); }); }); // ✅ 核心邏輯的測試(features/discount/logic.test.js) describe("calculateDiscount", () => { test("正確計算折扣", () => { expect(calculateDiscount(12)).toBe(50); expect(calculateDiscount(18)).toBe(100); }); }); 理由:✅ 符合 SRP(分層測試)✅ 避免重複測試✅ 核心邏輯假設輸入已驗證九、測試技巧1. 參數化測試(test.each)// ✅ 避免重複 test.each([ [10, 100], [20, 200], [30, 300], ])("輸入 %i 應回傳 %i", (input, expected) => { expect(calculate(input)).toBe(expected); }); 2. 快照測試(toMatchInlineSnapshot)// 複雜物件測試 test("產生正確的報表", () => { const result = generateReport(data); expect(result).toMatchInlineSnapshot(` { "summary": "...", "details": [...] } `); }); 3. 邏輯驗證測試describe("計算邏輯驗證", () => { test("折扣計算關係正確", () => { // 驗證「關係」而非「結果」 const price10 = calculatePrice(10); const price20 = calculatePrice(20); // 數量加倍,價格應該接近加倍(考慮折扣) expect(price20 / price10).toBeGreaterThan(1.8); expect(price20 / price10).toBeLessThan(2.2); }); }); 十、Git Commit 規範Commit Message 格式():