paint-brush
Kotlin'de DSL Nasıl Geliştirilir?ile@yaf
2,102 okumalar
2,102 okumalar

Kotlin'de DSL Nasıl Geliştirilir?

ile Fedor Yaremenko11m2023/12/11
Read on Terminal Reader

Çok uzun; Okumak

Alana özgü diller, konu alanının görevlerini tanımlamak için daha kolay, daha kullanışlı ve daha anlamlıdır. Kotlin birçok özelliği ve sözdizimsel yapısıyla modern bir programlama dilidir, dolayısıyla dahili DSL için ana dil olarak mükemmeldir. Makale, iş süreçlerini tanımlamak için bir DSL oluşturmak amacıyla Kotlin'in çeşitli özelliklerinin nasıl kullanılacağını açıklamaktadır.
featured image - Kotlin'de DSL Nasıl Geliştirilir?
Fedor Yaremenko HackerNoon profile picture


Programcılar sürekli olarak hangi dilin en iyi olduğu konusunda tartışıyorlar. Bir keresinde C ile Pascal'ı karşılaştırmıştık ama zaman geçti. Python / Ruby ve Java /C# savaşları artık geride kaldı. Her dilin artıları ve eksileri vardır, bu yüzden onları karşılaştırıyoruz. İdeal olarak, dilleri kendi ihtiyaçlarımıza uyacak şekilde genişletmek isteriz. Programcılar bu fırsata çok uzun zamandır sahip. Metaprogramlamanın, yani program oluşturmak için program oluşturmanın farklı yollarını biliyoruz. C'deki önemsiz makrolar bile küçük açıklamalardan büyük kod parçaları oluşturmanıza olanak tanır. Ancak bu makrolar güvenilmezdir, sınırlıdır ve pek anlamlı değildir. Modern diller çok daha anlamlı genişleme araçlarına sahiptir. Bu dillerden biri de Kotlin'dir.


Etki alanına özgü bir dilin tanımı

Etki alanına özgü dil (DSL), Java, C#, C++ ve diğerleri gibi genel amaçlı dillerin aksine, belirli bir konu alanı için özel olarak geliştirilmiş bir dildir. Bu, konu alanındaki görevleri tanımlamanın daha kolay, daha kullanışlı ve daha anlamlı olduğu anlamına gelir, ancak aynı zamanda günlük görevleri çözmek için uygun değildir ve pratik değildir, yani evrensel bir dil değildir. DSL'de normal ifade dilini kullanabilirsiniz. Düzenli ifadelerin konu alanı dizelerin biçimidir.


Dizenin formata uygunluğunu kontrol etmek için, normal ifadeler için destek uygulayan bir kitaplığın kullanılması yeterlidir:

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


Dizenin evrensel bir dilde, örneğin Java'da belirtilen formata uygunluğunu kontrol ederseniz, aşağıdaki kodu alırsınız:

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


Yukarıdaki kodu okumak normal ifadelere göre daha zordur, hata yapmak daha kolaydır ve değişiklik yapmak daha zordur.


DSL'in diğer yaygın örnekleri HTML, CSS, SQL, UML ve BPMN'dir (son ikisi grafiksel gösterim kullanır). DSL'ler yalnızca geliştiriciler tarafından değil aynı zamanda test uzmanları ve BT dışı uzmanlar tarafından da kullanılır.


DSL Türleri

DSL'ler iki türe ayrılır: harici ve dahili. Harici DSL dillerinin kendi sözdizimleri vardır ve desteklerinin uygulandığı evrensel programlama diline bağlı değildirler.


Harici DSL'lerin artıları ve eksileri:

🟢 Farklı dillerde / hazır kütüphanelerde kod üretimi

🟢 Sözdiziminizi ayarlamak için daha fazla seçenek

🔴 Özel araçların kullanımı: ANTLR, yacc, lex

🔴 Bazen dil bilgisini anlatmak zordur

🔴IDE desteği yoktur eklentilerinizi yazmanız gerekmektedir


Dahili DSL'ler belirli bir evrensel programlama dilini (ana bilgisayar dili) temel alır. Yani, ana dilin standart araçlarının yardımıyla, daha derli toplu yazmanıza olanak tanıyan kütüphaneler oluşturulur. Örnek olarak Fluent API yaklaşımını düşünün.


