美文网首页
从零搭建Spring Boot脚手架(4):手写Mybatis通

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

作者: 码农小胖哥 | 来源:发表于2020-08-07 13:51 被阅读0次

    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不会合并的主分支,因为这只是一次尝试,还不足以运用于实践,你可以选择其它知名的框架来做这些事情。多多关注并支持:码农小胖哥 分享更多开发中的事情。

    关注公众号:码农小胖哥,获取更多资讯

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

    相关文章

      网友评论

          本文标题:从零搭建Spring Boot脚手架(4):手写Mybatis通

          本文链接:https://www.haomeiwen.com/subject/ntyfdktx.html