본문 바로가기

프로그래밍 언어 노트/JAVA | Kotlin

Kotlin DSL 에 다양한 제약사항을 적용해 보자 Feat [Contract]

필자는 Fluent API DSL 보다 Kotlin Type safe builder 를 활용한 DSL 을 좋아하는데, 보기에 좀더 깔끔하기 때문이다.

Flunet API 는 Chaining 신경써야 하고 Depth 파악이 더 어렵기 때문

그러나 Fluent API의 경우 Class Method Chaining 을 기반으로 하기 때문에 Class base 로 제약조건을 걸기가 좀더 쉽다.

 

예를들어, Name Age Email 을 받아서 User 를 만드는 Builder 를 설계한다고 해보자.

제약 조건은 다음과 같다.

1. age 는 Optional

2. Name -> Email 순으로 받아야 하며, Required Field 이다 (일종의 Step)

물론 해당 조건은 Compile Time 에 해당 제약 조건이 적용 되어야 한다 (exception 으로 처리하는것이 아님)

 

러프하게 다음과 같이 작성 할 수 있다

interface NameStep {
    fun setName(name: String): EmailStep
    fun setAge(age: Int): NameStep
}

interface EmailStep {
    fun setEmail(email: String): OptionalStep
    fun setAge(age: Int): EmailStep
}

interface OptionalStep {
    fun setAge(age: Int): OptionalStep
    fun build(): User
}


class UserFluentBuilder : NameStep {
    private var name: String = ""
    private var age: Int? = null

    override fun setName(name: String): EmailStep {
        this.name = name
        return UserEmailBuilder(name=name, age=age)
    }

    override fun setAge(age: Int): NameStep {
        this.age = age
        return this
    }
}

class UserEmailBuilder(private var name: String, private var age: Int?) : EmailStep {
    private var email : String = ""

    override fun setEmail(email: String): OptionalStep {
        this.email = email
        return OptionalBuilder(name=name, age=age, email=email)
    }

    override fun setAge(age: Int): EmailStep {
        this.age = age
        return this
    }

}

class OptionalBuilder(private var name:String,private var age: Int?, private var email:String) : OptionalStep {
    override fun setAge(age: Int): OptionalStep {
        this.age = age
        return this
    }

    override fun build(): User {
        return User(name, email, age)
    }
}

fun main() {
    val user1 = UserFluentBuilder()
        .setName("User")
        .setEmail("user1@mail.com")
        .setAge(20)
        .build()

    println(user1)
}

 

 

fluent api 는 그냥 일반적인 class 의 chaining과 동일 하므로, 그냥 그렇게 되도록 짜면 된다.

 

문제는 이것을 kotlin type safe builder 식으로 구현 하고 싶을때인데, 이게 조금 까리한것이,

우리가 원하는것은 age/name/email 을 동일한 Line 에 구현 하고 싶다는 것이다.

// 이런 느낌으로 만들고 싶다
user {
    age = 10
    name = "seeroe"
    email = "dldnjs1013@nate.com"
}
    
// 이런 느낌은 싫다!
user {
   name ("seeroe") {
       age (10) {
          email("dlndjs1013@nate.com")
       }
    }
}

하지만 fluent api 를 그대로 kotlin type 식으로 표현하면 아래와 같은 하이어리키를 가지게 된다. 꼴보기 싫다.

 

요것이 까리한 이유이다..

 

같은 Depth 내에서

1. age 는 Optional

2. Name -> Email 순으로 받아야 하며, Required Field 이다(일종의 Step)

위 조건을 만족하는 Kotlin DSL 을 구현하기 어려운 이유는,  Depth 구분을 하지 않으면 리턴타입 표현이 안되고 Required 여부를 강제하는 방법도 딱히 없기 때문이다.

 

대충 Generic 과 Sealed class 에 확장메서드 + where generic 조건도 고민해보고

타 랭기지의 monad notation 이 flatmap chain 을 flat 하게 해주니까 Receivers vs. flatMap | Arrow (arrow-kt.io) 을 보면서 적용할수 있지 않을까 고민도 많이 해봣는데 

원하는 코드대로는 절대 나오지 않아서 열심히 구글링한 결과.. 비슷한 내용을 얻을 수 있었다. (허풍쟁이 AI는 이럴때 쓸모가 없다)

https://stackoverflow.com/questions/53651519/how-to-make-field-required-in-kotlin-dsl-builders

 

How to make field required in kotlin DSL builders

In Kotlin, when creating a custom DSL, what is the best way to force filling required fields inside the builder's extension functions in compile time. E.g.: person { name = "John Doe" // this ...

stackoverflow.com

 

어마어마한 보일러 플레이트 코드가 생기긴 하는데, 뭐 여튼..

방법은 확장 메서드 + Where 절로 타입 제약을 걸고  "contract" 를 이용하는것으로 스마트 케스팅을 유도한다  (왜 이 생각을 못햇지?!)

import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract

data class User(val name: String, val email: String, val age: Int?)

sealed class UserBuilder {
    var age: Int? = null

    interface Named {
        var name: String
    }

    interface Emailed {
        var email: String
    }

    private class Impl : UserBuilder(), Named, Emailed {
        override lateinit var name: String
        override lateinit var email: String
    }

    companion object {
        operator fun invoke(): UserBuilder = Impl()
    }
}

@OptIn(ExperimentalContracts::class)
fun UserBuilder.name(name: String) {
    contract {
        returns() implies (this@name is UserBuilder.Named)
    }
    (this as UserBuilder.Named).name = name
}

@OptIn(ExperimentalContracts::class)
fun <S> S.email(email: String) where S:UserBuilder, S:UserBuilder.Named {
    contract {
        returns() implies (this@email is UserBuilder.Emailed)
    }
    (this as UserBuilder.Emailed).email = email
}

fun <S> S.build(): User where S : UserBuilder, S : UserBuilder.Named, S:UserBuilder.Emailed =
    User(name, email, age)

fun user(init: UserBuilder.() -> User) = UserBuilder().init()

fun main() {
    val builder = UserBuilder()
    builder.age = 25
    builder.name("John Doe")
    builder.email("dldnjs1013@nate.com")
    builder.build()

    val u = user {
        name("seeroe")
        email("dldnjs1013@nate.com")
        age = 25
        build()
    }

    println(u)
}

728x90