Help us understand the problem. What is going on with this article?

awesome-go にある CLI アプリケーションフレームワークを試していく

More than 1 year has passed since last update.

はじめに

  • urfave/cli を使ってちょっと大きめの CLI アプリケーションを作ってみて、我慢できないほどではないけれども軽い不満が出てきたので別のフレームワークへの乗り換えを考え出した
  • いい機会なので awesome-go に記載されている CLI アプリケーションフレームワークやコマンドライン引数パーサーを一通り試していってみる

予選

  • urfave/cli で実装したものを移植してみて実際の使用感を確かめてみようと思うが、数が多いので予選を開催して明らかに使わなそうなものは落としていく
  • 選考基準は urfave/cli でできることはできた上で urfave/cli で感じた不満を解消してくれるもの
  • 具体的には、ロングフラグ・ショートフラグには対応、ヘルプメッセージを手軽に表示できる、コマンドとサブコマンドに対応している、必須オプションに対応している、コマンド定義の記述がやたらと長くなったりしない、などの点をみていく

argparse

  • コマンドライン引数の単なるパーサーでパースした結果を元に条件分岐して任意のロジックを実行していくスタイル
  • 欲しい機能は満たしている
  • あとに見る kingpin とコンセプトは似ていて kingpin の方がコードが好みなのでこちらは予選落ち

mkideal/cli

  • 構造体の定義でコマンドライン引数の定義を行うフレームワーク
  • 要件は満たしているが、タグにずらずら書いていくのが可読性が悪いので無理に使わなくていいなーということで予選落ち
type argT struct {
    Name string `cli:"name" usage:"tell me your name"`
}

func main() {
    cli.Run(new(argT), func(ctx *cli.Context) error {
        argv := ctx.Argv().(*argT)
        ctx.String("Hello, %s!\n", argv.Name)
        return nil
    })
}

teris-io/cli

  • コマンド定義をメソッドチェーンでどんどんやっていく感じ
  • パッと見、必須オプションに対応していないので予選落ち
co := cli.NewCommand("checkout", "checkout a branch or revision").
  WithShortcut("co").
  WithArg(cli.NewArg("revision", "branch or revision to checkout")).
  WithOption(cli.NewOption("branch", "Create branch if missing").WithChar('b').WithType(cli.TypeBool)).
  WithOption(cli.NewOption("upstream", "Set upstream for the branch").WithChar('u').WithType(cli.TypeBool)).
  WithAction(func(args []string, options map[string]string) int {
    // do something
    return 0
  })

add := cli.NewCommand("add", "add a remote").
  WithArg(cli.NewArg("remote", "remote to add")).

rmt := cli.NewCommand("remote", "Work with git remotes").
  WithCommand(add)

app := cli.New("git tool").
  WithOption(cli.NewOption("verbose", "Verbose execution").WithChar('v').WithType(cli.TypeBool)).
  WithCommand(co).
  WithCommand(rmt)
  // no action attached, just print usage when executed

os.Exit(app.Run(os.Args, os.Stdout))

climax

  • urfave/cli っぽい
  • 独自機能も見当たらないし必須オプションに対応していないので予選落ち

cobra

  • 要件満たしているし試してみたいので予選通過

decopt.go

  • ヘルプメッセージをパースしてコマンドライン引数の定義とするフレームワーク
  • 面白いけどクセが強い。ヘルプメッセージの記述とか補完効かないし大変じゃないのかな。とりあえず試す気が出ないので予選落ち
    usage := `Usage:
  quick_example tcp <host> <port> [--timeout=<seconds>]
  quick_example serial <port> [--baud=9600] [--timeout=<seconds>]
  quick_example -h | --help | --version`

    arguments, _ := docopt.Parse(usage, nil, true, "0.1.1rc", false)
    fmt.Println(arguments)

cosiner/flag

  • Go 標準の flag の改良版
  • サブコマンドや必須オプションには対応指定なさそうなので予選落ち

go-arg

  • コマンドライン引数パーサー
  • サブコマンドなんかには対応していないので予選落ち

go-flags

  • コマンドライン引数パーサー
  • サブコマンドなんかには対応していないので予選落ち

kingpin

  • コマンドライン引数のパーサーでパースした結果を元に条件分岐して任意のロジックを実行していくスタイル
  • 欲しい機能は満たしていて割ときれいにコードがかけそうな気がする。試してみたいので予選通過

