从Maven到sbt

Maven核心概念一文中提到,最近的项目中使用了akka与scala,因此选择sbt(Simple Build Tool)做为项目的构建工具。在sbt之前也使用过很多的构建工具,但是sbt是我至今遇到的最复杂的一个,这和它名字里的Simple完全搭不上边。在我看来,sbt的复杂主要在两个方面,一是它诡异的领域语言,另一个是Scope复杂的委托机制。

存在即合理,sbt的出现解决了scala项目中的一些特殊的问题,比如scala早期版本的二进制不兼容问题。另外,sbt在构建的速度上也较其他Java领域的构建工具更快,同时也提供了更丰富的特性。

文本基于sbt 1.0.0版,首先说明sbt的核心概念,介绍一下sbt的一些特性,之后列举与Maven中常用功能相对应的sbt实现方法。

核心概念

sbt是基于任务(Task)的构建工具,通过任务之间的相互依赖,完成复杂的构建工作,这和大多构建工具的原理相似。它更多的借鉴了Gradle,与Maven基于LifecyclePhase的构建方式有一些区别,所以从Maven转而使用sbt在概念的转换上需要花一些时间。

目录结构与构建文件

sbt的目录结构与Maven一脉相承,只是增加了对scala源代码的支持,但是构建的相关文件要多一些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
build.sbt <- sbt构建定义文件,后缀名必须是.sbt,前边可以是任意名字,不要求必须是build
project/ <- project目录下的所有.scala与.sbt文件都会被自动载入
build.properties <- 指定sbt的版本,如sbt.version=1.0.0,sbt启动器会自动安装本地没有的版本
Dependencies.scala <- 依赖配置,如果依赖较简单,可以省略该文件,直接写在build.sbt中
plugins.sbt <- 插件定义
src/
main/
resources/
scala/ <- scala源文件
java/
test/
resources/
scala/ <- scala测试源文件
java/
target/
bin/

sbt的构建文件可以使用其领域语言来编写,后缀名为.sbt文件,也可以使用scala编写,这两者的选择往往需要根据实际情况做出取舍。有Maven经验的用户可能会更多的选择使用领域语言,熟悉Gradle的用户往往会将两者混着用,也有一些极端的情况,比如akka就完全使用scala来写构建定义,因为它的构建工作十分复杂。一般推荐的做法是,首先考虑使用领域语言,只有在必要的时候,如写一些复杂的构建行为或者自定义插件时使用scala。

Keys

对于一般基于任务的构建系统,大多可以将任务(Task)看作是方法,配置看作是变量,而sbt则将两者都定义为Key,只是Key对应的值的类型不同而已。sbt的Key可以有三种类型:

  • SettingKey:配置值,这类值只会在载入时计算一次
  • TaskKey:任务,每次任务运行都会重新计算一次
  • InputKey:从命令行取的值

在其内部是现实上,SettingKey的类型为Setting[T],而TaskKey的类型则为Setting[Task[T]],Scala是强类型的语言,泛型T指的是值的类型,比如Int、String等。可以将sbt中的任务理解为求值的过程,而一个sbt的构建过程就是一个DAG(directed acyclic graph, 无回路有向图),定义了Key之间的依赖关系。因为SettingKey只在载入时求一次值,而TaskKey是每次任务运行时都进行求值,所以TaskKey可以依赖于SettingKey,但反之则不行。

内置的Key都在Keys包中,并不需要显式的进行引用。自定义的Key,需要先定义才可以使用,如:

1
2
3
lazy val someTask = taskKey[Unit]("An example task")
someTask := { println("Hello!") } // 对Keys赋值使用 :=,而不是Scala中常用的 =

在sbt的DSL中,Key之间的依赖是通过对其取值来实现的,这也和大多构建系统提供特定的依赖语法不同,例如:

