乐趣区

关于java:从零搭建Spring-Boot脚手架4手写Mybatis通用Mapper

1. 前言

明天持续搭建咱们的 kono Spring Boot 脚手架,上一文把国内最风行的 ORM 框架 Mybatis 也集成了进去。然而很多时候咱们心愿有一些开箱即用的通用 Mapper 来简化咱们的开发。我本人尝试实现了一个,接下来我分享一下思路。昨天晚上才写的,审慎用于理论生产开发,然而能够借鉴思路。

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

GitHub: https://github.com/NotFound40… 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 我的项目地位

这只是本人的一次小尝试,我曾经独自把这个性能抽出来了,有趣味可自行参考钻研。

  • GitHub: https://github.com/NotFound40…
  • Gitee: https://gitee.com/felord/myba…

6. 总结

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

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

集体博客:https://felord.cn

退出移动版