mitchellh/cli

  • 有名どころではあるけど example とかほぼないのでよくわからないし疲れてきたので深追いする気力がなくなっている
  • 多分自分のユースケースには合わないので予選落ち

mow.cli

  • decopt.go っぽい感じでオプションの書式を定義として利用する
  • decopt.go とは違いフラグや引数の定義は別途行うようなので見やすく書きやすそうな感じはある
  • 必須オプションには対応していなさそうなので予選落ち
    app := cli.App("cp", "Copy files around")

    app.Spec = "[-r] SRC... DST"

    var (
        recursive = app.BoolOpt("r recursive", false, "Copy files recursively")
        src       = app.StringsArg("SRC", nil, "Source files to copy")
        dst       = app.StringArg("DST", "", "Destination where to copy files to")
    )

    app.Action = func() {
        fmt.Printf("Copying %v to %s [recursively: %v]\n", *src, *dst, *recursive)
    }

    app.Run(os.Args)

clif

  • 要件は満たしていそう。求めていない機能もあって無駄にリッチにも見えるが試してみたくはあるので予選通過

本選

  • まずは urfave/cli で仮装のアプリケーションを実装してみたあとに各フレームワークに移植してみる

urfave/cli

  • README にあるものを元に実装
  • 3 コマンド 2 サブコマンド程度でまあ見れないほどではないが、コマンドが増えるごとに縦に長くネストが深くなっていくのでこれが改善されると嬉しい
  • また add コマンドに必須オプションを実装してみたが、これがフレームワーク標準機能で実装できると嬉しい
package main

import (
    "fmt"
    "github.com/urfave/cli"
    "os"
)

func main() {
    app := cli.NewApp()
    app.Name = "todo"

    app.Commands = []cli.Command{
        {
            Name:    "add",
            Aliases: []string{"a"},
            Usage:   "add a task to the list",
            Flags: []cli.Flag{
                cli.StringFlag{Name: "description, d"},
            },
            Before: func(c *cli.Context) error {
                if !c.IsSet("description") {
                    fmt.Println("description required\n")
                    cli.ShowAppHelpAndExit(c, 1)
                }
                return nil
            },
            Action: func(c *cli.Context) error {
                fmt.Printf("added task. title: %s, description: %s ", c.Args().First(), c.String("description"))
                return nil
            },
        },
        {
            Name:    "complete",
            Aliases: []string{"c"},
            Usage:   "complete a task on the list",
            Action: func(c *cli.Context) error {
                fmt.Println("completed task: ", c.Args().First())
                return nil
            },
        },
        {
            Name:    "template",
            Aliases: []string{"t"},
            Usage:   "options for task templates",
            Subcommands: []cli.Command{
                {
                    Name:  "add",
                    Usage: "add a new template",
                    Action: func(c *cli.Context) error {
                        fmt.Println("new task template: ", c.Args().First())
                        return nil
                    },
                },
                {
                    Name:  "remove",
                    Usage: "remove an existing template",
                    Action: func(c *cli.Context) error {
                        fmt.Println("removed task template: ", c.Args().First())
                        return nil
                    },
                },
            },
        },
    }

    app.Run(os.Args)
}

cobra

  • 60 行と urfave/cli の 69 行よりは短くなり、ネストも浅くなってだいぶいい感じ
  • 必須オプション指定は後付けっぽくて若干ダサい
  • 各コマンドのファイル分割も容易(というかジェネレータだと別ファイルになる)なのでアプリケーションが大きくなっても見通しよくできそう
package main

import (
    "fmt"
    "github.com/spf13/cobra"
)

func main() {
    rootCmd := &cobra.Command{Use: "todo"}

    addCmd := &cobra.Command{
        Use:     "add",
        Aliases: []string{"a"},
        Short:   "add a task to the list",
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Printf("added task. title: %s, description: %s ", args[0], cmd.Flag("description").Value)
        },
    }
    addCmd.Flags().StringP("description", "d", "", "description")
    addCmd.MarkFlagRequired("description")
    rootCmd.AddCommand(addCmd)

    completeCmd := &cobra.Command{
        Use:     "complete",
        Aliases: []string{"c"},
        Short:   "complete a task on the list",
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Println("completed task: ", args[0])
        },
    }
    rootCmd.AddCommand(completeCmd)

    templateCmd := &cobra.Command{
        Use:     "template",
        Aliases: []string{"t"},
        Short:   "options for task templates",
    }
    rootCmd.AddCommand(templateCmd)

    templateAddCmd := &cobra.Command{
        Use:   "add",
        Short: "add a new template",
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Println("new task template: ", args[0])
        },
    }
    templateCmd.AddCommand(templateAddCmd)

    templateRemoveCmd := &cobra.Command{
        Use:   "remove",
        Short: "remove an existing template",
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Println("remove task template: ", args[0])
        },
    }
    templateCmd.AddCommand(templateRemoveCmd)

    rootCmd.Execute()
}

