依存ライブラリ使って一定以上大きめのsbt pluginを作る時のいい感じの方法

sbtや、sbt pluginというのは所詮ただのScalaプログラムなので、普通に依存ライブラリが使えて、普通にScalaというか任意のJVM言語で書けば、そのまま呼び出せます。

しかし、普通に書いて普通に呼び出すのは、いくつかデメリットがあり得るので、代替として最近個人的に使うように至ったパターンの紹介をします。

普通にScalaを書いて、普通にsbt plugin内部で直接呼び出すデメリットとは

  • sbtの1.xはScala 2.12固定で作られているため、何か工夫しない限り、プログラムも全てScala 2.12で作る必要が出てくる
    • 古いversionで書くのが辛い、というだけならギリギリ許容したとしても、そもそもScala 2.12のサポートを打ち切ったライブラリを使いたい場合は原理上その方法は不可能になる
  • これはsbt pluginに限らない、任意の(JVMの)プログラムやライブラリで言えることだが、依存ライブラリの衝突などを考えると、ある意味では依存ライブラリは出来る限り少ない方が良い

などです。

これの解決策の1つとして、sbtには、裏で勝手にsub projectを生成する機能があります。 これを使えば、Scala 2.12に固定されるデメリットや、依存ライブラリの衝突の心配は、避けることが可能にみえます。

これらは、割とうまく動くように見えるのですが、これ書いてる2024年1月現在では、まずいくつかbug(かもしれないもの)があります。以下例

また、仮に上記のbug(かもしれないもの)が解決されたとしても、結局build全体のkeyやpluginの影響を意図せず受けたり、 ++ 2.13.12! などで全体のscala versionなどの状態を変えると影響を受ける可能性があって、完全に独立して動かそうとすると、個人的にはわかりづらく感じて、一時期試したことがあったけれど最近は使ってません。

また、そもそもsbt pluginとして作らず、普通のライブラリや単体で動くプログラムとして作ればいいのでは?という可能性もありますが、sbt pluginとして作りたい、という場合はどうしてもあるので、そこも考えないこととします。

(このblogの最後に、このパターンで作ったsbt pluginの例が載せてあるので、それ見るとsbt pluginとして作りたかった気持ちがわかる・・・かも?)

それがダメだとすると、他の手段としては、自分が思いつく限り大きく2つか3つくらい方法があると思います。

  • ClassLoaderで頑張る
  • 別のJVMプロセスを作る
  • バイトコードいじる系のツールでpackage変えて依存ライブラリ埋め込む(sbtの場合sbt-assemblyなど)

Scala使わずにJavaなどの他のJVM言語で書く、という方法はなくはないですが、結局それも依存ライブラリを色々使いたいなら衝突の可能性は回避できないし、そもそもScalaライブラリを使いたかったら無理なので、ここでは却下しておきます。

ClassLoaderで頑張ると、原理上は同じJVMプロセス内部なのに、完全に別のScala 2.13や3を使えたり、依存ライブラリの衝突を気にしなくて済むはずです。 詳細を把握してないですが、おそらく、sbt-scalafixなどのいくつかのsbt pluginは、この方式で頑張っているはずです。

バイトコードいじるツール使う方法も、sbt pluginに限らず割と使われてますが、色々デメリットもあるし、これがいいとも言い切れないので難しいところですね (詳細な説明は面倒になったので割愛)

別のJVMプロセスを作る方法ですが、プロセスを作るというのは

という問題やデメリットがありますが、独立して動く観点では、ある意味ではおそらく一番わかりやすい方法です。

ClassLoaderで頑張ってもいいのですが、それはそれで辛そうな側面があると思ったので、個人的に最近はプロセス作って通信するようになりました。

具体的なプロセス間の通信についてですが、ここは極論すると各自が使いやすいものならばなんでもいいと思いますが、例えば思いつくものをあげると

  • 独自にJavaの標準ライブラリだけでSocketで通信
  • httpやgRPCなど、少し高レベルなもので通信
  • 単に入力と出力を両方ファイルに書き出してやり取り

などがあると思います。(他にも方法は色々ありそう?)

結論から言うと、自分は最近

「単に入力と出力を両方ファイルに書き出してやり取り」

をやっています。適切に設計すると、outputのファイルを見ればいいので、scripted testも書きやすくなります。

他の方法を採用してないのは、そもそもリアルタイムで(相互の?複数回の?途中で人間の入力を待つようなインタラクティブな?)通信が必要ないならば、あるいはずっと常駐し続けてほしいようなものでないならば、それがある意味一番シンプルだと思ったからです。他には

  • Javaの標準ライブラリだけでSocketで通信: ファイル書き出すよりも、ある程度状態を持つことになるので、デバックやテスト面倒そう。途中で接続切れた時とかややこしそう
  • httpやgRPCやその他: 逆にそんなにリッチな機能が欲しいわけでもないし、そもそもJava 8などの範囲に限定しようとしたら、それらを使うためには結局別ライブラリの依存が必要になってしまうのでは?

