Day 36:重構 CLI 驗證流程:從過度抽象到剛好夠用

主題:在 Node.js 環境下,如何為 CLI 程式設計「可讀、好維護」的輸入驗證流程。


一級函式與驗證器設計

在 JavaScript 中,函式是一級公民,可以:

  • 存在變數、陣列、物件中

  • 當作參數傳入其他函式

  • 當作回傳值傳出

  • 擁有屬性與方法[1]

這非常適合拿來實作「驗證器」:

// 單一職責的驗證器
export function validatePositiveInt(raw, min = 1, max = Infinity) {
  const trimmed = String(raw).trim();

  if (!/^[+-]?[0-9]+$/.test(trimmed)) {
    throw new Error("請輸入整數");
  }

  const num = parseInt(trimmed, 10);

  if (num < min || num > max) {
    if (max === Infinity) {
      throw new Error(`請輸入大於等於 ${min} 的正整數`);
    }
    throw new Error(`請輸入 ${min} 到 ${max} 之間的正整數`);
  }

  return num;
}

設計重點:

  • 驗證器是「普通函式」,接 string、丟錯誤或回傳轉型後的值。

  • 名稱直接表意(validatePositiveInt),不需要額外文件。[1]


反例:過度抽象的 composeValidators

曾經的設計:為了「高可重用性」,先做一個驗證器組合器 composeValidators,然後所有驗證流程都要經過這個組合器。

// 抽象過度的範例(不推薦)
import { composeValidators } from "./composeValidators.js";
import {
  validateIntegerString,
  toInt,
  validatePositiveInt,
} from "./validators.js";

export const config = {
  validator: composeValidators([
    validateIntegerString, // 格式檢查
    toInt,                 // 型別轉換
    validatePositiveInt,   // 範圍檢查
  ]),
};

問題:

  • 需要先理解 composeValidators 的執行順序與錯誤傳遞規則,認知負擔高。

  • 每層可能拋出不同錯誤訊息,很難維持一致性。

  • 回傳值若被包成 { value } 之類的物件,只是多一層樣板。
    (「為了統一而統一」,但沒有實質收益。)

對單純的 CLI 練習題或小工具來說,這種抽象層級明顯超過實際需求。


改用「單一函式驗證器」的結構

重構後的目標:

  • 一個驗證需求,一個函式。

  • 直接回傳值,不包殼。

  • 所有錯誤訊息在函式內統一管理。

// util/validators.js
function validateFormat(input, pattern, errorMsg) {
  const trimmed = String(input).trim();
  if (!pattern.test(trimmed)) {
    throw new Error(errorMsg);
  }
  return trimmed;
}

function validateRange(num, min, max, errorMsg) {
  if (num < min || (max !== undefined && num > max)) {
    throw new Error(errorMsg);
  }
  return num;
}

export function validateNonNegativeInt(raw) {
  const trimmed = validateFormat(raw, /^[+-]?[0-9]+$/, "請輸入整數");
  const num = parseInt(trimmed, 10);
  return validateRange(num, 0, undefined, "請輸入非負整數");
}

export function validateEvenInt(raw, min = 2) {
  const trimmed = validateFormat(raw, /^[+-]?[0-9]+$/, "請輸入整數");
  const num = parseInt(trimmed, 10);

  if (num < 0) {
    throw new Error("請輸入非負整數");
  }

  if (num % 2 !== 0) {
    throw new Error("請輸入偶數");
  }

  return validateRange(num, min, undefined, `請輸入大於等於 ${min} 的偶數`);
}

使用方式:

import { validateNonNegativeInt } from "./util/validators.js";

const config = {
  name: "visitNum",
  prompt: "請輸入人數:",
  validator: validateNonNegativeInt,
};

優點:

  • 不需要閱讀 composeValidators 就能理解驗證邏輯。

  • 錯誤訊息路徑清楚:「格式」→「範圍」→「業務規則」。

  • 型別由 JS 自己承擔(直接回傳 number),不多包一層物件。[1]


CLI 輸入:為什麼改用遞迴而不用 while

情境:寫一個 CLI 工具,重複詢問使用者輸入,直到通過驗證。

初版:while(true) + return 退出

// 反例:while(true) + return
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

function createRl() {
  return createInterface({ input, output });
}

export async function askWithValidator(prompt, validator) {
  let rl = createRl();

  while (true) {
    const raw = await rl.question(prompt);

    try {
      const value = validator(raw);
      rl.close();
      return value; // 同時結束迴圈與函式
    } catch (error) {
      console.log("輸入錯誤,請重新輸入");
      rl.close();
      rl = createRl();
    }
  }
}

問題:

  • return 同時負責「跳出迴圈」與「結束函式」,職責混在一起。

  • 除錯時不容易判斷是迴圈控制有誤,還是函式邏輯有誤。

  • while(true) 需要讀者在腦中模擬可能的結束條件,本身認知負擔就高。[2][3]

