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

某一天當我由於某個功能須要又一次建立一個很簡單的數據庫表,而後再爲它寫增刪改查的操做時,我終於忍受不了了。對於寫代碼這件事,我一向的原則是少寫代碼,少寫重複代碼,而這些大同小異的增刪改查的xml配置,對我來講就是無腦重複的體力活。這是我沒法接受的。java

想一想當初使用Spring Data JPA 的時候, 只須要聲明一個接口, 增刪改查的方法立馬就有了,並且對於一些簡單的查詢,經過特定格式的方法名字,聲明一個接口方法就能完成。可是JPA是基於hibernate,效率低並且很不靈活,因此大部分企業的ORM框架選擇的是MyBatis,因此JPA老早也被我拋棄了。git

那麼我能不能在MyBatis之上構建一個相似Spring Data JPA的項目來完成像JPA同樣的功能呢?既可以擁有JPA式的簡單,又能保持Mybatis的靈活高效。一開始的想法是基於Spring Data JPA的源碼修改的,可是看了JPA源碼以後我放棄了這個想法,代碼太多了。後來偶然接觸到Mybatis Plus這個項目,讀了它的文檔以後,忽然有了思路,決定開始動手,基於Mybatis Plus來實現。github

項目的功能特色:

  • 支持根據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注入器

一切從這裏開始:spring

override fun getMethodList(): List<AbstractMethod> {
    return listOf(
        UnknownMethods()
    )
}
複製代碼

這裏只注入了一個Method,按照Mybatis Plus的設計思路,一個method只負責一個特定名稱方法的sql注入,可是經過閱讀AbstractMethod的代碼瞭解到,實際是在一個Method中能夠注入任意多的sql聲明,見以下代碼: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的注入方法,這裏本項目全部功能的入口。這個類的代碼也很少,我直接放上來bash

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所需的信息,全部仍須要一些額外的信息輔助,這些信息基本上都來自於註解。mybatis

提供元信息的註解說明

@Handler 註解在持久化類的屬性上,代表該屬性須要進行類型轉換,註解的value值是mybatis的typeHandler類app

@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…

歡迎你們使用並提出問題和建議

相關文章
相關標籤/搜索