你還在用 MyBatis 嗎,Ktorm 了解一下?

自從 Google 宣布 Kotlin 成為 Android 的官方語言,Kotlin 可以說是突然火了一波。其實不僅僅是 Android,在服務端開發(fā)的領(lǐng)域,Kotlin 也可以說是優(yōu)勢明顯。由于其支持空安全、方法擴展、協(xié)程等眾多的優(yōu)良特性,以及與 Java 幾乎完美的兼容性,選擇 Kotlin 可以說是好處多多。

然而,切換到 Kotlin 之后,你還在用 MyBatis 嗎?MyBatis 作為一個 Java 的 SQL 映射框架,雖然在國內(nèi)使用人數(shù)眾多,但是也受到了許多吐槽。使用 MyBatis,你必須要忍受在 XML 里寫 SQL 這種奇怪的操作,以及在眾多 XML 與 Java 接口文件之間跳來跳去的麻煩,以及往 XML 中傳遞多個參數(shù)時的一坨坨 @Param 注解(或者你使用 Map?那就更糟了,連基本的類型校驗都沒有,參數(shù)名也容易寫錯)。甚至,在與 Kotlin 共存的時候,還會出現(xiàn)一些奇怪的問題,比如: Kotlin 遇到 MyBatis:到底是 Int 的錯,還是 data class 的錯?

這時,你可能想要一款專屬于 Kotlin 的 ORM 框架。它可以充分利用 Kotlin 的各種優(yōu)良特性,讓我們寫出更加 Kotlin 的代碼。它應該是輕量級的,只需要添加依賴即可直接使用,不需要各種麻煩的配置文件。它的 SQL 最好可以自動生成,不需要像 MyBatis 那樣每條 SQL 都自己寫,但是也給我們保留精確控制 SQL 的能力,不至于像 Hibernate 那樣難以進行 SQL 調(diào)優(yōu)。

如果你真的這么想的話,Ktorm 可能會適合你。Ktorm 是直接基于純 JDBC 編寫的高效簡潔的 Kotlin ORM 框架,它提供了強類型而且靈活的 SQL DSL 和方便的序列 API,以減少我們操作數(shù)據(jù)庫的重復勞動。當然,所有的 SQL 都是自動生成的。本文的目的就是對 Ktorm 進行介紹,幫助我們快速上手使用。

你可以在 Ktorm 的官網(wǎng)上獲取更詳細的使用文檔,如果使用遇到問題,還可以在 GitHub 提出 issue。如果 Ktorm 對你有幫助的話,請在 GitHub 留下你的 star,也歡迎加入我們,共同打造 Kotlin 優(yōu)雅的 ORM 解決方案。

Ktorm 官網(wǎng):https://ktorm.liuwj.me/
GitHub 地址:https://github.com/vincentlauvlwj/Ktorm

Hello, Ktorm!

還記得我們剛開始學編程的時候?qū)懙牡谝粋€程序嗎,現(xiàn)在我們先從 Ktorm 的 "Hello, World" 開始,了解如何快速地搭建一個使用 Ktorm 的項目。

Ktorm 已經(jīng)發(fā)布到 maven 中央倉庫和 jcenter,因此,如果你使用 maven 的話,首先需要在 pom.xml 文件里面添加一個依賴:

<dependency>
    <groupId>me.liuwj.ktorm</groupId>
    <artifactId>ktorm-core</artifactId>
    <version>${ktorm.version}</version>
</dependency>

或者 gradle:

compile "me.liuwj.ktorm:ktorm-core:${ktorm.version}"

在使用 Ktorm 之前,我們需要要讓它能夠了解我們的表結(jié)構(gòu)。假設(shè)我們有兩個表,他們分別是部門表 t_department 和員工表 t_employee, 它們的建表 SQL 如下,我們要如何描述這兩個表呢?

create table t_department(
  id int not null primary key auto_increment,
  name varchar(128) not null,
  location varchar(128) not null
);

create table t_employee(
  id int not null primary key auto_increment,
  name varchar(128) not null,
  job varchar(128) not null,
  manager_id int null,
  hire_date date not null,
  salary bigint not null,
  department_id int not null
);

一般來說,Ktorm 使用 Kotlin 中的 object 關(guān)鍵字定義一個繼承 Table 類的對象來描述表結(jié)構(gòu),上面例子中的兩個表可以像這樣在 Ktorm 中定義:

object Departments : Table<Nothing>("t_department") {
    val id by int("id").primaryKey()    // Column<Int>
    val name by varchar("name")         // Column<String>
    val location by varchar("location") // Column<String>
}

object Employees : Table<Nothing>("t_employee") {
    val id by int("id").primaryKey()
    val name by varchar("name")
    val job by varchar("job")
    val managerId by int("manager_id")
    val hireDate by date("hire_date")
    val salary by long("salary")
    val departmentId by int("department_id")
}

可以看到,DepartmentsEmployees 都繼承了 Table,并且在構(gòu)造函數(shù)中指定了表名,Table 類還有一個泛型參數(shù),它是此表綁定到的實體類的類型,在這里我們不需要綁定到任何實體類,因此指定為 Nothing 即可。表中的列則使用 val 和 by 關(guān)鍵字定義為表對象中的成員屬性,列的類型使用 int、long、varchar、date 等函數(shù)定義,它們分別對應了 SQL 中的相應類型。

定義好表結(jié)構(gòu)后,我們就可以使用 Database.connect 函數(shù)連接到數(shù)據(jù)庫,然后執(zhí)行一個簡單的查詢:

fun main() {
    Database.connect("jdbc:mysql://localhost:3306/ktorm", driver = "com.mysql.jdbc.Driver")

    for (row in Employees.select()) {
        println(row[Employees.name])
    }
}

這就是一個最簡單的 Ktorm 項目,這個 main 函數(shù)中只有短短三四行代碼,但是你運行它時,它卻可以連接到數(shù)據(jù)庫,自動生成一條 SQL select * from t_employee,查詢表中所有的員工記錄,然后打印出他們的名字。因為 select 函數(shù)返回的查詢對象實現(xiàn)了 Iterable<QueryRowSet> 接口,所以你可以在這里使用 for-each 循環(huán)語法。當然,任何針對 Iteralble 的擴展函數(shù)也都可用,比如 Kotlin 標準庫提供的 map/filter/reduce 系列函數(shù)。

SQL DSL

讓我們在上面的查詢里再增加一點篩選條件:

val names = Employees
    .select(Employees.name)
    .where { (Employees.departmentId eq 1) and (Employees.name like "%vince%") }
    .map { row -> row[Employees.name] }
println(names)

生成的 SQL 如下:

select t_employee.name as t_employee_name 
from t_employee 
where (t_employee.department_id = ?) and (t_employee.name like ?)

這就是 Kotlin 的魔法,使用 Ktorm 寫查詢十分地簡單和自然,所生成的 SQL 幾乎和 Kotlin 代碼一一對應。并且,Ktorm 是強類型的,編譯器會在你的代碼運行之前對它進行檢查,IDE 也能對你的代碼進行智能提示和自動補全。

實現(xiàn)基于條件的動態(tài)查詢也十分簡單,因為都是純 Kotlin 代碼,直接使用 if 語句就好,比 MyBatis 在 XML 里面寫 <if> 標簽好太多。

val names = Employees
    .select(Employees.name)
    .whereWithConditions {
        if (someCondition) {
            it += Employees.managerId.isNull()
        }
        if (otherCondition) {
            it += Employees.departmentId eq 1
        }
    }
    .map { it.getString(1) }

聚合查詢:

val t = Employees
val salaries = t
    .select(t.departmentId, avg(t.salary))
    .groupBy(t.departmentId)
    .having { avg(t.salary) greater 100.0 }
    .associate { it.getInt(1) to it.getDouble(2) }

Union:

Employees
    .select(Employees.id)
    .unionAll(
        Departments.select(Departments.id)
    )
    .unionAll(
        Departments.select(Departments.id)
    )
    .orderBy(Employees.id.desc())

多表連接查詢:

data class Names(val name: String, val managerName: String?, val departmentName: String)

val emp = Employees.aliased("emp")
val mgr = Employees.aliased("mgr")
val dept = Departments.aliased("dept")