1
2
3
4
5
6
7
8
9
10
11
val scalacOptions = taskKey[Seq[String]]("Options for the Scala compiler.")
val update = taskKey[UpdateReport]("Resolves and optionally retrieves dependencies, producing a report.")
val clean = taskKey[Unit]("Deletes files produced by the build, such as generated sources, compiled classes, and task caches.")
scalacOptions := {
val ur = update.value // 对update取值,需要update任务先于scalacOptions任务运行
val x = clean.value // 对clean取值,需要clean任务先于scalacOptions任务运行
// 下边是scalacOptions任务的内容
ur.allConfigurations.take(3)
}

需要注意的是,虽然sbt的DSL和Scala的语法非常相似,但是上边的.value并不是普通的Scala方法调用。sbt会在任务运行前分析脚本,并确保通过.value进行求值的任务在引用它的任务前先被执行,因此.value不是必须写在任务定义的开头,可以出现在任何位置,并且其运行的先后顺序也和定义的顺序无关。下边的情况,即便没有用到clean.valueclean任务仍然会被运行:

1
2
3
4
5
scalacOptions := {
if (false) {
val x = clean.value
}
}

可以使用inspect命令来查看某个任务的依赖,也可以使用更高级的inspect tree命令来查看完整的依赖树:

1
2
3
4
5
6
7
8
9
> inspect scalacOptions
[info] Task: scala.collection.Seq[java.lang.String]
[info] Description:
[info] Options for the Scala compiler.
....
[info] Dependencies:
[info] *:clean
[info] *:update
....

sbt通过分析DAG,确保每个任务不论被引用多少次,只会被执行一次,并且没有依赖关系的任务会被并行的执行,这种执行策略在一定程度上提高了构建的速度。同时,它将对任务的依赖转变为了对任务执行结果的依赖,在整个构建过程中,每一个构建步骤都可以输出结果,后续的构建步骤也可以很容易的获取到之前步骤的输出,这种机制是sbt的一个改进,在Maven中如果一个goal想要取得之前某个goal的运行结果就没有这么容易了。

Scope

sbt中的每个Key都有自己的作用域(Scope),不同Scope下的同一个Key可以有不同的定义。Scope有三个维度,分别是:子项目(subproject axis)、依赖配置(dependency configuration axis)与任务(task axis)。其中的子项目与任务都不难理解,而依赖配置其实就是Maven的Scope。下边是一个KeyScope定义的例子:

1
name in (projA, Compile, console) := "hello" // 子项目: projA, 依赖配置: Compile, 任务: console

在实际编写sbt脚本时,往往不需要对每一个Key的定义与引用都显式的写出其Scope的三个维度,因为每个维度都有默认值。所有三个维度都可以有一个指代全局的值:*Global。子项目除了可以使用名称表示“子项目”,还可以使用ThisBuild来指整个项目,当子项目没有对应的定义时,会使用全局(ThisBuild)的定义。下边是一些列子:

1
2
3
4
5
6
name in (Global, Global, Global) := "hello" // 三个维度都是Global
name in Global := "hello" // 与上同
name := "hello" // (当前项目, Global, Global)
name in Compile := "hello" // (当前项目, Compile, Global)
name in packageBin := "hello" // (当前项目, Global, packageBin)
name in (ThisBuild, packageBin) := "hello" // (ThisBuild, Global, packageBin)

另外,依赖配置有一个层级继承与扩展关系,如下图:

sbt-configurations.png

sbt有复杂的作用域委托机制(Scope delegation),即不同Scope中的Key之间的继承与覆盖的规则:

  1. Scope的三个维度的优先级为:子项目、依赖配置与任务。
  2. 对于任务维度的搜索优先级为:当前任务,*Global)。
  3. 对于依赖配置维度的搜索优先级为:当前依赖配置,父依赖配置(递归搜索),*Global)。
  4. 对于子项目维度的搜索优先级为:当前子项目,ThisBuild*Global)。
  5. 对委托作用域中的Key进行求值时,不会使用其原始的上下文信息。

下边是三道sbt作用域委托机制的“奥数竞赛练习题”:

题一:

