Mybatis去xml化:我再也不想寫xml了

吳昊87發表於2018-12-14

某一天當我因為某個功能需要又一次建立一個很簡單的資料庫表,然後再為它寫增刪改查的操作時,我終於忍受不了了。對於寫程式碼這件事,我一貫的原則是少寫程式碼,少寫重複程式碼,而這些大同小異的增刪改查的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)
  }

}
複製程式碼

測試結果

Mybatis去xml化:我再也不想寫xml了

寫在最後

專案寫的比較倉促,大概花了一週的時間,程式碼質量會在後期進行一些優化和完善,但是目前我想要完成的功能基本上都已經完成了。

專案的Github地址github.com/wuhao000/my…

歡迎大家使用並提出問題和建議

相關文章