goはランタイムという発想のミニフレームワークを作り始めた
goはランタイムという発想のミニフレームワークを作り始めた。まだ全然完成はしていないけれど、どういう方針で作っているかなどをメモをしておく。
フレームワークと言いつつ、現状ではそこまでいろんなことをやってくれるという感じではない。あと使い方などはこれからもどんどん変わると思う。加えて依存するライブラリのまだマージされていないブランチの機能を利用していたりなど、現状では自分の環境以外で手軽に動かせるというようにはなっていない。
動機
もともとの動機などを書いてみることにする。コンセプトとしては概ね以下のような感じ。
- コード生成はIaC (Infrastructure as Code) におけるデプロイのようなもの
- メタプログラミングをpreprocessのタイミングで行い実行時には行わない
- アプリケーションコードとはランタイムのこと
一昔前からIaCなどと言う言葉が使われていて、ここでのデプロイは、特定のフォーマット(やコード)で定義された状態に、インフラの状態を遷移させることだと思っているのだけれど、実はこれと似たような形の試みがアプリケーションコードを書いているときにも起きているのではないか?というのが元々の発端。
仕様からのコード生成
例えば、openAPI(swagger)やgrpc、GraphQLを元にしたコード生成(コード出力)などをしているときにそのように感じる。一方で、これらのコード生成はエコシステムに閉じた形の生成結果となり、結局手元のコードとの接触面では、頑張ってつなぎ直さなければいけないというようなことがままある。
あるところでは、protocol buffersの定義部分だけを別リポジトリに置いておき、各マイクロサービスはそれを参照し内部のコードを生成し、これを利用するなどということをしているらしい。これは一定機能すると思う。
openAPIなどにも仕様からコードを生成するようなツールが存在したが、結局必要となるリソースを集める事ができなく十分にメンテされているようなツールは存在していない。仕様からのコード生成を試すと通信が必要な2つのコードを別途完成させる必要がある。一方コードからの仕様(ドキュメント)生成の場合には、必ず1つは完動するコードが存在しているので手軽。最近ではこちらのほうが現実的で主流になっているような気がする。
また各言語の利用者は、自身の得意な言語以外に依存したくないなどの理由で、openapi-generatorのような特定の言語(Java)を全員に要求するようなツールへの貢献はしたがらない。
(正確に言えば、ほとんどすべてのツールにおいてフリーライダー的な振る舞いをする人がほとんどで、通信可能な共通部分を広げる行為は特定のコアコミッターの消耗によって成し遂げられていた。また、その時のリソースの見積もりは、それらのツールがずっと人気であることを念頭におかれていたように思う。飽きというわけではないが早晩リソースは枯渇していた)
WAFの生産性とフットプリントの大きさ
あるいは、古き良きWAFを考えてみたときに、特定の言語や特定のWAFの範囲でだけ便利な一方で、他のシステムと繋ごうとしたときに苦労するということが良くあった(自分の経験上、Djangoなどを思い浮かべて言っている)。もちろんコード上で密結合にするということはせずに、マイクロサービスなどにして、通信部分は何らかの仕様を元に各自が実装すると言うように行う。ただ、ここで、何かしらの定義を到るところで使いまわしたいと感じる事があり、この不足部分をWAF上のコアとなる定義(たとえばモデル)を元に出力しようにも、それらを読み込むフットプリントが大きく、実用に耐えない。
このようなある機能や定義が便利でいられる領域をエコシステムと呼ぶとすると、エコシステム内での利便性の恩恵を受けつつも、それとは異なるエコシステム間のコミュニケーションを良い感じにやっていきたいと感じていた。
コミュニティーのクックブック
また、仮にあるエコシステム上での便利機能が使えたとして、その機能が依存する機能のすべてが欲しいとなることはあまり存在しない。例えばこれらの機能のセットを機能群と呼ぶとすると、ある機能群Xと別の機能群Yを組み合わせてなどという形でどんどん組み合わせていった場合に依存が依存を呼び肥大化してしまう。依存の肥大化は実行の読めなさや接続の難しさフットプリントの大きさなどまぁよくあるめんどくさい話を引き寄せる。
あるいは、頑張って汎用化しようとしたクックブックが、ただただ膨大なパラメーターを受け取って渡すだけのようなものになってしまい、柔軟さを意識した結果、元のミドルウェアの設定方法と共にそのwrapしたクックブックの設定方法を覚える必要が出てくることがある。またパラメーターのバケツリレーが発生してしまい、本当に設定を適用したかった層にたどり着くまでにものすごい苦労が生じるような構成にしてしまう場合がある。
プロビジョニングのツールの時代から、本番用のデプロイにおいてはコミュニティーのクックブックを捨てて自前で最小限のレシピを書くというのが一種のベストプラクティスになっている(と思っている)。これはコンテナのイメージについても同様という認識。
基本的には便利なイメージやIaCの設定例はプロトタイピングのためのもので本番には使えないと思っている。
POXOを利用したい
また、ある設定を入力とした変換を行いたいものの、YAMLのような設定ファイルを利用しようとした場合には機能の不足に悩まされる事になった。
- 定義の煩雑さ
- エラーレポーティングの不親切さ
- モジュールシステムなどの不在
これらはそのまま既存の言語を使えば無料でついてくる。フットプリントをへらすためにPOXO(Plain Old X Object)を使いたい。
どの言語を使いたいかは、結局リソースが無限にあるなら、それ用のDSLを作ったり、自分の好きな言語を使うのが一番手軽ではあるのだけれど。現実的ではない。何よりマーケティングが上手くいくとは思えないので。
なんでpython?
pythonで作ろうとした理由はいくつか有るのだけれど。主要な理由はこの3つ。
- 型(type hints)が値
- 慣れている
- 全ての依存をたどることなく浅くだけ辿りたい場合がある
基本的には、元となるseed的なものを埋めるというような操作が欲しくなって来る。なのでTypeScriptの型パズルで行うような型のフィールドをiterateしたり、2つの型の差集合を取ったり、というようなことを型チェック用ではなく、コード生成用のコードで利用したい。ある元となる型があればderiving xxxという形で宣言的に拡張していけるのだけれど。その元となる型定義を各エコシステム上に配りたい。
通常POXOを受け取って何かをするような機能は、ASTなどの特別なオブジェクトを扱う事になりそうな一方、pythonは型が値なので、実行時のフルの機能やライブラリがそのまま使える(もちろんcompile APIなどが揃っているような言語なら不可能というわけではないのでやっぱり好みの問題ということになるかもしれないけれど)。ここで大切なのは言語の機能ではなくライブラリという点。なので埋め込み言語を組み込みました柔軟にハンドリングできますというのはあんまり嬉しさにつながらない。
静的な言語を使わない理由は、依存の全てを読み込みたくない場合があるから。整合性のチェックは後のステップで実行されるので壊れた状態でも出力できるという状況が嬉しい。不要な依存を読み込まずに済めば済むほど良い。きれいなコードや遅延ロードがきれいに行えているツールが揃っていれば、もちろん静的な言語でも上手く行くのだけれど。対象となるコードは肥大化した依存を抱える事が多い。
あと、その場でビルドせずに実行できるという点は時々役に立つ。特にコード生成のようなものは微妙な調整をその時だけ試したいというようなことが結構あって、そのようなときに緊急脱出ハッチのような形で書き換えて実行できると便利なことがある。
ランタイムとしてのgo
compile-time DI
goは書くのが辛い一方でランタイムとしての信用度は割とすごい。おそらくこのあたりの辛さはミドルウェアやCLIのツールを開発しているときにはあまり感じず、アプリケーションコードがメインになってくると感じる様になってくる。それも少量のコードを丹念に書いているときにはあまり感じず、実装がそれほど複雑ではないものの大量のコードをメンテしようとしているときに辛さは際立ってくる(ここでの少量や大量はコードサイズというよりはmain.goの数としてしまって良い)。
特に個人的にはmain.goでの依存の注入が難点で、main.goが数十や数百あるような環境で、特定のコンポーネントの設定を別の方法に書き換えるのが辛い。これへの対応として、万人におすすめすると言う触れ込みではないものの、wireなどと言ったツールは存在している。
wireの「Compile-time Dependency Injection for Go 」という発想の機能は個人的にはパッケージの依存関係を気にする分には欲しくなる。interfaceで覆って実装を隠したとしても、その初期化のためのコードをどこに持つかというのは悩ましい。変に密結合したパッケージを手軽さのために作ってしまったときに、全てのバイナリが不要な依存を持つというようなことが発生してしまうことがある。あるいは最初のうちはinterface自体を使用したくない。パッケージとしての依存は切断したい一方で、各コンポーネントの依存はいい感じに作り上げられてほしい。この部分をコード生成といった形で対応するのはありだと思っている。
また、例えばミドルウェアへの接続部分は import (_ <package path>)
という形式で特定のミドルウェアに沿ったパッケージを別途読み込み、個々のミドルウェアに依存しないコードを書くというようなことが行われている(実際にはバイナリは特定のパッケージに依存している)。
これらが調整可能になっていてほしい。調整可能という表現が適切かは微妙なところだけれど。
- 関連する箇所の変更が伝搬されるので一箇所で良い
- 変更時の修正箇所が非常に少ない
みたいな意味合い。複数の選択肢があってそのどちらも試したい場合に調整可能であってほしくなる。
この小さなバイナリのために依存を断ち切りたいという気持ちと、調整可能になっていてほしいと言う部分がランタイムとしてのgoに影響を与えている。
(あとメタプログラミングは調整不能・出力結果をキャッシュとして使いたいみたいな話も別途有るが省略)
すごく雑に言えば、main.goのメンテが感覚的に O(1)
と感じるか O(N)
と感じるかの境界があって、O(N)
と感じ始めたときに、変更のための苦痛が試してみようという好奇心を押しつぶしてそこで立ち止まるというようなことが起きるようになる。
CLIを作るのも辛い
調整可能と言う文脈はもう少し話を広げると、何かを試してみるときのコストが安いと言う話になる。例えば、複数の可能性があるときにその両方を試したい、そして頭が良くないので(?)実際に実行して結果を確認してみたい。
同様に、何らかの探索的な行為を行うときに、それが必要になるか不要になるかわからずとりあえず試してみたい。そしてその動作は再現可能であってほしい。再現可能であってほしいということはGUIによる操作などは含まれてほしくない。
(GUIによる操作はデバッグのときにデバッガーを立ち上げることに似ている。必ず発生する有る瞬間にたどり着くためには便利だが、多数の入力を試してみてある入力のときだけ起こるような事象に関しては不適。例えばそのような場合にはログを出力しまくりgrepというような方法のほうが上手くいく)。
例えば、pythonであれば、typerや自作したhandofcatsを使えば、関数定義がそのままコマンドになる。
(typerのhello world)
import typer def main(name: str): typer.echo(f"Hello {name}") if __name__ == "__main__": typer.run(main)
(handofcatsのhellow world (特にオススメというわけではない))
from handofcats import as_command @as_command def run(*, name: str): print(f"Hello {name}")
引数が新たに必要になったら追加するのも手軽。このスクリプトは後々も必要になるかどうかもわからない。使われるようになるかもしれないが、それより捨てられることのほうが多い。
考えてみると、必ず役に立つとわかっているようなコードを作ろうとした場合には、素のgoでも全然便利なのだけれど。必要か不要かもわからないようなコマンドをポコポコと作りまくろうとした場合には困るということなのかもしれない。
このあたりを調整可能にしたい。というような目的があった。
後々の話
おそらく後々の話としては、CLIを作る機能をもう少しきれいにまとめたあとに、サブプロジェクト的に以下の様な名前のものを作ろうかなーと思っていたりする。
ただ、web APIを作るからegoistic-apiというわけではなく、http.Handler
をいい感じに作るためのDIだけですむならegoistic-cliだけで十分という感じになると思っている。
コード生成と言っても、あれもこれも生成してもあんまり嬉しさがないということは分かっていて、何らかの参照関係がある部分をいい感じにつなげてくれるものが嬉しい。そこだけに注力できたほうが良い。
既存のOSSはコミュニティーのクックブックと言うふうに捉えるとすると、各自が自分のフレームワーク(コード生成)を作れるようになっているような形が良い。なので、プロダクションでは最小限の自作のレシピをということを念頭に入れると、これらはプロジェクトと呼ぶ一方である意味exampleでしかないと言うような位置づけのものになるかもしれない。
いきなり全てをこれで管理するということを要求するようなものは無理筋なのでそれはやらない。 あと小さく始められることは大切だし、最終的には捨てられるということは維持していきたい。