Go 製 CLI にプラグイン機構を作る方法n選

6 min read読了の目安(約5600字

この記事は Go 3 Advent Calendar 2020 25日目の記事です。2018年から3年連続での Go 3 25日目です。クリスマスに追い込まれるのが得意。


便利なツール、とくに何かしらを操作したり生成するタイプのツールでは、その対象物の種類を増やすにあたってプラグイン機構を作りたくなることが多々あります。そういうときに世の中のツール、特に Go 製のツールがどういう手法をとっているかを軽く調べてまとめてみました。

似たような内容を2年前の GoCon で話しており、プラグイン機構を作るモチベーションなどはスライドの方を参照してもらうといいかもしれません。

https://speakerdeck.com/izumin5210/consider-pluggable-cli-tool-implementation-number-gocon

評価項目

これまたスライドでも述べていますが、プラグイン機構自体の導入モチベーションから、「プラグイン機構の導入で UX が悪化しないか」「プラグインの開発体験がどうか」の大きく2項目を見ていきます。

  • User Experience : ツール利用者が快適に・最小の事前知識で利用できるか
  • Plugin Developer Experience : 実装・デバッグ・配布の容易さなど

実装パターン

標準 plugin

まずは Go が標準で備えている機構です(これの言及忘れて GoCon で突っ込まれた)。

https://golang.org/pkg/plugin/

使い方は極めて単純。プラグイン側は main パッケージで変数・関数を Export しておいて、 -buildmode=plugin オプション付きでビルドします。

// https://golang.org/pkg/plugin/#Symbol より

package main

import "fmt"

func F() { fmt.Println("Hello, world\n") }

利用側はビルドしたプラグインを読み込んで、Lookup で Export したシンボルを拾って使う というものです。

package main

import "plugin"

func main() {
	p, err := plugin.Open("plugin.so")
	// error handling
	f, err := p.Lookup("F")
	// error handling
	f.(func())() // prints "Hello, world"
}

この標準の plugin パッケージについて、先程あげた評価項目がどうかを考えてみます。

  • User Experience : いまいち
    • プラグインをインストールの仕組みを考えないと難しそう
    • 利用者は、ツール側が規定したルールに従って.soを配置する必要があるというのがポイント
    • ありそうな仕組み
      • ツールにプラグインの URL を渡すとダウンロードして適切な場所に配置してくれる
      • プラグインのレジストリを提供する
      • プラグインがいい感じにインストールできるスクリプトを公開する(cf. goreleaser)
  • Developer Experience : ふつう
    • ツールが規定したシグネチャを守って関数や変数を Export するだけなので、開発自体は簡単そう
    • (あたりまえだけど)シグネチャを間違えるとちゃんと動かなくなる
      • 開発中にシグネチャを間違えても気付けるるような API があるといいのかも?
    • Go の関数や変数がそのままやり取りできるので、自由度は高い
    • プラグインのレジストリを用意した場合などは、ツール側でローカルの野良プラグインを扱える仕組みがないとデバッグが大変

Plugins as executable files

有名所では kubectl のプラグイン機構が採用しているパターン。「kubectl foo を実行すると $PATH から kubectl-foo という名前のファイルを探して実行する」という非常にシンプルなもの。 実行可能なら何でもいいので、Go どころかシェルスクリプトであっても問題なし。

https://kubernetes.io/docs/tasks/extend-kubectl/kubectl-plugins/

Protocol Buffers の IDL をコンパイルするツールである protoc も同じくプラグインを実行可能ファイルとして扱います。protoc が面白いのはそのインタフェースで、input / output に標準入出力を利用し、そこで protobuf のバイナリをやり取りします。

https://github.com/protocolbuffers/protobuf/blob/v3.14.0/src/google/protobuf/compiler/plugin.proto#L67-L183

SQLBoiler という ORM も protoc と似た手法を採用しています。こちらは標準入出力で JSON をやり取りします。DB のスキーマから Struct やクエリビルダのコードを生成するのですが、その DB ごとの adapter がプラグインとなっており、任意の RDB に対応させることができます。

https://github.com/volatiletech/sqlboiler
  • User Experience : そこそこ。
    • 実行可能ファイルを配置できればいいので、他の仕組みに乗ることで簡単にしやすい
      • 極論、 Homebrew でインストールさせることもできる
      • プラグインも Go であれば、go get でバイナリが作られていることを期待できる
        • gex のように Go Module で依存バイナリをうまく使える仕組みもある
    • プラグインが Go なら、Go Module でプラグインのバージョンを固定することも可能
      • 複数人で開発するプロジェクトで、プラグインのバージョンを固定できないと結構こまる
  • Developer Experience : 工夫次第
    • 試すの自体は簡単
      • より優先的に見られるところにファイルを配置すればいいだけなので
    • ツール - プラグイン間のやり取りをどう実現するかがポイント
      • 双方向のやり取りになるのであれば、標準入出力を使うのがメジャー?
      • 「プラグインは特定のインタフェースを満たすオブジェクトを関数に渡すだけ」みたいにできるとプラグイン実装はかなり簡単で、変更にも強くなる
      • やりとりに標準入出力を使うと、 print debug も debugger もちょっと面倒になるというちょっとしたデメリットが…

Plugins as RPC server

Terraform などが利用している hashicorp/go-plugin というプラグインが採用しているパターン。"Plugins as executable files" の亜種で、こちらはそのプラグインが RPC server となり、ツールが RPC client としてリクエストを送って遣り取りをするというアーキテクチャになっています。

https://github.com/hashicorp/go-plugin

Go 2 Advent Calendar 2020 の15日目に @po3rin さんが「go-plugin × gRPC で自作Goツールにプラグイン機構を実装する方法 - 好奇心に殺される。」という記事で解説してくれているので、詳しくはそちらをどうぞ。

https://po3rin.com/blog/go-plug
  • User Experience : そこそこ
    • こちらは "Plugins as executable files" とほぼ同じになるはず
  • Develoepr Experience : よさそう
    • 指定したプロトコルを喋ることができればいい
    • gRPC が使えるらしいので、インタフェースもかっちり規定でき、かつ実装も楽になる
      • GoCon で話したときは知らなかったけど、2017年には入っていたらしい…
      • メリット・背景は導入 Pull Request で詳しく説明されている
    • 更に工夫すれば、ツール側も server を立ててプラグインと双方向のやりとりもできるかも

CLI ではなくライブラリとして提供し、ユーザに実装させる

gqlgen が採用しているパターン。これもコード生成器で、GraphQL のスキーマから Go の GraphQL サーバのスケルトンを生成してくれる。これは結構画期的(?)で、プラグインは特定のインタフェースを実装して提供しておくだけ。利用者は自分で gqlgen のコンフィグを読み込み、プラグインを挿して、実行する というものです。

// +build ignore

package main

import (
	// ...
)

func main() {
	cfg, err := config.LoadConfigFromDefaultLocations()
	// error handling

	err = api.Generate(cfg,
		api.AddPlugin(yourplugin.New()), // ← ここでプラグインを追加
	)
	// error handling
}

https://gqlgen.com/reference/plugins/

この機構が偉いのは「gqlgenはGoがあるプロジェクトでしか使われない」という前提を踏まえて、gqlgen 自体の開発者にとって最もお手軽な手法をとっているところ。ただ $PATH から読み出してくる形式に比べれば User Experiences に劣るかもしれないが、go generate とかで gqlgen という名前でバイナリを吐くようにしておけば、ユーザもほぼ気にすることはなくなる。

// +build tools

//go:generate go build -o ./bin/gqlgen ./cmd/gqlgen

package tools
  • User Experience : (前提を置ければ)悪くない
  • Develoepr Experience : 最高
    • ツールが用意したインタフェースを実装するだけだからね!

gqlgen はこの他にも「(go get でビルドされるから)バイナリを配布しない」「(手元でビルドされてるから)静的ファイルの embed はしない」一方で「生成したコードを編集されてもいい感じに再生成できる」というように、現実的な割り切りと重要な体験の良く作り込みのバランスが素晴らしくて参考になります。ぜひ読んでみてください。

まとめ

プラグイン機構実装パターンをいくつか調べて、考察とともにまとめてみました。ここまでの内容を踏まえて、いまの自分であれば「"前提" がおければ gqlgen と同じパターン」「そうでなければ hashicorp/go-plugin を使う」とするでしょうか。

世の中には今回紹介した以外にもいろんな実装パターンが存在するので、ぜひ調べてみてください。個人的には create-react-app とか好きです。