開發與維運

Let’s Fluent:更順滑的MyBatis

image.png

作者 | 金戟
來源 | 阿里技術公眾號

只需瞅一眼Google Trends上全球Java界最熱門的兩款SQL映射框架近一年的對比數字,就不難了解其實力分佈:在此領域,MyBatis早已佔領東亞地區開發者市場,並以絕對優勢穩居中國最搶手Java數據庫訪問框架之首。

MyBatis霸榜的底氣來源於其廣袤的生態以及國內眾多大廠的支持。而在琳琅滿目的MyBatis擴展中,還埋藏著許多“寶藏項目”,來自阿里技術團隊的Fluent MyBatis便是其中一顆獨特的新星。

一 普拉斯們不香了

從iBatis到MyBatis,再到國內團隊以MyBatis Plus為典型代表的諸多周邊工具,"Batis"系列套餐的發展歷程,幾乎又是一部XML的興衰史。最初的iBatis誕生於2002年,彼時XML在Java乃至整個軟件技術界都還相當盛行,和同時期的許多項目一樣,iBatis硬生生的將一堆堆XML塞進千家萬戶的項目裡。

許多年後,曾今與iBatis並肩過的社區戰友們紛紛淡出了歷史舞臺,少數像Spring這樣延續至今的佼佼者,也逐漸摒棄XML,向代碼化配置的方式發展。在這方面,iBatis一直是個保守派,即使在MyBatis接過iBatis的衣缽之後,也只是”重磅“推出了支持代碼執行SQL的@Select/@Insert/@Update/@Delete註解(以及相應的4種Provider註解),用來抵擋開發者們對XML氾濫的吐槽,這是在2010年中旬,然後就再無動作。直到2016年底,MyBatis的主要貢獻者之一Jeff Butler正式創建MyBatis Dynamic SQL項目,MyBatis終於開始全面擁抱無XML的代碼化SQL構建。

在從MyBatis到MyBatis Dynamic SQL之間長達6年多的空窗期裡,開源社區催生出了許多民間基於MyBatis的無XML代碼方案,其中流行得比較廣泛的是Tk Mybatis、MyBatis Plus這類內置Mapper和自動生成CRUD的擴展庫,一經推出就收穫諸多好評。包括MyBatis Plus裡實際上並不太完備的"條件構造器"功能,也由於當時同類解決方案的匱乏而頗受追捧。與此同時,在MyBatis社區之外,一直在默默發展的JOOQ是一款歷史與MyBatis幾乎同樣悠久的純Java動態SQL執行庫,它的用戶群體不大,卻口碑甚好。如今在任意搜索引擎上輸入"MyBatis vs JOOQ",依然能得到幾乎是一邊倒選擇JOOQ的結果,大家給出的理由也非常一致:簡潔、靈活、無需XML,很"Java"。而在MyBatis陣營裡,若是拿出MyBatis Plus的"條件構造器"與之正面對陣,只消三個回合,就會被屁滾尿流的打出擂臺。只可惜JOOQ的家底沒有MyBatis那樣殷實,早早走上了商業數據庫支持賣License收費的道路,才讓MyBatis免於在輿論上迎來自己的中年危機。

Fluent MyBatis誕生於2019年底,即使與MyBatis Dynamic SQL相比都是晚輩,然而尚處成長期的它就已透出了青出於藍而勝於藍的味道。

在實現方式上,MyBatis Plus覆寫並替換了部分MyBatis內部類型的方法,整體機制較重,卻也因此能將一些功能細節隱藏到用戶無需關注的內部邏輯裡;與之相反,MyBatis Dynamic SQL的實現機制非常輕量,不僅完全基於MyBatis原生的Provider系列註解開發,而且沒有什麼隱藏邏輯,對用戶的每張表自動生成相應的Entity、DynamicSqlSupport和Mapper三個類,全部放入用戶的源碼目錄裡,因此暴露的細節比較多,代碼侵入性略高。Fluent MyBatis取二者之所長,整體機制與MyBatis Dynamic SQL更接近,同樣基於原生的Provider註解,對用戶的每個表生成Entity類和默認空白的Dao類,不同之處在於它還會通過JVM編譯期代碼增強功能自動生成許多開發者不可更改的標準輔助類,這些代碼無需放入用戶的源碼目錄但能夠在編碼時直接使用,即提供豐富的功能,又保證了用戶代碼的整潔。

在使用方式上,Fluent MyBatis同樣借鑑了前輩們的最優實踐,沒有花裡胡哨的註解和配置,直接複用MyBatis連接,所有功能開箱即用。同時由於Fluent MyBatis將所有表字段、條件、操作都以方法調用形式提供,因此獲得了比其他同類項目都更好的IDE語法輔助。舉一個不太複雜的例子:

