xshoji's blog

Go 標準の flag パッケージのテクニック集その2: ロングオプションへ対応する

目次

Go 標準の flag パッケージのテクニック集その1: Usageを綺麗に整形して見やすくする | xshoji’s blog

の続きです。前回は、Go 標準の flag パッケージで Usage を綺麗に整形する方法を紹介しました。 Usage は見やすくなったのですが、次の要件として、ロングオプションを扱いたい、というのがあります。

Unix系のコマンドラインツールでは、一般的にショートオプション( -f のような1文字のオプション)とロングオプション( --file のような複数文字のオプション)の両方をサポートしていることが多いです。 Go 標準の flag パッケージは、 任意の文字列をフラグ名として定義できるので、ショートオプションとロングオプションの両方を実装上定義することは可能ですが、それらが同じ意味のオプションであることが Usage からは伝わりづらいので、そのままではとても使えません。

前回紹介した有名な spf13/cobraurfave/cli といったフレームワークを使えば簡単に実現できるのですが、 今回はあえて main.go のみで実装することにこだわりたかったため、Go 標準の flag パッケージだけでロングオプションを扱えるようにする方法を試行錯誤してみました。

多少複雑ですが、実用的な実装ができたので、今回はその実装方法について紹介したいと思います。

注意: この記事で紹介するスニペットは Go 1.18以上(Genericsを使用するため) が必要です

そもそもの問題点

通常、Go 標準の flag パッケージを使ってコマンドライン引数を定義する場合、以下のようにします。

var filePath = flag.String("f", "", "Path to the file")

この場合、 -f オプションでファイルパスを指定できるようになります。 しかし、これだけではロングオプション( --file )をサポートすることはできません。 これに対して、 flag パッケージは、以下のように複数の名前で同じ変数を定義することができます。

var filePath = flag.String("f", "", "Path to the file")
flag.StringVar(filePath, "file", "", "Path to the file")

このようにすると、 -f--file の両方で同じ変数に値を設定できるようになります。 しかし、これだけでは Usage 出力時に両方のオプションが別々に表示されてしまい、同じ意味のオプションであることが伝わりづらいです。

Usage of ./myapp:
  -f string
        Path to the file
  -file string
        Path to the file

まぁ、これはオプションが1つなのでまだ良いんですが、複数オプションになるとかなり見づらくなります。 前回紹介した、 Custom Usage での表示を踏襲し、以下のように表示できると理想的だと思います。

Usage: myapp [options]

Options:
  -f, --file string        Path to the file

ロングオプションの利点

次に、そもそもロングオプションをサポートする必要ってあるの? という点について私の考えをまとめておきます。 ロングオプションをサポートすると、シンプルに「オプション名が直感的に分かりやすい」という利点が生まれます。

例: -f よりも --file-path の方がオプションの意味が直感的

この結果、業務でコマンドラインツールを使う際に以下のようなメリットがあります。

  • 実行結果の証跡をあとから見たときに、オプションの意味が分かりやすい
  • オプションの値を変えて実行する際に help を見なくても意味を推測しやすくなる
  • (最近だと) AIに実行させる場合にオプションの意味が伝わりやすい…かもしれません

ということで、もし手間でなければなるべくロングオプションもサポートしておくと良いのかな、と思っています。

今回作ったもの

前回の記事で紹介した Custom Usage を基に、ロングオプションのサポートを追加したよりリッチなスニペットを考えました。 今回紹介するスニペットを使うと、以下のように main.go のみでも良い感じにロングオプションをサポートできるようになります。

[01-12 02:53:17] macbookpro sample$ ls -al
total 8
drwxr-xr-x   3 user  wheel    96 Jan 12 02:53 .
drwxrwxrwt  15 root  wheel   480 Jan 12 02:53 ..
-rw-r--r--   1 user  wheel  3872 Jan 12 02:50 main.go

[01-12 02:53:18] macbookpro sample$ go run main.go -h
Usage: main --file-path <string> [OPTIONS]

Description:
  A sample command demonstrating full template usage in Go CLI applications.

