paint-brush
Cách phát triển DSL trong Kotlintừ tác giả@yaf
2,105 lượt đọc
2,105 lượt đọc

Cách phát triển DSL trong Kotlin

từ tác giả Fedor Yaremenko11m2023/12/11
Read on Terminal Reader

dài quá đọc không nổi

Các ngôn ngữ dành riêng cho miền dễ dàng hơn, thuận tiện hơn và biểu cảm hơn để mô tả các nhiệm vụ của lĩnh vực chủ đề. Kotlin là một ngôn ngữ lập trình hiện đại với nhiều tính năng và cú pháp dễ hiểu nên rất tuyệt vời khi làm ngôn ngữ chủ cho DSL nội bộ. Bài viết mô tả cách sử dụng nhiều tính năng khác nhau của Kotlin để tạo DSL nhằm xác định quy trình kinh doanh.
featured image - Cách phát triển DSL trong Kotlin
Fedor Yaremenko HackerNoon profile picture


Các lập trình viên liên tục tranh cãi về việc ngôn ngữ nào là tốt nhất. Có lần chúng tôi so sánh C và Pascal, nhưng thời gian trôi qua. Cuộc chiến Python / RubyJava /C# đã ở phía sau chúng ta. Mỗi ngôn ngữ đều có ưu và nhược điểm, đó là lý do tại sao chúng tôi so sánh chúng. Lý tưởng nhất là chúng tôi muốn mở rộng ngôn ngữ để phù hợp với nhu cầu của mình. Các lập trình viên đã có cơ hội này từ rất lâu rồi. Chúng tôi biết các cách lập trình siêu dữ liệu khác nhau, tức là tạo chương trình để tạo chương trình. Ngay cả các macro tầm thường trong C cũng cho phép bạn tạo ra các đoạn mã lớn từ các mô tả nhỏ. Tuy nhiên, các macro này không đáng tin cậy, bị hạn chế và không có tính biểu cảm cao. Các ngôn ngữ hiện đại có nhiều phương tiện mở rộng biểu cảm hơn. Một trong những ngôn ngữ này là Kotlin.


Định nghĩa của một ngôn ngữ dành riêng cho miền

Ngôn ngữ dành riêng cho miền (DSL) là ngôn ngữ được phát triển riêng cho một lĩnh vực chủ đề cụ thể, không giống như các ngôn ngữ có mục đích chung như Java, C#, C++ và các ngôn ngữ khác. Điều này có nghĩa là việc mô tả các nhiệm vụ của môn học sẽ dễ dàng hơn, thuận tiện hơn và biểu cảm hơn, nhưng đồng thời cũng bất tiện và không thực tế trong việc giải quyết các công việc hàng ngày, tức là nó không phải là một ngôn ngữ phổ quát. Như một ví dụ về DSL, bạn có thể sử dụng ngôn ngữ biểu thức chính quy. Lĩnh vực chủ đề của biểu thức chính quy là định dạng của chuỗi.


Để kiểm tra xem chuỗi có tuân thủ định dạng hay không, chỉ cần sử dụng thư viện triển khai hỗ trợ cho các biểu thức chính quy là đủ:

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


Nếu bạn kiểm tra xem chuỗi có tuân thủ định dạng đã chỉ định trong ngôn ngữ phổ quát, ví dụ: Java, bạn sẽ nhận được mã sau:

 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ạn mã trên khó đọc hơn biểu thức thông thường, dễ mắc lỗi hơn và khó thực hiện thay đổi hơn.


Các ví dụ phổ biến khác về DSL là HTML, CSS, SQL, UML và BPMN (hai ví dụ sau sử dụng ký hiệu đồ họa). DSL không chỉ được sử dụng bởi các nhà phát triển mà còn bởi những người thử nghiệm và những người không chuyên về CNTT.


Các loại DSL

DSL được chia thành hai loại: bên ngoài và bên trong. Các ngôn ngữ DSL bên ngoài có cú pháp riêng và chúng không phụ thuộc vào ngôn ngữ lập trình chung mà chúng được hỗ trợ.


Ưu và nhược điểm của DSL bên ngoài:

🟢 Tạo mã bằng các ngôn ngữ khác nhau / thư viện tạo sẵn

🟢 Thêm tùy chọn để thiết lập cú pháp của bạn

🔴 Sử dụng các công cụ chuyên dụng: ANTLR, yacc, lex

🔴 Đôi khi khó diễn tả ngữ pháp

🔴 Không có hỗ trợ IDE, bạn cần viết plugin của mình


DSL nội bộ dựa trên một ngôn ngữ lập trình phổ quát cụ thể (ngôn ngữ máy chủ). Nghĩa là, với sự trợ giúp của các công cụ tiêu chuẩn của ngôn ngữ máy chủ, các thư viện được tạo ra cho phép bạn viết gọn hơn. Ví dụ: hãy xem xét cách tiếp cận Fluent API.


Ưu và nhược điểm của DSL nội bộ:

🟢 Sử dụng cách diễn đạt của ngôn ngữ chủ nhà làm cơ sở

🟢 Dễ dàng nhúng DSL vào mã ở ngôn ngữ chủ nhà và ngược lại

🟢 Không yêu cầu tạo mã

🟢 Có thể được gỡ lỗi dưới dạng chương trình con trong ngôn ngữ máy chủ

🔴 Khả năng thiết lập cú pháp bị hạn chế


Một ví dụ thực tế

Gần đây, công ty chúng tôi phải đối mặt với nhu cầu tạo DSL của mình. Sản phẩm của chúng tôi đã thực hiện chức năng chấp nhận mua hàng. Mô-đun này là một công cụ nhỏ của BPM (Quản lý quy trình nghiệp vụ). Quy trình kinh doanh thường được thể hiện bằng đồ họa. Ví dụ: ký hiệu BPMN bên dưới hiển thị một quy trình bao gồm thực hiện Nhiệm vụ 1 và sau đó thực hiện song song Nhiệm vụ 2 và Nhiệm vụ 3.


Điều quan trọng đối với chúng tôi là có thể tạo các quy trình kinh doanh theo chương trình, bao gồm xây dựng lộ trình một cách linh hoạt, chỉ định người thực hiện cho các giai đoạn phê duyệt, đặt thời hạn thực hiện giai đoạn, v.v. Để thực hiện điều này, trước tiên chúng tôi đã cố gắng giải quyết vấn đề này bằng API thông thạo tiếp cận.


Sau đó, chúng tôi kết luận rằng việc thiết lập các tuyến chấp nhận bằng Fluent API vẫn còn phức tạp và nhóm của chúng tôi đã cân nhắc tùy chọn tạo DSL của riêng mình. Chúng tôi đã nghiên cứu xem lộ trình chấp nhận sẽ trông như thế nào trên DSL bên ngoài và DSL nội bộ dựa trên Kotlin (vì mã sản phẩm của chúng tôi được viết bằng Java và Kotlin ).


DSL bên ngoài:

 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 nội bộ:

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


Ngoại trừ dấu ngoặc nhọn, cả hai tùy chọn đều gần như giống nhau. Vì vậy, người ta quyết định không lãng phí thời gian và công sức vào việc phát triển DSL bên ngoài mà tạo ra DSL nội bộ.


Triển khai cấu trúc cơ bản của DSL

Hãy bắt đầu phát triển một mô hình đối tượng

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


Lambda

Đầu tiên, chúng ta hãy nhìn vào lớp AcceptanceContext . Nó được thiết kế để lưu trữ một tập hợp các phần tử tuyến đường và được sử dụng để thể hiện toàn bộ sơ đồ cũng như các khối parallel .

Các phương thức addStepparallel lấy lambda với bộ thu làm tham số.


Lambda với bộ thu là một cách để xác định biểu thức lambda có quyền truy cập vào một đối tượng bộ thu cụ thể. Bên trong phần thân của hàm đen, đối tượng người nhận được truyền cho cuộc gọi sẽ trở thành ẩn this , để bạn có thể truy cập các thành viên của đối tượng người nhận đó mà không cần bất kỳ vòng loại bổ sung nào hoặc truy cập đối tượng người nhận bằng cách sử dụng biểu thức this .


Ngoài ra, nếu đối số cuối cùng của lệnh gọi phương thức là lambda thì lambda có thể được đặt bên ngoài dấu ngoặc đơn. Đó là lý do tại sao trong DSL, chúng ta có thể viết mã như sau:

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


Điều này tương đương với một mã không có đường cú pháp:

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


Lambda có bộ thu và Lambda nằm ngoài dấu ngoặc đơn là các tính năng của Kotlin đặc biệt hữu ích khi làm việc với DSL.


Khai báo đối tượng

Bây giờ chúng ta hãy xem acceptance thực thể. acceptance là một đối tượng. Trong Kotlin, khai báo đối tượng là một cách để xác định một singleton — một lớp chỉ có một phiên bản. Vì vậy, việc khai báo đối tượng định nghĩa cả lớp và thể hiện đơn lẻ của nó cùng một lúc.


Quá tải toán tử “gọi”

Ngoài ra, toán invoke bị quá tải đối với đối tượng accreditation . Toán invoke là một hàm đặc biệt mà bạn có thể định nghĩa trong các lớp của mình. Khi bạn gọi một thể hiện của một lớp như thể nó là một hàm, thì hàm toán invoke gọi sẽ được gọi. Điều này cho phép bạn coi các đối tượng là các hàm và gọi chúng theo cách giống như hàm.


Lưu ý rằng tham số của phương thức invoke cũng là lambda có bộ thu. Bây giờ chúng ta có thể xác định lộ trình chấp nhận…

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


…và bước qua nó

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


Thêm chi tiết

Hàm trung tố

Hãy nhìn vào mã này

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


Chúng ta có thể thực hiện điều này bằng cách sau:

 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)


Lớp ExecutorCondition cho phép chúng ta thiết lập một số trình thực thi tác vụ có thể có. Trong ExecutorCondition chúng ta xác định hàm infix or . Hàm infix là một loại hàm đặc biệt cho phép bạn gọi nó bằng cách sử dụng ký hiệu infix tự nhiên hơn.


Nếu không sử dụng đặc điểm này của ngôn ngữ, chúng ta sẽ phải viết như sau:

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


Các hàm Infix cũng được sử dụng để đặt trạng thái yêu cầu của giao thức và thời gian theo múi giờ.

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


Các hàm mở rộng

String.timezone là một hàm mở rộng. Trong Kotlin, các hàm mở rộng cho phép bạn thêm các hàm mới vào các lớp hiện có mà không cần sửa đổi mã nguồn của chúng. Tính năng này đặc biệt hữu ích khi bạn muốn tăng cường chức năng của các lớp mà bạn không có quyền kiểm soát, chẳng hạn như các lớp từ thư viện tiêu chuẩn hoặc bên ngoài.


Cách sử dụng trong DSL:

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


Ở đây "2022-12-08 08:00" là một đối tượng máy thu, trên đó timezone của hàm mở rộng được gọi và PST là tham số. Đối tượng người nhận được truy cập bằng từ khóa this .

Quá tải toán tử

Tính năng Kotlin tiếp theo mà chúng tôi sử dụng trong DSL là nạp chồng toán tử. Chúng ta đã xem xét tình trạng quá tải của toán invoke . Trong Kotlin, bạn có thể nạp chồng các toán tử khác, bao gồm cả toán tử số học.

 addStep { ... +canChange }


Ở đây toán tử một ngôi + bị quá tải. Dưới đây là mã thực hiện điều này:

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


Chi tiết cuối cùng

Bây giờ chúng ta có thể mô tả các tuyến chấp nhận trên DSL của mình. Tuy nhiên, người dùng DSL cần được bảo vệ khỏi các lỗi có thể xảy ra. Ví dụ: trong phiên bản hiện tại, đoạn mã sau được chấp nhận:

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


addStep bên trong addStep trông có vẻ lạ phải không? Hãy cùng tìm hiểu lý do tại sao mã này biên dịch thành công mà không có bất kỳ lỗi nào. Như đã đề cập ở trên, các phương thức acceptance#invokeAcceptanceContext#addStep lấy lambda với bộ thu làm tham số và đối tượng bộ thu có thể truy cập được bằng từ khóa this . Vì vậy, chúng ta có thể viết lại mã trước đó như thế này:

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


Bây giờ bạn có thể thấy [email protected] được gọi cả hai lần. Đặc biệt đối với những trường hợp như vậy, Kotlin có chú thích DslMarker . Bạn có thể sử dụng @DslMarker để xác định chú thích tùy chỉnh. Các máy thu được đánh dấu bằng cùng một chú thích như vậy không thể được truy cập bên trong nhau.

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


Bây giờ mã

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


sẽ không biên dịch do lỗi 'fun addStep(init: StepContext.() -> Unit): Unit' can't be called in this context by implicit receiver. Use the explicit one if necessary

Liên kết

Dưới đây là các liên kết đến tài liệu chính thức của Kotlin về các tính năng ngôn ngữ đã được xem xét trong bài viết này:



Phần kết luận

Các ngôn ngữ dành riêng cho miền cung cấp một phương tiện mạnh mẽ để nâng cao năng suất, giảm lỗi và cải thiện sự cộng tác bằng cách cung cấp một cách chuyên biệt và biểu cảm để mô hình hóa và giải quyết các vấn đề trong một miền cụ thể. Kotlin là một ngôn ngữ lập trình hiện đại với nhiều tính năng và cú pháp dễ hiểu nên rất tuyệt vời khi làm ngôn ngữ chủ cho DSL nội bộ.