// 使用Fluent MyBatis構造查詢語句
mapper.listMaps(new StudentScoreQuery()
    .select
    .schoolTerm()
    .subject()
    .count.score("count")
    .min.score("min_score")
    .max.score("max_score")
    .avg.score("avg_score")
    .end()
    .where.schoolTerm().ge(2000)
    .and.subject.in(new String[]{"英語", "數學", "語文"})
    .and.score().ge(60)
    .and.isDeleted().isFalse()
    .end()
    .groupBy.schoolTerm().subject().end()
    .having.count.score.gt(1).end()
    .orderBy.schoolTerm().asc().subject().asc().end()
);

MyBatis Dynamic SQL的語法也比較美觀,但字段名和min/max/avg等方法都需要靜態引用,比Fluent MyBatis稍顯遜色。

// 使用MyBatis Dynamic SQL構造查詢語句
mapper.selectMany(
    select(
        schoolTerm,
        subject,
        count(score).as("count"),
        min(score).as("min_score"),
        max(score).as("max_score"),
        avg(score).as("avg_score")
    ).from(studentScore)
    .where(schoolTerm, isGreaterThanOrEqualTo(2000))
    .and(subject, isIn("英語", "數學", "語文"))
    .and(score, isGreaterThanOrEqualTo(60))
    .and(isDeleted, isEqualTo(false))
    .groupBy(schoolTerm, subject)
    .having(count(score), isGreaterThan(1)) //當前其實還不支持having方法
    .orderBy(schoolTerm, subject)
    .build(isDeleted, isEqualTo(false))
    .render(RenderingStrategies.MYBATIS3)
);

JOOQ的歷史比較悠久,寫出來的代碼鋪天蓋地都是常量字段,功能強大但美觀度欠佳。

// 使用JOOQ構造查詢語句
dslContext.select(
    STUDENT_SCORE.GENDER_MAN,
    STUDENT_SCORE.SCHOOL_TERM,
    STUDENT_SCORE.SUBJECT,
    count(STUDENT_SCORE.SCORE).as("count"),
    min(STUDENT_SCORE.SCORE).as("min_score"),
    max(STUDENT_SCORE.SCORE).as("max_score"),
    avg(STUDENT_SCORE.SCORE).as("avg_score")
)
.from(STUDENT_SCORE)
.where(
    STUDENT_SCORE.SCHOOL_TERM.ge(2000),
    STUDENT_SCORE.SUBJECT.in("英語", "數學", "語文"),
    STUDENT_SCORE.SCORE.ge(60),
    STUDENT_SCORE.IS_DELETED.eq(false)
)
.groupBy(
    STUDENT_SCORE.GENDER_MAN,
    STUDENT_SCORE.SCHOOL_TERM,
    STUDENT_SCORE.SUBJECT
)
.having(count().ge(1))
.orderBy(
    STUDENT_SCORE.SCHOOL_TERM.asc(),
    STUDENT_SCORE.SUBJECT.asc()
)
.fetch();

MyBatis Plus的條件構造器僅僅封裝了基本的SQL操作,對於字段、條件、別名等都要使用字符串拼接,極易出現由於拼寫失誤引起的SQL異常。

// 使用MyBatis Plus構造查詢語句
mapper.selectMaps(new QueryWrapper<StudentScore>()
    .select(
        "school_term",
        "subject",
        "count(score) as count",
        "min(score) as min_score",
        "max(score) as max_score",
        "avg(score) as avg_score"
    )
    .ge("school_term", 2000)
    .in("subject", "英語", "數學", "語文")
    .ge("score", 60)
    .eq("is_deleted", false)
    .groupBy("school_term", "subject")
    .having("count(score)>1")
    .orderByAsc("school_term", "subject")
);

在Java動態SQL構建的功能完整度方面,當前的排序是MyBatis Plus < MyBatis Dynamic SQL < Fluent MyBatis < JOOQ。

MyBatis Plus條件構造器在功能性上完敗,不僅無法表達JOIN、UNION語句,嵌套查詢之類稍複雜SQL也完全沒招。MyBatis Dynamic SQL支持JOIN和UNION語句,尚未支持嵌套查詢,且缺少HAVING等少量標準SQL語法。Fluent MyBatis支持多表JOIN、UNION、嵌套查詢和幾乎所有標準SQL語法,對於絕大多數場景都妥妥夠用。JOOQ是真正的王者,不僅支持標準SQL語法,連各廠商特有的專有關鍵字和內置方法都沒放過,如MySQL的ON DUPLICATE KEY UPDATE、PostgreSQL的WINDOW、Oracle的CONNECT BY等等。補齊各種SQL語法是一件瑣碎而費力的工作,考慮到SQL語法的總量已經基本不再變化,相信假以時日,各方的差距會逐漸縮小。

