关于 Mybatis Plus 的一些实践

最近对嵌入式比较感兴趣,在学一些电路知识。在学习(还是玩?这是另一个声音)一切东西的时候,心底都有个混账声音在质问我,学这些究竟有什么用,你在逃避什么,我不知道如何反驳,只能说一句,去你妈的,如果急功近利,你屁都实现不了。


记录关于 Mybatis Plus 在复杂使用上的实践,以让生活变得更轻松。包括:

  1. 嵌套 Wrapper 查询方法的实际作用(文档语焉不详,网上没有找到仔细分析的文章),包括 and,or,nested,func
  2. 使用 Java 代码进行 SQL 片段抽象,以类型安全和防 SQL 的方式定义新的方法
  3. 自定义 SQL,以及多表查询 SQL(多 Wrapper 查询) 的 Mybatis Plus 解决方案,包括一对一,一对多(在“多”上有查询条件,但终究是去查“一”),以及查询其他字段

只研究 LabmdaWrapper,同时不研究 groupBy 等方法,认为此种情况下自定义 SQL 更加合适。

连接词,以及嵌套 Wrapper

连接词指 and 和 or。

下面的每个方法都有带 condition 参数的版本,只有为 true 时该次调用才会真正生效,下面的描述都省略掉此种版本。

andorfuncnested接受Consumer<Wrapper>去以“子句”的形式去定义 SQL 片段,使用子句可以定义任意复杂的 SQL,在很多时候比 XML 还灵活。

  • or():修改当前连接词,下一条语句拼接过来时,使用 or 而非 and 去连接(其后仍是 and)。MP 在拼接条件时,使用 and 是默认行为
  • or(Consumer<Wrapper> wrapper):构造 or 子句,即形如 or (...)
  • nested(Consumer<Wrapper> wrapper):构造一个子句,其中连接词取当前连接词,这就是说,.or(x -> {...}) === .or().nested(x -> {...})
  • and(Consumer<Wrapper> wrapper):构造 and 子句,即形如and (...);如果没有使用or()的话,andnested功能是相同的
  • func(Consumer<Wrapper> wrapper)不构造子句,子 Wrapper 就是自己,即形如 ...(没有连接词,没有括号,扁平的

最佳实践:

  1. 尽量不要使用or()(除非 SQL 极其简单),永远使用 or 和 and 子句,使用子句能让 SQL 结构更加清晰,其作用(无论是对程序员还是实际上)就像加括号一样。
  2. 不要使用func(wrapper),它的扁平结构意味着它中间如果使用了 or 可能会导致错误的结构,没有限制好它自己的抽象。

SQL 片段抽象

Mybatis Plus 使用 LambdaWrapper 时,最大的痛点之一就是无法进行 SQL 片段抽象(这同时也是 Mybatis 的痛点)。但我们能够利用Consumer<Wrapper>去进行扩展

SQL 片段抽象是很重要的,考虑一些常见的问题:

  1. in 语句最大只允许 1000 个元素的查询,为此在之前使用 Mybatis 的时候,出现过一些非常恶心但又不得不反复拷贝的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<if test="userIds!=null">
and (user_id in
<!-- 处理 in 的集合超过 1000 条时 Oracle 不支持的情况 -->
<trim suffixOverrides=" or user_id in ()"> <!-- 表示删除最后一个条件 -->
<foreach collection="userIds" item="item" index="index" open="(" close=")">
<if test="index != 0">
<choose>
<when test="index % 1000 == 999">) or user_id in (</when>
<otherwise>,</otherwise>
</choose>
</if>
#{item}
</foreach>
</trim>)
</if>
  1. 经常有查询特定日期片段内的数据的需求,有时候要精确到天,有时候要精确到秒,每次遇到前者都要在 SQL 中对入参进行 trunc 操作,或者 JdbcType 类型用 DATE,十分繁琐
  2. 无法使用 DBMS 特定的函数,如 INSTR,FIND_IN_SET

解决方案有两类:

  1. 定义工具函数进行抽象,这些工具函数接受 condition,查询列以及查询条件,然后返回Consumer<Wrapper>用户使用时使用nested方法去引用这些工具类。这个方法实现简单,但是无法直接根据列的 getter 函数获取列名,所以无法直接对 SQL 函数进行抽象,但不是完全不可以。
  2. 集成 LambdaQueryWrapper,去定义新的方法。这个可以说是万能的,但是侵入性比较高

下面使用第一种方式,下面是一些示例,处理了上面的问题 1,2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
class WrapperUtil {
private WrapperUtil() {}

/**
* 更好的 in,避免 in 集合限制 1000 条的问题,推荐使用 wrapper.and(condition, betterIn(...)) 的形式去调用,在 condition 中检查 list 非空
* @param column 字段
* @param list in 集合,按每 1000 条分割成 or in,若为空则会抛出异常抛出异常
*/
public static <T> Consumer<LambdaQueryWrapper<T>> betterIn(SFunction<T, ?> column, List<?> list) {
return wrapper -> {
// 断言需要放在里面,因为函数参数的求值不是懒惰的,即使 condition 为假,函数还是会被调用
Assert.notEmpty(list, "list cannot be empty");
for (int i = 0; i < list.size(); i+=1000) {
wrapper.or().in(column, list.subList(i, Math.min(list.size(), i + 1000)));
}
};
}

/**
* 获取某日 00:00:00
*/
static Date getDayStart(Date date) {
if (date == null) {
return null;
}
Calendar instance = Calendar.getInstance();
instance.setTime(date);
instance.set(Calendar.HOUR_OF_DAY, 0);
instance.set(Calendar.MINUTE, 0);
instance.set(Calendar.SECOND, 0);
instance.set(Calendar.MILLISECOND, 0);
return instance.getTime();
}
/**
* 获取某日 23:59:59
*/
static Date getDayEnd(Date date) {
if (date == null) {
return null;
}
Calendar instance = Calendar.getInstance();
instance.setTime(date);
instance.set(Calendar.HOUR_OF_DAY, 23);
instance.set(Calendar.MINUTE, 59);
instance.set(Calendar.SECOND, 59);
instance.set(Calendar.MILLISECOND, 999);
return instance.getTime();
}

/**
* 获取特定时段内的数据,如果 startDate 和 endDate 全为 null, 填充 1=1
* @param column 字段
* @param startDate 起始时间,获取数据会大于等于起始时间如果非 null
* @param endDate 终止时间,获取数据会小于等于起始时间如果非 null
*/
public static <T> Consumer<LambdaQueryWrapper<T>> datetimeBetween(SFunction<T, ?> column, Date startDate, Date endDate) {
return wrapper -> {
if (startDate != null || endDate != null) {
wrapper.ge(startDate != null, column, startDate);
wrapper.le(endDate != null, column, endDate);
} else {
// 如果全为 NULL,会出现这样的 sql '(... and )',因此填充一个 1=1
// 这里也可以干脆报错,但感觉不太合适
wrapper.apply("1=1");
}
};
}
/**
* 获取特定日期段内的数据
* @param column 字段
* @param startDate 起始时间,获取数据会大于等于起始时间 00:00:00 如果非 null
* @param endDate 终止时间,获取数据会小于等于起始时间 23:59:59 如果非 null
*/
public static <T> Consumer<LambdaQueryWrapper<T>> dateBetween(SFunction<T, ?> column, Date startDate, Date endDate) {
return datetimeBetween(column, getDayStart(startDate), getDayEnd(endDate));
}
}

关于第 3 个问题,就需要玩一点真正的花活儿了——用反射拿到AbstractLambdaWrapper#getColumnCache方法,它接受列函数并返回一个包含实际列名的对象。这个方法是 protected 的,所以如果实现 LambdaQueryWrapper 的子类的话能够直接使用这个函数。

下面同时使用apply方法去插入函数调用,该方法第一个参数是 SQL 片段,但它的其他参数是通过#{}的方式去注入的,所以不会有 SQL 注入风险。下面同时也演示了如何处理多 DBMS 兼容问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
private static final Object lock = new Object();
private static volatile Method getColumnCacheMethod = null;
private static<T> String getTableField(SFunction<T, ?> column, LambdaQueryWrapper<?> wrapper) {
// 双重检查锁
if (getColumnCacheMethod == null) {
synchronized (lock) {
if (getColumnCacheMethod == null) {
try {
// com.baomidou.mybatisplus.core.conditions.AbstractLambdaWrapper.getColumnCache
Method method = AbstractLambdaWrapper.class.getDeclaredMethod("getColumnCache", SFunction.class);
method.setAccessible(true);
getColumnCacheMethod = method;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}
// TODO 使用 methodName 和 class 进行缓存以避免反复的反射调用
// LambdaMeta meta = LambdaUtils.extract(column);
// Class<?> clazz = meta.getInstantiatedClass();
// String methodName = meta.getImplMethodName();
ColumnCache invoke = null;
try {
invoke = (ColumnCache) getColumnCacheMethod.invoke(wrapper, column);
} catch (Exception e) {
throw new RuntimeException(e);
}
return invoke.getColumn();
}

/**
* MySQL 的 FIND_IN_SET 操作,find_in_set(value, column) > 0,兼容其他类型数据库(使用 INSTR(',' || column || ',', value) > 0)
* @param column 列,它的值是半角逗号分割的集合
* @param value 值
*/
public static <T> Consumer<LambdaQueryWrapper<T>> findInSet(SFunction<T, ?> column, Serializable value) {
if (currentDB.equals("MySQL")) {
return wrapper -> {
// {0} 这种形式传入的入参不会有 SQL 注入问题
wrapper.apply(String.format("FIND_IN_SET({0}, %s) > 0", getTableField(column, wrapper)), value);
};
} else {
return wrapper -> {
wrapper.apply(String.format("INSTR(',' || %s || ',', {0}) > 0", getTableField(column, wrapper)), value);
};
}
}

多表查询

很多时候需要自定义 SQL,如要连接查询,要查询表里没有的字段等,相关官方文档见 https://baomidou.com/guides/wrapper/#使用-wrapper-自定义-sql

简单来说的话,就是 wrapper 的<where>部分能够通过 customSqlSegment 参数给它取出来,注意该部分代码包括 where 关键字,不需要另外写 where。

官方文档声称 Wrapper 的参数名必须为 ew,这是偏颇的,Wrapper 的参数名是可以定义的,而且也可以有多个 Wrapper 作为参数。

自定义 SQL 的话,就无法使用 Wrapper 的 orderBy,select 功能了,但这是可以忍受的。

关于重新定义 Wrapper 的参数名,Wrapper 提供了一个方法withParamAlias去修改;它有一些问题:

  1. 必须和 SQL 方法上的参数名匹配,而这本不该由业务代码去关心(我采取的方法是把实际的 SQL 方法以__起头,表示不应该调用它,同时在 mapper 中定义同名的 default 方法,在其中对 Wrapper 进行此项处理)
  2. 该方法只会修改顶层的 paramAlias,如果 Wrapper 使用了子句,则它会有嵌套的子 Wrapper

为解决第二个问题,定义一个函数去递归地设置 Wrapper 的 paramAlias:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 递归地设置 Wrapper 的 paramAlias(默认为 ew),这可以用在某个 SQL 方法使用到多个 Wrapper 时。<br />
* 这里是“递归地”设置,因为使用嵌套的 Wrapper 的时候,setParamAlias 只会在最外层起效。<br />
* @param wrapper 查询 Wrapper
* @param paramAlias paramAlias
* @return 查询 Wrapper
*/
public static <T> T withParamAlias(T wrapper, String paramAlias) {
if (!(wrapper instanceof AbstractWrapper)) {
return wrapper;
}
AbstractWrapper<?, ?, ?> wrapper1 = (AbstractWrapper<?, ?, ?>) wrapper;
try {
wrapper1.setParamAlias(paramAlias);
} catch (Exception ignored) { }
MergeSegments expression = wrapper1.getExpression();
for (ISqlSegment iSqlSegment : expression.getNormal()) {
withParamAlias(iSqlSegment, paramAlias);
}
return wrapper;
}

然后关于多表查询,这里只讨论一种简单的情况,即我仍旧是查询某一张表的数据,但允许联其他表去查

要联其他表去查是容易的,问题是,如何避免重名字段?Wrapper 没提供任何方法去在查询字段前加上别名,为此,在 SQL 的设计上,我们就只能去控制 Wrapper 的作用域。下面直接给出一个示例 SQL,考虑有学生表,班级表,分数表,其中学生和班级,学生和分数是关联的),我要根据班级和成绩查学生的信息,同时对这些学生,我想查询学生的班级名称,查询学生的化学课的最高分,这时候我就直接 tmd:

1
2
3
4
5
6
7
8
9
10
11
12
select *
, (select class_name from class where class.id = st.class_id)
, (select max(score) from score where score.id = st.id and score.class_id = (select class_id from class where class_name = '化学' limit 1))
from (select * from student ${studentWrapper.customSqlSegment}) st
<where>
<if test="!classWrapper.emptyOfWhere">
and class_id in (select id from class ${classWrapper.customSqlSegment})
</if>
<if test="!scoreWrapper.emptyOfWhere">
and id in (select id from score ${classWrapper.customSqlSegment})
</if>
</where>

这种SQL简直就是在雷普DBMS,但是它能用:)。