携程机票跨端 Kotlin DSL 数据库框架 SQLlin

开发 新闻
SQLlin 目前已经在 Github 开源。

作者简介

禹昂,携程机票移动端资深工程师,专注于 Kotlin 移动端跨平台领域,Kotlin 中文社区核心成员,图书《Kotlin 编程实践》译者。

一、背景

2022年9月 Kotlin 1.7.20 发布之后,Kotlin Multiplatform Mobile(简称KMM)进入 Beta 阶段,Kotlin/Native new memory management 也变更为默认启用状态。无论从多端统一性还是性能上来看,Kotlin Multiplatform 都进入了下一个里程碑阶段。

携程机票移动端团队在2021年介绍过 KMM 技术在机票产线的落地情况(参考链接 1),2022 年年中开源了团队首个 KMM 项目—— MMKV-Kotlin(参考链接 2),并撰文(参考链接 3)详述 MMKV-Kotlin 的研发过程和一些常见问题。目前继续在 Kotlin Multiplatform 开源领域发力,打造出了基于 DSL 及 Kotlin Symbol Processor(KSP)开发的 SQLite 框架—— SQLlin。

二、需求调研

2.1 为什么要使用 SQLite 框架?

在移动端开发领域,在对 CRUD 操作有着复杂需求的数据存取场景上,SQLite 一直是首选方案。它同时内置于 Android 与 iOS 系统框架中,开发者无需增加额外的包大小。在数据的增删查改上它支持绝大部分 SQL 语法,功能足够强大。SQLite 本身是 C 语言库,虽然官方为它打造了多种语言及开发环境的 wrapper,但目前还不直接支持 Kotlin Multiplatform。因此,寻找或开发一款支持 Kotlin Multiplatform 的 SQLite 框架是我们的必选项。

但同时我们也注意到,SQLite 框架本身的意义并不仅仅在于扩展其支持的技术栈。例如,在 Android 开发中,我们有 Android Framework SQLite Java API,但是开发者们通常会在项目中使用 Jetpack Room 来操作数据库。在 iOS 开发中,开发者可以直接调用 SQLite C API,但是大家也仍然倾向于选择类似 FMDB 这样的框架。原因主要在于以下三点:

(1)SQLite 的原始 API 颗粒度较细,直接在业务代码中使用较为繁琐且容易出错。

(2)SQL 语句以字符串的形式存在于代码中,不受编译器检查。

(3)SQLite 不支持直接存取对象,将基本数据类型与对象进行转换需要编写大量样板代码。

我们期待我们未来使用的 SQLite 框架在支持 Kotlin Multiplatform 的同时可以解决掉以上三个痛点问题。

2.2 开源方案调研

在开发一个项目之前,我们通常会在开源社区寻找成熟的解决方案,如果可以完全契合我们的需求则没有必要重复造轮子。但如果我们调研的项目不完全符合我们的预期,则仍然可以学习其设计思想,为我们自己的设计与研发提供思路与参考。

2.2.1 Jetpack Room

Jetpack Room(参考链接 4)是 Google 官方提供的 SQLite 框架,最初用 Java 打造,并非专为 Kotlin 而生。它仅能用于 Android 开发,暂不支持 Kotlin Multiplatform,因此不符合我们的期望,但我们可以参考它的 API 设计:


@Entity
data class User(
@PrimaryKey val uid: Int,
@ColumnInfo(name = "first_name") val firstName: String?,
@ColumnInfo(name = "last_name") val lastName: String?
)

@Dao
interface UserDao {
@Query("SELECT * FROM user")
fun getAll(): List<User>

@Query("SELECT * FROM user WHERE uid IN (:userIds)")
fun loadAllByIds(userIds: IntArray): List<User>

@Query("SELECT * FROM user WHERE first_name LIKE :first AND " +
"last_name LIKE :last LIMIT 1")
fun findByName(first: String, last: String): User

@Insert
fun insertAll(vararg users: User)

@Delete
fun delete(user: User)
}

@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}

它的 API 采用 DAO(Data Access objects)思想,它可以自动完成对象到 SQL 语句的序列化与查询结果 Cursor 到对象的反序列化。开发者只需要定义 DAO 的 interface,并用它提供的注解描述需要操作的对象即可。Room 采用 APT/KAPT(目前正在向 KSP 迁移)对注解进行处理并生成代码,可以避免用户手动编写大量样板代码。用户在使用 Room 时仅需要通过 DAO set/get 对象即可。

