@Catyee
2021-04-18T17:04:10.000000Z
字数 10196
阅读 2200
Mybatis
相信使用过mybatis的人都有一些类似的困惑,xml格式的sql模板开发枯燥无味,而且容易出错,我们该如何减少不必要的模板代码的开发?或许下文的Mybatis Dynamic SQL是Mybatis官方给出的另一个答案。鉴于目前网上还没有太多介绍Mybatis Dynamic SQL的文章,这里进行一些简单的原理阐释。在理解其工作原理之后,读者如果对如何更好使用Mybatis Dynamic Sql有兴趣可以跳转到另一篇文章,在这篇文章中会给出一个最佳实践的示例:Mybatis DS Generator--更优雅的使用Mybatis
首先要澄清的是,这里的Mybatis Dynamic Sql并不是指Mybatis的动态sql能力,而是Mybatis官方的另一个项目,这个项目并不是为了取代Mybatis,而是为了让开发者更方便的使用mybatis, 也就是说它只是mybatis的一个补充。官网地址是:Mybatis Dynamic SQL官网
官方介绍Mybatis Dynamic Sql原话是:
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能力。
<!--mybatis动态sql能力的体现示例:-->
<select id="findUserById" resultType="user">
select * from user
<where>
<if test="id != null">
id=#{id}
</if>
and deleteFlag=0;
</where>
</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:
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模板:
<select id="getAnimalData" resultMap="AnimalDataResult">
SELECT
id,
animal_name,
body_weight,
brain_weight
FROM
animal_data
WHERE id IN
<foreach item="item" index="index" collection="idList" open="(" separator="," close=")">
#{item}
</foreach>
AND body_weight BETWEEN
#{minWeight} AND #{maxWeight}
ORDER BY
id DESC, body_weight
</select>
...
// 对应mapper接口中的方法签名:
List<AnimalData> getAnimalData(@Param("idList") List<Long> idList,
@Param("minWeight") double minWeight,
@Param("maxWeight") double maxWeight);
如果要在注解中使用动态sql要更加麻烦一点(一种方式是使用script标签,如下;另一种方式是Provider类,此处为演示):
@Select({
"<script>" +
"SELECT id, animal_name, body_weight, brain_weight from animal_data " +
" WHERE id IN" +
" <foreach item=\"item\" index=\"index\" collection=\"idList\" open=\"(\" separator=\",\" close=\")\">" +
" #{item} " +
" </foreach>" +
" AND body_weight BETWEEN" +
" #{minWeight} AND #{maxWeight}" +
" ORDER BY id DESC, body_weight" +
"</script>"
})
@ResultMap(value="AnimalDataResult")
List<AnimalData> getAnimalData(@Param("idList") List<Long> idList,
@Param("minWeight") double minWeight,
@Param("maxWeight") double maxWeight);
如果使用Mybatis Dynamic SQL:
public List<AnimalData> getAnimalData(List<Long> idList, double minWeight, double maxWeight) {
SelectStatementProvider selectStatement = select(id, animalName, bodyWeight, brainWeight)
.from(animalData)
.where(id, isIn(idList))
.and(bodyWeight, isBetween(minWeight).and(maxWeight))
.orderBy(id.descending(), bodyWeight)
.build()
.render(RenderingStrategies.MYBATIS3);
List<AnimalData> animals = mapper.selectMany(selectStatement);
}
...
// mapper接口中的方法签名
@SelectProvider(type=SqlProviderAdapter.class, method="select")
@ResultMap("AnimalDataResult")
List<AnimalData> selectMany(SelectStatementProvider selectStatement);
归根揭底,Mybatis Dynamic SQL只是mybatis动态sql能力的另一种使用方式,它和xml以及注解是平行的。
也就是说xml和注解能完成的事情mybatis dynamic sql也应该能够完成,那么我们为什么还要使用它呢?
个人认为至少有以下几个优点:
其实要明白Mybatis Dynamic SQL的原理,关键在于明白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模板的源码:
// InsertRenderer类:
public InsertStatementProvider<T> render() {
ValuePhraseVisitor visitor = new ValuePhraseVisitor(renderingStrategy);
List<Optional<FieldAndValue>> fieldsAndValues = model.mapColumnMappings(m -> m.accept(visitor))
.collect(Collectors.toList());
return DefaultInsertStatementProvider.withRecord(model.record())
.withInsertStatement(calculateInsertStatement(fieldsAndValues))
.build();
}
// 计算动态sql模板
private String calculateInsertStatement(List<Optional<FieldAndValue>> fieldsAndValues) {
return "insert into" //$NON-NLS-1$
+ spaceBefore(model.table().tableNameAtRuntime())
+ spaceBefore(calculateColumnsPhrase(fieldsAndValues))
+ spaceBefore(calculateValuesPhrase(fieldsAndValues));
}
从源码看,在调用render()方法的时候模板就已经计算出来了,那什么时候调用了render()方法呢?可以从这个方法往上溯源,最终的调用轨迹如图所示:
上图中,mapper接口中default insert()方法是开发者调用的入口,而insert(InsertStatementProvider< ?> provider)这个抽象接口是最终交给mybatis去执行的入口,注意蓝色线框,这里是一个lambda表达式的执行,是在执行完括号中的逻辑之后才调用的,也就是说在第六步真正交给mybatis去执行的之前动态sql的模板已经生成出来了。
Mybatis则是在ProviderSqlSource这个类中使用反射调用SqlProviderAdapter类中的方法去获取之前已经生成好的动态SQL模板的:
// ProviderSqlSource类,mybatis最终获取动态sql模板的地方:
private SqlSource createSqlSource(Object parameterObject) {
String sql;
... // 省略部分代码
if (providerMethodParameterTypes.length == 1) {
if (providerContext == null) {
// 通过反射获取到动态sql模板
sql = invokeProviderMethod(parameterObject);
} else {
sql = invokeProviderMethod(providerContext);
}
}
...
Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
return languageDriver.createSqlSource(configuration, sql, parameterType);
}
通过上面的源码,我们已经知道了mybatis dynamic sql什么时候生成动态sql模板(在交给mybatis执行之前就已经生成),以及mybatis什么时候获取动态sql模板。
接下来看第二个问题,最终交给mybatis的去执行的只是一个接口,没有具体的实现,mybatis是如何知道具体的执行逻辑的呢?
其实之前已经间接回答过这个问题了,mybatis的目的是执行sql,任何一个接口交给mybatis去执行,最终都是执行sql,mybatis只需要知道如何连接数据库,然后知道如何生成最终要执行的sql就可以了,简化之后的逻辑就是:根据动态sql模板和参数生成sql->连接数据库执行sql->获取返回结果,所以不需要开发者提供具体的执行逻辑,mybatis已经知道该怎么执行了。
那mybatis具体是如何实现的呢?答案也很简单,那就是动态代理,开发者定义的mapper接口最终都会通过JDK的动态代理生成代理类。我们可以使用jdk代理的工具将生成的代理类写入文件(实际上是class文件),然后反编译来查看具体的调用逻辑。做法如下:
// 这里默认是在spring环境下使用mybatis
// 定义一个MapperStore的bean,这个bean实现InitializingBean接口, 这样在spring初始化这个bean的时候会执行afterPropertiesSet(),在这个方法中将mapper接口的代理类持久化到文件
@Component
@DependsOn("studentMapper") // 确保StudentMapper先被spring加载
public class MapperStore implements InitializingBean {
@Override
public void afterPropertiesSet() throws Exception {
// 将生成的代理类持久化到文件
String proxyClazzFile = "$StudentMapper.class";
String storePath = "/tmp/" + proxyClazzFile;
byte[] bytes = ProxyGenerator.generateProxyClass(proxyClazzFile, new Class[] {StudentMapper.class});
try (FileOutputStream fos = new FileOutputStream(storePath)) {
fos.write(bytes);
fos.flush();
}
}
}
反编译之后的结果, 这里只聚焦一个方法,所以忽略大部分的其它代码:
public final class class extends Proxy implements StudentMapper {
private static Method m25;
// 构造器,这个InvocationHandler实际上就是MapperProxy对象,也就是下面的supper.h
public class(InvocationHandler var1) throws {
super(var1);
}
// 实现接口中的insert()方法
public final int insert(InsertStatementProvider var1) throws {
try {
// 调用insert方法,关键在于h,这个h就是InvocationHandler,实际上是MapperProxy类
return (Integer)super.h.invoke(this, m25, new Object[]{var1});
} catch (RuntimeException | Error var3) {
throw var3;
} catch (Throwable var4) {
throw new UndeclaredThrowableException(var4);
}
}
static {
// 通过反射获取mapper中的insert方法
m25 = Class.forName("com.catyee.mybatis.example.mapper.StudentMapper").getMethod("insert", Class.forName("org.mybatis.dynamic.sql.insert.render.InsertStatementProvider"));
}
}
上面代理类中最为关键的就是InvocationHandler,通过调试可以知道它实际上是MapperProxy类,这个类实现了InvocationHandler接口:
// MapperProxy类中的invoke方法
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
// 如果代理方法来源于一个普通类,直接执行方法
if (Object.class.equals(method.getDeclaringClass())) {
return method.invoke(this, args);
} else {
// 如果代理方法来源于一个接口,说明方法没有具体实现,则使用mybatis自己的逻辑来执行
return cachedInvoker(method).invoke(proxy, method, args, sqlSession);
}
} catch (Throwable t) {
throw ExceptionUtil.unwrapThrowable(t);
}
}
在MapperProxy类的invoke方法中,来自于mapper接口的接口方法都会使用mybatis自己的逻辑去执行,而不是直接调用方法,其实mybatis只是通过接口方法获取sql模板和参数,获取返回值的类型,获取执行sql的类型以及一些其它信息:
如上图,可以看到一个接口方法,哪怕没有具体的实现也已经包含了很多信息,对mybatis来说知道这些信息就足够了,依据这些信息,mybatis已经知道该如何去执行,返回结果是什么等等。所以mapper接口中的接口方法只是起到一个方法签名的作用,有点类似于java中的Serializable接口,我们知道Serializable接口中没有定义任何一个方法,它本身只是一个标记,某个类实现了这个接口就有了这个标记,有了这个标记java就知道这个类可以序列化。mapper接口中定义的接口方法也是一样的,它不需要具体的实现,它的存在本身已经给mybatis提供了足够多的信息。
上面阐释了mybatis dynamic sql能做什么以及原理的问题,那下一步考虑的就是如何使用的问题。
mybatis dynamic sql虽然减少了xml或者注解的开发,但它本身抽象程度较高,如果纯手写可能无从下手,而且就算使用mybatis dynamic sql也无可避免的有一些模板代码的开发。
如上图,使用mybatis dynamic sql,mapper接口虽然可以纯手写,但是为了符合java规则以及方便操作,每个mapper接口都需要一个图中类似的Support类,用于映射表列名。这一部分其实就是枯燥的模板代码开发。
为了避免模板代码开发的问题,降低mybatis dynamic sql的使用门槛,就不得不提mybatis官方的另外一个项目:mybatis generator
相关阐述请跳转:Mybatis DS Generator--更优雅的使用Mybatis
这里举例进行说明,假如有以下两个实体类A和B,A和B具有一对多的关联关系:
public class A {
private String id;
private String aCol;
private List<B> bList; // 一对多的关联关系
}
public class B {
private String id;
private String aId;
private String bCol;
}
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 |
建立关联查询:
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的接口签名为:
@Select({
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
})
@ResultMap(value="AResult")
List<A> getAllA();
mybatis会将结果装配成List, 虽然上面有4条查询结果, 但是由于A的主键是id,通过这个接口获取的结果也就是List< A>实际的长度是2,也就是aid为1和2的对象各一个,每个对象中bList的长度为2,这是符合我们预期的。
但这个结果会给我们一种错觉,那就是查询语句只查询出来了2条结果,这个时候如果在查询语句加上limit offset,比如limit 2,我们会期望最终得到的List< A>长度仍然是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 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。