尧图精选

Kotlin DSL实战:扩展函数与中缀表达式构建优雅查询

🕒 发布时间:2026/9/9 18:01:18 📁 来源:尧图网络
我第一次被 Kotlin 的扩展函数圈粉是在读一个开源库的源码时看到的这样一行代码users where { (age gt 20) and (name like 张) }当时我心里是有点震惊的一个字符串字面量居然能直接接where一个字符串还能去gt一个数值就好像这套 DSL 规则是这门语言天生自带的语法一样。后来我把 Kotlin 的扩展函数、中缀表达式、带接收者的 Lambda 组合起来认真研究了一遍才发现这根本不是魔法而是 Kotlin 为 DSL 设计准备的三块积木扩展函数负责“借壳”中缀表达式负责“组句”带接收者的 Lambda 负责“划界”。这篇文章就顺着这三块积木往下拆。我会先用大白话讲清楚扩展函数编译之后的本质再讲中缀表达式的使用边界然后用一个查询构建器的完整实战把两者串起来最后聊聊DslMarker作用域控制和我在实际项目中沉淀下来的 DSL 设计经验。不管你是刚开始学 Kotlin还是已经用它写了几年业务代码这篇都值得花几分钟读完。1. 为什么说扩展函数是 DSL 的基石1.1 先搞清楚扩展函数编译后变成了什么很多教程讲扩展函数会直接说“可以给已有类添加新方法。”这话没错但它很容易让人产生一个误解——好像 Kotlin 真的往String或者某个第三方类里塞了一个新方法。实际上不是。扩展函数编译之后就是一个普通的静态方法只不过接收者会作为第一个参数传进去。看个最简单的例子fun String.isEmail(): Boolean contains() println(abcexample.com.isEmail())这段代码经过编译后再反编译成 Java大概是这个样子public final class ExtensionsKt { public static final boolean isEmail(String $this$isEmail) { return $this$isEmail.contains(); } }那个String接收者变成了静态方法的第一个参数。调用处abcexample.com.isEmail()其实等价于 Java 里的ExtensionsKt.isEmail(abcexample.com)。理解这一层对后面特别重要因为它决定了扩展函数的一个核心特性它是静态解析的不是运行时动态分派。也就是说到底调用哪个扩展函数在编译期就定死了看的是变量的静态类型而不是运行时的实际类型。Kotlin 之所以敢在设计 DSL 时大量使用扩展函数正是因为它有这种可预测性。编译器在编译期就知道一个String上挂了哪些扩展函数哪个调用会命中哪个实现不会像动态代理那样在运行时偷偷改变行为。这给了 DSL 设计非常扎实的基础。1.2 静态解析扩展函数不是“往类里塞方法”我见过不少新手在扩展函数上踩坑踩完反而对 Kotlin 产生了误解。看这段代码open class Shape class Circle : Shape() fun Shape.describe() shape fun Circle.describe() circle val shape: Shape Circle() println(shape.describe()) // 猜猜输出什么如果 Kotlin 的扩展函数是动态分派的那这里shape运行时是Circle应该调用Circle.describe()输出 “circle”。但实际输出是 “shape”。原因就是上面说的静态解析shape的静态类型是Shape所以编译器只看得到Shape的扩展函数Shape.describe()并直接调用它。运行时的真实类型Circle不会参与方法决议。这个特性放到 DSL 场景里其实是优点。DSL 的本质是把一段复杂的配置逻辑变得“可读”如果扩展函数的决议规则是动态的那调用结果会因为运行时的类型变化而飘忽不定这对 DSL 的稳定性是毁灭性的。静态解析让扩展函数的行为完全由编译期类型决定代码读起来是什么样跑起来就是什么样设计者可以放心地把领域动作挂到各种类型上。1.3 扩展函数怎么变成 DSL 的“语法外挂”现在回到 DSL 本身。你可以把扩展函数理解成一种“语法外挂”它允许你在不修改原有类的前提下给这个类补充领域相关的能力。一个典型的 DSL 例子data class Condition(val sql: String) infix fun String.eq(value: Any?): Condition Condition($this $value)这里eq是String的扩展函数但使用起来age eq 20读起来非常自然就像String天生就懂查询条件一样。String自己并不知道什么叫eq但它通过扩展函数获得了表达“字段等于某个值”的能力。这种“借壳”能力是 DSL 设计的核心动力。你不需要继承String不需要装饰器模式包一层不需要写一堆工具方法只要一个扩展函数就能让一个最简单的类型承担起领域语义。更关键的是扩展函数不仅可以定义在语言内置类型String、List、Int上也可以定义在你自己的领域类型上。这意味着你能把自己的业务对象逐渐改造成一个领域的“语法骨架”让后续所有 DSL 调用看起来都像是在描述业务规则而不是在调用一堆零散的 API。2. 中缀表达式把调用链变成读得懂的“句子”2.1 中缀表达式到底是什么扩展函数解决了“让类型拥有领域能力”的问题但光有扩展函数还不够——age.eq(20)这样的写法虽然能用但语法噪音还是有点重。中缀表达式就是用来把这种调用变得更像自然语言的。中缀表达式允许你在调用一个函数时省略点和括号直接写成A 函数名 B的形式。要声明一个中缀函数需要满足几个硬性条件规则说明必须是成员函数或扩展函数顶层普通函数不能直接声明为 infix必须只有一个参数参数类型可以任意但不能是可变参数参数不能有默认值有默认值时调用方式会产生歧义编译器直接禁止必须用infix关键字修饰这是显式声明避免无意中把普通函数当中缀用用代码说就是infix fun String.concat(other: String): String this other val result Kotlin concat DSL println(result) // Kotlin DSL这个concat的存在感其实不强但它准确展示了中缀表达式的形态左侧是接收者右侧是唯一参数中间是函数名。2.2 一个最直观的体验从布尔判断到业务断言中缀表达式最适合的场景是那些逻辑上本来就存在“左操作数、操作符、右操作数”结构的地方。最典型的就是布尔判断。假设你在写一套权限校验逻辑传统写法可能是fun checkRole(role: String) { if (!listOf(admin, editor).contains(role)) { throw IllegalArgumentException(role is invalid) } }这代码逻辑没错但读起来总觉得绕。listOf(...).contains(role)需要先把集合写出来再调用contains脑子要做一次“反转”才能理解意思。换成中缀表达式infix fun T T.isIn(items: ListT): Boolean items.contains(this) fun checkRole(role: String) { if (role isIn listOf(admin, editor)) { // 通过 } }role isIn listOf(...)读起来就是一句很顺的话“角色在这个集合里。”这就是 DSL 设计里一直强调的“可读性优先”。中缀表达式把调用从“方法调用”变成了“句子的谓语”。你不再需要脑内把list.contains(role)拆开重组直接按顺序从左到右读下来意思就已经在脑海里了。2.3 优先级中缀调用的常见误区中缀表达式虽然好用但在组合场景里有个很容易踩的坑——中缀函数不能无脑链式编写。举个例子我想表达“a 等于 1 且 b 等于 2”时直觉上可能会写a eq 1 and b eq 2 // 编译不过这个表达式编译器是接受不了的因为中缀调用的解析顺序在这里会产生歧义。中缀函数虽然遵循从左到右的结合规则但and不是String的扩展函数a eq 1执行完返回的是ConditionCondition上才定义了and的扩展函数所以严格来说应该写成(a eq 1) and (b eq 2)加括号不是软件工程上的“不优雅”而是为了让表达式结构清晰可见。我在实际使用中一般会约定只要一条中缀链超过两个操作数就必须加括号不加括号的一律视为代码风格问题。这样既避免了编译器解析的歧义也让别人读代码时一眼就能看出条件的组合关系。还有一点容易被忽略中缀调用的优先级低于算术运算符和类型转换但高于elvis运算符?:。如果你在中缀表达式里混用了这些运算符记得用括号把中缀部分包起来否则解析结果会非常反直觉。3. 实战用扩展函数中缀表达式手写一个查询 DSL3.1 需求定义和 API 设计前面讲了一堆理论这一节我们动手把一个查询 DSL 从头到尾写出来。这个例子是我在真实项目中用过的简化版本——把查询条件拼成 SQL。先定一个目标 API// 目标生成 SELECT * FROM users WHERE age 20 AND name LIKE %张% val sql (users where { (age gt 20) and (name like 张) }).build() println(sql)这个 API 里同时出现了字符串的扩展函数age gt 20、name like 张、users where { ... }中缀表达式gt、like、and、where带接收者的 Lambdawhere后面的大括号这正是这篇文章想要展示的完整组合形态。3.2 一步步实现先定义Condition它承载一个查询条件片段data class Condition(val sql: String)然后定义一系列中缀扩展函数。注意这里都是infix fun同时都是扩展函数infix fun String.eq(value: Any?): Condition Condition($this $value) infix fun String.gt(value: Any?): Condition Condition($this $value) infix fun String.like(value: Any?): Condition Condition($this LIKE %${value}%)接着定义条件的组合逻辑。and和or是定义在Condition上的扩展函数这样两个条件才能拼到一起infix fun Condition.and(other: Condition): Condition Condition(${this.sql} AND ${other.sql}) infix fun Condition.or(other: Condition): Condition Condition(${this.sql} OR ${other.sql})再定义一个Query类来装表名和条件class Query { var tableName: String var conditionSql: String var orderBy: String var limit: Int 0 fun build(): String { val sb StringBuilder(SELECT * FROM $tableName) if (conditionSql.isNotBlank()) sb.append( WHERE ).append(conditionSql) if (orderBy.isNotBlank()) sb.append( ORDER BY ).append(orderBy) if (limit 0) sb.append( LIMIT ).append(limit) return sb.toString() } }然后是where扩展它接收一个返回Condition的 Lambdafun query(table: String, block: () - Condition): Query { val q Query() q.tableName table q.conditionSql block().sql return q } infix fun String.where(block: () - Condition): Query query(this, block)到这里最初的 API 就完整可用了。写个测试代码验证一下fun main() { val sql (users where { (age gt 20) and (name like 张) }).build() println(sql) // 输出SELECT * FROM users WHERE age 20 AND name LIKE %张% val sql2 (orders where { (status eq paid) or (total gt 1000) }).build() println(sql2) // 输出SELECT * FROM orders WHERE status paid OR total 1000 }两次调用的输出都符合预期。到这里一个能跑的最小查询 DSL 已经成型总共代码量不到 40 行。3.3 拆解这段代码为什么成立很多人第一次看到这种 DSL 代码会觉得“这怎么就能跑起来”其实把每一步拆开看就一目了然。第一步age gt 20。gt是定义在String上的中缀扩展函数age是接收者20是参数返回一个Condition(age 20)。第二步(age gt 20) and (name like 张)。and是定义在Condition上的中缀扩展函数前一个括号里算出Condition(age 20)后一个括号里算出Condition(name LIKE %张%)组合后得到Condition(age 20 AND name LIKE %张%)。第三步users where { ... }。where是定义在String上的中缀扩展函数接收者users会传入到query(this, block)里作为表名。大括号里的 Lambda 返回一个Condition就是前面组合出来的那条 SQL 条件片段。第四步.build()。Query拿到表名和条件后拼接出完整的 SQL 字符串。所以每一步都是确定的、可推理的。扩展函数保证了每个类型上有哪些操作是静态可见的中缀表达式保证了操作符形态的简洁Lambda 保证了条件构造逻辑可以被延迟到where的上下文中执行。三者各司其职组合起来就是一个非常顺滑的 DSL。3.4 扩展排序、分页和参数化处理上面这个 DSL 只能处理最简单的情况真实项目里还经常需要排序和分页。用同样的思路继续扩展infix fun Query.sortBy(column: String): Query { this.orderBy column return this } infix fun Query.page(pageNum: Int): Query { this.limit pageNum return this }调用方式就变成val sql (users where { (age gt 20) } sortBy created_at page 10).build()当然更重要的是参数化处理。我前面写的Condition直接用字符串拼接 SQL这在生产环境里非常危险——如果条件值来自用户输入会直接造成 SQL 注入。真正的项目里至少要改成预编译占位符的版本data class Condition( val sql: String, val args: MutableListAny mutableListOf() ) infix fun String.eq(value: Any?): Condition { return Condition($this ?).apply { args.add(value ?: NULL) } } infix fun String.gt(value: Any?): Condition { return Condition($this ?).apply { args.add(value ?: NULL) } }这样build()时生成的 SQL 是age ?参数值单独收集到一个列表里后续交给 JDBC 的PreparedStatement使用。从不到 40 行的最小版本扩展到排序、分页、参数化你会发现扩展函数和中缀表达式带来的不只是语法糖而是一套可增量演化的设计方式。DSL 的每个新特性都可以独立地以扩展函数的形式加进去不用改动已有代码。4. 作用域控制DslMarker 与几个常见的坑4.1 为什么不用 DslMarker 会出事当我们从一层 DSL 进入多层嵌套的 DSL 时一个非常隐蔽的问题会出现内层 lambda 的隐式接收者会把外层的接收者也暴露出来。看一个最简单的 HTML DSL 例子class Html { fun head(block: Head.() - Unit) { /* ... */ } fun body(block: Body.() - Unit) { /* ... */ } } class Head { fun title(block: Title.() - Unit) { /* ... */ } } class Body { fun div(block: Div.() - Unit) { /* ... */ } }如果直接在 HTML 里写出这样的嵌套调用html { head { title { // 没有 DslMarker 时这里不仅能看到 Title 的成员 // 还能看到 Head 的成员甚至 Html 的成员 } } }title的 Lambda 里理论上可以直接调用外层Head甚至Html上的方法因为Title的接收者作用域里外层接收者依然隐式可见。这会造成巨大的混淆DSL 设计者明明只想让用户在当前层级使用固定的一组 API结果因为作用域没有限制用户随手就能调用到不该在当前位置出现的方法。DslMarker就是用来解决这个问题的。它需要配合注解定义使用DslMarker annotation class HtmlDsl HtmlDsl class Html { ... } HtmlDsl class Head { ... } HtmlDsl class Body { ... }加上这个注解之后Kotlin 编译器会在嵌套的 lambda 中强制限制隐式接收者的使用内层 lambda 默认只能访问内层接收者的成员要访问外层接收者的成员必须显式使用thisHtml、thisHead这样的标签写法。这意味着没有DslMarker时你写的 DSL 里的嵌套作用域是“混沌的”有了它作用域边界才变得清晰可控。任何正经的多层级 DSL 设计都应该加上DslMarker这是我从实际项目中体会最深的一点。4.2 扩展函数静态解析的两个坑扩展函数虽然强大但有两个坑几乎每个人都会遇到。第一个坑是“对象扩展和实例扩展的混淆”。Kotlin 允许对可空类型定义扩展函数fun Any?.safeToString(): String this?.toString() ?: null val str: String? null println(str.safeToString()) // null这是合理的但当可空类型和不可空类型同时存在扩展函数时决议规则可能会让不熟悉的人意外。比如fun String?.dump(): String nullable fun String.dump(): String non-null val s: String? null println(s.dump()) // 输出 nullable只要接收者的静态类型是String?即使运行时值是null也会选到可空版本的扩展函数。这在 DSL 里没有太大问题但如果你在设计一个接受可空值的领域函数时一定要想清楚自己到底要扩展哪个类型否则调试时会非常困惑。第二个坑就是“同名成员函数优先”。如果某个类有一个成员函数foo()同时外部有一个同签名扩展函数foo()调用时成员函数胜出。这属于 Kotlin 语言层面的预定义规则成员函数优先级总是高于扩展函数。在 DSL 设计里这意味着如果你给某个接收者类型既写了成员方法又写了扩展方法编译器不会报错但实际调用时会忽略扩展版本。这个坑很难从代码表面发现因为 IDE 的自动补全偶尔会把两个都显示出来导致你以为调用的扩展版本其实跑的是成员版本。4.3 中缀表达式的边界中缀表达式除了前面提到的链式调用优先级问题还有几个边界条件需要重点记住。一是参数不能带默认值。Kotlin 的设计是如果中缀函数的参数有默认值那么调用时可以省略参数写成a foo这种形式这会破坏中缀调用“左右操作数对称”的形态编译器会直接禁止编译。二是参数不能是可变参数vararg。可变参数在语义上天然就不是“一个参数”无法满足中缀调用的语法约束。三是在 Java 中调用扩展函数比较别扭。Kotlin 的扩展函数编译成 Java 静态方法后需要显式传入接收者作为第一个参数。如果项目里有 Java 代码需要调用 Kotlin 的 DSL API调用处会变得很不优雅。做 SDK 或公共库时这一点要提前考虑好必要时要为 Java 调用方另写一层薄薄的静态工具方法包装。5. 从“能跑”到“好用”我沉淀下来的DSL设计经验5.1 先写命令式版本再提炼 DSL我见过不少朋友拿到扩展函数和中缀表达式后特别兴奋上来就想给整个业务写一套 DSL。我的建议是冷静一下先写命令式版本。命令式版本的代码逻辑清晰容易测试也容易让团队其他人理解。当命令式版本稳定运行一段时间后你再去观察哪些代码片段在多个地方重复出现哪些调用链条比较固定哪些业务规则经常变化——这些才是提炼 DSL 的候选对象。我做查询 DSL 的流程也是这样。最开始代码里到处是queryBuilder.addCondition(age, , 20) queryBuilder.addCondition(name, LIKE, %张%)后来发现addCondition被调用的模式太固定了才提炼出age gt 20这样的中缀写法。DSL 是“长出来”的不是“拍脑袋造”的。从命令式到声明式的演变才是 DSL 设计最健康的路径。5.2 作用域就是边界一个 DSL 块只做一件事多层级 DSL 设计里最容易失控的就是作用域。以文章前面那个查询 DSL 为例如果把from、where、orderBy、limit全部堆在一个Query类里用户调用时就会面临选择困难这个位置到底该写from还是该写where编译器不会拦你DSL 的可读性却会直线下降。更好的做法是给每个阶段设计独立的接收者类型用类型系统约束 DSL 的使用顺序class FromBuilder { fun where(block: ConditionBuilder.() - Unit): WhereBuilder { ... } } class WhereBuilder { fun orderBy(column: String): OrderBuilder { ... } } class OrderBuilder { fun limit(count: Int): Query { ... } }也就是说用户一旦进入了WhereBuilder上下文就再也没办法调用from里的方法了。这种“用类型限制操作顺序”的做法是把 DSL 从“能跑”提升到“好用”的关键一步。5.3 测试 DSL 也是一种常用形态查询 DSL 只是其中一个方向。我在项目里用得比较多的还有测试场景的 DSL。比如后端接口测试最传统的写法是val response httpClient.post(/api/login) .header(Content-Type, application/json) .body({username:admin,password:123456}) .execute() assertEquals(200, response.statusCode) assertEquals(success, response.jsonPath().getString(code))这段代码功能没问题但作为测试用例读起来不那么“像需求”。改成 DSL 形态之后test(登录接口成功返回) { http { method POST url /api/login body {username:admin,password:123456} } expect { status 200 code success } }这种 DSL 的可读性优势非常明显测试用例几乎可以直接拿给产品经理当验收标准。扩展函数和中缀表达式在这里依然起着同样的作用http块负责构建请求expect块负责断言响应。5.4 和生态的呼应了解扩展函数和中缀表达式之后你会发现很多 Kotlin 生态里的知名库都大量在使用它们。Gradle Kotlin DSL 里的依赖声明、Ktor 的路由配置、Compose 里Modifier的组合方式甚至 Kotlin 协程上下文部分 API 的设计都能看到这种“用扩展函数挂载领域能力用中缀表达式组织调用”的思路。所以学好这两个特性不只是为了写自己的 DSL更是为了在阅读这些高质量开源项目的源码时能一眼看懂作者的设计意图。当你看到Modifier.padding(16.dp).clickable { }这种链式调用时你能意识到它背后同样是一套精巧的扩展函数设计当你看到协程的launch(start CoroutineStart.UNDISPATCHED)这种声明时你也能联想到中缀和静态参数的组合思路。我自己的一点点体会是写 DSL 最容易犯的错是“为了 DSL 而 DSL”。扩展函数和中缀表达式确实香但如果业务本身很直接强行包装成 DSL 反而增加认知负担。我的判断标准很简单——如果有一个同事读这段代码时需要停下来思考“这到底是怎么拼起来的”那这个抽象就失败了反过来当 DSL 写到位时读代码的人会像读配置文件一样轻松。最后再分享一个小细节。我给 DSL 里的中缀函数命名时通常只用短动词或短介词eq、gt、like、and、or、from、where、sortBy、page不搞花里胡哨的长命名。中缀表达式的可读性完全靠“顺口”一个别扭的名字当场就会把整句 DSL 的美感毁掉。好的 DSL 是让调用方觉得“这个 API 本来就应该长这样”而不是让调用方感叹“这个库的作者脑洞真大”。这中间的平衡点就是你在写每一行扩展函数时都得反复揣摩的事。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →