某一天當我因為某個功能需要又一次建立一個很簡單的資料庫表,然後再為它寫增刪改查的操作時,我終於忍受不了了。對於寫程式碼這件事,我一貫的原則是少寫程式碼,少寫重複程式碼,而這些大同小異的增刪改查的xml配置,對我來說就是無腦重複的體力活。這是我無法接受的。
想想當初使用Spring Data JPA 的時候, 只需要宣告一個介面, 增刪改查的方法立馬就有了,而且對於一些簡單的查詢,通過特定格式的方法名字,宣告一個介面方法就能完成。但是JPA是基於hibernate,效率低而且很不靈活,所以大部分企業的ORM框架選擇的是MyBatis,所以JPA老早也被我拋棄了。
那麼我能不能在MyBatis之上構建一個類似Spring Data JPA的專案來完成像JPA一樣的功能呢?既能夠擁有JPA式的簡單,又能保持Mybatis的靈活高效。一開始的想法是基於Spring Data JPA的原始碼修改的,但是看了JPA原始碼之後我放棄了這個想法,程式碼太多了。後來偶然接觸到Mybatis Plus這個專案,讀了它的文件之後,突然有了思路,決定開始動手,基於Mybatis Plus來實現。
專案的功能特點:
- 支援根據DAO的方法名稱自動推斷新增、查詢、修改、刪除、統計、是否存在等資料庫操作
- 支援多種形式的表達,如findById,queryById,selectById是等價的,deleteById與removeById是等價的
- 支援根據物件結構自動解析resultMap(支援級聯的物件),不再需要在xml檔案中配置resultMap
- 支援join的推斷,複雜的sql也能自動推斷
- 支援分頁操作,支援spring data的Pageable物件分頁和排序
- 支援spring data的Pageable和Page物件,基本可以和jpa做到無縫切換
- 支援部分jpa註解:@Table、@Transient、@Id、@GeneratedValue,作用於持久化物件
- 支援自增主鍵回填,需要在主鍵屬性上新增jpa註解@GeneratedValue
設計思路
使用MyBatis Plus的Sql注入器
一切從這裡開始:
override fun getMethodList(): List<AbstractMethod> {
return listOf(
UnknownMethods()
)
}
複製程式碼
這裡只注入了一個Method,按照Mybatis Plus的設計思路,一個method只負責一個特定名稱方法的sql注入,但是通過閱讀AbstractMethod的程式碼瞭解到,實際是在一個Method中可以注入任意多的sql宣告,見如下程式碼:
/**
* 新增 MappedStatement 到 Mybatis 容器
*/
protected MappedStatement addMappedStatement(Class<?> mapperClass, String id, SqlSource sqlSource,
SqlCommandType sqlCommandType, Class<?> parameterClass,
String resultMap, Class<?> resultType,
KeyGenerator keyGenerator, String keyProperty, String keyColumn) {
...
}
複製程式碼
有了這個方法,你可以注入任意的sql宣告。
再回頭看上面,我只注入了一個UnknownMethods的注入方法,這裡本專案所有功能的入口。這個類的程式碼也不多,我直接放上來
override fun injectMappedStatement(mapperClass: Class<*>, modelClass: Class<*>, tableInfo: TableInfo): MappedStatement {
// 修正表資訊,主要是針對一些JPA註解的支援以及本專案中自定義的一些註解的支援,
MappingResolver.fixTableInfo(modelClass, tableInfo)
// 判斷Mapper方法是否已經定義了sql宣告,如果沒有定義才進行注入,這樣如果存在Mapper方法在xml檔案中有定義則會優先使用,如果沒有定義才會進行推斷
val statementNames = this.configuration.mappedStatementNames
val unmappedFunctions = mapperClass.kotlin.declaredFunctions.filter {
(mapperClass.name + DOT + it.name) !in statementNames
}
// 解析未定義的方法,進行sql推斷
val resolvedQueries = ResolvedQueries(mapperClass, unmappedFunctions)
unmappedFunctions.forEach { function ->
val resolvedQuery: ResolvedQuery = QueryResolver.resolve(function, tableInfo, modelClass, mapperClass)
resolvedQueries.add(resolvedQuery)
// query為null則表明推斷失敗,resolvedQuery中將包含推斷失敗的原因,會在後面進行統一輸出,方便開發人員瞭解sql推斷的具體結果和失敗的具體原因
if (resolvedQuery.query != null && resolvedQuery.sql != null) {
val sql = resolvedQuery.sql
try {
val sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass)
when (resolvedQuery.type()) {
in listOf(QueryType.Select,
QueryType.Exists,
QueryType.Count) -> {
val returnType = resolvedQuery.returnType
var resultMap = resolvedQuery.resultMap
if (resultMap == null && resolvedQuery.type() == QueryType.Select) {
// 如果沒有指定resultMap,則自動生成resultMap
val resultMapId = mapperClass.name + StringPool.DOT + function.name
resultMap = resolvedQuery.resolveResultMap(resultMapId, this.builderAssistant,
modelClass, resolvedQuery.query.mappings)
}
// addSelectMappedStatement這個方法中會使用預設的resultMap,該resultMap對映的型別和modelClass一致,所以如果當前方法的返回值和modelClass
// 不一致時,不能使用該方法,否則會產生型別轉換錯誤
if (returnType == modelClass && resultMap == null) {
addSelectMappedStatement(mapperClass, function.name, sqlSource, returnType, tableInfo)
} else {
addMappedStatement(mapperClass, function.name,
sqlSource, SqlCommandType.SELECT, null, resultMap, returnType,
NoKeyGenerator(), null, null)
}
// 為select查詢自動生成count的statement,用於分頁時查詢總數
if (resolvedQuery.type() == QueryType.Select) {
addSelectMappedStatement(mapperClass, function.name + COUNT_STATEMENT_SUFFIX,
languageDriver.createSqlSource(configuration, resolvedQuery.countSql(), modelClass),
Long::class.java, tableInfo
)
}
}
QueryType.Delete -> {
addDeleteMappedStatement(mapperClass, function.name, sqlSource)
}
QueryType.Insert -> {
// 如果id型別為自增,則將自增的id回填到插入的物件中
val keyGenerator = when {
tableInfo.idType == IdType.AUTO -> Jdbc3KeyGenerator.INSTANCE
else -> NoKeyGenerator.INSTANCE
}
addInsertMappedStatement(
mapperClass, modelClass, function.name, sqlSource,
keyGenerator, tableInfo.keyProperty, tableInfo.keyColumn
)
}
QueryType.Update -> {
addUpdateMappedStatement(mapperClass, modelClass, function.name, sqlSource)
}
else -> {
}
}
} catch (ex: Exception) {
LOG.error("""出錯了 >>>>>>>>
可能存在下列情形之一:
${possibleErrors.joinToString { String.format("\n\t\t-\t%s\n", it) }}
""".trimIndent(), ex)
}
}
}
resolvedQueries.log()
// 其實這裡的return是沒有必要的,mybatis plus也沒有對這個返回值做任何的處理,
// 所裡這裡隨便返回了一個sql宣告
return addSelectMappedStatement(mapperClass,
"unknown",
languageDriver.createSqlSource(configuration, "select 1", modelClass),
modelClass, tableInfo
)
}
複製程式碼
具體對於方法名稱的解析,程式碼比較多,這裡也無法一一放上來給大家講解,所以只講一下思路,方法名稱並不能包含所有構建sql所需的資訊,所有仍需要一些額外的資訊輔助,這些資訊基本上都來自於註解。
提供元資訊的註解說明
@Handler 註解在持久化類的屬性上,表明該屬性需要進行型別轉換,註解的value值是mybatis的typeHandler類
@InsertIgnore 註解在持久化類的屬性上,表明該屬性不參與資料庫插入操作
@UpdateIgnore 註解在持久化類的屬性上,表明該屬性不參與資料庫更新操作
@SelectIgnore 註解在持久化類的屬性上,表明該屬性不參與資料庫查詢操作
@JoinObject 表明該屬性是一個關聯的複雜物件,該物件的內容來自於關聯的另一張資料庫表
@JoinProperty 表明該屬性是一個關聯屬性,屬性內容來自於某個關聯表的欄位
@ModifyIgnore 註解在持久化類的屬性上,表明該屬性不參與資料庫更新和查詢操作
@ResolvedName 註解在Mapper介面的方法上,表示sql推斷使用註解指定的名稱而不是方法名稱,這樣可以不用為了sql推斷而更改方法名,使方法名更具邏輯化
@SelectedProperties 註解在Mapper介面的方法上,表明sql查詢、插入、或更新所使用的持久化物件的屬性集合
@ValueAssign 用於在@ResolvedName指定某個條件使用特定值
有了以上註解的資訊,結合方法名稱的推斷,可以完成百分之八十以上的資料庫操作的自動推斷,在簡單的應用場景下,可以一個xml檔案都不寫就能完成資料庫操作,而且後面要加入xml配置也完全不受影響。
使用方法
第一步: 新增maven倉庫
<distributionManagement>
<repository>
<id>nexus</id>
<url>http://nexus.aegis-info.com/repository/maven-releases/</url>
</repository>
</distributionManagement>
複製程式碼
第二步:在pom中引用依賴
<dependency>
<groupId>com.aegis</groupId>
<artifactId>aegis-starter-mybatis</artifactId>
<version>${mybatis-starter.version}</version>
</dependency>
複製程式碼
配置說明
本專案的引入使用無需任何配置(當然mybatis的配置是必要的)即可使用
@Mapper註解的DAO介面是否需要sql推斷是__可選__的,且mapper的xml檔案的配置是具有更高優先順序的,如果一個方法在xml中存在配置,則sql推斷自動失效
本外掛的使用可以是漸進式的,一開始在專案中使用本外掛對原專案沒有任何影響,可以先嚐試刪除一些方法的xml配置,讓其使用sql推斷,如果能夠正常工作,則可繼續去除xml,直到xml達到最簡化
啟用sql推斷
讓@Mapper註解的DAO介面繼承 XmlLessMapper 介面即可實現DAO的sql推斷
XmlLessMapper介面接收一個泛型引數,即該DAO要操作的物件,所有的sql推斷都是基於該物件的
XmlLessMapper介面沒有任何預設的方法,不會影響原有程式碼
原來使用mybatis-plus的方法注入需要繼承BaseMapper介面,但BaseMapper介面有很多方法,可能大部分方法都是不需要的,所以我改寫了這個邏輯,一個預設的方法也不新增,讓開發自行新增DAO所需要的方法,
功能增強說明
表名稱支援jpa註解__@Table__,原mybatis-plus的@TableName註解仍然有效,但@Table註解的優先順序更高
主鍵屬性支援jpa註解__@Id__
sql推斷說明
select查詢推斷
- 從方法名稱中推斷的欄位名稱均為mapper關聯資料物件的屬性名稱,而非資料庫中的表欄位名稱
例1 findById
解析為
SELECT * FROM table WHERE id = #{id}
複製程式碼
例2 findByName
解析為
SELECT * FROM table WHERE name = #{name}
複製程式碼
例3 findByNameLike
解析為
SELECT * FROM table WHERE name LIKE CONCAT('%',#{name}, '%')
複製程式碼
例4 findByNameLikeKeyword
解析為
SELECT * FROM table WHERE name LIKE CONCAT('%',#{keyword}, '%')
複製程式碼
例5 findByNameEqAndId
解析為
SELECT * FROM table WHERE name = #{name} AND id = #{id}
複製程式碼
例6 findIdAndNameByAge
解析為
SELECT id, name FROM table WHERE age = #{age}
複製程式碼
sql推斷名稱與方法名稱隔離
在mapper方法上使用@ResolvedName註解,該註解的必選引數name將會代替方法名稱作為推斷sql的名稱,這樣可以讓方法名稱更具語義化
例如
@ResolvedName("findIdAndNameAndAge")
fun findSimpleInfoList(): List<User>
複製程式碼
將使用 findIdAndNameAndAge 推斷sql,推斷的結果為:
SELECT id,name,age FROM user
複製程式碼
指定方法獲取的屬性集合
使用 @SelectedProperties註解
例如
@SelectedProperties(properties=["id", "name", "age"])
fun findSimpleInfoList(): List<User>
複製程式碼
上一個示例中的 @ResolvedName("findIdAndNameAndAge") 便可以用 @SelectedProperties(properties=["id", "name", "age"]) 來代替
- 注:使用@SelectedProperties註解之後,從方法名中推斷的查詢屬性將被忽略
delete操作推斷
支援 deleteAll deleteById deleteByName的寫法
update操作推斷
支援 update 一個物件或 update某個欄位
為了防止出現資料更新錯誤,update操作必須指定物件的主鍵屬性
例1:
fun update(user: User): Int
複製程式碼
最終解析為:
UPDATE
user
SET
user.name = #{name},
user.password = #{password},
user.email = #{email}
WHERE
id = #{id}
複製程式碼
例2:
fun updateNameById(name:String,id:Int): Int
複製程式碼
UPDATE
user
SET
user.name = #{name}
WHERE
id = #{id}
複製程式碼
支援 Insert 操作
支援批量插入
join的支援
join 一個物件
在持久化物件中可以關聯另外一個物件,這個物件對應資料庫中的另外一張表,那麼在查詢的時候如果需要級聯查詢可以這樣配置:
在關聯的物件(支援單個物件或物件集合,即一對一或一對多的關係都可以支援)屬性上新增註解:
@JoinObject(
targetTable = "t_score",
targetColumn = "student_id",
joinProperty = "id",
associationPrefix = "score_",
selectColumns = ["score", "subject_id"]
)
複製程式碼
註解中的屬性作用如下: targetTable 需要join的表 targetColumn join的表中用於關聯的列名稱 joinProperty 當前物件中用於關聯的屬性名稱(注意是物件屬性名稱而不是列名稱) associationPrefix 為防止列名稱衝突,給關聯表的屬性別名新增固定字首 selectColumns 關聯表中需要查詢的列集合
- 注:如果關聯的是物件集合,在kotlin中必須宣告為可變的集合
Spring Data的支援
專案提供了對Spring Data的一些支援,相容spring data的Pageable物件作為引數進行分頁和排序,並支援Page物件作為返回接受分頁的資料和資料總數。
測試
建立資料表
CREATE TABLE t_student
(
id VARCHAR(20) NOT NULL,
name VARCHAR(20) NOT NULL,
phone_number VARCHAR(20) NOT NULL,
sex INT NOT NULL,
CONSTRAINT t_student_id_uindex
UNIQUE (id)
);
ALTER TABLE t_student
ADD PRIMARY KEY (id);
CREATE TABLE t_score
(
id INT AUTO_INCREMENT
PRIMARY KEY,
score INT NOT NULL,
student_id VARCHAR(20) NOT NULL,
subject_id INT NOT NULL
);
CREATE TABLE t_subject
(
id INT AUTO_INCREMENT
PRIMARY KEY,
name VARCHAR(20) NOT NULL,
CONSTRAINT t_subject_name_uindex
UNIQUE (name)
);
複製程式碼
建立資料物件
/**
*
* @author 吳昊
* @since 0.0.4
*/
class Student() {
@TableField("sex")
var gender: Int = 1
@Id
var id: String = ""
var name: String = ""
var phoneNumber: String = ""
@JoinObject(
targetTable = "t_score",
targetColumn = "student_id",
joinProperty = "id",
associationPrefix = "score_",
selectColumns = ["score", "subject_id"]
)
@ModifyIgnore
var scores: MutableList<Score>? = null
constructor(id: String, name: String, phoneNumber: String, gender: Int)
: this() {
this.id = id
this.name = name
this.phoneNumber = phoneNumber
this.gender = gender
}
}
class Score {
var score: Int = 0
var studentId: String = ""
var subjectId: Int = 0
}
複製程式碼
建立DAO
@Mapper
interface UserDAO : XmlLessMapper<User> {
fun deleteById(id: Int)
@SelectedProperties(["name"])
fun findAllNames(): List<String>
fun findById(id: Int): User?
@ResolvedName("findById")
fun findSimpleUserById(id: Int): UserSimple
fun save(user: User)
fun saveAll(user: List<User>)
fun update(user: User)
fun count(): Int
}
複製程式碼
編寫測試類
class StudentDAOTest : BaseTest() {
val id = "061251170"
@Autowired
private lateinit var studentDAO: StudentDAO
@Test
fun count() {
assert(studentDAO.count() > 0)
}
@Test
fun delete() {
val id = "061251171"
studentDAO.save(Student(
id,
"wuhao",
"18005184916", 1
))
assert(studentDAO.existsById(id))
studentDAO.deleteById(id)
assert(!studentDAO.existsById(id))
}
@Test
fun deleteByName() {
val id = "testDeleteByName"
val name = "nameOfTestDeleteByName"
studentDAO.save(
Student(
id,
name,
"18005184916", 1
)
)
assert(studentDAO.existsByName(name))
studentDAO.deleteByName(name)
assert(!studentDAO.existsByName(name))
}
@Test
fun existsByClientId() {
val id = "1234"
assert(!studentDAO.existsById(id))
}
@Test
fun findAll() {
val list = studentDAO.findAll()
val spec = list.first { it.id == id }
assert(spec.scores != null && spec.scores!!.isNotEmpty())
assert(list.isNotEmpty())
}
@Test
fun findById() {
val student = studentDAO.findById(id)
println(student?.scores)
assert(studentDAO.findById(id) != null)
}
@Test
fun findPage() {
studentDAO.findAllPageable(
PageRequest.of(0, 20)).apply {
this.content.map {
it.name + " / ${it.id}"
}.forEach { println(it) }
println(this.content.first().name.compareTo(this.content.last().name))
}
studentDAO.findAllPageable(
PageRequest.of(0, 20, Sort(Sort.Direction.DESC, "name"))).apply {
this.content.map {
it.name + " / ${it.id}"
}.forEach { println(it) }
println(this.content.first().name.compareTo(this.content.last().name))
}
studentDAO.findAllPageable(
PageRequest.of(0, 20, Sort.by("name"))).apply {
this.content.map {
it.name + " / ${it.id}"
}.forEach { println(it) }
println(this.content.first().name.compareTo(this.content.last().name))
}
}
@Test
fun save() {
studentDAO.deleteById(id)
assert(!studentDAO.existsById(id))
studentDAO.save(Student(
id,
"wuhao",
"18005184916", 1
))
assert(studentDAO.existsById(id))
}
@Test
fun saveAll() {
val id1 = "saveAll1"
val id2 = "saveAll2"
studentDAO.saveAll(
listOf(
Student(id1,
"zs", "123", 1),
Student(id2,
"zs", "123", 1)
)
)
assert(studentDAO.existsById(id1))
assert(studentDAO.existsById(id2))
studentDAO.deleteByIds(listOf("saveAll1", "saveAll2"))
assert(!studentDAO.existsById(id1))
assert(!studentDAO.existsById(id2))
}
@Test
fun selectPage() {
val page = studentDAO.findAllPage(PageRequest.of(0, 20))
println(page.content.size)
println(page.totalElements)
}
@Test
fun update() {
assert(
studentDAO.update(
Student(
"061251170", "zhangsan",
"17712345678",
9
)
) == 1
)
}
@Test
fun updateNameById() {
val id = "testUpdateNameById"
val oldName = "oldName"
val newName = "newName"
studentDAO.save(
Student(
id,
oldName,
"18005184916", 1
)
)
assert(studentDAO.findById(id)?.name == oldName)
assert(studentDAO.updateNameById(newName, id) == 1)
assert(studentDAO.findById(id)?.name == newName)
studentDAO.deleteById(id)
}
}
複製程式碼
測試結果
寫在最後
專案寫的比較倉促,大概花了一週的時間,程式碼質量會在後期進行一些優化和完善,但是目前我想要完成的功能基本上都已經完成了。
專案的Github地址: github.com/wuhao000/my…
歡迎大家使用並提出問題和建議