除了SQL基本功,特別值得一提的是Fluent MyBatis的獨門絕技:支持動態換表名(FreeQuery/FreeUpdate特性)。在雲效項目的開發過程中,由於需要在各種嵌套查詢之上再根據視圖條件動態選擇聚合計算的維度表,多虧了Fluent MyBatis的動態表名功能,才得以在最大程度保留語法構造便利性的情況下,讓代碼複用成為可能。

相比密密麻麻的XML文件,Java代碼在易讀性和可維護性方面有著明顯的優勢。在官方和社區的共同推動下,一個全新的、代碼化的MyBatis生態正在冉冉升起。驀然回首,曾經驕傲的"Plus擴展"們全都不香了。

二 優雅的數據流

初識Fluent MyBatis,最明顯能感受到的特點是它及其便利的IDE語法提示。

基於數據表自動生成的Entity、Mapper、Query、Update等對象,讓所有的數據庫字段和SQL操作都變成了方法,串成平整的流式語句。即使是層層嵌套的查詢,也能表現得錯落有致:

new StudentQuery()
    .where.isDeleted().isFalse()
    .and.grade().eq(4)
    .and.homeCountyId().in(CountyDivisionQuery.class, q -> q
        .selectId()
        .where.isDeleted().isFalse()
        .and.province().eq("浙江省")
        .and.city().eq("杭州市")
        .end()
    ).end();

很容易就能看出,上述語句對應的SQL為:

SELECT * FROM student
WHERE is_deleted = false
AND grade = 4
AND home_county_id IN (
    SELECT id FROM county_division 
    WHERE is_deleted = false
    AND province = '浙江省'
    AND city = '杭州市'
)

不僅如此,Fluent MyBatis實現的JOIN語法經過幾次調整後,現在的版本也已經十分美觀:

JoinBuilder.from(
    new StudentQuery("t1", parameter)
        .selectAll()
        .where.age().eq(34)
        .end()
).join(
    new HomeAddressQuery("t2", parameter)
        .where.address().like("address")
        .end()
).on(
    l -> l.where.homeAddressId(),
    r -> r.where.id()
).endJoin().build();

其中利用Lambada語句表達JOIN條件的設計即充分符合了Java開發者的習慣,又很好的匹配了IDE語法提示的需要,細思極妙。

Fluent MyBatis中的流可以設置條件過濾,例如“僅更新值為非空的字段”:

new StudentUpdate()
    .update.name().is(student.getName(), If::notBlank)
    .set.phone().is(student.getPhone(), If::notBlank)
    .set.email().is(student.getEmail(), If::notBlank)
    .set.gender().is(student.getGender(), If::notNull)
    .end()
    .where.id().eq(student.getId()).end();

上面這段代碼等效於MyBatis中的如下XML內容:

image.png

顯然Java的流式代碼可讀性遠高於XML文件的尖括號套尖括號的層疊結構。

流是可續接的,對於更復雜的分支條件,Fluent MyBatis中能利用譬如下述語句,充分發揮出Java代碼的靈活性:

StudentQuery studentQuery = Refs.Query.student.aliasQuery()
    .select.age().end()
    .where.age().isNull().end()
    .groupBy.age().apply("id").end();
if (config.shouldFilterAge()) {
    studentQuery.having.max.age().gt(1L).end();
} else if (config.shouldOrder()) {
    studentQuery.orderBy.id().desc().end();
}

這種基於外部變量狀態的判斷,已然超出了MyBatis的XML文件的能力範圍。

三 三分鐘源碼淺析

Fluent MyBatis的代碼由Fluent Generator和Fluent MyBatis兩個子項目組成。這對組合與MyBatis Generator搭檔MyBatis Dynamic SQL有異曲同工之妙:Fluent Generator通過讀取數據庫裡的表,自動生成Fluent MyBatis所需的Entity和Dao對象;Fluent MyBatis提供編寫SQL語句的函數式DSL。

Fluent Generator子項目的代碼顯得樸實而平鋪直述,程序入口在包結構樹最外層的FileGenerator類型裡,由開發者直接調用該類的build()方法,使用鏈式構造器方式傳入需讀取的表名和存放生成文件的目錄等配置。Fluent Generator根據這些信息從數據庫裡讀取出表結構,然後為每張表生成Entity和Dao類型的Java文件,放置到約定位置,整個邏輯一氣呵成。值得一提的是,Fluent Generator的配置方法是完全代碼化的,相比MyBatis Generator雖支持純代碼化配置,卻在官方示例繼續沿用XML文件配置輸入的作風更勝一籌。

Fluent Generator生成的Dao類型默認是空的類,它只是一種推薦的數據查詢層結構,通過繼承各自的BaseDao類型,獲得便捷操作Mapper的能力。

Fluent MyBatis子項目的代碼要稍顯豐盈一些,分為三個模塊:

  • fluent-mybatis 包含各種公共基礎類
  • fluent-mybatis-test 測試用例
  • fluent-mybatis-processor 編譯期代碼生成器