Dahili DSL'lerin artıları ve eksileri:

🟢 Ana dilin ifadelerini temel alır

🟢 DSL'i ana dillerdeki koda yerleştirmek kolaydır ve bunun tersi de geçerlidir

🟢 Kod oluşturma gerektirmez

🟢 Ana dilde alt program olarak hata ayıklanabilir

🔴 Sözdizimini ayarlamada sınırlı olanaklar


Gerçek hayattan bir örnek

Son zamanlarda şirket olarak DSL'imizi oluşturma ihtiyacıyla karşı karşıya kaldık. Ürünümüz satın alma kabulü işlevini hayata geçirmiştir. Bu modül BPM'nin (İş Süreçleri Yönetimi) mini motorudur. İş süreçleri genellikle grafiksel olarak temsil edilir. Örneğin, aşağıdaki BPMN gösterimi, Görev 1'in yürütülmesinden ve ardından Görev 2 ile Görev 3'ün paralel olarak yürütülmesinden oluşan bir süreci göstermektedir.


Dinamik olarak rota oluşturmak, onay aşamaları için uygulayıcıları ayarlamak, aşamanın yürütülmesi için son tarihi belirlemek vb. dahil olmak üzere iş süreçlerini programlı bir şekilde oluşturabilmek bizim için önemliydi. Bunu yapmak için öncelikle bu sorunu Fluent API kullanarak çözmeye çalıştık. yaklaşmak.


Daha sonra Fluent API'yi kullanarak kabul rotaları belirlemenin hala zahmetli olduğu sonucuna vardık ve ekibimiz kendi DSL'sini oluşturma seçeneğini değerlendirdi. Harici bir DSL ve Kotlin tabanlı dahili bir DSL'de kabul yolunun nasıl görüneceğini araştırdık (çünkü ürün kodumuz Java ve Kotlin ile yazılmıştır).


Harici 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


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


Kıvrımlı parantezlerin dışında her iki seçenek de hemen hemen aynıdır. Bu nedenle harici bir DSL geliştirmek için zaman ve çaba harcamamaya, dahili bir DSL oluşturmaya karar verildi.


DSL'in temel yapısının uygulanması

Bir nesne modeli geliştirmeye başlayalım

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


Lambdalar

Öncelikle AcceptanceContext sınıfına bakalım. Rota elemanlarının bir koleksiyonunu depolamak için tasarlanmıştır ve parallel blokların yanı sıra tüm diyagramı temsil etmek için kullanılır.

addStep ve parallel yöntemler, parametre olarak alıcılı bir lambda alır.


Alıcılı bir lambda, belirli bir alıcı nesnesine erişimi olan bir lambda ifadesini tanımlamanın bir yoludur. İşlev değişmezinin gövdesi içinde, bir çağrıya iletilen alıcı nesnesi örtük bir this haline gelir, böylece bu alıcı nesnesinin üyelerine herhangi bir ek niteleyici olmadan erişebilir veya alıcı nesnesine this ifadesini kullanarak erişebilirsiniz.


Ayrıca, eğer bir yöntem çağrısının son argümanı lambda ise lambda parantezlerin dışına yerleştirilebilir. Bu nedenle DSL'imizde aşağıdaki gibi bir kod yazabiliriz:

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


Bu, sözdizimsel şeker içermeyen bir koda eşdeğerdir:

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


Alıcıları olan Lambdalar ve parantezlerin dışındaki Lambdalar, DSL'lerle çalışırken özellikle yararlı olan Kotlin özellikleridir.


Nesne bildirimi

Şimdi varlık acceptance bakalım. acceptance bir nesnedir. Kotlin'de nesne bildirimi, tek bir örneği olan bir sınıf olan singleton'u tanımlamanın bir yoludur. Yani nesne bildirimi hem sınıfı hem de onun tek örneğini aynı anda tanımlar.


Operatörün aşırı yüklenmesini “çağırmak”

