最近YouTuberのリュウジの料理を毎日作っているので至高とか無限とか言いがちですが個人の感想です。万人にとって美味しい料理はないように、万人にとって至高のツールは存在しません(何の話?)。ちなみに公開してすぐバグを見つけてしまったので全然至高じゃありませんでした。
Goでプラグイン機構を実現するためのツールを作りました。Protocol Buffersのスキーマからコードを自動生成するので簡単にプラグイン機構を実現可能です。内部的にはWebAssembly(Wasm)を使っています。最近はWasmはブラウザ外での利活用が進んでおり、今回のツールもブラウザは一切関係ないです。Wasmはサンドボックス内で実行されるためファイルアクセスやネットワークアクセスはホストが許可しなければプラグインは行えずセキュリティ的に安全ですし、異なるアーキテクチャ用に複数バイナリを用意しなくてもプラグイン用にWasmバイナリを1つだけ作れば良いので配布も簡単です。これらの特徴から、サードパーティ製のコードを動かす必要のあるプラグイン用途に向いている気がしたので作りました。
Goでプラグインを実現する方法はいくつかありますが、どれも一長一短でこれだ!というものがないのが現状かと思います。分かりやすく比較を載せてくださっている記事があったので置いておきます。
Goにはplugin パッケージがありますが自由度が高すぎてシグネチャのズレが容易に生じてしまいますし、あまり実例も少ないです。
上のブログ内でも言及されていますが、サードパーティ製で一番普及しているのはHashicorpにより開発されているhashicorp/go-plugin だと思います。Terraform等のOSSで採用されているため実例も多いです。内部はプラグインのバイナリがサーバとして動きツール側がクライアントとしてRPCで通信するアーキテクチャとなっています。プラグイン作りたいと思った人は100%知っていると思います。gRPCに対応しておりProtocol Buffersのprotoファイルを書けばクライアントとサーバ用のコードが自動生成できるため開発体験が良いです。ただし、プラグインと言ってもただの実行可能なバイナリなのでGoでプラグインを書く場合は環境ごとに異なるバイナリを配布する必要がありますし、単にバイナリを実行するという関係上セキュリティ的にはかなりよろしくないです。あと個人的にはprotobuf-go やprotobuf-gen-go-grpc で生成されたコードをプラグインで使えるようにするためのおまじないが必要で、書きっぷりが直感的じゃないなと感じています(これは自分がアホなだけの可能性もあります)。
もっともらしい理由を書いてきましたが、一言でいうと自分の美学に合わないというのが一番の理由です。趣味のOSS開発の動機なんかそんな感じでいいと思います。競合の調査とか真面目にやってるとどれも凄そうに見えて永遠に作り始められませんし、自分のツールこそが最高!(お前がそう思うんならそうなんだろう お前ん中ではな)と思っておけば良いと思います。
ということで別の方法を探していて、WebAssembly (Wasm) が使えないかと考えました。1年以上構想を温めてきたのですがようやく納得行くアーキテクチャを思いついたので作りました。それが以下のツールです。
マスコットは妻にさっと描いてもらいました。名前はgo-penguin 略してゴーペンくんです(適当)。
gRPCのようにProtocol Buffersの定義ファイルを渡すとWasm用のSDKを生成します。プラグイン側はそのSDKに従ってGoのinterfaceを実装するだけでプラグインが作れるようになっています。ホスト側もSDKが生成されるのでそれをimportしていくつか関数を呼び出すだけです。hashicorp/go-pluginと異なりプラグイン用のコード生成も行うためプラグイン開発者の負担はかなり低くなります。またSDKが自動生成されるため、Wasmに関する知識がなくても扱えるのも利点です。
プラグインはGoで書けるのですが、GoがWASI(ブラウザ外でWasmを使うためのSystem Interface)に対応していない関係でビルドはTinyGoで行う必要があります。TinyGoはGoに比べると機能が不足しており例えばencoding/json などが使えないのですが、その辺はHost Functionsを使ってある程度解消可能です。Host Functionsの使い方は後述します。
ただWasmは最近2.0のドラフトが出たばかりですし、WASIも2年前にsnapshot_preview1が出てからまだ1.0に到達していませんし、現時点で安定的に使えるかと言うとまだ微妙な感じではありますが今後に期待というところです。
knqyf263/go-plugin の特徴として以下のものがあります。
他にも色々あるのですが、そちらはREADMEを見ていただければと思います。
以下で使い方の説明をしていきますが、基本的に全てREADMEに書いてある内容なので直接GitHub見てもらったほうが早いかもしれません。サンプルコードもGitHubにいくつか置いています。
まず最初に開発フローについて説明し、その後に具体的な使い方を見ていきます。
go-plugin を使ってGoのSDKを生成しますinterface が生成されるのでプラグインでそれを実装します
まず必要なツールをインストールします。go-plugin はprotoc のプラグインとして動作するため、事前にprotoc をインストールしておく必要があります。そしてgo-plugin のバイナリは以下でダウンロード可能です。適当にパスの通った場所に置いてください。
あとはTinyGoも必要なのでインストールします。
Protocol Buffersと同じフォーマットを使います。.proto ファイルを書いたことがない人は以下のドキュメントを読むと良いです。extensionsとか一部の機能を除けば基本的な機能はシンプルなのですぐ理解できると思います。
大まかに言うとmessage とservice で成り立っており、message が型を、service がインタフェースを定義します。例を見てみます。
syntax ="proto3";package greeting;option go_package ="github.com/knqyf263/go-plugin/examples/helloworld/greeting";// The greeting service definition.// go:plugin type=plugin version=1service Greeter {// Sends a greetingrpc SayHello(GreetRequest)returns (GreetReply) {}}// The request message containing the user's name.message GreetRequest {string name =1;}// The reply message containing the greetingsmessage GreetReply {stringmessage =1;}
Greeter というサービスがSayHello という関数を持っています。rpc と言っていますが、go-plugin ではRPC通信は行わず、単にプラグインとのインタフェースを定義するためだけに使っています。
SayHello の引数はGreetRequest で戻り値はGreetReply になっています。それぞれはその下でmessage として定義されています。どちらもstringの値を一つ持つだけです。
ちなみに// go:plugin で始まる行がgo-plugin 用のserviceであることを示すプラグマとなっています。これを書かないとスルーされます。特にtype=plugin はプラグイン用のインタフェースであることを示すために必ず必要です。
go_package は自分のプロジェクトのパッケージを指定します。
上でprotoファイルが出来たら次にGoのコードを生成します。protoc が$PATH内のprotoc-gen-go-plugin を自動で見つける仕組みになっているので、必ずパスの通った場所にprotoc-gen-go-plugin を置いてください。
$ protoc--go-plugin_out=.--go-plugin_opt=paths=source_relative greeting.proto
成功すればgreet.pb.go,greet_host.pb.go,greet_plugin.pb.go,greet_vtproto.pb.go の4つのファイルが生成されているはずです。
以下のようなGoのinterface が生成されています。
type Greeterinterface { SayHello(context.Context, GreetRequest) (GreetReply,error)}
プラグインはこれを実装してstructをRegister関数経由で登録するだけで終わりです。例えば以下のように実装します。
//go:build tinygo.wasmpackage mainimport ("context""github.com/path/to/your/greeting")// main is required for TinyGo to compile to Wasm.func main() { greeting.RegisterGreeter(MyPlugin{})}type MyPluginstruct{}func (m MyPlugin) SayHello(ctx context.Context, request greeting.GreetRequest) (greeting.GreetReply,error) {return greeting.GreetReply{ Message:"Hello, " + request.GetName(), },nil}
もうプラグイン用のWasmとしてビルドできる状態です。簡単ですね。画期的です。SDKを自動生成するアプローチだからこういうことが可能になっています。
$ tinygo build-o plugin.wasm-scheduler=none-target=wasi--no-debug plugin.go
ターゲットをWASIにしている関係でバイナリがそこそこ大きいです。WASIの機能が不要な場合に-target=wasm で何とかやれないかなーという野望もありますが、現状はWASI必須です。
ホスト側のサンプルコードを載せます。
package mainimport ("context""fmt""log""github.com/path/to/your/greeting")func main() { ctx := context.Background()// Initialize a plugin loader p, err := greeting.NewGreeterPlugin(ctx, greeting.GreeterPluginOption{})if err !=nil {...}// Load a plugin plugin, err := p.Load(ctx,"path/to/plugin.wasm")if err !=nil {...}// Call SayHello reply, err := plugin.SayHello(ctx, greeting.GreetRequest{Name:"go-plugin"})if err !=nil {...}// Display the reply fmt.Println(reply.GetMessage())}
コード生成したパッケージをimportするとgreeting.NewGreeterPlugin() などは自動生成されていて使える状態になっています。これはただのローダなので、その後にLoad(ctx, "/path/to/plugin.wasm") で実際のプラグインを読み込んでいます。ローディングが終わればもうSayHello が呼べる状態になっています。今回はSayHello の戻り値を単に標準出力に表示しています。
通常通り上のホスト用ファイルを実行します。
$go run main.goHello,go-plugin
ということで無事にプラグインから返されたメッセージを表示できました。ほぼ同様のことを行うサンプルコードは以下に置いてあります。
基本的な機能は上で説明したのですが、いくつか発展的な内容についても書いておきます。
プラグイン側のコードはTinyGoでビルドすることになるのですが、一部機能が不足しており使えない標準パッケージがあります。
例えばJSONの操作に使うencoding/json に対応していません。そのような場合はgjson やeasyjson などTinyGoでも動作可能なサードパーティ製のライブラリを使う必要があります。
また、Wasmはセキュリティを考慮して作られているためファイルアクセスなどが制限されています。WASIによってファイルシステムへのアクセスは限定的に可能になっていますが(後述します)、ネットワークアクセスなどは現在のgo-plugin ではできません。これはTinyGoの制約ではなくWasmの制約です。また、Wasmランタイムとして利用しているwazeroで対応していないWASIの関数というのもあります。以下のwazeroのドキュメントにも書かれていますが、WASIの仕様がまだpreviewでいまいち固まりきっていないのが理由のようです。
そういった場合の方法としてhost functionを使う方法があります。これは名前の通り関数をホスト側で定義し、それをプラグインが呼び出せるようにする仕組みです。ホスト側はGoでコンパイルされるのでencoding/json なども利用可能です。他にもプラグイン側にロギング用の関数やHTTP通信するための関数を提供したり、といったことが可能です。
host functionの定義は簡単で、上でプラグイン用のインタフェースを定義したprotoファイル内に以下のようにserviceを書きます。//go:plugin type=host がhost functionを意味するので必須です。引数と戻り値のmessageも定義する必要がありますが今回は省略しています。GitHubの/examples で残りも見られます。
// go:plugin type=hostservice HostFunctions {// Sends a HTTP GET requestrpc HttpGet(HttpGetRequest)returns (HttpGetResponse) {}}
今回はプラグイン側で実行できないHTTPリクエストを定義しています。そして再度protoc でコード生成すると今度は以下のinterface も生成されます。
type HostFunctionsinterface { HttpGet(context.Context, HttpGetRequest) (HttpGetResponse,error)}
このインタフェースを実装します。
// myHostFunctions implements HostFunctionstype myHostFunctionsstruct{}// HttpGet is embedded into the plugin and can be called by the plugin.func (myHostFunctions) HttpGet(ctx context.Context, request greeting.HttpGetRequest) (greeting.HttpGetResponse,error) { ...}
protoファイル内にhost functionが定義されていると、Load() でそのインタフェースを受け取れるようになっています。上で定義したmyHostFunctions を渡せばOKです。
greetingPlugin, err := p.Load(ctx,"plugin/plugin.wasm", myHostFunctions{})ちなみに今回は雑にGETリクエストを送れる関数をexportしましたが、実際にプライベートIPアドレスには送れないようにするなどの制約を行うべきです。何でもかんでも便利関数をexportすると、せっかくWasmでサンドボックスにしているのにガバガバになってしまいます。
前述したようにプラグインはローカルのファイルにはデフォルトではアクセスできないのですが、ホスト側が明示的に許可することでプラグイン側でもファイルへのアクセスが可能になります。
プラグインローダの初期化時にホスト側からfs.FS を渡すことができます。特定のファイルでも良いですしディレクトリでも良いです。
publicDirFS := os.DirFS("./public")p, err := cat.NewFileCatPlugin(ctx, cat.FileCatPluginOption{ FS: publicDirFS, // Loaded plugins can access only files that the host allows.})サンプルコードは以下です。
go-plugin/examples/wasi at main · knqyf263/go-plugin · GitHub
何でprotobufのextensions使わないの?とかTinyGo以外の多言語対応は?とか、その他細かい諸々はREADMEを参照してください。
WebAssemblyはintやfloatぐらいしか引数や戻り値として使えません。より複雑な型を扱うにはどうするかというと、シリアライズしてメモリに書き込みWasm側でデシリアライズするのが一般的です。この辺については前回ブログを書いたので参照してください。
Wasmのコードを書いていて気付いたのは、上のシリアライズ・デシリアライズ処理を頻繁に書くということです。じゃあこれを一般化して自動生成できるようにすれば幸せなんじゃないか?と思ったのがgo-plugin の最初の動機です。
そこでシリアライズのフォーマットとして選んだのがProtocol Buffersになります。gRPCの普及により慣れ親しんでいる人が多いですし、スキーマドリブンの開発はやはり体験が良いためです。ですが、Wasm用途で広く使われるTinyGoはProtocol Buffersに対応していません(2022/08/29時点)。
そのため、protobuf-go で生成したコードはTinyGoではコンパイルできません。ここでprotobufの利用は一旦諦めたのですが、どうしてもスキーマドリブンの開発体験を諦めきれずもう少し中を見ることにしました。実際にコンパイルして試したところreflectパッケージの利用が問題のようでした。つまりreflectionを使わずにシリアライズ・デシリアライズできれば動くんじゃないかと考え実装を始めました。そうしたらvtprotobuf を発見しました。このツールはreflectionを使わずにmarshal/unmarshalするコードを生成してくれるのですが、protobuf-goとの併用が必須でprotobuf-goがreflectを使ってしまうため結局コンパイルできません。ならばprotobuf-goをベースにreflectを使わずに再実装すれば動くんじゃないかと思い、protobuf-goとvtprotobufを魔合体して改修して作ったのがgo-plugin です。思いつきで始めたものの何とか動くようになって安心しました。
その副産物として、1バイナリで全てのコード生成を行えるようになりました。通常はprotobuf-goなど複数のプラグインを組み合わせてコード生成を行うため色々と事前準備が面倒ですが、protoc-gen-go-plugin 一つだけprotocプラグインをインストールすればOKなので比較的楽です。
ですが今度はwell-known typesが動かないという問題に遭遇しました。protobufはデフォルトだとintやstringなどのプリミティブ型しか提供していませんが、Googleがtimestamp型などのよく使われる便利なmessageを提供してくれています。
これは事前にビルドされたものがprotobuf-goのリポジトリに置かれています。コード生成されたときにこれらのパッケージをimportしています。
これらがreflectを使っているせいで動かないという状態でした。そこで一旦諦めたのですが、well-known typesはよく使われるものだし対応しないわけにもいかないよな...と考え直した結果、自分で各well-known typesのコードを再生成することにしました。TinyGoでコンパイルできる型が以下に定義されています。import "google/protobuf/timestamp.proto"; とかするとGoogleではなく独自定義のstructが使われるようになっています。何も知らないとprotobuf-goのパッケージじゃないので驚くかもしれませんが、こういう事情があって泣く泣くreplaceしています。
まだ完全に移行が終わってないのでいくつか関数が足りなかったりしますが、最低限必要なものは動くんじゃないかなという気がします。以下にwell-known typesを使ったサンプルも置いてあります。
go-plugin/examples/known-types at main · knqyf263/go-plugin · GitHub
1年前の長期休暇で何か勉強したいなと思ってRust(初学)でWebAssembly(初学)ランタイムを書くという無謀なことをしたところ案の定作り終わらなかったのですが、手応えを得たのでプラグインのアイディアを思いつきました。ただWasm周りの処理をプラグイン開発者にそのまま書かせるのはしんどすぎるしSDKはどうする?シリアライズはどうする?とか色々悩んでいたら気付いたら1年経っていたという感じです。
他にも乗り越えるべき壁がたくさんあったのですが、寝不足でブログ書くの疲れてきたので一旦ここまでにします。
僕の考えた最強のGoプラグイン用ツールの紹介でした。WasmやTinyGoによる制約があるのでどんな要件でも満たせるかというと怪しいのですが、多くのケースで使えるプラグイン用ツールに仕上がったのではないかと思います。WebAssembly 2.0のドラフトも出ましたし、今後より便利になっていくと思います。
久々に0から趣味でOSSを作ったので疲れました。最近は自分用にライブラリやツールを作ることが多かったのでドキュメントとか適当でしたが、やはり人に使ってもらおうとするとドキュメントも必要だし労力が大きいです。疲労で目の痙攣が起こりましたしOSSは身体に悪いです。
なかなか良いアーキテクチャが思いつかず苦労したのですが、その分良いものになったと自負しています。最近時間ない中で睡眠を削ったりして割と頑張ったので広く使ってもらえると良いなと思っていますが、世界中誰にも認められなくても自分だけは自分を褒められる完成度になりました(バグは多分まだたくさんあると思いますが)。誰かに認めてもらおうというモチベーションだと個人OSSは続かないので、少なくとも自分だけは幸せだからヨシ!という気持ちです。一方で知らない誰かと共同でものを作っていくのがOSSの楽しさの一つだとも思っているので、使ってもらうための努力としてドキュメントはちゃんと書いておこう、というダブルスタンダードでやっています。
引用をストックしました
引用するにはまずログインしてください
引用をストックできませんでした。再度お試しください
限定公開記事のため引用できません。