paint-brush
Como desenvolver uma DSL em Kotlinpor@yaf
1,945 leituras
1,945 leituras

Como desenvolver uma DSL em Kotlin

por Fedor Yaremenko11m2023/12/11
Read on Terminal Reader

Muito longo; Para ler

Linguagens específicas de domínio são mais fáceis, convenientes e expressivas para descrever as tarefas da área temática. Kotlin é uma linguagem de programação moderna com muitos recursos e açúcar sintático, por isso é excelente como linguagem host para DSL interno. O artigo descreve como usar os vários recursos do Kotlin para criar uma DSL para definir processos de negócios.
featured image - Como desenvolver uma DSL em Kotlin
Fedor Yaremenko HackerNoon profile picture


Os programadores estão constantemente discutindo sobre qual linguagem é a melhor. Uma vez comparamos C e Pascal, mas o tempo passou. As batalhas de Python / Ruby e Java /C# já ficaram para trás. Cada idioma tem seus prós e contras, por isso os comparamos. Idealmente, gostaríamos de expandir os idiomas para atender às nossas próprias necessidades. Os programadores já têm essa oportunidade há muito tempo. Conhecemos diferentes formas de metaprogramação, ou seja, criar programas para criar programas. Mesmo macros triviais em C permitem gerar grandes pedaços de código a partir de pequenas descrições. Entretanto, essas macros não são confiáveis, são limitadas e pouco expressivas. As línguas modernas têm meios de extensão muito mais expressivos. Uma dessas linguagens é Kotlin.


A definição de uma linguagem específica de domínio

Uma linguagem específica de domínio (DSL) é uma linguagem desenvolvida especificamente para uma área de assunto específica, ao contrário de linguagens de uso geral, como Java, C#, C++ e outras. Isso significa que é mais fácil, conveniente e expressivo descrever as tarefas da área disciplinar, mas ao mesmo tempo é inconveniente e impraticável para resolver tarefas cotidianas, ou seja, não é uma linguagem universal. DSL, você pode usar a linguagem de expressão regular. A área de assunto das expressões regulares é o formato das strings.


Para verificar a conformidade da string com o formato, basta usar uma biblioteca que implemente suporte para expressões regulares:

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


Se você verificar a conformidade da string com o formato especificado em uma linguagem universal, por exemplo, Java, obterá o seguinte código:

 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(); }


O código acima é mais difícil de ler do que expressões regulares, é mais fácil cometer erros e mais complicado fazer alterações.


Outros exemplos comuns de DSL são HTML, CSS, SQL, UML e BPMN (os dois últimos usam notação gráfica). As DSLs são usadas não apenas por desenvolvedores, mas também por testadores e não especialistas em TI.


Tipos de DSL

As DSLs são divididas em dois tipos: externas e internas. As linguagens DSL externas possuem sintaxe própria e não dependem da linguagem de programação universal na qual seu suporte é implementado.


Prós e contras de DSLs externas:

🟢 Geração de código em diferentes linguagens/bibliotecas prontas

🟢 Mais opções para definir sua sintaxe

🔴 Uso de ferramentas especializadas: ANTLR, yacc, lex

🔴 Às vezes é difícil descrever a gramática

🔴 Não há suporte IDE, você precisa escrever seus plugins


As DSLs internas são baseadas em uma linguagem de programação universal específica (linguagem host). Ou seja, com a ajuda de ferramentas padrão da linguagem hospedeira, são criadas bibliotecas que permitem escrever de forma mais compacta. Por exemplo, considere a abordagem Fluent API.


Prós e contras de DSLs internas:

🟢 Usa as expressões do idioma anfitrião como base

🟢 É fácil incorporar DSL no código nas linguagens host e vice-versa

🟢 Não requer geração de código

🟢 Pode ser depurado como uma sub-rotina no idioma host

🔴 Possibilidades limitadas na configuração da sintaxe


Um exemplo da vida real

Recentemente, nós da empresa enfrentamos a necessidade de criar nossa DSL. Nosso produto implementou a funcionalidade de aceitação de compra. Este módulo é um minimotor de BPM (Business Process Management). Os processos de negócios são frequentemente representados graficamente. Por exemplo, a notação BPMN abaixo mostra um processo que consiste em executar a Tarefa 1 e, em seguida, executar a Tarefa 2 e a Tarefa 3 em paralelo.


Era importante para nós sermos capazes de criar processos de negócios de forma programática, incluindo a construção dinâmica de uma rota, definição de executores para etapas de aprovação, definição de prazo para execução de etapas, etc. Para fazer isso, primeiro tentamos resolver esse problema usando a API Fluent abordagem.


Concluímos então que definir rotas de aceitação usando a API Fluent ainda é complicado e nossa equipe considerou a opção de criar sua própria DSL. Investigamos como seria a rota de aceitação em uma DSL externa e em uma DSL interna baseada em Kotlin (porque nosso código de produto é escrito em Java e Kotlin ).


DSL externo:

 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 interno:

 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 } }


