位元詩人 從參數解析到終端控制:Dart 命令列程式開發實務

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

本文旨在分享使用 Dart 實作 CLI(命令列介面) 程式時的常見情境。為了保持內容精鍊,我們跳過命令列程式的基礎理論,直接聚焦於 Dart 的實作技巧與程式碼範例。

命令列參數處理

接收命令列參數

在 Dart 中,如果 main 函式沒有宣告參數,程式將無法接收外部傳入的指令:

void main() async {
  /* 程式無法接收命令列參數 */
}

若要處理參數,必須在 main 函式中明確加入 List<String> arguments

void main(List<String> arguments) async {
  /* arguments 包含了所有傳入的命令列參數 */
}

所有的參數都會被封裝在 arguments 字串串列中。接下來,你可以選擇使用現成的函式庫進行解析,或是根據需求自行撰寫解析邏輯。

使用官方函式庫解析 (args)

解析命令列參數是開發 CLI 程式最常見的任務之一。為了節省開發時間,建議優先使用官方維護的 args 套件。它完整支援 GNUPOSIX 風格的參數格式,能輕鬆處理旗標 (Flags) 與選項 (Options)。

自行解析命令列參數

雖然有現成套件,但在某些特殊情境下(例如需支援 -hl-gl 等非標準參數格式),你可能需要手動解析。

自行解析的核心思路是「遍歷」參數串列。以下是一個結合 Sealed ClassFactory Method 的實作範例,能讓參數解析更具結構化:

enum ArgumentAction { version, help, filePath }

sealed class Argument {
  final ArgumentAction action;
  Argument(this.action);

  factory Argument.parse(List<String> arguments) {
    String? inputFilePath;
    String? outputFilePath;

    for (var i = 0; i < arguments.length; ++i) {
      String arg = arguments[i];

      if (arg == '-v' || arg == '--version')
        return ArgumentBase(ArgumentAction.version);
      if (arg == '-h' || arg == '--help')
        return ArgumentBase(ArgumentAction.help);

      /* 更多解析邏輯... */

      throw Exception('Unknown option: ${arg}');
    }

    if (inputFilePath == null) throw Exception('No file path provided');

    return ArgumentFilePath(
      ArgumentAction.filePath,
      outputFilePath,
      inputFilePath,
    );
  }
}

class ArgumentBase extends Argument {
  ArgumentBase(super.action);
}

class ArgumentFilePath extends Argument {
  final String? outputFilePath;
  final String? inputFilePath;

  ArgumentFilePath(super.action, this.outputFilePath, this.inputFilePath);
}

當你的程式需要高度客製化的參數處理邏輯時,這種手動解析的方式能提供最大的靈活性。

終端機輸出

在 Dart 中,根據執行環境與輸出的精準度需求,主要有以下幾種方式:

print 函式

這是最通用且簡單的方式,同時支援 Dart Web 與 Native 平台:

  • Dart Web:功能等同於瀏覽器的 console.log()
  • Native Dart:將內容輸出至「標準輸出」(stdout),並在結尾自動附加換行符號。

stdout (標準輸出)

僅限於 Native Dart 環境。比起 print,它提供了更細緻的控制:

  • stdout.write():僅輸出內容,不會自動換行。
  • stdout.writeln():輸出內容並自動換行。

stderr (標準錯誤)

同樣僅限於 Native Dart。其用法與 stdout 相同(提供 writewriteln),但專門用於輸出錯誤訊息或診斷資訊。在自動化腳本或 CI/CD 流程中,區分 stdoutstderr 對於錯誤捕捉至關重要。

在 Runtime 動態切換輸出流

由於 stdoutstderr 皆繼承自 Stdout 類別,我們可以利用這個特性,在執行時期根據情境動態選擇輸出目的地。這在需要根據參數決定輸出一般資訊或錯誤訊息時非常方便:

const PROGRAM = 'cli';

void helpInfo({String stream = 'stdout'}) {
  // 根據參數決定使用 stdout 或 stderr
  var out = (stream == 'stderr') ? stderr : stdout;

  out.writeln('Usage: $PROGRAM [options] <file>');
}

這種小技巧能讓你的 CLI 工具在處理診斷訊息導向輸出時更具靈活性。

終端機輸入處理

在開發 CLI 程式時,辨識執行環境是「互動模式」還是「非互動模式」(如 Pipe 管道或重導向)非常重要。在 Dart 中,我們可以透過 stdin.hasTerminal 來輕鬆判斷。

實戰範例:實作一個會「大喊」的程式

下面的範例程式 shout.dart 會將使用者的輸入轉為大寫並輸出。若沒有傳入參數,程式會進入互動模式,模擬傳統 Unix 工具的行為。

我們特別處理了 stdin 的模式,讓輸入體驗更像專業的終端工具:

/* shout.dart */
import 'dart:io';
import 'dart:convert';

void main(List<String> arguments) async {
  // 若有傳入參數,直接處理後離開
  if (arguments.isNotEmpty) {
    for (var arg in arguments) print(arg.toUpperCase());
    exit(0);
  }

  try {
    // 判斷是否在互動式終端機中執行
    if (stdin.hasTerminal) {
      // 關閉回顯(Echo):輸入時不顯示原始字元
      stdin.echoMode = false; 
      // 關閉行模式(Line Mode):字元會立即處理,不必等待按下 Enter
      stdin.lineMode = false; 
    }

    // 監聽並轉換輸入串流
    await for (var c in stdin.transform(utf8.decoder)) {
      stdout.write(c.toUpperCase());
    }
  } finally {
    // 良好的習慣:離開程式前將終端機狀態還原
    if (stdin.hasTerminal) {
      stdin.echoMode = true;
      stdin.lineMode = true;
    }
  }

  exit(0);
}

