系列文章共三篇

  • Mybatis (1) 拓展机制
  • Mybatis (2) Mybatis-plus 为什么能和 Mybatis 成为 CP?
  • Mybatis (3) 常见面试点源码视角解析

前记

MyBatis-Plus (opens new window)(简称 MP)是一个 MyBatis (opens new window)的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。

愿景 我们的愿景是成为 MyBatis 最好的搭档,就像 魂斗罗 中的 1P、2P,基友搭配,效率翻倍。

image.png
上述这段话来自于 mybatis-plus(下文简称 mp) 的官方文档,在只做增强不做改变 的基础上,mp 就能实现功能丰富的增强工具,这让我对 mybatis 作为一个基础框架其拓展性架构是如何设计产生了好奇,于是有了此文。

从差异特性切入

如下是 MP 列举的框架增强特性,

  • 无侵入:只做增强不做改变,引入它不会对现有工程产生影响,如丝般顺滑
  • 损耗小:启动即会自动注入基本 CURD,性能基本无损耗,直接面向对象操作
  • 强大的 CRUD 操作:内置通用 Mapper、通用 Service,仅仅通过少量配置即可实现单表大部分 CRUD 操作,更有强大的条件构造器,满足各类使用需求
  • 支持 Lambda 形式调用:通过 Lambda 表达式,方便的编写各类查询条件,无需再担心字段写错
  • 支持主键自动生成:支持多达 4 种主键策略(内含分布式唯一 ID 生成器 - Sequence),可自由配置,完美解决主键问题
  • 支持 ActiveRecord 模式:支持 ActiveRecord 形式调用,实体类只需继承 Model 类即可进行强大的 CRUD 操作
  • 支持自定义全局通用操作:支持全局通用方法注入( Write once, use anywhere )
  • 内置代码生成器:采用代码或者 Maven 插件可快速生成 Mapper 、 Model 、 Service 、 Controller 层代码,支持模板引擎,更有超多自定义配置等您来使用
  • 内置分页插件:基于 MyBatis 物理分页,开发者无需关心具体操作,配置好插件之后,写分页等同于普通 List 查询
  • 分页插件支持多种数据库:支持 MySQL、MariaDB、Oracle、DB2、H2、HSQL、SQLite、Postgre、SQLServer 等多种数据库
  • 内置性能分析插件:可输出 Sql 语句以及其执行时间,建议开发测试时启用该功能,能快速揪出慢查询
  • 内置全局拦截插件:提供全表 delete 、 update 操作智能分析阻断,也可自定义拦截规则,预防误操作

无侵入

使用体验上启动入口只需要更改 SqlSessionFactoryBuilderMybatisSqlSessionFactoryBuilder 原 mybatis 配置无需改动

SqlSessionFactory sqlSessionFactory =
    // mybatis-plus 启动方式
    new MybatisSqlSessionFactoryBuilder().build(inputStream);
    // mybatis 启动方式
    // new SqlSessionFactoryBuilder().build(inputStream);

损耗小

启动即会自动注入基本 CURD,性能基本无损耗,直接面向对象操作

MP 重写了 MapperRegistryMybatisMapperRegistry
其中addMapper 方法又替换了如下两个类实现

Mybatis Mybatis-Plus
MapperProxyFactory MybatisMapperProxyFactory
MapperAnnotationBuilder MybatisMapperAnnotationBuilder

其中 MybatisMapperAnnotationBuilder#parse 中,新增了如下 CURD 动态 SQL 注入, 逻辑上只针对继承自 Mapper.class 的接口类进行 CURD 动态 SQL 注入。

// file => /com/baomidou/mybatisplus/core/MybatisMapperAnnotationBuilder.java:119
// TODO 注入 CURD 动态 SQL , 放在在最后, because 可能会有人会用注解重写sql
try {
    // https://github.com/baomidou/mybatis-plus/issues/3038
    // 只针对继承自 Mapper.class 的接口类进行 CURD 动态 SQL 注入
    if (GlobalConfigUtils.isSupperMapperChildren(configuration, type)) {
        parserInjector();
    }
} catch (IncompleteElementException e) {
    configuration.addIncompleteMethod(new InjectorResolver(this));
}

parserInjector 方法会注入如下文件夹内的动态 SQL

# folder => /com/baomidou/mybatisplus/core/injector/methods
injector
    methods
    Delete
    DeleteBatchByIds
    DeleteById
    DeleteByMap
    Insert
    SelectBatchByIds
    SelectById
    SelectByMap
    SelectCount
    SelectList
    SelectMaps
    SelectMapsPage
    SelectObjs
    SelectOne
    SelectPage
    Update
    UpdateById

