1. 前言

今天继续搭建我们的kono Spring Boot脚手架,上一文把国内最流行的ORM框架Mybatis也集成了进去。但是很多时候我们希望有一些开箱即用的通用Mapper来简化我们的开发。我自己尝试实现了一个,接下来我分享一下思路。昨天晚上才写的,谨慎用于实际生产开发,但是可以借鉴思路。

Gitee: https://gitee.com/felord/kono day03 分支

GitHub: https://github.com/NotFound403/kono day03 分支

2. 思路来源

最近在看一些关于Spring Data JDBC的东西,发现它很不错。其中CrudRepository非常神奇,只要ORM接口继承了它就被自动加入Spring IoC,同时也具有了一些基础的数据库操作接口。我就在想能不能把它跟Mybatis结合一下。

其实Spring Data JDBC本身是支持Mybatis的。但是我尝试整合它们之后发现,要做的事情很多,而且需要遵守很多规约,比如MybatisContext的参数上下文,接口名称前缀都有比较严格的约定,学习使用成本比较高,不如单独使用Spring Data JDBC爽。但是我还是想要那种通用的CRUD功能啊,所以就开始尝试自己简单搞一个。

3. 一些尝试

最开始能想到的有几个思路但是最终都没有成功。这里也分享一下,有时候失败也是非常值得借鉴的。

3.1 Mybatis plugin

使用Mybatis的插件功能开发插件,但是研究了半天发现不可行,最大的问题就是Mapper生命周期的问题。

在项目启动的时候Mapper注册到配置中,同时对应的SQL也会被注册到MappedStatement对象中。当执行Mapper的方法时会通过代理来根据名称空间(Namespace)来加载对应的MappedStatement来获取SQL并执行。

而插件的生命周期是在MappedStatement已经注册的前提下才开始,根本衔接不上。

3.2 代码生成器

这个完全可行,但是造轮子的成本高了一些,而且成熟的很多,实际生产开发中我们找一个就是了,个人造轮子时间精力成本比较高,也没有必要。

3.3 模拟MappedStatement注册

最后还是按照这个方向走,找一个合适的切入点把对应通用MapperMappedStatement注册进去。接下来会详细介绍我是如何实现的。

4. Spring 注册Mapper的机制

在最开始没有Spring Boot的时候,大都是这么注册Mapper的。

  <bean id="baseMapper" class="org.mybatis.spring.mapper.MapperFactoryBean" abstract="true" lazy-init="true">
<property name="sqlSessionFactory" ref="sqlSessionFactory" />
</bean>
<bean id="oneMapper" parent="baseMapper">
<property name="mapperInterface" value="my.package.MyMapperInterface" />
</bean>
<bean id="anotherMapper" parent="baseMapper">
<property name="mapperInterface" value="my.package.MyAnotherMapperInterface" />
</bean>

通过MapperFactoryBean每一个Mybatis Mapper被初始化并注入了Spring IoC容器。所以这个地方来进行通用Mapper的注入是可行的,而且侵入性更小一些。那么它是如何生效的呢?我在大家熟悉的@MapperScan中找到了它的身影。下面摘自其源码:

/**
* Specifies a custom MapperFactoryBean to return a mybatis proxy as spring bean.
*
* @return the class of {@code MapperFactoryBean}
*/
Class<? extends MapperFactoryBean> factoryBean() default MapperFactoryBean.class;

也就是说通常@MapperScan会将特定包下的所有Mapper使用MapperFactoryBean批量初始化并注入Spring IoC

5. 实现通用Mapper

明白了Spring 注册Mapper的机制之后就可以开始实现通用Mapper了。

5.1 通用Mapper接口

这里借鉴Spring Data项目中的CrudRepository<T,ID>的风格,编写了一个Mapper的父接口CrudMapper<T, PK>,包含了四种基本的单表操作。

/**
* 所有的Mapper接口都会继承{@code CrudMapper<T, PK>}.
*
* @param <T> 实体类泛型
* @param <PK> 主键泛型
* @author felord.cn
* @since 14 :00
*/
public interface CrudMapper<T, PK> { int insert(T entity); int updateById(T entity); int deleteById(PK id); T findById(PK id);
}

后面的逻辑都会围绕这个接口展开。当具体的Mapper继承这个接口后,实体类泛型 T 和主键泛型PK就已经确定了。我们需要拿到T的具体类型并把其成员属性封装为SQL,并定制MappedStatement

5.2 Mapper的元数据解析封装

