本文是多级查询(例如下图)的Mybatis设计思路分享。
如何让前台生成上面的筛选框(todo)- 如何实现前端传入筛选条件后端自动拼接SQL
关键词: mybatis
, restful
1. 需求分析
在开发中,经常会遇到筛选框等复杂查询,但是每次开发效率都不是很高。
1.1. 现有方案与不足
- Elastic缺少实时性,需要搭建MQ,搭建Elastic集群,牵一发动全身。它更加适用于保存计算结果后的数据仓库(DW)
- 对于单表查询,可以用开源的通用Mapper方案
- 对于多表查询,除了在mapper.xml 中加入大量硬编码或者代码生成器的产物,似乎也并没有什么好的办法。此外前后台接口也是一锅粥,需要定制VO对象,同时开发人员良莠不齐,在xml中拷贝了一大堆代码,后期改动成本非常高。
举个例子,有下面的对象,分别是学生,课程与分数
@Table(name = "STUDENT")
public class Student{
public Integer id;
public String name;
public List<Lesson> lessons;
}
@Table(name = "LESSON")
public class Lesson{
public Integer id;
public Integer studentId;
public String name;
public List<Grade> grades;
}
@Table(name = "GRADE")
publib class Grade{
public Integer id;
public Integer lessonId;
public String rate;
}
对应的SQL如下
SELECT
s.id,
s.name,
l.id as l_id,
l.name as l_name,
g.rate
FROM student s
LEFT JOIN LESSON l on s.id = l.student_id
LEFT JOIN GRADE g on l.id = g.lesson_id
假如此刻你的需求是做一个筛选网站,比如筛选出“选择了数学课,而且数学分数在60-70之间”的所有学生,而且这个查询需要前台动态拼接,不希望写额外的VO对象,那么应该如何设计呢?
针对上面的问题,愿景如下
- 前端最好能够动态直接拼接通用请求,避免重复写VO
- 前后端开发效率希望从3~5天,降低至一天出Demo
2. 系统设计
结合前端开发能力与其他开源方案,主要有如下方案
- 如果直接暴露SQL给前端,这个是不可能的,安全性先不说,由于细分工导致前端不会拼SQL的大有人在。
- 基于ElasticSearch的QueryDSL设计,这个可以说是AST了,但是过于复杂,没有Parser(它的Client只能透传Netty报文)
- 自己设计查询DSL,自己通过反射解析并生成复用通用Mapper中的ExampleCriterion
既然本文写了,肯定是用第三种方法啦,最终前台接口设计如下,发送的是一个原始的AST,树形的path被拍瘪(flatten)过,本方法比较类似原生Lucense的查询方案
[
{
'path': 'lessons.name',
'opt': 'EQUALS',
'args': ['数学']
},
{
'path': 'lessons.grades.rate',
'opt': 'IN',
'args': ['60','70']
}
]
后台将根据前端自动通过path生成SQL
SELECT
s.id,
s.name,
l.id as l_id,
l.name as l_name,
g.rate
FROM student s
LEFT JOIN LESSON l on s.id = l.student_id
LEFT JOIN GRADE g on l.id = g.lesson_id
WHERE l.name = #{?} AND g.rate in #{?}
3. 方案实现
3.1. 解析AST(可复用)
这一步需要将前台的JSON解析为通用Mapper(tk)的Example,伪代码如下
Map<String, Example> tkExampleMap = [:];
asts.stream()
.filter(ast -> ast.path?.trim()) //过滤空字符串
// 按照点号进行分组, "aa.bb.cc" -> "aa.bb"
.groupBy(ast -> ast.path.find("\\w+\\.\\w+"))
.forEach(path, groupAsts -> {
// 通过反射获取类型,比如getFieldTypeByPath('lessons.grades')
// => Class Gradle
Class c = getFieldTypeByPath(path)
Example e = new Example(c);
// 通过 opt 与 args 生成Criterion,一个简单的模式匹配映射
// 这里可以查询通用Mapper的API
createCriterionFromOpts(e, groupAsts)
tkExampleMap.put(path, e);
})
很多人可能会在这里疑惑为什么需要分组(GroupBy),这里分组是为了避免生成多个对应了同一个表的Example,这样后期查询再去重就不好做了
3.2. 引入mapper.xml模版(可复用)
首先找一个放公共的xml,把这个代码段放进去,这个也是来源于通用Mapper的,一个字没改
<sql id='byExampe'>
<if test="criteria.valid">
${@tk.mybatis.mapper.util.OGNL@andOr(criteria)}
<trim prefix="(" prefixOverrides="and |or " suffix=")">
<foreach collection="criteria.criteria" item="criterion">
<choose>
<when test="criterion.noValue">
${@tk.mybatis.mapper.util.OGNL@andOr(criterion)} ${alias}.${criterion.condition}
</when>
<when test="criterion.singleValue">
${@tk.mybatis.mapper.util.OGNL@andOr(criterion)} ${alias}.${criterion.condition} #{criterion.value}
</when>
<when test="criterion.betweenValue">
${@tk.mybatis.mapper.util.OGNL@andOr(criterion)} ${alias}.${criterion.condition} #{criterion.value} and
#{criterion.secondValue}
</when>
<when test="criterion.listValue">
${@tk.mybatis.mapper.util.OGNL@andOr(criterion)} ${alias}.${criterion.condition}
<foreach close=")" collection="criterion.value" item="listItem" open="(" separator=",">
#{listItem}
</foreach>
</when>
</choose>
</foreach>
</trim>
</if>
</sql>
3.3. 编写自有XML(复用不可)
实现自己的接口
<sql id="whereExample">
<where>
<if test="example.student">
<foreach collection="example.student.oredCriteria" item="criteria">
<include refid="byExampe">
<property name="alias" value="s"/>
</include>
</foreach>
<if test="example.get('student.lessons')">
<foreach collection="example.get('student.lessons').oredCriteria" item="criteria">
<include refid="byExampe">
<property name="alias" value="l"/>
</include>
</foreach>
</if>
<if test="example.get('student.lessons.grades')">
<foreach collection="example.get('student.lessons.grades').oredCriteria" item="criteria">
<include refid="byExampe">
<property name="alias" value="g"/>
</include>
</foreach>
</if>
</if>
</where>
</sql>
<select id="selectStudentByExample">
SELECT
s.id,
s.name,
l.id as l_id,
l.name as l_name,
g.rate
FROM student s
LEFT JOIN LESSON l on s.id = l.student_id
LEFT JOIN GRADE g on l.id = g.lesson_id
<include refid="whereExample"/>
</select>
<!-- 下面的是count需要用的,默认left-join的个数是不正确的,如果你使用了PageHelper的话 -->
<select id="selectStudentByExample_COUNT">
SELECT
count(distinct s.id)
FROM student s
LEFT JOIN LESSON l on s.id = l.student_id
LEFT JOIN GRADE g on l.id = g.lesson_id
<include refid="whereExample"/>
</select>
在java侧只用传入Example代码即可
studentMapper.selectStudentByExample([
'example': tkExampleMap
])
这样就全部ok了,后端只用写一个xml即可完成查询业务的开发,前端拼接AST即可
4. 方案的优缺点
优点
- 针对查询复杂度非常高的企业IT系统,开发效率有质的提升
- 对中低级开发比较友好,拷贝xml代码的现象更少
- 再说一个政治不正确的,领导一般对这种改进都比较重视
缺点
- 对通用mapper强依赖
- 由于OGNL语言的不足,空判断与重复代码写的很丑
- 没有经过大规模的考验
- 安全扫描可能无法通过
针对上述缺点的解决方法
- 持续迭代底层实现
- 自己定制Mybatis的LanguageDriver或者OGNL
5. 附录
5.1. OGNL与Map
在ognl中,假如你的map是{'aaa.bbb.ccc' => v1}这样的,那么在求值时,一定要写
map.get('aaa.bbb.ccc')
而不是
map.aaa.bbb.ccc
通过OGNL的ObjectAcessor可以了解到解析详情
5.2. #{}
与${}
的区别
-
美元符号是OGNL执行语句,用来生成字符串,比如
test="a>0"
在
include
标签中,${}
为特例,它只能传递文本,不能传递Object,有点类似于C中的宏#define alias "student"
-
而井号是一个占位符,内部不支持执行OGNL表达式,执行时将被替换为JDBC中的问号
?
,并在运行时填值,可以防止SQL注入 -
这两个可以在OgnlCache.getValue中打断点,就能明白OGNL何时执行
5.3. 如何自定义XML标签
在mybatis中,虽然xml已经提供了丰富的操作,比如if
, forEach
,但是这些标签特别是进行空判断时,与Groovy等语言相比就非常冗长,能不能像Angular或者VUE一样定制自己的xml的标签呢?
很遗憾,目前并不能很容易做到,从XMLLanguageDriver
中可以看出,所有的标签都是硬编码的,所以如果你想自己实现那么你就要维护自己的LanguageDriver