强大的 CRUD 操作

通用 Mapper

指代 BaseMapper,内置 17 个常用 CURD 方法,对应了 parserInjector 动态注入的 17 个动态 SQL。
Screen Shot 2021-04-05 at 9.51.25 AM.png
使用体验上用户只需要继承拓展即可,例如

public interface BlogMapper extends BaseMapper<Blog> {
    // 可添加一些拓展方法
}

假如用户的 Mapper 内接口方法定义与 BaseMapper 内发生重名,那么绑定的动态 SQL 到底以谁为准?

参见代码,可见以用户配置的动态 SQL 优先,用户可通过 XML 或者 Annotation 配置。
Tips:注意 statementName 生成规则里,是忽略函数参的,也就是说 foo(Stringfoo 会被映射到同一个 statementName

// file => /com/baomidou/mybatisplus/core/injector/AbstractMethod.java:318
/**
 * 添加 MappedStatement 到 Mybatis 容器
 */
protected MappedStatement addMappedStatement(
    Class<?> mapperClass, String id, SqlSource sqlSource,                                         
    SqlCommandType sqlCommandType, Class<?> parameterType,
    String resultMap, Class<?> resultType, KeyGenerator keyGenerator,
    String keyProperty, String keyColumn
) {
    String statementName = mapperClass.getName() + DOT + id;
    // 判断是否
    if (hasMappedStatement(statementName)) {
        logger.warn(LEFT_SQ_BRACKET + statementName + "] Has been loaded by XML or SqlProvider or Mybatis's Annotation, so ignoring this injection for [" + getClass() + RIGHT_SQ_BRACKET);
        return null;
    }
    // ......
}

通用 Service

com.baomidou.mybatisplus.extension.service.IService 接口和 com.baomidou.mybatisplus.extension.service.impl.ServiceImpl 默认实现,新增了批量操作、事务等特性封装。
该 Servcie 层接口需要依赖继承自 BaseMapper 的接口类,实际使用过程中会这样定义一个 Servcie 类。

public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IService<Blog> {
    // 拓展自定义方法
}

该封装基本都基于 MP 的特性,与 Mybatis 关联性已经不大。

支持 Lambda 形式调用

针对上文中自动注入的方法,默认可以使用 QueryWrapperUpdateWrapper 对象分别设置 where 条件和 更新内容,自定以 SQL 也可通过编码解析 Wrapper 类型入参。

QueryWrapperUpdateWrapper 实例上,通过调用 lambda 函数方法,来获取 LambdaWrapper

  • QueryWrapper中是获取 LambdaQueryWrapper
  • UpdateWrapper中是获取 LambdaUpdateWrapper

无需再担心字段写错,这是怎么实现的?

以如下代码为例,eq 函数 column 传递的是实例对象的方法引用(方法引用也是 lambda 对象)

public static void testLambda(SqlSession session) {
    List<Blog> list = session.getMapper(BlogMapper.class).selectList(
        new QueryWrapper<Blog>().lambda().eq(Blog::getBlogId, 3)
    );
    System.out.println(list);
}

内部通过 columnToString 方法返回字段名

// file => /com/baomidou/mybatisplus/core/conditions/AbstractLambdaWrapper.java:61
protected String columnToString(SFunction<T, ?> column, boolean onlyColumn) {
    return getColumn(LambdaUtils.resolve(column), onlyColumn);
}

// file => /com/baomidou/mybatisplus/core/conditions/AbstractLambdaWrapper.java:77
private String getColumn(SerializedLambda lambda, boolean onlyColumn) {
    Class<?> aClass = lambda.getInstantiatedType();
    tryInitCache(aClass);
    // 通过处理 Getter/Setter 方法获取字段名,具体逻辑为移除方法的 'is', 'get' 或 'set' 起始字段,例如 getBlogId 经处理后返回 blogId
    String fieldName = PropertyNamer.methodToProperty(lambda.getImplMethodName());
    // 从 TableInfo 信息里解析出来的 column 具体配置,假如存在 entity 字段与数据库 column 无法自动映射的情况,需要在 entity 内通过 @TableId 或 @TableField 注明
    ColumnCache columnCache = getColumnCache(fieldName, aClass);
    return onlyColumn ? columnCache.getColumn() : columnCache.getColumnSelect();
}

支持主键自动生成

ORM 框架标配,在 Mytatis 的基础上进行了拓展,此处不做展开

支持 ActiveRecord 模式

类似通用 Servcie 实现,必须存在对应的原始 mapper 并继承 baseMapper 并且可以使用的前提下 才能使用此 ActiveRecord 模式,此处不做细节展开。

该模式下 entity 需要继承 Model ,示例如下。

public class User extends Model<User> {
}

支持自定义全局通用操作

参见通用 Mapper 的方法注入,在 MP 的 DefaultSqlInjectorBaseMapper 基础上继续添加自定义通用 Mapper。

内置代码生成器

其实现行代码库中已经不内置了,外围可选工程效能插件,与 MP 核心框架无关,目前已经演化为 MybatisX 快速开发 IDEA 插件。

MP 内置插件

内置插件实现接口类 com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor
MP 实现 MybatisPlusInterceptor Mybatis 标准插件,注册拦截 Mybatis 如下方法

// file => /com/baomidou/mybatisplus/extension/plugins/MybatisPlusInterceptor.java:50
@Intercepts(
    {
        @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}),
        @Signature(type = StatementHandler.class, method = "getBoundSql", args = {}),
        @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
    }
)
public class MybatisPlusInterceptor implements Interceptor {
    // MP 内部为链式调用,非洋葱模型
    @Setter
    private List<InnerInterceptor> interceptors = new ArrayList<>();
}

