 |  |
|
구현
Lift는 완전한 웹 애플리케이션 스택이며, 일반적인 MVC(Model-View-Controller)
프레임워크와는 조금 다른 방식을 사용하기는 하지만 전체 기능을 갖춘 MVC 구현을 제공한다. Lift는
프로젝트 구조를 빌드하고 종속성을 충족하기 위해 Maven을 매우 많이 사용한다. 이처럼 Maven을
사용하면 사용자가 직접 Scala를 다운로드 또는 설치하지 않더라도 필요한 작업이 자동으로 수행되므로
Lift를 쉽게 사용할 수 있다. 또한 Lift는 Maven을 사용하여 데이터베이스(Apache Derby)와 웹 서버(Jetty)를 포함하기
때문에 데이터베이스나 웹 서버가 없어도 Lift를 사용할 수 있다. 실제로 Jetty는 Comet 스타일
애플리케이션과 매우 잘 어울리는 웹 서버이며 Lift를 사용하면 별도의 프로그램을 다운로드하지
않아도 된다.
Lift 애플리케이션 작성하기
앞에서 설명한 대로 애플리케이션을 작성하는 작업을 포함한 대부분의 작업에
Maven을 사용하는 Lift는 Maven archetype을 사용하여 프로젝트 구조를 작성한다. 이 작업에서는 다음과
같은 매개변수와 함께 mvn archetype:generate 명령을 사용한다.
표 1. 샘플 이벤트 데이터 필드
| 매개변수 | 값 | 설명 |
|---|
archetypeGroupId | net.liftweb | 사용할 archetype에 대한 네임스페이스이다. | archetypeArtifactId | lift-archetype-basic | archetype에 대한 ID이다. 이 경우에는 "basic" 애플리케이션을 지정한다(아래 내용 참조). | archetypeVersion | 0.10-SNAPSHOT | archetype의 버전으로 Lift의 버전에 해당한다(아래 내용 참조). | remoteRepositories | http://scala-tools.org/repo-snapshots | archetype이 포함된 Maven 저장소의 URL이다. | groupId | org.developerworks | 애플리케이션의 네임스페이스이다. 이 값은 변경할 수 있다. 모든 코드는
이 패키지의 하위 패키지에 포함된다. | artifactId | auctionNet | 애플리케이션의 이름이다. |
몇 가지 추가로 설명할 사항이 있다. 첫 번째 사항은 basic archetype을 사용한다는
것이다. blank archetype도 있다. blank archetype을 사용할 경우에는 Maven에 의해 최소한의 Lift
애플리케이션이 생성된다. 이 애플리케이션은 애플리케이션 구조와 최소한의 부트스트랩 코드로
구성된다. 그러나 이 기사에서는 Mapper라는 Lift의 ORM 기술과 Comet 프레임워크를 설정하는 basic
archetype을 사용한다. 이 archetype은 사용자 모델을 생성하고 등록, 로그인 및 암호 찾기 등의 표준 사용자
관리 페이지에 필요한 모든 코드를 작성한다. 이 archetype은 이 애플리케이션을 비롯한 많은 애플리케이션에
필요한 기능이므로 이 기사에서도 이 archetype을 사용한다. 이렇게 하면 불필요한 작업을 한 가지 줄일
수 있다.
그 다음으로는 사용할 archetype의 버전을 살펴보자. 이 버전은 사용 중인 Lift의
버전에 해당한다. 이 튜토리얼에서는 0.10 버전의 스냅샷을 사용했다. 이 튜토리얼을 작성하던
당시의 최신 공식 릴리스는 0.9였지만 0.10에 몇 가지 유용한 기능이 추가되어 있었기 때문에
"최첨단" 버전을 사용하기로 결정했다. 여기에서 설명하는 코드의 일부는 이 튜토리얼의 후반부에서
다운로드할 수 있지만 공식 0.10 릴리스에서 실행하려면 약간의 수정이 필요할 수도 있다. 또한
이 튜토리얼에서는 스냅샷을 사용하기 때문에 "repo-snapshots" 저장소를 사용한다. 공식 릴리스용으로는
"repo-releases" 저장소가 있다. 지금까지 Maven에 대해 실행할 명령에 대해 설명했으므로 이제
명령을 실행하여 수행되는 작업을 살펴보자. Listing 1에서는 전체 명령과 출력을 보여 준다.
Listing 1. Maven으로 Lift 프로젝트 명령 작성하기
$ mvn archetype:generate -DarchetypeGroupId=net.liftweb
-DarchetypeArtifactId=lift-archetype-basic -DarchetypeVersion=0.10-SNAPSHOT
-DremoteRepositories=http://scala-tools.org/repo-snapshots
-DgroupId=org.developerworks.lift -DartifactId=auctionNet
[INFO] Scanning for projects...
[INFO] Searching repository for plugin with prefix: 'archetype'.
[INFO] ------------------------------------------------------------------------
[INFO] Building Maven Default Project
[INFO] task-segment: [archetype:generate] (aggregator-style)
[INFO] ------------------------------------------------------------------------
[INFO] Preparing archetype:generate
[INFO] No goals needed for project - skipping
[INFO] Setting property: classpath.resource.loader.class =>
'org.codehaus.plexus.velocity.ContextClassLoaderResourceLoader'.
[INFO] Setting property: velocimacro.messages.on => 'false'.
[INFO] Setting property: resource.loader => 'classpath'.
[INFO] Setting property: resource.manager.logwhenfound => 'false'.
[INFO] [archetype:generate]
[INFO] Generating project in Interactive mode
[INFO] Archetype repository missing. Using the one from
[net.liftweb:lift-archetype-basic:RELEASE -> http://scala-tools.org/repo-releases]
found in catalog internal
[INFO] snapshot net.liftweb:lift-archetype-basic:0.10-SNAPSHOT:
checking for updates from lift-archetype-basic-repo
[INFO] snapshot net.liftweb:lift-archetype-basic:0.10-SNAPSHOT:
checking for updates from scala-tools.org
[INFO] snapshot net.liftweb:lift-archetype-basic:0.10-SNAPSHOT:
checking for updates from scala-tools.org.snapshots
Downloading: http://scala-tools.org/repo-snapshots/net/liftweb/lift-archetype-basic/
0.10-SNAPSHOT/lift-archetype-basic-0.10-SNAPSHOT.jar
15K downloaded
Define value for version: 1.0-SNAPSHOT: :
Confirm properties configuration:
groupId: org.developerworks.lift
artifactId: auctionNet
version: 1.0-SNAPSHOT
package: org.developerworks
Y: : y
[INFO] ----------------------------------------------------------------------------
[INFO] Using following parameters for creating OldArchetype: lift-archetype-basic:
0.10-SNAPSHOT
[INFO] ----------------------------------------------------------------------------
[INFO] Parameter: groupId, Value: org.developerworks.lift
[INFO] Parameter: packageName, Value: org.developerworks.lift
[INFO] Parameter: basedir, Value: /Users/michael/code/lift/auction2
[INFO] Parameter: package, Value: org.developerworks.lift
[INFO] Parameter: version, Value: 1.0-SNAPSHOT
[INFO] Parameter: artifactId, Value: auctionNet
|
이 명령은 필요한 라이브러리를 모두 다운로드하고 프로젝트 구조를 설정하는
등의 필요한 모든 작업을 수행한다. Listing 2와 같이 mvn jetty:run을
실행하여 애플리케이션을 즉시 실행할 수 있다.
Listing 2. 애플리케이션 시작하기
$ cd auctionNet/
$ mvn jetty:run
[INFO] Scanning for projects...
[INFO] Searching repository for plugin with prefix: 'jetty'.
[INFO] ------------------------------------------------------------------------
[INFO] Building auctionNet
[INFO] task-segment: [jetty:run]
[INFO] ------------------------------------------------------------------------
[INFO] Preparing jetty:run
[INFO] [resources:resources]
[INFO] Using default encoding to copy filtered resources.
[INFO] [yuicompressor:compress {execution: default}]
[INFO] nb warnings: 0, nb errors: 0
[INFO] Context path = /
[INFO] Tmp directory = determined at runtime
[INFO] Web defaults = org/mortbay/jetty/webapp/webdefault.xml
[INFO] Web overrides = none
[INFO] Webapp directory = /Users/michael/code/lift/auctionNet/src/main/webapp
[INFO] Starting jetty 6.1.10 ...
2008-12-06 18:11:43.621::INFO: jetty-6.1.10
2008-12-06 18:11:44.844::INFO: No Transaction manager found - if your webapp
requires one, please configure one.
INFO - CREATE TABLE users (id BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY , firstname
VARCHAR(32) , lastname VARCHAR(32) , email VARCHAR(48) , locale VARCHAR(16) , timezone
VARCHAR(32) , password_pw VARCHAR(48) , password_slt VARCHAR(20) , textarea
VARCHAR(2048) , superuser SMALLINT , validated SMALLINT , uniqueid VARCHAR(32))
INFO - ALTER TABLE users ADD CONSTRAINT users_PK PRIMARY KEY(id)
INFO - CREATE INDEX users_email ON users ( email )
INFO - CREATE INDEX users_uniqueid ON users ( uniqueid )
2008-12-06 18:11:47.199::INFO: Started SelectChannelConnector@0.0.0.0:8080
[INFO] Started Jetty Server
[INFO] Starting scanner at interval of 5 seconds.
|
일부 라이브러리가 로드되면서 이전 목적에서 생성된 모든 코드가 WAR 파일로
컴파일 및 패키징된다. Listing 2에서는 이에 해당하는 정보가 삭제되어 있다. 여기에서 중요한
점은 Lift가 자동으로 데이터베이스를 생성한다는 것이다. 앞에서 설명한 대로 basic archetype을
사용하기 때문에 사용자 관리 시스템이 무료로 제공된다. Listing 2를 보면 사용자 관리 시스템에
대한 데이터베이스 테이블이 생성되었다는 것을 알 수 있다. 이제 사용자가 마련되었으며 별도의
추가 작업이 필요하지 않다. 항목 및 입찰을 위한 도메인 모델만 작성하면 된다.
Mapper로 도메인 모델링하기
Lift는 Mapper라고 하는 고유한 ORM(Object Relational Modeling) 기술을
제공한다. 앞의 예제에서 보았듯이 이 튜토리얼에서는 사용자 관리를 위해 이 기술을 이미
사용하고 있다. 생성된 User 코드는 <groupId>.model.User에
있으며, 여기서 groupId는 Maven을 사용하여 애플리케이션을 작성할 때 사용했던 ID이다. 이
경우에는 org.developerworks가 되며, 따라서 사용자 모델은 org.developerworks.model.User에
있다. 모든 Scala 코드는 /auctionNet/src/main/scala에 있다. Listing 3에서는 사용자 모델을
보여 준다.
Listing 3. 기본 Lift User 모델
/**
* The singleton that has methods for accessing the database
*/
object User extends User with MetaMegaProtoUser[User] {
override def dbTableName = "users" // define the DB table name
override def screenWrap = Full(<lift:surround with="default"
at="content"><lift:bind /></lift:surround>)
// define the order fields will appear in forms and output
override def fieldOrder = id :: firstName :: lastName :: email ::
locale :: timezone ::
password :: textArea :: Nil
// comment this line out to require email validations
override def skipEmailValidation = true
}
/**
* An O-R mapped "User" class that includes first name, last name, password
* and we add a "Personal Essay" to it
*/
class User extends MegaProtoUser[User] {
def getSingleton = User // what's the "meta" server
// define an additional field for a personal essay
object textArea extends MappedTextarea(this, 2048) {
override def textareaRows = 10
override def textareaCols = 50
override def displayName = "Personal Essay"
}
}
|
이 코드는 Lift의 Mapper API를 이해하는 데 많은 도움이 된다. Listing 3의
맨 아래에 있는 User 클래스부터 살펴보자. 이 클래스는 MegaProtoUser라는
기존 클래스를 확장한다. 이 클래스는 Lift 소스 코드에서 볼 수 있으며 코드의 주석에 설명된
것처럼 이름, 성 및 암호를 제공한다. 이 코드에서는 사용자를 사용자 정의하는 방법을 볼 수
있다. 이 경우에는 textArea라는 데이터베이스 열에 맵핑된 "Personal Essay"를 추가한다.
Mapper에서 특이한 점 중 하나는 맵핑된 클래스의 필드(데이터베이스 열)가 var
또는 val(변경되지 않는 필드인 경우)과 같은 일반적인 Scala 필드가 아니라는 것이다. 대신 이러한
필드는 Scala 오브젝트 즉, 싱글톤이다. 따라서 이러한 필드는 포함하는 클래스 내에 중첩된 싱글톤으로
간주할 수 있기 때문에 여러 Users(클래스이므로)를 사용할 수 있다. 그러나
각각의 User에는 단 하나의 textArea 오브젝트만
있을 수 있다. User 클래스를 보면 오브젝트를 사용하여 얻을 수 있는 장점을
볼 수 있다. 오브젝트가 Lift 클래스 net.liftweb.mapper.MappedTextarea를
확장한다. 기존 클래스에 대한 서브클래스를 작성하여 동작을 재정의함으로써 필드를 사용자 정의할 수
있다. User 클래스에서는 해당 필드를 HTML TextArea 요소로 표현하는 방법을
변경하기 위해 이 작업을 수행한다. 모든 Mapper 필드 유형(MappedString, MappedLong 등)에는 내장된
HTML 표현이 있으므로 이 작업은 정상적으로 수행된다. 예를 들어, Listing 4에서는 MappedTextarea
클래스를 보여 준다.
Listing 4. Lift의 MappedTextarea 클래스
class MappedTextarea[T<:Mapper[T]](owner : T, maxLen: Int) extends
MappedString[T](owner, maxLen) {
/**
* Create an input field for the item
*/
override def _toForm: Can[NodeSeq] = {
val funcName = S.mapFunc({s: List[String] => this.setFromAny(s)})
Full(<textarea name={funcName}
rows={textareaRows.toString}
cols={textareaCols.toString} id={fieldId}>{is.toString}</textarea>)
}
override def toString = {
val v = is
if (v == null || v.length < 100) super.toString
else v.substring(0,40)+" ... "+v.substring(v.length - 40)
}
def textareaRows = 8
def textareaCols = 20
}
|
예제 코드에서 볼 수 있듯이 MappedTextarea는 MappedString을
확장하므로 데이터베이스 열에 맵핑될 때 문자열로 처리된다. 이 클래스에는 Scala의 XML 구문과
Lift의 도우미 함수를 사용하여 일반적인 HTML TextArea를 생성하는 _toForm
메소드가 있다. User 클래스에서는 행 수, 열 수 및 표시 이름을
재정의했다. 맵핑된 유형을 서브클래스화할 때 여러 가지 기능을 추가 또는 변경할 수 있으며, 특히
사용자 정의 유효성 검증 논리를 추가하기에 좋은 위치이다. 예를 들어, 에세이에서 HTML 문자를
허용하지 않으려는 경우 HTML 문자를 검사하는 정규 표현식을 추가할 수 있다. MappedString
클래스에는 정규 표현식을 실행하는 메소드뿐만 아니라 최소 및 최대 길이를 검사하는 기능과 데이터베이스에서
문자열의 고유성을 검사하는 기능 등의 일반적인 기능을 수행하는 데 사용되는 여러 가지 메소드가
있다. 물론 유형을 서브클래스화하면서 원하는 코드를 추가하여 좀 더 정교한 논리를 수행할 수도 있다.
다행히 여기에서는 User 클래스가 필요한 기능을 모두
제공한다. 다시 한번 Listing 3의 User 클래스를 보면 getSingleton이라는
메소드가 있다는 것을 알 수 있다. 이 메소드는 User 클래스 위에 정의된
User 오브젝트를 리턴한다. 이 오브젝트는 User
클래스 및 데이터베이스에 맵핑되는 방법에 대한 메타데이터를 나타낸다. 여기에서는 데이터베이스
테이블의 이름을 정의하는 작업과 Users를 나열하거나 User
양식을 생성할 때 표시할 필드를 정의하는 작업을 일반적으로 수행한다. 또한 User
오브젝트에서는 팩토리 및 검색 메소드와 같은 클래스 레벨 메소드를 추가할 수도 있다. Scala에서는 정적
변수 및 메소드가 사용되지 않으므로 이들 메소드를 User 클래스에 직접 연결할
수 없다.
이 예제에서는 오브젝트와 클래스의 이름이 둘 다 User라는
것을 알 수 있다. 이는 Scala에서 사용되는 일반적인 패러다임으로 이들을 동료 오브젝트라고 한다. 사용자
정의 모델에서는 코드를 쉽게 이해할 수 있도록 하기 위해 이 규칙을 조금 어기면서 "MetaData"라는
접미어를 붙일 것이다. 지금까지 Lift의 Mapper API를 살펴보았으며, 이제 몇 가지 사용자 정의 모델을
만드는 방법을 설명한다. 먼저 그림 1에서는 앞으로 만들 데이터 모델을 보여 준다.
그림 1. Auction Net 데이터 모델
이는 매우 간단한 경매 모델이다. 항목에는 이름, 설명, 최저 입찰 가격 및 마감
날짜가 있다. 모든 사용자가 모든 항목에 대해 입찰할 수 있으므로 이들 간에는 전형적인 다대다
관계가 형성된다. 관계형 데이터베이스 스키마에서 입찰은 join 테이블처럼 작동하지만 실제로는
입찰마다 가격이 있으므로 고유 권한을 가지고 있어야 한다. 지금까지 코드로 모델링할 내용인 Item
모델을 살펴보았다. Listing 5에서 이 모델을 보여 준다.
Listing 5. Item 모델
object ItemMetaData extends Item with KeyedMetaMapper[Long, Item]{
override def dbTableName = "items"
override def fieldOrder = List(name, description, reserve, expiration) }
class Item extends KeyedMapper[Long, Item] with CRUDify[Long, Item] {
def getSingleton = ItemMetaData
def primaryKeyField = id
object id extends MappedLongIndex(this)
object reserve extends MappedInt(this)
object name extends MappedString(this, 100)
object description extends MappedText(this)
object expiration extends MappedDateTime(this)
lazy val detailUrl = "/details.html?itemId=" + id.is
def bids = BidMetaData.findAll(By(BidMetaData.item, id))
def tr = {
<tr>
<td><a href={detailUrl}>{name}</a></td>
<td>{highBid.amount}</td>
<td>{expiration}</td>
</tr>
}
def highBid:Bid = {
val allBids = bids
if (allBids.size > 0){
return allBids.sort(_.amount.is > _.amount.is)(0)
}
BidMetaData.create
}
}
|
다시 한번 Item 클래스부터 살펴보면 대부분의 내용이
매우 일반적으로 구성되어 있다는 것을 알 수 있다. 여기에서 각 필드는 맵핑된 데이터 유형(long,
integer, string, date 등)을 확장한 오브젝트로 정의한다. 또 다른 속성인 detailUrl은
lazy val로 정의한다. 이는 호출될 때까지 계산되지 않는 변경할 수 없는 값이라는 뜻이다. 여기에서는
이 구문이 큰 역할을 수행하지는 않지만 평가가 복잡하고 가끔씩 호출되는 경우에는 유용한 구문이
될 수 있다. 그리고 모든 입찰을 쿼리하는 bids 메소드가 있다. Bid 클래스에
대해서도 곧 살펴볼 것이다. 항목을 HTML 테이블의 한 행으로 표현하는 tr
메소드도 있다.
마지막으로 highBid 메소드는 데이터베이스에서
검색된 모든 입찰가를 정렬하여 가장 높은 입찰가를 가져온다. 이 정렬은 데이터베이스에 의해
수행되기는 하지만 이 예제에서는 Scala에서 목록을 정렬하는 등의 일반적인 작업을 얼마나 쉽게
사용할 수 있는지를 보여 준다. 클로저를 sort 메소드에 전달하고 Scala의 클로저 약식 구문을
사용했다. 클로저에게 전달되는 매개변수에 밑줄(_)이 "채워져" 있다. 입찰 목록의 경우, 이 매개변수는
비교할 두 항목이 된다. 여기에서는 입찰가를 기준으로 입찰을 정렬할 것이므로 _.amount.is를
사용한다. 마지막 부분(.is)은 amount 오브젝트에 대한 is 메소드를
호출한다. 여기에서는 일반 필드가 아닌 복합 오브젝트를 사용하기 때문에 is
메소드는 필드의 값을 검색한다. 마지막으로 정렬을 완료한 후 (0) 구문을 살펴보자. 정렬이
완료되면 입찰 목록이 생성된다. 해당 목록의 첫 번째 요소를 원할 경우 (0)을 사용할 수 있다. 다시
한번 말하지만 이 구문은 Scala 약식 구문이다. 실제로 사용자는 값 0을 사용하여 목록에 대해
apply 메소드를 호출한다. 목록은 함수처럼 처리되며 이 방법은 항상 apply 메소드에 대한 약식 방법이다.
지금까지 Item 클래스의 실제 선언을 제외한 대부분의
항목을 살펴보았다. 먼저 Lift 특성 KeyedMapper를 확장하는 Item을
선언한다. 이 특성은 매개변수화된 특성이며, 여기에서 매개변수는 맵핑된 클래스의 기본 키와
맵핑된 클래스 자체의 유형이다. 그리고 CRUDify라는 다른 특성과 함께
KeyedMapper를 확장한 후 Scala의 혼합 모델을 사용하여 여러 상속을
시뮬레이션한다. 이제 KeyedMapper 및 CRUDify의
동작을 살펴보자. CRUDify는 매개변수화된 특성이다. 이는 Scala에서
일반적으로 사용되는 또 하나의 패러다임이다. 특성은 여러 가지 유형을 안전하게 사용할 수 있도록
매개변수화된다. 다른 언어(예: Python 및 Ruby)에서는 대개 혼합이 중요하지 않게 다루어지거나
요구 사항(특정 필드 또는 메소드의 존재 등)이 있지만 표현할 수 있는 방법이 없다. 클래스를
사용하여 이들 항목을 혼합할 수 있으며 런타임 오류가 발생하기 전까지는 혼합을 사용하기 위해
클래스를 수정해야 한다는 것을 알 수 없다. Scala의 매개변수화된 특성을 사용하면 이 문제를
방지할 수 있다.
Item 모델에는 KeyedMapper 및
CRUDify 특성이 둘 다 있다. KeyedMapper 특성은 데이터베이스 테이블에 맵핑되지만
CRUDify는 이름으로 알 수 있듯이 모델에 대한 CRUD 기능 즉, Create, Read,
Update 및 Delete 함수를 제공한다. Lift에서는 Items
목록을 표시하거나, 새 Item을 생성하거나, 기존 Item을
편집하거나 Item을 삭제하는 작업에 필요한 모든 상용구 코드(UI 포함)가 자동으로
작성된다. 새 항목을 나열하는 기능이 필요한 함수형 설계에서 CRUDify를 사용하면
이 작업을 간단하게 수행할 수 있다. 코드를 추가로 작성할 필요가 없으며 단지 추가 특성만 사용하면 된다. 지금까지
Item 모델을 살펴보았다. 이제 Bid 모델을 살펴보자. Listing 6에서 이 모델을 보여 준다.
Listing 6. Bid 모델
object BidMetaData extends Bid with KeyedMetaMapper[Long, Bid]{
override def dbTableName = "bids"
override def fieldOrder = amount :: Nil
override def dbIndexes = Index(item) :: Index(user) :: super.dbIndexes
}
class Bid extends KeyedMapper[Long, Bid]{
def getSingleton = BidMetaData
def primaryKeyField = id
object id extends MappedLongIndex(this)
object amount extends MappedLong(this)
object item extends MappedLongForeignKey(this, ItemMetaData)
object user extends MappedLongForeignKey(this, User)
}
|
매우 간단한 클래스인 이 모델에는 Item 오브젝트와
User 오브젝트가 있다. 이들 오브젝트는 Lift의 매개변수화된 클래스인
MappedLongForeignKey를 확장한다. 이 클래스는 결합할 대상을 나타내기
위해 메타데이터 오브젝트(Listing 5의 ItemMetaData 및 Listing 3의
User 오브젝트)를 전달한다. 또한 메타데이터 오브젝트에서 두 개의
외부 키 열에 데이터베이스 인덱스를 지정했으며 이를 통해 항목 또는 사용자를 기준으로 입찰을
쿼리할 것임을 예상할 수 있다. 지금까지 도메인 모델을 정의했으므로 이제 이 모델을 사용하는
코드를 작성할 준비가 완료되었다.
Actor
이 튜토리얼에서는 Lift의 CRUDify 특성을 사용하여
항목 관리를 처리하고 Lift의 기본 제공 지원을 사용하여 사용자 관리를 처리하므로 사용자는 입찰
시스템만 빌드하면 된다. 이 작업은 일반적인 제어기/CRUD 코드를 사용하여 수행할 수 있지만 Comet
시스템으로 만들어야 하므로 Scala의 동시성 스택인 Actor를 사용한다. Actor는 경량 스레드와
비슷하지만 공유 메모리를 사용하지 않으므로 동기화, 잠금 등이 필요하지 않다. Actor는 메시지와
통신한다. Scala의 case class(필수적으로 형식이 지정된 데이터 구조체)와 패턴 일치를 결합하여
손쉽게 메시지를 수신하고 응답할 수 있다. 여기에서는 먼저 Auctioneer Actor를 만든다. 이 Actor는
항목에 대한 입찰 메시지를 수신하고 항목에 대한 신규 경매를 알리는 메시지를 배포한다. 이제
사용할 메시지의 유형을 살펴보자. Listing 7에서는 이러한 메시지를 보여 준다.
Listing 7. 경매 메시지
case class AddListener(listener:Actor, itemId:Long)
case class RemoveListener(listener:Actor, itemId:Long)
case class BidOnItem(itemId:Long, amount:Long, user:User)
case class GetHighBid(item:Item)
case class TheCurrentHighBid(amount:Long, user:User)
case class Success(success:Boolean)
|
처음 두 메시지는 Item ID에 따라 수신기를 추가 및 제거하는 데 사용된다. AddListener
메시지를 Auctioneer에게 보내서 특정 항목에 관심이 있음을 알린다. 입찰하려면 BidOnItem
메시지를 보낸다. 새 입찰이 발생하면 Auctioneer가 새 TheCurrentHighBid
메시지를 사용자에게 보낼 수 있다. 마지막으로 Success 메시지는 AddListener
요청이 성공했음을 나타낸다. 이제 이러한 강력한 형식의 오브젝트에 대한 패턴 일치를 수행할 수 있다. Listing 8의
Auctioneer를 살펴보자.
Listing 8. Auctioneer Actor
object Auctioneer extends Actor{
val listeners = new HashMap[Long, ListBuffer[Actor]]
def notifyListeners(itemId:Long) = {
if (listeners.contains(itemId)){
listeners(itemId).foreach((actor) => {
val item = ItemMetaData.findByKey(itemId).open_!
actor ! highBid(item)
})
}
}
def act = {
loop {
react {
case AddListener(listener:Actor, itemId:Long) =>
if (!listeners.contains(itemId)){
listeners(itemId) = new ListBuffer[Actor]
}
listeners(itemId) += listener
reply(Success(true))
case RemoveListener(listener:Actor, itemId:Long) =>
listeners(itemId) -= listener
case GetHighBid(item:Item) =>
reply(highBid(item))
case BidOnItem(itemId:Long, amount:Long, user:User) =>
val item =
ItemMetaData.findAll(By(ItemMetaData.id, itemId)).firstOption.get
val bid = BidMetaData.create
bid.amount(amount).item(item).user(user).save
notifyListeners(item.id)
}
}
}
def highBid(item:Item):TheCurrentHighBid = {
val highBid = item.highBid
val user = highBid.user.obj.open_!
val amt = highBid.amount.is
TheCurrentHighBid(amt, user)
}
start
}
|
Auctioneer는 각 항목에 관심이 있는 사용자를 추적하기
위해 맵을 유지한다. 맵에 대한 키는 Item ID이며 값은 관심을 가지고 있는 Actor
목록이다. Actor의 주요 부분은 act 메소드로 반드시
구현해야 하는 Actor 특성의 추상 메소드이다. loop -> react는
Actor에서 전형적으로 사용되는 구문이다. 이 함수 루프는 scala.actors.Actor
오브젝트에 정의되며 클로저를 인수로 받아서 반복적으로 실행한다. 조건부를 인수로 받아서 조건부가 true인 동안 반복적으로
실행되는 loopWhile 구문도 있다. 이 구문을 사용하여 Actor를 손쉽게 종료할 수 있다. react
메소드는 scala.actors.Actor 특성에 정의되며 메시지를 받은 후 전달된 클로저를 실행한다. 해당
클로저 내에서는 Scala의 패턴 일치를 사용한다. 수신될 수 있는 메시지의 유형을 일치시킨다. 특히 BidOnItem
메시지가 수신된 경우에는 새 입찰을 데이터베이스에 저장한 후 수신기에게 알린다.
notifyListeners 메소드는 Item ID 맵을 사용하여
특정 항목에 관심이 있는 모든 Actor를 가져온다. 그런 다음 관심이
있는 각 Actor에게 새 TheCurrentBid
메시지를 보낸다. 이 작업은 actor ! highBid(item) 코드에서
수행된다. (이 코드는 actor.!(highBid(item))로 작성할 수도
있다.) 다시 말해서 Actor 클래스에 !
메소드가 있다. actor ! highBid(item) 구문은 이해하기도 쉽고
다른 언어(예: Erlang)에서 행위자를 구현하는 방법과도 일치한다. 이러한 언어에는 행위자에
대한 특정 구문 지원이 있지만 Scala에는 그러한 지원 기능이 없다. 기본적으로 Actor는 강력한
구문을 사용하여 빌드된 Scala 기반의 DSL(Domain Specific Language)이다.
Auctioneer로 돌아가서 마지막으로 코드의 마지막
행을 살펴보자. 이 행에서는 start 메소드가 호출되면서 Actor가
실행되며, 따라서 Actor의 act 메소드가 비동기적으로 호출된다. 이제부터
Auctioneer는 지속적으로 실행되면서 다른 Actor와
메시지를 주고 받는다. Java의 동시 프로그래밍과는 많이 다르지만 여러 가지 면에서 훨씬 더 간단하다. 이쯤되면
어떤 대상이 Auctioneer와 메시지를 주고 받는가라는 질문이 생기기
마련이다. 이러한 대상은 애플리케이션에서 단일 Actor만을 사용하고 싶어하지는 않을 것이다. 이
질문에 대한 답을 하기 위해 이 튜토리얼에서는 Lift의 CometActor를
사용한다.
CometActor
Lift의 CometActor 특성은 Scala의 Actor
특성의 확장이다. 이 특성을 사용하면 Actor를 Comet 애플리케이션에서
쉽게 사용할 수 있다. CometActor는 다른 Actor의
메시지에 대한 응답으로 해당 사용자에게 UI 업데이트를 보낼 수 있다. Maven archetype이 Comet 패키지를
만들 때 CometActor를 패키지에 포함시켜 놓으면 애플리케이션에서 CometActor를
자동으로 사용할 수 있다. Auction Net의 경우에는 CometActor를 만들어서
입찰에 참여하고 최고 입찰가 변경 시 업데이트를 받는다. 이 Actor의 이름은 AuctionActor이며
Listing 9에서 보여 준다.
Listing 9. Auction Actor
class AuctionActor extends CometActor {
var highBid : TheCurrentHighBid = null
def defaultPrefix = "auction"
val itemId = S.param("itemId").map(Long.parseLong(_)).openOr(0L)
override def localSetup {
Auctioneer !? AddListener(this, this.itemId) match {
case Success(true) => println("Listener added")
case _ => println("Other ls")
}
}
override def localShutdown {
Auctioneer ! RemoveListener(this, this.itemId)
}
override def lowPriority : PartialFunction[Any, Unit] = {
case TheCurrentHighBid(a,u) => {
highBid = TheCurrentHighBid(a,u)
reRender(false)
}
case _ => println("Other lp")
}
}
|
이 Listing은 CometActor의 수명 주기를 보여
준다. 이 Actor를 호출하면 localSetup 메소드가 호출된다. 이
메소드는 itemId 속성을 사용하여 AddListener
메시지를 Auctioneer Actor에게 보낸다. 이 코드에서는 !?를
사용하여 메시지를 보낸다. 이는 동기 호출이다. AuctionActor는 해당
사용자가 특정 항목에 관심이 있다는 것을 Auctioneer가 알 때까지 전혀
사용되지 않는다. Auctioneer로부터 회신을 받게 되면 패턴
일치를 사용하여 수행할 작업을 결정하고 간단한 로그 정보를 남긴다. CometActor를
모두 사용한 후에는 localShutdown 메소드가 호출된다. 이 메소드는
단순히 RemoveListener 메시지를 Auctioneer에게
보낸다. Auction Actor는 실행되는 동안 lowPriority
메소드를 사용하여 메시지를 수신한다. (highPriority 메소드 등도
있다.) Auction Actor는 사용자가 메시지를 받을 때 다시 한번 패턴
일치를 사용한다. TheCurrentHighBid 메시지가 수신되면 이 Actor는
최고 입찰가를 추적한 후 reRender를 호출한다. 이는 CometActor에
정의된 메소드이다. Listing 9에서는 수명 주기에 집중할 수 있도록 렌더링 코드를 생략했다. 이제
Listing 10에서 렌더링 코드를 살펴보자.
Listing 10. Auction Actor 렌더링
class AuctionActor extends CometActor {
var highBid : TheCurrentHighBid = null
def defaultPrefix = "auction"
val itemId = S.param("itemId").map(Long.parseLong(_)).openOr(0L)
def render = {
def itemView: NodeSeq = {
val item = if (itemId > 0)
ItemMetaData.findByKey(itemId).openOr(ItemMetaData.create)
else ItemMetaData.create
val currBid = item.highBid
val bidAmt = if (currBid.user.isEmpty) 0L else currBid.amount.is
highBid = TheCurrentHighBid(bidAmt,
currBid.user.obj.openOr(User.currentUser.open_!))
val minNewBid = highBid.amount + 1L
val button = <button type="button">{S.?("Bid Now!")}</button> %
("onclick" -> ajaxCall(JsRaw("$('#newBid').attr('value')"), bid _))
(<div>
<strong>{item.name}</strong>
<br/>
<div>
Current Bid: ${highBid.amount} by {highBid.user.niceName}
</div>
<div>
New Bid (min: ${minNewBid}) :
<input type="text" id="newBid"/>
{button}
</div>
{item.description}<br/>
</div>)
}
bind("foo" -> <div>{itemView}</div>)
}
def bid(s:String): JsCmd = {
val user = User.currentUser.open_!
Auctioneer ! BidOnItem(itemId, Long.parseLong(s), user)
Noop
}
}
|
render 메소드는 반드시 구현해야 하는
CometActor 특성의 추상 메소드이다. 이 메소드에서는 항목을
검색한 후 XHTML 코드를 작성해서 해당 사용자에게 되돌려 보낸다. 이 코드에서 가장 흥미 있는
부분은 "Bid Now!"라고 표시되는 기본 단추를 생성하는 단추 값이다. S.?
함수를 사용하면 문자열을 현지화할 수 있다. % 메소드는 속성을
요소에 추가한다. 이 경우에는 JavaScript 함수를 작성하여 단추의 onclick
이벤트에 응답한다. ajaxCall 함수는 Lift의 SHtml
도우미 오브젝트에 정의된다. 이 함수의 첫 번째 매개변수는 JsExp(JavaScript
표현식) 인스턴스이다. JsRaw 오브젝트를 사용하여 원시 JavaScript
문자열을 랩핑하고 JsExp를 작성한다.
jQuery에 익숙한 사용자라면 전달하는 원시 JavaScript를 쉽게 이해할 것이다. jQuery
라이브러리는 기본적으로 Lift에 포함되어 있다. jQuery의 $ 함수는 CSS
스타일 선택기를 받은 후 선택기를 충족하는 DOM 트리의 요소를 리턴한다. 이 경우 사용자는 #newBid를
전달하며, 이 값은 CSS에서 ID가 newBid인 요소를 지정한다. jQuery
라이브러리는 attr 함수를 DOM 요소에 추가한다. 이 함수를 사용하면
요소의 속성 값을 쉽게 가져올 수 있다. 이 튜토리얼에서는 value
속성을 가져온다. 코드를 자세히 보면 newBid 요소가 텍스트 입력
필드이기 때문에 value 속성은 사용자가 텍스트 입력 필드에 입력하는 값이라는 점을 알 수 있다.
jQuery 표현식이 평가된 후 해당 값이 Ajax 호출에 전달된다. ajaxCall
함수의 두 번째 매개변수는 문자열을 받은 후 Lift JsCmd 유형의
인스턴스를 리턴하는 클로저이다. 여기에서는 사용자가 정의한 bid 함수에 문자열을 전달하는
클로저에 대해 Scala의 약식 구문을 다시 한번 사용한다. bid 함수는
현재 사용자를 가져와서 문자열을 Long 형으로 구문 분석한 후 이러한 두 값을 사용하여 BidOnItem
메시지를 작성한다. 그런 다음 이 메시지를 Auctioneer에게 보낸 후 Lift의
Noop 오브젝트를 리턴한다. 이는 아무 작업도 수행하지 않는 no
op를 나타내는 JsCmd이다.
bid 메소드에서 itemId를
BidOnItem 메시지의 일부로 사용하고 있음을 알 수 있다. 이 값은 사용자가
항목에 대한 입찰에 참여한 후에도 유지된다. 바로 이 점이 클로저의 장점이다. 즉, 클로저는 자신이
생성된 포함 컨텍스트를 그대로 유지한다. ajaxCall에 전달된 클로저가 실행될
때마다 클로저는 자신이 생성될 때 액세스한 모든 데이터를 "기억"한다.
Auction Actor의 작동 방법을 살펴보았으므로 이제는
이 Actor를 사용하는 페이지를 작성해야 한다. 이 페이지는 항목 세부 사항 페이지이며 Listing
11에서 보여 준다.
Listing 11. 항목 세부 사항 보기
<lift:surround with="default" at="content">
<lift:comet type="AuctionActor">
<auction:foo>Loading...</auction:foo>
</lift:comet>
</lift:surround>
|
보다시피 이 페이지는 매우 간단한 페이지이다. 이 페이지에는 Lift의 기본 페이지
레이아웃이 사용되며 Auction Actor에 대한 코드가 포함되어 있다. auction:foo
태그에 대해 궁금하다면 Listing 10을 다시 한번 살펴보자. Listing 10에서는 CometActor의
defaultPrefix를 auction으로 정의했으며
bind 명령을 사용하여 render에서 작성한 XHTML을 foo에
바인딩했기 때문에 여기에서 auction:foo를 사용하는 것이다. 이 오브젝트는
비동기적으로 페이지에 로드되므로 CometActor가 로드될 때까지 "Loading..."
텍스트를 자리 표시자로 사용한다.
페이지를 사이트에 추가하려면 페이지를 애플리케이션의 SiteMap에
추가해야 한다. SiteMap은 액세스할 수 있는 페이지로 구성된 허용 목록인 Lift 생성자이며 탐색 메뉴, 이동 경로
등을 생성하는 데 사용된다. SiteMap은 Listing 12와 같이 Lift의 Boot 클래스에서
지정된다.
Listing 12. Lift 부트스트랩 코드
class Boot {
def boot {
if (!DB.jndiJdbcConnAvailable_?)
DB.defineConnectionManager(DefaultConnectionIdentifier, DBVendor)
// where to search snippet
LiftRules.addToPackages("org.developerworks")
Schemifier.schemify(true, Log.infoF _, User, ItemMetaData, BidMetaData)
// Build SiteMap
val entries:List[Menu] = Menu(Loc("Home", List("index"), "Home")) ::
Menu(Loc("Item Details", List("details"), "Item Details", Hidden)) ::
User.sitemap ++ ItemMetaData.menus
LiftRules.setSiteMap(SiteMap(entries:_*))
/*
* Show the spinny image when an Ajax call starts
*/
LiftRules.ajaxStart =
Full(() => LiftRules.jsArtifacts.show("ajax-loader").cmd)
/*
* Make the spinny image go away when it ends
*/
LiftRules.ajaxEnd =
Full(() => LiftRules.jsArtifacts.hide("ajax-loader").cmd)
LiftRules.appendEarly(makeUtf8)
S.addAround(DB.buildLoanWrapper)
}
/**
* Force the request to be UTF-8
*/
private def makeUtf8(req: HttpServletRequest) {
req.setCharacterEncoding("UTF-8")
}
}
|
이 파일(Boot.scala)에는 데이터베이스 설정을
변경하는 코드도 들어 있지만 이 애플리케이션의 경우에는 아무 것도 변경되지 않았다. 위
코드의 대부분은 Maven archetype의 일부로 생성되었으며 변경된 두 가지 중요 사항이 있다. 첫 번째는
Item 및 Bid 메타데이터 오브젝트가
Schemifier에 대한 호출에 추가되었다는 것이다. Schemifier는 사용자를 대신해서 사용자의
데이터베이스를 생성한다. 두 번째 변경 사항은 SiteMap을 빌드하는 데 사용된 항목에서 항목
세부 사항 페이지의 새 위치와 Item에 대한 CRUD 페이지가 추가되었다는
것이다. 이제 코드를 모두 살펴보았으므로 애플리케이션을 실행할 준비가 완료되었다.
애플리케이션 실행하기
애플리케이션이 실행 중인 경우에는 mvn install을 실행하여
코드를 다시 컴파일할 수 있다. 그렇지 않은 경우에는 mvn jetty:run을 다시
실행하여 애플리케이션을 시작한다. http://localhost:8080에 있는 페이지에 액세스할 수 있어야 한다. 이
페이지에서는 등록하고, 일부 항목을 생성하고 입찰을 시작할 수 있다. 작동 중인 Comet 기능을 보기 위해
두 개의 브라우저를 사용하여 각 브라우저에서 각기 다른 사용자로 로그인한 후 같은 항목을 본다. 각 사용자는
개별적으로 해당 항목에 입찰할 수 있으며 다른 사용자가 입찰할 경우 두 사용자 모두 업데이트된다. 그림
2에서는 이에 대한 스크린샷을 보여 준다.
그림 2. Auction Net에서 입찰하기
입찰할 때 수행되는 작업이 궁금한 경우 Firefox의 Firebug와 같은 도구를
사용하여 HTTP 트래픽을 검토할 수 있다. Listing 13에서는 샘플 출력을 보여 준다.
Listing 13. Comet 트래픽
try { destroy_LC2EAICJRLWEKDM4EXIPE2(); } catch (e) {}
try{jQuery('#LC2EAICJRLWEKDM4EXIPE2').each(function(i) {
this.innerHTML = '\u000a <div><div>\u000a
<strong>Programming in Scala</strong>\u000a';});} catch (e) {}
try { /* JSON Func auction $$ F1229133085612927000_NGB */
function F1229133085612927000_NGB(obj) {
lift_ajaxHandler('F1229133085612927000_NGB='+
encodeURIComponent(JSON.stringify(obj)),
null,null);} } catch (e) {}
try { destroy_LC2EAICJRLWEKDM4EXIPE2 = function() {}; } catch (e) {}
lift_toWatch['LC2EAICJRLWEKDM4EXIPE2'] = '11';
|
Listing 13에서 볼 수 있듯이 Lift는 실행할 JavaScript 스크립트를 리턴한다. 이
스크립트는 jQuery를 사용하여 원래 렌더링의 컨텐츠를 새 렌더링으로 대체한다. 이러한 작업은
jQuery('#...').each 표현식에서 수행된다. 이 표현식은 매우 이상하게
보이는 ID(보안을 위해 Lift에 의해 임의로 생성되는 ID로서 Lift의 우수한 기능 중 하나임)를 가진
요소를 찾은 후 검색된 각 요소에게 클로저를 전달한다. 이 클로저는 innerHTML을 서버에서 생성된
HTML과 대체한다. Listing 13에서는 중요 내용을 효과적으로 설명하기 위해 전체 HTML이 생략되었다. 그런
다음 또 다른 Comet 세션이 시작된다. 이 세션을 보면 Lift에 의해 자동으로 생성된 세션이라는 것을
알 수 있으며, 이는 곧 사용자가 세션을 직접 생성하지 않아도 된다는 것을 뜻한다.
|  |