[关闭]
@Catyee 2021-04-18T17:04:10.000000Z 字数 10196 阅读 2200

Mybatis Dynamic Sql原理

Mybatis


相信使用过mybatis的人都有一些类似的困惑,xml格式的sql模板开发枯燥无味,而且容易出错,我们该如何减少不必要的模板代码的开发?或许下文的Mybatis Dynamic SQL是Mybatis官方给出的另一个答案。鉴于目前网上还没有太多介绍Mybatis Dynamic SQL的文章,这里进行一些简单的原理阐释。在理解其工作原理之后,读者如果对如何更好使用Mybatis Dynamic Sql有兴趣可以跳转到另一篇文章,在这篇文章中会给出一个最佳实践的示例:Mybatis DS Generator--更优雅的使用Mybatis

一、Mybatis Dynamic Sql是什么

首先要澄清的是,这里的Mybatis Dynamic Sql并不是指Mybatis的动态sql能力,而是Mybatis官方的另一个项目,这个项目并不是为了取代Mybatis,而是为了让开发者更方便的使用mybatis, 也就是说它只是mybatis的一个补充。官网地址是:Mybatis Dynamic SQL官网

官方介绍Mybatis Dynamic Sql原话是:

  1. This library is a framework for generating dynamic SQL statements.

翻译过来:Mybatis Dynamic Sql是一个用于生成动态SQL语句的框架。
重点在于"生成动态sql语句",我们知道Mybatis是一个半自动化的ORM(对象关系映射)框架, 这个半自动化体现在需要开发者提供sql模板,mybatis会根据sql模板以及参数来生成最终要执行的sql。mybatis给开发者提供了 if, choose, when, otherwise, trim, where, set, foreach等标签用于更加灵活的组合sql,这就是mybatis的动态sql能力。

  1. <!--mybatis动态sql能力的体现示例:-->
  2. <select id="findUserById" resultType="user">
  3. select * from user
  4. <where>
  5. <if test="id != null">
  6. id=#{id}
  7. </if>
  8. and deleteFlag=0;
  9. </where>
  10. </select>

上面例子:如果传入的id 不为空,那么才会SQL才拼接id = #{id}。

mybatis的动态sql能力使它区别其它ORM框架,能够更加灵活的生成更加复杂的sql。回到Mybatis Dynamic SQL,官方提到它是一个生成动态sql的框架,也就是说它是为了将mybatis的动态sql能力发挥到极致才诞生的。在Mybatis Dynamic Sql诞生之前,我们使用mybatis有两种方式,一是使用xml格式的文件提供动态sql的模板;另一种是使用注解来提供动态sql的模板。接下来我们看Mybatsi Dynamic SQL的使用方式,并和传统方式进行直观的对比。
如果有这样一条sql:

  1. select id, animal_name, body_weight, brain_weight from animal_data where id in (1,5,7) and body_weight is between 1.0 and 3.0 order by id desc, body_weight;

如果使用xml的方式,需要在xml格式的文件中定义出sql模板:

  1. <select id="getAnimalData" resultMap="AnimalDataResult">
  2. SELECT
  3. id,
  4. animal_name,
  5. body_weight,
  6. brain_weight
  7. FROM
  8. animal_data
  9. WHERE id IN
  10. <foreach item="item" index="index" collection="idList" open="(" separator="," close=")">
  11. #{item}
  12. </foreach>
  13. AND body_weight BETWEEN
  14. #{minWeight} AND #{maxWeight}
  15. ORDER BY
  16. id DESC, body_weight
  17. </select>
  18. ...
  19. // 对应mapper接口中的方法签名:
  20. List<AnimalData> getAnimalData(@Param("idList") List<Long> idList,
  21. @Param("minWeight") double minWeight,
  22. @Param("maxWeight") double maxWeight);

如果要在注解中使用动态sql要更加麻烦一点(一种方式是使用script标签,如下;另一种方式是Provider类,此处为演示):

  1. @Select({
  2. "<script>" +
  3. "SELECT id, animal_name, body_weight, brain_weight from animal_data " +
  4. " WHERE id IN" +
  5. " <foreach item=\"item\" index=\"index\" collection=\"idList\" open=\"(\" separator=\",\" close=\")\">" +
  6. " #{item} " +
  7. " </foreach>" +
  8. " AND body_weight BETWEEN" +
  9. " #{minWeight} AND #{maxWeight}" +
  10. " ORDER BY id DESC, body_weight" +
  11. "</script>"
  12. })
  13. @ResultMap(value="AnimalDataResult")
  14. List<AnimalData> getAnimalData(@Param("idList") List<Long> idList,
  15. @Param("minWeight") double minWeight,
  16. @Param("maxWeight") double maxWeight);