val results = emp
    .leftJoin(dept, on = emp.departmentId eq dept.id)
    .leftJoin(mgr, on = emp.managerId eq mgr.id)
    .select(emp.name, mgr.name, dept.name)
    .orderBy(emp.id.asc())
    .map {
        Names(
            name = it.getString(1),
            managerName = it.getString(2),
            departmentName = it.getString(3)
        )
    }

插入:

Employees.insert {
    it.name to "jerry"
    it.job to "trainee"
    it.managerId to 1
    it.hireDate to LocalDate.now()
    it.salary to 50
    it.departmentId to 1
}

更新:

Employees.update {
    it.job to "engineer"
    it.managerId to null
    it.salary to 100

    where {
        it.id eq 2
    }
}

刪除:

Employees.delete { it.id eq 4 }

這就是 Ktorm 提供的 SQL DSL,使用這套 DSL,我們可以使用純 Kotlin 代碼來編寫查詢,不再需要在 XML 中寫 SQL,也不需要在代碼中拼接 SQL 字符串。而且,強類型的 DSL 還能讓我們獲得一些額外的好處,比如將一些低級的錯誤暴露在編譯期,以及 IDE 的智能提示和自動補全。最重要的是,它生成的 SQL 幾乎與我們的 Kotlin 代碼一一對應,因此雖然我們的 SQL 是自動生成的,我們?nèi)匀粚λ鼡碛薪^對的控制。

這套 DSL 幾乎可以覆蓋我們工作中常見的所有 SQL 的用法,比如 union、聯(lián)表、聚合等,甚至對嵌套查詢也有一定的支持。當然,肯定也有一些暫時不支持的用法,比如某些數(shù)據(jù)庫中的特殊語法,或者十分復雜的查詢(如相關(guān)子查詢)。這其實十分罕見,但如果真的發(fā)生,Ktorm 也提供了一些解決方案:

  • Ktorm 可以方便的對 SQL DSL 進行擴展,以支持某些數(shù)據(jù)庫中的特殊語法,這些擴展主要以獨立的 jar 包提供,比如 ktorm-support-mysql。當然,我們也能自己編寫擴展。
  • 對于確實無法支持的情況,Ktorm 也可以直接使用原生 SQL 進行查詢,并額外提供了一些方便的擴展函數(shù)支持。

更多 SQL DSL 的用法,請參考 Ktorm 的具體文檔。

實體類與列綁定

前面我們已經(jīng)介紹了 SQL DSL,但是如果只有 DSL,Ktorm 還遠不能稱為一個 ORM 框架。接下來我們將介紹實體類的概念,了解如何將數(shù)據(jù)庫中的表與實體類進行綁定,這正是 ORM 框架的核心:對象 - 關(guān)系映射。

我們?nèi)匀灰郧懊娴牟块T表 t_department 和員工表 t_employee 為例,創(chuàng)建兩個 Ktorm 的實體類,分別用來表示部門和員工這兩個業(yè)務概念:

interface Department : Entity<Department> {
    companion object : Entity.Factory<Department>()
    val id: Int
    var name: String
    var location: String
}

interface Employee : Entity<Employee> {
    companion object : Entity.Factory<Employee>()
    val id: Int?
    var name: String
    var job: String
    var manager: Employee?
    var hireDate: LocalDate
    var salary: Long
    var department: Department
}

可以看到,Ktorm 中的實體類都繼承了 Entity<E> 接口,這個接口為實體類注入了一些通用的方法。實體類的屬性則使用 var 或 val 關(guān)鍵字直接定義即可,根據(jù)需要確定屬性的類型及是否為空。

有一點可能會違背你的直覺,Ktorm 中的實體類并不是 data class,甚至也不是一個普通的 class,而是 interface。這是 Ktorm 的設(shè)計要求,通過將實體類定義為 interface,Ktorm 才能夠?qū)崿F(xiàn)一些特別的功能,以后你會了解到它的意義。

眾所周知,接口并不能實例化,既然實體類被定義為接口,我們要如何才能創(chuàng)建一個實體對象呢?其實很簡單,只需要像下面這樣,假裝它有一個構(gòu)造函數(shù):

val department = Department()

