sbtの密結合な内部アーキテクチャ

sbtというと、独特なSettingのシステム*1や、Scalaで記述する内部DSL*2ばかりが注目されがちです。それらは、初心者にわかりづらかったりして批判されることが多かったり、逆にsbtを使い慣れた人にとってはとても強力で面白い仕組みです。

Settingのシステムに注目すると、汎用的に色々な言語のビルドにも使えそうに思えます。事実、sbtでC++のpluginを作っている人もいます。


しかし、sbtはあくまで「Scala(とJava)のためのビルドツール」です。

これは「単にScalaをデフォルトでサポートしてる」という意味にとどまらず、おそらく皆さんが思っているよりもずっと深い意味で「Scalaに特化したビルドの仕組み」が内部に備わっています。


今回は、そんな「sbtの内部アーキテクチャ」の紹介をします。


以下、かなり長いです。読み物としては面白いかもしれませんが、単にsbtを使う上では直接必要な知識ではないので、そういった内部構造に興味が無い人は読むのをやめたほうがいいです。ただ、最後のほうにGradleなどのと根本的な違いを語っているので、そのあたりに興味がある人は、結論だけでも読むと面白いかもしれません。



これから説明する仕組みは、かなり古くから*3存在し、これからもしばらくは変わらないはずですが、細かい差異があるかもしれないので、特にsbtのversionを明記しない場合は、一応これを書いている時点での最新安定版である0.13.1*4について言及しているものとします。



さて、まず表題の「密結合」から説明していきましょう。なにが「密結合」かというと、sbtは「Scalaコンパイラと密結合」です。その「Scalaコンパイラとの結合部分」が、とてもおもしろい仕組みになっています。



ところで「密結合」という言葉は、プログラミングにおいて、いい意味で使われることのほうが少ないものでしょう。一般的にコンポーネント同士は疎結合にしたほうがいい、と言われてると思います。


しかし、繰り返しになりますが「Scalaコンパイラとsbtは密結合」です。なぜ密結合になっているのか?というと、もちろん理由があります。

その理由とは
「インクリメンタルコンパイル
です。 *5




ここから
「なぜインクリメンタルコンパイルするのに密結合である必要があるのか?」
「具体的にどのようにsbtとScalaコンパイラは結合しているのか?」
という説明をしていきます。


sbtとScalaコンパイラの関係を説明していく上で、重要なのは以下の4点です。

  • sbt本体は基本的にScalaで書かれている*6
  • sbtは複数versionのScalaのビルドに対応している*7
  • sbtは「ソースコードレベルで直接」Scalaコンパイラに依存している
  • sbt本体はクロスビルドされていない(例えばsbt0.13.xは、基本的にScala2.10.xで作られている)


1番目と2番目は、ほとんどの人は知っているというか、当たり前だと思うでしょう。
また、4番目もある程度sbtを使ったことある人ならば、知っていると思います。

しかし、3番目がポイントです。そして、3番目と「その他の1,2,4番目」を両立されるための仕組みこそ、sbt内部が独特なアーキテクチャになった原因です。



では3番目の「ソースコードレベルで直接Scalaコンパイラに依存」とはどういうことかを説明していきましょう。

Scalaコンパイラは、当たり前ですが特定のclass(scala.tools.nsc.Mainなど)に引数を渡せば基本的にすべてのコンパイルオプションを設定できるので、「sbtがソースコードレベルで直接コンパイラに依存」する必然性はありません。
*8

しかしsbtは先ほど言ったように「インクリメンタルコンパイル」のために、あえて「ソースコードレベルで直接コンパイラに依存」しています。*9
ソースコードレベルで直接依存して、コンパイラAPIに触って情報を取り出したほうが、色々なことができるからです。また、sbtは「複数versionのScala」に対応する必要があるので「複数versionのScalaコンパイラにそれぞれ依存」する必要があります。

ここで「複数versionのScalaコンパイラにそれぞれ依存」と書きましたが、一方先ほど「sbtはクロスビルドされていない」とも書きました。


一体これはどういうことでしょうか?


つまり、sbtには
「それぞれのScalaのversionのコンパイラに依存する部分(言い換えるとクロスビルドされている部分)」

「それ以外の、特定のversionのScala*10のみで書かれている部分」
という2つの部分があります。


さらに、上記2つの部分を橋渡しするものが必要になります。*11これは、どんなものでもいいのですが*12Scalaで書くわけにはいきません。*13
そして、その橋渡しの部分はJavaで書かれています!*14


まとめると、sbtは以下のように3つに分けることができるということです。*15
また、これら3つは、パッケージがそれぞれxsbt, xsbti, sbtと分かれています

  • xsbt *16
  • xsbti
    • Javaで書かれた部分。xsbtとsbt部分の橋渡し役 *18 *19
  • sbt
    • 特定のScalaのversion(sbt0.13.xならScala2.10.xというように)で書かれたメインの部分*20

以下では、この3つの部分のどれかを指し示す際にパッケージ名にちなんで、xsbti, xsbt, sbtと呼ぶことにします。*21

sbtのソースコードを読むときがあったとしても、ほとんどの人は、パッケージ名がsbtのメイン部分しか読んだことがないはずです。よほどのことが無い限り、xsbtiやxsbtの部分は知る必要がないからです。「知る必要がない」というのは、sbt plugin作者でさえ同様です。

また、xsbtiのJavaでできた自動生成された部分は、必要がない限りあまり変更されません。


ここで「Javaで作られたxsbtiの橋渡しの部分」がどんな情報を伝達しているのか?を説明をしていきましょう。

xsbti部分の自動生成されたソースコード*22をみてもらうとわかりますが、この部分は結構コード量が多いです。*23
「どんな情報を伝達しているのか?」というと、「コンパイル後のScalaの情報」です。「コンパイル後のScalaの情報」といってもわかりにくいと思う*24ので、例えば具体的には

  • classの名前
  • そのclassの親classやtrait
  • そのclassのメソッド一覧
  • それぞれのメソッドの情報(publicか?implicitはついてるか?引数や戻り値型は?)

などです。これらすべてをDSLから自動生成されたJavaのclassで表現しています。

先ほど「xsbtiの部分は、必要がない限りあまり変更されません」といいました。これはつまり「Scalaの言語仕様が大幅に拡張された場合以外は拡張されない」ということです。
例えば、具体的な最近の変更だと、Scala2.10でマクロが入ったことにより、マクロ関連の情報をあらわすものが加えられたことがあります。



さて、xsbtiの部分を簡単に説明したので、次はxsbtの説明にいきましょう。
xsbtの役割は、ソースコードレベルで複数versionのコンパイラに依存して、コンパイラから取り出した情報を「xsbtiのJavaで作られた部分」に変換して、メインのsbt部分に受け渡すことです。


ソースコードレベルで複数versionのコンパイラに依存」ということは、普通のライブラリをクロスビルドするのと同様に、あまりにも古いversionをサポートするのは無理です。
以前blogに、「普通は2世代、頑張って3世代サポートするくらい」と書いたことがあります。
Scalaのversion間の非互換について具体的に考える


実際sbt0.13.1でサポートされているのはScala2.8.1以上です*25もちろん2.11.xもサポートする予定でしょうから、もしsbt0.13.xでは2.8.xを切り捨てないのであれば、最終的に「2.8、2.9、2.10、2.11」と4世代でクロスビルドすることになります。

ところで、Scalaのライブラリ側(scala-library.jarのこと)と比べると、コンパイラ側(scala-compiler.jar)のほうが互換性が壊れやすい傾向にあります。*26 *27
かつては、コンパイラ側の変更のせいで、最新versionのScalaとxsbtの互換性が壊れるという事態も発生しました