不过它也有一些问题。例如:查询操作与按条件的更新和删除操作,用户仍然需要编写 SQL 语句,这些 SQL 语句虽然 Android Studio 提供了高亮,但是仍然是以字符串的形式存在,不受编译器静态类型检查。

2.2.2 Exposed

Kotlin在正式发布时有一个主力卖点就是可以用来构建开发者自己的DSL。Exposed(参考链接 5)是当时官方宣传DSL的范例项目之一。Exposed主要场景是 JVM 后端,它使用 JDBC 可以连接多种数据库,包括:MySQL、Oracle、MariaDB、SQLite 等等。从场景上看 Exposed 也不符合我们的预期,但是我们仍然可以看一下它的 API 设计:


object Users : Table() {
val id = varchar("id", 10) // Column<String>
val name = varchar("name", length = 50) // Column<String>
val cityId = (integer("city_id") references Cities.id).nullable() // Column<Int?>

override val primaryKey = PrimaryKey(id, name = "PK_User_ID") // name is optional here
}

fun main() {
Database.connect("jdbc:h2:mem:test", driver = "org.h2.Driver", user = "root", password = "")
transaction {
addLogger(StdOutSqlLogger)
Users.insert {
it[id] = "andrey"
it[name] = "Andrey"
it[Users.cityId] = saintPetersburgId
}
Users.update({ Users.id eq "alex"}) {
it[name] = "Alexey"
}
Users.deleteWhere{ Users.name like "%thing"}
for (city in Cities.selectAll()) {
println("${city[Cities.id]}: ${city[Cities.name]}")
}
}

用户需要自己定义一个表示数据库表的对象,继承自 Table,然后手动编写代码,使用属性表示表中的列。在进行 CURD 的 SQL 构建时通过调用不同的 Table 成员函数,然后使用类似键值对 get/set 的方式完成 SQL 子句(clause)的构建。

以当年的角度来看,Exposed 的 API 算是相当惊艳。但以今天的眼光来看,我认为 Exposed的 API 有如下不足:

(1)数据库不支持序列化与反序列化为对象,实际上的编程体验仍然像在操作一个 Map。

(2)用户需要手动定义 Table,需要编写大量样板代码。

(3)API 设计与 SQL 语句本身差异较大,部分 API 接收多个 lambda 表达式作为参数,看起来括号嵌套层级多,不够优雅。

但总的的来说 Exposed 的设计思路的方向非常棒,使用 Kotlin 语法构建自己的 DSL API,对用户来说使用方便,且只要充分利用其潜力,API 也能设计的非常优雅。

2.2.3 SQLDelight

SQLDelight(参考链接 6)由 Android 界的开源先锋 Square 开发,是我们目前调研过的最先进的 Kotlin 数据库框架。它支持 Kotlin Multiplatform,除了 Android、iOS 这样的移动端平台,还通过 Kotlin/Native 直接支持 macOS、Linux 以及 Windows 等桌面端平台,除此之外也有对 JavaScrip 以及 JVM 开发环境的支持。在所有平台上 SQLDelight 都支持 SQLite,但在 JVM 平台上还额外支持使用 JDBC 连接各种主流的服务端数据库。因此 SQLDelight 是一个能满足多种开发环境,多种技术栈的数据库框架。

在 API 的设计上,SQLDelight 更是一骑绝尘,它使用 Kotlin 官方尚未正式发布的技术 Kotlin Compiler Plugin(后简称 KCP)来构建 API。用户只需要在一个特殊的 .sq 文件中编写自己的 SQL 语句,并给 SQL 语句起一个名字,KCP 就可以在工程编译构建时对 SQL 语句进行语法检查及静态类型校验,并生成一个函数。用户直接在 Kotlin 代码中调用该函数即可完成 CRUD 操作。SQLDelight 示例代码如下图所示:

图片

看上去 SQLDelight 完美适合我们的场景。但实际上我们对 SQLDelight 的调研非常早,那时它会在 iOS 上带来过大的 size 增长。携程 app 是一个多功能聚合类 app,而机票又只是其中一个团队, 因此在 size 的增长上会较为敏感。在近期我的调研中,在 x86 架构下 SQLDelight 带来的包 size 增长为 200 kb,比之前有所改善。如果你准备从 0 打造一个 KMM app 或者你是某项目的基础架构团队的成员,我非常建议你尝试 SQLDelight。

此外在 API 上,虽然使用 KCP 帮助开发者生成大量代码非常惊艳,但是 SQLDelight 配置较为繁琐,使用方式的学习成本也较高,便利性上做不到开箱即用。因此许多开发者对其也持有一些不同的观点。

2.3 需求确定

我们调研过的库与框架并不只有以上三款,在经过充分的对比后,我们决定仍然自己研发一款符合我们需求的 SQLite 框架,在取长补短与权衡利弊之后,我们认为它应该具有以下特性:

(1)支持 KMM(即至少支持 Android、iOS 两个平台)。

(2)SQL 语句必须可以在某种程度上受编译器检查。

(3)支持直接将对象序列化为 SQL 语句(例如 UPDATE 语句中的 SET 子句),且支持将查询结果反序列化为 Kotlin 对象。

(4)Size 不能过大。

三、 基本设计与实现

3.1 架构设计与 module 划分

在一个项目开发之前,我们首先需要做的是将项目的基本功能理清,然后进行适当的 module 划分:

图片

无论是 iOS 还是 Android,最底层调用的都是 SQLite C 库。再往上是应用程序层,iOS 应用层可以直接调用 SQLite C API,但是在 Android 上,由于应用层的代码运行在 ART 虚拟机上,因此我们需要通过 Android Framework 提供的 Java API 对 SQLite 进行操作。

再往上就到了 KMM common 层,我们希望 DSL API 的实现应该是完全平台无关的, 因此我们需要 sqllin-dsl 的下层提供了一个叫做 sqllin-driver 的模块,它在不同的平台上提供不同的具体实现,但在 common source set 中提供了一层平台无关的 lower-level SQLite API 供 sqllin-dsl 层使用。

3.2 Driver 层的技术选型与实现

sqllin-driver 在 common source set 中提供了一套通用的 API,但其在不同平台的 source set 中需要采取不同的实现方式。在上面的架构中设计中,在 iOS source set 中可以直接调用 SQLite C API,而在 Android source set 中我们可以使用 Android Framework SQLite Java API。

使用 Android Framework SQLite Java API 有个问题,在 Android P 以下的版本上有众多的 SQLite 参数配置都不支持,比如:日志模式、同步模式、lookaside、内存数据库等等。如果要在低版本的 Android 系统上支持这些参数配置,我们需要自行编写 JNI 代码,实现一套 JVM 层的 SQLite API。

但是 Google 在 Android N 以上的版本中禁止在 NDK 开发中直接访问系统内置的 SQLite,如果坚持这么做,开发者必须自己重新打一份 SQLite 到自己的 apk 中,这不仅会增加一部分无谓的包大小,还会让这个项目变得过于复杂。所以最终我们仍然决定基于 Android Framework 来实现,不支持对低版本 Android 系统的 SQLite 多种个性化配置。

在 iOS 端的实现上我们也碰到了一些问题,虽然 Kotlin/Native 与 C 语言的互操作很完善,但是也非常繁琐,比如我们在 Kotlin/Native 上做一次 open database 的操作:

fun openDatabase() = memScoped {
val dbPtr = alloc<CPointerVar<sqlite3>>()
val openResult = sqlite3_open_v2(path, dbPtr.ptr, sqliteFlags, null)
if (openResult != SQLITE_OK) {
throw IllegalStateExcepetion(sqlite3_errmsg(dbPtr.value)?.toKString() ?: “”)
 }
}

由于 C 语言独有的运行时内存的特性以及其自身的概念,我们需要使用一些繁琐的 API 来完成对 C 的调用,比如上面示例中的:memScoped、alloc、ptr、toKString 等等。这导致这一块的开发工作量大幅上升。

但好在我们在开源社区找到了解决方案—— SQLiter。SQLiter 是 TouchLab 的开源项目,它的作用在于使用 Kotlin 实现多个 Native 平台统一的 SQLite lower-level API,它的 API 设计与 Android Framework SQLite Java API 有些相似,但又融合了许多 Kotlin 的语法特性。它不仅仅支持 iOS,还支持 macOS、tvOS、watchOS、Linux、Windows 等多个操作系统,抹平了包括线程锁在内的多端差异。它同时也是 SQLDelight 在 Kotlin/Native 上的底层引擎。使用 SQLiter 可以把 Kotlin-C 之间的互操作转化为 Kotlin 语言内的互相调用,大大节约开发成本。并且我们也能通过 SQLiter 的多平台支持能力,扩展到除 iOS 外的多个 Native 平台。

只要两个平台都可以完成对 SQLite 的操作,开发 common 层的通用 API 只需要声明 expect API,然后在各平台 source set 的 actual 实现中直接调用这些平台特有的实现即可,比如说还是以 open 数据库为例,我们在 common source set 中声明:

public expect fun openDatabase(config: DatabaseConfiguration): DatabaseConnection

在 Android source set 中可以这样实现:

import android.database.sqlite.SQLiteDatabase

public actual fun openDatabase(config: DatabaseConfiguration): DatabaseConnection {
SQLiteDatabase.openDatabase()
//......
}

在 Native source set 中:

import co.touchlab.sqliter.createDatabaseManager

public actual fun openDatabase(config: DatabaseConfiguration): DatabaseConnection {
createDatabaseManager(configNative).createMultiThreadedConnection()
//......
}

上面的只是示例,在 sqllin-driver 的真实实现中会更为复杂一些。

至此, sqllin-driver 的实现已经没有太多的困难,剩余的开发工作只需要通过封装来抹平两边的差异,并提供合适的 common API 即可。

3.3 DSL 设计与实现

3.3.1 基本设计

在 driver 层的实现没有太大障碍后,我们就可以着手进行 DSL 层的设计和开发。SQLDelight 的完全生成式 DSL 实现起来过于复杂,使用 Kotlin 的语法潜力构建我们自己的 DSL 相对简单且易于使用。在上面的调研中我们看到 Exposed 的 DSL API 设计依赖 KV 操作语法,并且不少子句的构建有太多的 lambda 表达式应用,以及过多的括号嵌套,整体使用下来写出来的代码与 SQL 语句相去甚远。

在我的构思中,我希望 DSL 的设计可以尽量还原 SQL 语法,并且能最大程度的减少用户编写的样板代码。所以我初步构思了一套 DSL 语法的样貌,这样便于后续的实现还原:

fun sample() {
lateinit var statement: SelectStatement<Person>
database {
val table = Table<Person>(“person”)
table INSERT listOf(tom, jerry, nick)
table DELETE WHERE (name == “Jerry”)
table UPDATE SET (age = 27) WHERE (name == “Nick”)
statement = table SELECT WHERE (name == “Tom”) ORDER_BY (age to DESC)
}
val result: List<Person> = statement.getResult()
}

注意,上面的代码是伪代码,仅仅是初步构思。但我们在后续的实现中会尽量还原它的设计。

总的来说,用户可以创建 Table 实例用来表示数据库表,在所有的 SQL 语句中,Table 实例都是主语,Table 同时约束序列化与反序列化对象的类型。Table 拥有 4 个谓语,分别代表增删改查等操作。谓语通过中缀函数实现,不同的表示操作的中缀函数接收不同类型的参数,例如我们看到 INSERT 直接接收一个对象的 List 即可完成插入操作。而 DELETE 和 SELECT 则接收 WHERE 子句来完成整条 SQL 语句的构建。此外,UPDATE 和 SELECT 语句可以连续连接多个子句, 这些多子句的连接也是通过中缀函数来实现的。最后,SELECT 语句返回了一个 SelectStatement 类型的对象,在整个 database {...} 作用域完结之后可以用它来提取查询结果。

以这样的方式构建出的 API 可以最大程度的还原 SQL 的语法与语序。

3.3.2 DSL 类型关系

在确定了基本的语法规则后,我们需要定义一些基本的类型关系,这无论是在面向对象编程还是函数式编程中都非常重要。这些类型关系可以在代码编写阶段约束一些语法准则,避免将 SQL 的语法错误留到运行时暴露。例如,INSERT 语句不能连接子句、SELECT 语句中 ORDER BY 子句不能位于 WHERE 子句之前等等。我们以一条 SELECT语句为例来为它的每个部分定义一些类型:

图片


Statement 、Table、Operation、Clause 我们都已经在前文讨论过了。这里要解释一下的是 ClauseElement 和 ClauseCondition。ClauseElement 表示数据库的列名,而 ClauseCondition 则表示一个条件,条件通常会用在 WHERE 和 HAVING 子句中。基于以上的类型定义,我们可以得到一些基本的类型间的关系:

Table + Operation + Clause -> Statement
Statement + Clause -> Statement
ClauseElement + String/Number -> ClauseCondition
ClauseCondition + ClauseCondition -> ClauseCondition

当然,以上的类型在真实的代码中都是 interface 或 abstract class,不同的 SQL 语句的类型关系有所不同,这些约束的真正实现在其子类型当中。

3.3.3 使用 Kotlin Symbol Processor 实现表与列元素生成

在 3.3.1 小节的基本设计中,Table 实例是通过构造函数创建的,每次创建时用户都需要手动传入数据库的真实表名作为其参数,这并不方便易用。在 Exposed 中也有类似的 Table 设计,用户定义自己的 class 并继承自 Table 抽象类,还要在其中定义一些表达列名的属性。这种设计的最大问题在于用户总是要手动编写大量样板代码。为了使这一步操作更方便,我希望 SQLlin 可以根据用户期待序列化与反序列化的类型自动生成 Table 单例,以及其内部的列名属性。

Kotlin Symbol Processor(后简称 KSP)是 Google 开发的元编程工具,基于前文所说的 KCP。它通常被用于注解处理及代码生成,它的功能虽然不如 KCP 强大,但拥有较为完整的教程与文档且更加易用。在 KSP 诞生之前,开发者通常使用 KAPT 来进行注解处理和代码生成,但其二者处理 Kotlin 的阶段不同,如下图所示:

图片

Kotlin 的编译大概分为两个阶段,第一个阶段由编译器前端进行,它将 Kotlin 代码编译为中间表示码 IR,而编译器后端则将 IR 编译为各平台的产物,由此实现了 Kotlin 的跨平台。KAPT 技术基于 Java APT 技术,它处理的是 JVM Bytecode,因此它仅仅能用于 Kotlin/JVM,无法实现跨平台需求。并且将 Java 与 Kotlin 间的一些语法概念互相转化相当耗时,这导致了 KAPT 的性能不够好,导致了代码编译构建的耗时增加。而 KSP 处理的则是中间表示码 IR,相当于在 Kotlin 编译到各平台产物之前对其进行了处理,因此可以用于跨平台场景,并且 IR 是 Kotlin 代码的直接编译产物,无须概念转换,这使得 KSP 在一些较好的工况下性能可以比 KAPT 提升两倍之多。

那我们如何实现注解处理?我们可以定义一个注解类,用户将注解添加到希望表示表的 data class 即可,比如:

@DBRow("person")
data class Person(
val age: Int,
val name: String,
)

字符串"person"表示数据库中真实的表名,它作为参数传递给注解,这样 KSP 就能在代码处理阶段拿到它。在 KSP 处理后,可以生成以下代码:

// KSP generated:
object PersonTable : Table<Person>("person") {

inline operator fun <R> invoke(block: PersonTable.(table: PersonTable) -> R): R = this.block(this)

val name: ClauseString get() {}
val age: ClauseNumber get() {}
var SetClause<Person>.name: String set(value) {}
var SetClause<Person>.age: Int set(value) {}
}

我们可以发现,生成的 Table 中含有两个 name 以及两个 age。使用 val 声明的属性用于在条件语句中表示列名,而使用 var 声明的则是 SetClause 的扩展属性,用于在 SET 子句中设置一个新值。之所以将二者分开主要是因为如果想要在 SET 子句中使用赋值运算符 = 进行 set,那么接收的参数则必须与该属性类型相同。举例来说如果将属性声明为 ClauseString 类型,那么它的 setter 就无法接收 String 类型的参数。

有了 KSP 的助力,用户再也无须手动编写大量的 Table 代码,为使用带来了极大的便利。

3.3.4 如何实现查询结果的反序列化

在纯 Android 库的开发中,我们通常会使用反射将某种格式的数据中的某个字段的值映射到与它名称相同的 class 中的某个属性,从而生成出该 class 的对象,这就是反序列化。反射是 JVM 的机制,无法跨平台。因此我们如果要在 Kotlin Multiplatform 的环境中进行反序列化,就必须另寻他路。

在 Kotlin Multiplatform 的开发中,最常见的 JSON 和 ProtoBuf 的序列化与反序列化库是官方的 kotlinx.serialization。它反序列化的原理是它通过 KCP 处理注解,并生成了每个被注解类的 KSerializer,KSerializer 是一个辅助类,它包含被注解类的属性名,属性类型等信息,kotlinx.serialization 正是通过它实现了非反射的序列化与反序列化。KCP 不仅使用门槛高,而且官方尚未正式发布(这意味着它没有文档且后续 API 可能会发生大的破坏性变更),因此使用 KCP 仿造编写一个类似的功能也同样很难。但我在调研 kotlinx.serialization 的原理时发现它开放了自定义数据格式的 API,我们可以直接复用 KSerializer。

在 sqllin-driver 中,查询语句将会返回一个 CommonCursor,这与 Android SQLite Java API 类似。它可以进行行迭代、获取指定列名的列号,以及 get 一些基本类型和 String 等数据,它的定义如下:

interface CommonCursor {
fun getInt(columnIndex: Int): Int
fun getString(columnIndex: Int): String?
//……
fun getColumnIndex(columnName: String): Int
fun forEachRow(block: (Int) -> Unit)
fun close()
}

而我们的目的则正是将 CommonCursor 反序列化为自己的 data class。

自定义反序列化器非常简单,只需要继承自 kotlinx.serialization 中提供的 AbstractDecoder 即可,核心实现如下:

@OptIn(ExperimentalSerializationApi::class)
internal class QueryDecoder(
private val cursor: CommonCursor
) : AbstractDecoder() {

private var elementIndex = 0
private var elementName = ""

override val serializersModule: SerializersModule = EmptySerializersModule()

override tailrec fun decodeElementIndex(descriptor: SerialDescriptor): Int =
if (elementIndex == descriptor.elementsCount)
CompositeDecoder.DECODE_DONE
else {
elementName = descriptor.getElementName(elementIndex)
val resultIndex = elementIndex++
if (cursorColumnIndex >= 0)
resultIndex
else
decodeElementIndex(descriptor)
}

override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = QueryDecoder(cursor)

private inline val cursorColumnIndex
get() = cursor.getColumnIndex(elementName)

private inline fun <T> deserialize(block: (Int) -> T): T = cursorColumnIndex.let {
if (it >= 0) block(it) else throw SerializationException("The Cursor doesn't have this column")
}

override fun decodeBoolean(): Boolean = deserialize { cursor.getInt(it) > 0 }
override fun decodeInt(): Int = deserialize { cursor.getInt(it) }
override fun decodeString(): String = deserialize { cursor.getString(it) ?: "" }
override fun decodeDouble(): Double = deserialize { cursor.getDouble(it) }
//......
}

我们自定义的 Decoder 接收一个 CommonCursor 作为参数。decodeElementIndex 函数驱动着整个反序列化流程。我们通过elementIndex 在该类的众多属性中查找到当前对应的属性名,再根据这个属性名查询到名称相同的列名的列号,如果列号大于等于 0 则表示列名合法,直接返回 elementIndex 即可,否则进行下一轮迭代。在针对各类型的基本数据的反序列化中,我们直接调用CommonCursor 对应的 get 函数取值并返回就可以了。

关于自定义 kotlinx.serialization,我曾经写过一篇文章详细讨论,大家可以参考(参考链接 7),或者查看官方文档(参考链接 8)。

3.3.5 最终效果

以上基本讨论完了 sqllin-dsl 设计过程中遇到的大部分难点。在真实的开发过程我们还解决了更多的问题,其中很大一部分在于类型设计。例如,某语句只能连接某子句,某子句后面不能连接某子句等等。利用 Kotlin 的语法规则可以在很大程度上保证在编译期间暴露出我们编写的 SQL 错误,并在绝大部分情况下阻止错误的 SQL 语句代码通过编译。但这不是 100% 的,使用者仍然可能使用 SQLlin 编写出错误的 SQL 语句,因此充分理解 SQL 知识对那些需要使用数据库的开发者来说非常重要。在开发完成之后,使用 sqllin-dsl 编写的真实代码如下所示:

fun sample() {
lateinit var statement: SelectStatement<Person>
database {
PersonTable { table ->
table INSERT listOf(tom, jerry, nick)
table DELETE WHERE (name EQ "Jerry")
table UPDATE SET {age = 27} WHERE (name NEQ "Nick")
statement = table SELECT WHERE (name EQ "Tom") ORDER_BY (age to DESC)
}
}
val result: List<Person> = statement.getResult()
}

我们大体还原了最初的设计构想,主要改变的地方有两点,首先是 Table 现在由 KSP 直接生成,不再依赖用户手动调用构造函数。其次是我们最终没能使用运算符重载来实现 ClauseElement 的运算符,例如 > 和 <,原因除了重载函数的返回值类型问题,也包括如果要重载> 和 <,我们需要实现 Comparable 接口,并覆盖 compareTo 函数。但在用户调用 compareTo 时,它的内部无法知道用户到底调用的是> 还是 <,因此无法准确构建正确的 SQL 语句。最终我们舍弃了运算符重载,转而采用中缀函数实现。

在完成最终的设计后,SQLlin 的架构设计图调整为如下所示:

图片

我们加入了 sqllin-processor 模块,它主要包含 KSP 相关的代码,负责注解处理与代码生成。在与 Native 平台交互这边,架构图中添加了 SQLiter 的部分。

得益于 SQLiter 对多个 Native 平台的支持,SQLlin 支持的平台数量也远超 Android、iOS 两个移动端平台:

  • Android
  • iOS (x64, arm32, arm64, simulatorArm64)
  • macOS (x64, arm64)
  • watchOS (x86, x64, arm32, arm64, simulatorArm64)
  • tvOS (x64, arm64, simulatorArm64)
  • Linux (x64)

四、未来计划

SQLlin 目前已经在 Github 开源,大家可以前往它的主页(参考链接 9)查看它更多的信息。

SQLlin 拥有全套的中英文文档以及 Sample 项目供大家学习如何使用。

但 SQLlin 的开发仍未结束,它目前仍然有一些不足,例如它还有如下功能不支持:

(1)不支持子查询,包括不支持条件语句中的子查询和 JOIN 子查询。

(2)不支持表创建、表删除、增加列、删除列等会导致数据库结构发生变化的 SQL 语句构建。

只有将以上两个功能开发完成,SQLlin 才基本拥有应对各种场景的能力。这两项功能的实现会是当下 SQLlin 后续迭代的主要工作。

此外,SQLiter 除了以上提到的 SQLlin 支持的平台外,还支持 Windows。由于目前我们是本地编译发布,而 Kotlin 当前不支持类 Unix 系统和 Windows 系统的交差编译,因此 SQLlin 暂时还不支持 Windows 平台。等 SQLlin 的 Github CI/CD 配置完成后,Windows 也将加入受支持行列。

在最近的 Github issue 中我们发现,有一些开发者希望我们能考虑 JVM 后端场景,可以像 SQLDelight 一样在 JVM 上连接后端数据库,这是个不错的建议,我们可以将其列为长期规划,不过目前 SQLlin 还是需要集中精力把客户端上的事情做好。

Kotlin Multiplatform 这项技术最近进展很快,特别是 compose-jb 在 iOS 上取得进步令人振奋。机票团队除 UI 层以外已经基本完成了基础架构建设,后续会继续调研 Kotlin Multiplatform 的 UI 跨端方案,并同步推进更多的业务代码向 KMM 的迁移。期待后续我们团队可以为社区带来更多的贡献。

责任编辑:张燕妮 来源: 携程技术
相关推荐

2022-06-17 09:42:20

开源MMKV携程机票

2022-05-20 11:09:15

Flybirds多端测试UI 自动化测试

2020-12-04 14:32:33

AndroidJetpackKotlin

2022-05-13 09:27:55

Widget机票业务App

2017-04-11 15:11:52

ABtestABT变量法

2022-06-03 09:21:47

Svelte前端携程

2015-05-28 14:05:02

2022-06-10 08:35:06

项目数据库携程机票

2023-06-06 16:01:00

Web优化

2022-08-06 08:27:41

Trace系统机票前台微服务架构

2023-05-12 10:14:38

APP开发

2017-04-11 15:34:41

机票前台埋点

2024-01-19 09:21:35

携程开源

2022-08-12 08:38:08

携程小程序Taro跨端解决方案

2017-03-15 17:38:19

互联网

2015-05-28 20:46:06

2015-05-28 22:46:29

2015-06-01 15:06:45

携程数据库灾备

2023-08-25 09:51:21

前端开发

2023-11-13 11:27:58

携程可视化
点赞
收藏

51CTO技术栈公众号