iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0

在終端機執行一個指令時,畫面上看起來只是一行文字印出來;但對作業系統與 Shell 來說,一個程式結束時其實會回報兩類完全不同的資訊:

  1. 文字訊息(分成兩個串流通道):
    • 標準輸出(stdout):傳遞程式正常執行的資料結果。
    • 標準錯誤(stderr):傳遞錯誤訊息、警告或給人看的排查提示。
  2. 執行成敗的代碼(Exit Code):
    • 程式結束時留在記憶體裡的一個整數狀態碼。依照 Unix 慣例,0 代表成功,非 0(通常是 1)代表失敗。

Exit Code 讓外部呼叫端不必解析文字就能判斷成敗,stderr 則留給人工閱讀與排查問題。

然而,當我們開始開發自己的 CLI 工具時,底層的檔案系統、網路請求與業務邏輯通常只會回傳語言層級的 error。如果每個子命令或函式一拿到 error 就各自用 fmt.Println 印出並呼叫 os.Exit(1),錯誤處理很快就會遇到三個問題:

  1. 資源無法正常釋放:os.Exit 會立刻結束 Process,已註冊的 defer 清理函式完全不會執行,開啟中的檔案、鎖與連線可能來不及釋放。
  2. 呼叫端無法穩定判斷:所有錯誤都印純文字並回傳 1,Shell 無法區分參數錯誤還是內部異常,AI Agent 與外部程式也無法透過結構化欄位進行自動修復。
  3. 輸出與 Process 終止散落各處:程式碼各處都在寫入 stderr 與呼叫 os.Exit,容易造成同一筆錯誤被重複列印,也無法全域切換 JSON 輸出或開啟 debug log。

要解決這些問題,CLI 內部不能讓每個地方自行決定如何終止程式。程式架構必須遵守一條固定原則:底層函式專注於辨認原因並封裝錯誤,沿著 Call Stack 向上拋出;最外層入口統一處理輸出與 Process 終止。

本篇使用一個名為 filectl 的檔案檢查 CLI 工具範例,來示範如何建立這條錯誤路徑。它提供 check <path> 命令,透過 os.Stat 檢查指定路徑;無論遇到檔案不存在、權限不足還是系統錯誤,都會遵循同一套傳遞與輸出規則。

釐清四種呼叫者與輸出通道

設計 CLI 的錯誤與日誌時,首先要釐清「誰在接收這次執行的結果」。現代 CLI 的呼叫端不只有人類,還包含外部程式與 AI Agent,每一類呼叫者需要的資訊完全不同:

呼叫者 關注重點 接收通道 格式需求
Shell / CI 命令成功或失敗,決定 if/else 分支 Exit Code 數值(0 為成功,非 0 為失敗)
終端機使用者 哪個操作失敗、原因為何、下一步該怎麼做 stderr 人類可讀文字(Message 與 Hint)
AI Agent / 程式 具體錯誤型態,依固定代碼執行修正 stderr 機器可讀的結構化 JSON(固定的 type)
維運排查人員 錯誤發生前的執行軌跡與系統內部狀態 stderr 除錯日誌(平時安靜,開啟 --debug 才輸出)

整個錯誤處理與日誌架構,就是為這四種呼叫者提供各自獨立、互不干擾的資訊通道。

讓使用者看懂失敗原因與處理方式

好的終端機錯誤訊息不應只印出未經處理的底層錯誤(例如 open missing.csv: no such file or directory),而要清楚拆成兩部分:說明哪個操作因何失敗的 Message(失敗原因),以及告訴使用者下一步可以執行什麼具體動作的 Hint(處置建議):

Error: file "missing.csv" not found
Hint: verify the path with ls -la

為了在程式內部統一把這兩項資訊(連同給 Shell 與程式使用的狀態碼)沿路傳遞,我們定義自訂的 ExitError:

// internal/errs/errors.go

type ExitError struct {
    ExitCode int    // 作業系統與 Shell 接收的退出碼
    Type     string // 程式與 AI Agent 判斷用的固定類型
    Message  string // 使用者看到的失敗原因
    Hint     string // 可以執行的下一步建議(可為空)
}

func (e *ExitError) Error() string {
    return e.Message
}

