最近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
の特徴として以下のものがあります。
- Goのインタフェースを実装するだけで良い
- セキュア
- ポータブル
他にも色々あるのですが、そちらはREADMEを見ていただければと思います。
使い方
以下で使い方の説明をしていきますが、基本的に全てREADMEに書いてある内容なので直接GitHub見てもらったほうが早いかもしれません。サンプルコードもGitHubにいくつか置いています。
まず最初に開発フローについて説明し、その後に具体的な使い方を見ていきます。
流れ
- まずプラグイン用のインタフェースをProtocol Buffersの定義ファイルに書きます
go-plugin
を使ってGoのSDKを生成します- 2でGoの
interface
が生成されるのでプラグインでそれを実装します - TinyGoを使ってプラグインをWasmにコンパイルします
- ホスト側でWasmを読み込んで定義したインタフェースに従って関数を呼び出します
事前準備
まず必要なツールをインストールします。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=1 service Greeter { // Sends a greeting rpc SayHello(GreetRequest) returns (GreetReply) {} } // The request message containing the user's name. message GreetRequest { string name = 1; } // The reply message containing the greetings message GreetReply { string message = 1; }
Greeter
というサービスが SayHello
という関数を持っています。 rpc
と言っていますが、 go-plugin
ではRPC通信は行わず、単にプラグインとのインタフェースを定義するためだけに使っています。
SayHello
の引数は GreetRequest
で戻り値は GreetReply
になっています。それぞれはその下で message
として定義されています。どちらもstringの値を一つ持つだけです。
ちなみに // go:plugin
で始まる行が go-plugin
用のserviceであることを示すプラグマとなっています。これを書かないとスルーされます。特に type=plugin
はプラグイン用のインタフェースであることを示すために必ず必要です。
go_package
は自分のプロジェクトのパッケージを指定します。
SDKの生成
上で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 Greeter interface { SayHello(context.Context, GreetRequest) (GreetReply, error) }
プラグインはこれを実装してstructをRegister関数経由で登録するだけで終わりです。例えば以下のように実装します。
//go:build tinygo.wasm package main import ( "context" "github.com/path/to/your/greeting" ) // main is required for TinyGo to compile to Wasm. func main() { greeting.RegisterGreeter(MyPlugin{}) } type MyPlugin struct{} 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 main import ( "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.go Hello, go-plugin
ということで無事にプラグインから返されたメッセージを表示できました。ほぼ同様のことを行うサンプルコードは以下に置いてあります。
発展
基本的な機能は上で説明したのですが、いくつか発展的な内容についても書いておきます。
Host Functions
プラグイン側のコードは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=host service HostFunctions { // Sends a HTTP GET request rpc HttpGet(HttpGetRequest) returns (HttpGetResponse) {} }
今回はプラグイン側で実行できないHTTPリクエストを定義しています。そして再度 protoc
でコード生成すると今度は以下の interface
も生成されます。
type HostFunctions interface { HttpGet(context.Context, HttpGetRequest) (HttpGetResponse, error) }
このインタフェースを実装します。
// myHostFunctions implements HostFunctions type myHostFunctions struct{} // 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の楽しさの一つだとも思っているので、使ってもらうための努力としてドキュメントはちゃんと書いておこう、というダブルスタンダードでやっています。