二次封裝 Spring Data JPA/MongoDB,打造更易用的資料訪問層

語言: CN / TW / HK

theme: vuepress

本文正在參加「金石計劃 . 瓜分6萬現金大獎」

最近我在做一個新專案,由於我們專案組一直使用的是 MongoDB 資料庫,所以新專案我就打算上 Spring Data MongoDB 嘗試一下,雖然我早就用過了 Spring Data JPA,對 Spring Data 的相關 CRUD 和 動態查詢的封裝也比較熟悉,但是自帶的封裝顯然不能很好的滿足我們的需求,本篇帶大家講述我所遇到的問題以及解決方案。

注: MongoRepository / JPARepository 都繼承自 PagingAndSortingRepository,除了對應的資料庫不同之外,功能都基本相同,所以本文的二次封裝也可以用於 JPARepository 上。

1. 我遇到的問題

問題一

在 Spring Data 中可以通過繼承 MongoRepository / JPARepository 介面的方式獲得 CRUD 和 分頁的能力,但是這種能力也僅僅滿足基礎的 CRUD 操作和 分頁,對於極其常用的兩個操作比如:針對資料庫某個欄位進行更新多條件查詢,這個介面並沒有提供。

準確的來說,多條件查詢的能力是提供了,但是非常不宜用,它必須使用你的類做為查詢條件,這個類的變數名還必須和資料庫表中的欄位名保持一致,這可以非常簡單的讓我們想到使用 PO 類當作這個查詢條件。

但是在有些規範中,PO 類應該是一個擁有全參構造器的不可變類,這使得先建立這個類然後對應的查詢欄位進行賦值的操作變得不可行,這裡我舉一個簡單的例子,我擁有一個數據表的對映物件:User,這就是俗稱的 PO

@Document("user") class User ( ​    @Id    val id : String, ​    val account : String, ​    val pwd : String, ​    val name : String, )

然後我如果想要單獨更新 name 這個欄位時,我需要擁有整個 User 物件中的所有屬性,因為 Repository 介面所提供的能力是把新增操作和更新操作放在一起的 (save 方法),每次更新都是所有欄位的更新,這是我不願意看到的,也是極其麻煩的。

接著就是多條件查詢的問題,我們先來看下如果我想要使用多條件查詢,它的引數是什麼:

image-20221119161627689

可以明顯看到是一個叫 Example 的物件,如果我想使用,它應該是這樣的:

fun test() {                val user = CssUser()                user.name = "我要查詢的引數具體值" ​        userRepository.findAll(Example.of(user))   }

這裡我定義了一個 CssUser 去當它的查詢條件的類,而且這個類和 User 類的內容幾乎一樣,因為我的 User 類是一個全參構造器沒辦法直接建立一個空物件進行賦值,所以我不得不建立一個 CssUser 去當查詢條件的類,對於程式設計師來講,這很煩

我想要的效果是什麼樣的呢?是這樣的:

fun test() { ​        userRepository.listAll(Criteria                                   .where("account").`is`("admin")                                   .and("name").`is`("你的名字")       ) ​   }

通過 lambda 的方式直接獲取到某個屬性的名字,然後作為查詢變數,然後跟著鏈式呼叫可以隨便在裡面加上各樣的查詢條件,例子中的 Criteria 類是 Spring 已經為我們做好的,但是 Repository 介面並沒有提供它,所以我們需要一層封裝。

問題二

從上面的例子中我們可以看到在組裝查詢條件時,需要硬編碼進去欄位名,這對於程式設計師來說,是很煩的

所以我們應該使用 lambda 的特性,幫助我們去獲取某一個類的欄位名,通常是 PO,因為它和資料庫屬性是一一對應的,整體要達到的有點像 Mybatis-PLus 的效果,大概是這樣:

fun test() { ​        userRepository.listAll(Criteria                                   .where(CssUser::account.mongoFiled()).`is`("admin")                                   .and(CssUser::name.mongoFiled()).`is`("你的名字")       ) ​   }

當然我的這個效果還沒有 Mybatis-PLus 的效果好,它可以直接省略 .mongoFiled() 這個操作,這是因為我只加了三四行程式碼就能達到這個效果,對我而言夠用了,而 Mybatis-PLus 則是有一套相關支援。

雖然我這是 Kotlin 示例,但隨後也會給出 Java 語法中的相關思路。

2. Repository 介面封裝

先來談談對 CRUD 的增強,正常情況下,我們只需要使用一個介面繼承 MongoRepository 介面,然後 Spring Data 就會幫我們生成一個動態代理類,並宣告為 Bean,直接注入就可以使用了,就像這樣(程式碼中的 :語法是繼承的意思):

interface UserMongoRepository : MongoRepository<User, String> { ​ }

現在既然我們要對 Repository 進行增強,就需要再抽象出一個類,作為我們新的基類,之後的自己的業務類需要繼承這個介面,而非原來的 MongoRepository 介面,當然,我們這個新的基類介面還會去繼承 MongoRepository 介面,然後在介面中定義我們需要的新操作即可:

@NoRepositoryBean interface BaseMongoRepository<T, ID> : MongoRepository<T, ID> { ​    fun listAll(condition: Criteria, pageable: Pageable): Page<T> ​    fun updateById(id: ID, update: Update): Long }

我建立了一個新的介面:BaseMongoRepository,用它來繼承 MongoRepository,接著定義我們需要的擴充套件的一些方法,這裡我擴充套件類了兩個方法:新的多條件分頁方法和新的更新介面。

其中 listAll 方法的第一個引數 Criteria 是 Spring Data 已經給我們提供好的類,它廣泛運用於 MongoTemplate 裡面,畢竟這層 CRUD 的封裝底層其實還是 MongoTemplate 來操作。

除了繼承介面外,我們還需要對這兩個方法進行實現,再建立一個 BaseMongoRepository 的實現類去繼承 MongoRepository 的實現類——SimpleMongoRepository

class BaseMongoRepositoryClass<T, ID>(    private val metadata: MongoEntityInformation<T, ID>,    private val mongoOperations: MongoOperations ) :    SimpleMongoRepository<T, ID>(metadata, mongoOperations), BaseMongoRepository<T, ID> { ​    private val clazz: Class<T> = metadata.javaType ​    override fun listAll(condition: Criteria, pageable: Pageable): Page<T> {        val list = mongoOperations.find(Query(condition).with(pageable), this.clazz, metadata.collectionName) ​        return PageableExecutionUtils.getPage(list, pageable) {            mongoOperations               .count(                    Query(condition).limit(-1).skip(-1),                    clazz,                    metadata.collectionName               )       }   } ​    override fun updateById(id: ID, update: Update): Long {        if (update.updateObject.isEmpty()) return 0        return mongoOperations.updateFirst(            Query().addCriteria(Criteria.where("_id").`is`(id)),            update,            metadata.collectionName       ).modifiedCount   } ​ ​ }

其中 BaseMongoRepositoryClass 需要兩個引數,這兩個引數直接從 SimpleMongoRepository 裡面拷貝過來然後通過構造再傳遞給 SimpleMongoRepository 即可,反正都是從自動注入裡面來。

兩個變數簡單講解一下都是什麼意思:

  1. MongoEntityInformation:這個是 MongoEntity 的元資訊,就是最上面用 @Document 註解標記的 PO 類的元資訊,我們可以通過它拿到 PO 類的型別和資料表的名字。
  2. MongoOperations:MongoTemplate 的實現類,這個我想不用多談。

接著就是方法實現,方法實現就是就是通過 MongoTemplate 操作了這個這個方法要做什麼事,程式碼都比較簡單因為不包含什麼邏輯,熟悉 MongoTemplate 的一眼就可看懂。

接下來就是最重要的一步,沒有這一步一切都是白費,還會造成專案啟動失敗,那就是把這個新的基類告訴 Spring,這是新的基類,你可以在專案的入口中加上這一句註解:

@EnableMongoRepositories(basePackages = ["com.xxx.*"], repositoryBaseClass = BaseMongoRepositoryClass::class) class AdminApplication ​ fun main(args: Array<String>) {    runApplication<AdminApplication>(*args) }

指定一下 repositoryBaseClass,這樣生成動態代理的時候會以這個類為基類,我們動態代理類也就具有了我們定義的兩個方法的能力了,使用中和原來的一樣,只不過繼承的介面不同罷了:

interface UserRepository : BaseMongoRepository<User, String> { ​ }

