DSL이란
DSL은 Domain Specific Language의 약자이다. 말 그대로 도메인에 특화된 언어라는 소리인데, 특정 도메인에 국한된, 특정 도메인을 위해 만들어진 언어이다. 대표적으로 HTML은 웹페이지를 위해, Markdown은 문서 작업을 위해 특화된 언어이기 때문에 DSL이라고 부를 수 있다.
반면 여러 목적을 위해 범용적으로 사용할 수 있는 언어를 GPL(General Purpose Language)라고 한다. GPL을 이용해서 다양한 문제를 해결하거나 다양한 소프트웨어를 개발할 수 있다. 대표적으로 C, Java, Kotlin, Javascript, Python 등의 언어를 GPL이라고 부를 수 있다.
DSL은 특정 도메인을 위해 만들어진 언어이기 때문에 범위가 특정 도메인에 국한된다. 언어의 가독성, 문법, 라이브러리 등이 항상 목적으로 삼는 도메인과 연결되어 있고, 이는 목적 도메인에 연관되어 있는 사람이 주로 사용하게 된다.
GPL은 범용적으로 사용 가능한 언어임으로 일반적인 프로그래밍 언어의 표현과 문법을 따른다. 따라서 다양한 분야에서 사용이 가능하며 여러 라이브러리나 도구를 통해 확장이 가능하다.
Kotlin과 DSL
지금까지의 설명만 보면 DSL은 특별하게 제작된 언어인 느낌이 든다. 하지만 Kotlin의 언어 특성을 활용하여 쉽게 도메인에 특화된, DSL을 만들 수 있다. 주로 Kotlin의 확장함수, 중위 표기 함수, 함수형 프로그래밍, 연산자 오버로딩 등을 활용하여 Kotlin 스타일의 DSL을 만든다.
val car = mockk<Car>()
every { car.drive(Direction.NORTH) } returns Outcome.OK
car.drive(Direction.NORTH) // returns OK
verify { car.drive(Direction.NORTH) }
confirmVerified(car)
위 코드는 Kotlin 모킹 라이브러리 MockK의 샘플 코드이다. MockK는 DSL을 통해 every ~ return ~ 형식으로 모킹할 수 있고, 위와 같이 작성하니 코드만 읽어도 그 의미가 와닿기 쉽다.
그렇다면 Kotlin의 어떤 특성을 이용해서 DSL을 구축할 수 있는지 살펴보자.
확장 함수
확장 함수는 클래스나 인터페이스의 기능을 확장할 때 사용하는 함수이다. 상속이나 Decorator 패턴과 같은 기법을 사용하지 않고 함수로 간단하게 확장할 수 있다는 장점이 있다.
fun String.concat(b: String): String {
return this + b
}
println("A".concat("B")) // AB
확장 함수는 확장 하려는 클래스를 수신 객체 타입으로 작성한다. 수신 객체 타입은 함수명 앞 . 기호 앞에 표기한다. 이러면 String 클래스에는 concat이라는 함수가 확장된다.
이 외에 확장함수의 자세한 특징은 공식 문서를 참조하면 좋다.
중위 표현 함수
중위 표현 함수는 말 그대로 중위 표현을 통해서 함수를 호출할 수 있는 함수이다.
public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)
mapOf(
"A" to 1,
"B" to 2,
)
public infix fun Int.until(to: Int): IntRange {
if (to <= Int.MIN_VALUE) return IntRange.EMPTY
return this .. (to - 1).toInt()
}
(10 until 20).forEach(::println)
`Pair` 객체를 생성하는 `to`, `IntRange` 객체를 생성하는 `until` 역시 중위 표현 함수이기 때문에 위와 같이 작성할 수 있다.
중위 표현 함수는 `infix` 키워드를 사용하여 만들 수 있는데, 함수의 사용 형식이 "a 표기식 b"의 형식이기 때문에 선언 조건이 있다.
1. 멤버 함수이거나 확장 함수여야 한다.
2. 함수 파라미터는 한 개만 가져야 하며, 가변인자(vararg)역시 불가능하다
3. 함수 파라미터는 기본 값을 가질 수 없다.
중위 표현 함수의 장점은 일반 함수 호출 문법을 따르지 않기 때문에 언어 자체를 자연스럽게 쓰는듯한 효과가 있다.
1. "A" to 1
2. "A".to(1)
실제 1번처럼 중위 표현 함수를 사용하여 코드를 작성한다면, 영어를 자연스럽게 쓰듯이 보이지만 2번은 프로그래밍 언어를 사용하는 느낌을 받게 된다. 아무튼 이러한 특징 때문에 특정 도메인의 용어를 중위 표현 함수를 DSL에 활용하여 의미를 표현하곤 한다.
함수형 프로그래밍
함수형 프로그래밍는 이전에 함수형 프로그래밍 포스팅에서 자세하게 다뤘기에 해당 포스팅을 참조하길 바란다. 함수형 프로그래밍을 사용하여 DSL을 만드는 경우는 주로 2가지 기법을 사용하는데, 함수 파라미터가 마지막에 들어갈 경우 괄호 바깥에 중괄호로 표기가 가능하다는 점과, 수신 객체 지정 람다식(확장 함수의 람다식)은 수신객체를 함수 블록 내에서 바로 사용할 수 있다는 점을 주로 이용하여 DSL을 구성한다. 이에 대해서는 예시를 만들 때 자세하게 설명할 예정이다.
연산자 오버로딩
연산자 오버로딩이란 언어에서 정의된 연산자를 특정 데이터에 맞게 구현한 것을 의미한다. 보통 + 라는 연산자는 숫자형 데이터에서는 더하기 기능을 수행하지만 문자열에서는 문자열을 합치는 기능을 수행한다. 이처럼 데이터에 따라 연산자의 의미가 달라질 수 있고, 이를 사용자가 만든 특정 데이터 타입에도 연산자를 구현할 수 있게 하는 것이 연산자 오버로딩이다.
Kotlin은 Java와 달리 언어에서 지원하는 연산자를 오버로딩 할 수 있다.
fun main() {
val pointA = Point(1, 2)
val pointB = Point(3, 4)
println(pointA + pointB) // "(x=4, y=6)"
}
class Point(
val x: Int,
val y: Int,
) {
operator fun plus(other: Point): Point {
return Point(x + other.x, y + other.y)
}
override fun toString(): String {
return "(x=$x, y=$y)"
}
}
연산자 오버로딩은 `operator` 수정자를 함수 선언부에 작성하여 할 수 있다. 연산자마다 정해진 함수명이 있으며, 위 예시에서 + 연산자는 plus라는 이름으로 정해져 있다. 위 예시에서는 두 점을 나타내는 `Point`라는 클래스의 더하기를 오버로딩 하여 객체끼리 + 연산자를 사용할 수 있도록 만들었다.
연산자 오버로딩을 DSL에 활용하는 이유는 연산자를 통해 간결하게 도메인을 표현할 수 있기 때문이다. 보통 plus라는 언어보다 + 라는 기호가 읽기 편하고 의미를 간단하게 전달하기 좋은데, 이를 도메인 상황에 맞추어서 표현할 수 있기에 DSL에 적용하기 좋다.
번외. 왜 연산자 오버라이딩(overriding)이 아니라 연산자 오버로딩(overloading)인가?
연산자 오버로딩은 연산자의 기능을 새롭게 구현 한다는 점에서 오버로딩이 아니라 오버라이딩이 아닌가? 라고 생각을 했는데, 연산자의 통상적인 의미를 변경하는 것을 목적으로 하지 않기 때문에, 같은 연산자이지만 적용되는 데이터 타입이 다르기 때문에 오버로딩으로 부른다는 의견이 많다.
말로 설명하니 이해하기 어려운데, 한번 예시를 들어보자. + 라는 연산자를 오버로딩 할 때 이를 의미상 더하기, 합치기 등의 의미를 가지도록 오버로딩 할 것이다. + 연산자를 오버로딩 하면서 빼기, 나누기 등의 의미를 가지는 기능으로는 구현을 하지 않는다는 소리다.
class Point(
val x: Int,
val y: Int,
) {
operator fun plus(other: Point): Point {
return Point(x - other.x, y - other.y)
}
}
위의 예시에서 다른 `Point` 객체의 x, y 프로퍼티를 빼도록 함수 구현부를 바꿔보자. 이러면 실제 + 연산자를 사용하는 사용자 입장에서 더하는 의미인 +를 사용했는데 x, y는 줄어들어 혼란에 빠질 수 있다. 이 연산자 오버로딩이라는 것이, 데이터 타입에 맞추어 그 연산자의 기능에 부합하도록 재정의 하는 것이 목적이지 아예 다른 기능으로 재정의 하는 것이 목적은 아니기 때문에 오버로딩이라는 워딩이 더 적합하다.
또 다른 근거로는 같은 연산자 함수이지만, 적용되는 데이터 타입이 달라지는 것이기 때문에 오버로딩이 맞다는 것이다. 원래 + 라는 연산자는 `Int`, `Double`, `String` 형 등등의 데이터 타입에서 사용 가능했다. 하지만 연산자 오버로딩을 통해서 다른 데이터 타입의 함수로 확장하는 개념이기 때문에, 부모 클래스의 함수를 하위 클래스에서 재정의 하는 것과는 다른 개념이다. 따라서 오버로딩이 더 적합한 표현이다.
DSL 예제 - Spring REST Docs 코드 DSL로 개선하기
현재 업무에서도 Spring REST Docs로 API 문서 작업을 수행하고 있다. 매번 작성하면서 웹 테스트 코드와 더불어서 REST Docs 코드까지, 하나의 케이스에 코드가 굉장히 길어지고 조잡해지는 것을 느꼈다. 마침 토스에서 이를 Kotlin DSL로 개선했다는 글을 보게 되었고, 이를 응용하여 나만의 DSL을 예제로 만들어보고자 한다.
@Test
fun test() {
mockMvc.post("/test/{testId}", 11) {
requestAttr(RestDocumentationGenerator.ATTRIBUTE_NAME_URL_TEMPLATE, "/testtest/{testId}")
contentType = MediaType.APPLICATION_JSON
characterEncoding = StandardCharsets.UTF_8.name()
content = createJson(TestRequest("123", 456))
}.andExpectAll {
status { isOk() }
}.andDo {
print()
handle(
MockMvcRestDocumentation.document(
"test-docs-rest",
Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
Preprocessors.preprocessResponse(Preprocessors.prettyPrint()),
RequestDocumentation.pathParameters(
RequestDocumentation.parameterWithName("testId")
.description("파라미터 이름")
),
PayloadDocumentation.requestFields(
PayloadDocumentation.fieldWithPath("id")
.attributes(Attributes.key("format").value("UUID"))
.description("아이디"),
PayloadDocumentation.fieldWithPath("num")
.attributes(Attributes.key("format").value("NUMBER"))
.description("아이디")
.optional()
),
PayloadDocumentation.responseFields(
PayloadDocumentation.beneathPath("data").withSubsectionId("data"),
PayloadDocumentation.fieldWithPath("type")
.type(JsonFieldType.STRING)
.attributes(Attributes.key("format").value("link:popup/custom-type.html[가능한 필드 참고 링크,role=\\\"popup\\\"]"))
.description("타입"),
PayloadDocumentation.subsectionWithPath("info")
.type(JsonFieldType.OBJECT)
.description("정보"),
),
PayloadDocumentation.responseFields(
PayloadDocumentation.beneathPath("data.info").withSubsectionId("info"),
PayloadDocumentation.fieldWithPath("description")
.type(JsonFieldType.STRING)
.attributes(Attributes.key("format").value("설명 형식"))
.description("설명")
.optional(),
)
)
)
}
}
Spring 테스트와 REST Docs를 이용해 간단하게 테스트 용도로 API 하나를 만들어 테스트 및 문서 코드를 추가했다. 간단한 API이지만 코드가 굉장히 복잡하다(물론 Static Import를 통해서 줄일 수 있지만 그래도 복잡하다). 업무에서는 유틸성으로 코드를 조금 줄여서 개발할 수 있도록 해놓긴 했지만, 그래도 역시 기본 구조 자체는 위와 같이 작성을 해야하다보니 복잡하다.
@Test
fun test() {
mockMvc.post("/test/{testId}", 11) {
requestAttr(RestDocumentationGenerator.ATTRIBUTE_NAME_URL_TEMPLATE, "/testtest/{testId}")
contentType = MediaType.APPLICATION_JSON
characterEncoding = StandardCharsets.UTF_8.name()
content = createJson(TestRequest("123", 456))
}.andExpectAll {
status { isOk() }
}.andDo {
print()
handle(
document("test-docs") {
pathParameters {
"testId" means "아이디"
}
requestFields {
"id" means "아이디" typeOf STRING formattedAs "UUID"
"num" means "번호" typeOf NUMBER isOptional true
}
responseFields("data") {
"type" means "타입" typeOf ENUM(CustomType::class)
"info" isNested "정보" typeOf OBJECT
}
responseFields("data.info") {
"description" means "설명" typeOf STRING formattedAs "설명 형식" isOptional true
}
}
)
}
}
직접 DSL을 만들어 개선한 코드이다. 코드의 양도 줄어들었고, 무엇보다 이해하기 쉬운 직관적인 코드가 되었다. 지금부터 어떻게 구현했는지 설명하겠다.
우선 기존 REST Docs의 코드 구조를 살펴보면 Document -> Snippet -> Descriptor의 구조로 작성하게 되어 있다. DSL을 만들기 위해 이 구조를 그대로 가져갈 것이다. `Descriptor`, `Snippet`을 래핑하는 형식으로 개발할 것이며, 최종적으로 Document 계층에서 이를 빌드하는 형식으로 구현할 것이다.
DescriptorWrapper
abstract class DescriptorWrapper<T: AbstractDescriptor<T>> {
abstract val descriptor: T
abstract infix fun typeOf(type: FieldTypeWrapper): DescriptorWrapper<T>
abstract infix fun formattedAs(format: String): DescriptorWrapper<T>
abstract infix fun isIgnored(isIgnored: Boolean): DescriptorWrapper<T>
abstract infix fun isOptional(isOptional: Boolean): DescriptorWrapper<T>
}
class FieldDescriptorWrapper(
override val descriptor: FieldDescriptor
) : DescriptorWrapper<FieldDescriptor>() {
override fun typeOf(type: FieldTypeWrapper): DescriptorWrapper<FieldDescriptor> {
descriptor.type(type.originType)
if (type.customFormat != null) descriptor.format(type.customFormat!!)
return this
}
override fun formattedAs(format: String): DescriptorWrapper<FieldDescriptor> {
descriptor.format(format)
return this
}
override fun isIgnored(isIgnored: Boolean): DescriptorWrapper<FieldDescriptor> {
if (isIgnored) descriptor.ignored()
return this
}
override fun isOptional(isOptional: Boolean): DescriptorWrapper<FieldDescriptor> {
if (isOptional) descriptor.optional()
return this
}
}
class ParameterDescriptorWrapper(
override val descriptor: ParameterDescriptor
) : DescriptorWrapper<ParameterDescriptor>() {
override fun typeOf(type: FieldTypeWrapper): DescriptorWrapper<ParameterDescriptor> {
descriptor.format(type::class.simpleName!!)
return this
}
override fun formattedAs(format: String): DescriptorWrapper<ParameterDescriptor> {
descriptor.format(format)
return this
}
override fun isIgnored(isIgnored: Boolean): DescriptorWrapper<ParameterDescriptor> {
if (isIgnored) descriptor.ignored()
return this
}
override fun isOptional(isOptional: Boolean): DescriptorWrapper<ParameterDescriptor> {
if (isOptional) descriptor.optional()
return this
}
}
SpringRestDocs의 `AbstractDescriptor` 구현체를 멤버로 가질 수 있도록 제네릭 클래스로 작성하였고, 각 구현체에 따라 멤버로 가지는 `AbstractDescriptor`의 구현체를 함수 체이닝 방식으로 조정할 수 있도록 추상메서드를 정의해 놓았다. `FieldDescriptorWrapper`는 JSON타입의 Field 형식을 다루기 위해, `ParamenterDescriptorWrapper`는 일반적인 파라미터 형식을 다루기 위해 구현했다. 이 코드가 실제 DSL에서 어떻게 사용되는지는 아래에서 다룰 `SnippetWrapper` 코드를 본 후에 살펴보자.
`typeOf` 함수의 파라미터로 들어가는 `FieldTypeWrapper`는 토스의 DSL 글에서 사용하는 방식이 직관적이라고 생각하여 응용했다.
sealed class FieldTypeWrapper(
val originType: JsonFieldType,
open val customFormat: String? = null
)
object ARRAY: FieldTypeWrapper(JsonFieldType.ARRAY)
object BOOLEAN: FieldTypeWrapper(JsonFieldType.BOOLEAN)
object OBJECT: FieldTypeWrapper(JsonFieldType.OBJECT)
object NUMBER: FieldTypeWrapper(JsonFieldType.NUMBER)
object NULL: FieldTypeWrapper(JsonFieldType.NULL)
object STRING: FieldTypeWrapper(JsonFieldType.STRING)
object ANY: FieldTypeWrapper(JsonFieldType.VARIES)
object DATE: FieldTypeWrapper(JsonFieldType.STRING, "yyyy-MM-dd")
object DATETIME: FieldTypeWrapper(JsonFieldType.STRING, "yyyy-MM-ddTHH:mm:ss")
data class ENUM<T : Enum<T>>(
val enums: Collection<T>,
override val customFormat: String? = enums.joinToString(", "),
) : FieldTypeWrapper(
JsonFieldType.STRING,
) {
constructor(clazz:KClass<T>) : this(
enums = clazz.java.enumConstants.asList(),
customFormat = createPopupLink(CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_HYPHEN, clazz.simpleName!!))
)
}
Enum 타입의 경우에는 ascii doc에 팝업 형식으로 문서를 띄어 따로 표시했는데, 이를 접목하여 개발하였다.
SnippetWrapper
abstract class SnippetWrapper<T: AbstractDescriptor<T>> {
val descriptors = mutableListOf<DescriptorWrapper<T>>()
abstract infix fun String.means(description: String): DescriptorWrapper<T>
abstract fun build(): Snippet
}
abstract class FieldSnippetWrapper(
nestedPathName: String? = null
): SnippetWrapper<FieldDescriptor>() {
protected var beneathPath: PayloadSubsectionExtractor<*>? =
if (nestedPathName != null) {
PayloadDocumentation.beneathPath(nestedPathName)
.withSubsectionId(nestedPathName.replace(".[]", "").split(".").last())
} else null
override fun String.means(description: String): DescriptorWrapper<FieldDescriptor> {
return addDescriptor(PayloadDocumentation.fieldWithPath(this).description(description))
}
infix fun String.isNested(description: String): DescriptorWrapper<FieldDescriptor> {
return addDescriptor(PayloadDocumentation.subsectionWithPath(this).description(description))
}
private fun addDescriptor(descriptor: FieldDescriptor): FieldDescriptorWrapper {
val descriptorDsl = FieldDescriptorWrapper(descriptor)
descriptors.add(descriptorDsl)
return descriptorDsl
}
}
class RequestFieldSnippetWrapper(
nestedPathName: String? = null
): FieldSnippetWrapper(nestedPathName) {
override fun build(): Snippet {
return if (beneathPath != null) PayloadDocumentation.requestFields(beneathPath, descriptors.map { it.descriptor })
else PayloadDocumentation.requestFields(descriptors.map { it.descriptor })
}
}
class ResponseFieldSnippetWrapper(
nestedPathName: String? = null
): FieldSnippetWrapper(nestedPathName) {
override fun build(): Snippet {
return if (beneathPath != null) PayloadDocumentation.responseFields(beneathPath, descriptors.map { it.descriptor })
else PayloadDocumentation.responseFields(descriptors.map { it.descriptor })
}
}
class QueryParameterSnippetWrapper: SnippetWrapper<ParameterDescriptor>() {
override fun String.means(description: String): DescriptorWrapper<ParameterDescriptor> {
val descriptorDsl = ParameterDescriptorWrapper(parameterWithName(this).description(description))
descriptors.add(descriptorDsl)
return descriptorDsl
}
override fun build(): Snippet {
return queryParameters(descriptors.map { it.descriptor })
}
}
class PathParameterSnippetWrapper: SnippetWrapper<ParameterDescriptor>() {
override fun String.means(description: String): DescriptorWrapper<ParameterDescriptor> {
val descriptorDsl = ParameterDescriptorWrapper(parameterWithName(this).description(description))
descriptors.add(descriptorDsl)
return descriptorDsl
}
override fun build(): Snippet {
return pathParameters(descriptors.map { it.descriptor })
}
}
Snippet을 빌드할 수 있도록 `SnippetWrapper`를 작성했다. Spring REST Docs는 `Snippet`을 단위로 하여 문서를 작성하는데, PathParamenter, QueryParameter, RequestField, RequestHeader... 등등의 `Snippet` 구현체가 각각 따로 구현되어 있다. 그래서 항상 `Snippet`을 생성하는 코드를 작성할 때 `requestFields`, `pathParameteres`... 등의 메서드를 각각의 형식의 맞도록 작성해야 했다. 기본 구조가 이렇게 때문에 `SnippetWrapper`라는 추상 클래스를 만들고 각 형식에 따라서 필요한 `Snippet`을 빌드할 수 있도록 했다. 각 구현체를 보면 `build` 함수에서 `pathParameteres`, `queryParameters`와 같이 각 래퍼 구현체에 맞는 `Snippet` 객체를 생성하고 있다.
우선 `String` 확장함수 `means`를 구현하여 이 함수를 통해 `DescriptorWrapper`를 만들 수 있도록 하였고 이를 `descriptors` 리스트에 저장한다. `DescriptorWrapper`는 함수 체이닝을 통해 `Descriptor`를 완성한다. 이를 실제 DSL에서 사용하면 아래와 같다.
"id" means "아이디" typeOf STRING formattedAs "UUID"
"num" means "번호" typeOf NUMBER isOptional true
`means` 중위 함수를 통해 `Descriptor` 구현체를 만들고, 해당 구현체의 멤버 함수를 체이닝으로 호출하여 `Descriptor`를 작성한다.
이렇게 작성된 `Descriptor`는 `SnippetWrapper`의 `build` 함수를 통해서 기존의 Spring REST Docs의 `AbstractFieldSnippet` 구현체로 만들어지게 된다. 이렇게 만들어진 구현체는 아래에서 살펴볼 `DocsDsl` 클래스에서 함께 빌드된다.
DocsDsl
class DocsDsl(
private val identifier: String
) {
private val snippets: MutableList<Snippet> = mutableListOf()
fun requestFields(nestedFieldName: String? = null, init: FieldSnippetWrapper.() -> Unit) {
val requestFieldSnippet = RequestFieldSnippetWrapper(nestedFieldName)
requestFieldSnippet.init()
this.snippets.add(requestFieldSnippet.build())
}
fun queryParameters(init: QueryParameterSnippetWrapper.() -> Unit) {
val queryParameterSnippet = QueryParameterSnippetWrapper()
queryParameterSnippet.init()
this.snippets.add(queryParameterSnippet.build())
}
fun pathParameters(init: PathParameterSnippetWrapper.() -> Unit) {
val pathParameterSnippet = PathParameterSnippetWrapper()
pathParameterSnippet.init()
this.snippets.add(pathParameterSnippet.build())
}
fun responseFields(nestedFieldName: String? = null, init: FieldSnippetWrapper.() -> Unit) {
val responseFieldSnippet = ResponseFieldSnippetWrapper(nestedFieldName)
responseFieldSnippet.init()
this.snippets.add(responseFieldSnippet.build())
}
fun build(): ResultHandler {
return MockMvcRestDocumentation.document(
identifier,
Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
Preprocessors.preprocessResponse(Preprocessors.prettyPrint()),
*snippets.toTypedArray()
)
}
}
fun document(identifier: String, init: DocsDsl.() -> Unit): ResultHandler {
val document = DocsDsl(identifier)
document.init()
return document.build()
}
작성한 `SnippetWrapper`를 저장하고, 이를 최종적으로 `RestDocumentationResultHandler` 구현체를 생성하는 역할을 수행한다. Spring의 `MockMvc`에서는 웹 호출 결과를 `ResultActions`을 통해 검증하고, 추가적인 작업을 수행한다. 기존의 `MockMvcRestDocumentation.document()` 함수는 `ResultHandler` 구현체를 생성하게 되는데 이는 `ResultActions`의 `andDo()` 메서드를 통해 추가적인 작업을 수행하게 되고 이 추가적인 작업이라는게 문서화를 하는 작업이다. 따라서 `build` 함수에서 `ResultHandler` 구현체를 생성하여 `andDo` 메서드의 인자로 넘겨주면 문서 작업을 실행할 수 있다.
맨 아래의 `document` 함수를 보면, 인자로 `identifier`, `init`을 받고 있다. `indentifier`는 문서의 식별자, `init`은 `DocsDsl`을 수신객체로 가지는 함수이다. 수신객체 지정 람다식은 람다 내부에서 해당 수신객체의 함수를 호출할 수 있다. 왜냐하면 수신객체 지정 람다식을 실제 사용하는 함수 안에서 해당 객체가 실제로 있어야 람다를 호출할 수 있기 때문이다. 그래서 `document` 함수에서 `DocsDsl` 객체를 생성한 후, 람다 `init`을 호출한다. 이 성질을 이용하여 DSL을 작성할 수 있게 된다.
document("test-docs") {
pathParameters {
"testId" means "아이디"
}
requestFields {
"id" means "아이디" typeOf STRING formattedAs "UUID"
"num" means "번호" typeOf NUMBER isOptional true
}
responseFields("data") {
"type" means "타입" typeOf ENUM(CustomType::class)
"info" isNested "정보" typeOf OBJECT
}
responseFields("data.info") {
"description" means "설명" typeOf STRING formattedAs "설명 형식" isOptional true
}
}
`document` 함수의 마지막 파라미터는 람다로 전해주고, 이 람다는 수신객체 지정 람다이기 때문에 `RestDsl`의 `pathParameters`, `requestFields`... 등의 멤버 함수를 객체 없이 사용할 수 있다. 이 각 함수 역시 `SnippetWrapper`를 수신객체로 가지는 함수가 파라미터로 선언되어 있는데, 이 역시 `SnippetWrapper`의 함수를 객체 없이 사용 가능하게 된다. 따라서 이 안에 `means` 함수를 사용할 수 있게 되는 것이다.