Day 33:Node.js CLI 專案架構重構:從零散到模組化

目標:建立可重用的 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" → Error


toInt - 轉換為數字

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)

功能

  1. 自動提取 { value: ... } 包裝

  2. 執行 processor

  3. 支援單欄位和多欄位模式

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;

關鍵規則

  1. 屬性名必須一致

  2. 順序不重要(只看屬性名)

  3. 可以只取部分屬性


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 格式

<type>(<scope>): <subject>

<body>

<footer>

Type 類型

  • feat: 新功能

  • refactor: 重構

  • fix: 修復 bug

  • test: 新增測試

  • docs: 文件更新

範例

git commit -m "feat(discount): 完成折扣計算功能

- 新增 calculateDiscount 核心邏輯與測試
- 新增 config(單欄位模式)
- 新增 main(統一模板)
- 驗證器使用 isNonNegativeInt(年齡不可為負)

業務規則:
- 基本價格 100 元
- 12 歲(含)以下享半價
- 65 歲(含)以上享半價

技術:Config-driven + 驗證器組合 + 純函式"

十一、架構優勢

可重用模組

util/
├── validators.js          # 7 個驗證器
├── composeValidators.js   # 驗證器組合
├── processUserInputs.js   # 處理流程(支援單/多欄位)
├── runCliQuestions.js     # CLI 問答
├── askWithValidator.js    # 帶驗證的輸入
└── runCliSession.js       # CLI Session 管理

shared/
└── utils.js               # 共用函式(跨功能重用)

技術成就

類別

技術點

CLI 架構

Config-driven、驗證器組合

陣列方法

map、filter、reduce、forEach

演算法

質數判斷、範圍生成、數值計算

模組重用

跨功能共用函式

解構賦值

參數解構、物件解構

測試策略

參數化、快照、邏輯驗證


十二、學到的設計原則

1. SRP(單一職責原則)

驗證器 → 負責驗證
核心邏輯 → 負責業務運算
Main 檔 → 負責組裝流程

2. DRY(不重複原則)

抽象共同邏輯 → util/
共用函式 → shared/
保持核心邏輯獨特性 → features/

3. KISS(保持簡單原則)

// ✅ 簡單
export function sum(numbers) {
  return numbers.reduce((a, b) => a + b, 0);
}

4. 配置優於程式碼

// ✅ 配置驅動(新增功能只需複製 Config)
export const questionConfigs = [
  {
    name: "input",
    validator: composeValidators([...]),
    processor: processLogic,
  },
];

十三、關鍵 Takeaways

架構設計

  1. 分層清晰:CLI → Processing → Business Logic

  2. 模組化:驗證器、處理流程、核心邏輯分離

  3. 可擴展:新增功能只需複製模板

  4. 模組重用:跨功能共用函式

程式設計

  1. 組合優於繼承composeValidators 串接驗證器

  2. 配置驅動:差異封裝在 Config 中

  3. 純函式:核心邏輯無副作用、易測試

  4. 解構賦值:提升程式碼可讀性

測試策略

  1. 分層測試:驗證器、核心邏輯分開測試

  2. 參數化測試test.each 避免重複

  3. 快照測試:視覺化驗證複雜輸出

  4. 邏輯驗證:測試業務關係(不只測結果)

開發流程

  1. TDD:先寫測試,再實作

  2. 小步提交:每個功能獨立 commit

  3. 重構不改功能:測試保證行為不變

  4. 規範統一:Airbnb Style Guide(camelCase)


十四、實戰成果

統計數據

  • 程式碼減少:約 60% 重複程式碼消除

  • 模板化:70% 功能使用完全相同的 Main 檔

  • 測試覆蓋:100% 核心邏輯有單元測試


效能優化範例

質數判斷優化

// 基礎版本
export function isPrime(num) {
  if (num <= 1) return false;
  for (let i = 2; i < num; i++) {
    if (num % i === 0) return false;
  }
  return true;
}

// 優化版本(跳過偶數 + 只檢查到 sqrt(n))✅
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;
}

效能提升

  • 基礎版本:O(n)

  • 優化版本:O(√n),且跳過所有偶數


十五、參考資源

官方文件

  • Node.js Console:https://nodejs.org/api/console.html

  • Vitest:https://vitest.dev/guide

  • MDN JavaScript:https://developer.mozilla.org/zh-TW/docs/Web/JavaScript

  • ECMAScript 規範:https://tc39.es/ecma262

設計模式

  • Composition Pattern:函式組合

  • Strategy Pattern:驗證器策略

  • Template Method Pattern:Main 檔模板

延伸閱讀

  • Clean Code by Robert C. Martin

  • Refactoring by Martin Fowler

  • Test-Driven Development by Kent Beck

  • Airbnb JavaScript Style Guide


結語

這個架構展示了如何從零散的程式碼,建立一個可維護、可擴展、可測試的 CLI 專案架構。

核心成就

  • 統一架構模板

  • 可重用模組系統(驗證器 + 共用函式)

  • 完整測試覆蓋

  • 模組重用實踐

  • 符合業界規範(Airbnb Style Guide)

最重要的收穫

"好的架構不是一次設計出來的,而是透過重構逐步演進的。"

關鍵技術

  • 🏗 Config-Driven 模式(配置驅動)

  • 🔄 Validator Composition(驗證器組合)

  • 📦 模組重用(跨功能共用)

  • 解構賦值(提升可讀性)

  • 🧪 分層測試(測試策略)

持續改進,保持程式碼的整潔和優雅!🚀