Cobra & Viperで設定値がうまく読み込まれなかった原因と対処

はじめに

GoでCLIツールを作ろうと思い、Cobraというライブラリを採用することにしました。
Cobraの作者の方はViperという設定値をいい感じに管理するためのライブラリも作っており、これらを組み合わせて使う方法が公式ドキュメントで紹介されています。
今回CobraとViperを使ってCLIツールを作っている中で、想定通りに設定値が読み込まれない状態になったので、その原因と対処方法を記録として残しておこうと思います。

環境

  • macOS Sonoma 14.6.1
  • Go 1.23.2
  • Cobra v1.8.1
  • Viper v1.19.0

確認した挙動

以下のようなコードを書きました。
importなど、一部のコードは省略しています。
完全なコードは、調査用リポジトリの以下のコミットを参照してください。
github.com

// cmd/root.go
package cmd

var rootCmd = &cobra.Command{
	Use:   "cobra-viper-config",
	Run: func(cmd *cobra.Command, args []string) {
		name := viper.GetString("name")
		fmt.Println("Hello:", name)
	},
}
func Execute() {
	err := rootCmd.Execute()
	if err != nil {
		os.Exit(1)
	}
}
func init() {
	cobra.OnInitialize(initConfig)
	rootCmd.Flags().StringP("name", "n", "name", "Your name")
	viper.BindPFlag("name", rootCmd.PersistentFlags().Lookup("name"))
}
func initConfig() {
	home, err := os.UserHomeDir()
	cobra.CheckErr(err)
	viper.AddConfigPath(home)
	viper.SetConfigType("yaml")
	viper.SetConfigName(".cobra-viper-config")

	viper.SetEnvPrefix("cli")
	viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
	viper.AutomaticEnv()
	if err := viper.ReadInConfig(); err == nil {
		fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
		fmt.Println(viper.AllSettings())
	}
}
// cmd/sub.go
package cmd

var subCmd = &cobra.Command{
	Use:   "sub",
	Run: func(cmd *cobra.Command, args []string) {
		subName := viper.GetString("name")
		fmt.Println("Hello sub:", subName)
	},
}
func init() {
	rootCmd.AddCommand(subCmd)
	subCmd.Flags().String("name", "subname", "Your name")
	viper.BindPFlag("name", subCmd.Flags().Lookup("name"))
}
// cmd/subsub.go
package cmd

var subsubCmd = &cobra.Command{
	Use:   "subsub",
	Run: func(cmd *cobra.Command, args []string) {
		subsubname := viper.GetString("name")
		fmt.Println("Hello subsub:", subsubname)
	},
}
func init() {
	subCmd.AddCommand(subsubCmd)
	subsubCmd.Flags().String("name", "subsubname", "Your name")
	viper.BindPFlag("name", subsubCmd.Flags().Lookup("name"))
}

サブコマンドの sub があり、さらにそのサブコマンドの subsub があるCLIツールです。
各コマンドには --name オプションがあり、優先度が高い順に以下のいずれかの方法で指定できます。

  1. コマンドライン引数
  2. 環境変数
  3. 設定ファイル

このコマンドのサブコマンドを実行した時、結果は以下のようになりました。

$ CLI_NAME=name2 go run main.go sub --name name3
Hello sub: name2

優先度は環境変数よりもコマンドライン引数の方が上なので、 Hello sub: name3 と表示されるべきですが、環境変数で設定した name2 が表示されています。
ちなみに、環境変数を指定しない場合は設定ファイルに書かれている設定値が表示されます。

原因

原因は、 init() 内で viper.BindPFlag() を呼んでいることにより、同一の設定値「 name 」に対して、サブコマンドの int() と、さらにそのサブコマンドの init() で2回Bindが行われていることでした。
これは、既にViperのGitHubリポジトリのissueに書かれている挙動でした。

github.com

対処方法

issueのコメントでも書かれていますが、 init()viper.BindPFlag() を呼ぶのではなく、 PreRun で呼び出すことで、想定通りの挙動になります。

// cmd/sub.go
package cmd

var subCmd = &cobra.Command{
	Use:   "sub",
	PreRun: func(cmd *cobra.Command, args []string) {
		viper.BindPFlag("name", cmd.Flags().Lookup("name"))
	},
	Run: func(cmd *cobra.Command, args []string) {
		subName := viper.GetString("name")
		fmt.Println("Hello sub:", subName)
	},
}
func init() {
	rootCmd.AddCommand(subCmd)
	subCmd.Flags().String("name", "subname", "Your name")
}

修正後の完全なコードは、調査用リポジトリの以下のコミットを参照してください。
github.com

Cobraのドキュメントでは viper.BindPFlag()init() で呼ばれていますが、必ずしも init() で呼び出す必要はなく、 PreRun でも問題無いようです。

おわりに

今回のこの挙動は特にエラーが出ない挙動だったので、原因を見つけるのが大変でした。
エラーが出る場合はライブラリのソースを読むなどして原因がある程度分かるのですが、エラーが出ない場合は勘も重要になると思っています。
今回は「Bindって二重で実行して問題無いんだっけ?」という疑問を持てたので、「viper bind flag same flag two command」のようなワードで検索してissueに辿り着くことができました。
エラーが出ない挙動の原因の見つけ方は、今後も色々と経験して勘を磨いていきたいです。