Let's Fluent:更順滑的 MyBatis
只需瞅一眼Google Trends上全球Java界最熱門的兩款SQL映射框架近一年的對(duì)比數(shù)字,就不難了解其實(shí)力分布:在此領(lǐng)域,MyBatis早已占領(lǐng)東亞地區(qū)開發(fā)者市場(chǎng),并以絕對(duì)優(yōu)勢(shì)穩(wěn)居中國(guó)最搶手Java數(shù)據(jù)庫(kù)訪問(wèn)框架之首。
MyBatis霸榜的底氣來(lái)源于其廣袤的生態(tài)以及國(guó)內(nèi)眾多大廠的支持。而在琳瑯滿目的MyBatis擴(kuò)展中,還埋藏著許多“寶藏項(xiàng)目”,來(lái)自阿里技術(shù)團(tuán)隊(duì)的Fluent MyBatis便是其中一顆獨(dú)特的新星。
一 普拉斯們不香了
從iBatis到MyBatis,再到國(guó)內(nèi)團(tuán)隊(duì)以MyBatis Plus為典型代表的諸多周邊工具,"Batis"系列套餐的發(fā)展歷程,幾乎又是一部XML的興衰史。最初的iBatis誕生于2002年,彼時(shí)XML在Java乃至整個(gè)軟件技術(shù)界都還相當(dāng)盛行,和同時(shí)期的許多項(xiàng)目一樣,iBatis硬生生的將一堆堆XML塞進(jìn)千家萬(wàn)戶的項(xiàng)目里。
許多年后,曾今與iBatis并肩過(guò)的社區(qū)戰(zhàn)友們紛紛淡出了歷史舞臺(tái),少數(shù)像Spring這樣延續(xù)至今的佼佼者,也逐漸摒棄XML,向代碼化配置的方式發(fā)展。在這方面,iBatis一直是個(gè)保守派,即使在MyBatis接過(guò)iBatis的衣缽之后,也只是”重磅“推出了支持代碼執(zhí)行SQL的@Select/@Insert/@Update/@Delete注解(以及相應(yīng)的4種Provider注解),用來(lái)抵擋開發(fā)者們對(duì)XML泛濫的吐槽,這是在2010年中旬,然后就再無(wú)動(dòng)作。直到2016年底,MyBatis的主要貢獻(xiàn)者之一Jeff Butler正式創(chuàng)建MyBatis Dynamic SQL項(xiàng)目,MyBatis終于開始全面擁抱無(wú)XML的代碼化SQL構(gòu)建。
在從MyBatis到MyBatis Dynamic SQL之間長(zhǎng)達(dá)6年多的空窗期里,開源社區(qū)催生出了許多民間基于MyBatis的無(wú)XML代碼方案,其中流行得比較廣泛的是Tk Mybatis、MyBatis Plus這類內(nèi)置Mapper和自動(dòng)生成CRUD的擴(kuò)展庫(kù),一經(jīng)推出就收獲諸多好評(píng)。包括MyBatis Plus里實(shí)際上并不太完備的"條件構(gòu)造器"功能,也由于當(dāng)時(shí)同類解決方案的匱乏而頗受追捧。與此同時(shí),在MyBatis社區(qū)之外,一直在默默發(fā)展的JOOQ是一款歷史與MyBatis幾乎同樣悠久的純Java動(dòng)態(tài)SQL執(zhí)行庫(kù),它的用戶群體不大,卻口碑甚好。如今在任意搜索引擎上輸入"MyBatis vs JOOQ",依然能得到幾乎是一邊倒選擇JOOQ的結(jié)果,大家給出的理由也非常一致:簡(jiǎn)潔、靈活、無(wú)需XML,很"Java"。而在MyBatis陣營(yíng)里,若是拿出MyBatis Plus的"條件構(gòu)造器"與之正面對(duì)陣,只消三個(gè)回合,就會(huì)被屁滾尿流的打出擂臺(tái)。只可惜JOOQ的家底沒有MyBatis那樣殷實(shí),早早走上了商業(yè)數(shù)據(jù)庫(kù)支持賣License收費(fèi)的道路,才讓MyBatis免于在輿論上迎來(lái)自己的中年危機(jī)。
Fluent MyBatis誕生于2019年底,即使與MyBatis Dynamic SQL相比都是晚輩,然而尚處成長(zhǎng)期的它就已透出了青出于藍(lán)而勝于藍(lán)的味道。
在實(shí)現(xiàn)方式上,MyBatis Plus覆寫并替換了部分MyBatis內(nèi)部類型的方法,整體機(jī)制較重,卻也因此能將一些功能細(xì)節(jié)隱藏到用戶無(wú)需關(guān)注的內(nèi)部邏輯里;與之相反,MyBatis Dynamic SQL的實(shí)現(xiàn)機(jī)制非常輕量,不僅完全基于MyBatis原生的Provider系列注解開發(fā),而且沒有什么隱藏邏輯,對(duì)用戶的每張表自動(dòng)生成相應(yīng)的Entity、DynamicSqlSupport和Mapper三個(gè)類,全部放入用戶的源碼目錄里,因此暴露的細(xì)節(jié)比較多,代碼侵入性略高。Fluent MyBatis取二者之所長(zhǎng),整體機(jī)制與MyBatis Dynamic SQL更接近,同樣基于原生的Provider注解,對(duì)用戶的每個(gè)表生成Entity類和默認(rèn)空白的Dao類,不同之處在于它還會(huì)通過(guò)JVM編譯期代碼增強(qiáng)功能自動(dòng)生成許多開發(fā)者不可更改的標(biāo)準(zhǔn)輔助類,這些代碼無(wú)需放入用戶的源碼目錄但能夠在編碼時(shí)直接使用,即提供豐富的功能,又保證了用戶代碼的整潔。
在使用方式上,F(xiàn)luent MyBatis同樣借鑒了前輩們的最優(yōu)實(shí)踐,沒有花里胡哨的注解和配置,直接復(fù)用MyBatis連接,所有功能開箱即用。同時(shí)由于Fluent MyBatis將所有表字段、條件、操作都以方法調(diào)用形式提供,因此獲得了比其他同類項(xiàng)目都更好的IDE語(yǔ)法輔助。舉一個(gè)不太復(fù)雜的例子:
- // 使用Fluent MyBatis構(gòu)造查詢語(yǔ)句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[]{"英語(yǔ)", "數(shù)學(xué)", "語(yǔ)文"}) .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的語(yǔ)法也比較美觀,但字段名和min/max/avg等方法都需要靜態(tài)引用,比Fluent MyBatis稍顯遜色。
- // 使用MyBatis Dynamic SQL構(gòu)造查詢語(yǔ)句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("英語(yǔ)", "數(shù)學(xué)", "語(yǔ)文")) .and(score, isGreaterThanOrEqualTo(60)) .and(isDeleted, isEqualTo(false)) .groupBy(schoolTerm, subject) .having(count(score), isGreaterThan(1)) //當(dāng)前其實(shí)還不支持having方法 .orderBy(schoolTerm, subject) .build(isDeleted, isEqualTo(false)) .render(RenderingStrategies.MYBATIS3));
JOOQ的歷史比較悠久,寫出來(lái)的代碼鋪天蓋地都是常量字段,功能強(qiáng)大但美觀度欠佳。
- // 使用JOOQ構(gòu)造查詢語(yǔ)句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("英語(yǔ)", "數(shù)學(xué)", "語(yǔ)文"), 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的條件構(gòu)造器僅僅封裝了基本的SQL操作,對(duì)于字段、條件、別名等都要使用字符串拼接,極易出現(xiàn)由于拼寫失誤引起的SQL異常。
- // 使用MyBatis Plus構(gòu)造查詢語(yǔ)句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", "英語(yǔ)", "數(shù)學(xué)", "語(yǔ)文") .ge("score", 60) .eq("is_deleted", false) .groupBy("school_term", "subject") .having("count(score)>1") .orderByAsc("school_term", "subject"));
在Java動(dòng)態(tài)SQL構(gòu)建的功能完整度方面,當(dāng)前的排序是MyBatis Plus < MyBatis Dynamic SQL < Fluent MyBatis < JOOQ。
MyBatis Plus條件構(gòu)造器在功能性上完敗,不僅無(wú)法表達(dá)JOIN、UNION語(yǔ)句,嵌套查詢之類稍復(fù)雜SQL也完全沒招。MyBatis Dynamic SQL支持JOIN和UNION語(yǔ)句,尚未支持嵌套查詢,且缺少HAVING等少量標(biāo)準(zhǔn)SQL語(yǔ)法。Fluent MyBatis支持多表JOIN、UNION、嵌套查詢和幾乎所有標(biāo)準(zhǔn)SQL語(yǔ)法,對(duì)于絕大多數(shù)場(chǎng)景都妥妥夠用。JOOQ是真正的王者,不僅支持標(biāo)準(zhǔn)SQL語(yǔ)法,連各廠商特有的專有關(guān)鍵字和內(nèi)置方法都沒放過(guò),如MySQL的ON DUPLICATE KEY UPDATE、PostgreSQL的WINDOW、Oracle的CONNECT BY等等。補(bǔ)齊各種SQL語(yǔ)法是一件瑣碎而費(fèi)力的工作,考慮到SQL語(yǔ)法的總量已經(jīng)基本不再變化,相信假以時(shí)日,各方的差距會(huì)逐漸縮小。
除了SQL基本功,特別值得一提的是Fluent MyBatis的獨(dú)門絕技:支持動(dòng)態(tài)換表名(FreeQuery/FreeUpdate特性)。在云效項(xiàng)目的開發(fā)過(guò)程中,由于需要在各種嵌套查詢之上再根據(jù)視圖條件動(dòng)態(tài)選擇聚合計(jì)算的維度表,多虧了Fluent MyBatis的動(dòng)態(tài)表名功能,才得以在最大程度保留語(yǔ)法構(gòu)造便利性的情況下,讓代碼復(fù)用成為可能。
相比密密麻麻的XML文件,Java代碼在易讀性和可維護(hù)性方面有著明顯的優(yōu)勢(shì)。在官方和社區(qū)的共同推動(dòng)下,一個(gè)全新的、代碼化的MyBatis生態(tài)正在冉冉升起。驀然回首,曾經(jīng)驕傲的"Plus擴(kuò)展"們?nèi)疾幌懔恕?/p>
二 優(yōu)雅的數(shù)據(jù)流
初識(shí)Fluent MyBatis,最明顯能感受到的特點(diǎn)是它及其便利的IDE語(yǔ)法提示。
基于數(shù)據(jù)表自動(dòng)生成的Entity、Mapper、Query、Update等對(duì)象,讓所有的數(shù)據(jù)庫(kù)字段和SQL操作都變成了方法,串成平整的流式語(yǔ)句。即使是層層嵌套的查詢,也能表現(xiàn)得錯(cuò)落有致:
- 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();
很容易就能看出,上述語(yǔ)句對(duì)應(yīng)的SQL為:
- SELECT * FROM studentWHERE is_deleted = falseAND grade = 4AND home_county_id IN ( SELECT id FROM county_division WHERE is_deleted = false AND province = '浙江省' AND city = '杭州市')
不僅如此,F(xiàn)luent MyBatis實(shí)現(xiàn)的JOIN語(yǔ)法經(jīng)過(guò)幾次調(diào)整后,現(xiàn)在的版本也已經(jīng)十分美觀:
- 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語(yǔ)句表達(dá)JOIN條件的設(shè)計(jì)即充分符合了Java開發(fā)者的習(xí)慣,又很好的匹配了IDE語(yǔ)法提示的需要,細(xì)思極妙。
Fluent MyBatis中的流可以設(shè)置條件過(guò)濾,例如“僅更新值為非空的字段”:
- 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內(nèi)容:
顯然Java的流式代碼可讀性遠(yuǎn)高于XML文件的尖括號(hào)套尖括號(hào)的層疊結(jié)構(gòu)。
流是可續(xù)接的,對(duì)于更復(fù)雜的分支條件,F(xiàn)luent MyBatis中能利用譬如下述語(yǔ)句,充分發(fā)揮出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();}
這種基于外部變量狀態(tài)的判斷,已然超出了MyBatis的XML文件的能力范圍。
三 三分鐘源碼淺析
Fluent MyBatis的代碼由Fluent Generator和Fluent MyBatis兩個(gè)子項(xiàng)目組成。這對(duì)組合與MyBatis Generator搭檔MyBatis Dynamic SQL有異曲同工之妙:Fluent Generator通過(guò)讀取數(shù)據(jù)庫(kù)里的表,自動(dòng)生成Fluent MyBatis所需的Entity和Dao對(duì)象;Fluent MyBatis提供編寫SQL語(yǔ)句的函數(shù)式DSL。
Fluent Generator子項(xiàng)目的代碼顯得樸實(shí)而平鋪直述,程序入口在包結(jié)構(gòu)樹最外層的FileGenerator類型里,由開發(fā)者直接調(diào)用該類的build()方法,使用鏈?zhǔn)綐?gòu)造器方式傳入需讀取的表名和存放生成文件的目錄等配置。Fluent Generator根據(jù)這些信息從數(shù)據(jù)庫(kù)里讀取出表結(jié)構(gòu),然后為每張表生成Entity和Dao類型的Java文件,放置到約定位置,整個(gè)邏輯一氣呵成。值得一提的是,F(xiàn)luent Generator的配置方法是完全代碼化的,相比MyBatis Generator雖支持純代碼化配置,卻在官方示例繼續(xù)沿用XML文件配置輸入的作風(fēng)更勝一籌。
Fluent Generator生成的Dao類型默認(rèn)是空的類,它只是一種推薦的數(shù)據(jù)查詢層結(jié)構(gòu),通過(guò)繼承各自的BaseDao類型,獲得便捷操作Mapper的能力。
Fluent MyBatis子項(xiàng)目的代碼要稍顯豐盈一些,分為三個(gè)模塊:
fluent-mybatis 包含各種公共基礎(chǔ)類
fluent-mybatis-test 測(cè)試用例
fluent-mybatis-processor 編譯期代碼生成器
fluent-mybatis模塊定義了與代碼生成相關(guān)的注解、數(shù)據(jù)模型和其他輔助類型,它們大多都是幕后英雄:開發(fā)者通常不會(huì)直接用到這個(gè)包中的類。
fluent-mybatis-test模塊包含豐富的測(cè)試用例,在一定程度上彌補(bǔ)了Fluent MyBatis當(dāng)前階段尚不完備的文檔。平時(shí)遇到的許多Fluent MyBatis使用問(wèn)題,若在文檔上無(wú)法找到,那么翻一翻代碼庫(kù)的測(cè)試用例,一定會(huì)有意外的收獲。
fluent-mybatis-processor模塊的原理與Lombook工具庫(kù)類似,但它并不修改原有的類型,而是掃描Entity類型上的注解,然后動(dòng)態(tài)產(chǎn)生新的輔助類。Fluent Generator產(chǎn)出的Entity類就像是潘多拉盒子,蘊(yùn)含著Fluent MyBatis魔法的秘密。FluentMybatisProcessor類是整場(chǎng)表演的魔術(shù)師,它將每個(gè)形如XyzEntity的實(shí)體類變幻出一系列輔助類,其中比較關(guān)鍵的包括:
XyzBaseDao:繼承BaseDao類型,實(shí)現(xiàn)IBaseDao接口,包含獲得Entity相關(guān)Mapper、Query、Update類型的方法,是Fluent Generator為用戶生成的空白Dao類的父類。
XyzMapper:實(shí)現(xiàn)IEntityMapper,IRichMapper、IWrapperMapper接口,用于構(gòu)造Query和Update對(duì)象,以及執(zhí)行IQuery或IUpdate類型的SQL指令。
XyzQuery:繼承BaseWrapper、BaseQuery類型,實(shí)現(xiàn)IWrapper、IQuery接口,用于組裝查詢語(yǔ)句的基本容器。
XyzUpdate:繼承BaseWrapper、BaseUpdate類型,實(shí)現(xiàn)IWrapper、IBaseUpdate接口,用于組裝更新語(yǔ)句的基本容器。
XyzSqlProvider:繼承BaseSqlProvider類型,用于最終組裝SQL語(yǔ)句。
還有XyzMapping、XyzDefaults、XyzFormSetter、XyzEntityHelper:、XyzWrapperHelper等。由fluent-mybatis-processor模塊生成的許多類型都會(huì)在編寫業(yè)務(wù)代碼的時(shí)候用到。
一個(gè)典型的Fluent MyBatis工作流程是先通過(guò)生成的Query或Update類型組裝出執(zhí)行對(duì)象,然后交給Mapper對(duì)象下發(fā)執(zhí)行。譬如:
- // 構(gòu)造并執(zhí)行查詢語(yǔ)句List<StudentEntity> users = mapper.listEntity( new StudentQuery() .select.name().score().end() .where.userName().like("user").end() .orderBy.id().asc().end() .limit(20, 10));// 構(gòu)造并執(zhí)行更新語(yǔ)句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類型不僅實(shí)現(xiàn)IQuery/IUpdate接口,還實(shí)現(xiàn)了IWrapper接口,前者用于組裝對(duì)象,后者用于讀取對(duì)象內(nèi)容,這是一處很有心的設(shè)計(jì)。Mapper類型中的許多方法都能接收IQuery或IUpdate接口類型的對(duì)象,再通過(guò)方法上的@InsertProvider、@SelectProvider、@UpdateProvider或@DeleteProvider注解把實(shí)際請(qǐng)求轉(zhuǎn)給生成的Provider類型。Provider們從約定的Map參數(shù)中取出傳入的IWrapper執(zhí)行對(duì)象,使用MapperSql工具類組裝SQL語(yǔ)句,最后交給MyBatis執(zhí)行。
在Mapper里也有一些直接接受Map對(duì)象的方法,可以省去用IQuery/IUpdate描述SQL的過(guò)程,進(jìn)行簡(jiǎn)單的插入和查詢。傳入的原始Map對(duì)象同樣會(huì)在Provider里被讀取出來(lái),用MapperSql組裝SQL語(yǔ)句,再交給MyBatis執(zhí)行。
Fluent MyBatis的這種基于Provider機(jī)制的實(shí)現(xiàn)方式不僅能為用戶提供流暢的SQL構(gòu)造體驗(yàn),也能充分復(fù)用MyBatis原生的諸多優(yōu)點(diǎn),譬如豐富的DB連接器、健全的防SQL注入機(jī)制等等,從而確保核心邏輯的穩(wěn)定可靠。