しかし、sbtがtypesafeの公式なプロダクトになってから、この辺りはコンパイラチームとかなり協力するようになり、最近はそういうこともなくなっています。*28


とはいっても、コンパイラ側のAPIは変わっていくし、4世代クロスビルドはかなり面倒で、xsbtの部分では他の普通のライブラリ以上に地味な努力をしています。*29
数カ所に「version◯◯のScalaにはこのメソッドが存在するが、△△には存在しないので、こうなってる」というような説明のコメントがかいてあります。以下具体例

このxsbt部分は

  • 新しいScalaのversionがでたら追随していかなくてはならない
  • 何世代にも渡ってずっとサポートはできないので、古いversionを切り捨てる必要がでてくる*30

という意味で、かなり大きなデメリットがあり危険です。新しいScalaのversionがでてコンパイラ側が変わるたびにメンテナンスするのは、相当コストかかります。

このデメリットは、単純に「scala.tools.nsc.Mainのみを呼ぶ」*31というかたちにすれば、ほぼ完全に回避できる部分です。
実際に(最近はzinc使う関係でそうでもないですが)基本的にmavenScalaプラグインや、GradleのScalaプラグインなどの、sbt以外のビルドツールのプラグインは、単に「scala.tools.nsc.Mainなどを呼ぶ」というかたちになっているはずです。*32
*33


このデメリットを覚悟してまで「Scalaコンパイラに直接依存して情報を取り出す」ということは、それを上回る大きなメリットがあるということなのでしょう。個人的な意見としては、sbtは良くも悪くも「やりすぎ」であり、ある意味コンパイラ側の仕事に足を突っ込んでいる感じがあります。*34
しかし、いまさらこの密結合をやめるわけにもいかず、むしろ成功しているので更にsbtでのインクリメンタルコンパイルを頑張る傾向にあり、逆に最近では「typesafeのScalaコンパイラ側のチームが、sbtの開発にどんどん積極的に参加する」という流れになっていますし「そのsbtの仕組みをラップしたzincというものを作り、gradleやmavenのpluginからもインクリメンタルコンパイルできるようにしよう」という流れになっています。




さて、大体の全体像は説明し尽くしたでしょうか。
本当は
「xsbtが生成して、xsbtiのJava部分を通して受け取った情報を、具体的にどのようにインクリメンタルコンパイルにいかしているのか?」
という部分も説明できればよかったのですが、そのあたりはあまりまだ詳しくないですし、それを説明したらまたすごく長くなるので、今回はここまでにしておきましょう。また、xsbtの部分は「compiler-inteface」と呼ばれていて、それが動的にコンパイルされるという面白い仕組みが存在していたりと、まだまだ語り尽くせないこともあります。


ただし、その「どのようにインクリメンタルコンパイルに活かすのか?」
という部分については、まだまだ改善の余地があるらしく、sbt-devのメーリングリストでそのあたりが活発に議論されています。また、typesafeのScalaコンパイラチームの人を含め、以前よりも外部の人がそのあたりに積極的に関わるようになっていて*35、大幅に改善がされている最中*36なので、今後に期待できる部分です。



さて、このようにsbtはインクリメンタルコンパイル改良のための必要悪として「Scalaコンパイラと危険な密結合」をしています。こういうアーキテクチャはあまり例がないのではないでしょうか?(自分が知らないだけかも。あったら教えて下さい)
*37


さて、一つ重要な点として、このような密結合をしてsbtがインクリメンタルコンパイルを頑張っているならば
「ほかのビルドツールがsbtにインクリメンタルコンパイルの速度で勝てるわけがない」
というのがあります。
最近ではzincを通してこのようなsbtのインクリメンタルコンパイルの仕組みを他のビルドツールでも使えるようになってきている*38のかもしれません。しかしそうだとしても、「最新の成果をいちはやく試せる」という意味においては、sbtに絶対的な優位性があるはずです。