Options:
  -f, --file-path <string>          (required) File path
  -d, --debug                       Debug mode
  -l, --line-index <int>            Index of line (default 10)
  -u, --url <string>                URL (default https://httpbin.org/get)
  -w, --wait-seconds <duration>     Duration of wait seconds (e.g., 500ms, 3s, 2m) (default 1s)

依存なしの main.go のみで実装してる割には、かなり本格的なCLIっぽいUsage表示ができているのでは、と思います。 前回紹介した Custom Usage で工夫した以下の点

  • 正確な実行バイナリ名を表示する
  • コマンドの説明文の表示
  • オプション一覧が一行で表示される
  • 必須のオプションがどれか分かりやすい(上位に表示される)

は当然踏襲していて、それに加えてロングオプションもサポートできています。 また、細かい点ですが、1行目の Usage 表示で、必須オプションの指定が例示されるようになっていて、よりユーザーフレンドリーな Usage を表示できるようになっています。 その他の工夫として、ロングオプションが加わるとオプション列の横幅が動的に変わるため、 Usage 出力時にオプション名の横幅を自動調整するような実装も入れていて、ロングオプション名が長くなっても綺麗に整列して表示されます。

スニペットの紹介

では、今回作成したスニペットを紹介します。 使う際は、このスニペットをコピペして

  • commandDescription を自分のツールの説明に置き換える
  • optionXxxx 等のオプション定義部分を自分のツールのオプションに置き換える
  • func main() にメインの処理を実装する

を修正してもらえればOKです。 flag Utils より下のコードは、Usage のフォーマットをカスタマイズしたい場合以外はそのままコピペで問題ありません。

package main

import (
	_ "embed"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"
)

const (
	Req        = "(required)"
	UsageDummy = "########"
)

var (
	commandDescription = "A sample command demonstrating full template usage in Go CLI applications."
	// Command options (the -h and --help flags are provided by default in the flag package)
	optionFilePath        = defineFlagValue("f", "file-path" /*    */, Req+" File path" /*                                 */, "" /*                         */, flag.String, flag.StringVar)
	optionUrl             = defineFlagValue("u", "url" /*          */, "URL" /*                                            */, "https://httpbin.org/get" /*  */, flag.String, flag.StringVar)
	optionLineIndex       = defineFlagValue("l", "line-index" /*   */, "Index of line" /*                                  */, 10 /*                         */, flag.Int, flag.IntVar)
	optionDurationWaitSec = defineFlagValue("w", "wait-seconds" /* */, "Duration of wait seconds (e.g., 500ms, 3s, 2m)" /* */, 1*time.Second /*              */, flag.Duration, flag.DurationVar)
	optionDebug           = defineFlagValue("d", "debug" /*        */, "Debug mode" /*                                     */, false /*                      */, flag.Bool, flag.BoolVar)
)

func init() {
	flag.Usage = customUsage(commandDescription)
}

func main() {

	flag.Parse()
	if *optionFilePath == "" {
		fmt.Printf("\n[ERROR] Missing required option\n\n")
		flag.Usage()
		os.Exit(1)
	}

	optionsUsage, _ := getOptionsUsage(true)
	fmt.Printf("[ Command options ]\n%s\n", optionsUsage)
}


// =======================================
// flag Utils
// =======================================

// Helper function for flag
func defineFlagValue[T comparable](short, long, description string, defaultValue T, flagFunc func(name string, value T, usage string) *T, flagVarFunc func(p *T, name string, value T, usage string)) *T {
	flagUsage := short + UsageDummy + description
	var zero T
	if defaultValue != zero {
		flagUsage = flagUsage + fmt.Sprintf(" (default %v)", defaultValue)
	}
	f := flagFunc(long, defaultValue, flagUsage)
	flagVarFunc(f, short, defaultValue, UsageDummy)
	return f
}

// Custom usage message
func customUsage(description string) func() {
	return func() {
		optionsUsage, requiredOptionExample := getOptionsUsage(false)
		fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s %s[OPTIONS]\n\n", func() string { e, _ := os.Executable(); return filepath.Base(e) }(), requiredOptionExample)
		fmt.Fprintf(flag.CommandLine.Output(), "Description:\n  %s\n\n", description)
		fmt.Fprintf(flag.CommandLine.Output(), "Options:\n%s", optionsUsage)
	}
}

// Get options usage message
func getOptionsUsage(currentValue bool) (string, string) {
	requiredOptionExample := ""
	optionNameWidth := 0
	usages := make([]string, 0)
	getType := func(v string) string {
		return strings.NewReplacer("*flag.boolValue", "", "*flag.", "<", "Value", ">").Replace(v)
	}
	flag.VisitAll(func(f *flag.Flag) {
		optionNameWidth = max(optionNameWidth, len(fmt.Sprintf("%s %s", f.Name, getType(fmt.Sprintf("%T", f.Value))))+4)
	})
	flag.VisitAll(func(f *flag.Flag) {
		if f.Usage == UsageDummy {
			return
		}
		value := getType(fmt.Sprintf("%T", f.Value))
		if currentValue {
			value = f.Value.String()
		}
		short := strings.Split(f.Usage, UsageDummy)[0]
		mainUsage := strings.Split(f.Usage, UsageDummy)[1]
		if strings.Contains(mainUsage, Req) {
			requiredOptionExample += fmt.Sprintf("--%s %s ", f.Name, value)
		}
		usages = append(usages, fmt.Sprintf("  -%-1s, --%-"+strconv.Itoa(optionNameWidth)+"s %s\n", short, f.Name+" "+value, mainUsage))
	})
	sort.SliceStable(usages, func(i, j int) bool {
		return strings.Count(usages[i], Req) > strings.Count(usages[j], Req)
	})
	return strings.Join(usages, ""), requiredOptionExample
}

実装の解説

使うときは意識しなくて良いのですが、多少複雑なので何をやってるのかを簡単に解説します。

defineFlagValue

var (
	optionFilePath   = defineFlagValue("f", "file-path" /*  */, Req+" File path" /* */, "" /*     */, flag.String, flag.StringVar)
	optionLineIndex  = defineFlagValue("l", "line-index" /* */, "Index of line" /*  */, 10 /*     */, flag.Int, flag.IntVar)
	optionDebug      = defineFlagValue("d", "debug" /*      */, "Debug mode" /*     */, false /*  */, flag.Bool, flag.BoolVar)
...

func defineFlagValue[T comparable](short, long, description string, defaultValue T, flagFunc func(name string, value T, usage string) *T, flagVarFunc func(p *T, name string, value T, usage string)) *T {
	flagUsage := short + UsageDummy + description
	var zero T
	if defaultValue != zero {
		flagUsage = flagUsage + fmt.Sprintf(" (default %v)", defaultValue)
	}
	f := flagFunc(long, defaultValue, flagUsage)
	flagVarFunc(f, short, defaultValue, UsageDummy)
	return f
}

いきなり独自実装が出てきました。 これは、ショートオプションとロングオプションの両方を同じ意味で定義するためのヘルパー関数です。 ショートオプション名、ロングオプション名、説明文、デフォルト値を指定すると、両方のオプションを同じ変数にバインドします。 flag.Stringflag.Int のような関数と同じものですね。

ポイントは以下の2点です。

  • short + UsageDummy + description
    • Usage の先頭にショートオプション名を埋め込んでいます。
    • 後の処理で、 Usage をパースしてショートオプション名を取得するために使います。
  • if defaultValue != zero {...}
    • デフォルト値が指定されている場合、 Usage の説明文にデフォルト値を含めています。
    • 後の処理で Usage を表示時にデフォルト値が表示されるようにするためです。

ちなみにこの関数は、Go 1.18 で導入された Generics を使って実装しています。 もし Generics なかったら同じ実装を大量にコピペしないといけなかったので、めちゃくちゃ助かりました!!

あ、あと /* */, のようなコメントブロックを入れてる理由は、 defineFlagValue の引数の位置が縦に揃うとパッとみた時に各種オプションの値が分かりやすくなるので可読性のために入れてます。

customUsage

// Custom usage message
func customUsage(description string) func() {
	return func() {
		optionsUsage, requiredOptionExample := getOptionsUsage(false)
		fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s %s[OPTIONS]\n\n", func() string { e, _ := os.Executable(); return filepath.Base(e) }(), requiredOptionExample)
		fmt.Fprintf(flag.CommandLine.Output(), "Description:\n  %s\n\n", description)
		fmt.Fprintf(flag.CommandLine.Output(), "Options:\n%s", optionsUsage)
	}
}

ここは前回紹介した Custom Usage の実装とほぼ同じです。 Usage メッセージのフォーマットを定義しています。 が、前回と違うのは getOptionsUsage 関数を呼び出して、オプション一覧の表示と、必須オプションの例示部分(一行目のやつ)を取得している点です。

getOptionsUsage

ここが今回のキモの部分です。 この関数は、 flag.VisitAll で登録されているすべてのオプションを走査し、 Custom Usage の “オプション一行表示” のフォーマットに合わせた Usage を生成します。 この時、必須オプションを検出して、 Usage の1行目に例示するための文字列も同時に生成し、関数の戻り値として返します。

前回の方法は、 flag パッケージの標準の Usage 出力をパースして整形していたのですが、今回はオプション名の横幅を動的に調整したり、ロングオプション名を追加したりする必要があるため、標準の Usage 出力をパースする方法は使えません。 このため、 flag.VisitAll でオプションを1つずつ走査して、 Usage を組み立てる方法を採用しています。 前回の記事を読んだ方がもしいらっしゃったら、「デフォルト値取得できないんじゃなかったの?」と思われるかもしれません。 そこはその通りで、flag が出力する default 値は VisitAll では取得できません。

今回どうやっているのかというと、 defineFlagValue のヘルパー関数を使ってオプションを定義している処理の中で

if defaultValue != zero {
	flagUsage = flagUsage + fmt.Sprintf(" (default %v)", defaultValue)
}

というようなことをやってます(「defineFlagValue」でも説明した内容です)。 これはつまり、指定されたデフォルト値がその型の初期値かどうかチェックし、 もし初期値と異なる値だった場合は “オプションの説明文に文字列として含めてしまう” というような方法で対処してます。 こうすることで、 VisitAll で取得された Usage 文字列をそのまま表示するだけで、デフォルト値も表示できるようになります。

また、 f.Usage をパースして、ショートオプション名と メインのオプション説明文を分離し、フォーマットに合わせて組み立て直しています。

(便利機能として、ヘルプ表示時の文字列組み立ての他に、実行時に現在のオプション値を表示するためにも使えるようにしています。)

まとめ

Go 標準の flag パッケージだけでロングオプションをサポートする方法を紹介しました。 main.go のみでツールを実装したいんだけど、お手軽にロングオプションをサポートしたい、という場合にもしかすると使えるかもしれません。 (人にスクリプトやバイナリを渡してヘルプ表示してもらった時に「お?」と思ってもらえると良いなと思ってます) ではでは。