行為說明

在傳統 Unix 文化中,當程式未接收到任何參數時,通常會切換至互動模式,讓使用者直接輸入內容。

  • 在本範例中,我們利用 await for 處理 stdin 串流,並即時將字元轉為大寫。
  • 使用者隨時可以透過 Ctrl+C 結束程式並回到 Shell。
  • 透過關閉 echoModelineMode,我們可以實現更靈活的輸入控制(例如製作輸入遮罩或即時快捷鍵響應)。

狀態碼 (Exit Code)

狀態碼(Exit Code)用於告知作業系統或呼叫它的 Shell 程式運行的最終結果。在 Dart 中,我們使用 exit() 函式來終止程式並回傳特定狀態碼。

常見的狀態碼約定如下:

  • exit(0):表示程式運行成功並正常結束。
  • exit(1):表示程式運行失敗或發生一般性錯誤。
  • 2127:當程式需要根據不同錯誤情境區分失敗原因(例如:2 代表參數錯誤、3 代表檔案不存在)時使用。
  • 128 以上:不建議自行定義。在 Unix 系統中,這類狀態碼通常預留給「因信號(Signal)而中斷」的情況(例如 128 + n)。

使用建議

在實作 CLI 程式時,應確保在所有非預期的 Exception 捕捉路徑中都回傳非零的狀態碼,這樣其他的自動化工具(如 CI/CD 或 Shell 腳本中的 && 運算子)才能正確判斷程式是否執行成功。

範例樣板程式碼

綜合前述特性,可以寫成一個範例樣板程式碼:

import 'dart:io';

const PROGRAM = 'cli';
const VERSION = '0.1.0';

void helpInfo({String stream = 'stdout'}) {
  var out = stream == 'stderr' ? stderr : stdout;
  out.writeln('Usage: $PROGRAM [options] <file>');
  out.writeln('');
  out.writeln('Options:');
  out.writeln('  -v, --version    Show version info');
  out.writeln('  -h, --help       Show help info');
  out.writeln('  -o <file>        Output file path');
}

enum ArgumentAction { version, help, filePath }

/* Simulate union in Dart. */
sealed class Argument {
  final ArgumentAction action;
  Argument(this.action);

  factory Argument.parse(List<String> arguments) {
    String? inputFilePath;
    String? outputFilePath;

    for (var i = 0; i < arguments.length; ++i) {
      String arg = arguments[i];

      if (arg == '-v' || arg == '--version')
        return ArgumentBase(ArgumentAction.version);
      if (arg == '-h' || arg == '--help')
        return ArgumentBase(ArgumentAction.help);

      if (arg == '-o') {
        if (i + 1 >= arguments.length || arguments[i + 1].startsWith('-')) {
          throw Exception('-o requires a valid file path');
        }
        outputFilePath = arguments[i + 1];
        i++;
        continue;
      }

      if (arg == '--') {
        /* Discard remaining arguments currently.
          You may want to collect them. */
        break;
      }

      if (!arg.startsWith('-')) {
        if (inputFilePath != null) {
          throw Exception(
            'Too many arguments: ${arg} (Expected only one input file)',
          );
        }
        inputFilePath = arg;
        continue;
      }

      throw Exception('Unknown option: ${arg}');
    }

    if (inputFilePath == null) throw Exception('No file path provided');

    return ArgumentFilePath(
      ArgumentAction.filePath,
      outputFilePath,
      inputFilePath,
    );
  }
}

class ArgumentBase extends Argument {
  ArgumentBase(super.action);
}

class ArgumentFilePath extends Argument {
  final String? outputFilePath;
  final String? inputFilePath;

  ArgumentFilePath(super.action, this.outputFilePath, this.inputFilePath);
}

Future<int> run(List<String> arguments) async {
  try {
    final result = Argument.parse(arguments);

    switch (result) {
      case ArgumentBase(action: ArgumentAction.version):
        stdout.writeln(VERSION);
        return 0;
      case ArgumentBase(action: ArgumentAction.help):
        helpInfo();
        return 0;
      case ArgumentBase(action: ArgumentAction.filePath):
        throw UnsupportedError('Internal logic error: ArgumentBase should not hold filePath action.');
      case ArgumentFilePath(
        inputFilePath: var input,
        outputFilePath: var output,
      ):
        if (output != null)
          stdout.writeln('Shim: Save to ${output}');
        else
          stdout.writeln('Shim: Print to stdout');

        return 0;
    }
  } catch (exception) {
    stderr.writeln(
      'Error: ${exception.toString().replaceAll('Exception: ', '')}',
    );
    stderr.writeln('');
    helpInfo(stream: 'stderr');
    return 1;
  }
}

void main(List<String> arguments) async {
  exit(await run(arguments));
}

讀者可以直接使用這個樣皮當基底,自行寫命令列程式。也可以從從中取出有用的部分,撰寫自己的命令列程式。

結語與展望

命令列程式的邊界,往往象徵著系統能力的總和。它將強大的功能封裝在純粹且簡約的介面之中,讓開發者能專注於邏輯實作,而不必耗費過多精力在 UI 刻畫上。

希望這篇文章能作為 Dart 程式設計者的起點,幫助各位發揮 Dart 強大的 Native 效能,開發出更多高效、實用的命令列工具。

關於作者

位元詩人 (ByteBard) 是資訊領域碩士,喜歡用開源技術來解決各式各樣的問題。這類技術跨平台、重用性高、技術生命長。

除了開源技術以外,位元詩人喜歡日本料理和黑咖啡,會一些日文,有時會自助旅行。