よく、Gradleとsbtを比べて「Gradleにはクロスビルドの機能がない」という話を聞きます。もちろんそれも重要なことです。
ですが、個人的には「クロスビルド」の機能なんてものは「今回説明した、独特なScalaコンパイラとsbtの密結合な関係」に比べたら、表面的に少し頑張ればできることです。*39

それよりも、Gradleやmaven含め、sbtとの最も根本的な違いは、「今回説明したこの密結合な仕組み(とそれによる、インクリメンタルコンパイルの絶対的優位性)」だと個人的には思うのです。


というわけで、だいぶ長くなりましたが、最後に「sbtはGradleやmavenScalaプラグインより頑張ってるので原理的に勝てるわけないんだぞ」という自慢(?)をして終わりにします。こんな長い記事をここまで読んでくれてありがとうございました。

*1:例えばこれ http://eed3si9n.com/ja/node/149 とか読んでみるといいと思う

*2: 例えば %% や、 <++= などといった記号メソッドを使ったり、マクロ使ったりなど

*3:少なくとも0.7.7より前

*4:これ書いた日くらいにちょうど0.13.1.finalでた

*5:この部分実は少し自信がないのですが。インクリメンタルコンパイルはもちろん大きな理由の一つでしょうけど、他に何かあったというか、もっと適切な言葉ないかな・・・

*6:後述しますが、Java部分もそれなりにあります

*7:sbt0.13.1現在で対応しているのは、2.8.x(2.8.0を除く)、2.9.x、2.10.x、2.11.x。 build.sbtにscalaVersion := "2.10.3" などと書くことによって切り替えることができる

*8:つまり、そこから引数を渡すだけにしたほうが明らかに疎結合でシンプルですが、sbtはそうなっていません

*9:例えばこのように https://github.com/sbt/sbt/blob/v0.13.1/compile/interface/src/main/scala/xsbt/CompilerInterface.scala#L8 直接コンパイラ内部のclassを参照しているということ。scala.tools.nscパッケージ以下は、Scalaコンパイラのclass

*10:たとえば、sbt0.13.xなら、Scala2.10.xというように

*11:橋渡しなどせずに、複数versionのコンパイラに依存するなら、「全部の部分をクロスビルドすればいいのでは?」という考えもあるかもしれません。しかし、多分それはほぼ不可能というかデメリットが多すぎます。ただでさえクロスビルド大変なので、クロスビルドする部分は最小限に抑えたいわけです。また、Scala2.8〜2.10でクロスビルドするとしたら、2.9や2.10の機能が使えず、それはかなり厳しいです。「2.9や2.10の機能が使えない」というのは、build.scalaを書くユーザー側にも影響するでしょう

*12:フォーマットさえ決まっていれば、たとえばxmlとかjsonでもよかったはず

*13:異なるversionのScala間で使われる部分のため

*14:詳しく語ると、この部分だけでとても長くなるのでやめておきますが、そのJavaコードの大半は外部DSLから完全に自動生成されています。生成元の定義ファイル例→ https://github.com/sbt/sbt/blob/v0.13.1/interface/type 生成後のJavaファイル例→ https://github.com/sbt/sbt.github.com/tree/136f16/0.13.1/sxr/xsbti/api

*15:もっと細かく分けることが可能、launcherに関連するところが抜けてるなど、実はもっと語りたいことが色々ありますが・・・

*16:正確には、xsbt全部がそうではなく、xsbt.bootパッケージはlauncher関連だったりするが

*17:言い換えると、クロスビルドされている部分

*18:外部DSLから自動生成されてる部分と、そうでない部分に分けることができる。自動生成以外の部分は、例えばこのあたり https://github.com/sbt/sbt/tree/v0.13.1/interface/src/main/java/xsbti/api

