二次封裝 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% 會被問到這個功能

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