只要實作了 Error() string 方法,*ExitError 就是標準的 Go error,可以一路向上回傳。

在業務邏輯中,我們檢查底層操作回傳的原始 error,辨認原因後再封裝成帶有具體建議的 ExitError:

// cmd/check.go

func runCheck(opts *checkOptions) error {
    _, err := opts.Factory.Stat(opts.Path)
    if err != nil {
        if errors.Is(err, fs.ErrNotExist) {
            return &errs.ExitError{
                ExitCode: errs.ExitFailure,
                Type:     "not_found",
                Message:  fmt.Sprintf("file %q not found", opts.Path),
                Hint:     "verify the path with ls -la",
            }
        }
        if errors.Is(err, fs.ErrPermission) {
            return &errs.ExitError{
                ExitCode: errs.ExitAccess,
                Type:     "permission_denied",
                Message:  fmt.Sprintf("permission denied: %q", opts.Path),
                Hint:     "check the file and directory permissions",
            }
        }
        // 未知或未分類的系統錯誤,不洩漏內部細節,引導使用者開啟 debug
        return &errs.ExitError{
            ExitCode: errs.ExitFailure,
            Type:     "internal",
            Message:  fmt.Sprintf("cannot inspect file %q", opts.Path),
            Hint:     "run again with --debug to see system details",
        }
    }

    fmt.Fprintf(opts.Factory.IOStreams.Out, "%s exists\n", opts.Path)
    return nil
}

寫錯誤提示時把握兩個原則:

  1. Hint 給指令,不說廢話:直接提示具體操作(如 ls -la),不要重複說「請確認檔案是否存在」。
  2. Message 講人話,複雜細節留給 log:對外只說「無法檢查檔案」,複雜的系統底層報錯留給 debug log,不要整串印給使用者看。

只在最外層統一印出錯誤與結束程式

子命令與內部函式不能直接呼叫 os.Exit(1) 或 fmt.Println,原因有兩個:

  • defer 清理不會執行:os.Exit 會直接終止 Process,開啟中的檔案、鎖與連線無法正常釋放。
  • 單元測試無法執行:測試程式碼只要走到失敗路徑,整個測試 Process 就會被中斷結束。

整座 CLI 的錯誤處理必須維持單一出口:所有子命令只負責向上回傳 error;只有最外層入口(如 cmd/root.go)才能統一寫入 stderr 與呼叫 os.Exit。

https://ithelp.ithome.com.tw/upload/images/20260926/20111896aZ8fbHgLeh.png

子命令只回傳 error

在 Cobra 中,使用 RunE 取代 Run。子命令只專注在執行邏輯並回傳 error,絕不在內部自己退出:

// cmd/check.go

func newCheckCmd(factory *cmdutil.Factory) *cobra.Command {
    opts := &checkOptions{Factory: factory}
    return &cobra.Command{
        Use:   "check <path>",
        Short: "Check whether a path exists",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            opts.Path = args[0]
            return runCheck(opts) // 只回傳 error,不印訊息也不 exit
        },
    }
}

最外層統一收斂輸出與 Exit Code

所有的 error 最終都會回到最外層的 root.Execute()。我們用 errors.As 提取出前面定義的 ExitError,輸出訊息後才結束程式:

// cmd/root.go

func Execute() {
    factory := cmdutil.NewFactory(os.Stdin, os.Stdout, os.Stderr)
    root := NewRootCmd(factory)
    if err := root.Execute(); err != nil {
        handleError(os.Stderr, err, errorFormat)
    }
}

func handleError(stderr io.Writer, err error, format string) {
    var exitErr *errs.ExitError
    if errors.As(err, &exitErr) {
        errs.WriteError(stderr, exitErr, format)
        os.Exit(exitErr.ExitCode)
    }

    // 非 ExitError(例如 Cobra 參數解析錯誤),轉為 validation 錯誤處理
    usageErr := &errs.ExitError{
        ExitCode: errs.ExitUsage,
        Type:     "validation",
        Message:  err.Error(),
    }
    errs.WriteError(stderr, usageErr, format)
    os.Exit(usageErr.ExitCode)
}

為了避免 Cobra 自己印一次錯誤、我們的 handleError 又印一次,必須在 Root 命令關閉預設輸出:

root := &cobra.Command{
    Use:           "filectl",
    SilenceErrors: true, // 關閉 Cobra 預設的錯誤輸出,避免重複列印
    SilenceUsage:  true, // 發生錯誤時不要自動印出落落長的 Help 訊息
}

這樣一來,整座 CLI 只有唯一的出口決定 Process 的生死。執行成功時資料走 stdout,失敗時錯誤走 stderr,分工乾淨明確:

$ filectl check data.csv
data.csv exists
$ echo $?
0

$ filectl check missing.csv
Error: file "missing.csv" not found
Hint: verify the path with ls -la
$ echo $?
1

處理錯誤訊息的輸出格式

文字訊息是寫給人看的,用詞隨時可能潤飾或改版。如果自動化程式或 AI Agent 靠爬文字(例如 grep "not found")來判斷結果,一旦文案修改,流程就會損壞。

對機器來說,穩定的錯誤介面只有兩種:Exit Code 與 結構化 JSON。

1. Shell 依賴的 Exit Code:只定義必要的分支代碼

Shell 透過 Exit Code 數字($?)來決定流程走向。設計狀態碼時應把握精簡原則:

  • 0:成功。
  • 1:通用失敗(多數錯誤回傳 1 即可)。
  • 只有當呼叫端「真的需要依數字做出不同處置」時,才自訂特定代碼(例如 2 代表參數錯誤、4 代表權限不足):
// internal/errs/exitcode.go

const (
    ExitOK      = 0 // 成功
    ExitFailure = 1 // 一般執行失敗(如檔案不存在、內部錯誤)
    ExitUsage   = 2 // 參數或使用方式錯誤
    ExitAccess  = 4 // 權限不足
)

不需要為每個錯誤都發明一個代碼。對多數 Shell 流程來說,只要能區分成功與失敗就足夠了:

if filectl check data.csv; then
  echo "file exists, continue"
else
  echo "execution failed"
fi

2. AI Agent 依賴的結構化輸出:固定的 error.type

AI Agent 與自動化程式需要比 Shell 更細緻的處置能力(例如在 not_found 時主動建立檔案,在 permission_denied 時請求授權)。

這類呼叫端應透過 --error-format json 取得結構化資訊,直接讀取固定不變的 error.type:

$ filectl check missing.csv --error-format json
{
  "error": {
    "type": "not_found",
    "message": "file \"missing.csv\" not found",
    "hint": "verify the path with ls -la"
  }
}
$ echo $?
1

在 Go 中定義對應的 Envelope 結構:

// internal/errs/envelope.go

type ErrorEnvelope struct {
    Error *ErrorBody `json:"error"`
}

type ErrorBody struct {
    Type    string `json:"type"`
    Message string `json:"message"`
    Hint    string `json:"hint,omitempty"` // 沒有 Hint 時透過 omitempty 自動省略
}

WriteError 函式集中處理輸出:文字模式寫入人讀的格式,JSON 模式則輸出結構化資料。成敗已由 Exit Code 表達,JSON 內部不需再放多餘的 ok: false 欄位:

func WriteError(w io.Writer, err *ExitError, format string) {
    if format == "json" {
        env := ErrorEnvelope{
            Error: &ErrorBody{
                Type:    err.Type,
                Message: err.Message,
                Hint:    err.Hint,
            },
        }
        data, _ := json.MarshalIndent(env, "", "  ")
        fmt.Fprintln(w, string(data))
        return
    }

    // 文字模式
    fmt.Fprintf(w, "Error: %s\n", err.Message)
    if err.Hint != "" {
        fmt.Fprintf(w, "Hint: %s\n", err.Hint)
    }
}

最後將 --error-format 宣告為 Persistent Flag,所有子命令就能共用相同的機器介面:

root.PersistentFlags().StringVar(
    &errorFormat,
    "error-format",
    "text",
    "error format: text or json",
)

支援 --debug 參數輸出排查日誌

在 CLI 的日常使用中,使用者只需要看到簡潔的操作結果;但當指令出錯或行為不如預期時,開發者需要掌握執行過程中的細節(例如檢查了哪個檔案路徑、底層報了什麼系統錯誤)。

這個機制的實作原則很單純:只有在加上 --debug 參數時,才將詳細日誌寫入 stderr。

1. 在業務邏輯中記錄除錯資訊

使用 Go 標準庫的 log/slog,在關鍵步驟與出錯點記錄 slog.Debug:

// cmd/check.go

func runCheck(opts *checkOptions) error {
    slog.Debug("checking file", "path", opts.Path) // 記錄執行前參數
    _, err := opts.Factory.Stat(opts.Path)
    if err != nil {
        if errors.Is(err, fs.ErrNotExist) {
            // ... 回傳 not_found ExitError ...
        }
        // 遇到未分類的底層錯誤時,記錄原始 error
        slog.Debug("stat failed", "path", opts.Path, "error", err)
        return &errs.ExitError{
            ExitCode: errs.ExitFailure,
            Type:     "internal",
            Message:  fmt.Sprintf("cannot inspect file %q", opts.Path),
            Hint:     "run again with --debug to see system details",
        }
    }

    fmt.Fprintf(opts.Factory.IOStreams.Out, "%s exists\n", opts.Path)
    return nil
}

遇到錯誤時,只要用 slog.Debug 記錄底層原因並回傳 ExitError 即可。不要在底層呼叫 slog.Error,因為最外層入口(handleError)已經會輸出錯誤,重複記錄會讓終端機出現兩筆錯誤訊息。

2. 用全域 Flag 切換日誌開關

在 Root 命令中註冊 --debug 的 Persistent Flag,並依據 flag 設定 slog 的門檻層級:

// cmd/root.go

var debugMode bool

func NewRootCmd(factory *cmdutil.Factory) *cobra.Command {
    root := &cobra.Command{
        Use:           "filectl",
        SilenceErrors: true,
        SilenceUsage:  true,
        PersistentPreRun: func(cmd *cobra.Command, args []string) {
            setupLogger(debugMode)
        },
    }

    root.PersistentFlags().BoolVar(
        &debugMode,
        "debug",
        false,
        "print debug logs to stderr",
    )
    return root
}

func setupLogger(debug bool) {
    level := slog.LevelWarn // 平常只顯示 WARN 以上,保持安靜
    if debug {
        level = slog.LevelDebug // 加上 --debug 才顯示 DEBUG 日誌
    }

    handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
        Level: level,
    })
    slog.SetDefault(slog.New(handler))
}

3. 執行效果與輸出分流

平常執行時,終端機維持簡潔:

$ filectl check missing.csv
Error: file "missing.csv" not found
Hint: run again with --debug to see system details
$ echo $?
1

加上 --debug 後,執行軌跡與原始錯誤直接輸出到 stderr:

$ filectl check missing.csv --debug
time=2026-07-26T18:30:00.000+08:00 level=DEBUG msg="checking file" path=missing.csv
time=2026-07-26T18:30:00.000+08:00 level=DEBUG msg="stat failed" path=missing.csv error="stat missing.csv: no such file or directory"
Error: file "missing.csv" not found
$ echo $?
1

因為日誌是寫入 os.Stderr,即使開了 --debug,也不會污染 stdout 的資料管線:

$ filectl check data.csv --debug | tee result.txt

tee 只會接收到 stdout 的 data.csv exists,Debug 日誌則留在終端機畫面上。

總結

設計 CLI 的錯誤處理與日誌,核心在於維持單一出口與明確分流:

  • 子命令只拋出 error,由最外層統一處理:確保 defer 資源能正常釋放,單元測試也能正常執行。
  • 人看訊息,機器看Exit Code:人類需要 Message 與可執行的 Hint,自動化程式與 AI Agent 則依賴標準 Exit Code 與固定的 JSON type。
  • 日誌作為排查輔助:詳細軌跡只在加上 --debug 時寫入 stderr,不污染 stdout 的資料管線。

讓錯誤資訊與執行軌跡各走各的通道,CLI 就能同時兼顧人類操作的友善度與自動化串接的穩定性。


上一篇
CLI 架構設計
下一篇
長時間 CLI 命令的進度、逾時與取消設計
系列文
30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言