Exceto pelas chaves, ambas as opções são quase iguais. Portanto, decidiu-se não perder tempo e esforço desenvolvendo uma DSL externa, mas sim criar uma DSL interna.


Implementação da estrutura básica do DSL

Vamos começar a desenvolver um modelo de objeto

 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 } }


Lambdas

Primeiro, vamos dar uma olhada na classe AcceptanceContext . Ele foi projetado para armazenar uma coleção de elementos de rota e é usado para representar todo o diagrama, bem como blocos parallel .

Os métodos addStep e parallel usam um lambda com um receptor como parâmetro.


Um lambda com um receptor é uma forma de definir uma expressão lambda que tem acesso a um objeto receptor específico. Dentro do corpo da função literal, o objeto receptor passado para uma chamada torna-se um this implícito, para que você possa acessar os membros desse objeto receptor sem quaisquer qualificadores adicionais ou acessar o objeto receptor usando uma expressão this .


Além disso, se o último argumento de uma chamada de método for lambda, então o lambda poderá ser colocado fora dos parênteses. É por isso que em nossa DSL podemos escrever um código como o seguinte:

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


Isso é equivalente a um código sem açúcar sintático:

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


Lambdas com receptores e Lambda fora dos parênteses são recursos do Kotlin particularmente úteis ao trabalhar com DSLs.


Declaração de objeto

Agora vamos dar uma olhada na acceptance da entidade. acceptance é um objeto. Em Kotlin, uma declaração de objeto é uma forma de definir um singleton – uma classe com apenas uma instância. Portanto, a declaração do objeto define a classe e sua instância única ao mesmo tempo.


sobrecarga do operador “invocar”

Além disso, o operador invoke fica sobrecarregado para o objeto accreditation . O operador invoke é uma função especial que você pode definir em suas classes. Quando você invoca uma instância de uma classe como se fosse uma função, a função do operador invoke é chamada. Isso permite tratar objetos como funções e chamá-los de maneira semelhante a uma função.


Observe que o parâmetro do método invoke também é um lambda com um receptor. Agora podemos definir uma rota de aceitação…

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


…e percorra isso

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


Adicionando detalhes

Funções infixas

Dê uma olhada neste código

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


Podemos implementar isso da seguinte maneira:

 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)


A classe ExecutorCondition nos permite definir vários executores de tarefas possíveis. Em ExecutorCondition definimos a função infix or . Uma função infixa é um tipo especial de função que permite chamá-la usando uma notação infixa mais natural.


Sem usar esse recurso da linguagem, teríamos que escrever assim:

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


As funções Infix também são usadas para definir o estado necessário do protocolo e a hora com um fuso horário.

 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) }


Funções de extensão

String.timezone é uma função de extensão. No Kotlin, as funções de extensão permitem adicionar novas funções a classes existentes sem modificar seu código-fonte. Esse recurso é particularmente útil quando você deseja aumentar a funcionalidade de classes sobre as quais você não tem controle, como classes de bibliotecas padrão ou externas.


Uso no DSL:

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


Aqui "2022-12-08 08:00" é um objeto receptor, no qual a função de extensão timezone é chamada, e PST é o parâmetro. O objeto receptor é acessado usando this palavra-chave.

Sobrecarga do operador

O próximo recurso Kotlin que usamos em nossa DSL é a sobrecarga do operador. Já consideramos a sobrecarga do operador invoke . No Kotlin, você pode sobrecarregar outros operadores, inclusive os aritméticos.

 addStep { ... +canChange }


Aqui o operador unário + está sobrecarregado. Abaixo está o código que implementa isso:

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


Toque final

Agora podemos descrever as rotas de aceitação em nossa DSL. No entanto, os usuários DSL devem ser protegidos de possíveis erros. Por exemplo, na versão atual, o seguinte código é aceitável:

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


addStep dentro de addStep parece estranho, não é? Vamos descobrir por que esse código é compilado com êxito e sem erros. Conforme mencionado acima, os métodos acceptance#invoke e AcceptanceContext#addStep usam um lambda com um receptor como parâmetro, e um objeto receptor é acessível por uma palavra-chave this . Portanto, podemos reescrever o código anterior assim:

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


Agora você pode ver que [email protected] é chamado nas duas vezes. Especialmente para esses casos, o Kotlin possui uma anotação DslMarker . Você pode usar @DslMarker para definir anotações personalizadas. Receptores marcados com a mesma anotação não podem ser acessados um dentro do outro.

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


Agora o código

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


não será compilado devido a um erro 'fun addStep(init: StepContext.() -> Unit): Unit' can't be called in this context by implicit receiver. Use the explicit one if necessary

Ligações

Abaixo estão links para a documentação oficial do Kotlin sobre os recursos da linguagem considerados neste artigo:



Conclusão

Linguagens específicas de domínio oferecem um meio poderoso para aumentar a produtividade, reduzir erros e melhorar a colaboração, fornecendo uma maneira especializada e expressiva de modelar e resolver problemas dentro de um domínio específico. Kotlin é uma linguagem de programação moderna com muitos recursos e açúcar sintático, por isso é excelente como linguagem host para DSL interno.