0
点赞
收藏
分享

微信扫一扫

从零搭建Spring Boot脚手架(4):手写Mybatis通用Mapper


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 注册

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

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();

还有一个很重要的东西,每一个​​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() && !configuration.hasMapper(mapperInterface)) {
try {
configuration.addMapper(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​功能就实现了。

6. 总结

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

从零搭建Spring Boot脚手架(4):手写Mybatis通用Mapper_java



举报

相关推荐

0 条评论