fluent-mybatis模塊定義了與代碼生成相關的註解、數據模型和其他輔助類型,它們大多都是幕後英雄:開發者通常不會直接用到這個包中的類。

fluent-mybatis-test模塊包含豐富的測試用例,在一定程度上彌補了Fluent MyBatis當前階段尚不完備的文檔。平時遇到的許多Fluent MyBatis使用問題,若在文檔上無法找到,那麼翻一翻代碼庫的測試用例,一定會有意外的收穫。

fluent-mybatis-processor模塊的原理與Lombook工具庫類似,但它並不修改原有的類型,而是掃描Entity類型上的註解,然後動態產生新的輔助類。Fluent Generator產出的Entity類就像是潘多拉盒子,蘊含著Fluent MyBatis魔法的祕密。FluentMybatisProcessor類是整場表演的魔術師,它將每個形如XyzEntity的實體類變幻出一系列輔助類,其中比較關鍵的包括:

  • XyzBaseDao:繼承BaseDao類型,實現IBaseDao接口,包含獲得Entity相關Mapper、Query、Update類型的方法,是Fluent Generator為用戶生成的空白Dao類的父類。
  • XyzMapper:實現IEntityMapper,IRichMapper、IWrapperMapper接口,用於構造Query和Update對象,以及執行IQuery或IUpdate類型的SQL指令。
  • XyzQuery:繼承BaseWrapper、BaseQuery類型,實現IWrapper、IQuery接口,用於組裝查詢語句的基本容器。
  • XyzUpdate:繼承BaseWrapper、BaseUpdate類型,實現IWrapper、IBaseUpdate接口,用於組裝更新語句的基本容器。
  • XyzSqlProvider:繼承BaseSqlProvider類型,用於最終組裝SQL語句。
  • 還有XyzMapping、XyzDefaults、XyzFormSetter、XyzEntityHelper:、XyzWrapperHelper等。由fluent-mybatis-processor模塊生成的許多類型都會在編寫業務代碼的時候用到。

一個典型的Fluent MyBatis工作流程是先通過生成的Query或Update類型組裝出執行對象,然後交給Mapper對象下發執行。譬如:

// 構造並執行查詢語句
List<StudentEntity> users = mapper.listEntity(
    new StudentQuery()        .select.name().score().end()
        .where.userName().like("user").end()
        .orderBy.id().asc().end()
        .limit(20, 10)
);


// 構造並執行更新語句
int effectedRecordCount = mapper.updateBy(
    new StudentUpdate()
        .set.userName().is("u2")
        .set.isDeleted().is(true)
        .set.homeAddressId().isNull().end()
        .where.isDeleted().eq(false).end()
);

Query和Update類型不僅實現IQuery/IUpdate接口,還實現了IWrapper接口,前者用於組裝對象,後者用於讀取對象內容,這是一處很有心的設計。Mapper類型中的許多方法都能接收IQuery或IUpdate接口類型的對象,再通過方法上的@InsertProvider、@SelectProvider、@UpdateProvider或@DeleteProvider註解把實際請求轉給生成的Provider類型。Provider們從約定的Map參數中取出傳入的IWrapper執行對象,使用MapperSql工具類組裝SQL語句,最後交給MyBatis執行。

在Mapper裡也有一些直接接受Map對象的方法,可以省去用IQuery/IUpdate描述SQL的過程,進行簡單的插入和查詢。傳入的原始Map對象同樣會在Provider裡被讀取出來,用MapperSql組裝SQL語句,再交給MyBatis執行。

Fluent MyBatis的這種基於Provider機制的實現方式不僅能為用戶提供流暢的SQL構造體驗,也能充分複用MyBatis原生的諸多優點,譬如豐富的DB連接器、健全的防SQL注入機制等等,從而確保核心邏輯的穩定可靠。

四 再見XML君

追求卓越是技術人的天性,我來自阿里雲·雲效產品團隊,我們在用Fluent MyBatis。

如果您也早已厭倦MyBatis裡毫無生氣的XML文件,那麼不妨就和它們做個告別吧。

Let's Fluent,加入飛速流動的隊伍,一起來感受未來的風潮迎面吹來。


2021阿里雲開發者大會資料

5月29日,2021阿里雲開發者大會圓滿結束。本次大會的主題是“雲讓應用創新更簡單”,大會探討了100+技術議題,涵蓋開發與運維、雲原生、大數據、人工智能、數據庫、低代碼等領域,阿里妹特意準備了大會議題PDF等資料,以便同學們回顧和學習大會核心技術內容。

掃碼加阿里妹好友,回覆“2021大會”獲取吧~(若掃碼無效,可直接添加alimei4、alimei5、alimei6、alimei7)

image.png

Leave a Reply

Your email address will not be published. Required fields are marked *