iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0

當一個 CLI 工具(例如 mytool)支援 tabletextjson 等多種輸出格式時,不同情境會有不同的設定需求:

  • 設定檔:使用者可以在 config.yaml 寫入 output: text,作為個人日常開發的預設格式。
  • 環境變數:自動化腳本透過 MYTOOL_OUTPUT=json 指定格式,不影響使用者的個人設定檔。
  • Flag:使用者執行 mytool --output table,只覆蓋這一次的執行結果。

當這三個來源同時設定了 output 時,程式必須依優先順序(Precedence)決定最終採用哪一個值:

Flag(最高)  >  環境變數  >  設定檔  >  預設值(最低)

這個順序並非隨意制定,其核心原則是越靠近「這一次執行」的來源,優先權越高

  • Flag(最高):使用者當下在終端機手動敲下,意圖最明確,專門用來覆蓋一次性的臨時需求。
  • 環境變數:存在當前執行環境或容器中,適合用來**固定某台機器或某個部署環境(如 CI/CD)**的行為,無需修改檔案。
  • 設定檔:持久化保存在硬碟檔案中,適合記錄不常變動的長期個人偏好
  • 預設值(最低):程式碼內建的保底值。

因此,若 config.yaml 設定為 text、環境變數為 json,而命令列傳入 --output table,最終會採用優先權最高的 table

手動合併設定來源

如果不使用設定管理套件,要同時支援三個來源時,程式必須自行分別讀取並由低到高覆蓋。以下範例從內建預設值開始,逐一檢查並套用優先權更高的來源:

RunE: func(cmd *cobra.Command, args []string) error {
    output := "table"

    // 1. 設定檔有值時,覆蓋預設值
    data, _ := os.ReadFile("config.yaml")
    cfg := map[string]string{}
    yaml.Unmarshal(data, &cfg)
    if value := cfg["output"]; value != "" {
        output = value
    }

    // 2. 環境變數的優先權高於設定檔
    if value := os.Getenv("MYTOOL_OUTPUT"); value != "" {
        output = value
    }

    // 3. 明確傳入的 flag 優先權最高
    if cmd.Flags().Changed("output") {
        output, _ = cmd.Flags().GetString("output")
    }

    fmt.Println(output)
    return nil
},

手動實作主要有兩個問題:

  1. 樣板程式碼過多:每一個設定項目都要重複撰寫讀檔、解析、讀取環境變數與判斷 Flags().Changed() 的邏輯。當設定項目增加時,維護成本會成倍增長。
  2. 命名衝突風險:環境變數若直接使用通用名稱(如 OUTPUT),極易與系統或其他工具撞名,因此必須手動加上工具前綴(如 MYTOOL_OUTPUT)。

使用 Viper 統一管理設定來源

為了解決手動合併設定的維護成本,在 Go 開發中通常會使用 Viper 套件。Viper 的核心機制是「抽象化設定鍵」:無論值來自 config.yamloutput 欄位、環境變數 MYTOOL_OUTPUT,還是命令列 --output Flag,最後在程式碼中都統一透過 output 這個設定鍵讀取,並由 Viper 自動依照優先順序覆蓋。

先安裝 Viper 套件:

go get github.com/spf13/viper

接著,我們一步步將三個設定來源連接到 Viper 中。

1. 讀取持久化設定檔

首先處理最底層的固定設定。在執行 mytool 的目錄下建立 config.yaml

output: text

接著告訴 Viper 設定檔的路徑並載入內容:

viper.SetConfigFile("config.yaml")

if err := viper.ReadInConfig(); err != nil {
    log.Fatalf("讀取設定檔失敗:%v", err)
}

完成讀取後,呼叫 viper.GetString("output") 即可取得 config.yaml 裡的 text

2. 連接環境變數

自動化腳本往往透過環境變數傳遞參數。為了防範變數名稱衝突,我們先設定工具前綴 MYTOOL,再開啟自動載入:

viper.SetEnvPrefix("MYTOOL")
viper.AutomaticEnv()

設定 SetEnvPrefix("MYTOOL") 後,Viper 在查詢 output 鍵時,會自動尋找名為 MYTOOL_OUTPUT 的環境變數。當執行 MYTOOL_OUTPUT=json ./mytool 時,viper.GetString("output") 取得的值便會變成 json

