sbt 0.13 を用いた四次元空間内の移動
警告: この sbt についての覚え書きは中級ユーザ向けだ。
セッティングシステム
sbt 0.12 同様に sbt 0.13 の中心にあるのはセッティングシステムだ。Settings.scala を見てみよう:
trait Init[Scope] {
...
final case class ScopedKey[T](
scope: Scope,
key: AttributeKey[T]) extends KeyedInitialize[T] {
...
}
sealed trait Initialize[T] {
def dependencies: Seq[ScopedKey[_]]
def evaluate(map: Settings[Scope]): T
...
}
sealed class Setting[T] private[Init](
val key: ScopedKey[T],
val init: Initialize[T],
val pos: SourcePosition) extends SettingsDefinition {
...
}
}
pos
を無視すると、型 T
のセッティングは、型が ScopedKey[T]
である左辺項 key
と型が Initialize[T]
である右辺項 init
によって構成される。
第一次元
便宜的に ScopedKey[T]
は、現在のプロジェクトなどのデフォルトのコンテキストにスコープ付けされた SettingKey[T]
や TaskKey[T]
だと考えることができる。すると残るのは Initialize[T]
だけで、これは依存キーの列と何らか方法で T
へと評価される能力を持っている。Initialized[T]
に直接作用するのはキーに実装されている <<=
演算子だ。Structure.scala 参照:
sealed trait DefinableSetting[T] {
final def <<= (app: Initialize[T]): Setting[T] =
macro std.TaskMacro.settingAssignPosition[T]
...
}
名前から推測して、このマクロは pos
を代入しているのだと思う。sbt 0.12 においてはキーのタプルにモンキーパッチされた apply
や map
メソッドによって Initialize[T]
が構築された。sbt 0.13 ではスマートな :=
演算子を使うことができる。Structure.scala 参照:
sealed trait DefinableTask[T] {
def := (v: T): Setting[Task[T]] =
macro std.TaskMacro.taskAssignMacroImpl[T]
}
素の :=
演算子は型が T
の引数を受け取り Setting[T]
もしくは Setting[Task[T]]
のインスタンスを返す。そのインスタンスの内部には Initialize[T]
が作られたと予想できる。マクロに渡されたコードの中でキーが value
メソッドを呼び出すと、自動的に式全体が <<=
式へと変換される。
name := {
organization.value + "-" + baseDirectory.value.getName
}
は
name <<= (organization, baseDirectory) { (o, b) =>
o + "-" + b.getName
}
へと展開される。便利なのは :=
がセッティングとタスクの両方に使えることだ。
val startServer = taskKey[Unit]("start server.")
val integrationTest = taskKey[Unit]("integration test.")
integrationTest := {
val x = startServer.value
println("do something")
}
startServer := {
println("start")
}
start.value
は実行時に評価され、キーに関連付けられた値が返される。このようなタスク間の依存性は Ant の他のビルドツールにも見ることができる。これが sbt における第一の次元だ。
:=
が少し崩れてくるのはタスクを他の場所で定義しようとした場合だ。
val orgBaseDirName = {
organization.value + "-" + baseDirectory.value.getName
}
name := orgBaseDirName
これを読み込むと以下のエラーが返ってくる:
build.sbt:14: error: `value` can only be used within a task or setting macro, such as :=, +=, ++=, Def.task, or Def.setting.
organization.value + "-" + baseDirectory.value.getName
^
ブロックを適切なマクロで包囲するためには以下のように書かなくてはいけない:
val orgBaseDirName: Def.Initialize[String] = Def.setting {
organization.value + "-" + baseDirectory.value.getName
}
name := orgBaseDirName
orgBaseDirName
の型注釈は必要ないが、この型をハッキリと知っておくことは役に立つ。次のエラーメッセージを見ても驚かないはずだ:
build.sbt:17: error: type mismatch;
found : sbt.Def.Initialize[String]
required: String
name := orgBaseDirName
^
[error] Type error in expression
:=
は String
を期待しているので、Initialize[String]
を評価する必要がある。興味深いことに value
メソッドはここでも動作する。value
メソッドは MacroValue[T]
にて定義されている。InputWrapper.scala 参照:
sealed abstract class MacroValue[T] {
@compileTimeOnly("`value` can only be used within a task or setting macro, such as :=, +=, ++=, Def.task, or Def.setting.")
def value: T = macro InputWrapper.valueMacroImpl[T]
}
暗黙の型変換が定義されていて、匿名の Initialize[T]
インスタンスやセッティングキー (実はキーも Initialize[T]
だ) に value
メソッドが注入される。
タスク毎のセッティング
sbt の第二の次元はキーのタスクスコープ付けだ。タスクスコープは以前からあったものだけど sbt のプラグインコミュニティーがキーをどのように活用すべきかということを模索してるうちに顕著なものとなってきた。Brian (@bmc)、 Doug (@softprops)、 Josh (@jsuereth)、 そして sbt 作者の Mark (@harrah) と並んで僕もわずかながらこのプロセスに貢献した。多くの ML への投稿や irc チャットから出てきたのが以下のものだ:
sbt/sbt-assembly を具体例として見ると、jarName
は以下のようにカスタマイズされる:
import AssemblyKeys._
assemblySettings
jarName in assembly := "something.jar"
これは特定のセッティングのビルド定義内での影響を制限することができる便利な概念だ。もう一つ例をみてみよう:
import AssemblyKeys._
assemblySettings
test in assembly := {}
assembly
タスクは fat jar を作る前にデフォルトでは test
タスクを実行するが、上記の設定によってビルドユーザはその振る舞いを無効化した。実際に何が行われているかと言うと、assembly
タスクは test
タスクには直接依存しないように書かれている。代わりに、それは assembly::test
タスクに依存している。Plugin.scala 参照:
private def assemblyTask(key: TaskKey[File]): Initialize[Task[File]] = Def.task {
val t = (test in key).value
val s = (streams in key).value
Assembly((outputPath in key).value, (assemblyOption in key).value,
(packageOptions in key).value, (assembledMappings in key).value,
s.cacheDirectory, s.log)
}
lazy val baseAssemblySettings: Seq[sbt.Def.Setting[_]] = Seq(
assembly := assemblyTask(assembly).value,
...
test in assembly := (test in Test).value,
...
}
test
キーを assembly
タスクにスコープ付けすることで、sbt-assembly はビルドユーザが拡張できるポイントを提供している。
コンフィギュレーション
コンフィギュレーションは sbt の第三の次元で、あまりよく理解されていないものだ。始める sbt のスコープでは以下のように定義されている:
コンフィギュレーション(configuration)は、ビルドの種類を定義し、独自のクラスパス、ソース、生成パッケージなどをもつことができる。 コンフィギュレーションの概念は、sbt が マネージ依存性 に使っている Ivy と、MavenScopes に由来する。
肝心な点はコンフィギュレーションは独自のクラスパスとソースを持つということだ。デフォルトのコンフィギュレーション以外でもっとも広く使われているものに Test
がある。これは独自のソースコードとライブラリを持っている良い例だ。
キーをコンフィギュレーションにスコープ付けする普通の構文は Scala だと key in Test
で、シェルからだと test:key
だ。マネージライブラリは少し変わっていて %
を使って libraryDependencies
のコンフィギュレーションを表す。
libraryDependencies += "org.specs2" %% "specs2" % "2.2.3" % "test"
% "test"
は % "test->default"
の略で、依存ライブラリの Compile
アーティファクトを落としてきてこのプロジェクトの Test
コンフィギュレーションに入れる。
比較的簡単にカスタムのコンフィギュレーションを定義することはできる。だけど、セッティングの木構造を正しく定義するには少しコツがいる。StackOverflow で僕が答えた sbt 関連の質問のいくつかは、コンフィギュレーションをどう設定するかという問題を解くことに転化した。
例えば sbt-assembly を用いて異なる外部依存ライブラリを用いた複数の実行可能 jar を作る方法を見てみよう。以下が僕が投稿した build.sbt
だ:
import AssemblyKeys._
val Dispatch10 = config("dispatch10") extend(Compile)
val TestDispatch10 = config("testdispatch10") extend(Test)
val Dispatch11 = config("dispatch11") extend(Compile)
val TestDispatch11 = config("testdispatch11") extend(Test)
val root = project.in(file(".")).
configs(Dispatch10, TestDispatch10, Dispatch11, TestDispatch11).
settings(
name := "helloworld",
organization := "com.eed3si9n",
scalaVersion := "2.10.2",
compile in Test := inc.Analysis.Empty,
compile in Compile := inc.Analysis.Empty,
libraryDependencies ++= Seq(
"net.databinder.dispatch" %% "dispatch-core" % "0.10.0" % "dispatch10,testdispatch10",
"net.databinder.dispatch" %% "dispatch-core" % "0.11.0" % "dispatch11,testdispatch11",
"org.specs2" %% "specs2" % "2.2" % "test",
"com.github.scopt" %% "scopt" % "3.0.0"
)
).
settings(inConfig(Dispatch10)(Classpaths.configSettings ++ Defaults.configTasks ++ baseAssemblySettings ++ Seq(
test := (test in TestDispatch10).value,
test in assembly := test.value,
assemblyDirectory in assembly := cacheDirectory.value / "assembly-dispatch10",
jarName in assembly := name.value + "-assembly-dispatch10_" + version.value + ".jar"
)): _*).
settings(inConfig(TestDispatch10)(Classpaths.configSettings ++ Defaults.configTasks ++ Defaults.testTasks ++ Seq(
internalDependencyClasspath := Seq((classDirectory in Dispatch10).value).classpath
)): _*).
settings(inConfig(Dispatch11)(Classpaths.configSettings ++ Defaults.configTasks ++ baseAssemblySettings ++ Seq(
test := (test in TestDispatch11).value,
test in assembly := test.value,
assemblyDirectory in assembly := cacheDirectory.value / "assembly-dispatch11",
jarName in assembly := name.value + "-assembly-dispatch11_" + version.value + ".jar"
)): _*).
settings(inConfig(TestDispatch11)(Classpaths.configSettings ++ Defaults.configTasks ++ Defaults.testTasks ++ Seq(
internalDependencyClasspath := Seq((classDirectory in Dispatch11).value).classpath
)): _*)
同一のメインとテストのソースを使って上記のビルドは Dispatch 0.10 と 0.11 を使う複数のコンフィギュレーションを設定する。dispatch10:assembly
を実行すると Dispatch 0.10 を用いた fat jar を作り、dispatch11:assembly
を実行すると Dispatch 0.11 を用いた fat jar を作る。これは sbt-assembly がコンフィギュレーション中立な設計になっていることで可能となった。
コンフィギュレーションを使ったもう一つの例はscalariform を使って sbt ビルドファイルを自動的にフォーマットするには? という質問だ。以下が scalariform.sbt
だ:
import scalariform.formatter.preferences._
import ScalariformKeys._
lazy val BuildConfig = config("build") extend Compile
lazy val BuildSbtConfig = config("buildsbt") extend Compile
noConfigScalariformSettings
inConfig(BuildConfig)(configScalariformSettings)
inConfig(BuildSbtConfig)(configScalariformSettings)
scalaSource in BuildConfig := baseDirectory.value / "project"
scalaSource in BuildSbtConfig := baseDirectory.value
includeFilter in (BuildConfig, format) := ("*.scala": FileFilter)
includeFilter in (BuildSbtConfig, format) := ("*.sbt": FileFilter)
format in BuildConfig := {
val x = (format in BuildSbtConfig).value
(format in BuildConfig).value
}
preferences := preferences.value.
setPreference(AlignSingleLineCaseStatements, true).
setPreference(AlignParameters, true)
build:scalariformFormat
を実行すると、**.sbt
と project/**.scala
にマッチするファイルがフォーマットされる。これも sbt-scalariform がコンフィギュレーション中立なお陰で可能となった。だけど、sources
の代わりに includeFilter
を使っているせいで一つの仕事をするのに二つのコンフィギュレーションを作る必要があった。
ScopeFilter
Akka プロジェクトに知る人ぞ知る Unidoc.scala というファイルがある。これは unidoc
タスクを定義してビルドで定義される全プロジェクトのソースコードを集約して、それに対して Scaladoc を実行する。ビルドを小さなサブプロジェクトにモジュール化しているプロジェクトにとって非常に便利なものだ。
当然僕がやったのはこのコードを拝借してきて sbt-unidoc というプラグインにすることだった。ところが数週間前 @inkytonik にこの unidoc
を Test
コンフィギュレーションに対して実行したいと言われた。散々コンフィギュレーション中立性が云々と言ってきたのに、このざまだ。
複数のプロジェクトやコンフィギュレーションからのソースの集約を実装する段階になって、sbt 0.13 で追加された ScopeFilter という逸品に巡りあった。詳細は Getting values from multiple scopes に書かれている。
複数のスコープから値を取得する式の一般形は:
<setting-or-task>.all(<scope-filter>).value
all
メソッドはタスクとセッテイングに暗黙に加えられる。
以下が全てのソースを集約する例だ:
val filter = ScopeFilter(inProjects(core, util), inConfigurations(Compile))
// each sources definition is of type Seq[File],
// giving us a Seq[Seq[File]] that we then flatten to Seq[File]
val allSources: Seq[Seq[File]] = sources.all(filter).value
allSources.flatten
sbt-unidoc を修正するためにはユーザが再配線できるように ProjectFilter
と ConfigurationFilter
それぞれのセッティングを作るだけいい。プロジェクトを除外する例:
val root = (project in file(".")).
settings(commonSettings: _*).
settings(unidocSettings: _*).
settings(
name := "foo",
unidocProjectFilter in (ScalaUnidoc, unidoc) := inAnyProject -- inProjects(app)
).
aggregate(library, app)
複数のコンフィギュレーションを加える例:
val root = (project in file(".")).
settings(commonSettings: _*).
settings(unidocSettings: _*).
settings(
name := "foo",
unidocConfigurationFilter in (TestScalaUnidoc, unidoc) := inConfigurations(Compile, Test),
).
aggregate(library, app)
内部では、sources
の all
を呼び出している:
val f = (unidocScopeFilter in unidoc).value
sources.all(f)
sbt の第四の次元はプロジェクトで、僕たちは第三と第四次元空間内を移動する乗り物を手にしたことになる。どこに行くかは僕たち次第だ。