Day 34:驗證器分層架構設計;While loop 時間序列狀態模擬

日期:2026/01/08
主題:驗證器架構設計、While Loop 狀態模擬、測試職責分離


今日技術重點

1. 驗證器架構設計

  • Predicate vs Validator 分層架構

  • 工廠函式(Factory Function)實踐

  • 命名慣例統一(is* vs ensure*)

2. 演算法實作

  • While Loop 狀態模擬(時間序列計算)

  • 模組重用模式

  • 函數命名重構

3. TDD 流程實踐

  • Red-Green-Refactor 循環

  • 測試與業務邏輯分離

  • Mock API 設計


🔧 技術決策記錄

案例 1:Predicate vs Validator 分層設計

核心概念釐清

層級

名稱

職責

回傳值

範例

底層

Predicate(斷言函式)

判斷真偽

Boolean

isPrime(7) => true

上層

Validator(驗證器)

驗證 + 轉換

{ value }

ensurePositive(7) => { value: 7 }

設計原則 :[1]

  • Predicate 是純函式:無副作用,可被 Array.filter()、Array.find() 使用

  • Validator 是包裝器:統一介面格式,支援鏈式組合

  • 可組合性:Predicate 可以被多個 Validator 重用

工廠函式實作

/**
 * 高階函式:將 Predicate 包裝成 Validator
 * @category Higher-Order Function, Factory Pattern, Wrapper
 */
export function predicateToValidator(predicate, errorMessage) {
  return (input) => {  // 返回閉包
    if (predicate(input)) {
      return { value: input };
    }
    throw new Error(errorMessage);
  };
}

// 使用範例:質數驗證器
const isPrime = (n) => {
  if (n < 2) return false;
  for (let i = 2; i <= Math.sqrt(n); i++) {
    if (n % i === 0) return false;
  }
  return true;
};

export const ensurePrime = predicateToValidator(
  isPrime, 
  '輸入必須是質數'
);

// 使用
ensurePrime(7);   // { value: 7 }
ensurePrime(4);   // Error: 輸入必須是質數

三重身份:

  1. 高階函式:接受函式參數 + 返回函式

  2. 工廠函式:批量製造客製化驗證器

  3. 包裝器模式:增強 predicate 功能而不修改原函式

底層機制:閉包(Closure)[2]

  • predicateToValidator 返回的函式記住了傳入的 predicate 和 errorMessage

  • 每次調用都能存取這些封閉變數


案例 2:命名慣例統一

語意衝突問題

前綴

語意

回傳值

使用情境

is*

判斷/詢問

Boolean

條件判斷、陣列過濾

ensure*

確保/斷言

{ value } 或拋錯

輸入驗證、資料轉換

to*

型別轉換

轉換後的值

類型轉換

重構範例

// ❌ 前:語意混淆
export function isPositiveInt(input) {
  const num = parseInt(input, 10);
  if (!Number.isInteger(num) || num <= 0) {
    throw new Error('必須是正整數');  // is* 不該拋錯
  }
  return { value: num };  // is* 不該回傳物件
}

// ✅ 後:語意清晰
export function ensurePositiveInt(input, min = 1, max = Infinity) {
  const num = parseInt(input, 10);
  
  if (!Number.isInteger(num) || num < min || num > max) {
    throw new Error(`必須是 ${min} 到 ${max} 之間的正整數`);
  }
  
  return { value: num };
}

// 配合的 Predicate(純判斷)
export const isPositiveInt = (n) => Number.isInteger(n) && n > 0;

// 使用分工
const numbers = [1, -2, 3.5, 4];
numbers.filter(isPositiveInt);  // [1, 4] - 用於過濾

ensurePositiveInt("5", 1, 10);  // { value: 5 } - 用於驗證輸入

重構成本:低(編輯器全域搜尋取代)
收益:語意清晰,符合 JavaScript 社群慣例[3][4]


案例 3:While Loop vs 數學公式

情境:計算「競賽者在有週期性規則下到達終點的時間」

方案比較

方案 A:數學公式(O(1))

// 假設:每 5 秒後退 1 公尺,速度 20 m/s
function calculateTimeWithFormula(distance, speed, interval, backward) {
  const netPerCycle = speed * interval - backward;
  const cycles = Math.floor(distance / netPerCycle);
  const remaining = distance - cycles * netPerCycle;
  return cycles * (interval + backward / speed) + remaining / speed;
}

方案 B:While Loop 模擬(O(n))

function calculateTimeWithLoop(distance, speed, interval, backward) {
  let time = 0;
  let currentDistance = 0;
  
  while (currentDistance < distance) {
    time += 1;
    currentDistance += speed;
    
    // 週期性規則
    if (time % interval === 0) {
      currentDistance -= backward;
    }
  }
  
  return time;
}

決策結果:選擇 While Loop ⭐

理由 :[1]

  • 可讀性:程式碼像在說故事,逐秒模擬過程

  • 可擴展性:規則複雜化時易於修改

  • 學習價值:直接對應現實思維模式

  • 除錯容易:可在迴圈內加 console.log 追蹤狀態

