干货 | 实现一个属于你的“语言”-携程Kotlin DSL开发与实践
作者简介
刘媛,携程金融高级开发工程师,主要负责中文版、国际版支付Android端的开发及维护工作。
每一个DSL,都是一定意义上专有的语言,这篇文章希望能够用浅显易懂的方式,将Kotlin DSL的应用与实践经验分享给大家。希望对你有所启发,能够构建一门属于自己的专有“语言”。
一、简介
DSL(domain specific language),即领域专用语言:专门解决某一特定问题的计算机语言。由于它是以简洁的形式进行表达,整体上直观易懂,使得调用代码和读代码的成本都得以降低,即使是不懂编程语言的一般人都可以进行使用,所以近年来频频被提起,颇受关注。
DSL分为外部DSL和内部DSL。
DSL:在主程序设计语言之外,用一种单独的语言表示领域专有语言。可以是定制语法,或者遵循另外一种语法,如XML、JSON。
内部DSL:通常是基于通用编程语言实现,具有特定的风格,如 iOS 的依赖管理组件 CocoaPods 和 Android 的主流编译工具 Gradle。
这里主要分享在Kotlin中构建使用DSL。
二、应用
Kotlin DSL的应用广泛,包括gradle编写、编写js、html、SQL等。下面列举几个使用场景:
2.1 Trip.com支付网络封装实践
在编写网络代码时,出现频率最高的就是request配置和大篇幅的response回调处理,那么这两部分的代码该如何优化?在Trip.com支付中利用kotlin DSL对网络进行二次封装,针对以上问题进行解决。
定义request配置,使得最终在做request配置时更为简洁:
fun requestBean(request: () -> BusinessBean) {
payClientBuilder.setRequestBean(request())
}
fun needRetry(needRetry: () -> Boolean) {
payClientBuilder.setNeedRetry(needRetry())
}
...
...
定义回调模版,解决以下问题:部分网络请求,我们不关心结果,或者不关心onFailed的场景,避免掉这部分的冗余代码:
private var callSubSuccess: ((T) -> Unit)? = null
private var callSubFailed: ((Client.Error?) -> Unit)? = null
private var subCallback: PayNetCallback<T> = object : PayNetCallback<T> {
override fun onSucceed(response: T) {
callSubSuccess?.invoke(response)
}
override fun onFailed(error: Client.Error?) {
callSubFailed?.invoke(error)
}
}
fun subSuccess(subSuccess: (T) -> Unit) {
callSubSuccess = subSuccess
payClientBuilder.setSubCallBack(subCallback)
}
fun subFailed(subFailed: (Client.Error?) -> Unit) {
callSubFailed = subFailed
payClientBuilder.setSubCallBack(subCallback)
}
预定义扩展函数
object PayNetworkClient {
fun <T : BusinessBean> init(
costClass: Class<T>,
config: PayClientConfigBuilder<T>.() -> Unit
): PayClientBuilder.NetworkClient? {
var networkClient: payClientBuilder.NetworkClient?
with(PayClientConfigBuilder(costClass)) {
networkClient = build(config)
}
return networkClient
}
}
最终调用
val networkClient = PayNetworkClient.init(BusinessResponse::class.java) {
//配置部分
requestBean { request }
needRetry { false }
cancelOtherSession { "sendGetPayInfo" }
//回调部分,可根据需求添加subSuccess或subFailed
subSuccess { serviceSuccess(it) }
subFailed { serviceFailed(it) }
}
networkClient?.send()
在定义DSL的过程中需要权衡冗余度、自由度、可扩展性。上面给出的伪代码消除了重复的模版代码,减少代码冗余,同时也做到自由选择配置项,有一定的自由度和可扩展性。
2.2 海外支付SDK DSL构建项目实践
众所周知Android studio中是使用groovy编写gradle脚本,而groovy由于是动态语言,不可避免的存在一个问题,就是代码提示不够智能,我们在使用groovy时往往需要配合文档进行编写;而kotlin是一种静态语言,使用它编写gradle脚本则可以有比较好的智能提示体验。
在Gradle5.0中,官方提供可以选择在项目中生成Groovy或者kotlin DSL构建脚本,并进一步的优化代码自动完成、重构和其他 IDE 辅助功能,为使用Kotlin DSL的 IDE 用户带来了极大的便利。
可见gradle官方也在努力将kotlin DSL推向大家视野中。
在我们最近的海外支付SDK中,采用该种方式构建项目, 部分gradle代码如下:
import org.jetbrains.kotlin.config.KotlinCompilerVersion
plugins {
id("com.android.application")
kotlin("android")
kotlin("android.extensions")
}
android {
compileSdkVersion(28)
defaultConfig {
applicationId = "trip.pay.app"
minSdkVersion(21)
targetSdkVersion(28)
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
getByName("release") {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
implementation(kotlin("stdlib-jdk8", KotlinCompilerVersion.VERSION))
implementation("com.android.support:appcompat-v7:28.0.0")
implementation("com.android.support.constraint:constraint-layout:1.1.3")
implementation(project(":TripPay"))
}
repositories {
mavenLocal()
maven(url = "maven地址")
}
可以看到使用kotlin编写和groovy编写区别不大,所以即使我们要将现有工程中的groovy脚本重写为kotlin脚本,工作量也不会过大。
以上种种都表明Kotlin DSL相对于groovy的优势非常明显,那么我们是不是应该立马开始改造现有的项目?
答案是“否”,因为它目前存在一个致命的缺陷,在首次编译项目时比groovy DSL慢很多,大项目中这一点会被放大,所以大家在上手之前需要慎重权衡利弊。
目前我们在海外支付SDK中利用kotlin DSL构建大约在17s,利用groovy DSL构建大约在16s,时间上来说几乎没有区别,所以小型项目推荐尝试使用!
相信在不久的未来kotlin DSL可以解决这个问题,那么利用kotlin DSL构建项目势必会成为趋势。
2.3 Anko
Anko库包括Anko Commons、Anko Layouts、Anko SQLite、Anko Coroutines,这些都是使用kotlin DSL编写,这里主要介绍Anko Layouts。
在写Android布局时,我们都习惯性的使用XML进行编写,但是可以考虑丢下冗长的XML写法,尝试使用Anko Layout来实现。
XML写法:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<EditText
android:id="@+id/todo_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/main_edit_hint" />
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/main_button_text" />
</LinearLayout>
Anko Layout写法:
verticalLayout {
setGravity(Gravity.CENTER_VERTICAL)
editText {
hintResource = R.string.main_edit_hint
}.lparams(width = matchParent, height = wrapContent)
button {
textResource = R.string.main_button_text
onClick {
toast("click!")
}
}.lparams(width = matchParent, height = wrapContent)
}
实际上前文提到过,XML本质上也是一种DSL,但是明显使用Anko Layout风格更加简单、也更加灵活。
XML编写后,我们需要findViewById找到控件,再对控件进行操作、赋值;Anko Layout编写过程中,可以在布局中就直接做显示隐藏、赋值操作等,同时这种写法也有类型安全、空安全、代码复用性强的优势。
Anko Layout由于是直接在kt文件中编写控件,那么它相对于xml来说,还有一个优势,即:减少了XML格式的解析过程,从而实现CPU资源和电量的节省。
XML的执行流程:
Anko Layout执行流程:
Anko库实际上是用kotlin对相关类做了一层扩展包装,基于这一点,它的局限性也体现在于会增加包大小,在使用之前可以根据项目评估一下是否适合引入Anko库。
2.4、创建一个自己的DSL
Kotlin DSl的优势这么多,那么如何自定义一个DSL?
kotlin的扩展函数、高阶函数、lambda表达式、中缀调用、invoke 约定和函数小括号省略等特性,使得Kotlin编写DSL尤为顺畅,我们可以使用这些特性来实现自己的“领域特定语言”。这里给一个简单的示例:
定义Trip、Department类
data class Trip(var name: String? = "", var address: String? = "", var departments: List<Department>? = mutableListOf(), var city: List<String>? = mutableListOf(), var culture: String? = "")
data class Department(var name: String = "", var nameEn: String = "")
定义中间类,主要是为了实现直接DSL方式添加department的效果
class TripBuilder {
var name: String? = ""
var address: String? = ""
var departments = mutableListOf<Department>()
fun department(block: DepartmentBuilder.() -> Unit) {
// 简单的写法departments.add(DepartmentBuilder().apply(block).build())即可
// 演示invoke实现
val departmentBuilder = DepartmentBuilder()
block.invoke(departmentBuilder)
departments.add(departmentBuilder.build())
}
fun build(): Trip = Trip(name, address, departments)
}
class DepartmentBuilder {
var name: String = ""
var nameEn: String = ""
fun build(): Department = Department(name, nameEn)
}
创建trip的DSL写法
fun trip(block: TripBuilder.() -> Unit): Trip = TripBuilder().apply(block).build()
实现中缀culture方法(只为了演示所用,实际上可以直接赋值)
infix fun Trip.culture(culture: String) {
this.culture = culture
}
最终调用效果:
val trip = trip {
name = "Trip"
address = "上海市长宁区金钟路968号凌空SOHO"
department {
name = "机票"
nameEn = "flight"
}
department {
name = "酒店"
nameEn = "hotel"
}
department {
name = "火车票"
nameEn = "train"
}
}
trip culture "Customer、Teamwork、Respect、Integrity、Partner"
Log.i("result",trip)
result结果:
Trip(name=Trip, address=上海市长宁区金钟路968号凌空SOHO, departments=[Department(name=机票, nameEn=flight), Department(name=酒店, nameEn=hotel), Department(name=火车票, nameEn=train)], culture=Customer、Teamwork、Respect、Integrity、Partner)
一个简单的Kotlin DSL就这样实现了,通过封装成结构化的 API 达到了直观易懂、最终调用时代码量减少的效果。即使是一个非kotlin开发人员也可以理解以上格式的含义,完成“Trip”对象的配置使用。
三、写在最后
1)Kotlin编写完的DSL整体简洁直观,调用代码和读代码的成本都得以降低,在生产项目中可以稳定使用。
2)DSL是通过简化语言中的元素,降低使用者的负担,使用者需要按照既定的规范进行编写。所以我们需要提供完善使用文档,以保证接入者学习成本降低。
3)在我们编写的DSL应用范围越来越大时,已有DSL往往满足不了现有的需求,我们仍然需要对DSL进行补充,所以在定义自己的DSL时需要评估后期开发维护效率,注意其可扩展性。
【推荐阅读】