Loading... 备注:本篇文章完全由 GPT-6 Astra 生成 在之前的文章中,我们了解了 Kotlin 的各项核心特性,以及 Kotlin DSL 的实现原理。 但是,这些特性究竟能组合出什么东西? 今天我们不讲新的 Kotlin 特性。 而是尝试把之前讲过的东西组合起来,看看 Kotlin 的语法究竟有多灵活。 比如,我们能不能完全利用 Kotlin 本身的语言特性,写出这样的代码? ```Kotlin User.select() .where { id eq 1 and username eq "admin" or email eq "a@a.a" } ``` 等等。 这怎么看着像 SQL? 实际上,这就是一段完全合法的 Kotlin 代码。 没有编译器插件,没有代码预处理,也没有专门的 SQL 语法支持。 我们甚至不需要任何第三方库。 只利用 Kotlin 本身的语言特性,就能拼出这样一套 SQL DSL。 甚至,`id` 是一个 `Int` 字段,`username` 是一个 `String` 字段,这套语法还能获得 Kotlin 类型系统的约束。 ```Kotlin id eq 1 // 正常 id eq "hello" // 编译失败 username eq "admin" // 正常 username eq 123 // 编译失败 email eq null // 正常,因为 email 是 String? ``` 看起来是不是有点意思? 那么,我们来看看,这个怪东西究竟是怎么拼出来的。 备注:本文前半部分的代码主要用于展示 Kotlin 的语言能力,并不直接构成完整的数据库查询框架。后半部分我们再讨论如何真正把它做成一个可以投入实际项目的库。 # 先从数据结构开始 ## 我们需要一张表 首先,我们需要一个普通的 Kotlin 数据类。 ```Kotlin data class User( val id: Int, val username: String, val password: String, val email: String?, ) { companion object } ``` 这就是一个很普通的 Kotlin `data class`。 假设我们希望这个类映射到数据库中的 `user` 表。 那么,我们还需要为这张表描述一些元信息。 ```Kotlin interface Column<T> interface BackTable<T> interface UserBackTable : BackTable<User> { val id: Column<Int> val username: Column<String> val password: Column<String> val email: Column<String?> } ``` 这里我们做了一件事情。 使用 Kotlin 的泛型,将数据库字段与 Kotlin 的类型系统关联起来。 `Column<Int>` 表示一个 Int 类型的字段。 `Column<String?>` 表示一个允许 null 的 String 类型字段。 这样一来,我们便可以依靠 Kotlin 自己的类型系统,在编译阶段约束字段值。 为什么需要单独设计 `Column<T>`? 因为我们想要操作的不是 `User` 实例中的那个 `id` 值,而是数据库中 `id` 这个字段本身。 这两者完全不是一回事。 `User.id` 是一个 Int 类型的值。 而 `UserBackTable.id` 是一个用于描述数据库字段的 `Column<Int>` 对象。 有了这个抽象,我们就能开始对数据库字段做一些奇怪的事情了。 # 把比较运算变成普通函数 ## eq 是哪里来的? 先来看这样一段代码。 ```Kotlin id eq 1 ``` 这很像 SQL 中的: ```SQL id = 1 ``` 但是 Kotlin 并没有 `eq` 这个运算符。 还记得之前提到的中缀函数吗? Kotlin 允许通过 `infix` 修饰符,把一个满足条件的普通成员函数或扩展函数,以中缀形式调用。 例如: ```Kotlin infix fun Int.add(other: Int) = this + other 1 add 2 ``` 那么,我们为什么不能给 `Column<T>` 定义一个 `eq` 呢? ```Kotlin infix fun <V> Column<V>.eq(value: V): WhereResult = Eq(this, value) ``` 这样就可以直接写: ```Kotlin id eq 1 ``` 实际上,这段代码与下面的代码完全等价。 ```Kotlin id.eq(1) ``` 而因为我们的 `Column` 带有泛型,`eq` 的参数 `value` 又使用了相同的泛型,所以编译器能够约束传入的值。 我们甚至没有编写一行类型检查逻辑。 只是给字段加上一个泛型,Kotlin 就帮我们完成了类型检查。 这就是类型系统的好处。 ## 那么 WhereResult 又是什么? 我们当然可以让 `eq` 直接返回一个 SQL 字符串。 例如: ```Kotlin infix fun <V> Column<V>.eq(value: V): String = "$this = $value" ``` 但这么做有很多问题。 比如 SQL 注入、参数绑定、括号优先级处理等。 更重要的是,我们后续还希望继续组合表达式。 所以,我们不直接拼接 SQL 字符串。 而是先设计一组用于描述 SQL 条件的数据结构。 ```Kotlin sealed interface WhereResult data class Eq<V>( val column: Column<V>, val value: V ) : WhereResult data class And( val left: WhereResult, val right: WhereResult ) : WhereResult data class Or( val left: WhereResult, val right: WhereResult ) : WhereResult ``` 很简单。 我们使用 `sealed interface` 定义所有 WHERE 表达式的共同类型。 然后使用 `data class` 表示不同的操作。 `Eq` 是一个比较条件。 `And` 是两个条件的逻辑与。 `Or` 是两个条件的逻辑或。 比如: ```Kotlin And( Eq(id, 1), Eq(username, "admin") ) ``` 这个对象描述的就是: ```SQL id = 1 AND username = 'admin' ``` 实际上,这已经是一棵很简单的抽象语法树(AST)了。 我们没有生成任何 SQL。 只是使用 Kotlin 的对象,描述了 SQL 的结构。 而且,`sealed` 还可以配合 `when`,让我们在处理这棵树的时候获得穷尽性检查。 还记得之前的密封类吗? 它在这里就派上用场了。 # 开始拼接表达式 ## and 和 or 既然我们已经有了 `Eq`、`And`、`Or`,那么接下来就可以实现逻辑组合了。 按照之前的思路,只需要定义两个中缀函数。 ```Kotlin infix fun WhereResult.and( right: WhereResult ): WhereResult = And(this, right) infix fun WhereResult.or( right: WhereResult ): WhereResult = Or(this, right) ``` 这样我们就能写: ```Kotlin (id eq 1) and (username eq "admin") ``` 实际上,它等价于: ```Kotlin And( Eq(id, 1), Eq(username, "admin") ) ``` 嗯,非常简单。 但是等等。 我们一开始想要的语法好像不是这样的? 我们想要的是: ```Kotlin id eq 1 and username eq "admin" ``` 而不是: ```Kotlin (id eq 1) and (username eq "admin") ``` 为什么这里非得加括号? 因为 Kotlin 的中缀函数存在一个重要的特性。 **所有中缀函数调用都具有相同的优先级,并且按照从左到右的顺序结合。** 也就是说: ```Kotlin id eq 1 and username eq "admin" ``` 并不会被理解为: ```Kotlin (id eq 1) and (username eq "admin") ``` 而是会被理解为: ```Kotlin ((id eq 1) and username) eq "admin" ``` 这就很尴尬了。 因为 `and` 后面接收到的根本不是一个 `WhereResult`。 而是一个 `Column<String>`。 但是,我们又不可能修改 Kotlin 的运算符优先级。 难道就只能乖乖写括号了吗? 当然不是。 ## 利用类型系统,实现一个未完成的表达式 仔细观察刚才的表达式。 ```Kotlin ((id eq 1) and username) eq "admin" ``` Kotlin 是先处理: ```Kotlin id eq 1 ``` 然后处理: ```Kotlin ... and username ``` 最后才处理: ```Kotlin ... eq "admin" ``` 既然如此,我们为什么不能顺着 Kotlin 的执行顺序来设计呢? 第一步,`id eq 1` 返回一个 `WhereResult`。 第二步,`WhereResult and username` 暂时不执行 AND。 而是返回一个代表「尚未完成的表达式」的对象。 第三步,等待后面的 `eq "admin"`,再把整个表达式拼接起来。 我们可以定义一个 `Pending`。 ```Kotlin enum class JoinOp { AND, OR } data class Pending<V>( val left: WhereResult, val op: JoinOp, val column: Column<V> ) ``` 这里的 `Pending<V>` 保存了三个信息。 左侧已经完成的表达式。 当前需要执行的逻辑运算。 以及右侧正在等待比较的字段。 然后,我们重新定义 `and` 和 `or`。 ```Kotlin infix fun <V> WhereResult.and( column: Column<V> ): Pending<V> = Pending(this, JoinOp.AND, column) infix fun <V> WhereResult.or( column: Column<V> ): Pending<V> = Pending(this, JoinOp.OR, column) ``` 这两个函数不再返回 `WhereResult`。 而是返回一个 `Pending<V>`。 接下来,我们再给 `Pending<V>` 提供一个 `eq`。 ```Kotlin infix fun <V> Pending<V>.eq( value: V ): WhereResult = when (op) { JoinOp.AND -> And(left, Eq(column, value)) JoinOp.OR -> Or(left, Eq(column, value)) } ``` 好了。 我们再看看这段代码。 ```Kotlin id eq 1 and username eq "admin" ``` 它实际上会按照下面的顺序执行: ```Kotlin val first = id eq 1 val pending = first and username val result = pending eq "admin" ``` 然后得到: ```Kotlin And( Eq(id, 1), Eq(username, "admin") ) ``` 成功了。 我们通过一个中间状态,成功让 Kotlin 按照我们希望的方式组合了表达式。 注意,这里并没有实现什么真正的语法解析器。 Kotlin 编译器依旧按照它自己的语法规则解析代码。 而我们只是通过不同的返回类型,以及不同接收者的扩展函数,让编译器在每个阶段调用不同的函数。 **相当于利用类型系统,手搓出了一个能够接住 SQL 语法结构的小型状态机。** 有点意思了。 # 但是,还有一个问题 ## SQL 的运算符优先级 SQL 中存在一个很重要的规则。 **AND 的优先级高于 OR。** 例如: ```SQL id = 1 OR username = 'admin' AND email = 'a@a.a' ``` 它实际上等价于: ```SQL id = 1 OR (username = 'admin' AND email = 'a@a.a') ``` 而不是: ```SQL (id = 1 OR username = 'admin') AND email = 'a@a.a' ``` 但是,Kotlin 的中缀函数没有这样的优先级区分。 如果我们直接使用上面的实现: ```Kotlin id eq 1 or username eq "admin" and email eq "a@a.a" ``` 由于 Kotlin 从左向右处理这些中缀函数,最终就会得到: ```Kotlin And( Or( Eq(id, 1), Eq(username, "admin") ), Eq(email, "a@a.a") ) ``` 这和我们期望的 SQL 语义完全不一样。 怎么办? 我们已经无法通过 Kotlin 的语法优先级处理这个问题了。 但有一个好消息。 我们现在操作的,并不是 SQL 字符串。 而是一棵 AST。 所以,我们完全可以在构建这棵树的时候,对它进行结构调整。 ## 利用 AST 修正优先级 我们可以编写这样一个函数。 ```Kotlin fun andExpr( left: WhereResult, right: WhereResult ): WhereResult = when (left) { is Or -> Or( left.left, andExpr(left.right, right) ) else -> And(left, right) } ``` 等等。 这个函数做了什么? 我们来拆开看。 假设当前有这样一棵树: ```Kotlin Or( Eq(id, 1), Eq(username, "admin") ) ``` 现在又出现了一个 AND 条件: ```Kotlin Eq(email, "a@a.a") ``` 如果直接拼接,就会得到: ```Kotlin And( Or( Eq(id, 1), Eq(username, "admin") ), Eq(email, "a@a.a") ) ``` 但是,我们希望 AND 先执行。 所以我们需要把 AND 插入到 OR 的右侧。 得到: ```Kotlin Or( Eq(id, 1), And( Eq(username, "admin"), Eq(email, "a@a.a") ) ) ``` 这就是 `andExpr` 做的事情。 如果左侧是一个 `Or`,就继续深入它的右侧,把新的 AND 条件插进去。 如果左侧不是 `Or`,就直接创建一个 `And`。 这样便能够让连续构建的表达式遵循 SQL 的 AND、OR 优先级。 然后,把之前的 `Pending.eq` 稍微修改一下。 ```Kotlin infix fun <V> Pending<V>.eq( value: V ): WhereResult = when (op) { JoinOp.AND -> andExpr(left, Eq(column, value)) JoinOp.OR -> Or(left, Eq(column, value)) } ``` 同时,直接组合两个 `WhereResult` 的 `and` 也使用它。 ```Kotlin infix fun WhereResult.and( right: WhereResult ): WhereResult = andExpr(this, right) ``` 现在再来看看: ```Kotlin id eq 1 or username eq "admin" and email eq "a@a.a" ``` 得到的便是: ```SQL id = 1 OR (username = 'admin' AND email = 'a@a.a') ``` 我们居然通过几个普通的 Kotlin 函数,解决了 SQL 与 Kotlin 之间的运算符优先级差异。 当然,这里有一个前提。 我们的 `andExpr` 会按照 SQL 的规则重新组织表达式树。 这意味着,我们需要额外考虑一种情况。 ## 如果我就是想加括号呢? 比如: ```SQL (id = 1 OR username = 'admin') AND email = 'a@a.a' ``` 我们能不能直接写: ```Kotlin (id eq 1 or username eq "admin") and (email eq "a@a.a") ``` 看起来应该可以。 但是很可惜。 不行。 因为对于 Kotlin 来说,括号只会影响当前表达式的求值顺序。 在括号内的表达式被计算完成以后,我们拿到的依旧只是一个 `Or` 对象。 而 `andExpr` 并不知道这个 `Or` 原本是不是被括号包裹起来的。 所以它还会继续重排优先级。 换句话说: **Kotlin 的括号,并不会自动成为我们自定义 AST 中的括号。** 既然这样,我们为什么不显式记录这个信息呢? ```Kotlin data class Group( val inner: WhereResult ) : WhereResult ``` 就这样。 我们增加一个代表显式分组的节点。 然后使用: ```Kotlin Group( (id eq 1) or (username eq "admin") ) and (email eq "a@a.a") ``` 此时,`andExpr` 遇到的左侧是 `Group`。 而不是 `Or`。 所以,它不会跨越这个分组继续重排。 最终得到: ```Kotlin And( Group( Or( Eq(id, 1), Eq(username, "admin") ) ), Eq(email, "a@a.a") ) ``` 这样,我们就能够正确表达: ```SQL (id = 1 OR username = 'admin') AND email = 'a@a.a' ``` 而且,这种方式还有一个额外的好处。 我们并不需要编写一个完整的 SQL 语法解析器。 Kotlin 已经帮我们完成了表达式解析。 我们只需要根据不同的类型,组合或者调整 AST。 至此,我们已经能够在 Kotlin 代码中,用几乎与 SQL 相同的方式,表达 SQL 的 WHERE 条件。 # 给这套语法一个作用域 现在,我们已经拥有了 `eq`、`and`、`or`。 但是,如果我们把这些扩展函数全部定义在顶层,它们就会在能够被导入的地方提供相应的调用能力。 有没有办法,让这些函数只在编写 WHERE 条件的时候出现? 当然有。 还记得之前提到的带接收者的 Lambda 吗? 我们可以把这些扩展函数全部定义在一个接口内部。 ```Kotlin interface WhereBuilder<T> { infix fun <V> Column<V>.eq(value: V): WhereResult = Eq(this, value) infix fun <V> WhereResult.and( column: Column<V> ): Pending<V> = Pending(this, JoinOp.AND, column) infix fun <V> WhereResult.or( column: Column<V> ): Pending<V> = Pending(this, JoinOp.OR, column) infix fun <V> Pending<V>.eq( value: V ): WhereResult = when (op) { JoinOp.AND -> andExpr(left, Eq(column, value)) JoinOp.OR -> Or(left, Eq(column, value)) } infix fun WhereResult.and( right: WhereResult ): WhereResult = andExpr(this, right) infix fun WhereResult.or( right: WhereResult ): WhereResult = Or(this, right) } ``` 然后,我们给 User 创建一个专门的 WHERE 构建器。 ```Kotlin interface UserWhereBuilder : WhereBuilder<User> { val id: Column<Int> val username: Column<String> val password: Column<String> val email: Column<String?> } ``` 到这里,我们就同时拥有了字段与操作这些字段的方法。 接下来,让 `UserQueryBuilder` 接收一个 Lambda。 ```Kotlin interface UserQueryBuilder { fun where( builder: UserWhereBuilder.() -> WhereResult ): UserQueryBuilder } ``` 重点来了。 ```Kotlin UserWhereBuilder.() -> WhereResult ``` 这个类型是什么意思? 实际上,它就是一个带接收者的 Lambda。 当我们调用 `where` 时,Lambda 内部的 `this` 就是 `UserWhereBuilder`。 因此,我们就能直接访问: ```Kotlin where { id eq 1 } ``` 而不需要写: ```Kotlin where { this.id.eq(1) } ``` 同时,`eq`、`and`、`or` 这些定义在 `WhereBuilder` 内部的扩展函数,也可以在这个作用域中直接使用。 这里其实涉及两个不同的接收者。 一个是 `UserWhereBuilder`,它作为 Lambda 的接收者,决定了当前作用域有哪些字段和函数。 另一个是 `Column<V>`、`WhereResult` 或者 `Pending<V>`,它们作为扩展函数的接收者,决定了当前可以调用哪一个中缀函数。 两个特性一组合,我们就能得到: ```Kotlin where { id eq 1 and username eq "admin" or email eq "a@a.a" } ``` 而且,`where` 的 Lambda 返回类型是 `WhereResult`。 也就是说,整个代码块最后的表达式,必须能够构成一个 WHERE 条件。 我们可以拿到这个条件对应的 AST,再进行后续的 SQL 生成。 **这就是 Kotlin DSL 的核心魅力。** 我们不是在创建一种新语言。 只是通过作用域、函数与类型系统,把 Kotlin 原本就存在的语法重新组合了一下。 # 那么,能不能真的生成 SQL? 当然可以。 不过到目前为止,我们只是完成了一套用于描述 SQL 条件的 DSL。 实际上还有很多工作没做。 例如,`Column<T>` 还没有真正的字段名称,`User.select()` 也没有返回具体的查询构建器实现。 但既然我们已经有了 AST,生成 SQL 其实就变得相当简单。 比如,我们可以把 `Column<T>` 稍微扩充一下。 ```Kotlin interface Column<T> { val name: String } ``` 然后实现一个用于将表达式转换成 SQL 的函数。 ```Kotlin fun render( expr: WhereResult, params: MutableList<Any?> ): String = when (expr) { is Eq<*> -> { if (expr.value == null) { "${expr.column.name} IS NULL" } else { params.add(expr.value) "${expr.column.name} = ?" } } is And -> "(${render(expr.left, params)} AND ${render(expr.right, params)})" is Or -> "(${render(expr.left, params)} OR ${render(expr.right, params)})" is Group -> "(${render(expr.inner, params)})" } ``` 这里我们利用了 `sealed interface` 的特性。 编译器知道 `WhereResult` 有哪些直接子类型,因此 `when` 不需要再写一个 `else`。 当我们调用 `render` 时,它会递归处理整棵 AST。 最终生成一段带有参数占位符的 SQL 条件,同时将参数保存到列表中。 例如: ```SQL ((id = ? AND username = ?) OR email = ?) ``` 参数为: ```Kotlin listOf(1, "admin", "a@a.a") ``` 注意,我们并没有直接把值拼接进 SQL。 而是将 SQL 结构和参数分开处理。 同时,对于 `email eq null`,我们还需要生成 SQL 的 `IS NULL`,而不是 `= NULL`。 因为 Kotlin 的 null 语义与 SQL 的 NULL 语义并不相同。 当然,这里还没有处理字段名转义、SQL 方言以及实际数据库驱动的参数绑定等问题。 不过,这已经足以说明,我们能够从这套 DSL 中获得一条真正的 SQL 查询条件。 # 甚至还可以继续往下拼 既然 WHERE 能这么做,那么 ORDER BY 呢? 是不是也可以? 比如: ```Kotlin interface OrderByAble { val desc: OrderByAble val asc: OrderByAble } interface UserOrderByBuilder { val id: OrderByAble val username: OrderByAble val password: OrderByAble val email: OrderByAble } ``` 我们甚至可以尝试把 SQL 的 `ASC` 和 `DESC` 变成 Kotlin 的属性。 ```Kotlin User.select() .where { id eq 1 and username eq "admin" } .orderBy { id.desc } ``` `id.desc` 看起来就像在声明一个排序规则。 而它实际上只是一次普通的属性访问。 还记得之前介绍的 Kotlin Property 吗? Property 的本质,是 Getter/Setter。 所以,我们完全可以让 `desc` 的 Getter 返回一个代表降序排序的对象。 不过,这里还有一个细节。 如果 `orderBy` 的 Lambda 返回类型是 `Unit`,单纯返回一个排序对象是无法让查询构建器获得这个对象的。 我们可以选择让 Lambda 返回一个排序描述对象。 ```Kotlin fun orderBy( builder: UserOrderByBuilder.() -> OrderByAble ): UserQueryBuilder ``` 也可以让排序操作直接修改一个共享的构建器状态。 两种实现方式都能够保持类似的外部语法。 而这套思路还可以继续扩展。 SELECT 的列选择。 JOIN 的表关联。 GROUP BY 的分组。 HAVING 的过滤。 甚至 INSERT、UPDATE、DELETE。 理论上,我们完全可以用 Kotlin 的语法特性,设计出一套具有静态类型检查能力的 SQL DSL。 但是…… # 难道这些东西还要自己写一遍? 回头看看我们前面创建的东西。 ```Kotlin interface UserBackTable : BackTable<User> { val id: Column<Int> val username: Column<String> val password: Column<String> val email: Column<String?> } interface UserWhereBuilder : WhereBuilder<User> { val id: Column<Int> val username: Column<String> val password: Column<String> val email: Column<String?> } interface UserOrderByBuilder { val id: OrderByAble val username: OrderByAble val password: OrderByAble val email: OrderByAble } ``` 这还只是一张表。 假设有一百张表呢? 每张表都需要自己声明一遍字段? 每个字段都需要手动指定类型? 那这样的 DSL 就算语法再漂亮,也没多少实际价值。 因为我们只是把原本需要编写的 SQL,变成了需要编写的一大堆 Kotlin 接口。 很显然,这不是我们想要的结果。 不过,细心的读者可能已经发现了。 这些接口中的字段,其实早就存在了。 就在我们的 `data class` 里面。 ```Kotlin data class User( val id: Int, val username: String, val password: String, val email: String?, ) ``` `User` 已经包含了字段名称、字段类型,以及字段是否允许为 null。 那么,我们为什么还要再手动描述一次呢? ## KSP:既然都是重复代码,那就让编译器帮我们写 还记得前面介绍 Kotlin 核心特性时提到的一个规律吗? Kotlin 很多看起来非常方便的功能,本质上都是编译器替我们生成了那些本来需要手写的代码。 那这次,我们为什么不自己借助编译期代码生成呢? 这里就可以使用 KSP。 KSP,全称 Kotlin Symbol Processing。 它允许我们在编译阶段读取 Kotlin 源代码中的符号信息。 例如类、属性、类型、注解等。 然后根据这些信息生成新的 Kotlin 源代码。 注意,KSP 并不是直接修改已有的 Kotlin 类。 也不是让我们随意改写 Kotlin 的语法树。 它所做的事情,是读取编译期能够获得的符号信息,然后额外生成源文件,让 Kotlin 编译器继续编译这些生成的代码。 也就是说,我们完全可以把之前那些手写的接口,交给 KSP 自动生成。 比如,我们给 User 添加一个注解。 ```Kotlin @Target(AnnotationTarget.CLASS) annotation class DbTable(val name: String) @Target(AnnotationTarget.PROPERTY) annotation class DbColumn(val name: String) ``` 然后定义: ```Kotlin @DbTable("users") data class User( @DbColumn("id") val id: Int, @DbColumn("username") val username: String, @DbColumn("password") val password: String, @DbColumn("email") val email: String?, ) { companion object } ``` 当然,如果字段名称与数据库中的名称一致,`DbColumn` 也完全可以省略。 KSP 处理器只需要找到所有被 `@DbTable` 标记的类。 读取它们的属性。 获取属性的名称与 Kotlin 类型。 再根据这些信息生成对应的 DSL 符号即可。 例如,我们可以自动生成: ```Kotlin // 以下代码由 KSP 自动生成 interface UserBackTable : BackTable<User> { val id: Column<Int> val username: Column<String> val password: Column<String> val email: Column<String?> } interface UserWhereBuilder : WhereBuilder<User> { val id: Column<Int> val username: Column<String> val password: Column<String> val email: Column<String?> } ``` 除此之外,当然还可以生成 `UserOrderByBuilder`,以及对应的实际实现。 甚至连 `User.select()` 也不需要手写。 因为 Kotlin 支持扩展函数。 KSP 可以生成一个针对 `User.Companion` 的扩展函数。 ```Kotlin // 由 KSP 生成,具体实现省略 fun User.Companion.select(): UserQueryBuilder = createUserQueryBuilder() ``` 这里为什么要给 `User` 声明一个空的 `companion object`? 因为 KSP 无法直接向已有的 `User` 类中添加成员。 但是,只要 `User` 存在一个 Companion Object,我们就能通过生成扩展函数,让调用端获得几乎相同的效果。 最终,我们的业务代码只需要声明: ```Kotlin @DbTable("users") data class User( val id: Int, val username: String, val password: String, val email: String?, ) { companion object } ``` 然后就可以使用: ```Kotlin User.select() .where { id eq 1 and username eq "admin" } ``` 字段类型依旧受到 Kotlin 编译器的检查。 字段名称与数据库映射关系,也已经由 KSP 在编译时确定。 没有运行时反射。 没有运行时扫描所有实体类。 甚至不需要在运行时分析 `User` 的属性。 因为那些信息早就被 KSP 转换成了实际存在的 Kotlin 代码。 这就是编译期代码生成的好处。 我们不需要为了让使用者少写代码,而把复杂性转移到运行时。 **完全可以让开发者享受 DSL 的便利,同时把大量重复劳动交给编译期完成。** 不过,这里还有另外一个问题。 # 那这一坨对象的开销怎么办? 现在,我们已经解决了手动编写符号的问题。 但是回头看看之前实现的 AST。 ```Kotlin data class Eq<V>( val column: Column<V>, val value: V ) : WhereResult data class And( val left: WhereResult, val right: WhereResult ) : WhereResult data class Or( val left: WhereResult, val right: WhereResult ) : WhereResult ``` 再看看这个 `Pending`。 ```Kotlin data class Pending<V>( val left: WhereResult, val op: JoinOp, val column: Column<V> ) ``` 是不是突然发现了什么? 我们每调用一次 `eq`,就可能创建一个 `Eq` 对象。 每调用一次 `and`、`or`,就会继续创建新的表达式节点。 中途还需要创建 `Pending`。 一条稍微复杂一点的查询,就可能在堆上产生一大堆短命对象。 然后,我们还需要遍历这些对象,将它们转换成 SQL。 最后,这些对象又会被 GC 回收。 这就有点尴尬了。 我们明明只是希望生成一条 SQL,为什么还需要创建这么多中间对象? ## JVM JIT 会不会帮我们优化? 当然会。 而且 JVM 的 JIT 优化能力实际上相当强大。 对于这种生命周期非常短的对象,JIT 可以通过逃逸分析(Escape Analysis)判断对象是否真的需要被分配到堆上。 在满足条件时,还可能通过标量替换(Scalar Replacement),直接把对象拆解成局部的值。 比如: ```Kotlin data class Point( val x: Int, val y: Int ) fun sum(): Int { val point = Point(1, 2) return point.x + point.y } ``` 从 Kotlin 源代码来看,我们创建了一个 `Point` 对象。 但是对于经过充分优化的 JVM 代码来说,这个对象并不一定真的需要被分配出来。 最终,它完全有可能被优化成类似: ```Java public static int sum() { return 1 + 2; } ``` 当然,这只是用于说明优化效果的伪代码。 具体是否能够消除分配,要取决于 JIT 的实现、对象逃逸情况以及实际执行路径。 特别是我们之前的 AST。 它涉及接口、多态、递归,以及多个对象之间的引用关系。 JIT 能够优化其中一部分开销,但并不代表它一定能够把所有对象都消除掉。 况且,JIT 还需要预热。 我们当然可以相信 JVM 的优化能力。 但如果某些对象从设计上就没有存在的必要,为什么一定要先创建它们,然后再指望 JVM 把它们优化掉呢? ## Kotlin 的 @JvmInline 还记得之前介绍的 `inline` 吗? 实际上,Kotlin 不仅可以内联函数。 还能够定义内联值类。 ```Kotlin @JvmInline value class UserId(val value: Int) ``` 与普通类不同,Value Class 可以在 JVM 上直接使用其底层类型表示。 例如: ```Kotlin fun getUser(id: UserId) { println(id.value) } ``` 在无需装箱的情况下,它对应的 JVM 方法可以近似理解为: ```Java public static void getUser(int id) { System.out.println(id); } ``` 也就是说,虽然我们在 Kotlin 的类型系统中获得了一个全新的 `UserId` 类型,但它并不一定需要对应一个实际的 Java 对象。 我们既获得了类型安全,又避免了单纯为了包装一个 Int 而产生的对象分配。 当然,`@JvmInline` 不是任何时候都能保证不装箱。 当 Value Class 被当作接口类型、泛型参数,或者可空的包装类型使用时,仍然可能需要装箱。 但至少,它让我们拥有了一个能够在很多场景下减少包装对象开销的工具。 那么,我们为什么不利用它来重新设计刚才的 DSL 呢? # 不创建 AST,能不能直接构建 SQL? 仔细想想。 我们为什么要创建 `Eq`、`And`、`Or`? 一开始,是因为需要保存表达式结构。 特别是需要处理 SQL 的运算符优先级。 但是,如果我们只是希望把一条查询转换成 SQL,实际上并不一定需要把整棵 AST 保留下来。 例如: ```Kotlin id eq 1 or username eq "admin" and email eq "a@a.a" ``` 我们的目标本来就是: ```SQL id = ? OR username = ? AND email = ? ``` 注意。 **SQL 自己就有 AND 高于 OR 的优先级规则。** 既然最终是由 SQL 引擎解析这段表达式,那么我们完全可以直接按照 Kotlin 从左到右的求值顺序,将条件写入 SQL 构建器。 这样,之前的 `andExpr` 重排过程,就不再是必须的了。 而 `Pending` 也不一定真的需要保存左侧完整的表达式树。 因为左侧表达式早就已经被写入构建器了。 它只需要知道下一次比较应该使用哪个字段。 这就可以让我们换一种设计方式。 **不再为每一个表达式节点创建对象,而是让所有操作共同修改一个 Builder。** ## 一个真正保存状态的 Builder 首先,我们需要一个负责保存 SQL 的构建器。 这里先给出一个简化版本。 ```Kotlin private val USER_COLUMNS = arrayOf( "id", "username", "password", "email" ) class SqlSink(table: String) { private val sql = StringBuilder("SELECT * FROM ") .append(table) private val params = ArrayList<Any?>() fun beginWhere() { sql.append(" WHERE ") } fun eq(column: Int, value: Any?) { sql.append(USER_COLUMNS<div class="flex-column"></div>) if (value == null) { sql.append(" IS NULL") } else { sql.append(" = ?") params.add(value) } } fun op(operator: String) { sql.append(' ') .append(operator) .append(' ') } fun openGroup() { sql.append('(') } fun closeGroup() { sql.append(')') } fun build(): Pair<String, List<Any?>> = sql.toString() to params.toList() } ``` 这里,真正保存查询状态的对象只有一个 `SqlSink`。 当然,内部的 `StringBuilder` 和 `ArrayList` 仍然需要存储空间。 但这些是实际构建 SQL 和参数列表所需要的数据。 我们不再需要为每个 `Eq`、`And`、`Or` 构建一个独立的对象节点。 而且,字段名称直接通过整数索引进行查找。 这样,我们也不需要每次访问字段时,都新建一个用于保存字段名称的对象。 上面的 `USER_COLUMNS` 也可以由 KSP 自动生成,作为可复用的表元信息。 接下来,我们重新设计之前的 `Column` 和 `Pending`。 ## 让中间状态变成 Value Class ```Kotlin @JvmInline value class Column<V>(val index: Int) @JvmInline value class Expr(val marker: Int) @JvmInline value class Pending<V>(val column: Int) ``` 等等。 这次的 `Pending` 怎么只剩下一个 Int 了? 之前我们不是还需要保存左侧表达式和运算符吗? 原因很简单。 我们已经不再构建 AST 了。 左侧表达式已经被直接写入 `SqlSink`。 运算符也已经被写入 `SqlSink`。 所以,`Pending<V>` 只需要记录正在等待 `eq` 的字段索引。 而 `Expr` 呢? 它甚至不需要保存任何有意义的数据。 我们定义它,只是为了在 Kotlin 的类型系统中表示「一个完整的条件已经构建完毕」。 例如: ```Kotlin @JvmInline value class Expr(val marker: Int) ``` 这里的 `marker` 可以始终为 0。 它的作用不是提供运行时数据。 而是让编译器知道: 这个表达式可以继续调用 `and` 或 `or`。 而 `Pending<V>` 则只能继续完成 `eq`。 这就是我们前面所说的,利用类型系统描述表达式状态。 区别在于,这次我们不再需要为每个状态分配一个独立的堆对象。 对于能够采用底层类型表示的调用路径,JVM 甚至可以只传递一个普通的 Int。 ## 重新实现 WHERE DSL 接下来,我们创建一个 WHERE 作用域。 ```Kotlin @JvmInline value class UserWhereScope(val sink: SqlSink) { val id: Column<Int> get() = Column(0) val username: Column<String> get() = Column(1) val password: Column<String> get() = Column(2) val email: Column<String?> get() = Column(3) infix fun <V> Column<V>.eq(value: V): Expr { sink.eq(index, value) return Expr(0) } infix fun <V> Expr.and( column: Column<V> ): Pending<V> { sink.op("AND") return Pending(column.index) } infix fun <V> Expr.or( column: Column<V> ): Pending<V> { sink.op("OR") return Pending(column.index) } infix fun <V> Pending<V>.eq(value: V): Expr { sink.eq(column, value) return Expr(0) } inline fun group( block: UserWhereScope.() -> Expr ): Expr { sink.openGroup() UserWhereScope(sink).block() sink.closeGroup() return Expr(0) } } ``` 我们做了什么? 实际上,外部的使用方式几乎没有变化。 但内部的执行逻辑已经完全不同了。 ```Kotlin id eq 1 and username eq "admin" ``` 首先,`id eq 1` 将 `id = ?` 写入 `SqlSink`。 然后返回一个 `Expr(0)`。 接着,`Expr.and(username)` 将 `AND` 写入 `SqlSink`,再返回一个包含 username 字段索引的 `Pending`。 最后,`Pending.eq("admin")` 继续写入 `username = ?`。 整个过程并不需要生成任何 AST 节点。 而且,因为 `Column`、`Expr`、`Pending` 都是 Value Class,在不触发装箱的调用路径下,它们可以直接使用底层的 Int 表示。 我们甚至连原来需要递归遍历的 `andExpr` 都不再需要了。 因为 SQL 本来就知道怎么处理 AND 和 OR 的优先级。 ## 那括号怎么办? 之前我们已经发现,Kotlin 的括号并不会自动成为 SQL 中的括号。 在 AST 版本中,我们通过 `Group` 节点解决了这个问题。 那么,既然这次不再保存 AST,该怎么办? 其实更加简单。 既然我们正在直接生成 SQL,那就直接向构建器写入括号。 ```Kotlin inline fun group( block: UserWhereScope.() -> Expr ): Expr { sink.openGroup() UserWhereScope(sink).block() sink.closeGroup() return Expr(0) } ``` 于是,我们就可以写: ```Kotlin group { id eq 1 or username eq "admin" } and email eq "a@a.a" ``` 最终得到: ```SQL (id = ? OR username = ?) AND email = ? ``` 没有 AST。 也没有递归重排。 只是往同一个 `StringBuilder` 中依次写入字符。 这里当然还有进一步的设计空间,例如通过专门的 `andGroup`、`orGroup` 函数处理右侧嵌套分组。 但基本思路已经非常清晰了。 通过显式的 DSL 分组,控制 SQL 的括号结构。 ## 最后,把 QueryBuilder 也包装一下 既然我们已经选择了 Value Class,那么 QueryBuilder 当然也可以使用同样的方式。 ```Kotlin @JvmInline value class UserQueryBuilder(val sink: SqlSink) { inline fun where( block: UserWhereScope.() -> Expr ): UserQueryBuilder { sink.beginWhere() UserWhereScope(sink).block() return this } fun build(): Pair<String, List<Any?>> = sink.build() } ``` 这里还有一个熟悉的东西。 `inline`。 因为 `where` 是一个接收 Lambda 的高阶函数。 如果我们希望尽可能减少运行时开销,就可以让编译器直接把 Lambda 的执行逻辑内联到调用处。 于是,我们便可以同时利用: - `@JvmInline value class`,减少 DSL 中间状态的包装对象开销。 - `inline fun`,减少高阶函数调用和 Lambda 对象的开销。 - 单一 `SqlSink`,避免为了描述每个条件而创建一棵独立的 AST。 - KSP,在编译阶段生成表结构与字段映射信息。 最后,给 `User.Companion` 提供一个 `select` 扩展。 ```Kotlin fun User.Companion.select(): UserQueryBuilder = UserQueryBuilder(SqlSink("users")) ``` 在实际框架中,这个函数也可以由 KSP 自动生成。 现在,我们再调用: ```Kotlin val query = User.select() .where { id eq 1 and username eq "admin" or email eq "a@a.a" } println(query.build()) ``` 就会得到: ```SQL SELECT * FROM users WHERE id = ? AND username = ? OR email = ? ``` 参数: ```Kotlin [1, "admin", "a@a.a"] ``` 我们已经得到了一个能够真正生成 SQL 的最小实现。 而且,这次的核心查询构建过程,不需要创建 `Eq`、`And`、`Or`、`Group` 这一大堆 AST 节点。 当然,这个例子仍然只是最小原型,省略了很多生产环境中必须处理的细节。 但它至少证明了一件事情。 **我们完全可以保持 DSL 外部语法的丝滑,同时在内部使用一种截然不同、更加轻量的执行方式。** # 这是不是就意味着零开销了? 还不能这么说。 因为 `@JvmInline` 虽然可以避免很多包装对象的分配,但并不保证任何情况下都不装箱。 特别是将 Value Class 作为接口、泛型或者可空类型使用时,Kotlin 编译器可能需要生成包装对象。 而且,我们的 `SqlSink` 仍然需要创建 SQL 字符串、保存查询参数。 上面的 `Any?` 参数也可能导致 Int 等基本类型装箱。 这些开销并不会因为使用了 `@JvmInline` 就凭空消失。 但是,我们已经能够消除原本为了表达 DSL 语法而引入的大量 AST 节点对象。 这是两回事。 另外,单 Builder 的设计也并非没有代价。 之前的 AST 是一套可以独立保存、重复组合和转换的表达式结构。 而现在的 `Expr` 只代表某一次写入操作已经完成。 它不能再被当成一棵独立的表达式树,随意保存、复用或者重排。 例如,如果我们希望提前构建一个条件,再将它组合到多个不同查询中,简单的流式写入方案就不再适合了。 而且,我们也不能再随意提供一个接收两个已经完成的 `Expr` 的 `and` 函数。 因为右侧表达式可能早就已经写入同一个 Builder,求值顺序也就不再受这个 `and` 函数控制。 因此,如果希望支持任意嵌套、条件复用和更复杂的表达式组合,还需要进一步设计。 可以继续保留 AST。 也可以通过单一 Builder 内部的索引、数组或其他紧凑数据结构保存表达式关系。 这样仍然有机会避免为每个表达式节点创建一个独立的 Java 对象。 究竟选择哪一种方案,要取决于这个 DSL 需要支持什么能力。 不过,如果我们的目标只是生成 SQL,而不是把 AST 暴露给使用者,那么轻量化的流式构建方式就非常值得考虑。 它甚至可能比依赖 JIT 去消除 AST 节点更加直接。 当然,实际性能是否更好,还是应该使用 JMH 等工具进行基准测试。 毕竟,**没有经过测量的零开销,只能算是一种设计目标。** ## 真正投入生产,还需要什么? 到这里,我们已经有了一套具有实际使用潜力的 SQL DSL。 但要将它称为一个生产级数据库框架,还需要继续处理很多问题。 例如,正确的 SQL 标识符转义、不同数据库方言、参数绑定、事务、连接管理、复杂 JOIN、类型映射、异常处理,以及 Builder 的生命周期和线程安全。 还有一个非常重要的问题。 如果我们使用一个可变的 `SqlSink` 保存查询状态,就必须明确它只能在什么范围内被使用。 它不能毫无限制地在多个线程或者多个查询之间共享。 对于这种可变状态构建器,通常应当将其生命周期限制在一次查询构建过程内。 并且,在构建结束以后生成不可变的 SQL 描述对象,交给后续数据库驱动执行。 另外,KSP 也无法凭空保证 Kotlin 数据类与真实数据库表结构永远保持一致。 如果数据库表结构由迁移脚本维护,就需要考虑如何检查两者的一致性。 这些都是框架设计需要解决的实际问题。 但它们已经不再是 Kotlin DSL 能不能做到的问题了。 而是一个数据库框架应该如何设计的问题。 从语言能力的角度来说,我们已经拿到了构建它所需要的大部分工具。 而且,在编译期生成符号、静态类型检查以及减少中间对象分配这几个方向上,Kotlin 都提供了非常不错的基础。 # 总结 回头看看,我们今天都用了哪些 Kotlin 特性? 实际上,没有任何一个新特性。 `Column<T>` 利用泛型,将字段类型交给 Kotlin 编译器检查。 `eq`、`and`、`or` 利用中缀函数,让普通函数调用看起来像 SQL 运算符。 `Pending<T>` 利用不同的返回类型,让一段原本不符合我们预期分组方式的中缀表达式,能够按照 Kotlin 自己的求值顺序逐步完成。 `WhereResult` 利用密封接口和数据类,构建出一棵 SQL 条件的 AST。 `andExpr` 利用表达式与递归,对这棵 AST 进行重排,弥补 Kotlin 中缀函数与 SQL 运算符之间的优先级差异。 然后,通过带接收者的 Lambda,让字段与操作函数自然地进入 `where` 的作用域。 到了这里,我们已经拼出了一套看起来非常像 SQL 的 Kotlin DSL。 但是,这还不够。 为了真正把它用到项目里,我们又引入了 KSP。 通过编译期符号处理,自动生成那些原本需要手动维护的数据库字段描述、类型接口,以及查询入口。 最后,我们又利用 `@JvmInline value class`、`inline fun` 和单一 Builder,将原本需要构建的大量中间 AST 对象,替换成了尽可能轻量的查询构建过程。 仔细想想,其实 Kotlin 在整个过程中都没有使用什么特别神奇的运行时魔法。 中缀函数就是普通的函数调用。 扩展函数就是静态函数与参数映射。 带接收者的 Lambda,也只是另一种形式的高阶函数。 Value Class 通过编译器生成合适的底层表示,减少不必要的包装。 KSP 则把重复的代码生成工作,直接放到了编译阶段。 所有这些东西,单独拿出来其实都没有什么特别的地方。 但是,当我们把它们组合到一起,就得到了这样一段代码。 ```Kotlin User.select() .where { id eq 1 and username eq "admin" or email eq "a@a.a" } .orderBy { id.desc } ``` 这不是 SQL。 也没有任何特殊的 SQL 语法支持。 甚至,Kotlin 编译器根本不知道我们正在编写一条数据库查询。 在编译器眼里,这只是一组普通的函数调用、属性访问和 Lambda。 但对开发者来说,它已经变成了一套用于描述数据库查询的小型语言。 而且,我们不仅能够让这套语言看起来足够漂亮。 还能够通过 KSP,把大量工作提前到编译阶段。 再借助 Kotlin 的内联机制,让漂亮的语法尽可能不需要支付额外的运行时成本。 当然,能够拼出来,并不代表任何场景下都应该这么写。 像 `Pending` 这种为了适应 Kotlin 中缀函数语法而额外设计的结构,本身也增加了理解成本。 特别是 Kotlin 的括号无法自动保留为 DSL 的显式分组这一点,就足以说明过度追求语法上的丝滑,也可能制造新的坑。 DSL 的最终目标,应该是让使用者更容易表达业务,而不是让实现者炫耀语言技巧。 但这并不妨碍我们拿它来做一些有意思的实验。 因为 Kotlin 真正令人喜欢的地方,并不只是它比 Java 少写了多少行代码。 而是它给了开发者足够多的基础能力,让我们能够通过这些能力的组合,创造出新的表达方式。 甚至在追求表达能力的同时,还能够保留对运行时性能进行精细控制的空间。 **Kotlin 并没有为 SQL 提供特殊支持。** **它只是足够灵活,让我们自己拼出了一个 SQL。** **而且,我们甚至可以让这套看起来十分复杂的语法,最终只围绕一个简单的 Builder 运转。** 这大概也是 Kotlin 缘何如此丝滑的另一个答案。 最后修改:2026 年 11 月 08 日 © 允许规范转载 赞 1 如果觉得我的文章对你有用,请随意赞赏