1
2
3
4
5
6
7
8
9
scalaVersion in (ThisBuild, packageBin) := "2.12.2"
lazy val projC = (project in file("c"))
.settings(
name := {
"foo-" + (scalaVersion in packageBin).value
},
scalaVersion := "2.11.11"
)

问:name in projC的值是什么?

  1. “foo-2.12.2”
  2. “foo-2.11.11”
  3. 其他

答案:”foo-2.11.11”

解析:(scalaVersion in packageBin)Scope(projC, *, packageBin),没有定义。根据规则2,搜索(projC, *, *),根据规则4,搜索(ThisBuild, *, packageBin),再根据规则1,子项目的优先级更高,因此答案是”foo-2.11.11”。

题二:

1
2
3
4
5
6
7
8
9
10
scalacOptions in ThisBuild += "-Ywarn-unused-import"
lazy val projD = (project in file("d"))
.settings(
test := {
println((scalacOptions in (Compile, console)).value)
},
scalacOptions in console -= "-Ywarn-unused-import",
scalacOptions in Compile := scalacOptions.value // added by sbt
)

问:运行projD/test的输出结果是什么?

  1. List()
  2. List(-Ywarn-unused-import)
  3. 其他

答案:List(-Ywarn-unused-import)

解析:scalacOptions in (Compile, console)Scope(projD, Compile, console),没有定义。根据规则2,搜索(projD, Compile, *),根据规则3,搜索(projD, *, console),根据规则4,搜索(ThisBuild, *, *),最后根据规则1,选择(projD, Compile, *),因为子项目相同,依赖配置优先级最高。但scalacOptions in Compile的值来自于scalacOptions,其默认的Scope(projD, *, *),根据规则4,搜索(ThisBuild, *, *),因此答案为List(-Ywarn-unused-import)。

题三:

1
2
3
4
5
6
7
8
9
10
11
12
scalacOptions in ThisBuild += "-D0"
scalacOptions += "-D1"
lazy val projF = (project in file("f"))
.settings(
scalacOptions in compile += "-D2",
scalacOptions in Compile += "-D3",
scalacOptions in (Compile, compile) += "-D4",
test := {
println("bippy" + (scalacOptions in (Compile, compile)).value.mkString)
}
)

问:运行projF/test的输出结果是什么?

  1. “bippy-D4”
  2. “bippy-D2-D4”
  3. “bippy-D0-D3-D4”
  4. 其他

答案:”bippy-D0-D3-D4”

解析:略(请发挥您的聪明才智)

以上题目摘录自sbt的官方文档,不知道做完之后,您有没有体会到来自sbt设计者的深深的恶意。相比之下,Maven的惯例优先的理念与继承扩展机制还是要简单一些的。

sbt Shell

sbt的一个重要的改进就是提供了很好用的交互式命令行工具:sbt Shell。

在项目根目录下运行sbt就可以进入sbt Shell,之后就可以在当前Shell中多次运行任务,这样每次任务的运行就节省了JVM预热的时间。

因为每次运行任务的速度快了,sbt又进一步提供了触发执行(Triggered Execution)的功能,即实时监视项目的变更,在特定的变更发生时,立即重新运行指定的任务,如果有使用gulp构建工具的经历,对这个特性应该不会陌生。在任务之前加上~号即表示要持续运行该任务:如~test会在代码变更时自动运行单元测试,适合于测试驱动开发(TDD)的场景,~compile会在代码变更时重新编译项目,适用于没有IDE支持的情况。也可以一次触发多个任务,如~ ;clean ;test

另外,sbt Shell还提供了一个REPL环境,可以在里边测试scala代码,功能类似于ruby的irb,在Shell中输入console即可进入REPL模式。

最后,在sbt Shell中对构建脚本进行调试也是很常用的功能。如之前的三道“奥数题”,其实不需要自己动脑子去想,可以在Shell中使用inspectshow等命令来查看详细信息,找到每一项配置的来龙去脉。

从Maven到sbt

新建项目

sbt使用Giter8模版来初始化新项目,这与Maven使用Archetype Plugin的方式类似。例如,初始化一个scala项目,可以输入:sbt new sbt/scala-seed.g8

