有了MyBatis,为什么还要搞一个Plus

0 阅读7分钟

最近突发奇想,想看看当年,为啥有了mybaits后,才会有人去折腾搞一个mybatis plus。

肯定是有需求有痛点的,才会有人去做。总是得有动机的。

但是我最好奇的是,最大最大的那个痛点是什么? 作者又是如何在架构设计层面,解决了这个痛点,思路是什么? 最核心的代码又是什么?

作者肯定是要优先在架构设计这里,思考如何入手的。我们今天不妨在一步一步的看一下。我先贴一段代码。

// UserMapper.java
int insert(User user);
int updateById(User user);
int deleteById(Serializable id);
User selectById(Serializable id);
List<User> selectList();
<insert id=insert>
INSERT INTO user (name, age, create_time) VALUES (#{name}, #{age}, #{createTime})
</insert>
<update id=updateById>
UPDATE user SET name=#{name}, age=#{age} WHERE id=#{id}
</update>
<delete id=deleteById>
DELETE FROM user WHERE id=#{id}
</delete>
<select id=selectById resultType=User>
SELECT * FROM user WHERE id=#{id}
</select>
<select id=selectList resultType=User>
SELECT * FROM user
</select>

每加一张表,接口和XML往往要写成上面的样子。换Order、Product,多半是复制粘贴,只改表名和列名。

程序员嘛,都是希望能复用的就复用,能少写的就少写,不要让类似的代码满天飞,失去控制。

那表面上看有多痛?

上面贴的那段代码本身不复杂,麻烦就麻烦在量和频率。粗算一下,在业务系统里通常都会有如下的固定成本:

维度说明
规模N张表×约8个方法×约15行XML,往往是数百行结构相同、仅表名列名不同的代码
频率每个新项目、每次加表都要来一遍
维护实体改字段,XML、接口、实体三处同步;列名写错,编译期不报错

痛点的本质

就单表CRUD在MyBatis里被固定成一套重复动作,这是最痛的地方。

MyBatis帮你省掉的是后半段,SQL怎么执行、结果怎么映射到实体,这条链路它封装好了。运行模式是下面这样子:

Mapper接口方法 → MappedStatement → SqlSession → JDBC

MappedStatement里挂着最终要执行的SQL。启动时MyBatis会把XML或注解解析成一个个 MappedStatement,放进 Configuration缓存。到这里为止,MyBatis的其中一个大贡献就很清楚了:执行和映射不用你手写JDBC。

缺的一环也在最前面。MyBatis不会替你造这些 MappedStatement:你要么在XML里一段段写 <insert><update>,要么在接口方法上挂 @Select@Insert。每一条都得自己声明id、写SQL、配好怎么映射成实体。还是上面User这张表,光 insertupdateByIddeleteByIdselectByIdselectList这五样,就要在XML或注解里各写一遍。

业务里单表增删改查往往占大头,而这类SQL骨架几乎同构,差别主要在表名和列名。MyBatis把执行和映射做好了,但是「注册MappedStatement」这一步的重复量,它没帮你减掉。

那要解决这个问题,可以从哪里入手呢?

解法思路

既然每张表的CRUD,SQL骨架几乎一样,有差别的也就是表名和列名。

那么能不能把表长什么样,用某种方式描述出来,让框架照着描述,把那几段SQL生成好,替掉手写的XML和注解?

这是一个「用描述替代手写」的方向。

不过MyBatis原本执行的那套方案,已经运行很多年了,不能去动它的。动了等于另造一套ORM。所以生成的SQL,最后还是用回MyBatis原来的路。

这个约束决定了Plus只能在MyBatis的启动环节找切入点。顺着这个方向,就有2个问题值得看一看:

  • 表结构用什么描述、放哪;
  • 生成出来的SQL要复用MyBaits的,因此得了解MyBatis的执行原理。

元数据从哪来

先看第一个问题:表结构用什么描述。

Plus的做法是把描述信息直接写在实体类上。它定义了@TableName@TableId@TableField几个注解,用来标表名、主键、普通列。拿User举例:

@TableName
public class User {
    @TableId
    private Long id;
    private String name;
    private Integer age;
}

不用在XML里写任何东西,也不用在接口上写注解。实体上标好注解,框架就知道这张表长什么样了。

启动时TableInfoHelper会反射扫这些注解,拼出一个TableInfo对象。表名、列名、主键是哪个、哪些字段参与insert,全在这个对象里。可以把它理解成一张表的说明书,后面生成SQL的时候,需要它。

注意这里和@Select那类注解的区别。@Select是在接口方法上直接写SQL,描述的是「怎么查」。后面这组注解描述的是「表长什么样」,SQL是框架根据描述生成的。方向不一样。

元数据有了,接下来就是第二个问题:生成出来的SQL,怎么塞进MyBatis?

生成的SQL怎么进MyBatis

前面提过,MyBatis手写XML时,启动期做了一件事:把XML里的<insert><update>这些标签,解析成MappedStatement,放进Configuration缓存。运行期直接查缓存拿SQL来执行。

Plus要做的,就是在启动期往Configuration里多塞几个MappedStatement。塞进去的和XML解析出来的,是一类东西。运行期完全不用改。

那它是在什么时机塞进去的?

Plus替换了MyBatis的MapperAnnotationBuilder,换成了自己的MybatisMapperAnnotationBuilder。在parse()方法里,执行顺序是这样的:

1. 加载同名的XML文件(如果有的话)
2. 遍历接口方法,解析@Select、@Insert等注解
3. 如果接口继承了BaseMapper,调用parserInjector()注入CRUD

关键在第3步排在最后。XML和注解先parse,手写的SQL先占坑。轮到注入CRUD的时候,如果同一个方法名已经存在MappedStatement了,就跳过。你在XML里自己写了insert,Plus不会覆盖它。只做补充,不抢你的活。

这个顺序是有意的。实际项目里,大部分表用默认CRUD就够了,但总有些查询需要手写SQL。Plus的策略是:你手写的优先级最高,自动生成的只补你没写的部分。

和前面讲的手写XML那条路比,差别只在启动期:手写XML时,SQL是你自己写的;用Plus时,SQL是框架启动时生成的。运行期走的是同一条路,没有任何差别。

那注入这一步,具体是怎么实现的?

Plus怎么把SQL注进去

看一下Plus和MyBatis原生在架构上的差异。

MyBatis原生MyBatis-Plus增加
运行时执行SqlSession、Executor、JDBC不改
Statement注册XML/@Select解析SqlInjector启动注入
元数据ResultMap、TypeHandlerTableInfo
用户契约Mapper接口BaseMapper默认CRUD签名

运行期那一层完全没动,增强全在启动期。多出来的是SqlInjector这条线,负责往Configuration里注入CRUD的MappedStatement

注入的时候,框架得先搞清楚几件事:这个Mapper对应哪张表?表有哪些列?主键是什么?这些信息从Mapper接口的泛型参数里就能拿到。比如UserMapper extends BaseMapper<User>,泛型里的User就是实体类,框架反射取出来,交给TableInfoHelper去建TableInfo

建好TableInfo之后,接下来就是给每个CRUD方法生成对应的SQL。insert、updateById、deleteById、selectList,每个方法背后都有一个专门的类来负责。它们各自知道自己的SQL模板长什么样,知道怎么从TableInfo里取列名和参数,拼出来,注册进去。

Insert举例。它从TableInfo拿到列名列表和值列表,套进INSERT的模板,包成SqlSource,注册成MappedStatement。关键代码就这几行:

// 拼列名和值片段
String columnScript = SqlScriptUtils.convertTrim(
    tableInfo.getAllInsertSqlColumnMaybeIf(null, ignoreAutoIncrementColumn), ...);
// 套进INSERT模板,生成完整SQL
String sql = SqlMethod.INSERT_ONE.format(
    tableInfo.getTableName(), columnScript, valuesScript);
// 包成SqlSource,注册成MappedStatement
SqlSource sqlSource = super.createSqlSource(configuration, sql, modelClass);
return this.addInsertMappedStatement(mapperClass, modelClass, methodName,
    sqlSource, keyGenerator, keyProperty, keyColumn);

UpdateDeleteSelectList都是同一个套路,各自的子类拿各自的模板填空。

用一张完整的时序图把启动过程走一遍:

小结

回头看Plus做这件事,最关键的设计决策是没有改MyBatis的运行期。所有增强都发生在启动期,应用跑起来之后,和手写XML走的是同一条路。这个选择挺务实的。MyBatis的运行期经过了多年验证,动它风险大,也没必要。Plus在启动期把SQL注册进去,后面就完全交给MyBatis了,对用户来说几乎零侵入。

「用描述替代手写」这个思路挺常见的,JPA的实体映射、Django ORM的Model都在做类似的事。Plus的价值在于,它是在MyBatis的体系里找到的切入点。Plus没有另起炉灶,而是在MyBatis的基础上,把最重复的那部分工作自动化了。

参考的内容