为了简化代码,实体类做了一些常见的规约:

  • 实体类名称的下划线风格就是对应的表名,例如 UserInfo的数据库表名就是user_info
  • 实体类属性的下划线风格就是对应数据库表的字段名称。而且实体内所有的属性都有对应的数据库字段,其实可以实现忽略。
  • 如果对应Mapper.xml存在对应的SQL,该配置忽略。

因为主键属性必须有显式的标识才能获得,所以声明了一个主键标记注解:

/**
* Demarcates an identifier.
*
* @author felord.cn
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(value = { FIELD, METHOD, ANNOTATION_TYPE })
public @interface PrimaryKey {
}

然后我们声明一个数据库实体时这样就行了:

/**
* @author felord.cn
* @since 15:43
**/
@Data
public class UserInfo implements Serializable { private static final long serialVersionUID = -8938650956516110149L;
@PrimaryKey
private Long userId;
private String name;
private Integer age;
}

然后就可以这样编写对用的Mapper了。

public interface UserInfoMapper extends CrudMapper<UserInfo,String> {}

下面就要封装一个解析这个接口的工具类CrudMapperProvider了。它的作用就是解析UserInfoMapper这些Mapper,封装MappedStatement。为了便于理解我通过举例对解析Mapper的过程进行说明。

public CrudMapperProvider(Class<? extends CrudMapper<?, ?>> mapperInterface) {
// 拿到 具体的Mapper 接口 如 UserInfoMapper
this.mapperInterface = mapperInterface;
Type[] genericInterfaces = mapperInterface.getGenericInterfaces();
// 从Mapper 接口中获取 CrudMapper<UserInfo,String>
Type mapperGenericInterface = genericInterfaces[0];
// 参数化类型
ParameterizedType genericType = (ParameterizedType) mapperGenericInterface; // 参数化类型的目的是为了解析出 [UserInfo,String]
Type[] actualTypeArguments = genericType.getActualTypeArguments();
// 这样就拿到实体类型 UserInfo
this.entityType = (Class<?>) actualTypeArguments[0];
// 拿到主键类型 String
this.primaryKeyType = (Class<?>) actualTypeArguments[1];
// 获取所有实体类属性 本来打算采用内省方式获取
Field[] declaredFields = this.entityType.getDeclaredFields(); // 解析主键
this.identifer = Stream.of(declaredFields)
.filter(field -> field.isAnnotationPresent(PrimaryKey.class))
.findAny()
.map(Field::getName)
.orElseThrow(() -> new IllegalArgumentException(String.format("no @PrimaryKey found in %s", this.entityType.getName()))); // 解析属性名并封装为下划线字段 排除了静态属性 其它没有深入 后续有需要可声明一个忽略注解用来忽略字段
this.columnFields = Stream.of(declaredFields)
.filter(field -> !Modifier.isStatic(field.getModifiers()))
.collect(Collectors.toList());
// 解析表名
this.table = camelCaseToMapUnderscore(entityType.getSimpleName()).replaceFirst("_", "");
}

拿到这些元数据之后就是生成四种SQL了。我们期望的SQL,以UserInfoMapper为例是这样的:

