SBT 是一个现代化的构建工具。虽然它由 Scala 编写并提供了很多 Scala 便利,但它是一个通用的构建工具。
为什么选择 SBT?
译注:最新的 SBT 安装方式请参考 scala-sbt 的文档
java -Xmx512M -jar sbt-launch.jar "$@"
[local ~/projects]$ sbt Project does not exist, create new project? (y/N/s) y Name: sample Organization: com.twitter Version [1.0]: 1.0-SNAPSHOT Scala version [2.7.7]: 2.8.1 sbt version [0.7.4]: Getting Scala 2.7.7 ... :: retrieving :: org.scala-tools.sbt#boot-scala confs: [default] 2 artifacts copied, 0 already retrieved (9911kB/221ms) Getting org.scala-tools.sbt sbt_2.7.7 0.7.4 ... :: retrieving :: org.scala-tools.sbt#boot-app confs: [default] 15 artifacts copied, 0 already retrieved (4096kB/167ms) [success] Successfully initialized directory structure. Getting Scala 2.8.1 ... :: retrieving :: org.scala-tools.sbt#boot-scala confs: [default] 2 artifacts copied, 0 already retrieved (15118kB/386ms) [info] Building project sample 1.0-SNAPSHOT against Scala 2.8.1 [info] using sbt.DefaultProject with sbt 0.7.4 and Scala 2.7.7
可以看到它已经以较好的形式创建了项目的快照版本。
project/build/.scala
– 主项目定义文件
project/build.properties
– 项目、sbt 和 Scala 版本定义
src/main
– 你的应用程序代码出现在这里,在子目录表明代码的语言(如src/main/scala
, src/main/java
)
src/main/resources
– 你想要添加到 jar 包中的静态文件(如日志配置)
src/test
– 就像 src/main,不过是对测试
lib_managed
– 你的项目依赖的 jar文件。由 sbt update 时填充
target
– 生成物的目标路径(如自动生成的 thrift 代码,类文件,jar包)
我们将为简单的 tweet 消息创建一个简单的 JSON 解析器。将以下代码加在这个文件中 src/main/scala/com/twitter/sample/SimpleParser.scala
package com.twitter.sample case class SimpleParsed(id: Long, text: String) class SimpleParser { val tweetRegex = "\"id\":(.*),\"text\":\"(.*)\"".r def parse(str: String) = { tweetRegex.findFirstMatchIn(str) match { case Some(m) => { val id = str.substring(m.start(1), m.end(1)).toInt val text = str.substring(m.start(2), m.end(2)) Some(SimpleParsed(id, text)) } case _ => None } } }
这段代码丑陋并有 bug,但应该能够编译通过。
SBT 既可以用作命令行脚本,也可以作为构建控制台。我们将主要利用它作为构建控制台,不过大多数命令可以作为参数传递给 SBT 独立运行,如
sbt test
需要注意如果一个命令需要参数,你需要使用引号包括住整个参数路径,例如
sbt 'test-only com.twitter.sample.SampleSpec'
这种方式很奇怪。
不管怎样,要开始我们的代码工作了,启动SBT吧
[local ~/projects/sbt-sample]$ sbt [info] Building project sample 1.0-SNAPSHOT against Scala 2.8.1 [info] using sbt.DefaultProject with sbt 0.7.4 and Scala 2.7.7 >SBT 允许你启动一个 Scala REPL 并加载所有项目依赖。它会在启动控制台前编译项目的源代码,从而为我们提供一个快速测试解析器的工作台。
> console [info] [info] == compile == [info] Source analysis: 0 new/modified, 0 indirectly invalidated, 0 removed. [info] Compiling main sources... [info] Nothing to compile. [info] Post-analysis: 3 classes. [info] == compile == [info] [info] == copy-test-resources == [info] == copy-test-resources == [info] [info] == test-compile == [info] Source analysis: 0 new/modified, 0 indirectly invalidated, 0 removed. [info] Compiling test sources... [info] Nothing to compile. [info] Post-analysis: 0 classes. [info] == test-compile == [info] [info] == copy-resources == [info] == copy-resources == [info] [info] == console == [info] Starting scala interpreter... [info] Welcome to Scala version 2.8.1.final (Java HotSpot(TM) 64-Bit Server VM, Java 1.6.0_22). Type in expressions to have them evaluated. Type :help for more information. scala>我们代码编译通过了,并提供了典型的 Scala 提示符。我们将创建一个新的解析器,一个 tweet 以确保其“能工作”
scala> import com.twitter.sample._ import com.twitter.sample._ scala> val tweet = """{"id":1,"text":"foo"}""" tweet: java.lang.String = {"id":1,"text":"foo"} scala> val parser = new SimpleParser parser: com.twitter.sample.SimpleParser = com.twitter.sample.SimpleParser@71060c3e scala> parser.parse(tweet) res0: Option[com.twitter.sample.SimpleParsed] = Some(SimpleParsed(1,"foo"})) scala>添加依赖
我们简单的解析器对这个非常小的输入集工作正常,但我们需要添加更多的测试并让它出错。第一步是在我们的项目中添加 specs 测试库和一个真正的 JSON 解析器。要做到这一点,我们必须超越默认的 SBT 项目布局来创建一个项目。
SBT 认为 project/build
目录中的 Scala 文件是项目定义。添加以下内容到这个文件中project/build/SampleProject.scala
import sbt._ class SampleProject(info: ProjectInfo) extends DefaultProject(info) { val jackson = "org.codehaus.jackson" % "jackson-core-asl" % "1.6.1" val specs = "org.scala-tools.testing" % "specs_2.8.0" % "1.6.5" % "test" }
一个项目定义是一个 SBT 类。在上面例子中,我们扩展了 SBT 的 DefaultProject。
这里是通过 val 声明依赖。SBT 使用反射来扫描项目中的所有 val 依赖,并在构建时建立依赖关系树。这里使用的语法可能是新的,但本质和 Maven 依赖是相同的
<dependency> <groupId>org.codehaus.jackson</groupId> <artifactId>jackson-core-asl</artifactId> <version>1.6.1</version> </dependency> <dependency> <groupId>org.scala-tools.testing</groupId> <artifactId>specs_2.8.0</artifactId> <version>1.6.5</version> <scope>test</scope> </dependency>现在可以下载我们的项目依赖了。在命令行中(而不是 sbt console 中)运行 sbt update
[local ~/projects/sbt-sample]$ sbt update [info] Building project sample 1.0-SNAPSHOT against Scala 2.8.1 [info] using SampleProject with sbt 0.7.4 and Scala 2.7.7 [info] [info] == update == [info] :: retrieving :: com.twitter#sample_2.8.1 [sync] [info] confs: [compile, runtime, test, provided, system, optional, sources, javadoc] [info] 1 artifacts copied, 0 already retrieved (2785kB/71ms) [info] == update == [success] Successful. [info] [info] Total time: 1 s, completed Nov 24, 2010 8:47:26 AM [info] [info] Total session time: 2 s, completed Nov 24, 2010 8:47:26 AM [success] Build completed successfully.
你会看到 sbt 检索到 specs 库。现在还增加了一个 lib_managed 目录,并且在 lib_managed/scala_2.8.1/test
目录中包含 specs_2.8.0-1.6.5.jar
现在有了测试库,可以把下面的测试代码写入src/test/scala/com/twitter/sample/SimpleParserSpec.scala
文件
package com.twitter.sample import org.specs._ object SimpleParserSpec extends Specification { "SimpleParser" should { val parser = new SimpleParser() "work with basic tweet" in { val tweet = """{"id":1,"text":"foo"}""" parser.parse(tweet) match { case Some(parsed) => { parsed.text must be_==("foo") parsed.id must be_==(1) } case _ => fail("didn't parse tweet") } } } }在 SBT 控制台中运行 test
> test [info] [info] == compile == [info] Source analysis: 0 new/modified, 0 indirectly invalidated, 0 removed. [info] Compiling main sources... [info] Nothing to compile. [info] Post-analysis: 3 classes. [info] == compile == [info] [info] == test-compile == [info] Source analysis: 0 new/modified, 0 indirectly invalidated, 0 removed. [info] Compiling test sources... [info] Nothing to compile. [info] Post-analysis: 10 classes. [info] == test-compile == [info] [info] == copy-test-resources == [info] == copy-test-resources == [info] [info] == copy-resources == [info] == copy-resources == [info] [info] == test-start == [info] == test-start == [info] [info] == com.twitter.sample.SimpleParserSpec == [info] SimpleParserSpec [info] SimpleParser should [info] + work with basic tweet [info] == com.twitter.sample.SimpleParserSpec == [info] [info] == test-complete == [info] == test-complete == [info] [info] == test-finish == [info] Passed: : Total 1, Failed 0, Errors 0, Passed 1, Skipped 0 [info] [info] All tests PASSED. [info] == test-finish == [info] [info] == test-cleanup == [info] == test-cleanup == [info] [info] == test == [info] == test == [success] Successful. [info] [info] Total time: 0 s, completed Nov 24, 2010 8:54:45 AM >我们的测试通过了!现在,我们可以增加更多。运行触发动作是 SBT 提供的优秀特性之一。在动作开始添加一个波浪线会启动一个循环,在源文件发生变化时重新运行动作。让我们运行
~test
并看看会发生什么吧。
[info] == test == [success] Successful. [info] [info] Total time: 0 s, completed Nov 24, 2010 8:55:50 AM 1. Waiting for source changes... (press enter to interrupt)现在,让我们添加下面的测试案例
"reject a non-JSON tweet" in { val tweet = """"id":1,"text":"foo"""" parser.parse(tweet) match { case Some(parsed) => fail("didn't reject a non-JSON tweet") case e => e must be_==(None) } } "ignore nested content" in { val tweet = """{"id":1,"text":"foo","nested":{"id":2}}""" parser.parse(tweet) match { case Some(parsed) => { parsed.text must be_==("foo") parsed.id must be_==(1) } case _ => fail("didn't parse tweet") } } "fail on partial content" in { val tweet = """{"id":1}""" parser.parse(tweet) match { case Some(parsed) => fail("didn't reject a partial tweet") case e => e must be_==(None) } }在我们保存文件后,SBT 会检测到变化,运行测试,并通知我们的解析器有问题
[info] == com.twitter.sample.SimpleParserSpec == [info] SimpleParserSpec [info] SimpleParser should [info] + work with basic tweet [info] x reject a non-JSON tweet [info] didn't reject a non-JSON tweet (Specification.scala:43) [info] x ignore nested content [info] 'foo","nested":{"id' is not equal to 'foo' (SimpleParserSpec.scala:31) [info] + fail on partial content因此,让我们返工实现真正的 JSON 解析器
package com.twitter.sample import org.codehaus.jackson._ import org.codehaus.jackson.JsonToken._ case class SimpleParsed(id: Long, text: String) class SimpleParser { val parserFactory = new JsonFactory() def parse(str: String) = { val parser = parserFactory.createJsonParser(str) if (parser.nextToken() == START_OBJECT) { var token = parser.nextToken() var textOpt:Option[String] = None var idOpt:Option[Long] = None while(token != null) { if (token == FIELD_NAME) { parser.getCurrentName() match { case "text" => { parser.nextToken() textOpt = Some(parser.getText()) } case "id" => { parser.nextToken() idOpt = Some(parser.getLongValue()) } case _ => // noop } } token = parser.nextToken() } if (textOpt.isDefined && idOpt.isDefined) { Some(SimpleParsed(idOpt.get, textOpt.get)) } else { None } } else { None } } }这是一个简单的 Jackson 解析器。当我们保存,SBT 会重新编译代码和运行测试。代码变得越来越好了!
info] SimpleParser should [info] + work with basic tweet [info] + reject a non-JSON tweet [info] x ignore nested content [info] '2' is not equal to '1' (SimpleParserSpec.scala:32) [info] + fail on partial content [info] == com.twitter.sample.SimpleParserSpec ==哦。我们需要检查嵌套对象。让我们在 token 读取循环处添加一些丑陋的守卫。
def parse(str: String) = { val parser = parserFactory.createJsonParser(str) var nested = 0 if (parser.nextToken() == START_OBJECT) { var token = parser.nextToken() var textOpt:Option[String] = None var idOpt:Option[Long] = None while(token != null) { if (token == FIELD_NAME && nested == 0) { parser.getCurrentName() match { case "text" => { parser.nextToken() textOpt = Some(parser.getText()) } case "id" => { parser.nextToken() idOpt = Some(parser.getLongValue()) } case _ => // noop } } else if (token == START_OBJECT) { nested += 1 } else if (token == END_OBJECT) { nested -= 1 } token = parser.nextToken() } if (textOpt.isDefined && idOpt.isDefined) { Some(SimpleParsed(idOpt.get, textOpt.get)) } else { None } } else { None } }
…测试通过了!
现在我们已经可以运行 package 命令来生成一个 jar 文件。不过我们可能要与其他组分享我们的 jar 包。要做到这一点,我们将在 StandardProject 基础上构建,这给了我们一个良好的开端。
第一步是引入 StandardProject 为 SBT 插件。插件是一种为你的构建引进依赖的方式,注意不是为你的项目引入。这些依赖关系定义在 project/plugins/Plugins.scala
文件中。添加以下代码到 Plugins.scala 文件中。
import sbt._ class Plugins(info: ProjectInfo) extends PluginDefinition(info) { val twitterMaven = "twitter.com" at "http://maven.twttr.com/" val defaultProject = "com.twitter" % "standard-project" % "0.7.14" }注意我们指定了一个 Maven 仓库和一个依赖。这是因为这个标准项目库是由 twitter 托管的,不在 SBT 默认检查的仓库中。
我们也将更新项目定义来扩展 StandardProject,包括 SVN 发布特质,和我们希望发布的仓库定义。修改SampleProject.scala
import sbt._ import com.twitter.sbt._ class SampleProject(info: ProjectInfo) extends StandardProject(info) with SubversionPublisher { val jackson = "org.codehaus.jackson" % "jackson-core-asl" % "1.6.1" val specs = "org.scala-tools.testing" % "specs_2.8.0" % "1.6.5" % "test" override def subversionRepository = Some("http://svn.local.twitter.com/maven/") }现在如果我们运行发布操作,将看到以下输出
[info] == deliver == IvySvn Build-Version: null IvySvn Build-DateTime: null [info] :: delivering :: com.twitter#sample;1.0-SNAPSHOT :: 1.0-SNAPSHOT :: release :: Wed Nov 24 10:26:45 PST 2010 [info] delivering ivy file to /Users/mmcbride/projects/sbt-sample/target/ivy-1.0-SNAPSHOT.xml [info] == deliver == [info] [info] == make-pom == [info] Wrote /Users/mmcbride/projects/sbt-sample/target/sample-1.0-SNAPSHOT.pom [info] == make-pom == [info] [info] == publish == [info] :: publishing :: com.twitter#sample [info] Scheduling publish to http://svn.local.twitter.com/maven/com/twitter/sample/1.0-SNAPSHOT/sample-1.0-SNAPSHOT.jar [info] published sample to com/twitter/sample/1.0-SNAPSHOT/sample-1.0-SNAPSHOT.jar [info] Scheduling publish to http://svn.local.twitter.com/maven/com/twitter/sample/1.0-SNAPSHOT/sample-1.0-SNAPSHOT.pom [info] published sample to com/twitter/sample/1.0-SNAPSHOT/sample-1.0-SNAPSHOT.pom [info] Scheduling publish to http://svn.local.twitter.com/maven/com/twitter/sample/1.0-SNAPSHOT/ivy-1.0-SNAPSHOT.xml [info] published ivy to com/twitter/sample/1.0-SNAPSHOT/ivy-1.0-SNAPSHOT.xml [info] Binary diff deleting com/twitter/sample/1.0-SNAPSHOT [info] Commit finished r977 by 'mmcbride' at Wed Nov 24 10:26:47 PST 2010 [info] Copying from com/twitter/sample/.upload to com/twitter/sample/1.0-SNAPSHOT [info] Binary diff finished : r978 by 'mmcbride' at Wed Nov 24 10:26:47 PST 2010 [info] == publish == [success] Successful. [info] [info] Total time: 4 s, completed Nov 24, 2010 10:26:47 AM
这样(一段时间后),就可以在 binaries.local.twitter.com
上看到我们发布的 jar 包。
任务就是 Scala 函数。添加一个任务最简单的方法是,在你的项目定义中引入一个 val 定义的任务方法,如
lazy val print = task {log.info("a test action"); None}
你也可以这样加上依赖和描述
lazy val print = task {log.info("a test action"); None}.dependsOn(compile) describedAs("prints a line after compile")
刷新项目,并执行 print 操作,我们将看到以下输出
> print
[info]
[info] == print ==
[info] a test action
[info] == print ==
[success] Successful.
[info]
[info] Total time: 0 s, completed Nov 24, 2010 11:05:12 AM
>
所以它起作用了。如果你只是在一个项目定义一个任务的话,这工作得很好。然而如果你定义的是一个插件的话,它就很不灵活了。我可能要
lazy val print = printAction
def printAction = printTask.dependsOn(compile) describedAs("prints a line after compile")
def printTask = task {log.info("a test action"); None}
这可以让消费者覆盖任务本身,依赖和/或任务的描述,或动作本身。大多数 SBT 内建的动作都遵循这种模式。作为一个例子,我们可以通过修改内置打包任务来打印当前时间戳
常用命令
更多命令
lazy val printTimestamp = task { log.info("current time is " + System.currentTimeMillis); None}
override def packageAction = super.packageAction.dependsOn(printTimestamp)
有很多例子介绍了怎样调整 SBT 默认的 StandardProject,和如何添加自定义任务。
快速参考