도메인 특화 언어
도메인 특화 언어 (Domain-Specific Languages)
Groovy는 도메인 특화 언어(DSL)를 만드는 데 아주 좋은 언어예요. DSL을 직접 만들려면 클로저를 비롯한 다양한 기법을 써야 하는데, 이 문서에서는 명령 체인(command chains), 연산자 오버로딩, 스크립트 기반 클래스, @DelegatesTo, 컴파일 커스터마이저, 그리고 다양한 빌더들을 차례로 살펴볼 거예요.
본문
1. 명령 체인 (Command chains)
Groovy는 최상위 문장에서 메서드 호출의 인자 주변 괄호를 생략할 수 있어요. "명령 체인(command chain)" 기능은 이걸 확장해서, 그런 괄호 없는 메서드 호출을 연결할 수 있게 해 줘요. 인자 주변의 괄호도, 연결된 호출 사이의 점(dot)도 필요하지 않아요. 기본 아이디어는 a b c d 같은 호출이 실제로는 a(b).c(d)와 동등해진다는 거예요. 이건 여러 인자, 클로저 인자, 심지어 이름 붙은 인자(named arguments)에서도 동작해요. 게다가 이런 명령 체인은 할당문의 오른쪽에도 나타날 수 있어요. 이 새 문법이 지원하는 예시를 몇 가지 볼게요.
// equivalent to: turn(left).then(right)
turn left then right
// equivalent to: take(2.pills).of(chloroquinine).after(6.hours)
take 2.pills of chloroquinine after 6.hours
// equivalent to: paint(wall).with(red, green).and(yellow)
paint wall with red, green and yellow
// with named parameters too
// equivalent to: check(that: margarita).tastes(good)
check that: margarita tastes good
// with closures as parameters
// equivalent to: given({}).when({}).then({})
given { } when { } then { }
인자를 받지 않는 메서드를 체인에서 쓰는 것도 가능한데, 그 경우에는 괄호가 필요해요.
// equivalent to: select(all).unique().from(names)
select all unique() from names
명령 체인에 홀수 개의 요소가 들어 있다면, 체인은 메서드/인자로 구성되고 마지막 프로퍼티 접근으로 끝나요.
// equivalent to: take(3).cookies
// and also this: take(3).getCookies()
take 3 cookies
이런 명령 체인 방식은 Groovy로 쓸 수 있는 DSL의 범위를 훨씬 넓혀 주는 흥미로운 가능성을 열어 줘요.
위 예시들은 명령 체인 기반 DSL을 사용하는 법을 보여 주지만, 어떻게 만드는지는 보여 주지 않아요. 쓸 수 있는 전략이 여러 가지 있는데, 그런 DSL을 만드는 법을 보여 주기 위해 예시를 몇 개 들게요. 먼저 맵과 클로저를 사용하는 경우예요.
show = { println it }
square_root = { Math.sqrt(it) }
def please(action) {
[the: { what ->
[of: { n -> action(what(n)) }]
}]
}
// equivalent to: please(show).the(square_root).of(100)
please show the square_root of 100
// ==> 10.0
두 번째 예시로, 기존 API 중 하나를 단순화하는 DSL을 어떻게 쓸지 생각해 볼게요. 아마 이 코드를 고객이나 비즈니스 분석가, 테스터 같은 하드코어한 자바 개발자가 아닌 사람들 앞에 놓아야 하는 상황이 있을 수 있어요. Google Guava 라이브러리 프로젝트의 Splitter를 사용할게요. 이미 멋진 Fluent API가 있거든요. 그대로 사용하면 이렇게 됩니다.
@Grab('com.google.guava:guava:r09')
import com.google.common.base.*
def result = Splitter.on(',').trimResults(CharMatcher.is('_' as char)).split("_a ,_b_ ,c__").iterator().toList()
자바 개발자라면 꽤 잘 읽히지만, 대상 독자가 그게 아니거나 그런 문장을 많이 써야 한다면 조금 장황하다고 느낄 수 있어요. DSL을 쓰는 옵션도 역시 많아요. 여기서는 맵과 클로저로 간단하게 유지할게요. 먼저 헬퍼 메서드를 쓸게요.
@Grab('com.google.guava:guava:r09')
import com.google.common.base.*
def split(string) {
[on: { sep ->
[trimming: { trimChar ->
Splitter.on(sep).trimResults(CharMatcher.is(trimChar as char)).split(string).iterator().toList()
}]
}]
}
이제 원래 예시의 이 줄 대신:
def result = Splitter.on(',').trimResults(CharMatcher.is('_' as char)).split("_a ,_b_ ,c__").iterator().toList()
이렇게 쓸 수 있어요.
def result = split "_a ,_b_ ,c__" on ',' trimming '_\
2. 연산자 오버로딩 (Operator overloading)
Groovy의 다양한 연산자는 객체에 대한 평범한 메서드 호출에 매핑돼요. 이 덕분에 연산자 오버로딩을 활용할 수 있는 자신만의 자바 또는 Groovy 객체를 제공할 수 있어요. 다음 표는 Groovy가 지원하는 연산자와 그 연산자가 매핑되는 메서드를 설명해요.
| 연산자 | 메서드 |
|---|---|
| a + b | a.plus(b) |
| a - b | a.minus(b) |
| a * b | a.multiply(b) |
| a **b | a.power(b) |
| a / b | a.div(b) |
| a % b | a.mod(b) |
| a | b | a.or(b) |
| a & b | a.and(b) |
| a ^ b | a.xor(b) |
| a++ 또는 ++a | a.next() |
| a-- 또는 --a | a.previous() |
| a[b] | a.getAt(b) |
| a[b] = c | a.putAt(b, c) |
| a << b | a.leftShift(b) |
| a >> b | a.rightShift(b) |
| a >>> b | a.rightShiftUnsigned(b) |
| switch(a) { case(b) : } | b.isCase(a) |
| if(a) | a.asBoolean() |
| ~a | a.bitwiseNegate() |
| -a | a.negative() |
| +a | a.positive() |
| a as b | a.asType(b) |
| a == b | a.equals(b) |
| a != b | ! a.equals(b) |
| a <=> b | a.compareTo(b) |
| a > b | a.compareTo(b) > 0 |
| a >= b | a.compareTo(b) >= 0 |
| a < b | a.compareTo(b) < 0 |
| a <= b | a.compareTo(b) <= 0 |
3. 스크립트 기반 클래스 (Script base classes)
3.1. Script 클래스 (The Script class)
Groovy 스크립트는 항상 클래스로 컴파일돼요. 예를 들어 이렇게 단순한 스크립트는:
println 'Hello from Groovy'
추상 groovy.lang.Script 클래스를 확장하는 클래스로 컴파일돼요. 이 클래스에는 run이라는 추상 메서드가 하나 있어요. 스크립트가 컴파일되면 그 본문이 run 메서드가 되고, 스크립트에서 찾은 다른 메서드들은 그 클래스를 구현하는 클래스에 있어요. Script 클래스는 Binding 객체를 통해 애플리케이션과 통합하기 위한 기본 지원을 제공해요. 이 예시에서 볼 수 있죠.
def binding = new Binding() (1)
def shell = new GroovyShell(binding) (2)
binding.setVariable('x',1) (3)
binding.setVariable('y',3)
shell.evaluate 'z=2*x+y' (4)
assert binding.getVariable('z') == 5 (5)
(1)binding은 스크립트와 호출 클래스 사이에서 데이터를 공유하는 데 사용돼요.(2)GroovyShell을 이 binding과 함께 사용할 수 있어요.(3)입력 변수는 호출 클래스에서 binding 안에 설정돼요.(4)그다음 스크립트가 평가돼요.(5)그리고z변수가 binding 안으로 "내보내져"요.
이건 호출자와 스크립트 사이에서 데이터를 공유하는 매우 실용적인 방법이지만, 어떤 경우에는 충분하지 않거나 실용적이지 않을 수 있어요. 그런 목적으로 Groovy는 자신만의 기반 스크립트 클래스를 설정하게 해 줘요. 기반 스크립트 클래스는 groovy.lang.Script를 확장하고 단일 추상 메서드 타입이어야 해요.
abstract class MyBaseClass extends Script {
String name
public void greet() { println "Hello, $name!" }
}
그다음 커스텀 스크립트 기반 클래스를 컴파일러 설정에서 선언할 수 있어요. 예를 들면:
def config = new CompilerConfiguration() (1)
config.scriptBaseClass = 'MyBaseClass' (2)
def shell = new GroovyShell(this.class.classLoader, config) (3)
shell.evaluate """
setName 'Judith' (4)
greet()
"""
(1)커스텀 컴파일러 구성을 만들어요.(2)기반 스크립트 클래스를 우리가 만든 커스텀 기반 스크립트 클래스로 설정해요.(3)그 구성을 사용해서 GroovyShell을 만들어요.(4)그러면 스크립트가 기반 스크립트 클래스를 확장해서,name프로퍼티와greet메서드에 직접 접근할 수 있게 돼요.
3.2. @BaseScript 애노테이션 (The @BaseScript annotation)
대안으로, 스크립트 안에서 직접 @BaseScript 애노테이션을 쓰는 것도 가능해요.
import groovy.transform.BaseScript
@BaseScript MyBaseClass baseScript
setName 'Judith'
greet()
여기서 @BaseScript는 타입이 기반 스크립트의 클래스인 변수에 애노테이션을 달아야 해요. 또는 기반 스크립트 클래스를 @BaseScript 애노테이션 자체의 멤버로 설정할 수도 있어요.
@BaseScript(MyBaseClass)
import groovy.transform.BaseScript
setName 'Judith'
greet()
특별한 인자 없는 run 메서드를 쓸 때는, 그 메서드에 이렇게 애노테이션을 달 수도 있어요.
abstract class CustomScript extends Script {
int getTheMeaningOfLife() { 42 }
}
@BaseScript(CustomScript)
def run() {
assert theMeaningOfLife == 42
}
3.3. 대체 추상 메서드 (Alternate abstract method)
기반 스크립트 클래스는 run 메서드를 구현해야 하는 단일 추상 메서드 타입이라는 걸 보았어요. run 메서드는 스크립트 엔진이 자동으로 실행해요. 어떤 상황에서는 run 메서드를 구현하지만, 스크립트 본문을 위해 대체 추상 메서드를 제공하는 기반 클래스가 흥미로울 수 있어요. 예를 들어 기반 스크립트 run 메서드가 run 메서드가 실행되기 전에 초기화를 수행할 수도 있어요. 이렇게 하면 가능해요.
abstract class MyBaseClass extends Script {
int count
abstract void scriptBody() (1)
def run() {
count++ (2)
scriptBody() (3)
count (4)
}
}
(1)기반 스크립트 클래스는 추상 메서드를 하나(그리고 단 하나만) 정의해야 해요.(2)run메서드는 오버라이드해서 스크립트 본문을 실행하기 전에 작업을 수행할 수 있어요.(3)run은 사용자 스크립트에 위임할 추상scriptBody메서드를 호출해요.(4)그다음 스크립트의 값이 아닌 다른 것을 반환할 수 있어요.
이 코드를 실행하면:
def result = shell.evaluate """
println 'Ok'
"""
assert result == 1
스크립트가 실행되지만, 평가 결과는 기반 클래스의 run 메서드가 반환하는 1이라는 걸 볼 수 있어요. evaluate 대신 parse를 쓰면 훨씬 더 명확해져요. 같은 스크립트 인스턴스에서 run 메서드를 여러 번 실행할 수 있게 해 주거든요.
def script = shell.parse("println 'Ok'")
assert script.run() == 1
assert script.run() == 2
4. 숫자에 프로퍼티 추가하기 (Adding properties to numbers)
Groovy에서 숫자 타입은 다른 어떤 타입과도 동등하게 취급돼요. 그래서 숫자에 프로퍼티나 메서드를 추가해서 숫자를 향상시킬 수 있어요. 예를 들어 측정 가능한 양(measurable quantities)을 다룰 때 아주 유용하죠. Groovy에서 기존 클래스를 어떻게 향상시킬 수 있는지에 대한 자세한 내용은 extension modules 섹션이나 categories 섹션에 있어요. Groovy에서 이를 보여 주는 예시를 TimeCategory로 찾아볼 수 있어요.
use(TimeCategory) {
println 1.minute.from.now (1)
println 10.hours.ago
def someDate = new Date() (2)
println someDate - 3.months
}
(1)TimeCategory를 사용하면minute프로퍼티가 Integer 클래스에 추가돼요.(2)마찬가지로months메서드는 계산에 사용할 수 있는groovy.time.DatumDependentDuration을 반환해요.
카테고리(Categories)는 어휘적으로(literally) 바인딩되어서, 내부 DSL(internal DSL)에 아주 잘 맞아요.
5. @DelegatesTo
5.1. 컴파일 타임에 위임 전략 설명하기 (Explaining delegation strategy at compile time)
@groovy.lang.DelegatesTo는 문서화이자 컴파일 타임 애노테이션으로, 다음을 목적으로 해요.
- 클로저를 인자로 쓰는 API를 문서화하기
- 정적 타입 체커와 컴파일러에 타입 정보 제공하기
Groovy 언어는 DSL을 만드는 데 최고의 플랫폼이에요. 클로저를 쓰면 커스텀 제어 구조를 만드는 것도 꽤 쉽고, 빌더를 만드는 것도 간단해요. 다음 코드가 있다고 상상해 보세요.
email {
from '[email protected]'
to '[email protected]'
subject 'The pope has resigned!'
body {
p 'Really, the pope has resigned!'
}
}
이걸 구현하는 한 가지 방법은 빌더 전략을 쓰는 거예요. 클로저를 인자로 받는 email이라는 메서드가 있고, 그 메서드는 이후의 호출을 from, to, subject, body 메서드를 구현하는 객체에 위임해요. 다시, body는 클로저를 인자로 받고 빌더 전략을 사용하는 메서드예요.
그런 빌더를 구현하는 건 보통 다음 방식으로 해요.
def email(Closure cl) {
def email = new EmailSpec()
def code = cl.rehydrate(email, this, this)
code.resolveStrategy = Closure.DELEGATE_ONLY
code()
}
EmailSpec 클래스는 from, to 등의 메서드를 구현해요. rehydrate를 호출하면 클로저의 복사본을 만들고, 그 복사본에 delegate, owner, thisObject 값을 설정해요. 여기서 owner와 this object를 설정하는 건 그다지 중요하지 않아요. DELEGATE_ONLY 전략을 사용할 거기 때문인데, 이 전략은 메서드 호출이 클로저의 delegate에 대해서만 해석되도록 해요.
class EmailSpec {
void from(String from) { println "From: $from"}
void to(String... to) { println "To: $to"}
void subject(String subject) { println "Subject: $subject"}
void body(Closure body) {
def bodySpec = new BodySpec()
def code = body.rehydrate(bodySpec, this, this)
code.resolveStrategy = Closure.DELEGATE_ONLY
code()
}
}
EmailSpec 클래스는 그 자체로 클로저를 받는 body 메서드를 가지고 있고, 그 클로저는 복제되어 실행돼요. 이것이 우리가 Groovy에서 빌더 패턴이라고 부르는 것이에요.
우리가 보여 준 코드의 문제 중 하나는, email 메서드의 사용자가 클로저 안에서 호출할 수 있는 메서드에 대한 정보가 전혀 없다는 거예요. 가능한 유일한 정보는 메서드 문서에서 나와요. 여기에는 두 가지 문제가 있어요. 첫째, 문서가 항상 작성되지는 않고, 작성됐더라도 항상 사용 가능하지 않아요(예를 들어 javadoc을 내려받지 않은 경우). 둘째, IDE를 도와주지 않아요. 여기서 정말 흥미로운 건, 개발자가 클로저 본문 안에 들어갔을 때 email 클래스에 존재하는 메서드를 IDE가 제안해 주는 거예요.
게다가 사용자가 EmailSpec 클래스가 정의하지 않은 메서드를 클로저에서 호출하면, IDE가 최소한 경고를 해야 해요(런타임에 깨질 가능성이 아주 높으니까요).
위 코드의 또 다른 문제는 정적 타입 검사와 호환되지 않는다는 거예요. 타입 검사는 메서드 호출이 허용되는지 런타임 대신 컴파일 타임에 사용자에게 알려 줄 텐데, 이 코드에 타입 검사를 수행하려고 하면:
email {
from '[email protected]'
to '[email protected]'
subject 'The pope has resigned!'
body {
p 'Really, the pope has resigned!'
}
}
타입 체커는 Closure를 받는 email 메서드가 있다는 걸 알지만, 클로저 안의 모든 메서드 호출에 대해 불평할 거예요. from은 클래스에 정의된 메서드가 아니니까요. 실제로 그건 EmailSpec 클래스에 정의되어 있고, 타입 체커가 클로저 delegate가 런타임에 EmailSpec 타입이 될 거라는 걸 알려 주는 힌트가 전혀 없어요.
@groovy.transform.TypeChecked
void sendEmail() {
email {
from '[email protected]'
to '[email protected]'
subject 'The pope has resigned!'
body {
p 'Really, the pope has resigned!'
}
}
}
이건 다음과 같은 오류로 컴파일에 실패할 거예요.
[Static type checking] - Cannot find matching method MyScript#from(java.lang.String). Please check if the declared type is correct and if the method exists.
@ line 31, column 21.
from '[email protected]'
5.2. @DelegatesTo
이런 이유로 Groovy 2.1은 @DelegatesTo라는 새 애노테이션을 도입했어요. 이 애노테이션의 목표는 두 문제를 모두 해결하는 거예요. 문서화 문제를 해결해서 IDE가 클로저 본문에서 기대되는 메서드를 알게 하고, 타입 검사 문제도 해결해서 클로저 본문에서 메서드 호출의 잠재적 수신자(receivers)가 무엇인지 컴파일러에 힌트를 줘요.
아이디어는 email 메서드의 Closure 파라미터에 애노테이션을 다는 거예요.
def email(@DelegatesTo(EmailSpec) Closure cl) {
def email = new EmailSpec()
def code = cl.rehydrate(email, this, this)
code.resolveStrategy = Closure.DELEGATE_ONLY
code()
}
여기서 한 일은 컴파일러(또는 IDE)에게, 메서드가 클로저로 호출되면 그 클로저의 delegate가 email 타입의 객체로 설정될 거라고 알려 주는 거예요. 하지만 아직 문제가 하나 있어요. 기본 위임 전략이 우리 메서드에서 사용하는 것과 같지 않다는 거예요. 그래서 더 많은 정보를 주고, 위임 전략도 바뀐다고 컴파일러(또는 IDE)에 알려 줄게요.
def email(@DelegatesTo(strategy=Closure.DELEGATE_ONLY, value=EmailSpec) Closure cl) {
def email = new EmailSpec()
def code = cl.rehydrate(email, this, this)
code.resolveStrategy = Closure.DELEGATE_ONLY
code()
}
이제 IDE와 타입 체커(@TypeChecked를 쓰는 경우) 둘 다 delegate와 위임 전략을 알게 돼요. IDE가 스마트 완성을 제공할 수 있게 되고, 프로그램의 동작이 보통 런타임에만 알려지기 때문에 존재하던 컴파일 타임 오류도 사라지게 되어 아주 좋아요!
다음 코드는 이제 컴파일을 통과할 거예요.
@TypeChecked
void doEmail() {
email {
from '[email protected]'
to '[email protected]'
subject 'The pope has resigned!'
body {
p 'Really, the pope has resigned!'
}
}
}
5.3. DelegatesTo 모드 (DelegatesTo modes)
@DelegatesTo는 여러 모드를 지원하는데, 이 섹션에서 예시와 함께 설명할게요.
5.3.1. 단순 위임 (Simple delegation)
이 모드에서 유일하게 필수인 파라미터는 value로, 호출을 어떤 클래스에 위임하는지 말해 줘요. 그게 전부예요. 컴파일러에 delegate의 타입이 @DelegatesTo가 문서화한 타입일 거라고 알려 주는 거죠. (서브클래스일 수도 있지만, 서브클래스라면 서브클래스가 정의한 메서드는 타입 체커에 보이지 않아요.)
void body(@DelegatesTo(BodySpec) Closure cl) {
// ...
}
5.3.2. 위임 전략 (Delegation strategy)
이 모드에서는 delegate 클래스와 위임 전략을 둘 다 지정해야 해요. 클로저가 기본 위임 전략인 Closure.OWNER_FIRST로 호출되지 않을 때 이걸 써야 해요.
void body(@DelegatesTo(strategy=Closure.DELEGATE_ONLY, value=BodySpec) Closure cl) {
// ...
}
5.3.3. 파라미터에 위임 (Delegate to parameter)
이 변형에서는 메서드의 다른 파라미터에 위임한다고 컴파일러에 알려 줄 거예요. 다음 코드를 볼게요.
def exec(Object target, Closure code) {
def clone = code.rehydrate(target, this, this)
clone()
}
여기서 사용될 delegate는 exec 메서드 안에서 만들어지지 않아요. 실제로 메서드의 인자를 가져와서 그 인자에 위임해요. 사용법은 이렇게 생겼을 거예요.
def email = new Email()
exec(email) {
from '...'
to '...'
send()
}
메서드 호출 각각이 email 파라미터에 위임돼요. 이건 널리 쓰이는 패턴이고, @DelegatesTo도 함께 쓰는 애노테이션으로 지원해요.
def exec(@DelegatesTo.Target Object target, @DelegatesTo Closure code) {
def clone = code.rehydrate(target, this, this)
clone()
}
클로저에는 @DelegatesTo가 달려 있는데, 이번에는 클래스 없이요. 대신 다른 파라미터에 @DelegatesTo.Target을 달아요. 그러면 delegate의 타입이 컴파일 타임에 결정돼요. 이 경우 파라미터 타입을 사용한다고 생각할 수 있는데, 여기서는 Object죠. 하지만 그건 사실이 아니에요. 다음 코드를 볼게요.
class Greeter {
void sayHello() { println 'Hello' }
}
def greeter = new Greeter()
exec(greeter) {
sayHello()
}
@DelegatesTo로 애노테이션하지 않아도 이게 바로 동작한다는 걸 기억하세요. 하지만 IDE가 delegate 타입을, 또는 타입 체커가 그것을 알게 하려면 @DelegatesTo를 추가해야 해요. 이 경우 타입 체커는 Greeter 변수가 Greeter 타입이라는 걸 알게 되므로, exec 메서드가 target을 Greeter 타입으로 명시적으로 정의하지 않아도 sayHello 메서드에서 오류를 보고하지 않아요. 이건 아주 강력한 기능이에요. 서로 다른 수신자 타입을 위해 같은 exec 메서드를 여러 버전으로 작성하지 않게 해 주거든요!
이 모드에서 @DelegatesTo 애노테이션은 위에서 설명한 strategy 파라미터도 지원해요.
5.3.4. 여러 클로저 (Multiple closures)
앞선 예시에서 exec 메서드는 클로저 하나만 받았지만, 여러 클로저를 받는 메서드가 있을 수도 있어요.
void fooBarBaz(Closure foo, Closure bar, Closure baz) {
...
}
그러면 각 클로저에 @DelegatesTo를 다는 걸 막는 건 없어요.
class Foo { void foo(String msg) { println "Foo ${msg}!" } }
class Bar { void bar(int x) { println "Bar ${x}!" } }
class Baz { void baz(Date d) { println "Baz ${d}!" } }
void fooBarBaz(@DelegatesTo(Foo) Closure foo, @DelegatesTo(Bar) Closure bar, @DelegatesTo(Baz) Closure baz) {
...
}
더 중요한 건, 클로저가 여러 개이고 그리고 인자가 여러 개라면, 여러 target을 쓸 수 있어요.
void fooBarBaz(
@DelegatesTo.Target('foo') foo,
@DelegatesTo.Target('bar') bar,
@DelegatesTo.Target('baz') baz,
@DelegatesTo(target='foo') Closure cl1,
@DelegatesTo(target='bar') Closure cl2,
@DelegatesTo(target='baz') Closure cl3) {
cl1.rehydrate(foo, this, this).call()
cl2.rehydrate(bar, this, this).call()
cl3.rehydrate(baz, this, this).call()
}
def a = new Foo()
def b = new Bar()
def c = new Baz()
fooBarBaz(
a, b, c,
{ foo('Hello') },
{ bar(123) },
{ baz(new Date()) }
)
Note: 이 시점에서 왜 파라미터 이름을 참조로 쓰지 않는지 궁금할 수 있어요. 그 이유는 정보(파라미터 이름)가 항상 사용 가능하지 않기 때문이에요. 디버그 전용 정보라서요. JVM의 한계랍니다.
5.3.5. 제네릭 타입에 위임 (Delegating to a generic type)
어떤 상황에서는 delegate 타입이 파라미터가 아니라 제네릭 타입이 될 거라고 IDE나 컴파일러에 알려 주는 게 흥미로울 수 있어요. 요소 리스트에 동작하는 설정기를(configurator) 상상해 보세요.
public <T> void configure(List<T> elements, Closure configuration) {
elements.each { e->
def clone = configuration.rehydrate(e, this, this)
clone.resolveStrategy = Closure.DELEGATE_FIRST
clone.call()
}
}
그러면 이 메서드는 어떤 리스트로든 이렇게 호출할 수 있어요.
@groovy.transform.ToString
class Realm {
String name
}
List<Realm> list = []
3.times { list << new Realm() }
configure(list) {
name = 'My Realm'
}
assert list.every { it.name == 'My Realm' }
configure 메서드가 리스트의 각 요소에 대해 클로저를 호출한다는 걸 타입 체커와 IDE에 알리려면 @DelegatesTo를 다르게 써야 해요.
public <T> void configure(
@DelegatesTo.Target List<T> elements,
@DelegatesTo(strategy=Closure.DELEGATE_FIRST, genericTypeIndex=0) Closure configuration) {
def clone = configuration.rehydrate(e, this, this)
clone.resolveStrategy = Closure.DELEGATE_FIRST
clone.call()
}
@DelegatesTo는 선택적인 genericTypeIndex 인자를 받는데, delegate 타입으로 사용될 제네릭 타입의 인덱스가 무엇인지 알려 줘요. 이건 @DelegatesTo.Target과 함께 사용해야 하고, 인덱스는 0에서 시작해요. 위 예시에서 이것은 delegate 타입이 List<T>에 대해 해석된다는 뜻이고, 인덱스 0의 제네릭 타입이 T이고 Realm으로 추론되므로 타입 체커는 delegate 타입이 Realm 타입일 거라고 추론해요.
Note: JVM 한계 때문에 자리 표시자(T) 대신 genericTypeIndex를 사용하고 있어요.
5.3.6. 임의 타입에 위임 (Delegating to an arbitrary type)
위 옵션 중 어느 것도 위임하고 싶은 타입을 나타내지 못하는 경우가 있을 수 있어요. 예를 들어 객체로 파라미터화되고, 다른 타입의 객체를 반환하는 map 메서드를 정의하는 매퍼 클래스를 정의해 볼게요.
class Mapper<T,U> { (1)
final T value (2)
Mapper(T value) { this.value = value }
U map(Closure<U> producer) { (3)
producer.delegate = value
producer()
}
}
(1)매퍼 클래스는 제네릭 타입 인자를 두 개 받아요. 원본 타입과 대상 타입이에요.(2)원본 객체는 final 필드에 저장돼요.(3)map메서드는 원본 객체를 대상 객체로 변환하라고 요청해요.
보시다시피 map의 메서드 시그니처는 클로저가 조작할 객체가 무엇인지에 대한 정보를 주지 않아요. 메서드 본문을 읽으면 그것이 T 타입의 value일 거라는 걸 알지만, T는 메서드 시그니처에 없어요. 그래서 @DelegatesTo의 사용 가능한 옵션 중 어느 것도 적합하지 않은 경우를 만나게 됐어요. 예를 들어 이 코드를 정적으로 컴파일하려고 하면:
def mapper = new Mapper<String,Integer>('Hello')
assert mapper.map { length() } == 5
컴파일러는 이렇게 실패해요.
Static type checking] - Cannot find matching method TestScript0#length()
그 경우 @DelegatesTo 애노테이션의 type 멤버를 사용해서 T를 타입 토큰으로 참조할 수 있어요.
class Mapper<T,U> {
final T value
Mapper(T value) { this.value = value }
U map(@DelegatesTo(type="T") Closure<U> producer) { (1)
producer.delegate = value
producer()
}
}
(1)@DelegatesTo애노테이션이 메서드 시그니처에 없는 제네릭 타입을 참조해요.
제네릭 타입 토큰만 쓸 수 있는 건 아니라는 점을 기억하세요. type 멤버는 List<T>나 Map<T,List<U>> 같은 복잡한 타입을 나타내는 데도 쓸 수 있어요. 최후의 수단으로 그걸 써야 하는 이유는, 타입이 타입 체커가 @DelegatesTo의 사용을 발견할 때만 검사되고, 애노테이션된 메서드 자체가 컴파일될 때는 검사되지 않기 때문이에요. 즉 타입 안전성은 호출 지점에서만 보장돼요. 게다가 컴파일도 느려져요(대부분의 경우 알아차리지 못할 것이지만요).
6. 컴파일 커스터마이저 (Compilation customizers)
6.1. 소개 (Introduction)
groovyc로 클래스를 컴파일하든, 예를 들어 스크립트를 실행하려고 GroovyShell을 쓰든, 내부적으로는 *컴파일러 구성(compiler configuration)*이 사용돼요. 이 구성은 소스 인코딩이나 classpath 같은 정보를 담고 있지만, 기본적으로 import를 추가한다거나 AST 변환을 투명하게 적용한다거나 전역 AST 변환을 비활성화하는 것 같은 더 많은 작업을 수행하는 데도 쓸 수 있어요.
컴파일 커스터마이저의 목표는 그런 일반적인 작업들을 구현하기 쉽게 만드는 거예요. 이를 위해 CompilerConfiguration 클래스가 진입점이에요. 일반적인 구조는 항상 다음 코드에 기반해요.
import org.codehaus.groovy.control.CompilerConfiguration
// create a configuration
def config = new CompilerConfiguration()
// tweak the configuration
config.addCompilationCustomizers(...)
// run your script
def shell = new GroovyShell(config)
shell.evaluate(script)
컴파일 커스터마이저는 org.codehaus.groovy.control.customizers.CompilationCustomizer 클래스를 확장해야 해요. 커스터마이저는 다음과 같이 동작해요.
- 특정 컴파일 단계(phase)에서
- 컴파일되는 모든 클래스 노드에 대해
자신만의 컴파일 커스터마이저를 구현할 수도 있지만, Groovy는 가장 흔한 연산 몇 가지를 포함하고 있어요.
6.2. Import 커스터마이저 (Import customizer)
이 컴파일 커스터마이저를 사용하면 코드에 import가 투명하게 추가돼요. 사용자가 import를 쓰지 않아도 되게 하고 싶은 DSL을 구현하는 스크립트에서 특히 유용해요. import 커스터마이저는 Groovy 언어가 허용하는 모든 변형의 import를 추가하게 해 줘요. 즉:
- 클래스 import (선택적으로 별칭 지정)
- star import
- static import (선택적으로 별칭 지정)
- static star import
import org.codehaus.groovy.control.customizers.ImportCustomizer
def icz = new ImportCustomizer()
// "normal" import
icz.addImports('java.util.concurrent.atomic.AtomicInteger', 'java.util.concurrent.ConcurrentHashMap')
// "aliases" import
icz.addImport('CHM', 'java.util.concurrent.ConcurrentHashMap')
// "static" import
icz.addStaticImport('java.lang.Math', 'PI') // import static java.lang.Math.PI
// "aliased static" import
icz.addStaticImport('pi', 'java.lang.Math', 'PI') // import static java.lang.Math.PI as pi
// "star" import
icz.addStarImports 'java.util.concurrent' // import java.util.concurrent.*
// "static star" import
icz.addStaticStars 'java.lang.Math' // import static java.lang.Math.*
모든 단축 메서드의 자세한 설명은 org.codehaus.groovy.control.customizers.ImportCustomizer에서 찾을 수 있어요.
6.3. AST 변환 커스터마이저 (AST transformation customizer)
AST 변환 커스터마이저는 AST 변환을 투명하게 적용하기 위한 것이에요. 변환이 classpath에 있기만 하면 컴파일되는 모든 클래스에 적용되는 전역 AST 변환과 달리(이것은 컴파일 시간을 늘리거나 적용되면 안 되는 곳에 적용되어 생기는 부작용 같은 단점이 있어요), 이 커스터마이저는 특정 스크립트나 클래스에만 변환을 선택적으로 적용하게 해 줘요.
예를 들어 스크립트에서 @Log를 쓸 수 있게 하고 싶다고 해 볼게요. 문제는 @Log가 보통 클래스 노드에 적용되는데, 스크립트는 정의상 클래스 노드가 필요 없다는 거예요. 하지만 구현상으로 스크립트는 클래스예요. 그저 이 암묵적인 클래스 노드에 @Log를 애노테이션할 수 없을 뿐이에요. AST 커스터마이저를 쓰면 그렇게 하는 우회 방법이 있어요.
import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
import groovy.util.logging.Log
def acz = new ASTTransformationCustomizer(Log)
config.addCompilationCustomizers(acz)
그게 전부예요! 내부적으로 @Log AST 변환이 컴파일 단위의 모든 클래스 노드에 적용돼요. 즉 스크립트에도 적용되고, 스크립트 안에 정의된 클래스에도 적용된다는 뜻이에요.
사용하는 AST 변환이 파라미터를 받는다면, 생성자에서도 파라미터를 쓸 수 있어요.
def acz = new ASTTransformationCustomizer(Log, value: 'LOGGER')
// use name 'LOGGER' instead of the default 'log'
config.addCompilationCustomizers(acz)
AST 변환 커스터마이저는 AST 노드 대신 객체로 동작하기 때문에, 모든 값이 AST 변환 파라미터로 변환될 수는 없어요. 예를 들어 프리미티브 타입은 ConstantExpression으로 변환돼요(즉 LOGGER는 new ConstantExpression('LOGGER')로 변환돼요). 하지만 AST 변환이 인자로 클로저를 받는다면, 다음 예시처럼 ClosureExpression을 줘야 해요.
def configuration = new CompilerConfiguration()
def expression = new AstBuilder().buildFromCode(CompilePhase.CONVERSION) { -> true }.expression[0]
def customizer = new ASTTransformationCustomizer(ConditionalInterrupt, value: expression, thrown: SecurityException)
configuration.addCompilationCustomizers(customizer)
def shell = new GroovyShell(configuration)
shouldFail(SecurityException) {
shell.evaluate("""
// equivalent to adding @ConditionalInterrupt(value={true}, thrown: SecurityException)
class MyClass {
void doIt() { }
}
new MyClass().doIt()
""")
}
옵션의 전체 목록은 org.codehaus.groovy.control.customizers.ASTTransformationCustomizer를 참고하세요.
6.4. Secure AST 커스터마이저 (Secure AST customizer)
이 커스터마이저는 DSL의 개발자가 언어의 **문법(grammar)**을 제한하게 해 줘요. 예를 들어 사용자가 특정 구문을 쓰지 못하게 막는 거죠. 이건 "보안"이라는 단순한 한 측면, 즉 DSL 안에서 허용되는 구문을 제한하는 것만 의미해요. 이것은 전체 보안의 직교적 측면으로서 추가로 필요할 수 있는 보안 관리자(security manager)를 대체하지 않아요.
존재하는 유일한 이유는 언어의 표현력을 제한하기 위해서예요. 이 커스터마이저는 AST(추상 구문 트리) 수준에서만 동작하고, 런타임이 아니에요! 처음에는 이상해 보일 수 있는데, Groovy를 DSL을 만드는 플랫폼으로 생각하면 훨씬 말이 돼요. 사용자가 완전한 언어를 손에 넣게 하고 싶지 않을 수 있으니까요. 아래 예시에서는 산술 연산만 허용하는 언어의 예시로 보여 드릴게요. 하지만 이 커스터마이저는 다음을 할 수 있게 해 줘요.
- 클로저 생성 허용/불허
- import 허용/불허
- 패키지 정의 허용/불허
- 메서드 정의 허용/불허
- 메서드 호출의 수신자(receivers) 제한
- 사용자가 쓸 수 있는 AST 표현식의 종류 제한
- 사용자가 쓸 수 있는 토큰(문법적으로) 제한
- 코드에서 쓸 수 있는 상수의 타입 제한
이런 모든 기능에 대해, secure AST 커스터마이저는 허용 목록(허용된 요소 목록) 또는 불허 목록(허용되지 않은 요소 목록)을 사용해서 동작해요. 기능 종류(import, 토큰 등)마다 허용 목록이나 불허 목록 중 하나를 쓸 수 있지만, 서로 다른 기능에 대해 불허/허용 목록을 섞을 수도 있어요. 전형적으로 허용 목록을 선택할 거예요(나열된 구문만 허용하고 그 외의 모든 것을 불허하는 방식).
import org.codehaus.groovy.control.customizers.SecureASTCustomizer
import static org.codehaus.groovy.syntax.Types.* (1)
def scz = new SecureASTCustomizer()
scz.with {
closuresAllowed = false // user will not be able to write closures
methodDefinitionAllowed = false // user will not be able to define methods
allowedImports = [] // empty allowed list means imports are disallowed
allowedStaticImports = [] // same for static imports
allowedStaticStarImports = ['java.lang.Math'] // only java.lang.Math is allowed
// the list of tokens the user can find
// constants are defined in org.codehaus.groovy.syntax.Types
allowedTokens = [ (1)
PLUS,
MINUS,
MULTIPLY,
DIVIDE,
REMAINDER,
POWER,
PLUS_PLUS,
MINUS_MINUS,
COMPARE_EQUAL,
COMPARE_NOT_EQUAL,
COMPARE_LESS_THAN,
COMPARE_LESS_THAN_EQUAL,
COMPARE_GREATER_THAN,
COMPARE_GREATER_THAN_EQUAL,
].asImmutable()
// limit the types of constants that a user can define to number types only
allowedConstantTypesClasses = [ (2)
Integer,
Float,
Long,
Double,
BigDecimal,
Integer.TYPE,
Long.TYPE,
Float.TYPE,
Double.TYPE
].asImmutable()
// method calls are only allowed if the receiver is of one of those types
// be careful, it's not a runtime type!
allowedReceiversClasses = [ (2)
Math,
Integer,
Float,
Double,
Long,
BigDecimal
].asImmutable()
}
(1)org.codehaus.groovy.syntax.Types의 토큰 타입에 사용하는 것.(2)여기서 클래스 리터럴을 사용할 수 있어요.
secure AST 커스터마이저가 기본 제공하는 것이 요구에 충분하지 않다면, 자신만의 컴파일 커스터마이저를 만들기 전에 AST 커스터마이저가 지원하는 표현식과 문장 체커(expression and statement checkers)에 관심이 갈 수 있어요. 기본적으로 AST 트리 위에서, 표현식(식 체커)이나 문장(문장 체커)에 대해 커스텀 검사를 추가하게 해 줘요. 이를 위해 org.codehaus.groovy.control.customizers.SecureASTCustomizer.StatementChecker나 org.codehaus.groovy.control.customizers.SecureASTCustomizer.ExpressionChecker를 구현해야 해요.
이 인터페이스들은 isAuthorized라는 단일 메서드를 정의하는데, boolean을 반환하고 파라미터로 Statement(또는 Expression)를 받아요. 이를 통해 표현식이나 문장에 대해 복잡한 로직을 수행해서 사용자가 그걸 해도 되는지 말지를 알 수 있어요.
예를 들어 커스터마이저에는 사용자가 attribute 표현식을 쓰지 못하게 하는 사전 정의된 구성 플래그가 없어요. 커스텀 체커를 쓰면 아주 간단해요.
def scz = new SecureASTCustomizer()
def checker = { expr ->
!(expr instanceof AttributeExpression)
} as SecureASTCustomizer.ExpressionChecker
scz.addExpressionCheckers(checker)
그다음 간단한 스크립트를 평가해서 이게 동작하는지 확인할 수 있어요.
new GroovyShell(config).evaluate '''
class A {
int val
}
def a = new A(val: 123)
a.@val (1)
'''
(1)컴파일에 실패할 거예요.
문장은 org.codehaus.groovy.control.customizers.SecureASTCustomizer.StatementChecker로 검사할 수 있어요.
표현식은 org.codehaus.groovy.control.customizers.SecureASTCustomizer.ExpressionChecker로 검사할 수 있어요.
6.5. 소스 인지 커스터마이저 (Source aware customizer)
이 커스터마이저는 다른 커스터마이저에 대한 필터로 사용할 수 있어요. 그 경우 필터는 org.codehaus.groovy.control.SourceUnit이에요. 이를 위해 소스 인지 커스터마이저는 다른 커스터마이저를 delegate로 받고, 소스 단위에 대한 조건(predicates)이 일치하는 경우에만 그 delegate의 커스터마이징을 적용해요.
SourceUnit은 여러 가지에 접근하게 해 주는데, 특히 컴파일 중인 파일(물론 파일에서 컴파일할 때)에 접근하게 해 줘요. 예를 들어 파일 이름에 기반해서 연산을 수행할 가능성을 줘요. 소스 인지 커스터마이저를 만드는 방법은 다음과 같아요.
import org.codehaus.groovy.control.customizers.SourceAwareCustomizer
import org.codehaus.groovy.control.customizers.ImportCustomizer
def delegate = new ImportCustomizer()
def sac = new SourceAwareCustomizer(delegate)
그다음 소스 인지 커스터마이저에 조건을 쓸 수 있어요.
// the customizer will only be applied to classes contained in a file name ending with 'Bean'
sac.baseNameValidator = { baseName ->
baseName.endsWith 'Bean'
}
// the customizer will only be applied to files which extension is '.spec'
sac.extensionValidator = { ext -> ext == 'spec' }
// source unit validation
// allow compilation only if the file contains at most 1 class
sac.sourceUnitValidator = { SourceUnit sourceUnit -> sourceUnit.AST.classes.size() == 1 }
// class validation
// the customizer will only be applied to classes ending with 'Bean'
sac.classValidator = { ClassNode cn -> cn.endsWith('Bean') }
6.6. 커스터마이저 빌더 (Customizer builder)
Groovy 코드에서 컴파일 커스터마이저를 사용한다면(위 예시들처럼), 컴파일을 커스터마이즈하는 대체 문법을 쓸 수 있어요. 빌더(org.codehaus.groovy.control.customizers.builder.CompilerCustomizationBuilder)는 계층적 DSL을 사용해서 커스터마이저 생성을 단순화해요.
import org.codehaus.groovy.control.CompilerConfiguration
import static org.codehaus.groovy.control.customizers.builder.CompilerCustomizationBuilder.withConfig (1)
def conf = new CompilerConfiguration()
withConfig(conf) {
// ... (2)
}
(1)빌더 메서드의 static import.(2)구성이 여기에 들어가요.
위 코드 예시는 빌더를 사용하는 방법을 보여 줘요. withConfig라는 static 메서드가 빌더 코드에 해당하는 클로저를 받고, 컴파일 커스터마이저를 구성에 자동으로 등록해요. 배포본에서 사용 가능한 모든 컴파일 커스터마이저를 이렇게 구성할 수 있어요.
6.6.1. Import 커스터마이저
withConfig(configuration) {
imports { // imports customizer
normal 'my.package.MyClass' // a normal import
alias 'AI', 'java.util.concurrent.atomic.AtomicInteger' // an aliased import
star 'java.util.concurrent' // star imports
staticMember 'java.lang.Math', 'PI' // static import
staticMember 'pi', 'java.lang.Math', 'PI' // aliased static import
}
}
6.6.2. AST 변환 커스터마이저
withConfig(conf) {
ast(Log) (1)
}
withConfig(conf) {
ast(Log, value: 'LOGGER') (2)
}
(1)@Log를 투명하게 적용해요.(2)@Log를 로거의 다른 이름으로 적용해요.
6.6.3. Secure AST 커스터마이저
withConfig(conf) {
secureAst {
closuresAllowed = false
methodDefinitionAllowed = false
}
}
6.6.4. 소스 인지 커스터마이저
withConfig(configuration){
source(extension: 'sgroovy') {
ast(CompileStatic) (1)
}
}
withConfig(configuration){
source(extensions: ['sgroovy','sg']) {
ast(CompileStatic) (2)
}
}
withConfig(configuration) {
source(extensionValidator: { it.name in ['sgroovy','sg']}) {
ast(CompileStatic) (2)
}
}
withConfig(configuration) {
source(basename: 'foo') {
ast(CompileStatic) (3)
}
}
withConfig(configuration) {
source(basenames: ['foo', 'bar']) {
ast(CompileStatic) (4)
}
}
withConfig(configuration) {
source(basenameValidator: { it in ['foo', 'bar'] }) {
ast(CompileStatic) (4)
}
}
withConfig(configuration) {
source(unitValidator: { unit -> !unit.AST.classes.any { it.name == 'Baz' } }) {
ast(CompileStatic) (5)
}
}
(1).sgroovy파일에 CompileStatic AST 애노테이션을 적용해요.(2).sgroovy또는.sg파일에 CompileStatic AST 애노테이션을 적용해요.(3)이름이 'foo'인 파일에 CompileStatic AST 애노테이션을 적용해요.(4)이름이 'foo' 또는 'bar'인 파일에 CompileStatic AST 애노테이션을 적용해요.(5)'Baz'라는 이름의 클래스를 포함하지 않는 파일에 CompileStatic AST 애노테이션을 적용해요.
6.6.5. 커스터마이저 인라인 (Inlining a customizer)
인라인 커스터마이저는 클래스를 만들지 않고도 컴파일 커스터마이저를 직접 작성하게 해 줘요.
withConfig(configuration) {
inline(phase:'CONVERSION') { source, context, classNode -> (1)
println "visiting $classNode" (2)
}
}
(1)CONVERSION 단계에서 실행될 인라인 커스터마이저를 정의해요.(2)컴파일 중인 클래스 노드의 이름을 출력해요.
6.6.6. 여러 커스터마이저 (Multiple customizers)
물론 빌더는 여러 커스터마이저를 한 번에 정의하게 해 줘요.
withConfig(configuration) {
ast(ToString)
ast(EqualsAndHashCode)
}
6.7. configscript 명령줄 파라미터 (The configscript commandline parameter)
지금까지 CompilationConfiguration 클래스를 사용해서 컴파일을 커스터마이즈하는 방법을 설명했는데, 이것은 Groovy를 내장하고 자신만의 CompilerConfiguration 인스턴스를 만들 때만 가능해요(그다음 그것으로 GroovyShell이나 GroovyScriptEngine 등을 만들죠).
일반 Groovy 컴파일러(예를 들어 groovyc, ant, gradle로)로 컴파일하는 클래스에 이것을 적용하고 싶다면, 인자로 Groovy 구성 스크립트를 받는 configscript라는 명령줄 파라미터를 사용할 수 있어요.
이 스크립트는 파일이 컴파일되기 전에 CompilerConfiguration 인스턴스에 접근할 수 있게 해 줘요(구성 스크립트에서 configuration이라는 변수로 노출돼요). 그래서 그것을 조정할 수 있죠. 또한 위의 컴파일러 구성 빌더를 투명하게 통합해요. 예를 들어 모든 클래스에 정적 컴파일을 기본으로 활성화하는 방법을 볼게요.
6.7.1. Configscript 예시: 기본 정적 컴파일 (Configscript example: Static compilation by default)
보통 Groovy의 클래스는 동적 런타임으로 컴파일돼요. 어떤 클래스에든 @CompileStatic이라는 애노테이션을 두면 정적 컴파일을 활성화할 수 있어요. 어떤 사람들은 이 모드가 기본으로 활성화되길 원할 수 있는데, 즉 (아마 많은) 클래스에 애노테이션하지 않아도 되길 원하는 거예요. configscript를 사용하면 그게 가능해요.
먼저 src/conf 같은 곳에 다음 내용으로 config.groovy라는 파일을 만들어야 해요.
withConfig(configuration) { (1)
ast(groovy.transform.CompileStatic)
}
(1)configuration은 CompilerConfiguration 인스턴스를 참조해요.
실제로 필요한 건 그게 전부예요. 빌더를 import 할 필요 없이 스크립트에 자동으로 노출돼요. 그다음 다음 명령줄로 파일을 컴파일하면 돼요.
groovyc -configscript src/conf/config.groovy src/main/groovy/MyClass.groovy
구성 파일을 클래스와 분리하는 걸 강력히 권장해요. 그래서 위에서 src/main과 src/conf 디렉터리를 제안한 거예요.
6.7.2. Configscript 예시: 시스템 프로퍼티 설정 (Configscript example: Setting system properties)
구성 스크립트에서 시스템 프로퍼티를 설정할 수도 있어요. 예를 들어:
System.setProperty('spock.iKnowWhatImDoing.disableGroovyVersionCheck', 'true')
설정할 시스템 프로퍼티가 많다면, 구성 파일을 사용하면 긴 명령줄이나 적절히 정의된 환경 변수로 시스템 프로퍼티 묶음을 설정할 필요를 줄일 수 있어요. 또한 구성 파일을 공유하는 것만으로 모든 설정을 공유할 수도 있어요.
6.8. AST 변환 (AST transformations)
만약:
- 런타임 메타프로그래밍으로는 원하는 것을 할 수 없고
- DSL 실행 성능을 개선해야 하며
- Groovy와 같은 문법을 다른 의미론(semantics)으로 활용하고 싶고
- DSL에서 타입 검사 지원을 개선하고 싶다면
그렇다면 AST 변환이 정답이에요. 지금까지 사용한 기법들과 달리, AST 변환은 코드가 바이트코드로 컴파일되기 전에 코드를 바꾸거나 생성하기 위한 것이에요. AST 변환은 예를 들어 컴파일 타임에 새 메서드를 추가하거나, 필요에 따라 메서드 본문을 완전히 바꿀 수 있어요. 아주 강력한 도구이지만, 작성하기 쉽지 않다는 대가도 따라와요. AST 변환에 대한 더 많은 정보는 이 매뉴얼의 compile-time metaprogramming 섹션을 참고하세요.
7. 커스텀 타입 검사 확장 (Custom type checking extensions)
어떤 상황에서는 잘못된 코드에 대한 피드백을 가능한 한 빨리, 즉 DSL 스크립트가 실행되기를 기다리는 것이 아니라 컴파일될 때 사용자에게 주는 것이 흥미로울 수 있어요. 하지만 동적 코드에서는 종종 이것이 가능하지 않아요. Groovy는 실제로 이것에 대한 실용적인 답을 제공하는데, type checking extensions이라고 불러요.
8. 빌더 (Builders)
많은 작업이 무언가를 만드는 것을 포함하고, 빌더 패턴은 개발자들이 특히 본질적으로 계층적인 구조를 만드는 것을 더 쉽게 하기 위해 사용하는 기법 중 하나예요.
이 패턴은 너무 보편적이어서 Groovy가 특별한 내장 지원을 가지고 있어요. 첫째, 많은 내장 빌더가 있어요. 둘째, 자신만의 빌더를 쓰기 쉽게 해 주는 클래스들이 있어요.
8.1. 기존 빌더 (Existing builders)
Groovy는 많은 내장 빌더와 함께 제공돼요. 그중 몇 가지를 볼게요.
8.1.1. MarkupBuilder
Creating Xml - MarkupBuilder를 참고하세요.
8.1.2. StreamingMarkupBuilder
Creating Xml - StreamingMarkupBuilder를 참고하세요.
8.1.3. SaxBuilder
Simple API for XML (SAX) 이벤트를 생성하기 위한 빌더예요. 다음과 같은 SAX 핸들러가 있다면:
class LogHandler extends org.xml.sax.helpers.DefaultHandler {
String log = ''
void startElement(String uri, String localName, String qName, org.xml.sax.Attributes attributes) {
log += "Start Element: $localName, "
}
void endElement(String uri, String localName, String qName) {
log += "End Element: $localName, "
}
}
핸들러를 위한 SAX 이벤트를 생성하려면 SaxBuilder를 이렇게 쓸 수 있어요.
def handler = new LogHandler()
def builder = new groovy.xml.SAXBuilder(handler)
builder.root() {
helloWorld()
}
그다음 모든 것이 예상대로 동작했는지 확인할 수 있어요.
assert handler.log == 'Start Element: root, Start Element: helloWorld, End Element: helloWorld, End Element: root, '
8.1.4. StaxBuilder
Streaming API for XML (StAX) 프로세서와 함께 동작하는 Groovy 빌더예요. XML을 생성하는 Java의 StAX 구현을 사용한 간단한 예시를 볼게요.
def factory = javax.xml.stream.XMLOutputFactory.newInstance()
def writer = new StringWriter()
def builder = new groovy.xml.StaxBuilder(factory.createXMLStreamWriter(writer))
builder.root(attribute:1) {
elem1('hello')
elem2('world')
}
assert writer.toString() == '<?xml version="1.0" ?><root attribute="1"><elem1>hello</elem1><elem2>world</elem2></root>'
Jettison 같은 외부 라이브러리는 다음과 같이 사용할 수 있어요.
@Grab('org.codehaus.jettison:jettison:1.3.3')
@GrabExclude('stax:stax-api') // part of Java 6 and later
import org.codehaus.jettison.mapped.*
def writer = new StringWriter()
def mappedWriter = new MappedXMLStreamWriter(new MappedNamespaceConvention(), writer)
def builder = new groovy.xml.StaxBuilder(mappedWriter)
builder.root(attribute:1) {
elem1('hello')
elem2('world')
}
assert writer.toString() == '{"root":{"@attribute":"1","elem1":"hello","elem2":"world"}}'
8.1.5. DOMBuilder
HTML, XHTML, XML을 W3C DOM 트리로 파싱하기 위한 빌더예요. 예를 들어 이 XML String을:
String recordsXML = '''
<records>
<car name='HSV Maloo' make='Holden' year='2006'>
<country>Australia</country>
<record type='speed'>Production Pickup Truck with speed of 271kph</record>
</car>
<car name='P50' make='Peel' year='1962'>
<country>Isle of Man</country>
<record type='size'>Smallest Street-Legal Car at 99cm wide and 59 kg in weight</record>
</car>
<car name='Royale' make='Bugatti' year='1931'>
<country>France</country>
<record type='price'>Most Valuable Car at $15 million</record>
</car>
</records>'''
DOMBuilder로 이렇게 DOM 트리로 파싱할 수 있어요.
def reader = new StringReader(recordsXML)
def doc = groovy.xml.DOMBuilder.parse(reader)
그다음 예를 들어 DOMCategory를 사용해서 더 처리할 수 있어요.
def records = doc.documentElement
use(groovy.xml.dom.DOMCategory) {
assert records.car.size() == 3
}
8.1.6. NodeBuilder
NodeBuilder는 임의의 데이터를 다루기 위해 groovy.util.Node 객체의 중첩 트리를 만드는 데 사용돼요. 간단한 사용자 목록을 만들려면 NodeBuilder를 이렇게 사용해요.
def nodeBuilder = new NodeBuilder()
def userlist = nodeBuilder.userlist {
user(id: '1', firstname: 'John', lastname: 'Smith') {
address(type: 'home', street: '1 Main St.', city: 'Springfield', state: 'MA', zip: '12345')
address(type: 'work', street: '2 South St.', city: 'Boston', state: 'MA', zip: '98765')
}
user(id: '2', firstname: 'Alice', lastname: 'Doe')
}
이제 예를 들어 GPath 표현식을 사용해서 데이터를 더 처리할 수 있어요.
assert [email protected](', ') == 'John, Alice'
assert userlist.user.find { it.@lastname == 'Smith' }.address.size() == 2
8.1.7. JsonBuilder
Groovy의 JsonBuilder는 Json을 만들기 쉽게 해 줘요. 예를 들어 이 Json 문자열을 만들 때:
String carRecords = '''
{
"records": {
"car": {
"name": "HSV Maloo",
"make": "Holden",
"year": 2006,
"country": "Australia",
"record": {
"type": "speed",
"description": "production pickup truck with speed of 271kph"
}
}
}
}
'''
JsonBuilder를 이렇게 사용할 수 있어요.
JsonBuilder builder = new JsonBuilder()
builder.records {
car {
name 'HSV Maloo'
make 'Holden'
year 2006
country 'Australia'
record {
type 'speed'
description 'production pickup truck with speed of 271kph'
}
}
}
String json = JsonOutput.prettyPrint(builder.toString())
JsonUnit을 사용해서 빌더가 기대한 결과를 만들었는지 확인해요.
8.1.8. StreamingJsonBuilder
메모리에 데이터 구조를 만드는 JsonBuilder와 달리(그것은 출력 전에 구조를 프로그래밍 방식으로 바꾸고 싶은 상황에서 유용해요), StreamingJsonBuilder는 중간 메모리 데이터 구조 없이 writer로 직접 스트리밍해요. 구조를 수정할 필요가 없고 더 메모리 효율적인 접근을 원한다면 StreamingJsonBuilder를 쓰세요.
StreamingJsonBuilder의 사용법은 JsonBuilder와 비슷해요. 이 Json 문자열을 만들기 위해:
String carRecords = """
{
"records": {
"car": {
"name": "HSV Maloo",
"make": "Holden",
"year": 2006,
"country": "Australia",
"record": {
"type": "speed",
"description": "production pickup truck with speed of 271kph"
}
}
}
}
"""
StreamingJsonBuilder를 이렇게 사용해요.
StringWriter writer = new StringWriter()
StreamingJsonBuilder builder = new StreamingJsonBuilder(writer)
builder.records {
car {
name 'HSV Maloo'
make 'Holden'
year 2006
country 'Australia'
record {
type 'speed'
description 'production pickup truck with speed of 271kph'
}
}
}
String json = JsonOutput.prettyPrint(writer.toString())
JsonUnit을 사용해서 기대한 결과를 확인해요.
JsonAssert.assertJsonEquals(json, carRecords)
생성된 출력을 커스터마이즈해야 한다면 StreamingJsonBuilder를 만들 때 JsonGenerator 인스턴스를 전달할 수 있어요.
def generator = new JsonGenerator.Options()
.excludeNulls()
.excludeFieldsByName('make', 'country', 'record')
.excludeFieldsByType(Number)
.addConverter(URL) { url -> "http://groovy-lang.org" }
.build()
StringWriter writer = new StringWriter()
StreamingJsonBuilder builder = new StreamingJsonBuilder(writer, generator)
builder.records {
car {
name 'HSV Maloo'
make 'Holden'
year 2006
country 'Australia'
homepage new URL('http://example.org')
record {
type 'speed'
description 'production pickup truck with speed of 271kph'
}
}
}
assert writer.toString() == '{"records":{"car":{"name":"HSV Maloo","homepage":"http://groovy-lang.org"}}}'
8.1.9. SwingBuilder
SwingBuilder는 완전한 기능을 갖춘 Swing GUI를 선언적이고 간결한 방식으로 만들게 해 줘요. Groovy의 흔한 관용구인 빌더를 사용해서 그걸 해내죠. 빌더는 자식 인스턴스화, Swing 메서드 호출, 자식을 부모에 붙이는 것 같은 복잡한 객체를 만드는 번거로운 작업을 대신 처리해 줘요. 그 결과 코드는 훨씬 읽기 쉽고 유지하기 쉬우면서도, 여전히 Swing 컴포넌트 전체 범위에 접근할 수 있어요.
SwingBuilder를 사용하는 간단한 예시예요.
import groovy.swing.SwingBuilder
import java.awt.BorderLayout as BL
count = 0
new SwingBuilder().edt {
frame(title: 'Frame', size: [250, 75], show: true) {
borderLayout()
textlabel = label(text: 'Click the button!', constraints: BL.NORTH)
button(text:'Click Me',
actionPerformed: {count++; textlabel.text = "Clicked ${count} time(s)."; println "clicked"}, constraints:BL.SOUTH)
}
}
이렇게 생겼을 거예요.
이 컴포넌트 계층은 보통 일련의 반복적인 인스턴스화, setter, 그리고 마지막에 이 자식을 각각의 부모에 붙이는 과정으로 만들어져요. 하지만 SwingBuilder를 쓰면 이 계층을 본래 형태로 정의할 수 있어서, 코드를 읽는 것만으로 인터페이스 설계를 이해할 수 있어요.
여기서 보여진 유연성은 Groovy에 내장된 많은 프로그래밍 기능(클로저, 암묵적 생성자 호출, import 별칭, 문자열 보간)을 활용함으로써 가능해요. 물론 SwingBuilder를 쓰기 위해 이런 것들을 완전히 이해할 필요는 없어요. 위 코드에서 보듯 그 사용법은 직관적이니까요.
클로저를 통한 SwingBuilder 코드 재사용 예시가 있는, 조금 더 복잡한 예시를 볼게요.
import groovy.swing.SwingBuilder
import javax.swing.*
import java.awt.*
def swing = new SwingBuilder()
def sharedPanel = {
swing.panel() {
label("Shared Panel")
}
}
count = 0
swing.edt {
frame(title: 'Frame', defaultCloseOperation: JFrame.EXIT_ON_CLOSE, pack: true, show: true) {
vbox {
textlabel = label('Click the button!')
button(
text: 'Click Me',
actionPerformed: {
count++
textlabel.text = "Clicked ${count} time(s)."
println "Clicked!"
}
)
widget(sharedPanel())
widget(sharedPanel())
}
}
}
observable 빈과 바인딩에 의존하는 또 다른 변형도 있어요.
import groovy.swing.SwingBuilder
import groovy.beans.Bindable
class MyModel {
@Bindable int count = 0
}
def model = new MyModel()
new SwingBuilder().edt {
frame(title: 'Java Frame', size: [100, 100], locationRelativeTo: null, show: true) {
gridLayout(cols: 1, rows: 2)
label(text: bind(source: model, sourceProperty: 'count', converter: { v -> v? "Clicked $v times": ''}))
button('Click me!', actionPerformed: { model.count++ })
}
}
@Bindable는 핵심 AST 변환 중 하나예요. 간단한 빈을 observable 빈으로 바꾸는 데 필요한 모든 보일러플레이트 코드를 생성해 줘요. bind() 노드는 PropertyChangeEvent가 발생할 때마다 관심 있는 당사자들을 갱신할 적절한 PropertyChangeListener를 만들어요.
8.1.10. AntBuilder
Note: 여기서는 XML 대신 Groovy로 Ant 빌드 스크립트를 쓰게 해 주는 AntBuilder를 설명해요. Groovy Ant 태스크를 사용해서 Ant에서 Groovy를 쓰는 것에도 관심이 있을 수 있어요.
주로 빌드 도구이지만, Apache Ant는 zip 파일, 복사, 리소스 처리 등을 포함한 파일을 다루는 데 아주 실용적인 도구예요. 하지만 build.xml 파일이나 어떤 Jelly 스크립트로 작업하면서 그 모든 꺾쇠 괄호에 좀 제약을 느꼈거나, XML을 스크립팅 언어로 쓰는 게 조금 이상하다고 느껴서 더 깔끔하고 직관적인 무언가를 원했다면, Groovy로 하는 Ant 스크립팅이 바로 여러분이 찾는 것일 수 있어요.
Groovy에는 AntBuilder라는 헬퍼 클래스가 있는데, Ant 태스크 스크립팅을 정말 쉽게 만들어 줘요. 프로그래밍 구성(변수, 메서드, 루프, 논리 분기, 클래스 등)에 진짜 스크립팅 언어를 쓸 수 있게 해 줘요. 여전히 모든 꺾쇠 괄호가 없는, 깔끔하고 간결한 Ant의 XML처럼 보여요. 물론 스크립트 안에서 이 마크업을 섞어 쓸 수도 있어요. Ant 자체는 jar 파일 모음이에요. classpath에 추가하면 Groovy 안에서 그대로 쉽게 쓸 수 있어요. AntBuilder를 쓰면 더 간결하고 쉽게 이해되는 문법이 된다고 생각해요.
AntBuilder는 우리가 Groovy에서 익숙한 편리한 빌더 표기법으로 Ant 태스크를 직접 노출해요. 가장 기본적인 예시는 표준 출력에 메시지를 출력하는 것이에요.
def ant = new groovy.ant.AntBuilder() (1)
ant.echo('hello from Ant!') (2)
(1)AntBuilder 인스턴스를 만들어요.(2)파라미터의 메시지로 echo 태스크를 실행해요.
ZIP 파일을 만들어야 한다고 상상해 보세요. 이렇게 간단할 수 있어요.
def ant = new AntBuilder()
ant.zip(destfile: 'sources.zip', basedir: 'src')
다음 예시에서는 고전적인 Ant 패턴을 사용해서 파일 목록을 복사하는 AntBuilder의 사용을 Groovy 안에서 직접 보여 드릴게요.
// let's just call one task
ant.echo("hello")
// here is an example of a block of Ant inside GroovyMarkup
ant.sequential {
echo("inside sequential")
def myDir = "build/AntTest/"
mkdir(dir: myDir)
copy(todir: myDir) {
fileset(dir: "src/test") {
include(name: "**/*.groovy")
}
}
echo("done")
}
// now let's do some normal Groovy again
def file = new File(ant.project.baseDir,"build/AntTest/some/pkg/MyTest.groovy")
assert file.exists()
특정 패턴에 맞는 파일 목록을 순회하는 또 다른 예시도 있어요.
// let's create a scanner of filesets
def scanner = ant.fileScanner {
fileset(dir:"src/test") {
include(name:"**/My*.groovy")
}
}
// now let's iterate over
def found = false
for (f in scanner) {
println("Found file $f")
found = true
assert f instanceof File
assert f.name.endsWith(".groovy")
}
assert found
또는 JUnit 테스트를 실행하기:
ant.junit {
classpath { pathelement(path: '.') }
test(name:'some.pkg.MyTest')
}
Groovy에서 자바 파일을 직접 컴파일하고 실행하는 것도 그 이상으로 가능해요.
ant.echo(file:'Temp.java', '''
class Temp {
public static void main(String[] args) {
System.out.println("Hello");
}
}
''')
ant.javac(srcdir:'.', includes:'Temp.java', fork:'true')
ant.java(classpath:'.', classname:'Temp', fork:'true')
ant.echo('Done')
AntBuilder가 Gradle에 포함되어 있다는 점을 언급할 만해요. 그래서 Groovy에서 그러하듯 Gradle에서도 쓸 수 있어요. 추가 문서는 Gradle 매뉴얼에서 찾을 수 있어요.
8.1.11. CliBuilder
CliBuilder는 명령줄 애플리케이션의 사용 가능한 옵션을 지정하는 간결한 방법을 제공하고, 그 명세에 따라 애플리케이션의 명령줄 파라미터를 자동으로 파싱해요. 관례상 옵션 명령줄 파라미터와, 애플리케이션에 인자로 전달되는 나머지 파라미터 사이를 구분해요. 일반적으로 -V나 --tabsize=4 같은 여러 종류의 옵션을 지원할 수 있어요. CliBuilder는 명령줄 처리를 위한 많은 코드를 개발하는 부담을 없애 줘요. 대신 옵션을 선언하는 다소 선언적인 접근을 지원하고, 옵션을 조사하는 간단한 메커니즘으로 명령줄 파라미터를 파싱하는 단일 호출을 제공해요(옵션을 위한 간단한 모델이라고 생각하면 돼요).
만드는 각 명령줄의 세부 사항은 꽤 다를 수 있어도, 매번 같은 주요 단계를 따라요. 먼저 CliBuilder 인스턴스를 만들어요. 그다음 허용된 명령줄 옵션을 정의해요. 이것은 dynamic api 스타일이나 annotation 스타일로 할 수 있어요. 그다음 옵션 명세에 따라 명령줄 파라미터를 파싱해서 옵션 컬렉션을 얻고, 그것을 조사해요.
사용법을 보여 주는 간단한 Greeter.groovy 스크립트 예시예요.
// import of CliBuilder not shown (1)
// specify parameters
def cli = new CliBuilder(usage: 'groovy Greeter [option]') (2)
cli.a(longOpt: 'audience', args: 1, 'greeting audience') (3)
cli.h(longOpt: 'help', 'display usage') (4)
// parse and process parameters
def options = cli.parse(args) (5)
if (options.h) cli.usage() (6)
else println "Hello ${options.a ? options.a : 'World'}" (7)
(1)Groovy의 이전 버전에는 CliBuilder가 groovy.util 패키지에 있었고 import가 필요 없었어요. Groovy 2.5에서 이 접근 방식은 deprecated가 되었어요. 애플리케이션은 대신 groovy.cli.picocli나 groovy.cli.commons 버전을 선택해야 해요. Groovy 2.5의 groovy.util 버전은 하위 호환성을 위해 commons-cli 버전을 가리키지만, Groovy 3.0에서 제거되었어요.(2)선택적인 usage 문자열을 지정한 새 CliBuilder 인스턴스를 정의해요.(3)단일 인자를 받고 선택적인 긴 변형--audience가 있는-a옵션을 지정해요.(4)인자를 받지 않고 선택적인 긴 변형--help가 있는-h옵션을 지정해요.(5)스크립트에 제공된 명령줄 파라미터를 파싱해요.(6)h옵션이 발견되면 usage 메시지를 표시해요.(7)표준 인사말을 표시하거나,a옵션이 발견되면 커스터마이즈된 인사말을 표시해요.
이 스크립트를 명령줄 파라미터 없이 실행하면, 즉:
> groovy Greeter
다음 출력이 나와요.
Hello World
이 스크립트를 유일한 명령줄 파라미터로 -h를 주고 실행하면, 즉:
> groovy Greeter -h
다음 출력이 나와요.
usage: groovy Greeter [option]
-a,--audience <arg> greeting audience
-h,--help display usage
이 스크립트를 명령줄 파라미터로 --audience Groovologist를 주고 실행하면, 즉:
> groovy Greeter --audience Groovologist
다음 출력이 나와요.
Hello Groovologist
위 예시에서 CliBuilder 인스턴스를 만들 때 생성자 호출 안에서 선택적인 usage 프로퍼티를 설정했어요. 이는 생성 중에 인스턴스의 추가 프로퍼티를 설정하는 Groovy의 평범한 능력을 따른 거예요. header, footer 같은 설정할 수 있는 다른 수많은 프로퍼티가 있어요. 사용 가능한 프로퍼티의 전체 목록은 groovy.util.CliBuilder 클래스의 사용 가능한 프로퍼티를 참고하세요.
허용된 명령줄 옵션을 정의할 때는 짧은 이름(예: 앞서 보여 준 help 옵션의 "h")과 짧은 설명(예: help 옵션의 "display usage")을 둘 다 제공해야 해요. 위 예시에서 longOpt와 args 같은 일부 추가 프로퍼티도 설정했어요. 허용된 명령줄 옵션을 지정할 때 지원되는 추가 프로퍼티는 다음과 같아요.
| 이름 | 설명 | 타입 |
|---|---|---|
| argName | 출력에 사용되는 이 옵션 인자의 이름 | String |
| longOpt | 옵션의 긴 표현 또는 긴 이름 | String |
| args | 인자 값의 개수 | int 또는 String (1) |
| optionalArg | 인자 값이 선택적인지 여부 | boolean |
| required | 옵션이 필수인지 여부 | boolean |
| type | 이 옵션의 타입 | Class |
| valueSeparator | 값 구분 문자 | char (2) |
| defaultValue | 기본 값 | String |
| convert | 들어오는 String을 필요한 타입으로 변환 | Closure (1) |
(1) 더 자세한 내용은 나중에.
(2) Groovy에서는 특수한 경우에 단일 문자 String이 char로 강제 변환돼요.
longOpt 변형만 있는 옵션이 있다면, 특수한 짧은 이름 _을 사용해서 옵션을 지정할 수 있어요. 예: cli._(longOpt: 'verbose', 'enable verbose logging').
나머지 이름 붙은 파라미터 중 일부는 꽤 자명하고 다른 것들은 조금 더 설명이 필요해요. 더 설명하기 전에 애노테이션과 함께 CliBuilder를 사용하는 방법을 볼게요.
애노테이션과 인터페이스 사용 (Using Annotations and an interface)
허용된 옵션을 지정하기 위해 일련의 메서드 호출(아주 선언적인 미니 DSL 형태이긴 하지만)을 하는 대신, 애노테이션이 그 옵션들과 처리되지 않은 파라미터를 어떻게 다루는지 나타내고 세부 사항을 제공하는, 허용된 옵션의 인터페이스 명세를 제공할 수 있어요. groovy.cli.Option과 groovy.cli.Unparsed 두 애노테이션이 사용돼요.
이런 명세를 이렇게 정의할 수 있어요.
interface GreeterI {
@Option(shortName='h', description='display usage') Boolean help() (1)
@Option(shortName='a', description='greeting audience') String audience() (2)
@Unparsed(description = "positional parameters") List remaining() (3)
}
(1)-h또는--help로 설정하는 Boolean 옵션을 지정해요.(2)-a또는--audience로 설정하는 String 옵션을 지정해요.(3)남은 파라미터가 저장될 곳을 지정해요.
긴 이름이 인터페이스 메서드 이름에서 자동으로 결정된다는 점을 주목하세요. longName 애노테이션 속성을 사용해서 그 동작을 오버라이드하고 원하면 커스텀 긴 이름을 지정하거나, 긴 이름을 제공하지 않음을 나타내는 longName으로 _을 사용할 수 있어요. 그런 경우 shortName을 지정해야 해요.
인터페이스 명세를 이렇게 사용할 수 있어요.
// import CliBuilder not shown
def cli = new CliBuilder(usage: 'groovy Greeter') (1)
def argz = '--audience Groovologist'.split()
def options = cli.parseFromSpec(GreeterI, argz) (2)
assert options.audience() == 'Groovologist' (3)
argz = '-h Some Other Args'.split()
options = cli.parseFromSpec(GreeterI, argz) (4)
assert options.help()
assert options.remaining() == ['Some', 'Other', 'Args'] (5)
(1)선택적 프로퍼티들로 이전처럼 CliBuilder 인스턴스를 만들어요.(2)인터페이스 명세를 사용해서 파라미터를 파싱해요.(3)인터페이스의 메서드를 사용해서 옵션을 조사해요.(4)다른 파라미터 세트를 파싱해요.(5)남은 파라미터를 조사해요.
parseFromSpec이 호출되면 CliBuilder가 인터페이스를 구현하는 인스턴스를 자동으로 만들고 채워요. 옵션 값을 조사하려면 인터페이스 메서드를 호출하면 돼요.
애노테이션과 인스턴스 사용 (Using Annotations and an instance)
대안으로, 이미 옵션 정보를 담고 있는 도메인 클래스가 있을 수도 있어요. 그 클래스의 프로퍼티나 setter에 애노테이션을 달기만 하면 CliBuilder가 도메인 객체를 적절히 채울 수 있어요. 각 애노테이션은 애노테이션 속성을 통해 그 옵션의 프로퍼티를 설명하고, CliBuilder가 도메인 객체에서 그 옵션을 채우기 위해 사용할 setter를 나타내요.
이런 명세를 이렇게 정의할 수 있어요.
class GreeterC {
@Option(shortName='h', description='display usage')
Boolean help (1)
private String audience
@Option(shortName='a', description='greeting audience')
void setAudience(String audience) { (2)
this.audience = audience
}
String getAudience() { audience }
@Unparsed(description = "positional parameters")
List remaining (3)
}
(1)Boolean 프로퍼티가 옵션임을 나타내요.(2)String 프로퍼티(명시적 setter 포함)가 옵션임을 나타내요.(3)남은 인자들이 저장될 곳을 지정해요.
명세를 이렇게 사용할 수 있어요.
// import CliBuilder not shown
def cli = new CliBuilder(usage: 'groovy Greeter [option]') (1)
def options = new GreeterC() (2)
def argz = '--audience Groovologist foo'.split()
cli.parseFromInstance(options, argz) (3)
assert options.audience == 'Groovologist' (4)
assert options.remaining == ['foo'] (5)
(1)선택적 파라미터들로 이전처럼 CliBuilder 인스턴스를 만들어요.(2)CliBuilder가 채울 인스턴스를 만들어요.(3)인자를 파싱해서 제공된 인스턴스를 채워요.(4)String 옵션 프로퍼티를 조사해요.(5)남은 인자 프로퍼티를 조사해요.
parseFromInstance가 호출되면 CliBuilder가 여러분의 인스턴스를 자동으로 채워요. 옵션 값에 접근하려면 도메인 객체에서 제공한 인스턴스 프로퍼티(또는 어떤 접근자 메서드든)를 조사하면 돼요.
애노테이션과 스크립트 사용 (Using Annotations and a script)
마지막으로, 스크립트 전용 편의 애노테이션 별칭이 두 개 더 있어요. 이전에 언급한 애노테이션들과 groovy.transform.Field를 단순히 결합한 것이에요. 그 애노테이션들의 groovydoc에 세부 사항이 공개돼요: groovy.cli.OptionField와 groovy.cli.UnparsedField.
앞선 인스턴스 예시와 같은 인자로 호출되는 독립형 스크립트에서 그 애노테이션들을 사용하는 예시가 여기 있어요.
// import CliBuilder not shown
import groovy.cli.OptionField
import groovy.cli.UnparsedField
@OptionField String audience
@OptionField Boolean help
@UnparsedField List remaining
new CliBuilder().parseFromInstance(this, args)
assert audience == 'Groovologist'
assert remaining == ['foo']
인자가 있는 옵션 (Options with arguments)
첫 예시에서 일부 옵션은 Greeter -h처럼 플래그처럼 동작하고, 다른 옵션은 Greeter --audience Groovologist처럼 인자를 받는다는 것을 보았어요. 가장 단순한 경우는 플래그처럼 동작하거나 단일(잠재적으로 선택적인) 인자를 가진 옵션이에요. 그런 경우들이 포함된 예시를 볼게요.
// import CliBuilder not shown
def cli = new CliBuilder()
cli.a(args: 0, 'a arg') (1)
cli.b(args: 1, 'b arg') (2)
cli.c(args: 1, optionalArg: true, 'c arg') (3)
def options = cli.parse('-a -b foo -c bar baz'.split()) (4)
assert options.a == true
assert options.b == 'foo'
assert options.c == 'bar'
assert options.arguments() == ['baz']
options = cli.parse('-a -c -b foo bar baz'.split()) (5)
assert options.a == true
assert options.c == true
assert options.b == 'foo'
assert options.arguments() == ['bar', 'baz']
(1)단순히 플래그인 옵션 - 기본값이에요. args를 0으로 설정하는 건 허용되지만 필요하지 않아요.(2)정확히 하나의 인자를 가진 옵션이에요.(3)선택적 인자를 가진 옵션이에요. 옵션이 빠지면 플래그처럼 동작해요.(4)'c' 옵션에 인자가 공급되는 이 명세를 사용한 예시예요.(5)'c' 옵션에 인자가 공급되지 않는 이 명세를 사용한 예시예요. 그냥 플래그예요.
참고: 선택적 인자를 가진 옵션을 만나면, (다소) 탐욕적으로 제공된 명령줄 파라미터에서 다음 파라미터를 소비해요. 하지만 다음 파라미터가 알려진 긴/짧은 옵션(이중 또는 단일 하이픈으로 시작하는)과 일치하면, 그게 우선해요. 예를 들어 위 예시의 -b가 그래요.
옵션 인자는 애노테이션 스타일로도 지정할 수 있어요. 그런 정의를 보여 주는 인터페이스 옵션 명세가 여기 있어요.
interface WithArgsI {
@Option boolean a()
@Option String b()
@Option(optionalArg=true) String[] c()
@Unparsed List remaining()
}
그리고 이렇게 사용돼요.
def cli = new CliBuilder()
def options = cli.parseFromSpec(WithArgsI, '-a -b foo -c bar baz'.split())
assert options.a()
assert options.b() == 'foo'
assert options.c() == ['bar']
assert options.remaining() == ['baz']
options = cli.parseFromSpec(WithArgsI, '-a -c -b foo bar baz'.split())
assert options.a()
assert options.c() == []
assert options.b() == 'foo'
assert options.remaining() == ['bar', 'baz']
이 예시는 배열 타입 옵션 명세를 사용해요. 이건 여러 인자를 논의할 때 곧 더 자세히 다룰게요.
타입 지정 (Specifying a type)
명령줄의 인자는 본질적으로 String이지만(또는 플래그의 경우 Boolean으로 간주될 수도 있지만), 추가 타입 정보를 공급함으로써 더 풍부한 타입으로 자동 변환될 수 있어요. 애노테이션 기반 인자 정의 스타일에서는 이 타입들이 필드 타입(애노테이션 프로퍼티), 애노테이션된 메서드의 반환 타입(또는 setter 메서드의 setter 인자 타입)으로 공급돼요. 동적 메서드 인자 정의 스타일에서는 Class 이름을 지정할 수 있는 특별한 'type' 프로퍼티가 지원돼요.
명시적 타입이 정의되면, args 이름 붙은 파라미터는 1로 가정돼요(기본적으로 0인 Boolean 타입 옵션 제외). 필요하면 명시적 args 파라미터를 여전히 제공할 수 있어요.
동적 api 인자 정의 스타일에서 타입을 사용하는 예시예요.
def argz = '''-a John -b -d 21 -e 1980 -f 3.5 -g 3.14159
-h cv.txt -i DOWN and some more'''.split()
def cli = new CliBuilder()
cli.a(type: String, 'a-arg')
cli.b(type: boolean, 'b-arg')
cli.c(type: Boolean, 'c-arg')
cli.d(type: int, 'd-arg')
cli.e(type: Long, 'e-arg')
cli.f(type: Float, 'f-arg')
cli.g(type: BigDecimal, 'g-arg')
cli.h(type: File, 'h-arg')
cli.i(type: RoundingMode, 'i-arg')
def options = cli.parse(argz)
assert options.a == 'John'
assert options.b
assert !options.c
assert options.d == 21
assert options.e == 1980L
assert options.f == 3.5f
assert options.g == 3.14159
assert options.h == new File('cv.txt')
assert options.i == RoundingMode.DOWN
assert options.arguments() == ['and', 'some', 'more']
프리미티브, 숫자 타입, 파일, enum, 그리고 그 배열들이 지원돼요(org.codehaus.groovy.runtime.StringGroovyMethods#asType를 사용해 변환돼요).
인자 String의 커스텀 파싱 (Custom parsing of the argument String)
지원되는 타입만으로 충분하지 않다면, String을 풍부한 타입으로 변환하는 것을 처리할 클로저를 제공할 수 있어요. 동적 api 스타일을 사용하는 예시예요.
def argz = '''-a John -b Mary -d 2016-01-01 and some more'''.split()
def cli = new CliBuilder()
def lower = { it.toLowerCase() }
cli.a(convert: lower, 'a-arg')
cli.b(convert: { it.toUpperCase() }, 'b-arg')
cli.d(convert: { Date.parse('yyyy-MM-dd', it) }, 'd-arg')
def options = cli.parse(argz)
assert options.a == 'john'
assert options.b == 'MARY'
assert options.d.format('dd-MM-yyyy') == '01-01-2016'
assert options.arguments() == ['and', 'some', 'more']
대안으로 변환 클로저를 애노테이션 파라미터로 공급해서 애노테이션 스타일을 쓸 수 있어요. 예시 명세예요.
interface WithConvertI {
@Option(convert={ it.toLowerCase() }) String a()
@Option(convert={ it.toUpperCase() }) String b()
@Option(convert={ Date.parse("yyyy-MM-dd", it) }) Date d()
@Unparsed List remaining()
}
그 명세를 사용하는 예시예요.
Date newYears = Date.parse("yyyy-MM-dd", "2016-01-01")
def argz = '''-a John -b Mary -d 2016-01-01 and some more'''.split()
def cli = new CliBuilder()
def options = cli.parseFromSpec(WithConvertI, argz)
assert options.a() == 'john'
assert options.b() == 'MARY'
assert options.d() == newYears
assert options.remaining() == ['and', 'some', 'more']
여러 인자가 있는 옵션 (Options with multiple arguments)
1보다 큰 args 값을 사용해도 여러 인자가 지원돼요. valueSeparator라는 특별한 이름 붙은 파라미터가 있는데, 여러 인자를 처리할 때 선택적으로 사용할 수 있어요. 명령줄에서 그런 인자 목록을 공급할 때 지원되는 문법에 약간의 유연성을 더해 줘요. 예를 들어 값 구분 문자로 ','를 공급하면 명령줄에서 쉼표로 구분된 값 목록을 전달할 수 있어요.
args 값은 보통 정수예요. 선택적으로 String으로 공급될 수도 있어요. 두 가지 특수 String 기호가 있어요: ` `와 `*`.
* 값은 0개 이상을 의미해요. ` 값은 1개 이상을 의미해요. * 값은 +를 사용하면서 optionalArg 값을 true로 설정하는 것과 같아요.
여러 인자에 접근하는 것은 특별한 관례를 따라요. 인자 옵션에 접근할 때 사용하는 평범한 프로퍼티에 's'를 더하기만 하면 모든 공급된 인자를 목록으로 가져올 수 있어요. 그래서 짧은 옵션 'a'의 경우, options.a로 첫 번째 'a' 인자에 접근하고 options.as로 모든 인자 목록에 접근해요. 단수 변형('s' 없는 것)이 없다면 's'로 끝나는 짧은 이름이나 긴 이름을 가져도 괜찮아요. 그래서 여러 인자를 가진 옵션 하나가 name이고 단일 인자를 가진 다른 옵션이 guess라면, options.names와 options.guess를 사용하는 데 혼동이 없어요.
여러 인자의 사용을 강조하는 발췌문이 여기 있어요.
// import CliBuilder not shown
def cli = new CliBuilder()
cli.a(args: 2, 'a-arg')
cli.b(args: '2', valueSeparator: ',', 'b-arg') (1)
cli.c(args: '+', valueSeparator: ',', 'c-arg') (2)
def options = cli.parse('-a 1 2 3 4'.split()) (3)
assert options.a == '1' (4)
assert options.as == ['1', '2'] (5)
assert options.arguments() == ['3', '4']
options = cli.parse('-a1 -a2 3'.split()) (6)
assert options.as == ['1', '2']
assert options.arguments() == ['3']
options = cli.parse(['-b1,2']) (7)
assert options.bs == ['1', '2']
(1)args 값이 String으로 공급되고 쉼표 값 구분 문자가 지정돼요.(2)하나 이상의 인자가 허용돼요.(3)두 개의 명령줄 파라미터가 'b' 옵션의 인자 목록으로 공급될 거예요.(4)'a' 옵션의 첫 번째 인자에 접근해요.(5)'a' 옵션의 인자 목록에 접근해요.(6)'a' 옵션에 두 인자를 지정하는 대체 문법이에요.(7)쉼표로 구분된 값으로 공급된 'b' 옵션의 인자들이에요.
복수 이름(plural name) 접근 방식으로 여러 인자에 접근하는 대안으로, 옵션에 배열 기반 타입을 사용할 수 있어요. 그 경우 모든 옵션이 항상 평범한 단수 이름으로 접근되는 배열을 통해 반환돼요. 타입을 논의할 때 곧 그 예시를 볼게요.
애노테이션 스타일 옵션 정의에서 배열 타입을 애노테이션된 클래스 멤버(메서드나 프로퍼티)로 사용해서도 여러 인자가 지원돼요. 이 예시에서 볼 수 있죠.
interface ValSepI {
@Option(numberOfArguments=2) String[] a()
@Option(numberOfArgumentsString='2', valueSeparator=',') String[] b()
@Option(numberOfArgumentsString='+', valueSeparator=',') String[] c()
@Unparsed remaining()
}
그리고 이렇게 사용돼요.
def cli = new CliBuilder()
def options = cli.parseFromSpec(ValSepI, '-a 1 2 3 4'.split())
assert options.a() == ['1', '2']
assert options.remaining() == ['3', '4']
options = cli.parseFromSpec(ValSepI, '-a1 -a2 3'.split())
assert options.a() == ['1', '2']
assert options.remaining() == ['3']
options = cli.parseFromSpec(ValSepI, ['-b1,2'] as String[])
assert options.b() == ['1', '2']
options = cli.parseFromSpec(ValSepI, ['-c', '1'] as String[])
assert options.c() == ['1']
options = cli.parseFromSpec(ValSepI, ['-c1'] as String[])
assert options.c() == ['1']
options = cli.parseFromSpec(ValSepI, ['-c1,2,3'] as String[])
assert options.c() == ['1', '2', '3']
타입과 여러 인자 (Types and multiple arguments)
동적 api 인자 정의 스타일에서 타입과 여러 인자를 사용하는 예시예요.
def argz = '''-j 3 4 5 -k1.5,2.5,3.5 and some more'''.split()
def cli = new CliBuilder()
cli.j(args: 3, type: int[], 'j-arg')
cli.k(args: '+', valueSeparator: ',', type: BigDecimal[], 'k-arg')
def options = cli.parse(argz)
assert options.js == [3, 4, 5] (1)
assert options.j == [3, 4, 5] (1)
assert options.k == [1.5, 2.5, 3.5]
assert options.arguments() == ['and', 'some', 'more']
(1)배열 타입의 경우 뒤의 's'를 쓸 수 있지만 필요하지는 않아요.
기본 값 설정 (Setting a default value)
Groovy는 엘비스 연산자를 사용해서 어떤 변수의 사용 지점에서 기본 값을 제공하기 쉽게 해 줘요. 예: String x = someVariable ?: 'some default'. 하지만 때로는 그런 기본 값을 옵션 명세의 일부로 만들어서 나중 단계에서 조사자(interrogator)의 작업을 최소화하고 싶을 수 있어요. CliBuilder는 이 시나리오를 충족하기 위해 defaultValue 프로퍼티를 지원해요.
동적 api 스타일로 이렇게 사용할 수 있어요.
def cli = new CliBuilder()
cli.f longOpt: 'from', type: String, args: 1, defaultValue: 'one', 'f option'
cli.t longOpt: 'to', type: int, defaultValue: '35', 't option'
def options = cli.parse('-f two'.split())
assert options.hasOption('f')
assert options.f == 'two'
assert !options.hasOption('t')
assert options.t == 35
options = cli.parse('-t 45'.split())
assert !options.hasOption('from')
assert options.from == 'one'
assert options.hasOption('to')
assert options.to == 45
비슷하게, 애노테이션 스타일로 그런 명세를 원할 수도 있어요. 인터페이스 명세를 사용하는 예시예요.
interface WithDefaultValueI {
@Option(shortName='f', defaultValue='one') String from()
@Option(shortName='t', defaultValue='35') int to()
}
이렇게 사용돼요.
def cli = new CliBuilder()
def options = cli.parseFromSpec(WithDefaultValueI, '-f two'.split())
assert options.from() == 'two'
assert options.to() == 35
options = cli.parseFromSpec(WithDefaultValueI, '-t 45'.split())
assert options.from() == 'one'
assert options.to() == 45
인스턴스와 함께 애노테이션을 쓸 때도 defaultValue 애노테이션 속성을 사용할 수 있어요. 다만 프로퍼티(또는 뒷받침 필드)에 대한 초기 값을 제공하는 것도 아마 그만큼 쉬울 거예요.
TypeChecked와 함께 사용 (Use with TypeChecked)
CliBuilder를 사용하는 동적 api 스타일은 본질적으로 동적이지만, Groovy의 정적 타입 검사 기능을 활용하고 싶다면 몇 가지 옵션이 있어요. 먼저 애노테이션 스타일을 고려해 보세요. 예를 들어 여기 인터페이스 옵션 명세가 있어요.
interface TypeCheckedI{
@Option String name()
@Option int age()
@Unparsed List remaining()
}
그리고 여기 보이듯 @TypeChecked와 결합해 사용할 수 있어요.
@TypeChecked
void testTypeCheckedInterface() {
def argz = "--name John --age 21 and some more".split()
def cli = new CliBuilder()
def options = cli.parseFromSpec(TypeCheckedI, argz)
String n = options.name()
int a = options.age()
assert n == 'John' && a == 21
assert options.remaining() == ['and', 'some', 'more']
}
둘째, 동적 api 스타일에는 약간의 지원을 제공하는 기능이 있어요. 정의 문장은 본질적으로 동적이지만 실제로는 값(앞선 예시들에서 무시했던)을 반환해요. 반환된 값은 실제로 TypedOption<Type>이고, 특수한 getAt 지원 덕분에 options[저장된타입옵션]처럼 타입화된 옵션으로 조사할 수 있어요. 그래서 코드의 타입 검사되지 않은 부분에 이런 문장이 있다면:
def cli = new CliBuilder()
TypedOption<Integer> age = cli.a(longOpt: 'age', type: Integer, 'some age option')
그다음 다음 문장들은 타입 검사되는 별도의 코드 부분에 있을 수 있어요.
def args = '--age 21'.split()
def options = cli.parse(args)
int a = options[age]
assert a == 21
마지막으로 CliBuilder가 제공하는 추가 편의 메서드가 하나 더 있어서 정의 부분까지 타입 검사하게 할 수 있어요. 조금 더 장황한 메서드 호출이에요. 메서드 호출에 짧은 이름(opt 이름)을 사용하는 대신 option이라는 고정 이름을 사용하고 opt 값을 프로퍼티로 공급해요. 다음 예시에서 보듯 타입도 직접 지정해야 해요.
import groovy.cli.TypedOption
import groovy.transform.TypeChecked
@TypeChecked
void testTypeChecked() {
def cli = new CliBuilder()
TypedOption<String> name = cli.option(String, opt: 'n', longOpt: 'name', 'name option')
TypedOption<Integer> age = cli.option(Integer, longOpt: 'age', 'age option')
def argz = "--name John --age 21 and some more".split()
def options = cli.parse(argz)
String n = options[name]
int a = options[age]
assert n == 'John' && a == 21
assert options.arguments() == ['and', 'some', 'more']
}
고급 CLI 사용 (Advanced CLI Usage)
Note: 고급 CLI 기능. CliBuilder는 picocli나 Apache Commons CLI 위의 Groovy 친화적인 래퍼라고 생각할 수 있어요. CliBuilder가 제공하지 않는데 밑에 있는 라이브러리에서 지원되는 기능이 있다면, 현재 CliBuilder 구현(과 다양한 Groovy 언어 기능) 덕분에 밑에 있는 라이브러리 메서드를 직접 호출하기 쉬워요. 그렇게 하는 것은 CliBuilder가 제공하는 Groovy 친화적 문법을 활용하면서도 여전히 밑에 있는 라이브러리의 일부 고급 기능에 접근하는 실용적인 방법이에요. 다만 주의할 점이 있어요. 미래 버전의 CliBuilder는 다른 밑에 있는 라이브러리를 사용할 수 있고, 그 경우 Groovy 클래스 및/또는 스크립트에 일부 이식 작업이 필요할 수 있어요.
####### Apache Commons CLI
예시로, Apache Commons CLI의 그룹화(grouping) 메커니즘을 사용하는 코드가 여기 있어요.
import org.apache.commons.cli.*
def cli = new CliBuilder()
cli.f longOpt: 'from', 'f option'
cli.u longOpt: 'until', 'u option'
def optionGroup = new OptionGroup()
optionGroup.with {
addOption cli.option('o', [longOpt: 'output'], 'o option')
addOption cli.option('d', [longOpt: 'directory'], 'd option')
}
cli.options.addOptionGroup optionGroup
assert !cli.parse('-d -o'.split()) (1)
(1)그룹에서 한 번에 하나의 옵션만 사용할 수 있으므로 parse는 실패할 거예요.
####### Picocli
아래는 picocli 버전의 CliBuilder에서 사용 가능한 일부 기능이에요.
새 프로퍼티: errorWriter 애플리케이션 사용자가 잘못된 명령줄 인자를 주면, CliBuilder는 오류 메시지와 usage 도움말 메시지를 stderr 출력 스트림에 써요. 프로그램 출력이 다른 프로세스의 입력으로 사용될 때 오류 메시지가 파싱되지 않도록 stdout 스트림을 사용하지 않아요. errorWriter를 다른 값으로 설정해서 대상을 커스터마이즈할 수 있어요. 반면에 CliBuilder.usage()는 usage 도움말 메시지를 stdout 스트림에 출력해요. 이렇게 하면 사용자가 도움말을 요청할 때(예: --help 파라미터로) 출력을 less나 grep 같은 유틸리티로 파이프할 수 있어요. 테스트를 위해 다른 writer를 지정할 수 있어요.
하위 호환성을 위해, writer 프로퍼티를 다른 값으로 설정하면 writer와 errorWriter 둘 다 지정한 writer로 설정된다는 점을 알아두세요.
ANSI 색상 picocli 버전의 CliBuilder는 지원되는 플랫폼에서 usage 도움말 메시지를 ANSI 색상으로 자동 렌더링해요. 원하면 이를 커스터마이즈할 수 있어요.(예시는 아래에 있어요.)
새 프로퍼티: name 이전처럼 usage 프로퍼티로 usage 도움말 메시지의 개요(synopsis)를 설정할 수 있어요. 작은 개선점에 관심이 갈 수 있어요. 명령 이름만 설정하면 반복 요소 뒤에 …이 붙고 선택 요소는 [와 ]로 감싸진 개요가 자동으로 생성돼요.(예시는 아래에 있어요.)
새 프로퍼티: usageMessage 이 프로퍼티는 밑에 있는 picocli 라이브러리의 UsageMessageSpec 객체를 노출하는데, usage 도움말 메시지의 다양한 섹션에 대한 세밀한 제어를 제공해요. 예를 들어:
def cli = new CliBuilder()
cli.name = "myapp"
cli.usageMessage.with {
headerHeading("@|bold,underline Header heading:|@%n")
header("Header 1", "Header 2") // before the synopsis
synopsisHeading("%n@|bold,underline Usage:|@ ")
descriptionHeading("%n@|bold,underline Description heading:|@%n")
description("Description 1", "Description 2") // after the synopsis
optionListHeading("%n@|bold,underline Options heading:|@%n")
footerHeading("%n@|bold,underline Footer heading:|@%n")
footer("Footer 1", "Footer 2")
}
cli.a('option a description')
cli.b('option b description')
cli.c(args: '*', 'option c description')
cli.usage()
이 출력이 나와요.
프로퍼티: parser parser 프로퍼티는 파서 동작을 커스터마이즈하는 데 쓸 수 있는 picocli ParserSpec 객체에 접근하게 해 줘요. 이것은 파서를 제어하는 CliBuilder 옵션이 충분히 세밀하지 않을 때 유용할 수 있어요. 예를 들어 CliBuilder의 Commons CLI 구현과의 하위 호환을 위해, 기본적으로 CliBuilder는 알 수 없는 옵션을 만나면 옵션 찾기를 멈추고 이후의 명령줄 인자를 위치 파라미터(positional parameters)로 취급해요. CliBuilder는 stopAtNonOption 프로퍼티를 제공하고, 이것을 false로 설정하면 파서를 더 엄격하게 만들어 알 수 없는 옵션이 Unknown option: '-x' 오류를 내게 해요.
하지만 알 수 없는 옵션을 위치 파라미터로 취급하면서도 이후의 명령줄 인자를 옵션으로 계속 처리하고 싶다면 어떨까요? 이건 parser 프로퍼티로 해결할 수 있어요. 예를 들어:
def cli = new CliBuilder()
cli.parser.stopAtPositional(false)
cli.parser.unmatchedOptionsArePositionalParams(true)
// ...
def opts = cli.parse(args)
// ...
자세한 내용은 문서를 참고하세요.
맵 옵션 마지막으로, 애플리케이션에 키-값 쌍인 옵션이 있다면 picocli의 맵 지원에 관심이 갈 수 있어요. 예를 들어:
import java.util.concurrent.TimeUnit
import static java.util.concurrent.TimeUnit.DAYS
import static java.util.concurrent.TimeUnit.HOURS
def cli = new CliBuilder()
cli.D(args: 2, valueSeparator: '=', 'the old way') (1)
cli.X(type: Map, 'the new way') (2)
cli.Z(type: Map, auxiliaryTypes: [TimeUnit, Integer].toArray(), 'typed map') (3)
def options = cli.parse('-Da=b -Dc=d -Xx=y -Xi=j -ZDAYS=2 -ZHOURS=23'.split())(4)
assert options.Ds == ['a', 'b', 'c', 'd'] (5)
assert options.Xs == [ 'x':'y', 'i':'j' ] (6)
assert options.Zs == [ (DAYS as TimeUnit):2, (HOURS as TimeUnit):23 ] (7)
(1)이전에는 key=value 쌍이 부분으로 나뉘어 목록에 추가됐어요.(2)picocli 맵 지원: 옵션의 타입으로 Map을 지정하기만 하면 돼요.(3)맵 요소의 타입까지 지정할 수 있어요.(4)비교하려고 각 옵션에 키-값 쌍 두 개를 지정할게요.(5)이전에는 모든 키-값 쌍이 목록으로 끝났고, 이 목록을 다루는 건 애플리케이션의 몫이었어요.(6)picocli는 키-값 쌍을 Map으로 반환해요.(7)맵의 키와 값 모두 강타입이 될 수 있어요.
Picocli 버전 제어 특정 버전의 picocli를 사용하려면 빌드 구성에 그 버전에 대한 의존성을 추가하세요. 사전 설치된 Groovy 버전으로 스크립트를 실행한다면 @Grab 애노테이션을 사용해서 CliBuilder에서 사용할 picocli 버전을 제어하세요.
@GrabConfig(systemClassLoader=true)
@Grab('info.picocli:picocli:4.2.0')
import groovy.cli.picocli.CliBuilder
def cli = new CliBuilder()
8.1.12. ObjectGraphBuilder
ObjectGraphBuilder는 JavaBean 관례를 따르는 임의의 빈 그래프를 위한 빌더예요. 특히 테스트 데이터를 만드는 데 유용해요. 도메인에 속한 클래스 목록부터 시작해 볼게요.
package com.acme
class Company {
String name
Address address
List employees = []
}
class Address {
String line1
String line2
int zip
String state
}
class Employee {
String name
int employeeId
Address address
Company company
}
그러면 ObjectGraphBuilder로 직원 세 명을 가진 Company를 만드는 것은 이렇게 쉬워요.
def builder = new ObjectGraphBuilder() (1)
builder.classLoader = this.class.classLoader (2)
builder.classNameResolver = "com.acme" (3)
def acme = builder.company(name: 'ACME') { (4)
3.times {
employee(id: it.toString(), name: "Drone $it") { (5)
address(line1:"Post street") (6)
}
}
}
assert acme != null
assert acme instanceof Company
assert acme.name == 'ACME'
assert acme.employees.size() == 3
def employee = acme.employees[0]
assert employee instanceof Employee
assert employee.name == 'Drone 0'
assert employee.address instanceof Address
(1)새 객체 그래프 빌더를 만들어요.(2)클래스가 해석될 classloader를 설정해요.(3)해석될 클래스의 기본 패키지 이름을 설정해요.(4)Company 인스턴스를 만들어요.(5)Employee 인스턴스 3개를요.(6)각각 서로 다른 Address를 가지고요.
뒤에서 객체 그래프 빌더는:
- 기본 ClassNameResolver 전략(패키지 이름을 요구하는)을 사용해서 노드 이름을 Class와 일치시키려 시도해요.
- 그다음 기본 NewInstanceResolver 전략(인자 없는 생성자를 호출하는)을 사용해서 적절한 클래스의 인스턴스를 만들어요.
- 두 가지 다른 전략이 관련된 중첩 노드의 부모/자식 관계를 해석해요.
- RelationNameResolver는 부모에 있는 자식 프로퍼티의 이름과, 자식에 있는 부모 프로퍼티의 이름(있다면, 이 경우 Employee는 적절히 company라는 이름의 부모 프로퍼티를 가져요)을 산출해요.
- ChildPropertySetter는 자식이 Collection에 속하는지 여부를 고려해서 자식을 부모에 삽입해요(이 경우 employees는 Company의 Employee 인스턴스 목록이어야 해요).
4개 전략 모두 코드가 JavaBeans 작성의 평범한 관례를 따르면 예상대로 동작하는 기본 구현이 있어요. 빈이나 객체 중 어느 것이든 그 관례를 따르지 않는다면 각 전략에 자신만의 구현을 끼워 넣을 수 있어요. 예를 들어 불변(immutable) 클래스를 만들어야 한다고 상상해 보세요.
@Immutable
class Person {
String name
int age
}
그다음 빌더로 Person을 만들려고 하면:
def person = builder.person(name:'Jon', age:17)
런타임에 이렇게 실패할 거예요.
Cannot set read-only property: name for class: com.acme.Person
이건 새 인스턴스 전략을 바꿔서 고칠 수 있어요.
builder.newInstanceResolver = { Class klazz, Map attributes ->
if (klazz.getConstructor(Map)) {
def o = klazz.newInstance(attributes)
attributes.clear()
return o
}
klazz.newInstance()
}
ObjectGraphBuilder는 노드당 id를 지원해요. 즉 빌더에 노드에 대한 참조를 저장할 수 있다는 뜻이에요. 여러 객체가 같은 인스턴스를 참조할 때 유용해요. 일부 도메인 모델에서 id라는 프로퍼티는 비즈니스 의미가 있을 수 있으므로, ObjectGraphBuilder에는 기본 이름 값을 변경하도록 구성할 수 있는 IdentifierResolver라는 전략이 있어요. 이전에 저장한 인스턴스를 참조하는 데 사용되는 프로퍼티에서도 같은 일이 일어날 수 있는데, ReferenceResolver라는 전략이 적절한 값을 산출해요(기본은 refId).
def company = builder.company(name: 'ACME') {
address(id: 'a1', line1: '123 Groovy Rd', zip: 12345, state: 'JV') (1)
employee(name: 'Duke', employeeId: 1, address: a1) (2)
employee(name: 'John', employeeId: 2 ){
address( refId: 'a1' ) (3)
}
}
(1)address를 id로 만들 수 있어요.(2)employee는 자신의 id로 address를 직접 참조할 수 있어요.(3)또는 해당 address의 id에 해당하는 refId 속성을 사용해요.
참조된 빈의 프로퍼티는 수정할 수 없다는 점을 언급할 만해요.
8.1.13. JmxBuilder
자세한 내용은 Working with JMX - JmxBuilder를 참고하세요.
8.1.14. FileTreeBuilder
groovy.util.FileTreeBuilder는 명세에서 파일 디렉터리 구조를 생성하는 빌더예요. 예를 들어 다음 트리를 만들려면:
src/
|--- main
| |--- groovy
| |--- Foo.groovy
|--- test
|--- groovy
|--- FooTest.groovy
FileTreeBuilder를 이렇게 사용할 수 있어요.
tmpDir = File.createTempDir()
def fileTreeBuilder = new FileTreeBuilder(tmpDir)
fileTreeBuilder.dir('src') {
dir('main') {
dir('groovy') {
file('Foo.groovy', 'println "Hello"')
}
}
dir('test') {
dir('groovy') {
file('FooTest.groovy', 'class FooTest extends groovy.test.GroovyTestCase {}')
}
}
}
모든 게 예상대로 동작했는지 확인하기 위해 다음 assert들을 사용해요.
assert new File(tmpDir, '/src/main/groovy/Foo.groovy').text == 'println "Hello"'
assert new File(tmpDir, '/src/test/groovy/FooTest.groovy').text == 'class FooTest extends groovy.test.GroovyTestCase {}'
FileTreeBuilder는 축약 문법도 지원해요.
tmpDir = File.createTempDir()
def fileTreeBuilder = new FileTreeBuilder(tmpDir)
fileTreeBuilder.src {
main {
groovy {
'Foo.groovy'('println "Hello"')
}
}
test {
groovy {
'FooTest.groovy'('class FooTest extends groovy.test.GroovyTestCase {}')
}
}
}
이것은 위와 같은 디렉터리 구조를 만들어요. 다음 assert들이 보여 주죠.
assert new File(tmpDir, '/src/main/groovy/Foo.groovy').text == 'println "Hello"'
assert new File(tmpDir, '/src/test/groovy/FooTest.groovy').text == 'class FooTest extends groovy.test.GroovyTestCase {}'
8.2. 빌더 만들기 (Creating a builder)
Groovy에는 많은 내장 빌더가 있지만, 빌더 패턴이 너무 흔해서 결국 내장 빌더들이 충족하지 못하는 빌드 요구 사항을 만나게 될 거예요. 좋은 소식은 직접 만들 수 있다는 거예요. Groovy의 메타프로그래밍 능력에 의존해서 처음부터 전부 할 수도 있어요. 대안으로 BuilderSupport와 FactoryBuilderSupport 클래스가 자신만의 빌더를 설계하는 것을 훨씬 쉽게 만들어 줘요.
8.2.1. BuilderSupport
빌더를 만드는 한 가지 접근은 BuilderSupport를 서브클래싱하는 거예요. 이 접근에서 기본 아이디어는 BuilderSupport 추상 클래스의 setParent, nodeCompleted, 그리고 createNode 메서드 중 일부나 전부를 포함한 여러 라이프사이클 메서드 중 하나 이상을 오버라이드하는 거예요.
예를 들어 운동 훈련 프로그램을 추적하는 빌더를 만들고 싶다고 해 볼게요. 각 프로그램은 여러 세트로 구성되고, 각 세트는 자신만의 단계(steps)를 가져요. 단계 자체가 더 작은 단계들의 집합일 수도 있어요. 각 세트나 단계에 대해 필요한 거리(또는 시간), 단계를 특정 횟수만큼 반복할지, 각 단계 사이에 휴식을 취할지 등을 기록하고 싶을 수 있어요.
이 예시의 단순함을 위해, 훈련 프로그래밍을 맵과 리스트로 포착할게요. 세트는 단계 목록을 가져요. 반복 횟수나 거리 같은 정보는 각 단계와 세트의 속성 맵에서 추적돼요.
빌더 구현은 다음과 같아요.
- createNode 메서드를 몇 개 오버라이드해요. 세트 이름, 빈 단계 목록, 그리고 잠재적으로 일부 속성을 담는 맵을 만들어요.
- 노드를 완료할 때마다 부모(있다면)의 단계 목록에 그 노드를 추가해요.
코드는 이렇게 생겼어요.
class TrainingBuilder1 extends BuilderSupport {
protected createNode(name) {
[name: name, steps: []]
}
protected createNode(name, Map attributes) {
createNode(name) + attributes
}
void nodeCompleted(maybeParent, node) {
if (maybeParent) maybeParent.steps << node
}
// unused lifecycle methods
protected void setParent(parent, child) { }
protected createNode(name, Map attributes, value) { }
protected createNode(name, value) { }
}
다음으로, 필요에 따라 반복된 단계를 고려하면서 모든 하위 단계의 거리를 재귀적으로 더하는 작은 헬퍼 메서드를 쓸게요.
def total(map) {
if (map.distance) return map.distance
def repeat = map.repeat ?: 1
repeat * map.steps.sum{ total(it) }
}
마지막으로 이제 빌더와 헬퍼 메서드를 사용해서 수영 훈련 프로그램을 만들고 총 거리를 확인할 수 있어요.
def training = new TrainingBuilder1()
def monday = training.swimming {
warmup(repeat: 3) {
freestyle(distance: 50)
breaststroke(distance: 50)
}
endurance(repeat: 20) {
freestyle(distance: 50, break: 15)
}
warmdown {
kick(distance: 100)
choice(distance: 100)
}
}
assert 1500 == total(monday)
8.2.2. FactoryBuilderSupport
빌더를 만드는 두 번째 접근은 FactoryBuilderSupport를 서브클래싱하는 거예요. 이 빌더는 BuilderSupport와 목표가 비슷하지만, 도메인 클래스 구성을 단순화하는 추가 기능이 있어요. 이 접근에서 기본 아이디어는 FactoryBuilderSupport 추상 클래스의 resolveFactory, nodeCompleted, postInstantiate 메서드를 포함한 여러 라이프사이클 메서드 중 하나 이상을 오버라이드하는 거예요.
이전 BuilderSupport 예시와 같은 예시를 사용할게요. 운동 훈련 프로그램을 추적하는 빌더요. 이 예시에서는 훈련 프로그래밍을 맵과 리스트로 포착하는 대신, 간단한 도메인 클래스를 몇 개 사용할게요.
빌더 구현은 다음과 같아요.
- resolveFactory 메서드를 오버라이드해서, 미니 DSL에서 사용한 이름을 대문자로 바꿔 클래스를 반환하는 특수 팩토리를 반환해요.
- 노드를 완료할 때마다 부모(있다면)의 단계 목록에 그 노드를 추가해요.
특수 팩토리 클래스 코드를 포함한 코드는 이렇게 생겼어요.
import static org.apache.groovy.util.BeanUtils.capitalize
class TrainingBuilder2 extends FactoryBuilderSupport {
def factory = new TrainingFactory(loader: getClass().classLoader)
protected Factory resolveFactory(name, Map attrs, value) {
factory
}
void nodeCompleted(maybeParent, node) {
if (maybeParent) maybeParent.steps << node
}
}
class TrainingFactory extends AbstractFactory {
ClassLoader loader
def newInstance(FactoryBuilderSupport fbs, name, value, Map attrs) {
def clazz = loader.loadClass(capitalize(name))
value ? clazz.newInstance(value: value) : clazz.newInstance()
}
}
리스트와 맵을 사용하는 대신, 간단한 도메인 클래스와 관련 트레이트를 사용할게요.
trait HasDistance {
int distance
}
trait Container extends HasDistance {
List steps = []
int repeat
}
class Cycling implements Container { }
class Interval implements Container { }
class Sprint implements HasDistance {}
class Tempo implements HasDistance {}
BuilderSupport 예시와 마찬가지로, 훈련 세션 동안 덮은 총 거리를 계산하는 헬퍼 메서드가 있으면 유용해요. 구현은 이전 예시와 아주 비슷하지만, 새로 정의한 트레이트와 잘 동작하도록 조정돼요.
def total(HasDistance c) {
c.distance
}
def total(Container c) {
if (c.distance) return c.distance
def repeat = c.repeat ?: 1
repeat * c.steps.sum{ total(it) }
}
마지막으로 이제 새 빌더와 헬퍼 메서드들을 사용해서 사이클 훈련 프로그램을 만들고 총 거리를 확인할 수 있어요.
def training = new TrainingBuilder2()
def tuesday = training.cycling {
interval(repeat: 5) {
sprint(distance: 400)
tempo(distance: 3600)
}
}
assert 20000 == total(tuesday)
더 알아보기 (Learn more)
- 문법 (Syntax) — 명령 체인 등 DSL에 쓰이는 문법
- 메타프로그래밍 (Metaprogramming) — AST 변환과 카테고리
- Groovy 개발 키트 (GDK) — XML·JSON·Swing 등 내장 빌더가 쓰는 유틸리티
- XML 사용자 가이드 — XML 빌더들