paint-brush
Kotlin에서 DSL을 개발하는 방법~에 의해@yaf
2,102 판독값
2,102 판독값

Kotlin에서 DSL을 개발하는 방법

~에 의해 Fedor Yaremenko11m2023/12/11
Read on Terminal Reader

너무 오래; 읽다

도메인 특정 언어는 주제 영역의 작업을 설명하는 데 더 쉽고 편리하며 표현력이 뛰어납니다. Kotlin은 많은 기능과 구문 설탕을 갖춘 현대적인 프로그래밍 언어이므로 내부 DSL의 호스트 언어로 적합합니다. 이 문서에서는 Kotlin의 다양한 기능을 사용하여 비즈니스 프로세스 정의를 위한 DSL을 만드는 방법을 설명합니다.
featured image - Kotlin에서 DSL을 개발하는 방법
Fedor Yaremenko HackerNoon profile picture


프로그래머들은 어떤 언어가 가장 좋은지 끊임없이 논쟁하고 있습니다. C와 Pascal을 비교했지만 시간이 지났습니다. Python / RubyJava /C#의 전쟁은 이미 끝났습니다. 각 언어에는 장단점이 있으므로 이를 비교합니다. 이상적으로는 우리 자신의 필요에 맞게 언어를 확장하고 싶습니다. 프로그래머들은 아주 오랫동안 이런 기회를 누려왔습니다. 우리는 메타프로그래밍의 다양한 방법, 즉 프로그램을 만들기 위해 프로그램을 만드는 방법을 알고 있습니다. C의 사소한 매크로라도 작은 설명에서 큰 코드 덩어리를 생성할 수 있습니다. 그러나 이러한 매크로는 신뢰할 수 없고 제한적이며 표현력이 부족합니다. 현대 언어에는 훨씬 더 표현적인 확장 수단이 있습니다. 이러한 언어 중 하나가 Kotlin입니다.


도메인 특정 언어의 정의

DSL(도메인 특정 언어)은 Java, C#, C++ 등과 같은 범용 언어와 달리 특정 주제 영역을 위해 특별히 개발된 언어입니다. 이는 해당 주제 영역의 작업을 설명하는 것이 더 쉽고 편리하며 표현력이 뛰어나지만 동시에 일상적인 작업을 해결하는 데 불편하고 비실용적이라는 의미입니다. 즉, 보편적인 언어가 아닙니다. DSL에서는 정규식 언어를 사용할 수 있습니다. 정규식의 주제 영역은 문자열 형식입니다.


문자열이 형식을 준수하는지 확인하려면 정규식 지원을 구현하는 라이브러리를 사용하는 것으로 충분합니다.

 private boolean isIdentifierOrInteger(String s) { return s.matches("^\\s*(\\w+\\d*|\\d+)$"); }


Java와 같은 범용 언어로 지정된 형식을 준수하는지 문자열을 확인하면 다음 코드를 얻게 됩니다.

 private boolean isIdentifierOrInteger(String s) { int index = 0; while (index < s.length() && isSpaceChar(s.charAt(index))) { index++; } if (index == s.length()) { return false; } if (isLetter(s.charAt(index))) { index++; while (index < s.length() && isLetter(s.charAt(index))) index++; while (index < s.length() && isDigit(s.charAt(index))) index++; } else if (Character.isDigit(s.charAt(index))) { while (index < s.length() && isDigit(s.charAt(index))) index++; } return index == s.length(); }


위의 코드는 정규식보다 읽기 어렵고 실수하기 쉽고 변경하기가 더 까다롭습니다.


DSL의 다른 일반적인 예로는 HTML, CSS, SQL, UML 및 BPMN이 있습니다(후자의 두 가지는 그래픽 표기법을 사용함). DSL은 개발자뿐만 아니라 테스터, IT 전문가가 아닌 사람도 사용합니다.


DSL 유형

DSL은 외부와 내부의 두 가지 유형으로 구분됩니다. 외부 DSL 언어에는 고유한 구문이 있으며 지원이 구현되는 범용 프로그래밍 언어에 의존하지 않습니다.


외부 DSL의 장점과 단점:

🟢 다양한 언어로 코드 생성/기성 라이브러리

🟢 구문 설정을 위한 추가 옵션

🔴 전문 도구 사용: ANTLR, yacc, lex

🔴 때로는 문법을 설명하기 어려울 때도 있습니다

🔴 IDE 지원이 없으므로 플러그인을 작성해야 합니다.


내부 DSL은 특정 범용 프로그래밍 언어(호스트 언어)를 기반으로 합니다. 즉, 호스트 언어의 표준 도구를 사용하여 보다 간결하게 작성할 수 있는 라이브러리가 생성됩니다. 예를 들어 Fluent API 접근 방식을 고려해보세요.


내부 DSL의 장점과 단점:

🟢 호스트 언어의 표현을 기반으로 사용

🟢 호스트 언어의 코드에 DSL을 삽입하거나 그 반대의 경우도 쉽습니다.

🟢 코드 생성이 필요하지 않습니다.

🟢 호스트 언어에서 서브루틴으로 디버깅 가능

🔴 구문 설정의 제한된 가능성


실제 사례

최근 우리 회사에서는 DSL을 만들어야 하는 상황에 직면했습니다. 당사 제품에는 구매 승인 기능이 구현되었습니다. 이 모듈은 BPM(Business Process Management)의 미니 엔진입니다. 비즈니스 프로세스는 종종 그래픽으로 표현됩니다. 예를 들어 아래 BPMN 표기법은 태스크 1을 실행한 다음 태스크 2와 태스크 3을 병렬로 실행하는 프로세스를 보여줍니다.


동적인 경로 구축, 승인 단계 수행자 설정, 단계 실행 기한 설정 등을 포함하여 프로그래밍 방식으로 비즈니스 프로세스를 생성할 수 있는 것이 중요했습니다. 이를 위해 먼저 Fluent API를 사용하여 이 문제를 해결하려고 했습니다. 접근하다.


그런 다음 Fluent API를 사용하여 승인 경로를 설정하는 것이 여전히 번거롭다는 결론을 내렸고 우리 팀은 자체 DSL을 만드는 옵션을 고려했습니다. 우리는 Kotlin을 기반으로 하는 외부 DSL과 내부 DSL에서 승인 경로가 어떻게 보이는지 조사했습니다(우리 제품 코드는 Java 및 Kotlin 으로 작성되었기 때문입니다).


외부 DSL:

 acceptance addStep executor: HEAD_OF_DEPARTMENT duration: 7 days protocol should be formed parallel addStep executor: FINANCE_DEPARTMENT or CTO or CEO condition: ${!request.isInternal} duration: 7 work days after start date addStep executor: CTO dueDate: 2022-12-08 08:00 PST can change addStep executor: SECRETARY protocol should be signed


내부 DSL:

 acceptance { addStep { executor = HEAD_OF_DEPARTMENT duration = days(7) protocol shouldBe formed } parallel { addStep { executor = FINANCE_DEPARTMENT or CTO or CEO condition = !request.isInternal duration = startDate() + workDays(7) } addStep { executor = CTO dueDate = "2022-12-08 08:00" timezone PST +canChange } } addStep { executor = SECRETARY protocol shouldBe signed } }


중괄호를 제외하면 두 옵션 모두 거의 동일합니다. 따라서 외부 DSL 개발에 시간과 노력을 낭비하지 않고 내부 DSL을 만들기로 결정했습니다.


DSL의 기본 구조 구현

개체 모델 개발을 시작해 보겠습니다.

 interface AcceptanceElement class StepContext : AcceptanceElement { lateinit var executor: ExecutorCondition var duration: Duration? = null var dueDate: ZonedDateTime? = null val protocol = Protocol() var condition = true var canChange = ChangePermission() } class AcceptanceContext : AcceptanceElement { val elements = mutableListOf<AcceptanceElement>() fun addStep(init: StepContext.() -> Unit) { elements += StepContext().apply(init) } fun parallel(init: AcceptanceContext.() -> Unit) { elements += AcceptanceContext().apply(init) } } object acceptance { operator fun invoke(init: AcceptanceContext.() -> Unit): AcceptanceContext { val acceptanceContext = AcceptanceContext() acceptanceContext.init() return acceptanceContext } }


람다

먼저 AcceptanceContext 클래스를 살펴보겠습니다. 이는 경로 요소 모음을 저장하도록 설계되었으며 전체 다이어그램과 parallel 블록을 나타내는 데 사용됩니다.

addStepparallel 메소드는 수신자가 있는 람다를 매개변수로 사용합니다.


수신기가 있는 람다는 특정 수신기 개체에 액세스할 수 있는 람다 식을 정의하는 방법입니다. 함수 리터럴의 본문 내에서 호출에 전달된 수신자 객체는 암시적인 this 가 되므로 추가 한정자 없이 해당 수신자 객체의 멤버에 액세스하거나 this 표현식을 사용하여 수신자 객체에 액세스할 수 있습니다.


또한 메서드 호출의 마지막 인수가 람다인 경우 람다는 괄호 외부에 배치될 수 있습니다. 이것이 DSL에서 다음과 같은 코드를 작성할 수 있는 이유입니다.

 parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } }


이는 구문 설탕이 없는 코드와 동일합니다.

 parallel({ this.addStep({ this.executor = FINANCE_DEPARTMENT ... }) this.addStep({ this.executor = CTO ... }) })


수신기가 있는 Lambda와 괄호 밖의 Lambda는 DSL 작업 시 특히 유용한 Kotlin 기능입니다.


객체 선언

이제 엔터티 acceptance 살펴보겠습니다. acceptance 객체입니다. Kotlin에서 객체 선언은 인스턴스가 하나만 있는 클래스인 싱글톤을 정의하는 방법입니다. 따라서 객체 선언은 클래스와 해당 단일 인스턴스를 동시에 정의합니다.


"호출" 연산자 오버로드

또한 accreditation 개체에 대해 invoke 연산자가 오버로드됩니다. invoke 연산자는 클래스에서 정의할 수 있는 특수 함수입니다. 마치 함수인 것처럼 클래스의 인스턴스를 호출하면 invoke 연산자 함수가 호출됩니다. 이를 통해 객체를 함수로 처리하고 함수와 같은 방식으로 호출할 수 있습니다.


invoke 메서드의 매개변수도 수신자가 있는 람다라는 점에 유의하세요. 이제 승인 경로를 정의할 수 있습니다.

 val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT ... } parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } } addStep { executor = SECRETARY ... } }


...그리고 그 길을 걸어가세요

 val headOfDepartmentStep = acceptanceRoute.elements[0] as StepContext val parallelBlock = acceptanceRoute.elements[1] as AcceptanceContext val ctoStep = parallelBlock.elements[1] as StepContext


세부정보 추가

중위 함수

이 코드를 살펴보세요

 addStep { executor = FINANCE_DEPARTMENT or CTO or CEO ... }


우리는 이것을 다음과 같이 구현할 수 있습니다:

 enum class ExecutorConditionType { EQUALS, OR } data class ExecutorCondition( private val name: String, private val values: Set<ExecutorCondition>, private val type: ExecutorConditionType, ) { infix fun or(another: ExecutorCondition) = ExecutorCondition("or", setOf(this, another), ExecutorConditionType.OR) } val HEAD_OF_DEPARTMENT = ExecutorCondition("HEAD_OF_DEPARTMENT", setOf(), ExecutorConditionType.EQUALS) val FINANCE_DEPARTMENT = ExecutorCondition("FINANCE_DEPARTMENT", setOf(), ExecutorConditionType.EQUALS) val CHIEF = ExecutorCondition("CHIEF", setOf(), ExecutorConditionType.EQUALS) val CTO = ExecutorCondition("CTO", setOf(), ExecutorConditionType.EQUALS) val SECRETARY = ExecutorCondition("SECRETARY", setOf(), ExecutorConditionType.EQUALS)


ExecutorCondition 클래스를 사용하면 여러 가지 가능한 작업 실행자를 설정할 수 있습니다. ExecutorCondition 에서는 중위 함수 or 정의합니다. 중위 함수는 보다 자연스러운 중위 표기법을 사용하여 호출할 수 있는 특별한 종류의 함수입니다.


이 언어 기능을 사용하지 않으면 다음과 같이 작성해야 합니다.

 addStep { executor = FINANCE_DEPARTMENT.or(CTO).or(CEO) ... }


중위 함수는 프로토콜의 필수 상태와 시간대를 사용하여 시간을 설정하는 데에도 사용됩니다.

 enum class ProtocolState { formed, signed } class Protocol { var state: ProtocolState? = null infix fun shouldBe(state: ProtocolState) { this.state = state } } enum class TimeZone { ... PST, ... } infix fun String.timezone(tz: TimeZone): ZonedDateTime { val format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z") return ZonedDateTime.parse("$this $tz", format) }


확장 기능

String.timezone 확장 기능입니다. Kotlin에서 확장 함수를 사용하면 소스 코드를 수정하지 않고도 기존 클래스에 새 함수를 추가할 수 있습니다. 이 기능은 표준 또는 외부 라이브러리의 클래스와 같이 제어할 수 없는 클래스의 기능을 강화하려는 경우 특히 유용합니다.


DSL에서의 사용법:

 addStep { ... protocol shouldBe formed dueDate = "2022-12-08 08:00" timezone PST ... }


여기서 "2022-12-08 08:00" 은 확장 함수 timezone 이 호출되는 수신자 개체이고 PST 는 매개 변수입니다. 수신자 객체는 this 키워드를 사용하여 액세스됩니다.

연산자 오버로딩

DSL에서 사용하는 다음 Kotlin 기능은 연산자 오버로딩입니다. 우리는 이미 invoke 연산자의 오버로드를 고려했습니다. Kotlin에서는 산술 연산자를 포함한 다른 연산자를 오버로드할 수 있습니다.

 addStep { ... +canChange }


여기서 단항 연산자 + 가 오버로드되었습니다. 아래는 이를 구현하는 코드입니다.

 class StepContext : AcceptanceElement { ... var canChange = ChangePermission() } data class ChangePermission( var canChange: Boolean = true, ) { operator fun unaryPlus() { canChange = true } operator fun unaryMinus() { canChange = false } }


마무리 손질

이제 DSL에서 승인 경로를 설명할 수 있습니다. 그러나 DSL 사용자는 발생할 수 있는 오류로부터 보호되어야 합니다. 예를 들어, 현재 버전에서는 다음 코드가 허용됩니다.

 val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT duration = days(7) protocol shouldBe signed addStep { executor = FINANCE_DEPARTMENT } } }


addStep 내의 addStep 이상해 보이지 않나요? 이 코드가 오류 없이 성공적으로 컴파일되는 이유를 알아봅시다. 위에서 언급한 대로 acceptance#invokeAcceptanceContext#addStep 메소드는 수신자와 함께 람다를 매개변수로 사용하며 수신자 객체는 this 키워드로 액세스할 수 있습니다. 따라서 이전 코드를 다음과 같이 다시 작성할 수 있습니다.

 val acceptanceRoute = acceptance { [email protected] { [email protected] = HEAD_OF_DEPARTMENT [email protected] = days(7) [email protected] shouldBe signed [email protected] { executor = FINANCE_DEPARTMENT } } }


이제 [email protected] 두 번 호출되는 것을 볼 수 있습니다. 특히 이러한 경우를 위해 Kotlin에는 DslMarker 주석이 있습니다. @DslMarker 사용하여 사용자 정의 주석을 정의할 수 있습니다. 동일한 주석이 표시된 수신기는 서로 내부에서 액세스할 수 없습니다.

 @DslMarker annotation class AcceptanceDslMarker @AcceptanceDslMarker class AcceptanceContext : AcceptanceElement { ... } @AcceptanceDslMarker class StepContext : AcceptanceElement { ... }


이제 코드

 val acceptanceRoute = acceptance { addStep { ... addStep { ... } } }


오류 'fun addStep(init: StepContext.() -> Unit): Unit' can't be called in this context by implicit receiver. Use the explicit one if necessary

연결

다음은 이 기사에서 고려한 언어 기능에 대한 공식 Kotlin 문서에 대한 링크입니다.



결론

도메인 특정 언어는 특정 도메인 내의 문제를 모델링하고 해결하는 전문적이고 표현적인 방법을 제공함으로써 생산성을 향상하고 오류를 줄이며 협업을 향상시키는 강력한 수단을 제공합니다. Kotlin은 많은 기능과 구문 설탕을 갖춘 현대적인 프로그래밍 언어이므로 내부 DSL의 호스트 언어로 적합합니다.