本文是多级查询(例如下图)的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

5.4 不错的网站