從零搭建Spring Boot腳手架(4):手寫Mybatis通用Mapper

碼農小胖哥發表於2020-08-07

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

相關文章