こんにちは、24Mの@Rasです。
個人でtraPにブログを出すのは1年半ぶりになってしまいました。
作ったものでサッとブログが書けそうだったのでモチベがあるうちに書いています。
さて、GoでCLIを開発する際は、フレームワークとしてspf13/cobraやurfave/cliを使うのがメジャーだと思います。
今回は、そのどちらでもなくalecthomas/kong(以下Kong)を使ってCLIを開発したのでその紹介と感想です。
Kong?
https://github.com/alecthomas/kong
Kongは、あらゆる複雑なコマンドライン構造をサポートすることを目指したコマンドラインパーサーです。
Kong aims to support arbitrarily complex command-line structures with as little developer effort as possible.
Gorrila Orgに影響を受けているのか、ゴリラみたいな名前をしています。
日本語の紹介記事は少ないですが、以下のスライドは機能が一通りまとめられていて参考になります(Kongを知るきっかけとなった記事です)。
alecthomas/kong はいいぞ / kamakura.go#7 - Speaker Deck
Kongでは、1つの構造体の中に各コマンド(サブコマンド)やそれらのコマンドで用いる引数やフラグなどを定義する形で記述します。
// https://github.com/alecthomas/kong?#introduction
package main
import "github.com/alecthomas/kong"
var CLI struct {
Rm struct {
Force bool `help:"Force removal."`
Recursive bool `help:"Recursively remove files."`
Paths []string `arg:"" name:"path" help:"Paths to remove." type:"path"`
} `cmd:"" help:"Remove files."`
Ls struct {
Paths []string `arg:"" optional:"" name:"path" help:"Paths to list." type:"path"`
} `cmd:"" help:"List paths."`
}
func main() {
ctx := kong.Parse(&CLI)
switch ctx.Command() {
case "rm <path>":
case "ls":
default:
panic(ctx.Command())
}
}
他のフレームワークとの比較
Cobraやurfave/cliはフレームワークが用意した構造体に設定を記述して手続き型にコマンドを紐づけていくのに対し、Kongは1つの構造体の中でコマンドの構造を宣言的に記述し、各フィールドの構造体タグで設定を記述する形になります。
Cobraで先ほどと同じCLIを作ろうとすると、こんな感じです(ChatGPTに書かせた)。
Cobraの場合
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var (
force bool
recursive bool
)
func main() {
var rootCmd = &cobra.Command{Use: "cli"}
var rmCmd = &cobra.Command{
Use: "rm [path]",
Short: "Remove files",
Run: func(cmd *cobra.Command, args []string) {
// ...
},
}
rmCmd.Flags().BoolVar(&force, "force", false, "Force removal")
rmCmd.Flags().BoolVar(&recursive, "recursive", false, "Recursively remove files")
var lsCmd = &cobra.Command{
Use: "ls [path]",
Short: "List paths",
Run: func(cmd *cobra.Command, args []string) {
// ...
},
}
rootCmd.AddCommand(rmCmd)
rootCmd.AddCommand(lsCmd)
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
先ほどのスライドにも書かれていますが、Kongはコマンドラインパーサーの部分に特化していて、パースさえKongに任せれば他はKongに依存せずにGoのコードを書けるのも嬉しいポイントです。
パース以外は自分で自由に書くこともできますが、Kongは以下のようにRun()
を実装することでコマンドを実行する方法もサポートしています。
しかし、(ここも偉いところで)Run()
の引数は開発者側が決めることができ、ここでもKongが登場することはないため、制約に縛られない書き方ができます。
Run()
を使ったコード
type Context struct {
Debug bool
}
type RmCmd struct {
Force bool `help:"Force removal."`
Recursive bool `help:"Recursively remove files."`
Paths []string `arg:"" name:"path" help:"Paths to remove." type:"path"`
}
func (r *RmCmd) Run(ctx *Context) error {
fmt.Println("rm", r.Paths)
return nil
}
var cli struct {
Debug bool `help:"Enable debug mode."`
Rm RmCmd `cmd:"" help:"Remove files."`
}
func main() {
ctx := kong.Parse(&cli)
// Call the Run() method of the selected parsed command.
err := ctx.Run(&Context{Debug: cli.Debug})
ctx.FatalIfErrorf(err)
}
また、個人的には以下を構造体タグで表現できる点をかなり評価しています。
- 1つのオプションにフラグ、環境変数を設定できる
- 各オプションのデフォルト値を設定できる
Cobraでこれをやろうとすると、spf13/pflagやspf13/viperといったいわゆる「spf13スタック」に乗っかる必要があり、依存は多いし記述も複雑で正直なところかなり大変です。
デフォルト値を構造体タグで記述できるのは探せばcreasty/defaultsなどがあるのですが、これら2つを1つのコマンドラインパーサーで実現できるKongはとても魅力的です。
Kongで何を作ったのか
部内SNS「traQ」のメッセージとシェルの標準入出力を繋げるツール「trapipe
」を作りました。
コード量もそこまで多くないので読もうと思えばサッと読めると思います。
https://github.com/ras0q/trapipe
trapipe receive
(traQ → Shell)とtrapipe send
(Shell → traQ)の2つのコマンドがあり、これらを駆使することでtraQのメッセージをシェルに渡したり、シェルの出力をtraQに投げたりすることができます。
trapipe receive -t "{{ .Message.ChannelID }} {{ .Message.PlainText }}" |
while read -r channel_id mention args; do
if [ "$mention" = "@BOT_AWESOME" ]; then
my-awesome-cli $args | trapipe send --channel-id "$channel_id"
fi
done
これまでtraQ Botを作るときには、連携用のライブラリを入れてtraQ特化のBotを作る必要があったのですが、 trapipe
に連携部分の役割を委譲することで普通のCLI開発と同じようにBotを作ることができます。
以下のようにコマンドの出力を返すだけの単純なBotであれば、Dockerfileに20行程度記述すればすぐに動かすことができます。
@BOT_figletの構成コード
FROM alpine:latest
RUN apk add --no-cache ca-certificates figlet
COPY --from=ghcr.io/ras0q/trapipe /bin/trapipe /bin/trapipe
ARG BOT_NAME="@BOT_figlet"
ARG COMMAND="figlet"
COPY --chmod=755 <<EOF /entrypoint.sh
#!/bin/sh -eu
trapipe receive -t "{{ .Message.ChannelID }} {{ .Message.PlainText }}" |
while read -r channel_id mention args; do
if [ "\$mention" = "$BOT_NAME" ]; then
echo "\\`\\`\\`plaintext"
$COMMAND \$args
echo "\\`\\`\\`"
fi | trapipe send --channel-id "\$channel_id"
done
EOF
ENTRYPOINT ["/entrypoint.sh"]

figlet
をデプロイ先のシェルで実行し、実行結果をtraQに返すBottrapipeではKong用の構造体を以下のように定義して使っています。trapipe receive
に対応する構造体にdefault:"1"
のタグを付けることでサブコマンドなしで実行した際にtrapipe receive
を呼ぶことができます。
特にFormatterやLinterではfoofmt run ./
でもfoofmt ./
でも実行したいという需要がありがちなので、KongはこれらのCLIを作る際にも有効な選択肢になり得ます。
var cli struct {
AccessToken string `help:"BOT Access Token" env:"TRAQ_BOT_ACCESS_TOKEN" required:""`
WSOrigin string `help:"traQ Websocket Origin" default:"wss://q.trap.jp" env:"TRAQ_WS_ORIGIN"`
Receive commands.Receive `cmd:"" default:"1" help:"Receive messages from traQ server (default)"`
Send commands.Send `cmd:"" help:"Send a message to traQ server"`
}
サブコマンドの実行は先述のRun()
を使う方法を採用しており、commands
パッケージにそれぞれの実装を分離して書いています。
package commands
type Send struct {
ChannelID string `help:"Channel ID to send a message" required:""`
}
func (c *Send) Run(ctx *Context) error {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return err
}
postMessage(data)
return nil
}
だいぶ好みのスタイルで書くことができ、今後のCLI開発の強力な選択肢になりそうです。
ぜひKong(と trapipe
)を使ってみてください。