などです。

書き出すファイルの形式も、これも極論するとなんでもいいですが、これはもうsbt関係ない一般的な観点になるのであまり詳細には書きませんが、例えば

  • JSON
    • 大抵これでやってます
    • JSONライブラリが必要になるが、sbt内部のものを無理やり使ってる
  • 単なる(独自形式の?)テキストやCSV
    • すごく単純ならそれでもいけるが、後から拡張すること考えるとJSONなどの、もう少し柔軟性のあるフォーマットの方がおすすめ
  • protobuf(などのバイナリも扱えたり、効率いいもの)
    • JSONでもbase64か何かでバイナリを埋め込むことは可能だが、まぁすごくパフォーマンスその他を重視したいならあり・・・?
    • ただし、(JSONと同様sbt内部の無理やり使う方法あり得るが)これは外部のライブラリに依存することに?sbtのものはscalapbではなくprotobuf-javaか?
  • def main の引数
    • これはそもそもファイルではないが
    • 値返したい時どうするの?問題は結局残る
    • 引数が数個ならギリギリ許容範囲だが、引数多くなりると色々大変。(parser書くのが大変、受け渡し側でそれに従った形式でミスなく渡すのが、jsonなどと比較して普通はやりにくい?)
    • 言い換えると、jsonに限らないが、何か、シリアライズとデシリアライズが整合性保って(型クラスで)同時に定義できる形式が一番楽な気がする

などです。

ここから先の実装詳細は、プロセスを別で立ち上げるといったら、単にひたすら scala.sys.process.Process 使ったり、 ScalaJavaの標準ライブラリの範囲でファイル操作しても、原理上は十分コードは書けるのですが、sbtには微妙に便利なものがあるので、それらを紹介して終わります。 手順としては

  • sbt内部のAPI(結局はcoursier)を使って、指定したsbtのversionのsbt-launcherのjarをいい感じに取得
    • これに限らず後半でもsbtで立ち上げればcoursier使われるので、余計に毎回jarをdownloadせずに、いい感じにcacheが使われる
  • sbtの IO.withTemporaryDirectory で、別プロセスを立ち上げるための一時ディレクトリ作成して、その中に処理つっこむ
  • build.sbt (必要なら plugin.sbt やその他)を、単純な文字列結合で作成
    • 文字列結合で作成は、ある意味では低レベルで残念だが、余計なsbtの内部構造を知らなくてもとにかく動かせるのは、割とメリットである、はず
  • input(引数となる情報全て)を、case classで作っておいて、SettingKeyやTaskKey経由で受け取り、それをjson変換して、決まった場所に書き出す
    • よほど巨大になるなどの特別な事情がない限り、入力も出力もこれは全部それぞれ1ファイルにまとめた方が、forkされて動くプロセスと、受け渡す側で、暗黙的な決まりが少なくなくなって良いと思う
    • どうしても内容が巨大になりそうなら、別途またそれぞれのファイルのpathだけ記述して渡すなどの工夫
  • sbt.Fork.javaForkOption といった、直接 scala.sys.process.Process を使うより多少便利なものがあるので、それらを使って事前に取得したsbt-launcherでsbtを起動して sbt runMain とか何かする
  • その別プロセスのプログラム内では「jsonをデシリアライズしてcase classに戻す、メインの処理行う、結果をまた決まった場所にjsonで吐き出す」という処理をする
  • 別プロセスの処理が終わったら、結果が吐き出されたはずのjsonを読み取って、sbtのtaskとしての値を返して終わり

といった流れです。 1つのgit repoで複数sub projectを作り、シリアライズ、デシリアライズのためのcase classやjsonの型クラスのインスタンス定義だけ、Scala 2.12とその他、必要に応じて使いたい新しいScalaのversionでcross buildしておけば、inputやoutputのやり取りで不整合が起きることはほぼないですし、その程度なら(慣れれば?)そこまで苦労なく作成できるようになります。 そういった、sub projectごとにbuildするscala versionが異なる場合の構成は、普通にcrossScalaVersionsで行うと辛いので、場合によってはsbt-projectmatrixを使うと良いでしょう。

https://github.com/sbt/sbt-projectmatrix

さらに細かい話に関しても書きたいことがなくもないのですが、大まかな流れは書いたので、一旦この程度にしておきます。 また、このパターンを使って作ったいくつかのsbt pluginの実例を貼っておきます