有心的同學應該已經(jīng)發(fā)現(xiàn),上面定義實體類接口的時候,還為這兩個接口都增加了一個伴隨對象。這個伴隨對象重載了 Kotlin 中的 invoke 操作符,因此可以使用括號像函數(shù)一樣直接調(diào)用。在 Ktorm 的內(nèi)部,我們使用了 JDK 的動態(tài)代理創(chuàng)建了實體對象。

還記得在上一節(jié)中我們定義的兩個表對象嗎?現(xiàn)在我們已經(jīng)有了實體類,下一步就是把實體類和前面的表對象進行綁定。這個綁定其實十分簡單,只需要在聲明列之后繼續(xù)鏈式調(diào)用 bindTo 函數(shù)或 references 函數(shù)即可,下面的代碼修改了前面的兩個表對象,完成了 ORM 綁定:

object Departments : Table<Department>("t_department") {
    val id by int("id").primaryKey().bindTo { it.id }
    val name by varchar("name").bindTo { it.name }
    val location by varchar("location").bindTo { it.location }
}

object Employees : Table<Employee>("t_employee") {
    val id by int("id").primaryKey().bindTo { it.id }
    val name by varchar("name").bindTo { it.name }
    val job by varchar("job").bindTo { it.job }
    val managerId by int("manager_id").bindTo { it.manager.id }
    val hireDate by date("hire_date").bindTo { it.hireDate }
    val salary by long("salary").bindTo { it.salary }
    val departmentId by int("department_id").references(Departments) { it.department }
}

命名規(guī)約:強烈建議使用單數(shù)名詞命名實體類,使用名詞的復數(shù)形式命名表對象,如:Employee/Employees、Department/Departments。

把兩個表對象與修改前進行對比,我們可以發(fā)現(xiàn)兩處不同:

  1. Table 類的泛型參數(shù),我們需要指定為實體類的類型,以便 Ktorm 將表對象與實體類進行綁定;在之前,我們設(shè)置為 Nothing 表示不綁定到任何實體類。
  2. 在每個列聲明函數(shù)的調(diào)用后,都鏈式調(diào)用了 bindToreferences 函數(shù)將該列與實體類的某個屬性進行綁定;如果沒有這個調(diào)用,則不會綁定到任何屬性。

列綁定的意義在于,通過查詢從數(shù)據(jù)庫中獲取實體對象的時候(如 findList 函數(shù)),Ktorm 會根據(jù)我們的綁定配置,將某個列的數(shù)據(jù)填充到它所綁定的屬性中去;在將實體對象中的修改更新到數(shù)據(jù)庫中的時候(如 flushChanges 函數(shù)),Ktorm 也會根據(jù)我們的綁定配置,將某個屬性的變更,同步更新到綁定它的那個列。

完成列綁定后,我們就可以使用針對實體類的各種方便的擴展函數(shù)。比如根據(jù)名字獲取員工:

val vince = Employees.findOne { it.name eq "vince" }
println(vince)

findOne 函數(shù)接受一個 lambda 表達式作為參數(shù),使用該 lambda 的返回值作為條件,生成一條查詢 SQL,自動 left jion 了關(guān)聯(lián)表 t_department。生成的 SQL 如下:

select * 
from t_employee 
left join t_department _ref0 on t_employee.department_id = _ref0.id 
where t_employee.name = ?

其他 find* 系列函數(shù):

Employees.findAll()
Employees.findById(1)
Employees.findListByIds(listOf(1))
Employees.findMapByIds(listOf(1))
Employees.findList { it.departmentId eq 1 }
Employees.findOne { it.name eq "vince" }

將實體對象保存到數(shù)據(jù)庫:

val employee = Employee {
    name = "jerry"
    job = "trainee"
    manager = Employees.findOne { it.name eq "vince" }
    hireDate = LocalDate.now()
    salary = 50
    department = Departments.findOne { it.name eq "tech" }
}

Employees.add(employee)

將內(nèi)存中實體對象的變化更新到數(shù)據(jù)庫:

val employee = Employees.findById(2) ?: return
employee.job = "engineer"
employee.salary = 100
employee.flushChanges()

從數(shù)據(jù)庫中刪除實體對象:

val employee = Employees.findById(2) ?: return
employee.delete()

更多實體 API 的用法,可參考列綁定實體查詢相關(guān)的文檔。

可以看到,只需要將表對象與實體類進行綁定,我們就可以使用這些方便的函數(shù),大部分對實體對象的增刪改查操作,都只需要一個函數(shù)調(diào)用即可完成,但 Ktorm 能做到的,還遠不止于此。

實體序列 API

除了 find* 函數(shù)以外,Ktorm 還提供了一套名為”實體序列”的 API,用來從數(shù)據(jù)庫中獲取實體對象。正如其名字所示,它的風格和使用方式與 Kotlin 標準庫中的序列 API 及其類似,它提供了許多同名的擴展函數(shù),比如 filter、map、reduce 等。

要獲取一個實體序列,我們可以在表對象上調(diào)用 asSequence 擴展函數(shù):

val sequence = Employees.asSequence()

Ktorm 的實體序列 API,大部分都是以擴展函數(shù)的方式提供的,這些擴展函數(shù)大致可以分為兩類,它們分別是中間操作和終止操作。

中間操作

這類操作并不會執(zhí)行序列中的查詢,而是修改并創(chuàng)建一個新的序列對象,比如 filter 函數(shù)會使用指定的篩選條件創(chuàng)建一個新的序列對象。下面使用 filter 獲取部門 1 中的所有員工:

val employees = Employees.asSequence().filter { it.departmentId eq 1 }.toList()

可以看到,用法幾乎與 kotlin.Sequence 完全一樣,不同的僅僅是在 lambda 表達式中的等號 == 被這里的 eq 函數(shù)代替了而已。filter 函數(shù)還可以連續(xù)使用,此時所有的篩選條件將使用 and 操作符進行連接,比如:

val employees = Employees
    .asSequence()
    .filter { it.departmentId eq 1 }
    .filter { it.managerId.isNotNull() }
    .toList()

生成 SQL:

select * 
from t_employee 
left join t_department _ref0 on t_employee.department_id = _ref0.id 
where (t_employee.department_id = ?) and (t_employee.manager_id is not null)

使用 sortedBysortedByDescending 對序列中的元素進行排序:

val employees = Employees.asSequence().sortedBy { it.salary }.toList()

使用 droptake 函數(shù)進行分頁:

val employees = Employees.asSequence().drop(1).take(1).toList()

終止操作

實體序列的終止操作會馬上執(zhí)行一個查詢,獲取查詢的執(zhí)行結(jié)果,然后執(zhí)行一定的計算。for-each 循環(huán)就是一個典型的終止操作,下面我們使用 for-each 循環(huán)打印出序列中所有的員工:

for (employee in Employees.asSequence()) {
    println(employee)
}

生成的 SQL 如下:

select * 
from t_employee 
left join t_department _ref0 on t_employee.department_id = _ref0.id

toCollection、toList 等方法用于將序列中的元素保存為一個集合:

val employees = Employees.asSequence().toCollection(ArrayList())

mapColumns 函數(shù)用于獲取指定列的結(jié)果:

val names = Employees.asSequenceWithoutReferences().mapColumns { it.name }

除此之外,還有 mapColumns2、mapColumns3 等更多函數(shù),它們用來同時獲取多個列的結(jié)果,這時我們需要在閉包中使用 PairTriple 包裝我們的這些字段,函數(shù)的返回值也相應變成了 List<Pair<C1?, C2?>>List<Triple<C1?, C2?, C3?>>

Employees
    .asSequenceWithoutReferences()
    .filter { it.departmentId eq 1 }
    .mapColumns2 { Pair(it.id, it.name) }
    .forEach { (id, name) ->
        println("$id:$name")
    }

生成 SQL:

select t_employee.id, t_employee.name
from t_employee 
where t_employee.department_id = ?

其他我們熟悉的序列函數(shù)也都支持,比如 fold、reduceforEach 等,下面使用 fold 計算所有員工的工資總和:

val totalSalary = Employees
    .asSequenceWithoutReferences()
    .fold(0L) { acc, employee -> 
        acc + employee.salary 
    }

序列聚合

