sbtの独自Configurationを使ったbuild速度の改善

突然ですが、皆さんsbtのchrome trace吐き出す機能使ってますか?大抵の人は使ってないというか、存在すら知らないと思います。

https://github.com/sbt/sbt/pull/4576

以前以下のあたりでも多少話を出しました

https://xuwei-k.hatenablog.com/entry/2022/03/30/142529

さて、それを使ってsbtのbuildのsub project毎のcompile速度を眺めて、頭の中でDAGを描いてみたときに、稀に、

「testのためのユーティリティを参照させるために test->test を追加しているが、そのせいで遅い」

というパターンがあると思います。

ここに共感してもらわないと話が始まらないのですが、そこを詳しく説明すると長くなるので、多少、大雑把な説明で済ませますが、

https://github.com/xuwei-k/sbt-own-Configuration-example/commit/dd6ac409f5fc01fb12191401a8abf753a2013d17

build.sbtが以下の構成

val common = Def.settings(
  scalaVersion := "2.13.10",
  libraryDependencies += "org.scalacheck" %% "scalacheck" % "1.17.0" % Test
)

val a = project
  .settings(
    common
  )

val b = project
  .settings(
    common
  )
  .dependsOn(
    a % "compile->compile;test->test"
  )

ファイル一覧は以下(すごく単純化してあるので、本当はもっと多いが)

  • a/src/main/scala/A.scala
    • sub project a におけるmainのファイル
  • a/src/test/scala/ASpec.scala
    • A に対する普通のテスト。他のsub projectからは参照されない
  • a/src/test/scala/TestInstances.scala
    • A のscalacheckのArbitraryのinstanceなどが入る。sub projectのb などからも参照される
  • b/src/main/scala/B.scala
    • sub project b におけるmainのファイル。A を参照するかもしれない
  • b/src/test/scala/BSpec.scala
    • Bのテスト。sub project aTestInstances も参照する

こういうパターンの場合 dependsOn で test->test があるのは自然なのですが、よく見ると、上記のパターンでは細かい無駄がありますね?

無駄とは

「ASpec.scalaコンパイルは待たずに、sub project bTest/compile は開始可能」

(TestInstances.scalaコンパイルは待つ必要はある)

です。

こんな無駄が有意なボトルネックになることはあまり多くないのですが、とはいえ数十万行もあるprojectだと、たまにそういうことが発生します。

また、例として scalacheckのArbitraryのinstance と言いましたが、別に複数のsub project間で参照したいもので、しかしmain側に置きたくないものならば、なんでも良いです。 例えば、テスト用の共通の親classやtraitを作ることはよくあるでしょう。

さて、これの解決策としては、例えば以下のようなものもあります

  • main側にscalacheckの依存を追加してしまって、コンパニオンにArbitraryのinstance定義してしまう
    • 定義場所が一意に定まってわかりやすい?
    • main側に追加するデメリット
    • sbtに詳しくなる必要はないメリット
  • ボトルネックになるのならば、専用のsub projectを追加で作る
    • 手動で作るのは面倒
    • 必要なところだけ作るならば、どこがボトルネックなのか?を考える必要がある
    • あるいは一括で全部作る(sub project数が倍増する)、というデメリット

それら以外の解決策として、タイトルの

sbtの独自Configuration

を、ここから解説していきます

Configuration とは、sbtにそういう名前のclassが存在します

Configurationの具体的なinstanceとしては

  • Compile
  • Test
  • Runtime
  • Provided

などです。

さて、これは独自に作って拡張することが可能です。以下が実際のコード例です。

https://github.com/xuwei-k/sbt-own-Configuration-example/commit/280dfb91c5ecaa6bc5ab34706524c0527df4631d

+val TestShared = Configuration.of("TestShared", "test-shared") extend Compile
+
 val common = Def.settings(
   scalaVersion := "2.13.10",
-  libraryDependencies += "org.scalacheck" %% "scalacheck" % "1.17.0" % Test
+  libraryDependencies += "org.scalacheck" %% "scalacheck" % "1.17.0" % "test,test-shared",
+  Test / internalDependencyClasspath ++= (TestShared / fullClasspath).value,
+  TestShared / internalDependencyClasspath ++= (Compile / fullClasspath).value,
+  inConfig(TestShared)(Defaults.compileSettings)
 )
 
 val a = project
+  .configs(TestShared)
   .settings(
     common
   )
 
 val b = project
+  .configs(TestShared)
   .settings(
     common
   )
   .dependsOn(
-    a % "compile->compile;test->test"
+    a % "compile->compile;test-shared->test-shared"
   )
  • 独自Configugationを作る
    • 今回はTestSharedとしたが、名前はある程度任意に設定可能
    • extend Compile 部分が最善なのか?が謎
  • それをsub projectにconfigsで追加する
  • libraryDependenciesで % Test としてるものも、必要に応じて変更
  • test間で共通で参照したいファイルは src/test/scala から src/test-shared/scala へ移動する
    • 共通で参照しないものは src/test/scala に残す
  • Compile -> TestShared -> Test となるようにclasspathなど設定する
    • ここの設定が最善なのか?が怪しい
  • sub-project間の依存は、基本的に test->test は全てやめて、代わりに test-shared->test-shared を追加する

こうすることにより

「ASpec.scalaコンパイルは待たずに、sub project bTest/compile は開始可能」

という問題点が解消されました。

実例だと、この仕組みを入れた場合、某社のcompile時間が、おそらく最大1〜2割程度縮む可能性はあります。 (まだ入れるかどうか未決定)

1〜2割程度縮む代わりに「sbtの独自Configuration」というあまり見慣れない複雑な仕組みを入れることになるので、このトレードオフをどう捉えるか?によって導入するべきか考えましょう。 例えば、scalafixAllやscalafmtAllなどが test-shared についても同様に動くするようにするために、追加の設定が必要になるなど、結構なsbtの知識が要求されることになります。

ちなみに独自Configurationといえば、以前こういう記事も書きました

https://xuwei-k.hatenablog.com/entry/2022/06/17/143359