常用的模版都可以在其WIKI中找到。

依赖管理

sbt的依赖分为非托管(unmanaged)与托管(managed)两种。

非托管的依赖类似Maven中的system作用域,默认搜索的路径是项目下的lib目录,也可以通过下边的方式进行更改:

1
unmanagedBase := baseDirectory.value / "custom_lib"

托管的依赖是基于Apache Ivy来实现的,与Maven一脉相承。依赖配置的一般写法为libraryDependencies += groupID % artifactID % revision % configuration,其中的libraryDependencies是一个集合,符号+=表示向集合中添加元素,也可以用符号++=添加另一个集合中的所有元素,符号%其实是一个方法,将字符串转化为ModuleID,。实际的写法如下:

1
2
3
4
5
6
7
libraryDependencies += "com.orangereading" % "stardict" % "0.2.2"
libraryDependencies += "org.springframework.boot" % "spring-boot-starter-test" % "1.5.6.RELEASE" % Test
libraryDependencies ++= Seq(
"com.fasterxml.jackson.core" % "jackson-core" % 2.8,
"com.fasterxml.jackson.core" % "jackson-databind" % 2.8
)

因为早期scala发行版二进制不兼容,所以当发布一个库的时候,需要交叉编译,为每一个支持的scala版本都发布一个包,因此在引用依赖的时候就需要根据当前的scala版本来指定对应版本的软件包,这种情况要使用专用的符号%%。例如,scala版本为2.11.1时,下边引用的是同一个依赖:

1
2
libraryDependencies += "org.scala-tools" % "scala-stm_2.11.1" % "0.3"
libraryDependencies += "org.scala-tools" %% "scala-stm" % "0.3"

自动匹配scala版本的机制并不总是奏效,因为scala可能会在同一个大版本下发布多个小版本,而这些小版本可能是二进制兼容的。比如,用户使用的是2.10.4,但是软件包的发布者只为2.10版本的scala编译了一个2.10.1的软件包,虽然2.10.1的软件包可以兼容2.10.4版的scala,但是sbt并不会做自动匹配,而是需要人工去仓库里边找到兼容的版本,并使用上边的第一种方式来进行定义。

另外,因为基于Ivy,sbt也支持动态的依赖版本(dynamic revisions),如:[1.0,2.0], [1.0,), 1.0.+等,而Maven则需要借助Versions Maven Plugin插件才能支持类似功能。

对于依赖较多的项目,常见的做法是在project路径下创建一个Dependencies.scala文件,使用scala来编写依赖定义,之后在build.sbt中进行引用。

使用插件

sbt插件的作用与Maven类似,常用的Maven插件大多都有对应的sbt插件。当需要特定功能的插件时,可以到sbt的官方插件列表中寻找。

官方推荐的引入插件的方式是在project目录下,为每一个plugin单独创建一个sbt文件,或者将所有插件定义都放入project/plugins.sbt中,插件定义的写法如addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.11.2")

插件需要被启用(Enabling)之后才会发挥作用。从0.13.5版以后,sbt添加了自动插件(auto plugins)的特性,允许插件默认启用并自行将自己的配置注入构建工程中,也有一些插件是需要手动启用的,一般在插件的文档中都会注明其启用方式,可以使用命令plugins查看启用的插件列表。下边是插件启用禁用的例子:

1
2
3
4
5
6
lazy val util = (project in file("util"))
.enablePlugins(FooPlugin, BarPlugin) // 需要手动启用的插件
.disablePlugins(plugins.IvyPlugin) // 禁用自动启用的插件
.settings(
name := "hello-util"
)

sbt默认会载入并启用三个插件,分别是:CorePlugin,控制任务的运行。IvyPlugin,依赖管理与项目发布。JvmPlugin,用于编译、测试、运行与打包应用。

项目示例

下边是一个完整的sbt项目示例:

目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
build.sbt
project/
build.properties
Dependencies.scala
plugins.sbt
commons/ <-- commons子项目
src/
main/
resources/
scala/
java/
test/
resources
scala/
java/
web/ <-- web子项目
src/
main/
resources/
scala/
java/
test/
resources/
scala/
java/

project/build.properties:

1
sbt.version=1.0.0

project/plugins.sbt:

1
2
addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "5.2.2")
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.5")

project/Dependencies.scala:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import sbt._
object Dependencies {
// 统一管理依赖版本
// Versions
val springVersion = "1.5.6.RELEASE"
val akkaVersion = "2.5.4"
val jacksonVersion = "2.8+"
// Spring Libraries
val springBootWeb = "org.springframework.boot" % "spring-boot-starter-web" % springVersion
val springBootWebsocket = "org.springframework.boot" % "spring-boot-starter-websocket" % springVersion
val springBootSecurity = "org.springframework.boot" % "spring-boot-starter-security" % springVersion
val springBootAop = "org.springframework.boot" % "spring-boot-starter-aop" % springVersion
val springBootCache = "org.springframework.boot" % "spring-boot-starter-cache" % springVersion
val springBootDataRedis = "org.springframework.boot" % "spring-boot-starter-data-redis" % springVersion
val springBootLogging = "org.springframework.boot" % "spring-boot-starter-logging" % springVersion
val springSession = "org.springframework.session" % "spring-session" % "1.3+"
val springBootTest = "org.springframework.boot" % "spring-boot-starter-test" % springVersion
val springSecurityTest = "org.springframework.security" % "spring-security-test" % "4.2+"
// Logging
val scalaLogging = "com.typesafe.scala-logging" %% "scala-logging" % "3.7.2"
// Akka Libraries
val akkaActor = "com.typesafe.akka" %% "akka-actor" % akkaVersion
val akkaTestkit = "com.typesafe.akka" %% "akka-testkit" % akkaVersion
// 几个总是一起使用的依赖可以放到一个集合里,方便多次引用
// Json Libraries
val jackson =
Seq(
"com.fasterxml.jackson.core" % "jackson-core" % jacksonVersion,
"com.fasterxml.jackson.core" % "jackson-databind" % jacksonVersion,
"com.fasterxml.jackson.core" % "jackson-annotations" % jacksonVersion
)
val javaUuidGenerator = "com.fasterxml.uuid" % "java-uuid-generator" % "3.1.4"
// commons子项目的依赖,在build.sbt中使用
var commonsDeps =
Seq(javaUuidGenerator)
// web子项目的依赖,在build.sbt中使用
val webDeps =
(Seq(springBootWeb,
springBootWebsocket,
springBootSecurity,
springBootAop,
springBootCache,
springBootDataRedis,
springBootLogging,
springSession,
akkaActor,
scalaLogging,
springBootTest % Test,
springSecurityTest % Test,
akkaTestkit % Test)
++ jackson)
}

build.sbt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import Dependencies._
lazy val root = (project in file("."))
.aggregate(commons, web) // 对两个子项目做聚合,在根项目运行任务时,会同时在所有聚合的子项目中运行任务
.settings(
// inThisBuild内的设置在所有的子项目中通用
inThisBuild(List(
organization := "com.someproj",
scalaVersion := "2.12.3",
version := "0.0.1-SNAPSHOT",
// 使用阿里云的仓库
externalResolvers := List("Aliyun Mirror" at "http://maven.aliyun.com/nexus/content/groups/public"),
EclipseKeys.skipParents := true,
EclipseKeys.withSource := true
)),
name := "someproj-app-server"
)
// commons子项目
lazy val commons = project
.settings(
name := "someproj-app-server-commons",
libraryDependencies ++= commonsDeps // 引入Dependencies.scala中的依赖设置
)
// web子项目
lazy val web = project
.dependsOn(commons) // web子项目依赖于commons子项目
.settings(
name := "someproj-app-server-web",
mainClass in assembly := Some("com.someproj.web.WebApplication"),
libraryDependencies ++= webDeps // 引入Dependencies.scala中的依赖设置
)

参考