到這一步,我們可以完成這個效果:

fun test() { ​        userRepository.listAll(Criteria                                   .where("account").`is`("admin")                                   .and("name").`is`("你的名字")       ) ​   }

3. 實體類變數進行 lambda 封裝

接下來是對實體變數進行 lambda 封裝,這個東西我覺得可以分為 Kotlin 和 Java 兩個版本來說,兩者各有千秋。

先來說說Kotlin,因為 Kotlin 自身的語言特性的關係,實現起來比較簡單,但也會拖一個尾巴,Kotlin 具有一個擴充套件函式的能力,簡單點說就是直接給某個類加上一些自定義方法,比如 String 我們可以在不繼承的情況下直接給 String 類加上一個新的方法,然後它就會出現在 String 物件可呼叫的函式列表中。

所以我們如果想要 User::account.mongoFiled() 這種效果,就得先知道 User::account 返回值是什麼,在 Kotlin 中,它的返回值是一個 KProperty 類物件,那麼我們直接給這個類加上擴充套件如下:

fun KProperty<*>.mongoFiled(): String {    if (this.hasAnnotation<Id>()) return "_id"    return this.findAnnotation<Field>()?.run {        this.name.ifEmpty { [email protected] }   } ?: this.name }

這樣在 lambda 呼叫下就可以再呼叫這個方法了,接著來看看方法內容。

  1. 首先判斷了是否存在 ID 註解,這個 ID 註解是用來標識 Mongo 的主鍵屬性的註解,這種註解標識的變數在資料庫中統一叫做 "_id",所以這裡我也返回這個名字。
  2. 接著判斷是否存在 Field 註解,它是用來標識資料庫欄位和類變數不一樣的情況,如果出現這種情況,我們使用註解所標識的欄位名。
  3. 最後,以上兩種情況排除後,我們直接使用這個欄位的名字。

這樣就可以達到如下效果了:

fun test() { ​        userRepository.listAll(Criteria                                   .where(CssUser::account.mongoFiled()).`is`("admin")                                   .and(CssUser::name.mongoFiled()).`is`("你的名字")       ) ​   }

接著我們可以來說說 Java 的做法,首先也需要一個方法通過 lambda 拿到欄位名,這個方法網上有很多我不再贅述,但是拿到之後該怎麼辦呢?

你當然可以直接通過工具類的靜態方法去拿,就像這樣:

fun test() { ​        userRepository.listAll(Criteria                                   .where(Util.getName(CssUser::account).`is`("admin")                                   .and(Util.getName(CssUser::name).`is`("你的名字")       ) ​   }

可能到這一步看起來還是略微不雅,追求極致的小夥伴這個時候就可以再度發揮封裝的本色,將 Criteria 類封裝出一個新的查詢條件類,比如叫 Condition,然後將 Criteria 裝在裡面再封裝一下查詢時的相關常用方法,就像這樣(注意此處的 Funtion 入參只是一個例子,實際應該是泛型):

public class Condition {        private Criteria criteria = new Criteria(); ​    public Condition where(Function<String, String> function, String value) {        criteria.andOperator(Criteria.where(Util.getName(function)).is(value));        return this;   } }

除了 where 方法你還可以繼續封裝 gt、lt、or 等常用方法,並且它們還能形成鏈式呼叫,最終的效果是這樣的:

public static void main(String[] args) {        Criteria criteria = new Condition()               .where(CssUser::getName, "你的名字")               .where(CssUser::getAccount, "admin");   }

是不是更優雅了呢?

4. 最後

今天是滿滿的技術乾貨,希望 Get 到新技能的小夥伴可以積極的點贊,有什麼問題都可以再評論區留言,我會積極對線的,下篇見。

作者其他文章:

「微服務閘道器實戰一」SCG 和 APISIX 該怎麼選?

「微服務閘道器實戰二」SCG + Nacos 動態感知上下線

「微服務閘道器實戰三」詳細理解 SCG 路由、斷言與過濾器

「微服務閘道器實戰四」隨意擴充套件定製的分散式限流,看看我怎麼做

「微服務閘道器實戰五」做網關係統, 99% 會被問到這個功能

「微服務閘道器實戰六」後端自學兩個小時前端,究竟能做出什麼東西?