只需瞅一眼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 studentWHERE is_deleted = falseAND grade = 4AND 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内容:
显然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注入机制等等,从而确保核心逻辑的稳定可靠。