kingpin

  • 行数は 42 行と clif と同立一位
  • 引数の定義ができるのは嬉しい
  • フラグの定義もみやすくていい感じ
  • コマンドライン引数のパーサーでしかないので main の中で switch で実行アクションに分岐していくのはちょっと面倒そう
  • README にしたがってコマンドの定義をトップレベルの var でやってみたがファイル分割とかできるのかなこれ
package main

import (
    "fmt"
    "gopkg.in/alecthomas/kingpin.v2"
    "os"
)

var (
    app = kingpin.New("todo", "")

    addCmd                = app.Command("add", "add a task to the list").Alias("a")
    addCmdTitle           = addCmd.Arg("title", "").String()
    addCmdDescriptionFlag = addCmd.Flag("description", "").Short('d').Required().String()

    completeCmd      = app.Command("complete", "complete a task on the list").Alias("c")
    completeCmdTitle = completeCmd.Arg("title", "").String()

    templateCmd = app.Command("template", "options for task templates").Alias("t")

    templateAddCmd      = templateCmd.Command("add", "add a new template")
    templateAddCmdTitle = templateAddCmd.Arg("title", "").String()

    templateRemoveCmd      = templateCmd.Command("remove", "remove an existing template")
    templateRemoveCmdTitle = templateRemoveCmd.Arg("title", "").String()
)

func main() {
    switch kingpin.MustParse(app.Parse(os.Args[1:])) {
    case addCmd.FullCommand():
        fmt.Printf("added task. title: %s, description: %s ", *addCmdTitle, *addCmdDescriptionFlag)
    case completeCmd.FullCommand():
        fmt.Println("completed task: ", *completeCmdTitle)
    case templateAddCmd.FullCommand():
        fmt.Println("new task template: ", *templateAddCmdTitle)
    case templateRemoveCmd.FullCommand():
        fmt.Println("removed task template: ", *templateRemoveCmdTitle)
    default:
        app.Usage(os.Args)
    }
}

clif

  • 行数は 42 行と kingpin と同立一位
  • サブコマンドの形式が "cmd sub" ではなく "cmd:sub" になるのが若干違和感
  • 引数の定義ができたり、書式が割と直感的だったりするのは結構嬉しい
package main

import (
    "fmt"
    "gopkg.in/ukautz/clif.v1"
)

func main() {
    cli := clif.New("todo", "", "")

    add := clif.NewCommand("add", "add a task to the list", func(c *clif.Command) {
        fmt.Printf("added task. title: %s, description: %s ", c.Argument("title").String(), c.Option("description").String())
    })
    add.NewArgument("title", "", "", false, false)
    add.NewOption("description", "d", "", "", true, false)

    cli.Add(add)

    complete := clif.NewCommand("complete", "complete a task on the list", func(c *clif.Command) {
        fmt.Printf("completed task: %s", c.Argument("title").String())
    })
    complete.NewArgument("title", "", "", false, false)

    cli.Add(complete)

    templateAdd := clif.NewCommand("template:add", "", func(c *clif.Command) {
        fmt.Printf("new task template: %s", c.Argument("title").String())
    })
    templateAdd.NewArgument("title", "", "", false, false)

    cli.Add(templateAdd)

    templateRemove := clif.NewCommand("template:remove", "", func(c *clif.Command) {
        fmt.Printf("removed task template: %s", c.Argument("title").String())
    })
    templateRemove.NewArgument("title", "", "", false, false)

    cli.Add(templateRemove)

    cli.Run()
}

おわりに

  • cobra はやっぱり安定して使えそう
  • clif もサブコマンドの書式が気持ち悪い以外は良さそうだった
  • とはいえ kingpin のシンプルで直感的な書式にはなんだか惹かれるものがあったので、実際のアプリケーションで使ってみてもう少し深く知っていきたい。ということで優勝は kingpin でした。

追記

  • kingpin を深追いしてみてアクション毎に記述を分割する方法についてわかったので別記事にまとめた。これで他の CLI フレームワークとたたかえる。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした