Day 31:CLI 驗證流程實戰 - 用閉包實作部分套用與 API 封裝

專案背景

這是一個 Node.js CLI 互動式輸入驗證系統,使用 readline 模組讀取使用者輸入,並透過可組合的驗證器檢查輸入正確性。核心設計目標包含模組化、可測試性與可擴展性。

系統架構

分層設計

┌─────────────────────────────────────────────────────┐
│  Application Layer (main.js)                        │
│  定義 questionConfigs,呼叫 runCliQuestions           │
└────────────────┬────────────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────────────┐
│  Orchestration Layer (runCliQuestions.js)           │
│  組裝依賴:createRl、askWithValidator、session        │
└────────────────┬────────────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────────────┐
│  Core Logic Layer (runCliQuestionsCore.js)          │
│  管理 rl 生命週期,用閉包產生 askWithValidatorBoundToRl│
└────────────────┬────────────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────────────┐
│  I/O Adapter Layer (askWithValidator.js)            │
│  封裝 readline 互動邏輯                               │
└────────────────┬────────────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────────────┐
│  Validator Layer (validators.js)                    │
│  提供可組合的驗證器函式                                 │
└─────────────────────────────────────────────────────┘

設計原則對應

層級

職責

SOLID 原則

Application

定義業務問題配置

SRP:只負責配置組裝

Orchestration

依賴注入與組裝

DIP:依賴抽象而非具體

Core Logic

流程控制邏輯

OCP:擴展不修改核心

I/O Adapter

封裝底層 I/O 細節

ISP:介面隔離

Validator

驗證規則實作

SRP:單一驗證職責

核心問題:為什麼需要閉包封裝 API

問題起源

底層 I/O 模組的 API

// askWithValidator.js
export async function askWithValidator(rl, prompt, validator) {
  while (true) {
    const raw = await questionOnce(rl, prompt);
    try {
      const value = validator(raw);
      return value;
    } catch (error) {
      console.log(error.message);
    }
  }
}

特性:需要 rl(readline 實例)作為第一個參數,直接依賴具體的 I/O 實作。

高階流程模組的需求

// runCliQuestionsCore.js
export async function askOneRound(questionConfigs, askWithValidator) {
  const answers = {};

  for (const config of questionConfigs) {
    // 希望只傳 (prompt, validator),不想知道 rl 是什麼
    const answer = await askWithValidator(config.prompt, config.validator);
    answers[config.name] = answer;
  }

  return answers;
}

特性:只關心抽象的「問問題」能力,不應該知道底層實作細節。

API 層級不一致

模組

需要的 API

實際的 API

askOneRound

ask(prompt, validator)

askWithValidator(rl, prompt, validator)

如果直接呼叫,會導致職責污染:

// ❌ 錯誤:askOneRound 必須知道 rl,違反職責分離
export async function askOneRound(questionConfigs, askWithValidator, rl) {
  const answers = {};

  for (const config of questionConfigs) {
    const answer = await askWithValidator(rl, config.prompt, config.validator);
    answers[config.name] = answer;
  }

  return answers;
}

問題分析:

  • askOneRound 被 rl 污染,必須知道 readline 的存在

  • 測試時必須 mock readline,而不是 mock 抽象行為

  • 違反依賴反轉原則 (DIP):高階模組依賴低階細節

解決方案:用閉包實作部分套用

部分套用(Partial Application)概念

定義:固定一個函式的部分參數,回傳一個新函式只接受剩餘參數。

通用範例:

// 原始函式:三個參數
function add(a, b, c) {
  return a + b + c;
}

// 部分套用:固定第一個參數
function addWith5(b, c) {
  return add(5, b, c);
}

addWith5(2, 3); // 10

用閉包實作:

function createAdder(a) {
  // 閉包:回傳的函式「記住」外層的 a
  return function (b, c) {
    return a + b + c;
  };
}

const addWith5 = createAdder(5);
addWith5(2, 3); // 10

應用:封裝 readline 細節

在 runCliQuestionsCore 中的實作:

// runCliQuestionsCore.js
export async function runCliQuestionsCore(
  questionConfigs,
  askWithValidator,
  runCliSession,
  rounds = 1
) {
  return runCliSession(async (rl) => {
    const result = [];

    // 關鍵:用閉包固定第一個參數 rl
    const askWithValidatorBoundToRl = (prompt, validator) =>
      askWithValidator(rl, prompt, validator);

    for (let round = 0; round < rounds; round += 1) {
      const oneRoundResult = await askOneRound(
        questionConfigs,
        askWithValidatorBoundToRl
      );
      result.push(oneRoundResult);
    }

    return result;
  });
}

執行流程拆解

步驟 1:建立詞法環境

根據 ECMAScript 規範,當 runCliSession 執行時:

  • JavaScript 引擎建立執行上下文 (Execution Context)

  • 建立宣告式環境記錄 (Declarative Environment Record),儲存 rl 變數

  • rl 的值是 createRl() 回傳的 readline interface

步驟 2:宣告閉包函式

const askWithValidatorBoundToRl = (prompt, validator) =>
  askWithValidator(rl, prompt, validator);

這個箭頭函式在宣告時:

  • 內部有 [[Environment]] 屬性,指向外層的 Environment Record

  • 即使外層函式執行完畢,只要這個函式存在,rl 就不會被 GC 回收

步驟 3:傳遞簡化後的 API

const oneRoundResult = await askOneRound(
  questionConfigs,
  askWithValidatorBoundToRl
);

askOneRound 拿到的是簽章為 (prompt, validator) 的函式。

步驟 4:執行時存取閉包變數

// 在 askOneRound 裡
const answer = await askWithValidator(config.prompt, config.validator);

實際執行的是:

askWithValidator(rl, config.prompt, config.validator)

這裡的 rl 來自當初 runCliSession 回呼時的詞法環境。

記憶體狀態圖解

┌─────────────────────────────────────────────────┐
│  runCliSession 的 Lexical Environment            │
│                                                 │
│  rl: ReadlineInterface { ... }  ← 私有變數      │
└────────────────┬────────────────────────────────┘
                 │
                 │ 閉包持有這個環境的參照
                 │
                 ▼
┌─────────────────────────────────────────────────┐
│  askWithValidatorBoundToRl: Function             │
│                                                 │
│  (prompt, validator) => {                       │
│    askWithValidator(rl, prompt, validator)      │
│  }                                              │
│                                                 │
│  ← 這個函式「記住」上方的 rl                      │
└─────────────────────────────────────────────────┘
                 │
                 │ 傳遞給 askOneRound
                 │
                 ▼
┌─────────────────────────────────────────────────┐
│  askOneRound 的執行上下文                         │
│                                                 │
│  呼叫 askWithValidator(prompt, validator)        │
│  ↓                                              │
│  實際執行:askWithValidator(rl, prompt, validator)│
└─────────────────────────────────────────────────┘

設計優勢分析

職責分離 (Single Responsibility Principle)

模組

職責

依賴細節

askWithValidator

操作 readline,處理重試邏輯

知道 rl、console.log

askOneRound

遍歷問題清單,收集答案

只知道「有個函式可以問問題」

runCliQuestionsCore

組裝依賴,管理多輪問答

知道 rl,但不直接操作

依賴反轉 (Dependency Inversion Principle)

高階模組不依賴低階模組的具體實作,雙方都依賴抽象:

┌─────────────────────────────────────────┐
│  askOneRound (高階模組)                   │
│  依賴:ask(prompt, validator) 抽象        │
└──────────────────▲──────────────────────┘
                   │
                   │ 透過閉包注入
                   │
┌──────────────────┴──────────────────────┐
│  askWithValidator (低階模組)              │
│  實作:askWithValidator(rl, prompt, ...)  │
└─────────────────────────────────────────┘

未來可以替換實作:

// 替換成從檔案讀取的版本
const askFromFile = (prompt, validator) => {
  const input = fs.readFileSync('inputs.txt', 'utf-8');
  return validator(input);
};

// askOneRound 完全不用改
await askOneRound(questionConfigs, askFromFile);

可測試性提升

測試時不需要 readline:

// askOneRound.test.js
import { describe, it, expect } from 'vitest';
import { askOneRound } from './runCliQuestionsCore.js';

describe('askOneRound', () => {
  it('應該正確收集所有問題的答案', async () => {
    const mockAsk = async (prompt, validator) => {
      if (prompt.includes('偶數')) return validator('42');
      if (prompt.includes('正整數')) return validator('5');
      return validator('0');
    };

    const questionConfigs = [
      {
        name: 'evenNum',
        prompt: '請輸入偶數',
        validator: (v) => ({ value: parseInt(v, 10) }),
      },
      {
        name: 'posNum',
        prompt: '請輸入正整數',
        validator: (v) => ({ value: parseInt(v, 10) }),
      },
    ];

    const result = await askOneRound(questionConfigs, mockAsk);

    expect(result).toEqual({
      evenNum: { value: 42 },
      posNum: { value: 5 },
    });
  });
});

實作方式對比

方案 A:參數一路傳(不用閉包)

export async function askOneRound(questionConfigs, askWithValidator, rl) {
  const answers = {};

  for (const config of questionConfigs) {
    const answer = await askWithValidator(rl, config.prompt, config.validator);
    answers[config.name] = answer;
  }

  return answers;
}

評估:

面向

優點

缺點

可讀性

參數傳遞清楚

每層都要傳 rl

職責

-

違反 SRP / DIP

測試

-

必須 mock rl

方案 B:使用 bind

export async function runCliQuestionsCore(
  questionConfigs,
  askWithValidator,
  runCliSession,
  rounds = 1
) {
  return runCliSession(async (rl) => {
    const result = [];

    const askBound = askWithValidator.bind(null, rl);

    for (let round = 0; round < rounds; round += 1) {
      const oneRoundResult = await askOneRound(questionConfigs, askBound);
      result.push(oneRoundResult);
    }
    return result;
  });
}

評估:

面向

優點

缺點

簡潔性

一行搞定

對初學者較難理解

功能

等價於閉包版本

-

語意

明確表達「綁定參數」

-

方案 C:使用 Class 封裝

class CliQuestionSession {
  constructor(rl, askWithValidator) {
    this.rl = rl;
    this.askWithValidator = askWithValidator;
  }

  async ask(prompt, validator) {
    return this.askWithValidator(this.rl, prompt, validator);
  }

  async askOneRound(questionConfigs) {
    const answers = {};
    for (const config of questionConfigs) {
      const answer = await this.ask(config.prompt, config.validator);
      answers[config.name] = answer;
    }
    return answers;
  }
}

評估:

面向

優點

缺點

狀態管理

適合需要記錄重試次數等狀態

目前不需要狀態累積

風格

OOP 風格

Node.js CLI 慣用 FP 風格

複雜度

-

需管理實例生命週期

完整程式碼範例

核心邏輯層

// util/runCliQuestionsCore.js

/**
 * 執行一輪問答
 * @param {Array} questionConfigs - 問題配置陣列
 * @param {Function} askWithValidator - 問問題的函式
 * @returns {Promise<Object>} 答案物件
 */
export async function askOneRound(questionConfigs, askWithValidator) {
  const answers = {};

  for (const config of questionConfigs) {
    const answer = await askWithValidator(config.prompt, config.validator);
    answers[config.name] = answer;
  }

  return answers;
}

/**
 * 執行多輪問答的核心邏輯
 * @param {Array} questionConfigs - 問題配置陣列
 * @param {Function} askWithValidator - 原始的問問題函式
 * @param {Function} runCliSession - Session 管理函式
 * @param {number} rounds - 執行輪數
 * @returns {Promise<Array>} 每輪答案的陣列
 */
export async function runCliQuestionsCore(
  questionConfigs,
  askWithValidator,
  runCliSession,
  rounds = 1
) {
  return runCliSession(async (rl) => {
    const result = [];

    // 用閉包實作部分套用
    const askWithValidatorBoundToRl = (prompt, validator) =>
      askWithValidator(rl, prompt, validator);

    for (let round = 0; round < rounds; round += 1) {
      const oneRoundResult = await askOneRound(
        questionConfigs,
        askWithValidatorBoundToRl
      );
      result.push(oneRoundResult);
    }

    return result;
  });
}

I/O 層

// util/askWithValidator.js

/**
 * 詢問使用者輸入(只問一次)
 * @param {ReadlineInterface} rl - readline 實例
 * @param {string} prompt - 提示訊息
 * @returns {Promise<string>} 使用者輸入的原始字串
 */
export async function questionOnce(rl, prompt) {
  const userInput = await rl.question(prompt);
  return userInput;
}

/**
 * 反覆詢問使用者,直到驗證通過
 * @param {ReadlineInterface} rl - readline 實例
 * @param {string} prompt - 提示訊息
 * @param {Function} validator - 驗證器
 * @returns {Promise<any>} 驗證通過的值
 */
export async function askWithValidator(rl, prompt, validator) {
  while (true) {
    const raw = await questionOnce(rl, prompt);

    try {
      const result = validator(raw);
      return result;
    } catch (error) {
      console.log('❌', error.message);
    }
  }
}

驗證器組合

// util/composeValidators.js

/**
 * 組合多個驗證器成一個驗證鏈
 * @param {Array<Function>} validators - 驗證器陣列
 * @returns {Function} 組合後的驗證器
 */
export function composeValidators(validators) {
  return (value) => {
    let current = value;

    for (const validate of validators) {
      const result = validate(current);
      current = result.value;
    }

    return { value: current };
  };
}

常見問題與解決方案

問題 1:readline API 版本混用導致卡住

症狀

輸入後遊標卡住,無法正常結束。

根本原因

createRl 使用 node:readline/promises,但 questionOnce 用 callback 版本 API:

// ❌ 錯誤:Promise 版的 Interface,用 callback 版的 API
export function questionOnce(rl, prompt) {
  return new Promise((resolve) => {
    rl.question(prompt, (userInput) => {
      // callback 不會被觸發
      resolve(userInput);
    });
  });
}

解決方案

改用 Promise 版 API:

// ✅ 正確:搭配 node:readline/promises
export async function questionOnce(rl, prompt) {
  const userInput = await rl.question(prompt);
  return userInput;
}

經驗法則

import 來源

Interface.question API

適合的寫法

node:readline/promises

await rl.question()

直接 await

node:readline

rl.question(p, cb)

傳 callback 或自己包成 Promise

混用

callback 不會被觸發

必須統一

問題 2:config 欄位名稱不一致

症狀

答案物件的 key 變成 undefined。

根本原因

askOneRound 使用 config.key,但 config 只有 name 欄位:

// ❌ 錯誤
answers[config.key] = answer;

// config 實際結構
{
  name: "evenInput",  // 只有 name
  prompt: "請輸入偶數",
  validator: fn
}

解決方案

統一使用 config.name:

// ✅ 正確
answers[config.name] = answer;

問題 3:驗證器回傳結構與業務邏輯不一致

症狀

遞迴函式導致堆疊溢位 (Maximum call stack size exceeded)。

根本原因

驗證器回傳 { value: 8 },但業務邏輯期望 number:

// processor 直接呼叫業務邏輯
processor: umleven

// 實際呼叫變成
umleven({ value: 8 })  // ❌ 期望 number,收到 object

解決方案

在 processor 中拆解結構:

// ✅ 正確
processor: (validated) => {
  const n = validated.value;
  return umleven(n);
}

或在 processUserInputs 統一處理:

export function processUserInputs(userInputRounds, questionConfigs) {
  return userInputRounds.map((roundInputs) => {
    const processedValues = {};

    for (const config of questionConfigs) {
      const validated = roundInputs[config.name];

      // 自動判斷:如果是 { value } 就拆出來
      const input = validated && typeof validated === 'object' && 'value' in validated
        ? validated.value
        : validated;

      processedValues[config.name] = config.processor
        ? config.processor(input)
        : input;
    }

    return processedValues;
  });
}

JSDoc 型別標註實作

定義核心型別

// util/types.js
// @ts-check

/**
 * 驗證結果
 * @typedef {Object} ValidationResult
 * @property {any} value - 驗證後的值
 */

/**
 * 驗證器函式
 * @typedef {(input: string | ValidationResult) => ValidationResult} Validator
 */

/**
 * 後處理函式
 * @typedef {(validated: ValidationResult) => any} Processor
 */

/**
 * 問題配置物件
 * @typedef {Object} QuestionConfig
 * @property {string} name - 用來當答案物件的 key
 * @property {string} prompt - 提示訊息
 * @property {Validator} validator - 驗證器函式
 * @property {Processor} [processor] - 選擇性的後處理函式
 */

export {};

標註驗證器

// @ts-check

/**
 * 確保輸入是整數字串
 * @param {string} raw - 使用者原始輸入
 * @returns {ValidationResult} 包含原始字串的驗證結果
 * @throws {Error} 當輸入不是整數字串時拋出錯誤
 */
export function ensureIntegerString(raw) {
  if (!/^-?\d+$/.test(raw)) {
    throw new Error('請輸入整數');
  }
  return { value: raw };
}

/**
 * 將字串轉換為整數
 * @param {{ value: string }} validated - 前一個驗證器的結果
 * @returns {ValidationResult} 包含數字的驗證結果
 */
export function toInt(validated) {
  return { value: parseInt(validated.value, 10) };
}

在配置檔使用型別

// q6/q6config.js
// @ts-check

/**
 * @type {import('../util/types.js').QuestionConfig[]}
 */
export const questionConfigs = [
  {
    name: "evenInput",
    prompt: "請輸入一個大於4的偶數:",
    validator: composeValidators([
      ensureIntegerString,
      toInt,
      (v) => isEvenNum(v, 4),
    ]),
    processor: (validated) => umleven(validated.value),
  },
];

除錯策略

使用 console.log 追蹤執行路徑

在關鍵位置加上標記:

console.log('[DEBUG] 模組名: 狀態描述', 相關變數);

檢查 import 來源與 API 版本

當遇到「明明呼叫了卻沒反應」:

  1. 檢查 import 路徑(node:readline vs node:readline/promises)

  2. 查官方文件確認 API 簽章

  3. 確認有沒有混用不同版本的 API

優先檢查組裝層

單元測試過但整合卡住時:

  1. config 欄位名稱是否統一

  2. 函式簽章是否一致

  3. 閉包是否正確封裝依賴

設計原則總結

原則

實作方式

體現在程式碼

SRP

每個模組只負責一件事

askWithValidator 只管 I/O,askOneRound 只管流程

OCP

新增功能不修改舊程式碼

新增驗證規則只要加 validator

DIP

高階模組依賴抽象

askOneRound 依賴抽象函式,不依賴 readline

封裝

隱藏實作細節

rl 只在少數模組可見

參考資源