最近のgoでCLIのコマンドを作るときの雛形のメモ (updated)
昔にも似たような記事を書いていたみたいだった。最近のgoでのCLIツールを書く際の心境の変化があったのでその辺をメモしておく。
心境の変化
心境の変化は2つ
- コマンドラインオプションをstructで囲む理由が特になくなった
- サブコマンドがやっぱりほしい
コマンドラインオプションをstructで囲む理由が特になくなった
昔はわざわざコマンドラインオプションをstructで囲んで、Run()
関数に渡していたのだけれど。そもそもRun()関数が長々と書かれていたらそれはmain.goの記述が長すぎる。
一度しか使われないものはグローバル変数で良いということでグローバルの変数としてポインタを保持する形式になった。
Run()に対して引数として渡す必要もなくて、dereferenceしてあげればおしまい。それ以前の段階で引数のvalidationは通っているはずなので。 Run()がエラー値を返すのはそのまま。
サブコマンドがやっぱりほしい
サブコマンドのものとコマンドが1つだけのものをほとんど同様の記述で書きたい。そういうわけで少し不思議なコードになっている。
(どの辺が不思議かというと、例えば Cmd = kingpin.CommandLine
のあたり)
コマンドが1つだけの場合
以下の様な感じで書く。kingpinを使うのは惰性なので他のライブラリが便利ならそれを使っても良い。
package main import ( "fmt" "log" "os" kingpin "gopkg.in/alecthomas/kingpin.v2" ) var ( // Cmd ... Cmd = kingpin.CommandLine debug = Cmd.Flag("debug", "Enable debug mode.").Bool() name = Cmd.Arg("name", "name").Required().String() ) func init() { Cmd.Name = "hello world" Cmd.Help = "hello world example" Cmd.Version("0.0.1") } func main() { _, err := Cmd.Parse(os.Args[1:]) if err != nil { Cmd.FatalUsage(fmt.Sprintf("\x1b[33m%+v\x1b[0m", err)) } if err := run(); err != nil { log.Fatalf("!!%+v", err) } } func run() error { name := *name fmt.Println("hello", name) return nil }
実行結果
ヘルプメッセージ
usage: hello world [<flags>] <name> hello world example Flags: --help Show context-sensitive help (also try --help-long and --help-man). --debug Enable debug mode. --version Show application version. Args: <name> name
実行結果
$ go run 00onefile/main.go world hello world
サブコマンド対応
(手元で動かすためにgo.modやimport pathにhackが入っているけれどその辺りは読み替えてほしい)
サブコマンドを定義するときにはだいたい以下の様な構造にしている。結構何も考えずにパッケージを分けちゃったほうがあとあと楽という結論になった。
この例では以下2つのサブコマンドを定義している
- register
- post
$ tree -P "*.go" 01multifiles/ 01multifiles/ ├── main.go ├── post │ └── cmd.go └── register └── cmd.go
go.mod
module m go 1.13 require ( github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect gopkg.in/alecthomas/kingpin.v2 v2.2.6 )
ポイントはサブコマンドとしての定義とそうではない場合の1つのコマンドとしての定義の記述が概ね同じような形になるようにしている点。あと気持ちの問題でヘルプメッセージに色を付けている。
main.go
package main import ( "fmt" "log" post "m/01multifiles/post" register "m/01multifiles/register" "os" kingpin "gopkg.in/alecthomas/kingpin.v2" ) var ( // Cmd ... Cmd = kingpin.CommandLine ) func init() { Cmd.Name = "multi commands" Cmd.Help = "multi commands example" Cmd.Version("0.0.1") } func main() { command, err := Cmd.Parse(os.Args[1:]) if err != nil { Cmd.FatalUsage(fmt.Sprintf("\x1b[33m%+v\x1b[0m", err)) } switch command { // Register user case register.Cmd.FullCommand(): err = register.Run() // Post message case post.Cmd.FullCommand(): err = post.Run() } if err != nil { log.Fatalf("!!%+v", err) } }
register/cmd.go
package register import ( "fmt" kingpin "gopkg.in/alecthomas/kingpin.v2" ) var ( // Cmd ... Cmd = kingpin.Command("register", "Register a new user.") nickname = Cmd.Flag("nickname", "Nickname for user.").Default("anonymous").String() name = Cmd.Arg("name", "Name of user.").Required().String() ) // Run ... func Run() error { name := *name nickname := *nickname fmt.Println("register, name=", name, "nickname=", nickname) return nil }
post/cmd.go
package post import ( "fmt" kingpin "gopkg.in/alecthomas/kingpin.v2" ) var ( // Cmd ... Cmd = kingpin.Command("post", "Post a message to a channel.") image = Cmd.Flag("image", "Image to .").File() channel = Cmd.Arg("channel", "Channel to to.").Required().String() text = Cmd.Arg("text", "Text to .").Strings() ) // Run ... func Run() error { image := *image channel := *channel fmt.Println("post, image=", image, "channel=", channel) return nil }
実行結果
ヘルプメッセージ (全体)
usage: multi commands [<flags>] <command> [<args> ...] multi commands example Flags: --help Show context-sensitive help (also try --help-long and --help-man). --version Show application version. Commands: help [<command>...] Show help. post [<flags>] <channel> [<text>...] Post a message to a channel. register [<flags>] <name> Register a new user.
ヘルプメッセージ (サブコマンド)
usage: multi commands register [<flags>] <name> Register a new user. Flags: --help Show context-sensitive help (also try --help-long and --help-man). --version Show application version. --nickname="anonymous" Nickname for user. Args: <name> Name of user.
実行結果
$ go run 01multifiles/main.go register world register, name= world nickname= anonymous
monogusaを使ってただのpythonの関数をslackやdiscord上のコマンドとして公開できるようになった
今までただの関数をCLIとして利用できたり、ちょっとしたwebAPIとして利用できるようにしていたけれど。 今度はmonogusaを使ってただのpythonの関数をslackやdiscord上のコマンドとして公開できるようになった。
簡単な例
例えば以下の様な関数がある。本当にただの関数。
02hello.py
def hello(*, name: str) -> None: print(f"hello {name}") def byebye() -> None: print("byebye")
これを以下の様な形で呼ぶとbotになる。
$ python -m monogusa.chatbot.slackcli 02hello.py
slack上での表示
slackbotの場合にはthreadとして実行結果を返す。インターフェイス自体はほぼほぼCLI上のコマンドのまま。 丁寧な進捗表示やコンソールの同期のようなリッチな機能は存在していない。
デフォルトでは $app
で始まる投稿に反応する。これは実行時の --name
オプションで変えられる。
とりあえずヘルプメッセージを表示してみる。
helloとbyebyeが公開されている。試しにhelloを実行してみる。引数が足りなかった。
--name=world
を追加してみる。良さそう。
そういう感じ。
tokenの設定方法
注意点としてtokenの設定をしないと以下の様なエラーが出る。この辺はもう少し親切にするかもしれない。
$ python -m monogusa.chatbot.slackcli 02hello.py Traceback (most recent call last): File "/opt/local/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/runpy.py", line 193, in _run_module_as_main return _run_code(code, main_globals, None, File "/opt/local/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/runpy.py", line 86, in _run_code exec(code, run_globals) File "$HOME/vboxshare/venvs/my/monogusa/monogusa/chatbot/slackcli/__main__.py", line 8, in <module> def run( File "$HOME/vboxshare/venvs/my/lib/python3.8/site-packages/handofcats/__init__.py", line 30, in as_command return call(fn, level=level, argv=argv) File "$HOME/vboxshare/venvs/my/lib/python3.8/site-packages/handofcats/__init__.py", line 25, in call return driver.run(fn, argv) File "$HOME/vboxshare/venvs/my/lib/python3.8/site-packages/handofcats/driver.py", line 44, in run return fn(**params) File "$HOME/vboxshare/venvs/my/monogusa/monogusa/chatbot/slackcli/__main__.py", line 22, in run token = token or component.api_token() File "$HOME/vboxshare/venvs/my/monogusa/monogusa/chatbot/slackcli/component.py", line 17, in api_token return os.environ[envvar] File "/opt/local/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/os.py", line 673, in __getitem__ raise KeyError(key) from None KeyError: 'SLACKCLI_API_TOKEN'
tokenの設定方法は以下の2つ
- .envに書く
- オプションで渡す
.envには以下の様に書く(cwdの.envを見る)。
SLACKCLI_API_TOKEN = xoxb-xxxxxxxxxxxxxxxxxxxxxxxxxxXXxXxxxxXxXxXXxxxxxxxx
オプションで渡す場合は--token
に。
$ python -m monogusa.chatbot.slackcli --token <TOKEN> <FILE>
discord
slackしかこの記事では触れなかったけれど。discord上にも同様の手順で公開できる。
full examples
もう少しまともな利用例は以下を見ると良い。sqliteを使ったmini todo app的な例がある。