Ayrıca, accreditation nesnesi için invoke operatörü aşırı yüklenmiştir. invoke operatörü, sınıflarınızda tanımlayabileceğiniz özel bir fonksiyondur. Bir sınıfın örneğini bir işlevmiş gibi çağırdığınızda, invoke operatörü işlevi çağrılır. Bu, nesneleri işlev olarak ele almanıza ve onları işlev benzeri bir şekilde çağırmanıza olanak tanır.


invoke yönteminin parametresinin de alıcılı bir lambda olduğuna dikkat edin. Artık bir kabul rotası tanımlayabiliriz…

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


…ve onun üzerinden yürüyün

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


Ayrıntılar ekleniyor

Infix işlevleri

Bu koda bir göz atın

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


Bunu aşağıdaki yöntemlerle uygulayabiliriz:

 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 sınıfı birkaç olası görev yürütücüsü ayarlamamıza olanak tanır. ExecutorCondition infix işlevini or . Bir infix işlevi, onu daha doğal bir infix gösterimi kullanarak çağırmanıza olanak tanıyan özel bir işlev türüdür.


Dilin bu özelliğini kullanmasaydık şu şekilde yazmamız gerekirdi:

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


Infix işlevleri aynı zamanda protokolün gerekli durumunu ve saat dilimini ayarlamak için de kullanılır.

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


Uzatma işlevleri

String.timezone bir uzantı işlevidir. Kotlin'de eklenti işlevleri, mevcut sınıflara kaynak kodlarını değiştirmeden yeni işlevler eklemenizi sağlar. Bu özellik, standart veya harici kitaplıklardaki sınıflar gibi, üzerinde kontrol sahibi olmadığınız sınıfların işlevselliğini artırmak istediğinizde özellikle kullanışlıdır.


DSL'de kullanım:

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


Burada "2022-12-08 08:00" , üzerinde genişletme işlevi timezone çağrıldığı bir alıcı nesnesidir ve PST parametredir. Alıcı nesnesine this anahtar sözcük kullanılarak erişilir.

Operatör aşırı yüklemesi

DSL'imizde kullandığımız bir sonraki Kotlin özelliği operatör aşırı yüklemesidir. invoke operatörünün aşırı yükünü zaten değerlendirdik. Kotlin'de aritmetik olanlar da dahil olmak üzere diğer operatörlere aşırı yükleme yapabilirsiniz.

 addStep { ... +canChange }


Burada tekli operatör + aşırı yüklenmiştir. Bunu uygulayan kod aşağıdadır:

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


Bitirici dokunuş

Artık DSL'imizde kabul yollarını tanımlayabiliriz. Ancak DSL kullanıcılarının olası hatalardan korunması gerekmektedir. Örneğin, mevcut sürümde aşağıdaki kod kabul edilebilir:

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


addStep içindeki addStep tuhaf görünüyor, değil mi? Bu kodun neden hatasız bir şekilde başarıyla derlendiğini bulalım. Yukarıda bahsedildiği gibi, acceptance#invoke ve AcceptanceContext#addStep yöntemleri parametre olarak alıcı içeren bir lambda alır ve alıcı nesnesine this anahtar sözcüğüyle erişilebilir. Böylece önceki kodu şu şekilde yeniden yazabiliriz:

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


Artık [email protected] iki seferde de çağrıldığını görebilirsiniz. Özellikle bu gibi durumlar için Kotlin'in DslMarker açıklaması bulunmaktadır. Özel ek açıklamalar tanımlamak için @DslMarker kullanabilirsiniz. Aynı açıklamayla işaretlenen alıcılara birbirlerinin içinden erişilemez.

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


Şimdi kod

 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

Bağlantılar

Aşağıda, bu makalede dikkate alınan dil özelliklerine ilişkin resmi Kotlin belgelerine bağlantılar bulunmaktadır:



Çözüm

Etki alanına özgü diller, belirli bir etki alanındaki sorunları modellemek ve çözmek için özelleştirilmiş ve etkileyici bir yol sağlayarak üretkenliği artırmak, hataları azaltmak ve işbirliğini geliştirmek için güçlü bir araç sunar. Kotlin birçok özelliği ve sözdizimsel yapısıyla modern bir programlama dilidir, dolayısıyla dahili DSL için ana dil olarak mükemmeldir.