3. 綁定命令列 Flag

使用者在命令列手動傳入的 Flag 具有最高優先權。我們先在 Cobra 中定義 --output,再透過 BindPFlag 把它與 Viper 的 output 設定鍵綁在一起:

rootCmd.Flags().String("output", "table", "output format")

if err := viper.BindPFlag("output", rootCmd.Flags().Lookup("output")); err != nil {
    log.Fatalf("綁定 flag 失敗:%v", err)
}

當傳入 ./mytool --output table 時,Viper 偵測到命令列傳入了 Flag,便會將 output 的值覆蓋為 table

4. 在程式中統一讀取與驗證

完成了三個來源的綁定後,我們將初始化邏輯放入 init() 函式,並在命令執行處(RunE)直接讀取 output。程式碼不需要自行判斷資料來源:

package main

import (
    "fmt"
    "log"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var rootCmd = &cobra.Command{
    Use: "mytool",
    RunE: func(cmd *cobra.Command, args []string) error {
        // 統一從 Viper 讀取 output,自動享有優先權覆蓋機制
        output := viper.GetString("output")
        fmt.Println(output)
        return nil
    },
}

func init() {
    // 1. 讀取設定檔
    viper.SetConfigFile("config.yaml")
    if err := viper.ReadInConfig(); err != nil {
        log.Fatalf("讀取設定檔失敗:%v", err)
    }

    // 2. 自動綁定環境變數(加上 MYTOOL_ 前綴)
    viper.SetEnvPrefix("MYTOOL")
    viper.AutomaticEnv()

    // 3. 綁定 Cobra Flag
    rootCmd.Flags().String("output", "table", "output format")
    if err := viper.BindPFlag("output", rootCmd.Flags().Lookup("output")); err != nil {
        log.Fatalf("綁定 flag 失敗:%v", err)
    }
}

執行工具時,可以親自測試三個來源在不同情境下的覆蓋效果:

# 情境 1:直接執行,採用 config.yaml 的設定
$ ./mytool
text

# 情境 2:透過環境變數執行,環境變數覆蓋設定檔
$ MYTOOL_OUTPUT=json ./mytool
json

# 情境 3:明確傳入 flag,最高優先權覆蓋所有來源
$ MYTOOL_OUTPUT=json ./mytool --output table
table

Go 知識補充:環境變數存取、Package 全域單例與 log.Fatalf 中斷

如果對本篇範例中出現的 Go 語法與套件設計不熟悉,以下為相關特性的補充說明:

1. os.Getenv 與作業系統環境變數存取

在 Go 中,os.Getenv("MYTOOL_OUTPUT") 用於讀取目前 Process 執行環境中的環境變數:

if value := os.Getenv("MYTOOL_OUTPUT"); value != "" {
    output = value
}

若環境變數未被設定,os.Getenv 會回傳空字串 ""。因此程式中常搭配 value != "" 判斷環境中是否存在有效設定。

2. Package 層級的全域單例(Singleton)

在呼叫 viper.SetConfigFile(...)viper.GetString(...) 時,不需要手動實例化建立物件(例如 v := viper.New()):

viper.SetConfigFile("config.yaml")
output := viper.GetString("output")

這是因為 viper 套件在內部維護了一個全域可用的單例物件(v *Viper)。直接呼叫套件名稱 viper.Xxx() 便是在存取這個預設的全域實例。在簡單的 CLI 工具中,使用全域實例能減少傳遞物件的樣板程式碼;但在大型專案或單元測試中,也可以使用 viper.New() 建立獨立實例進行隔離。

3. log.Fatalf 與程式異常終止

在讀取設定檔或綁定 Flag 發生錯誤時,範例中使用了 log.Fatalf

if err := viper.ReadInConfig(); err != nil {
    log.Fatalf("讀取設定檔失敗:%v", err)
}

log.Fatalf 相當於呼叫 log.Printf 印出錯誤訊息後,隨即呼叫 os.Exit(1)。這會立即中斷當前 Process 並回傳 Exit Code 1。在 CLI 初始階段,若連基礎設定檔都無法載入,直接使用 log.Fatalf 中斷執行是常見的錯誤處置方式。


上一篇
管道與重導向 (Pipeline & Redirection)
下一篇
Human-friendly CLI 設計:命名、Help 與錯誤提示
系列文
30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言