適用情境:

  • 學習階段(理解優先於效能)

  • 規則可能變動(需求不明確)

  • 複雜邏輯(數學公式難以推導)

何時選數學公式:

  • 效能關鍵(處理百萬級資料)

  • 規則固定且簡單

  • 生產環境優化階段


案例 4:測試 vs 業務邏輯的界線

問題識別

// ❌ 錯誤:在測試中寫業務邏輯
describe("Race Calculator", () => {
  it("計算休息時間", () => {
    const runner1Time = calculateFinishTime(1000, 0.28);
    const runner2Time = calculateFinishTime(1000, 20, 5, 1);
    const restTime = runner1Time - runner2Time;  // ← 業務邏輯
    
    expect(restTime).toBeGreaterThan(3400);
  });
});

問題:

  • 測試應該驗證「calculateFinishTime 是否正確」

  • 但這段程式碼在「用工具函式解決問題」

  • 違反測試單一職責[5][1]

正確分工

// ✅ 測試:只驗證函數正確性
describe("calculateFinishTime", () => {
  it("無後退行為時的計算", () => {
    expect(calculateFinishTime(1000, 0.28)).toBe(3572);
  });

  it("有後退行為時的計算", () => {
    expect(calculateFinishTime(1000, 20, 5, 1)).toBeCloseTo(51, 0);
  });
});

// ✅ 主程式:組合函數解決問題
function main() {
  const runner1Time = calculateFinishTime(1000, 0.28);
  const runner2Time = calculateFinishTime(1000, 20, 5, 1);
  const restTime = Math.floor(runner1Time - runner2Time);
  
  console.log(`最大休息時間:${restTime} 秒`);
}

核心原則:

層級

職責

輸出方式

測試

驗證工具函數正確性

expect() 斷言

主程式

組合工具函數解決問題

console.log() 結果


💡 技術亮點實作

1. 狀態計算模組

/**
 * 計算跑者在特定時間點的狀態(含週期性規則)
 * @param {number} speed - 速度(公尺/秒)
 * @param {number} time - 經過時間(秒)
 * @param {number} interval - 後退間隔(秒,0 表示無後退)
 * @param {number} backward - 每次後退距離(公尺)
 * @returns {{ time: number, distance: number }}
 */
export function calculateRunnerState(speed, time, interval = 0, backward = 0) {
  const state = { 
    time, 
    distance: speed * time 
  };

  if (interval > 0 && backward > 0) {
    const backwardCount = Math.floor(time / interval);
    state.distance -= backwardCount * backward;
    state.time += (backwardCount * backward) / speed;  // 後退耗時
  }
  
  return state;
}

設計重點:

  • 預設參數處理簡單情況(無後退)

  • 回傳物件包含 { time, distance }(狀態快照)

  • Math.floor() 計算完整週期數[3]


2. 完賽模擬模組(重用上層模組)

import { calculateRunnerState } from './calculateRunnerState.js';

/**
 * 模擬跑者到達目標距離所需時間
 * @param {number} target - 目標距離
 * @param {number} speed - 速度
 * @param {number} interval - 後退間隔
 * @param {number} backward - 後退距離
 * @returns {number} 總耗時(秒)
 */
export function calculateFinishTime(target, speed, interval = 0, backward = 0) {
  let time = 0;
  
  while (true) {
    time += 1;
    const state = calculateRunnerState(speed, time, interval, backward);
    
    if (state.distance >= target) {
      return state.time;  // 回傳實際耗時(包含後退時間)
    }
  }
}

模組重用模式 :[2]

calculateFinishTime (上層)
    └── 呼叫 calculateRunnerState (下層)
         └── 每秒計算一次狀態

類似開源範例:

  • React Hooks:useEffect 重用 useState

  • Express Middleware:路由處理器重用驗證中介層


3. 主程式(業務邏輯層)

import { calculateFinishTime } from './calculateFinishTime.js';

async function main() {
  console.log('=== 競賽模擬器 ===\n');
  
  const distance = 1000;
  const runner1Speed = 0.28;
  const runner2Speed = 20;
  const runner2Interval = 5;
  const runner2Backward = 1;
  
  const runner1Time = calculateFinishTime(distance, runner1Speed);
  const runner2Time = calculateFinishTime(
    distance, 
    runner2Speed, 
    runner2Interval, 
    runner2Backward
  );
  
  const timeDiff = Math.floor(runner1Time - runner2Time);
  
  console.log(`跑者 1 完賽:${runner1Time} 秒`);
  console.log(`跑者 2 完賽:${runner2Time} 秒`);
  console.log(`\n時間差:${timeDiff} 秒`);
}

main().catch((err) => {
  console.error('程式發生錯誤:', err);
  process.exit(1);
});

🧪 TDD 實踐案例

Red-Green-Refactor 循環[5][1]

🔴 Red → 寫測試 → 測試失敗(預期行為未實作) 🟢 Green → 實作功能 → 測試通過 🔄 Refactor → 優化程式碼 → 測試持續通過

案例:參數驗證的 TDD 流程

Round 1: Red(寫測試)

// ensurePositiveInt.test.js
import { describe, it, expect } from 'vitest';
import { ensurePositiveInt } from './validators.js';