即使改成 break 也一樣會有「必須在腦中跑迴圈」的問題,只是職責稍微清楚一些:

// 仍然偏難讀:需要外部變數 + 模擬迴圈狀態
export async function askWithValidator(prompt, validator) {
  let rl = createRl();
  let result;

  while (true) {
    const raw = await rl.question(prompt);

    try {
      result = validator(raw);
      break;
    } catch (error) {
      console.log("輸入錯誤,請重新輸入");
      rl.close();
      rl = createRl();
    }
  }

  rl.close();
  return result;
}

重構:單一遞迴函式,去掉 while

更直覺的方式是改成「錯了就再問一次」的遞迴寫法:

// 版本 1:每次呼叫都建立/關閉一次 rl
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

export function createRl() {
  return readline.createInterface({ input, output });
}

export async function askWithValidatorUntilPass(prompt, validator) {
  const rl = createRl();
  const raw = await rl.question(prompt);

  try {
    const value = validator(raw);
    rl.close();
    return value;
  } catch (error) {
    console.log(error.message);
    rl.close();
    return askWithValidatorUntilPass(prompt, validator);
  }
}

閱讀時的心智模型變成:

  1. 問一次。

  2. 驗證成功 → 回傳。

  3. 驗證失敗 → 顯示錯誤 → 再呼叫自己一次。

幾乎不需要在腦中模擬狀態。對「重試直到成功」這種流程,遞迴比 while 更自然。[3][2]

進一步優化:外層管理 readline 生命週期

為了避免每次遞迴都重新建立 readline.Interface,可以把資源管理抽到外層,內層只管重試:

import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

export function createRl() {
  return readline.createInterface({ input, output });
}

export async function askWithValidatorUntilPass(prompt, validator) {
  const rl = createRl();

  async function attempt() {
    const raw = await rl.question(prompt);

    try {
      return validator(raw);
    } catch (error) {
      console.log(error.message || "輸入錯誤,請重新輸入");
      return attempt();
    }
  }

  try {
    return await attempt();
  } finally {
    rl.close();
  }
}

結構上的分工:

  • 外層:建立與關閉 rl(資源管理)。

  • 內層 attempt():只負責「問 → 驗證 → 成功或重試」。

這樣既避免了 while,也讓職責非常清晰。Node 官方文件也建議為 readline/promises 建立 interface 後,以 async/await 形式操作問題流程。[4][5]


統一 async/await 與錯誤處理風格

在 CLI 主程式中,常見兩種寫法:

// 寫法 A:async 函式 + 外層 .catch()
async function main() {
  // ...
}

main().catch((err) => {
  console.error("程式發生未預期錯誤:", err);
  process.exit(1);
});
// 寫法 B:async 函式 + 內部 try/catch
async function main() {
  try {
    // ...
  } catch (err) {
    console.error("程式發生未預期錯誤:", err);
    process.exit(1);
  }
}

main();

為了風格一致與可讀性,多數情況下選擇「函式內使用 async/await,就同時用 try/catch 在函式內處理錯誤」會比較好;這與 MDN 對 async function + try...catch 的示範風格相符。[6][7]

範例:

import { askWithValidatorUntilPass } from "./util/askWithValidatorUntilPass.js";
import { validatePositiveInt } from "./util/validators.js";

async function main() {
  console.log("=== 細菌分裂總數量計算機 ===\n");

  try {
    const hours = await askWithValidatorUntilPass(
      "請輸入經過的小時數:",
      (raw) => validatePositiveInt(raw, 1, 48),
    );

    const result = 2 ** hours;
    console.log("\n結果:", result);
  } catch (err) {
    console.error("程式發生未預期錯誤:", err);
    process.exit(1);
  }
}

main();

設計原則整理

  1. 函式命名即文件
    validatePositiveInt、validateEvenInt 比 validate、check 清楚許多。[1]

  2. 錯誤訊息分層

    • 格式錯誤:「請輸入整數」

    • 範圍錯誤:「請輸入非負整數」或「請輸入 1–10 之間的整數」

    • 業務規則錯誤:「請輸入偶數」

  3. 直接回傳,不過度包裝
    讓錯誤靠 throw,成功靠回傳值,不需要額外 { value, errors } 外殼。

  4. 避免不必要的抽象
    沒有實際需求時,不要先設計通用的 validator pipe/chain;一個清楚的函式往往已足夠。

  5. 降低認知負擔優先
    在迭代流程上,優先順序大致可以是:

    認知負擔:遞迴 < 條件明確的迴圈 < while(true) + break < while(true) + return
    
  6. 風格一致性

    • 若用 async/await,就搭配 try/catch。

    • 一致的錯誤處理方式讓程式碼更好維護。[7][6]

1234567