如果使用Mybatis Dynamic SQL:

  1. public List<AnimalData> getAnimalData(List<Long> idList, double minWeight, double maxWeight) {
  2. SelectStatementProvider selectStatement = select(id, animalName, bodyWeight, brainWeight)
  3. .from(animalData)
  4. .where(id, isIn(idList))
  5. .and(bodyWeight, isBetween(minWeight).and(maxWeight))
  6. .orderBy(id.descending(), bodyWeight)
  7. .build()
  8. .render(RenderingStrategies.MYBATIS3);
  9. List<AnimalData> animals = mapper.selectMany(selectStatement);
  10. }
  11. ...
  12. // mapper接口中的方法签名
  13. @SelectProvider(type=SqlProviderAdapter.class, method="select")
  14. @ResultMap("AnimalDataResult")
  15. List<AnimalData> selectMany(SelectStatementProvider selectStatement);

归根揭底,Mybatis Dynamic SQL只是mybatis动态sql能力的另一种使用方式,它和xml以及注解是平行的。
也就是说xml和注解能完成的事情mybatis dynamic sql也应该能够完成,那么我们为什么还要使用它呢?
个人认为至少有以下几个优点:

二、Mybatis Dynamic Sql原理

其实要明白Mybatis Dynamic SQL的原理,关键在于明白mybatis的原理。先来思考两个问题

  1. mybatis的本质是什么
  2. 不管我们使用xml、注解、mybatis dynamic sql这三种的那种方式,我们都只定义了一个接口,并没有实现类,mybatis是怎么知道要去执行什么逻辑?

2.1 mybatis的本质是什么

先从第一个问题开始,我们都知道mybatis是一个ORM框架,ORM即Object Relational Mapping,也就是java的pojo对象与关系表的映射框架。但是在我个人看来,mybatis是一个屏蔽了底层细节的sql执行器,mybatis做的事情就是连接数据库,在数据库上执行sql。至于查询结果与pojo对象映射,数据类型的对应,session的管理、事务的管理等等功能都是执行sql自然而然的附带功能。

既然要执行sql,首先要面对的就是sql从哪儿来。最为直接的思路就是完整的把sql写出来,但是这样不具有通用性,因为一点点改动就会造成sql的不适用。为了增加复用性,进一步的想法是采用类似于模板引擎的实现思路:模板和参数共同生成具体的结果(模板+参数=输出),模板是静态的,参数是动态的,参数改变会使生成的结果不同,但是它们都是相似的。

回过头来看,mybatis的三种使用方式,xml、注解以及mybatis dynamic sql实际上就是模板+参数。这三种使用方式中,参数都来自于方法的调用,但是模板的定义方式不同,xml是在xml格式的mapper文件中定义sql模板,注解是在注解中用字符串定义模板,而mybatis dynamic sql是使用代码的调用关系来定义模板

不管使用那种方式,mybatis最终都是获取模板,然后用参数填充来生成最终要去执行的sql。
xml和注解都是直接可见的sql模板,那mybatis dynamic sql是怎么生成动态sql模板的呢?Mybatis又是在什么时候获取到动态sql模板的呢?

其实生成模板的具体逻辑都在各自的Render类中:
org.mybatis.dynamic.sql.insert.render.InsertRenderer
org.mybatis.dynamic.sql.delete.render.DeleteRenderer
org.mybatis.dynamic.sql.select.render.SelectRenderer
org.mybatis.dynamic.sql.update.render.UpdateRenderer

以下是InsertRender类中计算sql模板的源码:

  1. // InsertRenderer类:
  2. public InsertStatementProvider<T> render() {
  3. ValuePhraseVisitor visitor = new ValuePhraseVisitor(renderingStrategy);
  4. List<Optional<FieldAndValue>> fieldsAndValues = model.mapColumnMappings(m -> m.accept(visitor))
  5. .collect(Collectors.toList());
  6. return DefaultInsertStatementProvider.withRecord(model.record())
  7. .withInsertStatement(calculateInsertStatement(fieldsAndValues))
  8. .build();
  9. }
  10. // 计算动态sql模板
  11. private String calculateInsertStatement(List<Optional<FieldAndValue>> fieldsAndValues) {
  12. return "insert into" //$NON-NLS-1$
  13. + spaceBefore(model.table().tableNameAtRuntime())
  14. + spaceBefore(calculateColumnsPhrase(fieldsAndValues))
  15. + spaceBefore(calculateValuesPhrase(fieldsAndValues));
  16. }