MybatisPlusInterceptor 内部通过遍历 interceptors,依次触发 InnerInterceptor 上的接口方法。

MP 内置插件整体执行流程可用下图表示,为职责链模型,非洋葱模型。
Screen Shot 2021-04-05 at 10.13.27 PM.png

MP 架构

拓展点架构图

基于上面的分析,我们试着画出 MP 的拓展架构图,其中主键生成器嵌入在数据插入流程中,未在图中体现。
设计架构上 Mybatis-Plus 是可以兼容 Mybatis 的社区插件。
Screen Shot 2021-04-05 at 10.44.52 PM.png

裂痕

跟随升级困难

由于 MP 复制和重写了大量 Mybatis 的原生类,导致一旦 mybatis 存在版本升级,需要人工比对差异并做修改实现,维护成本高且容易出错。

例如 MP 的内置分页插件,为了实现可以参数返回 IPage 对象,复制重写了 MapperRegistryMapperProxyMapperMethod 等类,目前已经与 mybatis 最新版本存在较大差异。

依赖分层不够清晰

例如 com.baomidou.mybatisplus.extension.service.impl.ServiceImpl 作为一个通用实现,内部存在了 spring-tx 事务依赖。

@Transactional(rollbackFor = Exception.class)
@Override
public boolean saveBatch(Collection<T> entityList, int batchSize) {
    String sqlStatement = getSqlStatement(SqlMethod.INSERT_ONE);
    return executeBatch(entityList, batchSize, (sqlSession, entity) -> sqlSession.insert(sqlStatement, entity));
}

虽然在如今 Spring 大行其道情况下,对于绝大部分开发者不会存在困扰,但作为通用框架能够在 Spring 接入层通过拓展引入特殊依赖,会更加合理。

使用小提示

ResultMap 相互映射问题

从 MP Entity 到 XML

通过 @TableName注解,可以将 Mp Entity ResultMap 配置注入到 mybatis 中,
返回 select 查询结果的时候,只需要 resultMap 设置为自动注入的 id 即可。

开启自动注入后,在 xml 中只需要引入 mybatis-plus_${EntityName} 即可,源码见:

void initResultMapIfNeed() {
    if (autoInitResultMap && null == resultMap) {
        String id = currentNamespace + DOT + MYBATIS_PLUS + UNDERSCORE + entityType.getSimpleName();
        // ......
        ResultMap resultMap = new ResultMap.Builder(configuration, id, entityType, resultMappings).build();
        configuration.addResultMap(resultMap);
        this.resultMap = id;
    }
}

使用示例

// entityType.getSimpleName() 为 UpperCaseCamelStyle(首字母大写驼峰)
<select id="aSelectId" resultMap="mybatis-plus_SmsRecordHash">
</select>

从 XML 到 MP Entity

@TableName 注解中,指定 XML 中的 resultMap ID

@TableName(
    resultMap = "BlogResultMap"
)
public class Blog implements Serializable {
}

总结

在用户侧,MP 作为一个轻量化的 ORM,在杜绝大幅提升使用复杂度的情况下,覆盖了常用的 CURD 查询,保证了常见需求的编码实现一致性,对开发新手也相对友好;另一方面,针对复杂查询也保留了 mybatis 可定制化手写 sql 语句的特性,赋予高级开发者编写高性能、定制化 SQL 语句的能力。

在开发侧,跟随 mybatis 升级对于 MP 的维护者来说,是个不小的工作量。新人 contributor 上手也较为困难,容易牵一发而动全身。

本文写作使用示例代码可见:mybatis-plus-explore | plusman’s poc

参考资料