*19:xsbtと同様に、xsbtiというパッケージすべてが今回説明する「橋渡し役」というわけではなく、このあたり https://github.com/sbt/sbt/tree/v0.13.1/launch/interface/src/main/java/xsbtiJavaで書かれているが、launcher関連で別のモジュールである

*20:以前とりあげた http://d.hatena.ne.jp/xuwei/20120421/1335039576 Keys.scala, Defaults.scalaなどはここ

*21:ここから先、単に"sbt"といった場合に、「その3のうちの1つとしてのsbt」のことなのか、「sbtというソフトウェア全体」を指すのかわかりずらいですが、頑張って文脈から汲み取ってください

*22:自動生成だから直接みれなくて、sxrを見るしか無い・・・。さらに不幸なことに、自動生成されたコードはsxrの一覧ページ→ http://www.scala-sbt.org/0.13.1/sxr/ にリストアップされないというsxrのバグ?があり不便

*23:sbt0.13.1現在、 "package xsbti.api"の自動生成されたファイルは52ファイル

*24:いい言葉思いつかなかった・・・

*25: 例えば sbt0.13.1 で 「scalaVersion := "2.7.7" 」としてみるとわかりますが、「compiler-interfaceのコンパイルに失敗」します。

*26:ライブラリはかなり多くの人が使うが、コンパイラ側は、コンパイラプラグイン書く人くらいしか使わないため

*27:後述のように、最近はScalaコンパイラの中の人が気をつかっているので、正確には「互換性が壊れやすい傾向にあります」ではなく「互換性が壊れやすい傾向にありますが、sbtに関連するところは互換性を保つように出来る限り努力するようになっています」かもしれない

*28:詳しく調べてないが、多分現在はjenkinsでこのあたりもCIやってるはず。これ書いてる現在、run-sbt-on-scala-snapshotという名前のjobがあるので、多分これ https://scala-webapps.epfl.ch/jenkins/job/run-sbt-on-scala-snapshot/

*29:たとえばこのファイル https://github.com/sbt/sbt/blob/v0.13.1/compile/interface/src/main/scala/xsbt/Compat.scala とか。コメントに "Collection of hacks that make it possible for the compiler interface to stay source compatible with Scala compiler 2.9, 2.10 and 2.11." と書いてある

*30:つまり、近い将来(sbt0.14かな?)にはScala2.8.xのビルドはサポートされなくなるかもしれません。ちなみにchangelogによると、sbt0.10.1から0.11.0になったときに、Scala2.7のサポートはdropされたらしいです http://www.scala-sbt.org/0.13.1/docs/Community/Changes#to-0-11-0

*31:実際、ディストリビューションに添付されてるシェルスクリプトもしくはバッチファイルのscalacは、これを呼び出しています

*32:このあたりだろうか? https://github.com/davidB/scala-maven-plugin/blob/v3.1.0/src/main/java/scala_maven/ScalaContinuousCompileMojo.java#L60-L61 少なくとも、"scala.tools.nsc" という、Scalaコンパイラのパッケージ名でmaven pluginをgrepしても、文字列としていくつか埋め込まれているだけで、直接それらをソースコードレベルで参照してる部分は存在しない。

*33:このあたりzincの連携のあたりあまり詳しくない・・・。気が向いたらいつか調べて書きたい

*34:まぁコンパイラ側がやってくれないので、sbt側でやるしかないのですが

*35:逆に昔は、そのあたりをharrahさんが完全に一人で作っていた感じ

*36:sbt0.13.0から0.13.1にかけても、そのあたりある程度改善があったみたい

*37:こんなツッコミ?をもらいましたが、たしかにJavaの場合はあれですね・・・ https://twitter.com/yasushia/status/410796774744281088

*38:すいません、このあたりのよくわかってません。zincを利用したとしても、sbtと全く同等の効果的なインクリメンタルコンパイルが可能なのか?とか

*39:なんでクロスビルドの機能入れるのを頑張ってないのでしょう?誰か詳しい人いたら教えて