在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基于Lifecycle
与Phase
的构建方式有一些区别,所以从Maven转而使用sbt在概念的转换上需要花一些时间。
目录结构与构建文件
sbt的目录结构与Maven一脉相承,只是增加了对scala源代码的支持,但是构建的相关文件要多一些:
|
|
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
,需要先定义才可以使用,如:
|
|
在sbt的DSL中,Key
之间的依赖是通过对其取值来实现的,这也和大多构建系统提供特定的依赖语法不同,例如:
|
|
需要注意的是,虽然sbt的DSL和Scala的语法非常相似,但是上边的.value
并不是普通的Scala方法调用。sbt会在任务运行前分析脚本,并确保通过.value
进行求值的任务在引用它的任务前先被执行,因此.value
不是必须写在任务定义的开头,可以出现在任何位置,并且其运行的先后顺序也和定义的顺序无关。下边的情况,即便没有用到clean.value
,clean
任务仍然会被运行:
|
|
可以使用inspect
命令来查看某个任务的依赖,也可以使用更高级的inspect tree
命令来查看完整的依赖树:
|
|
sbt通过分析DAG,确保每个任务不论被引用多少次,只会被执行一次,并且没有依赖关系的任务会被并行的执行,这种执行策略在一定程度上提高了构建的速度。同时,它将对任务的依赖转变为了对任务执行结果的依赖,在整个构建过程中,每一个构建步骤都可以输出结果,后续的构建步骤也可以很容易的获取到之前步骤的输出,这种机制是sbt的一个改进,在Maven中如果一个goal
想要取得之前某个goal
的运行结果就没有这么容易了。
Scope
sbt中的每个Key
都有自己的作用域(Scope),不同Scope
下的同一个Key
可以有不同的定义。Scope
有三个维度,分别是:子项目(subproject axis)、依赖配置(dependency configuration axis)与任务(task axis)。其中的子项目与任务都不难理解,而依赖配置其实就是Maven的Scope。下边是一个Key
的Scope
定义的例子:
|
|
在实际编写sbt脚本时,往往不需要对每一个Key
的定义与引用都显式的写出其Scope
的三个维度,因为每个维度都有默认值。所有三个维度都可以有一个指代全局的值:*
或Global
。子项目除了可以使用名称表示“子项目”,还可以使用ThisBuild
来指整个项目,当子项目没有对应的定义时,会使用全局(ThisBuild
)的定义。下边是一些列子:
|
|
另外,依赖配置有一个层级继承与扩展关系,如下图:

sbt有复杂的作用域委托机制(Scope delegation),即不同Scope
中的Key
之间的继承与覆盖的规则:
Scope
的三个维度的优先级为:子项目、依赖配置与任务。- 对于任务维度的搜索优先级为:当前任务,
*
(Global
)。 - 对于依赖配置维度的搜索优先级为:当前依赖配置,父依赖配置(递归搜索),
*
(Global
)。 - 对于子项目维度的搜索优先级为:当前子项目,
ThisBuild
,*
(Global
)。 - 对委托作用域中的
Key
进行求值时,不会使用其原始的上下文信息。
下边是三道sbt作用域委托机制的“奥数竞赛练习题”:
题一:
|
|
问:name in projC
的值是什么?
- “foo-2.12.2”
- “foo-2.11.11”
- 其他
答案:”foo-2.11.11”
解析:(scalaVersion in packageBin)
的Scope
是(projC, *, packageBin)
,没有定义。根据规则2,搜索(projC, *, *)
,根据规则4,搜索(ThisBuild, *, packageBin)
,再根据规则1,子项目的优先级更高,因此答案是”foo-2.11.11”。
题二:
|
|
问:运行projD/test的输出结果是什么?
- List()
- List(-Ywarn-unused-import)
- 其他
答案: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)。
题三:
|
|
问:运行projF/test的输出结果是什么?
- “bippy-D4”
- “bippy-D2-D4”
- “bippy-D0-D3-D4”
- 其他
答案:”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中使用inspect
和show
等命令来查看详细信息,找到每一项配置的来龙去脉。
从Maven到sbt
新建项目
sbt使用Giter8模版来初始化新项目,这与Maven使用Archetype Plugin的方式类似。例如,初始化一个scala项目,可以输入:sbt new sbt/scala-seed.g8
。
常用的模版都可以在其WIKI中找到。
依赖管理
sbt的依赖分为非托管(unmanaged)与托管(managed)两种。
非托管的依赖类似Maven中的system作用域,默认搜索的路径是项目下的lib
目录,也可以通过下边的方式进行更改:
|
|
托管的依赖是基于Apache Ivy来实现的,与Maven一脉相承。依赖配置的一般写法为libraryDependencies += groupID % artifactID % revision % configuration
,其中的libraryDependencies
是一个集合,符号+=
表示向集合中添加元素,也可以用符号++=
添加另一个集合中的所有元素,符号%
其实是一个方法,将字符串转化为ModuleID,。实际的写法如下:
|
|
因为早期scala发行版二进制不兼容,所以当发布一个库的时候,需要交叉编译,为每一个支持的scala版本都发布一个包,因此在引用依赖的时候就需要根据当前的scala版本来指定对应版本的软件包,这种情况要使用专用的符号%%
。例如,scala版本为2.11.1时,下边引用的是同一个依赖:
|
|
自动匹配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
查看启用的插件列表。下边是插件启用禁用的例子:
|
|
sbt默认会载入并启用三个插件,分别是:CorePlugin,控制任务的运行。IvyPlugin,依赖管理与项目发布。JvmPlugin,用于编译、测试、运行与打包应用。
项目示例
下边是一个完整的sbt项目示例:
目录结构:
|
|
project/build.properties
:
|
|
project/plugins.sbt
:
|
|
project/Dependencies.scala
:
|
|
build.sbt
:
|
|