从源码看,在调用render()方法的时候模板就已经计算出来了,那什么时候调用了render()方法呢?可以从这个方法往上溯源,最终的调用轨迹如图所示:
动态sql模板生成过程
上图中,mapper接口中default insert()方法是开发者调用的入口,而insert(InsertStatementProvider< ?> provider)这个抽象接口是最终交给mybatis去执行的入口,注意蓝色线框,这里是一个lambda表达式的执行,是在执行完括号中的逻辑之后才调用的,也就是说在第六步真正交给mybatis去执行的之前动态sql的模板已经生成出来了
Mybatis则是在ProviderSqlSource这个类中使用反射调用SqlProviderAdapter类中的方法去获取之前已经生成好的动态SQL模板的:

  1. // ProviderSqlSource类,mybatis最终获取动态sql模板的地方:
  2. private SqlSource createSqlSource(Object parameterObject) {
  3. String sql;
  4. ... // 省略部分代码
  5. if (providerMethodParameterTypes.length == 1) {
  6. if (providerContext == null) {
  7. // 通过反射获取到动态sql模板
  8. sql = invokeProviderMethod(parameterObject);
  9. } else {
  10. sql = invokeProviderMethod(providerContext);
  11. }
  12. }
  13. ...
  14. Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
  15. return languageDriver.createSqlSource(configuration, sql, parameterType);
  16. }

通过上面的源码,我们已经知道了mybatis dynamic sql什么时候生成动态sql模板(在交给mybatis执行之前就已经生成),以及mybatis什么时候获取动态sql模板。

2.2 mapper接口中没有具体实现,mybatis如何知道该怎么执行

接下来看第二个问题,最终交给mybatis的去执行的只是一个接口,没有具体的实现,mybatis是如何知道具体的执行逻辑的呢?
其实之前已经间接回答过这个问题了,mybatis的目的是执行sql,任何一个接口交给mybatis去执行,最终都是执行sql,mybatis只需要知道如何连接数据库,然后知道如何生成最终要执行的sql就可以了,简化之后的逻辑就是:根据动态sql模板和参数生成sql->连接数据库执行sql->获取返回结果,所以不需要开发者提供具体的执行逻辑,mybatis已经知道该怎么执行了。
那mybatis具体是如何实现的呢?答案也很简单,那就是动态代理,开发者定义的mapper接口最终都会通过JDK的动态代理生成代理类。我们可以使用jdk代理的工具将生成的代理类写入文件(实际上是class文件),然后反编译来查看具体的调用逻辑。做法如下:

  1. // 这里默认是在spring环境下使用mybatis
  2. // 定义一个MapperStore的bean,这个bean实现InitializingBean接口, 这样在spring初始化这个bean的时候会执行afterPropertiesSet(),在这个方法中将mapper接口的代理类持久化到文件
  3. @Component
  4. @DependsOn("studentMapper") // 确保StudentMapper先被spring加载
  5. public class MapperStore implements InitializingBean {
  6. @Override
  7. public void afterPropertiesSet() throws Exception {
  8. // 将生成的代理类持久化到文件
  9. String proxyClazzFile = "$StudentMapper.class";
  10. String storePath = "/tmp/" + proxyClazzFile;
  11. byte[] bytes = ProxyGenerator.generateProxyClass(proxyClazzFile, new Class[] {StudentMapper.class});
  12. try (FileOutputStream fos = new FileOutputStream(storePath)) {
  13. fos.write(bytes);
  14. fos.flush();
  15. }
  16. }
  17. }

反编译之后的结果, 这里只聚焦一个方法,所以忽略大部分的其它代码:

  1. public final class class extends Proxy implements StudentMapper {
  2. private static Method m25;
  3. // 构造器,这个InvocationHandler实际上就是MapperProxy对象,也就是下面的supper.h
  4. public class(InvocationHandler var1) throws {
  5. super(var1);
  6. }
  7. // 实现接口中的insert()方法
  8. public final int insert(InsertStatementProvider var1) throws {
  9. try {
  10. // 调用insert方法,关键在于h,这个h就是InvocationHandler,实际上是MapperProxy类
  11. return (Integer)super.h.invoke(this, m25, new Object[]{var1});
  12. } catch (RuntimeException | Error var3) {
  13. throw var3;
  14. } catch (Throwable var4) {
  15. throw new UndeclaredThrowableException(var4);
  16. }
  17. }
  18. static {
  19. // 通过反射获取mapper中的insert方法
  20. m25 = Class.forName("com.catyee.mybatis.example.mapper.StudentMapper").getMethod("insert", Class.forName("org.mybatis.dynamic.sql.insert.render.InsertStatementProvider"));
  21. }
  22. }

上面代理类中最为关键的就是InvocationHandler,通过调试可以知道它实际上是MapperProxy类,这个类实现了InvocationHandler接口:

  1. // MapperProxy类中的invoke方法
  2. public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
  3. try {
  4. // 如果代理方法来源于一个普通类,直接执行方法
  5. if (Object.class.equals(method.getDeclaringClass())) {
  6. return method.invoke(this, args);
  7. } else {
  8. // 如果代理方法来源于一个接口,说明方法没有具体实现,则使用mybatis自己的逻辑来执行
  9. return cachedInvoker(method).invoke(proxy, method, args, sqlSession);
  10. }
  11. } catch (Throwable t) {
  12. throw ExceptionUtil.unwrapThrowable(t);
  13. }
  14. }

