前言
本文旨在分享使用 Dart 實作 CLI(命令列介面) 程式時的常見情境。為了保持內容精鍊,我們跳過命令列程式的基礎理論,直接聚焦於 Dart 的實作技巧與程式碼範例。
命令列參數處理
接收命令列參數
在 Dart 中,如果 main 函式沒有宣告參數,程式將無法接收外部傳入的指令:
void main() async {
/* 程式無法接收命令列參數 */
}
若要處理參數,必須在 main 函式中明確加入 List<String> arguments:
void main(List<String> arguments) async {
/* arguments 包含了所有傳入的命令列參數 */
}
所有的參數都會被封裝在 arguments 字串串列中。接下來,你可以選擇使用現成的函式庫進行解析,或是根據需求自行撰寫解析邏輯。
使用官方函式庫解析 (args)
解析命令列參數是開發 CLI 程式最常見的任務之一。為了節省開發時間,建議優先使用官方維護的 args 套件。它完整支援 GNU 與 POSIX 風格的參數格式,能輕鬆處理旗標 (Flags) 與選項 (Options)。
自行解析命令列參數
雖然有現成套件,但在某些特殊情境下(例如需支援 -hl、-gl 等非標準參數格式),你可能需要手動解析。
自行解析的核心思路是「遍歷」參數串列。以下是一個結合 Sealed Class 與 Factory 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 相同(提供 write 與 writeln),但專門用於輸出錯誤訊息或診斷資訊。在自動化腳本或 CI/CD 流程中,區分 stdout 與 stderr 對於錯誤捕捉至關重要。
在 Runtime 動態切換輸出流
由於 stdout 與 stderr 皆繼承自 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。 - 透過關閉
echoMode與lineMode,我們可以實現更靈活的輸入控制(例如製作輸入遮罩或即時快捷鍵響應)。
狀態碼 (Exit Code)
狀態碼(Exit Code)用於告知作業系統或呼叫它的 Shell 程式運行的最終結果。在 Dart 中,我們使用 exit() 函式來終止程式並回傳特定狀態碼。
常見的狀態碼約定如下:
exit(0):表示程式運行成功並正常結束。exit(1):表示程式運行失敗或發生一般性錯誤。2至127:當程式需要根據不同錯誤情境區分失敗原因(例如: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 效能,開發出更多高效、實用的命令列工具。