describe('ensurePositiveInt', () => {
  it('應拋錯當 min 參數無效', () => {
    expect(() => ensurePositiveInt(5, -1)).toThrow('min 參數錯誤');
    expect(() => ensurePositiveInt(5, 0)).toThrow('min 參數錯誤');
    expect(() => ensurePositiveInt(5, 1.5)).toThrow('min 參數錯誤');
  });
});

執行測試: 失敗(功能未實作)

Round 2: Green(實作)

// validators.js
export function ensurePositiveInt(input, min = 1, max = Infinity) {
  // ✅ 新增參數驗證
  if (!Number.isInteger(min) || min < 1) {
    throw new Error('min 參數錯誤');
  }
  
  if (max !== Infinity && (!Number.isInteger(max) || max < min)) {
    throw new Error('max 參數錯誤');
  }
  
  // 原有邏輯...
  const num = parseInt(input, 10);
  if (!Number.isInteger(num) || num < min || num > max) {
    throw new Error(`必須是 ${min} 到 ${max} 之間的正整數`);
  }
  
  return { value: num };
}

執行測試: 通過

Round 3: Refactor(重構)

// 抽取驗證邏輯
function validateRange(min, max) {
  if (!Number.isInteger(min) || min < 1) {
    throw new Error('min 參數錯誤');
  }
  
  if (max !== Infinity && (!Number.isInteger(max) || max < min)) {
    throw new Error('max 參數錯誤');
  }
}

export function ensurePositiveInt(input, min = 1, max = Infinity) {
  validateRange(min, max);  // 重用驗證邏輯
  
  const num = parseInt(input, 10);
  if (!Number.isInteger(num) || num < min || num > max) {
    throw new Error(`必須是 ${min} 到 ${max} 之間的正整數`);
  }
  
  return { value: num };
}

執行測試: 持續通過


關鍵學習總結

1. 設計模式實踐

模式

應用

效益

Factory Pattern

predicateToValidator 工廠函式

批量生成驗證器

Wrapper Pattern

Validator 包裝 Predicate

統一介面

Strategy Pattern

While Loop vs 數學公式

彈性切換演算法

Module Pattern

分層模組設計

可重用、可測試

2. 命名的重要性

// ❌ 不好的命名
calculator(20, 5, 5, 1)         // 名詞,不清楚動作
isPositive(x) // 回傳 { value }  // 語意不符
// ✅ 好的命名
calculateRunnerState(20, 5, 5, 1)  // 動詞+名詞,清楚動作
ensurePositive(x) // 回傳 { value } // 語意一致
isPositive(x) // 回傳 Boolean      // Predicate 用法

3. TDD 的「小步快跑」哲學[1]

核心優勢:

  • 每次改動小 → 錯誤容易定位

  • 持續通過測試 → 信心累積

  • 可隨時重構 → 測試保護網


📂 專案結構範例

race-simulator/
├── calculateRunnerState.js       # 狀態計算(底層)
├── calculateRunnerState.test.js  # 4 tests
├── calculateFinishTime.js        # 完賽模擬(上層,重用底層)
├── calculateFinishTime.test.js   # 5 tests
└── main.js                       # 業務邏輯(組合上層模組)

validators/
├── predicates.js                 # 純判斷函式(isPrime, isEven...)
├── validators.js                 # 驗證器(ensurePositive, ensurePrime...)
├── factory.js                    # predicateToValidator 工廠
└── validators.test.js            # 測試

設計原則:

  • SRP:每個模組只做一件事

  • DRY:重用已測試的模組

  • 分層:底層工具 → 上層組合 → 業務邏輯


🔧 工具與技術棧

  • 測試框架:Vitest

  • Node.js API:readline/promises(Promise-based)

  • 程式碼風格:Airbnb JavaScript Style Guide

  • 模組系統:ES Modules (ESM)

  • 版本控制:Git(小步提交)


📚 延伸學習資源

JavaScript 核心概念

測試驅動開發

設計模式


💡 今日核心收穫

  1. Predicate vs Validator 分層:底層判斷邏輯與上層驗證介面分離

  2. 工廠函式三重身份:高階函式 + 工廠模式 + 包裝器

  3. 命名慣例:is* 回傳布林,ensure* 回傳物件或拋錯

  4. While Loop 思維:可讀性優於效能(學習階段)

  5. 測試職責:驗證工具正確性,不包含業務邏輯

  6. 模組重用:下層工具被上層組合,上層工具被主程式使用

  7. TDD 小步快跑:每次只改 5-10 行,快速驗證


📊 測試結果範例

✓ calculateRunnerState.test.js (4 tests) 5ms
  ✓ 基本前進計算
  ✓ 含後退邏輯
  ✓ 後退耗時計算
  ✓ 無後退情況

✓ calculateFinishTime.test.js (5 tests) 3ms
  ✓ 短距離計算
  ✓ 觸發後退情況
  ✓ 長距離計算
  ✓ 無後退情況
  ✓ 邊界條件

Test Files  2 passed (2)
     Tests  9 passed (9)
  Duration  278ms

1 2 3 4 5 6 7