今天继续搭建我们的 kono Spring Boot 脚手架,上一文把国内最流行的 ORM 框架 Mybatis 也集成了进去。但是很多时候我们希望有一些开箱即用的通用 Mapper 来简化我们的开发。我自己尝试实现了一个,接下来我分享一下思路。昨天晚上才写的,谨慎用于实际生产开发,但是可以借鉴思路。
Gitee: https://gitee.com/felord/kono day03 分支 GitHub: https://github.com/NotFound403/kono day03 分支
Gitee: https://gitee.com/felord/kono day03 分支
GitHub: https://github.com/NotFound403/kono day03 分支
最近在看一些关于 Spring Data JDBC 的东西,发现它很不错。其中CrudRepository非常神奇,只要 ORM 接口继承了它就被自动加入 Spring IoC ,同时也具有了一些基础的数据库操作接口。我就在想能不能把它跟 Mybatis 结合一下。
CrudRepository
其实 Spring Data JDBC 本身是支持 Mybatis 的。但是我尝试整合它们之后发现,要做的事情很多,而且需要遵守很多规约,比如MybatisContext的参数上下文,接口名称前缀都有比较严格的约定,学习使用成本比较高,不如单独使用 Spring Data JDBC 爽。但是我还是想要那种通用的CRUD功能啊,所以就开始尝试自己简单搞一个。
MybatisContext
最开始能想到的有几个思路但是最终都没有成功。这里也分享一下,有时候失败也是非常值得借鉴的。
使用 Mybatis 的插件功能开发插件,但是研究了半天发现不可行,最大的问题就是 Mapper 生命周期的问题。
在项目启动的时候 Mapper 注册到配置中,同时对应的 SQL 也会被注册到MappedStatement对象中。当执行 Mapper 的方法时会通过代理来根据名称空间( Namespace )来加载对应的MappedStatement来获取 SQL 并执行。
MappedStatement
而插件的生命周期是在MappedStatement已经注册的前提下才开始,根本衔接不上。
这个完全可行,但是造轮子的成本高了一些,而且成熟的很多,实际生产开发中我们找一个就是了,个人造轮子时间精力成本比较高,也没有必要。
最后还是按照这个方向走,找一个合适的切入点把对应通用 Mapper 的MappedStatement注册进去。接下来会详细介绍我是如何实现的。
在最开始没有 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中找到了它的身影。下面摘自其源码:
MapperFactoryBean
@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 。
明白了 Spring 注册 Mapper 的机制之后就可以开始实现通用 Mapper 了。
这里借鉴 Spring Data 项目中的 CrudRepository <T,ID>的风格,编写了一个 Mapper 的父接口CrudMapper<T, PK>,包含了四种基本的单表操作。
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。
T
PK
为了简化代码,实体类做了一些常见的规约:
UserInfo
user_info
因为主键属性必须有显式的标识才能获得,所以声明了一个主键标记注解:
/** * 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 的过程进行说明。
CrudMapperProvider
UserInfoMapper
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
/** * 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了。
cn.felord.kono.mapperClientUserRoleMapper.findById
一个最佳的切入点是在 Mapper 注册后进行MappedStatement的注册。我们可以继承MapperFactoryBean重写其checkDaoConfig方法利用CrudMapperProvider来注册MappedStatement。
checkDaoConfig
@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(); } } }
因为我们覆盖了默认的MapperFactoryBean所以我们要显式声明启用自定义的MybatisMapperFactoryBean,如下:
MybatisMapperFactoryBean
@MapperScan(basePackages = {"cn.felord.kono.mapper"},factoryBean = MybatisMapperFactoryBean.class)
然后一个通用 Mapper 功能就实现了。
这只是自己的一次小尝试,我已经单独把这个功能抽出来了,有兴趣可自行参考研究。
成功的关键在于对 Mybatis 中一些概念生命周期的把控。其实大多数框架如果需要魔改时都遵循了这一个思路:把流程搞清楚,找一个合适的切入点把自定义逻辑嵌进去。本次 DEMO 不会合并的主分支,因为这只是一次尝试,还不足以运用于实践,你可以选择其它知名的框架来做这些事情。多多关注并支持: 码农小胖哥 分享更多开发中的事情。
原文链接:https://www.cnblogs.com/felordcn/p/13452416.html