攜程機(jī)票跨端 Kotlin DSL 數(shù)據(jù)庫(kù)框架 SQLlin
作者簡(jiǎn)介
禹昂,攜程機(jī)票移動(dòng)端資深工程師,專(zhuān)注于 Kotlin 移動(dòng)端跨平臺(tái)領(lǐng)域,Kotlin 中文社區(qū)核心成員,圖書(shū)《Kotlin 編程實(shí)踐》譯者。
一、背景
2022年9月 Kotlin 1.7.20 發(fā)布之后,Kotlin Multiplatform Mobile(簡(jiǎn)稱(chēng)KMM)進(jìn)入 Beta 階段,Kotlin/Native new memory management 也變更為默認(rèn)啟用狀態(tài)。無(wú)論從多端統(tǒng)一性還是性能上來(lái)看,Kotlin Multiplatform 都進(jìn)入了下一個(gè)里程碑階段。
攜程機(jī)票移動(dòng)端團(tuán)隊(duì)在2021年介紹過(guò) KMM 技術(shù)在機(jī)票產(chǎn)線的落地情況(參考鏈接 1),2022 年年中開(kāi)源了團(tuán)隊(duì)首個(gè) KMM 項(xiàng)目—— MMKV-Kotlin(參考鏈接 2),并撰文(參考鏈接 3)詳述 MMKV-Kotlin 的研發(fā)過(guò)程和一些常見(jiàn)問(wèn)題。目前繼續(xù)在 Kotlin Multiplatform 開(kāi)源領(lǐng)域發(fā)力,打造出了基于 DSL 及 Kotlin Symbol Processor(KSP)開(kāi)發(fā)的 SQLite 框架—— SQLlin。
二、需求調(diào)研
2.1 為什么要使用 SQLite 框架?
在移動(dòng)端開(kāi)發(fā)領(lǐng)域,在對(duì) CRUD 操作有著復(fù)雜需求的數(shù)據(jù)存取場(chǎng)景上,SQLite 一直是首選方案。它同時(shí)內(nèi)置于 Android 與 iOS 系統(tǒng)框架中,開(kāi)發(fā)者無(wú)需增加額外的包大小。在數(shù)據(jù)的增刪查改上它支持絕大部分 SQL 語(yǔ)法,功能足夠強(qiáng)大。SQLite 本身是 C 語(yǔ)言庫(kù),雖然官方為它打造了多種語(yǔ)言及開(kāi)發(fā)環(huán)境的 wrapper,但目前還不直接支持 Kotlin Multiplatform。因此,尋找或開(kāi)發(fā)一款支持 Kotlin Multiplatform 的 SQLite 框架是我們的必選項(xiàng)。
但同時(shí)我們也注意到,SQLite 框架本身的意義并不僅僅在于擴(kuò)展其支持的技術(shù)棧。例如,在 Android 開(kāi)發(fā)中,我們有 Android Framework SQLite Java API,但是開(kāi)發(fā)者們通常會(huì)在項(xiàng)目中使用 Jetpack Room 來(lái)操作數(shù)據(jù)庫(kù)。在 iOS 開(kāi)發(fā)中,開(kāi)發(fā)者可以直接調(diào)用 SQLite C API,但是大家也仍然傾向于選擇類(lèi)似 FMDB 這樣的框架。原因主要在于以下三點(diǎn):
(1)SQLite 的原始 API 顆粒度較細(xì),直接在業(yè)務(wù)代碼中使用較為繁瑣且容易出錯(cuò)。
(2)SQL 語(yǔ)句以字符串的形式存在于代碼中,不受編譯器檢查。
(3)SQLite 不支持直接存取對(duì)象,將基本數(shù)據(jù)類(lèi)型與對(duì)象進(jìn)行轉(zhuǎn)換需要編寫(xiě)大量樣板代碼。
我們期待我們未來(lái)使用的 SQLite 框架在支持 Kotlin Multiplatform 的同時(shí)可以解決掉以上三個(gè)痛點(diǎn)問(wèn)題。
2.2 開(kāi)源方案調(diào)研
在開(kāi)發(fā)一個(gè)項(xiàng)目之前,我們通常會(huì)在開(kāi)源社區(qū)尋找成熟的解決方案,如果可以完全契合我們的需求則沒(méi)有必要重復(fù)造輪子。但如果我們調(diào)研的項(xiàng)目不完全符合我們的預(yù)期,則仍然可以學(xué)習(xí)其設(shè)計(jì)思想,為我們自己的設(shè)計(jì)與研發(fā)提供思路與參考。
2.2.1 Jetpack Room
Jetpack Room(參考鏈接 4)是 Google 官方提供的 SQLite 框架,最初用 Java 打造,并非專(zhuān)為 Kotlin 而生。它僅能用于 Android 開(kāi)發(fā),暫不支持 Kotlin Multiplatform,因此不符合我們的期望,但我們可以參考它的 API 設(shè)計(jì):
它的 API 采用 DAO(Data Access objects)思想,它可以自動(dòng)完成對(duì)象到 SQL 語(yǔ)句的序列化與查詢(xún)結(jié)果 Cursor 到對(duì)象的反序列化。開(kāi)發(fā)者只需要定義 DAO 的 interface,并用它提供的注解描述需要操作的對(duì)象即可。Room 采用 APT/KAPT(目前正在向 KSP 遷移)對(duì)注解進(jìn)行處理并生成代碼,可以避免用戶(hù)手動(dòng)編寫(xiě)大量樣板代碼。用戶(hù)在使用 Room 時(shí)僅需要通過(guò) DAO set/get 對(duì)象即可。
不過(guò)它也有一些問(wèn)題。例如:查詢(xún)操作與按條件的更新和刪除操作,用戶(hù)仍然需要編寫(xiě) SQL 語(yǔ)句,這些 SQL 語(yǔ)句雖然 Android Studio 提供了高亮,但是仍然是以字符串的形式存在,不受編譯器靜態(tài)類(lèi)型檢查。
2.2.2 Exposed
Kotlin在正式發(fā)布時(shí)有一個(gè)主力賣(mài)點(diǎn)就是可以用來(lái)構(gòu)建開(kāi)發(fā)者自己的DSL。Exposed(參考鏈接 5)是當(dāng)時(shí)官方宣傳DSL的范例項(xiàng)目之一。Exposed主要場(chǎng)景是 JVM 后端,它使用 JDBC 可以連接多種數(shù)據(jù)庫(kù),包括:MySQL、Oracle、MariaDB、SQLite 等等。從場(chǎng)景上看 Exposed 也不符合我們的預(yù)期,但是我們?nèi)匀豢梢钥匆幌滤?API 設(shè)計(jì):
用戶(hù)需要自己定義一個(gè)表示數(shù)據(jù)庫(kù)表的對(duì)象,繼承自 Table,然后手動(dòng)編寫(xiě)代碼,使用屬性表示表中的列。在進(jìn)行 CURD 的 SQL 構(gòu)建時(shí)通過(guò)調(diào)用不同的 Table 成員函數(shù),然后使用類(lèi)似鍵值對(duì) get/set 的方式完成 SQL 子句(clause)的構(gòu)建。
以當(dāng)年的角度來(lái)看,Exposed 的 API 算是相當(dāng)驚艷。但以今天的眼光來(lái)看,我認(rèn)為 Exposed的 API 有如下不足:
(1)數(shù)據(jù)庫(kù)不支持序列化與反序列化為對(duì)象,實(shí)際上的編程體驗(yàn)仍然像在操作一個(gè) Map。
(2)用戶(hù)需要手動(dòng)定義 Table,需要編寫(xiě)大量樣板代碼。
(3)API 設(shè)計(jì)與 SQL 語(yǔ)句本身差異較大,部分 API 接收多個(gè) lambda 表達(dá)式作為參數(shù),看起來(lái)括號(hào)嵌套層級(jí)多,不夠優(yōu)雅。
但總的的來(lái)說(shuō) Exposed 的設(shè)計(jì)思路的方向非常棒,使用 Kotlin 語(yǔ)法構(gòu)建自己的 DSL API,對(duì)用戶(hù)來(lái)說(shuō)使用方便,且只要充分利用其潛力,API 也能設(shè)計(jì)的非常優(yōu)雅。
2.2.3 SQLDelight
SQLDelight(參考鏈接 6)由 Android 界的開(kāi)源先鋒 Square 開(kāi)發(fā),是我們目前調(diào)研過(guò)的最先進(jìn)的 Kotlin 數(shù)據(jù)庫(kù)框架。它支持 Kotlin Multiplatform,除了 Android、iOS 這樣的移動(dòng)端平臺(tái),還通過(guò) Kotlin/Native 直接支持 macOS、Linux 以及 Windows 等桌面端平臺(tái),除此之外也有對(duì) JavaScrip 以及 JVM 開(kāi)發(fā)環(huán)境的支持。在所有平臺(tái)上 SQLDelight 都支持 SQLite,但在 JVM 平臺(tái)上還額外支持使用 JDBC 連接各種主流的服務(wù)端數(shù)據(jù)庫(kù)。因此 SQLDelight 是一個(gè)能滿(mǎn)足多種開(kāi)發(fā)環(huán)境,多種技術(shù)棧的數(shù)據(jù)庫(kù)框架。
在 API 的設(shè)計(jì)上,SQLDelight 更是一騎絕塵,它使用 Kotlin 官方尚未正式發(fā)布的技術(shù) Kotlin Compiler Plugin(后簡(jiǎn)稱(chēng) KCP)來(lái)構(gòu)建 API。用戶(hù)只需要在一個(gè)特殊的 .sq 文件中編寫(xiě)自己的 SQL 語(yǔ)句,并給 SQL 語(yǔ)句起一個(gè)名字,KCP 就可以在工程編譯構(gòu)建時(shí)對(duì) SQL 語(yǔ)句進(jìn)行語(yǔ)法檢查及靜態(tài)類(lèi)型校驗(yàn),并生成一個(gè)函數(shù)。用戶(hù)直接在 Kotlin 代碼中調(diào)用該函數(shù)即可完成 CRUD 操作。SQLDelight 示例代碼如下圖所示:
看上去 SQLDelight 完美適合我們的場(chǎng)景。但實(shí)際上我們對(duì) SQLDelight 的調(diào)研非常早,那時(shí)它會(huì)在 iOS 上帶來(lái)過(guò)大的 size 增長(zhǎng)。攜程 app 是一個(gè)多功能聚合類(lèi) app,而機(jī)票又只是其中一個(gè)團(tuán)隊(duì), 因此在 size 的增長(zhǎng)上會(huì)較為敏感。在近期我的調(diào)研中,在 x86 架構(gòu)下 SQLDelight 帶來(lái)的包 size 增長(zhǎng)為 200 kb,比之前有所改善。如果你準(zhǔn)備從 0 打造一個(gè) KMM app 或者你是某項(xiàng)目的基礎(chǔ)架構(gòu)團(tuán)隊(duì)的成員,我非常建議你嘗試 SQLDelight。
此外在 API 上,雖然使用 KCP 幫助開(kāi)發(fā)者生成大量代碼非常驚艷,但是 SQLDelight 配置較為繁瑣,使用方式的學(xué)習(xí)成本也較高,便利性上做不到開(kāi)箱即用。因此許多開(kāi)發(fā)者對(duì)其也持有一些不同的觀點(diǎn)。
2.3 需求確定
我們調(diào)研過(guò)的庫(kù)與框架并不只有以上三款,在經(jīng)過(guò)充分的對(duì)比后,我們決定仍然自己研發(fā)一款符合我們需求的 SQLite 框架,在取長(zhǎng)補(bǔ)短與權(quán)衡利弊之后,我們認(rèn)為它應(yīng)該具有以下特性:
(1)支持 KMM(即至少支持 Android、iOS 兩個(gè)平臺(tái))。
(2)SQL 語(yǔ)句必須可以在某種程度上受編譯器檢查。
(3)支持直接將對(duì)象序列化為 SQL 語(yǔ)句(例如 UPDATE 語(yǔ)句中的 SET 子句),且支持將查詢(xún)結(jié)果反序列化為 Kotlin 對(duì)象。
(4)Size 不能過(guò)大。
三、 基本設(shè)計(jì)與實(shí)現(xiàn)
3.1 架構(gòu)設(shè)計(jì)與 module 劃分
在一個(gè)項(xiàng)目開(kāi)發(fā)之前,我們首先需要做的是將項(xiàng)目的基本功能理清,然后進(jìn)行適當(dāng)?shù)?module 劃分:
無(wú)論是 iOS 還是 Android,最底層調(diào)用的都是 SQLite C 庫(kù)。再往上是應(yīng)用程序?qū)樱琲OS 應(yīng)用層可以直接調(diào)用 SQLite C API,但是在 Android 上,由于應(yīng)用層的代碼運(yùn)行在 ART 虛擬機(jī)上,因此我們需要通過(guò) Android Framework 提供的 Java API 對(duì) SQLite 進(jìn)行操作。
再往上就到了 KMM common 層,我們希望 DSL API 的實(shí)現(xiàn)應(yīng)該是完全平臺(tái)無(wú)關(guān)的, 因此我們需要 sqllin-dsl 的下層提供了一個(gè)叫做 sqllin-driver 的模塊,它在不同的平臺(tái)上提供不同的具體實(shí)現(xiàn),但在 common source set 中提供了一層平臺(tái)無(wú)關(guān)的 lower-level SQLite API 供 sqllin-dsl 層使用。
3.2 Driver 層的技術(shù)選型與實(shí)現(xiàn)
sqllin-driver 在 common source set 中提供了一套通用的 API,但其在不同平臺(tái)的 source set 中需要采取不同的實(shí)現(xiàn)方式。在上面的架構(gòu)中設(shè)計(jì)中,在 iOS source set 中可以直接調(diào)用 SQLite C API,而在 Android source set 中我們可以使用 Android Framework SQLite Java API。
使用 Android Framework SQLite Java API 有個(gè)問(wèn)題,在 Android P 以下的版本上有眾多的 SQLite 參數(shù)配置都不支持,比如:日志模式、同步模式、lookaside、內(nèi)存數(shù)據(jù)庫(kù)等等。如果要在低版本的 Android 系統(tǒng)上支持這些參數(shù)配置,我們需要自行編寫(xiě) JNI 代碼,實(shí)現(xiàn)一套 JVM 層的 SQLite API。
但是 Google 在 Android N 以上的版本中禁止在 NDK 開(kāi)發(fā)中直接訪問(wèn)系統(tǒng)內(nèi)置的 SQLite,如果堅(jiān)持這么做,開(kāi)發(fā)者必須自己重新打一份 SQLite 到自己的 apk 中,這不僅會(huì)增加一部分無(wú)謂的包大小,還會(huì)讓這個(gè)項(xiàng)目變得過(guò)于復(fù)雜。所以最終我們?nèi)匀粵Q定基于 Android Framework 來(lái)實(shí)現(xiàn),不支持對(duì)低版本 Android 系統(tǒng)的 SQLite 多種個(gè)性化配置。
在 iOS 端的實(shí)現(xiàn)上我們也碰到了一些問(wèn)題,雖然 Kotlin/Native 與 C 語(yǔ)言的互操作很完善,但是也非常繁瑣,比如我們?cè)?Kotlin/Native 上做一次 open database 的操作:
由于 C 語(yǔ)言獨(dú)有的運(yùn)行時(shí)內(nèi)存的特性以及其自身的概念,我們需要使用一些繁瑣的 API 來(lái)完成對(duì) C 的調(diào)用,比如上面示例中的:memScoped、alloc、ptr、toKString 等等。這導(dǎo)致這一塊的開(kāi)發(fā)工作量大幅上升。
但好在我們?cè)陂_(kāi)源社區(qū)找到了解決方案—— SQLiter。SQLiter 是 TouchLab 的開(kāi)源項(xiàng)目,它的作用在于使用 Kotlin 實(shí)現(xiàn)多個(gè) Native 平臺(tái)統(tǒng)一的 SQLite lower-level API,它的 API 設(shè)計(jì)與 Android Framework SQLite Java API 有些相似,但又融合了許多 Kotlin 的語(yǔ)法特性。它不僅僅支持 iOS,還支持 macOS、tvOS、watchOS、Linux、Windows 等多個(gè)操作系統(tǒng),抹平了包括線程鎖在內(nèi)的多端差異。它同時(shí)也是 SQLDelight 在 Kotlin/Native 上的底層引擎。使用 SQLiter 可以把 Kotlin-C 之間的互操作轉(zhuǎn)化為 Kotlin 語(yǔ)言?xún)?nèi)的互相調(diào)用,大大節(jié)約開(kāi)發(fā)成本。并且我們也能通過(guò) SQLiter 的多平臺(tái)支持能力,擴(kuò)展到除 iOS 外的多個(gè) Native 平臺(tái)。
只要兩個(gè)平臺(tái)都可以完成對(duì) SQLite 的操作,開(kāi)發(fā) common 層的通用 API 只需要聲明 expect API,然后在各平臺(tái) source set 的 actual 實(shí)現(xiàn)中直接調(diào)用這些平臺(tái)特有的實(shí)現(xiàn)即可,比如說(shuō)還是以 open 數(shù)據(jù)庫(kù)為例,我們?cè)?common source set 中聲明:
在 Android source set 中可以這樣實(shí)現(xiàn):
在 Native source set 中:
上面的只是示例,在 sqllin-driver 的真實(shí)實(shí)現(xiàn)中會(huì)更為復(fù)雜一些。
至此, sqllin-driver 的實(shí)現(xiàn)已經(jīng)沒(méi)有太多的困難,剩余的開(kāi)發(fā)工作只需要通過(guò)封裝來(lái)抹平兩邊的差異,并提供合適的 common API 即可。
3.3 DSL 設(shè)計(jì)與實(shí)現(xiàn)
3.3.1 基本設(shè)計(jì)
在 driver 層的實(shí)現(xiàn)沒(méi)有太大障礙后,我們就可以著手進(jìn)行 DSL 層的設(shè)計(jì)和開(kāi)發(fā)。SQLDelight 的完全生成式 DSL 實(shí)現(xiàn)起來(lái)過(guò)于復(fù)雜,使用 Kotlin 的語(yǔ)法潛力構(gòu)建我們自己的 DSL 相對(duì)簡(jiǎn)單且易于使用。在上面的調(diào)研中我們看到 Exposed 的 DSL API 設(shè)計(jì)依賴(lài) KV 操作語(yǔ)法,并且不少子句的構(gòu)建有太多的 lambda 表達(dá)式應(yīng)用,以及過(guò)多的括號(hào)嵌套,整體使用下來(lái)寫(xiě)出來(lái)的代碼與 SQL 語(yǔ)句相去甚遠(yuǎn)。
在我的構(gòu)思中,我希望 DSL 的設(shè)計(jì)可以盡量還原 SQL 語(yǔ)法,并且能最大程度的減少用戶(hù)編寫(xiě)的樣板代碼。所以我初步構(gòu)思了一套 DSL 語(yǔ)法的樣貌,這樣便于后續(xù)的實(shí)現(xiàn)還原:
注意,上面的代碼是偽代碼,僅僅是初步構(gòu)思。但我們?cè)诤罄m(xù)的實(shí)現(xiàn)中會(huì)盡量還原它的設(shè)計(jì)。
總的來(lái)說(shuō),用戶(hù)可以創(chuàng)建 Table 實(shí)例用來(lái)表示數(shù)據(jù)庫(kù)表,在所有的 SQL 語(yǔ)句中,Table 實(shí)例都是主語(yǔ),Table 同時(shí)約束序列化與反序列化對(duì)象的類(lèi)型。Table 擁有 4 個(gè)謂語(yǔ),分別代表增刪改查等操作。謂語(yǔ)通過(guò)中綴函數(shù)實(shí)現(xiàn),不同的表示操作的中綴函數(shù)接收不同類(lèi)型的參數(shù),例如我們看到 INSERT 直接接收一個(gè)對(duì)象的 List 即可完成插入操作。而 DELETE 和 SELECT 則接收 WHERE 子句來(lái)完成整條 SQL 語(yǔ)句的構(gòu)建。此外,UPDATE 和 SELECT 語(yǔ)句可以連續(xù)連接多個(gè)子句, 這些多子句的連接也是通過(guò)中綴函數(shù)來(lái)實(shí)現(xiàn)的。最后,SELECT 語(yǔ)句返回了一個(gè) SelectStatement 類(lèi)型的對(duì)象,在整個(gè) database {...} 作用域完結(jié)之后可以用它來(lái)提取查詢(xún)結(jié)果。
以這樣的方式構(gòu)建出的 API 可以最大程度的還原 SQL 的語(yǔ)法與語(yǔ)序。
3.3.2 DSL 類(lèi)型關(guān)系
在確定了基本的語(yǔ)法規(guī)則后,我們需要定義一些基本的類(lèi)型關(guān)系,這無(wú)論是在面向?qū)ο缶幊踢€是函數(shù)式編程中都非常重要。這些類(lèi)型關(guān)系可以在代碼編寫(xiě)階段約束一些語(yǔ)法準(zhǔn)則,避免將 SQL 的語(yǔ)法錯(cuò)誤留到運(yùn)行時(shí)暴露。例如,INSERT 語(yǔ)句不能連接子句、SELECT 語(yǔ)句中 ORDER BY 子句不能位于 WHERE 子句之前等等。我們以一條 SELECT語(yǔ)句為例來(lái)為它的每個(gè)部分定義一些類(lèi)型:
Statement 、Table、Operation、Clause 我們都已經(jīng)在前文討論過(guò)了。這里要解釋一下的是 ClauseElement 和 ClauseCondition。ClauseElement 表示數(shù)據(jù)庫(kù)的列名,而 ClauseCondition 則表示一個(gè)條件,條件通常會(huì)用在 WHERE 和 HAVING 子句中?;谝陨系念?lèi)型定義,我們可以得到一些基本的類(lèi)型間的關(guān)系:
當(dāng)然,以上的類(lèi)型在真實(shí)的代碼中都是 interface 或 abstract class,不同的 SQL 語(yǔ)句的類(lèi)型關(guān)系有所不同,這些約束的真正實(shí)現(xiàn)在其子類(lèi)型當(dāng)中。
3.3.3 使用 Kotlin Symbol Processor 實(shí)現(xiàn)表與列元素生成
在 3.3.1 小節(jié)的基本設(shè)計(jì)中,Table 實(shí)例是通過(guò)構(gòu)造函數(shù)創(chuàng)建的,每次創(chuàng)建時(shí)用戶(hù)都需要手動(dòng)傳入數(shù)據(jù)庫(kù)的真實(shí)表名作為其參數(shù),這并不方便易用。在 Exposed 中也有類(lèi)似的 Table 設(shè)計(jì),用戶(hù)定義自己的 class 并繼承自 Table 抽象類(lèi),還要在其中定義一些表達(dá)列名的屬性。這種設(shè)計(jì)的最大問(wèn)題在于用戶(hù)總是要手動(dòng)編寫(xiě)大量樣板代碼。為了使這一步操作更方便,我希望 SQLlin 可以根據(jù)用戶(hù)期待序列化與反序列化的類(lèi)型自動(dòng)生成 Table 單例,以及其內(nèi)部的列名屬性。
Kotlin Symbol Processor(后簡(jiǎn)稱(chēng) KSP)是 Google 開(kāi)發(fā)的元編程工具,基于前文所說(shuō)的 KCP。它通常被用于注解處理及代碼生成,它的功能雖然不如 KCP 強(qiáng)大,但擁有較為完整的教程與文檔且更加易用。在 KSP 誕生之前,開(kāi)發(fā)者通常使用 KAPT 來(lái)進(jìn)行注解處理和代碼生成,但其二者處理 Kotlin 的階段不同,如下圖所示:
Kotlin 的編譯大概分為兩個(gè)階段,第一個(gè)階段由編譯器前端進(jìn)行,它將 Kotlin 代碼編譯為中間表示碼 IR,而編譯器后端則將 IR 編譯為各平臺(tái)的產(chǎn)物,由此實(shí)現(xiàn)了 Kotlin 的跨平臺(tái)。KAPT 技術(shù)基于 Java APT 技術(shù),它處理的是 JVM Bytecode,因此它僅僅能用于 Kotlin/JVM,無(wú)法實(shí)現(xiàn)跨平臺(tái)需求。并且將 Java 與 Kotlin 間的一些語(yǔ)法概念互相轉(zhuǎn)化相當(dāng)耗時(shí),這導(dǎo)致了 KAPT 的性能不夠好,導(dǎo)致了代碼編譯構(gòu)建的耗時(shí)增加。而 KSP 處理的則是中間表示碼 IR,相當(dāng)于在 Kotlin 編譯到各平臺(tái)產(chǎn)物之前對(duì)其進(jìn)行了處理,因此可以用于跨平臺(tái)場(chǎng)景,并且 IR 是 Kotlin 代碼的直接編譯產(chǎn)物,無(wú)須概念轉(zhuǎn)換,這使得 KSP 在一些較好的工況下性能可以比 KAPT 提升兩倍之多。
那我們?nèi)绾螌?shí)現(xiàn)注解處理?我們可以定義一個(gè)注解類(lèi),用戶(hù)將注解添加到希望表示表的 data class 即可,比如:
字符串"person"表示數(shù)據(jù)庫(kù)中真實(shí)的表名,它作為參數(shù)傳遞給注解,這樣 KSP 就能在代碼處理階段拿到它。在 KSP 處理后,可以生成以下代碼:
我們可以發(fā)現(xiàn),生成的 Table 中含有兩個(gè) name 以及兩個(gè) age。使用 val 聲明的屬性用于在條件語(yǔ)句中表示列名,而使用 var 聲明的則是 SetClause 的擴(kuò)展屬性,用于在 SET 子句中設(shè)置一個(gè)新值。之所以將二者分開(kāi)主要是因?yàn)槿绻胍?SET 子句中使用賦值運(yùn)算符 = 進(jìn)行 set,那么接收的參數(shù)則必須與該屬性類(lèi)型相同。舉例來(lái)說(shuō)如果將屬性聲明為 ClauseString 類(lèi)型,那么它的 setter 就無(wú)法接收 String 類(lèi)型的參數(shù)。
有了 KSP 的助力,用戶(hù)再也無(wú)須手動(dòng)編寫(xiě)大量的 Table 代碼,為使用帶來(lái)了極大的便利。
3.3.4 如何實(shí)現(xiàn)查詢(xún)結(jié)果的反序列化
在純 Android 庫(kù)的開(kāi)發(fā)中,我們通常會(huì)使用反射將某種格式的數(shù)據(jù)中的某個(gè)字段的值映射到與它名稱(chēng)相同的 class 中的某個(gè)屬性,從而生成出該 class 的對(duì)象,這就是反序列化。反射是 JVM 的機(jī)制,無(wú)法跨平臺(tái)。因此我們?nèi)绻?Kotlin Multiplatform 的環(huán)境中進(jìn)行反序列化,就必須另尋他路。
在 Kotlin Multiplatform 的開(kāi)發(fā)中,最常見(jiàn)的 JSON 和 ProtoBuf 的序列化與反序列化庫(kù)是官方的 kotlinx.serialization。它反序列化的原理是它通過(guò) KCP 處理注解,并生成了每個(gè)被注解類(lèi)的 KSerializer,KSerializer 是一個(gè)輔助類(lèi),它包含被注解類(lèi)的屬性名,屬性類(lèi)型等信息,kotlinx.serialization 正是通過(guò)它實(shí)現(xiàn)了非反射的序列化與反序列化。KCP 不僅使用門(mén)檻高,而且官方尚未正式發(fā)布(這意味著它沒(méi)有文檔且后續(xù) API 可能會(huì)發(fā)生大的破壞性變更),因此使用 KCP 仿造編寫(xiě)一個(gè)類(lèi)似的功能也同樣很難。但我在調(diào)研 kotlinx.serialization 的原理時(shí)發(fā)現(xiàn)它開(kāi)放了自定義數(shù)據(jù)格式的 API,我們可以直接復(fù)用 KSerializer。
在 sqllin-driver 中,查詢(xún)語(yǔ)句將會(huì)返回一個(gè) CommonCursor,這與 Android SQLite Java API 類(lèi)似。它可以進(jìn)行行迭代、獲取指定列名的列號(hào),以及 get 一些基本類(lèi)型和 String 等數(shù)據(jù),它的定義如下:
而我們的目的則正是將 CommonCursor 反序列化為自己的 data class。
自定義反序列化器非常簡(jiǎn)單,只需要繼承自 kotlinx.serialization 中提供的 AbstractDecoder 即可,核心實(shí)現(xiàn)如下:
我們自定義的 Decoder 接收一個(gè) CommonCursor 作為參數(shù)。decodeElementIndex 函數(shù)驅(qū)動(dòng)著整個(gè)反序列化流程。我們通過(guò)elementIndex 在該類(lèi)的眾多屬性中查找到當(dāng)前對(duì)應(yīng)的屬性名,再根據(jù)這個(gè)屬性名查詢(xún)到名稱(chēng)相同的列名的列號(hào),如果列號(hào)大于等于 0 則表示列名合法,直接返回 elementIndex 即可,否則進(jìn)行下一輪迭代。在針對(duì)各類(lèi)型的基本數(shù)據(jù)的反序列化中,我們直接調(diào)用CommonCursor 對(duì)應(yīng)的 get 函數(shù)取值并返回就可以了。
關(guān)于自定義 kotlinx.serialization,我曾經(jīng)寫(xiě)過(guò)一篇文章詳細(xì)討論,大家可以參考(參考鏈接 7),或者查看官方文檔(參考鏈接 8)。
3.3.5 最終效果
以上基本討論完了 sqllin-dsl 設(shè)計(jì)過(guò)程中遇到的大部分難點(diǎn)。在真實(shí)的開(kāi)發(fā)過(guò)程我們還解決了更多的問(wèn)題,其中很大一部分在于類(lèi)型設(shè)計(jì)。例如,某語(yǔ)句只能連接某子句,某子句后面不能連接某子句等等。利用 Kotlin 的語(yǔ)法規(guī)則可以在很大程度上保證在編譯期間暴露出我們編寫(xiě)的 SQL 錯(cuò)誤,并在絕大部分情況下阻止錯(cuò)誤的 SQL 語(yǔ)句代碼通過(guò)編譯。但這不是 100% 的,使用者仍然可能使用 SQLlin 編寫(xiě)出錯(cuò)誤的 SQL 語(yǔ)句,因此充分理解 SQL 知識(shí)對(duì)那些需要使用數(shù)據(jù)庫(kù)的開(kāi)發(fā)者來(lái)說(shuō)非常重要。在開(kāi)發(fā)完成之后,使用 sqllin-dsl 編寫(xiě)的真實(shí)代碼如下所示:
我們大體還原了最初的設(shè)計(jì)構(gòu)想,主要改變的地方有兩點(diǎn),首先是 Table 現(xiàn)在由 KSP 直接生成,不再依賴(lài)用戶(hù)手動(dòng)調(diào)用構(gòu)造函數(shù)。其次是我們最終沒(méi)能使用運(yùn)算符重載來(lái)實(shí)現(xiàn) ClauseElement 的運(yùn)算符,例如 > 和 <,原因除了重載函數(shù)的返回值類(lèi)型問(wèn)題,也包括如果要重載> 和 <,我們需要實(shí)現(xiàn) Comparable 接口,并覆蓋 compareTo 函數(shù)。但在用戶(hù)調(diào)用 compareTo 時(shí),它的內(nèi)部無(wú)法知道用戶(hù)到底調(diào)用的是> 還是 <,因此無(wú)法準(zhǔn)確構(gòu)建正確的 SQL 語(yǔ)句。最終我們舍棄了運(yùn)算符重載,轉(zhuǎn)而采用中綴函數(shù)實(shí)現(xiàn)。
在完成最終的設(shè)計(jì)后,SQLlin 的架構(gòu)設(shè)計(jì)圖調(diào)整為如下所示:
我們加入了 sqllin-processor 模塊,它主要包含 KSP 相關(guān)的代碼,負(fù)責(zé)注解處理與代碼生成。在與 Native 平臺(tái)交互這邊,架構(gòu)圖中添加了 SQLiter 的部分。
得益于 SQLiter 對(duì)多個(gè) Native 平臺(tái)的支持,SQLlin 支持的平臺(tái)數(shù)量也遠(yuǎn)超 Android、iOS 兩個(gè)移動(dòng)端平臺(tái):
- Android
- iOS (x64, arm32, arm64, simulatorArm64)
- macOS (x64, arm64)
- watchOS (x86, x64, arm32, arm64, simulatorArm64)
- tvOS (x64, arm64, simulatorArm64)
- Linux (x64)
四、未來(lái)計(jì)劃
SQLlin 目前已經(jīng)在 Github 開(kāi)源,大家可以前往它的主頁(yè)(參考鏈接 9)查看它更多的信息。
SQLlin 擁有全套的中英文文檔以及 Sample 項(xiàng)目供大家學(xué)習(xí)如何使用。
但 SQLlin 的開(kāi)發(fā)仍未結(jié)束,它目前仍然有一些不足,例如它還有如下功能不支持:
(1)不支持子查詢(xún),包括不支持條件語(yǔ)句中的子查詢(xún)和 JOIN 子查詢(xún)。
(2)不支持表創(chuàng)建、表刪除、增加列、刪除列等會(huì)導(dǎo)致數(shù)據(jù)庫(kù)結(jié)構(gòu)發(fā)生變化的 SQL 語(yǔ)句構(gòu)建。
只有將以上兩個(gè)功能開(kāi)發(fā)完成,SQLlin 才基本擁有應(yīng)對(duì)各種場(chǎng)景的能力。這兩項(xiàng)功能的實(shí)現(xiàn)會(huì)是當(dāng)下 SQLlin 后續(xù)迭代的主要工作。
此外,SQLiter 除了以上提到的 SQLlin 支持的平臺(tái)外,還支持 Windows。由于目前我們是本地編譯發(fā)布,而 Kotlin 當(dāng)前不支持類(lèi) Unix 系統(tǒng)和 Windows 系統(tǒng)的交差編譯,因此 SQLlin 暫時(shí)還不支持 Windows 平臺(tái)。等 SQLlin 的 Github CI/CD 配置完成后,Windows 也將加入受支持行列。
在最近的 Github issue 中我們發(fā)現(xiàn),有一些開(kāi)發(fā)者希望我們能考慮 JVM 后端場(chǎng)景,可以像 SQLDelight 一樣在 JVM 上連接后端數(shù)據(jù)庫(kù),這是個(gè)不錯(cuò)的建議,我們可以將其列為長(zhǎng)期規(guī)劃,不過(guò)目前 SQLlin 還是需要集中精力把客戶(hù)端上的事情做好。
Kotlin Multiplatform 這項(xiàng)技術(shù)最近進(jìn)展很快,特別是 compose-jb 在 iOS 上取得進(jìn)步令人振奮。機(jī)票團(tuán)隊(duì)除 UI 層以外已經(jīng)基本完成了基礎(chǔ)架構(gòu)建設(shè),后續(xù)會(huì)繼續(xù)調(diào)研 Kotlin Multiplatform 的 UI 跨端方案,并同步推進(jìn)更多的業(yè)務(wù)代碼向 KMM 的遷移。期待后續(xù)我們團(tuán)隊(duì)可以為社區(qū)帶來(lái)更多的貢獻(xiàn)。