2022년 9월 11일 일요일

Kotlin DSL

Kotlin DSL

Kotlin DSL

Kotlin은 DSL 친화적인 언어이며 다양한 구문 기능을 통해 DSL(domain-specific languages (DSLs))을 구축하고 특정 시나리오에서 코드의 가독성과 보안을 개선할 수 있습니다.

1. DSL이란?

domain-specific languages, 즉 도메인 특정 언어이다. 도메인이라는 지정된 영역에 맞게 정의해서 사용하는 언어라는 의미이다.
일반 범용언어랑 다르게 SQL처럼 특정 작업을 할때 사용하는 언어의 종류이다.
시스템 구축 초기에 사용자와 빌더의 언어 모델이 일치하지 않아 요구사항 수집이 어려운 문제를 해결하기 위함입니다.
도메인전문가와 개발간의 공통된 언어와 개체,규칙을 사용할수 있도록 해줍니다.

Android개발자라면 groovy 언어의 DSL로 괄호와 지정된 문장을 적는 적만으로도 설정값을 지정할수 있다는 것을 알수 있을 것이다.

android {
  compileSdkVersion 28
  defaultConfig {
    applicationId "com.my.app"
    minSdkVersion 24
    targetSdkVersion 30
    versionCode 1
    versionName "1.0"
    testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
  }
  buildTypes {
    release {
      minifyEnabled false
      proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
  }
}

Kotlin 도 DSL을 지원하여 프로그래밍시에도 위의 예제처럼 좀더 직관적이고 간단하게 작업할수 있습니다.

DSL과 범용 프로그래밍 언어의 차이점

  • 비 프로그래머용 DSL, 도메인 전문가용
  • DSL에는 데이터 구조와 같은 세부 정보를 포함하지 않는 상위 수준 추상화가 있습니다.
  • DSL은 표현력이 제한적이며 현장의 모델만 설명할 수 있는 반면 범용 프로그래밍 언어는 임의의 모델을 설명할 수 있습니다.

2. Kotlin DSL

Kotlin은 Android의 주요 프로그래밍 언어이므로 Android 개발에서 Kotlin의 DSL을 활용하여 특정 시나리오에서 개발 효율성을 높일 수 있습니다. 예를 들어 Compose의 UI 코드가 좋은 예인데, DSL을 사용하여 Kotlin 코드가 XML과 동일한 표현성을 갖도록 함과 동시에 형식 안전성을 고려하여 UI 개발의 효율성을 높입니다.

Kotin DSL에는 많은 이점이 있음을 알 수 있습니다.

  • XML과 유사한 구조적 표현력
  • 더 적은 수의 문자열, 더 강력한 유형, 더 안전한
  • linearLayoutParams와 같은 객체는 쉽게 재사용할 수 있도록 추출할 수 있습니다.
  • onClick과 같은 이벤트 처리를 레이아웃에 동기적으로 포함
  • 필요한 경우 if 및 for와 같은 제어 문을 포함할 수도 있습니다.

3. Kotlin 에서 DSL 구현 방법

3.0 build.gradle의 depencencies를 kotlin dsl로 흉내내보자.

Dependency 라는 이름의 클래스를 만들자

class Dependency {
    var libs = mutableListOf<String>()
    fun implementation(lib: String) {
        libs.add(lib)
    }
}

고차함수를 만들어서 Dependency의 확장함수(익명람다) 로 만들어 입력된 경로가 클래스변수에 저장하도록 하자.

fun dependencies(block: Dependency.() -> Unit): List<String> {
    val dependency = Dependency()
    dependency.block()
    return dependency.libs
}

dependencies는 마지막 인자가 고차함수를 받을수 있으니까 { } 로 표현가능하다. 그리고 함수내부에서 Dependency()를 선언후 인자로 받은 익명 확장함수를 실행할수 있다.
결국 아래처럼 쓸 수 있다

dependencies {
        implementation("com.jacky.ll")
        implementation("com.jacky.hh")
    }

var list = dependencies {
        implementation("com.jacky.ll")
        implementation("com.jacky.hh")
    }
    for (text in list) {
       println("$text")
    }    

다른 예로 하루전 날짜 가져오기는 다음과 같이

3.1 고차함수는 중괄호 호출로 표현가능

일반적인 DSL은 중괄호를 사용하여 수준을 표현합니다. Kotlin의 고차 함수를 사용하면 람다 유형의 매개변수를 지정할 수 있으며 람다가 매개변수 목록의 끝에 있을 때 괄호를 이스케이프 처리하여 DSL의 중괄호 구문 요구 사항을 충족할 수 있습니다.

중괄호 구문 구현의 핵심은 객체 생성 및 초기화 논리를 꼬리 람다로 고차 함수로 캡슐화하는 것이라는 것을 알고 있습니다.이 아이디어에 따라 다음 코드를 변환합니다.

//정의된 함수
public LinearLayout(Context context, @Nullable AttributeSet attrs) {  
    super((Context)null);  
    throw new RuntimeException("Stub!");  
}

//Kotlin 에서 사용시
LinearLayout(context).apply {
    orientation = LinearLayout.HORIZONTAL
    addView(ImageView(context))
}

가독성을 향상시키기 위해 기본값에 따라 orientation명명 . HorizontalLayout또한 Compose 스타일을 모방하고 대문자를 사용하여 DSL 노드를 더 쉽게 인식할 수 있도록 합니다.

fun HorizontalLayout(context: Context, init: (LinearLayout) -> Unit) : LinearLayout {
    return LinearLayout(context).apply {
        orientation = LinearLayout.HORIZONTAL
        init(this)
    }
}

매개변수 initLinearLayout중괄호로 초기화할 수 있도록 방금 만든 개체를 전달하는 후행 람다입니다. ImageView에 대한 유사한 고차 함수 도 정의한 후 호출 효과는 다음과 같습니다.

HorizontalLayout(context) {
    ...
    it.addView(ImageView(context) {
        ...
    })
}

3.2 리시버를 통한 컨텍스트 전달

3.1의 고차 함수에 의해 변환된 DSL에서 중괄호는 의 도움으로 초기화되어야 it하며 addView의 모양은 거의 우아하지 않습니다. 먼저 람다의 매개변수를 Receiver로 변경할 수 있으며 중괄호에 it대한 this직접 다음으로 변경하거나 생략 할 수 있습니다.
리시버의 원리는 익명의 확장함수를 인수로 추가함으로서, 해당 인수에

fun HorizontalLayout(context: Context, init: LinearLayout.() -> Unit) : LinearLayout {
    return LinearLayout(context).apply {
        orientation = LinearLayout.HORIZONTAL
        init()
    }
}

ImageView도 DSL답게 바꾸어 봅니다.

fun ViewGroup.ImageView(init: ImageView.() -> Unit) {
    addView(ImageView(context).apply(init))
}

최종 DSL

HorizontalLayout(context) { // this: LinearLayout
    ...
    ImageView { // this: ImageView
        ...
    }
}

3.3 확장기능 최적화 코드스타일

자주 사용되는 이벤트 리스너의 경우 override fun 가 중복되어 보기 싫을 때가 있습니다. 이를 DSL로 깔끔하게 변경 가능합니다.

TextView의 경우.

TextView {
    addTextChangedListener( object : TextWatcher {
        override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
            ...
        }

        override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
            ...
        }

        override fun afterTextChanged(s: Editable?) {
            ...
        }
    })

TextView다음에 대한 DSL 친화적인 확장 기능 추가 :

fun TextView.textChangedListener(init: _TextWatcher.() -> Unit) {
    val listener = _TextWatcher()
    listener.init()
    addTextChangedListener(listener)
}

class _TextWatcher : android.text.TextWatcher {

    private var _onTextChanged: ((CharSequence?, Int, Int, Int) -> Unit)? = null
    override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
        _onTextChanged?.invoke(s, start, before, count)
    }
    fun onTextChanged(listener: (CharSequence?, Int, Int, Int) -> Unit) {
        _onTextChanged = listener
    }
   
}

DSL 로 다시 작성해 보면 직관적이고 깔끔해졌습니다

Text {
    textChangedListener {
        beforeTextChanged { charSequence, i, i2, i3 ->
            //...
        }
    
        onTextChanged { charSequence, i, i2, i3 ->
            //...
        }
    
        afterTextChanged {
            //...
        }
    }
}

4. DSL 추가최적화

4.1 infix로 가독성 향상

Kotlin의 중위 함수를 사용하면 함수에서 점 및 괄호와 같은 프로그램 기호를 생략할 수 있으므로 명령문이 더 자연스럽고 가독성이 더욱 향상됩니다.
예를 들어 모든 View에는 일반적으로 다음과 같이 사용되는 setTag메서드 가 있습니다.

HorizontalLayout {
    setTag(1,"a")
    setTag(2,"b")
}

infix 로 최적화

class _Tag(val view: View) {
    infix fun <B> Int.to(that: B) =  view.setTag(this, that)
}

fun View.tag(block: _Tag.() -> Unit) {
    _Tag(this).apply(block)
}

DSL 에서 사용하기

HorizontalLayout { tag {  1 to "a"  2 to "b" } }

4.2 @DslMarker 로 범위 제한

Kotlin은 메서드의 범위를 제한할 수 있는 DSL 사용 시나리오에 대한 @DslMarker주석 합니다. 주석이 달린 람다 this에서 외부 Receiver의 메서드를 호출하면 오류가 보고됩니다.
즉,객체 중 가장 마지막이 this되는 객체 이외는 this으로서 사용할 수 없습니다.

HorizontalLayout {// this: LinearLayout
    ...
    TextView {//this : TextView
        // 此处仍然可以调用 HorizontalLayout
        HorizontalLayout {
            ...
        }
    }

}

DslMarker를 재정의 해 줍시다.

@DslMarker  
@Target(AnnotationTarget.TYPE)  
annotation  class ViewDslMarker
fun ViewGroup.TextView(init: (@ViewDslMarker TextView).() -> Unit) {
    addView(TextView(context).apply(init))
}

4.3 클래스 및 인터페이스 정의

먼저 여러 메서드 인터페이스를 포함하는 클래스와 이 인터페이스를 사용해야 하는 클래스를 정의합니다. Kotlin에서 DSL을 사용하는 방법을 단계별로 보여줍니다.

interface Listener {
    fun onStart()

    fun onNext()

    fun onComplete(result:String)
}

class Work {
    private var listener: Listener? = null
    fun start() {
        listener?.run {
            onStart()
            onNext()
            onComplete("onComplete")
        }
    }

    fun addListener(listener: Listener) {
        this.listener = listener
    }
    //doSomething
}
}

보통의 사용방법

    val  work = Work()
    work.addListener(object :Listener{
        override fun onStart() {

        }

        override fun onNext() {

        }

        override fun onComplete(result:String) {

        }
    })

DSL로 바꾸어보자
일단 인터페이스가 가지는 함수들을 만들어 고차함수 를 받을 수있도록 한다.

//别名
typealias onEmpty = () -> Unit
typealias onResult = (String) -> Unit

class ListenerDSL {
    var _onStart: onEmpty? = null
    var _onNext: onEmpty? = null
    var _onComplete: onResult? = null


    fun onStart(onEmpty: onEmpty) {
        _onStart = onEmpty
    }

    fun onNext(onEmpty: onEmpty) {
        _onNext = onEmpty
    }

    fun onComplete(onNext: onResult) {
        _onComplete = onNext
    }
}

그런 다음 확장 기능의 쓰기 DSL을 사용합니다.

fun Work.addListenerDSL(init: ListenerDSL.() -> Unit) {
    val listenerDSL = ListenerDSL()
    listenerDSL.init()
    this.addListener(object : Listener {
        override fun onStart() {
            listenerDSL._onStart?.invoke()
        }

        override fun onNext() {
            listenerDSL._onNext?.invoke()
        }

        override fun onComplete(result: String) {
            listenerDSL._onComplete?.invoke(result)
        }
    })
}

사용 할때는 아래처럼 깔끔합니다.

   val work = Work()
    work.addListenerDSL {
        onStart { println("onStart") }
        onComplete { result -> println(result) }
    }
    work.start()

참고자료

0 comments:

댓글 쓰기