在MapperProxy类的invoke方法中,来自于mapper接口的接口方法都会使用mybatis自己的逻辑去执行,而不是直接调用方法,其实mybatis只是通过接口方法获取sql模板和参数,获取返回值的类型,获取执行sql的类型以及一些其它信息:
mapper接口中的方法签名
如上图,可以看到一个接口方法,哪怕没有具体的实现也已经包含了很多信息,对mybatis来说知道这些信息就足够了,依据这些信息,mybatis已经知道该如何去执行,返回结果是什么等等。所以mapper接口中的接口方法只是起到一个方法签名的作用,有点类似于java中的Serializable接口,我们知道Serializable接口中没有定义任何一个方法,它本身只是一个标记,某个类实现了这个接口就有了这个标记,有了这个标记java就知道这个类可以序列化。mapper接口中定义的接口方法也是一样的,它不需要具体的实现,它的存在本身已经给mybatis提供了足够多的信息。

三、如何更好的使用Mybatis Dynamic Sql

上面阐释了mybatis dynamic sql能做什么以及原理的问题,那下一步考虑的就是如何使用的问题。

mybatis dynamic sql虽然减少了xml或者注解的开发,但它本身抽象程度较高,如果纯手写可能无从下手,而且就算使用mybatis dynamic sql也无可避免的有一些模板代码的开发。
mybatis dynamic sql依然无法避免模板代码

如上图,使用mybatis dynamic sql,mapper接口虽然可以纯手写,但是为了符合java规则以及方便操作,每个mapper接口都需要一个图中类似的Support类,用于映射表列名。这一部分其实就是枯燥的模板代码开发。

为了避免模板代码开发的问题,降低mybatis dynamic sql的使用门槛,就不得不提mybatis官方的另外一个项目:mybatis generator

相关阐述请跳转:Mybatis DS Generator--更优雅的使用Mybatis

四、为什么在Mybatis中使用join的时候不能使用limit/offset物理分页

这里举例进行说明,假如有以下两个实体类A和B,A和B具有一对多的关联关系:

  1. public class A {
  2. private String id;
  3. private String aCol;
  4. private List<B> bList; // 一对多的关联关系
  5. }
  6. public class B {
  7. private String id;
  8. private String aId;
  9. private String bCol;
  10. }

A和B实体所对应的关系表为A表和B表,A表和B表的主键都是id字段,A表的id和B表的aId可以建立关联关系,两张表已经有了一些基础数据:
A表:

id a_col
1 a1
2 a2

B表:

id a_id b_col
1 1 b1
2 1 b2
3 2 b3
4 2 b4

建立关联查询:

  1. select A.id as aid, A.a_col, B.id as bid, B.b_col from A left join B on A.id = B.a_id;

查询得到的结果:

aid a_col bid b_col
1 a1 1 b1
1 a1 2 b2
2 a2 3 b3
2 a2 4 b4

在mapper接口中,假设获取A的接口签名为:

  1. @Select({
  2. select A.id as aid, A.a_col, B.id as bid, B.b_col from A left join B on A.id = B.a_id
  3. })
  4. @ResultMap(value="AResult")
  5. List<A> getAllA();

mybatis会将结果装配成List, 虽然上面有4条查询结果, 但是由于A的主键是id,通过这个接口获取的结果也就是List< A>实际的长度是2,也就是aid为1和2的对象各一个,每个对象中bList的长度为2,这是符合我们预期的。
但这个结果会给我们一种错觉,那就是查询语句只查询出来了2条结果,这个时候如果在查询语句加上limit offset,比如limit 2,我们会期望最终得到的List< A>长度仍然是2。

  1. select A.id as aid, A.a_col, B.id as bid, B.b_col from A left join B on A.id = B.a_id limit 2 offset 0;

但是Mybatis将查询结果装配成List< A>之后,我们发现它的实际长度是1, List< A>中只有一个对象,这个对象的bList属性的长度是2,但这并不符合我们最初的期待,因为我们期待有两个对象,实际上只有1个。其实原因很简单,上面sql的查询结果为:

aid a_col bid b_col
1 a1 1 b1
1 a1 2 b2

查询结果确实是两条,但这两条结果的主键都是1,mybatis会把它装配为一个对象。
所以在mybatis中使用join之后,不要使用limit offset进行物理分页。这并不是mybatis的bug,只能算一个使用的技巧。

那如果确实遇到需要物理分页的情况怎么办呢?可以从实现上绕过这个问题,物理分页是为了展示列表,展示列表往往只需要一部分的信息,并不需要完整的信息,所以可以只基于A表进行物理分页,如果用户想查看某一条记录的全部信息,这个时候再进行join。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注