#  findById
SELECT user_id, name, age FROM user_info WHERE (user_id = #{userId})
# insert
INSERT INTO user_info (user_id, name, age) VALUES (#{userId}, #{name}, #{age})
# deleteById
DELETE FROM user_info WHERE (user_id = #{userId})
# updateById
UPDATE user_info SET name = #{name}, age = #{age} WHERE (user_id = #{userId})

Mybatis提供了很好的SQL工具类来生成这些SQL:

 String findSQL = new SQL()
.SELECT(COLUMNS)
.FROM(table)
.WHERE(CONDITION)
.toString(); String insertSQL = new SQL()
.INSERT_INTO(table)
.INTO_COLUMNS(COLUMNS)
.INTO_VALUES(VALUES)
.toString(); String deleteSQL = new SQL()
.DELETE_FROM(table)
.WHERE(CONDITION).toString(); String updateSQL = new SQL().UPDATE(table)
.SET(SETS)
.WHERE(CONDITION).toString();

我们只需要把前面通过反射获取的元数据来实现SQL的动态创建就可以了。以insert方法为例:

/**
* Insert.
*
* @param configuration the configuration
*/
private void insert(Configuration configuration) {
String insertId = mapperInterface.getName().concat(".").concat("insert");
// xml配置中已经注册就跳过 xml中的优先级最高
if (existStatement(configuration,insertId)){
return;
}
// 生成数据库的字段列表
String[] COLUMNS = columnFields.stream()
.map(Field::getName)
.map(CrudMapperProvider::camelCaseToMapUnderscore)
.toArray(String[]::new);
// 对应的值 用 #{} 包裹
String[] VALUES = columnFields.stream()
.map(Field::getName)
.map(name -> String.format("#{%s}", name))
.toArray(String[]::new); String insertSQL = new SQL()
.INSERT_INTO(table)
.INTO_COLUMNS(COLUMNS)
.INTO_VALUES(VALUES)
.toString(); Map<String, Object> additionalParameters = new HashMap<>();
// 注册
doAddMappedStatement(configuration, insertId, insertSQL, SqlCommandType.INSERT, entityType, additionalParameters);
}

这里还有一个很重要的东西,每一个MappedStatement都有一个全局唯一的标识,Mybatis的默认规则是Mapper的全限定名用标点符号 . 拼接上对应的方法名称。例如 cn.felord.kono.mapperClientUserRoleMapper.findById。这些实现之后就是定义自己的MapperFactoryBean了。

5.3 自定义MapperFactoryBean

一个最佳的切入点是在Mapper注册后进行MappedStatement的注册。我们可以继承MapperFactoryBean重写其checkDaoConfig方法利用CrudMapperProvider来注册MappedStatement

    @Override
protected void checkDaoConfig() {
notNull(super.getSqlSessionTemplate(), "Property 'sqlSessionFactory' or 'sqlSessionTemplate' are required");
Class<T> mapperInterface = super.getMapperInterface();
notNull(mapperInterface, "Property 'mapperInterface' is required"); Configuration configuration = getSqlSession().getConfiguration(); if (isAddToConfig()) {
try {
// 判断Mapper 是否注册
if (!configuration.hasMapper(mapperInterface)) {
configuration.addMapper(mapperInterface);
}
// 只有继承了CrudMapper 再进行切入
if (CrudMapper.class.isAssignableFrom(mapperInterface)) {
// 一个注册SQL映射的时机
CrudMapperProvider crudMapperProvider = new CrudMapperProvider(mapperInterface);
// 注册 MappedStatement
crudMapperProvider.addMappedStatements(configuration);
}
} catch (Exception e) {
logger.error("Error while adding the mapper '" + mapperInterface + "' to configuration.", e);
throw new IllegalArgumentException(e);
} finally {
ErrorContext.instance().reset();
}
}
}

5.4 启用通用Mapper

因为我们覆盖了默认的MapperFactoryBean所以我们要显式声明启用自定义的MybatisMapperFactoryBean,如下:

@MapperScan(basePackages = {"cn.felord.kono.mapper"},factoryBean = MybatisMapperFactoryBean.class)

然后一个通用Mapper功能就实现了。

5.5 项目位置

这只是自己的一次小尝试,我已经单独把这个功能抽出来了,有兴趣可自行参考研究。

6. 总结

成功的关键在于对Mybatis中一些概念生命周期的把控。其实大多数框架如果需要魔改时都遵循了这一个思路:把流程搞清楚,找一个合适的切入点把自定义逻辑嵌进去。本次DEMO不会合并的主分支,因为这只是一次尝试,还不足以运用于实践,你可以选择其它知名的框架来做这些事情。多多关注并支持:码农小胖哥 分享更多开发中的事情。

关注公众号:Felordcn 获取更多资讯

个人博客:https://felord.cn

从零搭建Spring Boot脚手架(4):手写Mybatis通用Mapper的更多相关文章

  1. 从零搭建Spring Boot脚手架(1):开篇以及技术选型

    1. 前言 目前Spring Boot已经成为主流的Java Web开发框架,熟练掌握Spring Boot并能够根据业务来定制Spring Boot成为一个Java开发者的必备技巧,但是总是零零碎碎 ...

  2. 从零搭建Spring Boot脚手架(2):增加通用的功能

    1. 前言 今天开始搭建我们的kono Spring Boot脚手架,首先会集成Spring MVC并进行定制化以满足日常开发的需要,我们先做一些刚性的需求定制,后续再补充细节.如果你看了本文有什么问 ...

  3. 从零搭建Spring Boot脚手架(3):集成mybatis

    1. 前言 今天继续搭建我们的kono Spring Boot脚手架,上一文集成了一些基础的功能,比如统一返回体.统一异常处理.快速类型转换.参数校验等常用必备功能,并编写了一些单元测试进行验证,今天 ...

  4. 从零搭建Spring Boot脚手架(7):整合OSS作为文件服务器

    1. 前言 文件服务器是一个应用必要的组件之一.最早我搞过FTP,然后又用过FastDFS,接私活的时候我用MongoDB也凑合凑合.现如今时代不同了,开始流行起了OSS. Gitee: https: ...

  5. 从零搭建Spring Boot脚手架(7):Elasticsearch应该独立服务

    1. Spring Data Elasticsearch Spring Data Elasticsearch是Spring Data项目的子项目,提供了Elasticsearch与Spring的集成. ...

  6. 从零搭建Spring Boot脚手架(5):整合 Mybatis Plus

    1. 前言 在上一文中我根据Mybatis中Mapper的生命周期手动实现了一个简单的通用Mapper功能,但是遗憾的是它缺乏实际生产的检验.因此我选择更加成熟的一个Mybatis开发增强包.它就是已 ...

  7. 从零搭建Spring Boot脚手架(6):整合Redis作为缓存

    1. 前言 上一文我们整合了Mybatis Plus,今天我们会把缓存也集成进来.缓存是一个系统应用必备的一种功能,除了在减轻数据库的压力之外.还在存储一些短时效的数据场景中发挥着重大作用,比如存储用 ...

  8. (一 、上)搭建简单的SpringBoot + java + maven + mysql + Mybatis+通用Mapper 《附项目源码》

    最近公司一直使用 springBoot 作为后端项目框架, 也负责搭建了几个新项目的后端框架.在使用了一段时间springBoot 后,感觉写代码 比spring 更加简洁了(是非常简洁),整合工具也 ...

  9. Spring Boot 项目学习 (二) MySql + MyBatis 注解 + 分页控件 配置

    0 引言 本文主要在Spring Boot 基础项目的基础上,添加 Mysql .MyBatis(注解方式)与 分页控件 的配置,用于协助完成数据库操作. 1 创建数据表 这个过程就暂时省略了. 2 ...

随机推荐

  1. 数据可视化之分析篇(一)使用Power BI进行动态帕累托分析

    https://zhuanlan.zhihu.com/p/57763423 通过简单的点击交互,就能进行动态分析发现见解,才是我们需要的,恰好这也是 PowerBI 所擅长的. 就帕累托分析来说,能从 ...

  2. 数据可视化之DAX篇(二十二)一文搞懂Power BI中的排名问题

    https://zhuanlan.zhihu.com/p/68384001 本文聊聊在PowerBI中如何进行各种类型的排名问题. PowerBI中计算排名主要使用RANKX函数,关于该函数的具体语法 ...

  3. python小游戏-水文

    脚本不会,全都白费.所以就去学习了简单的python,结果不慎学了python中的pygame,浪费了不少时间,没啥用如果不做游戏个人觉得最好别学,学爬虫她不香吗?不过也有一点收获,打飞机小游戏,源码 ...

  4. GitHub 热点速览 Vol.28:有品位程序员的自我修养

    作者:HelloGitHub-小鱼干 摘要:一个程序员除了技术好,还得品位高,有什么比一个高颜值的 GUI 更能体现你品味的呢?rocketredis 就是一个高颜值.简约的 Redis 管理界面,比 ...

  5. 学习mysql,你必须要了解的 “ 索引 ” 基本知识

    1.select * 对效率的影响在我们平时的代码编写或面试题中,很多人都会疑惑:select * 到底合理吗? 如果说不合理,为什么?如果说合理,原因又是什么? 1).阿里规范 在阿里java规范中 ...

  6. Lucas定理 & Catalan Number & 中国剩余定理(CRT)

    又双叒叕来水数论了 今天来学习\(Lucas \:\ \& \:\ Catalan Number\) 两者有着密切的联系(当然还有CRT),所以放在一起学习一下 \(Lucas\) 定义\(\ ...

  7. 搭建jmeter+influxdb+grafana压测实时监控平台(超详细,小白适用)

    1.前言 在使用jmeter做性能测试的时候,监控系统性能的时候,无论是使用插件还是报告生成,都没法实现实时监控.使用JMeter+Influxdb+Grafana可以实现实时监控. 本次环境搭建各软 ...

  8. [redis] -- 缓存雪崩和缓存穿透、缓存击穿问题解决方案篇

    缓存雪崩 缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉 解决方案 事前:尽量保证整个redis集群的高可用性,发现机器宕机尽快补上.选择合适的内存淘 ...

  9. 中科大数分教材:用阶乘倒数和计算e值的误差和e是无理数的证明,用到误差计算

    \(e=lim_{n \to \infty}e_{n}(1+\frac{1}{n})^n\\\) \(=\lim_{n \to \infty}(\frac{1}{0!}+\frac{1}{1!}+\f ...

  10. paramiko上传文件到Linux

    一.传输单个文件到Linux服务器 import paramiko transport = paramiko.Transport(('host',22)) transport.connect(user ...