이번 강좌에서는 SBT를 다룬다! 아래 주제에 대해 설명할 것이다.

SBT란

SBT는 최신 빌드 도구 중 하나이다. 스칼라로 작성되었고, 스칼라에 사용하기 편한 기능을 많이 제공하기는 하지만, SBT 자체는 범용 빌드 도구이다.

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
> 

항상 프로젝트 시작시에는 SNAPSHOT 버전을 가지고 시작하라.

프로젝트 구조

코드 추가하기

간단한 트윗을 처리하는 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
    }
  }
}

버그도 많고 보기도 안좋다. 그렇지만 컴파일에는 문제가 없다.

콘솔에서 테스트하기

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에서 프로젝트가 의존하는 모든 모듈을 적재한 상태의 스칼라 REPL을 시작할 수 있다. 콘솔을 시작하기 전에 프로젝트 코드를 컴파일할 것이다. 이 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> 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 아래 있는 스칼라 파일을 프로젝트 정의로 간주한다. 다음을 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는 리플렉션을 사용해 프로젝트 안에 정의된 모든 의존관계 값을 읽고 빌드시 해당 의존관계 트리를 만든다. 여기 쓰인 문법이 낯설지도 모르지만, 실제로는 아래의 메이븐(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 콘솔이 아님)에서 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 ==

우~. 내포된 객체를 처리해야 한다. 토큰을 읽는 루프에 보기는 싫지만 가드를 추가하자.

  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 "https://maven.twttr.com/"
  val defaultProject = "com.twitter" % "standard-project" % "0.7.14"
}

메이븐 저장소와 의존성을 같이 지정했다는 점에 유의하라. 왜냐하면 표준 프로젝트 라이브러리는 우리(=트위터)가 관리하고 있는데, 이 저장소는 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("https://svn.local.twitter.com/maven/")
}

이제 publish 명령을 내리면 결과를 불 수 있다.

[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 https://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 https://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 https://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:https://binaries.local.twitter.com/maven/com/twitter/sample/1.0-SNAPSHOT/에 가 보면 배포한 jar파일을 볼 수 있다.

작업(task) 추가하기

작업은 스칼라 함수이다. 작업을 추가하는 제일 쉬운 방법은 프로젝트 정의에 task 메소드를 사용한 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에 내장된 명령들도 대부분 이런 패턴을 따른다. 예를 들어 내장된 package 작업을 다음과 같이 변경하면 타임스탬프를 표시하도록 할 수 있다.

lazy val printTimestamp = task { log.info("current time is " + System.currentTimeMillis); None}
override def packageAction = super.packageAction.dependsOn(printTimestamp)

StandardProject를 보면 SBT 기본 설정을 약간 변경하거나, 새 작업을 추가하는 예를 많이 볼 수 있다.

정리

자주 쓰는 명령

다른 명령들

프로젝트 구성

(향후 정의할 예정)