實體序列 API 不僅可以讓我們使用類似 kotlin.Sequence 的方式獲取數(shù)據(jù)庫中的實體對象,它還支持豐富的聚合功能,讓我們可以方便地對指定字段進行計數(shù)、求和、求平均值等操作。

下面使用 aggregateColumns 函數(shù)獲取部門 1 中工資的最大值:

val max = Employees
    .asSequenceWithoutReferences()
    .filter { it.departmentId eq 1 }
    .aggregateColumns { max(it.salary) }

如果你希望同時獲取多個聚合結(jié)果,可以改用 aggregateColumns2aggregateColumns3 函數(shù),這時我們需要在閉包中使用 PairTriple 包裝我們的這些聚合表達式,函數(shù)的返回值也相應變成了 Pair<C1?, C2?>Triple<C1?, C2?, C3?>。下面的例子獲取部門 1 中工資的平均值和極差:

val (avg, diff) = Employees
    .asSequenceWithoutReferences()
    .filter { it.departmentId eq 1 }
    .aggregateColumns2 { Pair(avg(it.salary), max(it.salary) - min(it.salary)) }

生成 SQL:

select avg(t_employee.salary), max(t_employee.salary) - min(t_employee.salary) 
from t_employee 
where t_employee.department_id = ?

除了直接使用 aggregateColumns 函數(shù)以外,Ktorm 還為序列提供了許多方便的輔助函數(shù),他們都是基于 aggregateColumns 函數(shù)實現(xiàn)的,分別是 countany、noneall、sumBy、maxBy、minByaverageBy。

下面改用 maxBy 函數(shù)獲取部門 1 中工資的最大值:

val max = Employees
    .asSequenceWithoutReferences()
    .filter { it.departmentId eq 1 }
    .maxBy { it.salary }

除此之外,Ktorm 還支持分組聚合,只需要先調(diào)用 groupingBy,再調(diào)用 aggregateColumns。下面的代碼可以獲取所有部門的平均工資,它的返回值類型是 Map<Int?, Double?>,其中鍵為部門 ID,值是各個部門工資的平均值:

val averageSalaries = Employees
    .asSequenceWithoutReferences()
    .groupingBy { it.departmentId }
    .aggregateColumns { avg(it.salary) }

生成 SQL:

select t_employee.department_id, avg(t_employee.salary) 
from t_employee 
group by t_employee.department_id

在分組聚合時,Ktorm 也提供了許多方便的輔助函數(shù),它們是 eachCount(To)、eachSumBy(To)、eachMaxBy(To)、eachMinBy(To)、eachAverageBy(To)。有了這些輔助函數(shù),上面獲取所有部門平均工資的代碼就可以改寫成:

val averageSalaries = Employees
    .asSequenceWithoutReferences()
    .groupingBy { it.departmentId }
    .eachAverageBy { it.salary }

除此之外,Ktorm 還提供了 aggregatefold、reduce 等函數(shù),它們與 kotlin.collections.Grouping 的相應函數(shù)同名,功能也完全一樣。下面的代碼使用 fold 函數(shù)計算每個部門工資的總和:

val totalSalaries = Employees
    .asSequenceWithoutReferences()
    .groupingBy { it.departmentId }
    .fold(0L) { acc, employee -> 
        acc + employee.salary 
    }

更多實體序列 API 的用法,可參考實體序列序列聚合相關(guān)的文檔。

小結(jié)

本文從一個 "Hello, World" 程序開始,對 Ktorm 的幾大特性進行了介紹,它們分別是 SQL DSL、實體類與列綁定、實體序列 API 等。有了 Ktorm,我們就可以使用純 Kotlin 代碼方便地完成數(shù)據(jù)持久層的操作,不需要再使用 MyBatis 煩人的 XML。同時,由于 Ktorm 是專注于 Kotlin 語言的框架,因此沒有兼容 Java 的包袱,能夠讓我們更加充分地使用 Kotlin 各種優(yōu)越的語法特性,寫出更加優(yōu)雅的代碼。既然語言都已經(jīng)切換到 Kotlin,為何不嘗試一下純 Kotlin 的框架呢?

Enjoy Ktorm, enjoy Kotlin!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內(nèi)容