攜程機票跨端 Kotlin DSL 數據庫框架 SQLlin
作者簡介
禹昂,攜程機票移動端資深工程師,專注于 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 設計:
它的 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 設計:
用戶需要自己定義一個表示數據庫表的對象,繼承自 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 的操作:
由于 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 中聲明:
在 Android source set 中可以這樣實現:
在 Native source set 中:
上面的只是示例,在 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 語法的樣貌,這樣便于后續的實現還原:
注意,上面的代碼是偽代碼,僅僅是初步構思。但我們在后續的實現中會盡量還原它的設計。
總的來說,用戶可以創建 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 子句中。基于以上的類型定義,我們可以得到一些基本的類型間的關系:
當然,以上的類型在真實的代碼中都是 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 即可,比如:
字符串"person"表示數據庫中真實的表名,它作為參數傳遞給注解,這樣 KSP 就能在代碼處理階段拿到它。在 KSP 處理后,可以生成以下代碼:
我們可以發現,生成的 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 等數據,它的定義如下:
而我們的目的則正是將 CommonCursor 反序列化為自己的 data class。
自定義反序列化器非常簡單,只需要繼承自 kotlinx.serialization 中提供的 AbstractDecoder 即可,核心實現如下:
我們自定義的 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 編寫的真實代碼如下所示:
我們大體還原了最初的設計構想,主要改變的地方有兩點,首先是 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 的遷移。期待后續我們團隊可以為社區帶來更多的貢獻。