當一個 CLI 工具(例如 mytool)支援 table、text 與 json 等多種輸出格式時,不同情境會有不同的設定需求:
config.yaml 寫入 output: text,作為個人日常開發的預設格式。MYTOOL_OUTPUT=json 指定格式,不影響使用者的個人設定檔。mytool --output table,只覆蓋這一次的執行結果。當這三個來源同時設定了 output 時,程式必須依優先順序(Precedence)決定最終採用哪一個值:
Flag(最高) > 環境變數 > 設定檔 > 預設值(最低)
這個順序並非隨意制定,其核心原則是越靠近「這一次執行」的來源,優先權越高:
因此,若 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
},
手動實作主要有兩個問題:
Flags().Changed() 的邏輯。當設定項目增加時,維護成本會成倍增長。OUTPUT),極易與系統或其他工具撞名,因此必須手動加上工具前綴(如 MYTOOL_OUTPUT)。為了解決手動合併設定的維護成本,在 Go 開發中通常會使用 Viper 套件。Viper 的核心機制是「抽象化設定鍵」:無論值來自 config.yaml 的 output 欄位、環境變數 MYTOOL_OUTPUT,還是命令列 --output Flag,最後在程式碼中都統一透過 output 這個設定鍵讀取,並由 Viper 自動依照優先順序覆蓋。
先安裝 Viper 套件:
go get github.com/spf13/viper
接著,我們一步步將三個設定來源連接到 Viper 中。
首先處理最底層的固定設定。在執行 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。
自動化腳本往往透過環境變數傳遞參數。為了防範變數名稱衝突,我們先設定工具前綴 MYTOOL,再開啟自動載入:
viper.SetEnvPrefix("MYTOOL")
viper.AutomaticEnv()
設定 SetEnvPrefix("MYTOOL") 後,Viper 在查詢 output 鍵時,會自動尋找名為 MYTOOL_OUTPUT 的環境變數。當執行 MYTOOL_OUTPUT=json ./mytool 時,viper.GetString("output") 取得的值便會變成 json。
使用者在命令列手動傳入的 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。
完成了三個來源的綁定後,我們將初始化邏輯放入 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
log.Fatalf 中斷如果對本篇範例中出現的 Go 語法與套件設計不熟悉,以下為相關特性的補充說明:
os.Getenv 與作業系統環境變數存取在 Go 中,os.Getenv("MYTOOL_OUTPUT") 用於讀取目前 Process 執行環境中的環境變數:
if value := os.Getenv("MYTOOL_OUTPUT"); value != "" {
output = value
}
若環境變數未被設定,os.Getenv 會回傳空字串 ""。因此程式中常搭配 value != "" 判斷環境中是否存在有效設定。
在呼叫 viper.SetConfigFile(...) 或 viper.GetString(...) 時,不需要手動實例化建立物件(例如 v := viper.New()):
viper.SetConfigFile("config.yaml")
output := viper.GetString("output")
這是因為 viper 套件在內部維護了一個全域可用的單例物件(v *Viper)。直接呼叫套件名稱 viper.Xxx() 便是在存取這個預設的全域實例。在簡單的 CLI 工具中,使用全域實例能減少傳遞物件的樣板程式碼;但在大型專案或單元測試中,也可以使用 viper.New() 建立獨立實例進行隔離。
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 中斷執行是常見的錯誤處置方式。