使用Mybatis自定義外掛實現不侵入業務的公共引數自動追加

指標旋轉九十度發表於2023-12-27

背景

後臺業務開發的過程中,往往會遇到這種場景:需要記錄每條記錄產生時間、修改時間、修改人及新增人,在查詢時查詢出來。
以往的做法通常是手動在每個業務邏輯裡耦合上這麼一塊程式碼,也有更優雅一點的做法是寫一個攔截器,然後在Mybatis攔截器中為實體物件中的公共引數進行賦值,但最終依然需要在業務SQL上手動新增上這幾個引數,很多開源後臺專案都有類似做法。

這種做法往往不夠靈活,新增或修改欄位時每處業務邏輯都需要同步修改,業務量大的話這麼改非常麻煩。

最近在我自己的專案中寫了一個Mybatis外掛,這個外掛能夠實現不修改任何業務邏輯就能實現新增或修改時資料庫公共欄位的賦值,並能在查詢時自動查詢出來。

實現原理

Mybatis提供了一系列的攔截器,用於實現在Mybatis執行的各個階段允許插入或修改自定義邏輯。

Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
ParameterHandler (getParameterObject, setParameters)
ResultSetHandler (handleResultSets, handleOutputParameters)
StatementHandler (prepare, parameterize, batch, update, query)

我這裡用的是Executor,它能做到在所有資料庫操作前後執行一些邏輯,甚至可以修改Mybatis的上下文引數後繼續執行。
在Mybaits的攔截器中,可以拿到MappedStatement物件,這裡麵包含了一次資料庫操作的原始SQL以及實體物件與結果集的對映關係,為了實現公共引數自動攜帶,我們就需要在攔截器中修改原始SQL:

  1. Insert操作:自動為Insert語句新增公共欄位並賦值
  2. Update操作:自動為Update語句新增公共欄位並賦值
  3. Select操作:自動為Select語句的查詢引數上新增上公共欄位

以及修改實體物件與結果集的對映關係,做到自動修改查詢語句新增公共欄位後能夠使Mybatis將查出的公共欄位值賦給實體類。

簡單來說就是修改MappedStatement中的SqlSource以及ResultMap

修改SqlSource

在SqlSource中,包含了原始待執行的SQL,需要將它修改為攜帶公共引數的SQL。
需要注意的是Mybatis的SqlSource、ResultMap中的屬性僅允許初次構造SqlSource物件時進行賦值,後續如果需要修改只能透過反射或者新構造一個物件替換舊物件的方式進行內部引數修改。

直接貼出來程式碼,這裡新構造了SqlSource物件,在裡面實現了原始SQL的解析修改:
SQL的動態修改使用了JSQLParser將原始SQL解析為AST抽象語法樹後做引數追加,之後重新解析為SQL,使用自定義SqlSource返回修改後的SQL實現SQL修改

static class ModifiedSqlSourceV2 implements SqlSource {
        private final MappedStatement mappedStatement;
        private final Configuration configuration;

        public ModifiedSqlSourceV2(MappedStatement mappedStatement, Configuration configuration) {
            this.mappedStatement = mappedStatement;
            this.configuration = configuration;
        }

        @Override
        public BoundSql getBoundSql(Object parameterObject) {
            // 獲取原始的 BoundSql 物件
            BoundSql originalBoundSql = mappedStatement.getSqlSource().getBoundSql(parameterObject);

            // 獲取原始的 SQL 字串
            String originalSql = originalBoundSql.getSql();
            log.debug("公共引數新增 - 修改前SQL:{}", originalSql);

            // 建立新的 BoundSql 物件
            String modifiedSql;
            try {
                modifiedSql = buildSql(originalSql);
                log.debug("公共引數新增 - 修改後SQL:{}", modifiedSql);
            } catch (JSQLParserException e) {
                log.error("JSQLParser解析修改SQL新增公共引數失敗, 繼續使用原始SQL執行" , e);
                modifiedSql = originalSql;
            }
            BoundSql modifiedBoundSql = new BoundSql(configuration, modifiedSql,
                    originalBoundSql.getParameterMappings(), parameterObject);
            // 複製其他屬性
            originalBoundSql.getAdditionalParameters().forEach(modifiedBoundSql::setAdditionalParameter);
            modifiedBoundSql.setAdditionalParameter("_parameter", parameterObject);

            return modifiedBoundSql;
        }

        private String buildSql(String originalSql) throws JSQLParserException {
            Statement statement = CCJSqlParserUtil.parse(originalSql);

            switch(mappedStatement.getSqlCommandType()) {
                case INSERT -> {
                    if(statement instanceof Insert insert) {
                        insert.addColumns(new Column(CREATE_BY_COLUMN), new Column(CREATE_TIME_COLUMN));
                        ExpressionList expressionList = insert.getItemsList(ExpressionList.class);
                        Timestamp currentTimeStamp = new Timestamp(System.currentTimeMillis());

                        if (!expressionList.getExpressions().isEmpty()) {
                            // 多行插入 行構造器解析
                            if (expressionList.getExpressions().get(0) instanceof RowConstructor) {
                                expressionList.getExpressions().forEach((expression -> {
                                    if (expression instanceof RowConstructor rowConstructor) {
                                        rowConstructor.getExprList().getExpressions().add(new StringValue(getCurrentUser()));
                                        rowConstructor.getExprList().getExpressions().add(new TimestampValue().withValue(currentTimeStamp));
                                    }
                                }));
                            } else {
                                // 其餘預設單行插入
                                expressionList.addExpressions(new StringValue(getCurrentUser()), new TimestampValue().withValue(currentTimeStamp));
                            }
                        }

                        return insert.toString();
                    }
                }
                case UPDATE -> {
                    if(statement instanceof Update update) {
                        List<UpdateSet> updateSetList = update.getUpdateSets();
                        UpdateSet updateBy = new UpdateSet(new Column(UPDATE_BY_COLUMN), new StringValue(getCurrentUser()));
                        Timestamp currentTimeStamp = new Timestamp(System.currentTimeMillis());
                        UpdateSet updateTime = new UpdateSet(new Column(UPDATE_TIME_COLUMN), new TimestampValue().withValue(currentTimeStamp));
                        updateSetList.add(updateBy);
                        updateSetList.add(updateTime);

                        return update.toString();
                    }
                }
                case SELECT -> {
                    if(statement instanceof Select select) {
                        SelectBody selectBody = select.getSelectBody();
                        if(selectBody instanceof PlainSelect plainSelect) {
                            TablesNamesFinder tablesNamesFinder = new TablesNamesFinder();
                            List<String> tableNames = tablesNamesFinder.getTableList(select);

                            List<SelectItem> selectItems = plainSelect.getSelectItems();
                            tableNames.forEach((tableName) -> {
                                String lowerCaseTableName = tableName.toLowerCase();
                                selectItems.add(new SelectExpressionItem().withExpression(new Column(new Table(tableName), CREATE_BY_COLUMN)).withAlias(new Alias(lowerCaseTableName + "_" + CREATE_BY_COLUMN)));
                                selectItems.add(new SelectExpressionItem().withExpression(new Column(new Table(tableName), CREATE_TIME_COLUMN)).withAlias(new Alias(lowerCaseTableName + "_" + CREATE_TIME_COLUMN)));
                                selectItems.add(new SelectExpressionItem().withExpression(new Column(new Table(tableName), UPDATE_BY_COLUMN)).withAlias(new Alias(lowerCaseTableName + "_" + UPDATE_BY_COLUMN)));
                                selectItems.add(new SelectExpressionItem().withExpression(new Column(new Table(tableName), UPDATE_TIME_COLUMN)).withAlias(new Alias(lowerCaseTableName + "_" + UPDATE_TIME_COLUMN)));
                            });

                            return select.toString();
                        }
                    }
                }
                default -> {
                    return originalSql;
                }
            }
            return originalSql;
        }
}

修改ResultMap

ResultMap中存放了結果列與對映實體類屬性的對應關係,這裡為了自動生成公共屬性的結果對映,直接根據當前ResultMap中儲存的結果對映實體類的名稱作為表名,自動建立與結果列的對映關係。

就是說資料庫表對應的實體類的名字需要與資料庫表保持一致(但是實體類名可以是資料庫表的名字的駝峰命名,如表user_role的實體類需要命名為UserRole),只要遵守這個命名規則即可實現查詢結果中自動攜帶公共引數值
如下為新增公共引數結果對映的程式碼

private static List<ResultMapping> addResultMappingProperty(Configuration configuration, List<ResultMapping> resultMappingList, Class<?> mappedType) {
        // resultMappingList為不可修改物件
        List<ResultMapping> modifiableResultMappingList = new ArrayList<>(resultMappingList);

        String []checkList = {CREATE_BY_PROPERTY, CREATE_TIME_PROPERTY, UPDATE_BY_PROPERTY, UPDATE_TIME_PROPERTY};
        boolean hasAnyTargetProperty = Arrays.stream(checkList).anyMatch((property) -> ReflectionUtils.findField(mappedType, property) != null);

        // 用於防止對映目標為基本型別卻被新增對映 導致列名規則 表名_列名 無法與對映的列名的新增規則 對映型別名_列名 相照應
        // 從而導致對映型別為基本型別時會生成出類似與string_column1的對映名 而產生找不到對映列名與實際結果列相照應的列名導致mybatis產生錯誤
        // 規則: 僅對映型別中包含如上四個欄位其一時才會新增對映
        if(hasAnyTargetProperty) {
            // 支援型別使用駝峰命名
            String currentTable = upperCamelToLowerUnderscore(mappedType.getSimpleName());

            // 對映方式 表名_公共欄位名 在實體中 表名與實體名相同 則可完成對映
            modifiableResultMappingList.add(new ResultMapping.Builder(configuration, CREATE_BY_PROPERTY, currentTable + "_" + CREATE_BY_COLUMN, String.class).build());
            modifiableResultMappingList.add(new ResultMapping.Builder(configuration, CREATE_TIME_PROPERTY, currentTable + "_" + CREATE_TIME_COLUMN, Timestamp.class).build());
            modifiableResultMappingList.add(new ResultMapping.Builder(configuration, UPDATE_BY_PROPERTY, currentTable + "_" + UPDATE_BY_COLUMN, String.class).build());
            modifiableResultMappingList.add(new ResultMapping.Builder(configuration, UPDATE_TIME_PROPERTY, currentTable + "_" + UPDATE_TIME_COLUMN, Timestamp.class).build());
        }

        return modifiableResultMappingList;
}

構建MappedStatement

原本的由Mybatis建立的MappedStatement無法直接修改,因此這裡手動透過ResultMap.Builder()構造一個新的MappedStatement,同時保持其餘引數不變,只替換SqlSource、ResultMap為先前重新建立的物件。

public MappedStatement buildMappedStatement(Configuration newModifiedConfiguration, MappedStatement mappedStatement) {
        SqlSource modifiedSqlSource = new ModifiedSqlSourceV2(mappedStatement, newModifiedConfiguration);

        List<ResultMap> modifiedResultMaps = mappedStatement.getResultMaps().stream().map((resultMap) -> {
            List<ResultMapping> resultMappingList = resultMap.getResultMappings();
            // 為每個resultMap中的resultMappingList新增公共引數對映
            List<ResultMapping> modifiedResultMappingList = addResultMappingProperty(newModifiedConfiguration, resultMappingList, resultMap.getType());

            return new ResultMap.Builder(newModifiedConfiguration, resultMap.getId(), resultMap.getType(), modifiedResultMappingList, resultMap.getAutoMapping()).build();
        }).toList();

        // 構造新MappedStatement 替換SqlSource、ResultMap、Configuration
        MappedStatement.Builder newMappedStatementBuilder = new MappedStatement.Builder(newModifiedConfiguration, mappedStatement.getId(), modifiedSqlSource, mappedStatement.getSqlCommandType())
                .cache(mappedStatement.getCache()).databaseId(mappedStatement.getDatabaseId()).dirtySelect(mappedStatement.isDirtySelect()).fetchSize(mappedStatement.getFetchSize())
                .flushCacheRequired(mappedStatement.isFlushCacheRequired())
                .keyGenerator(mappedStatement.getKeyGenerator())
                .lang(mappedStatement.getLang()).parameterMap(mappedStatement.getParameterMap()).resource(mappedStatement.getResource()).resultMaps(modifiedResultMaps)
                .resultOrdered(mappedStatement.isResultOrdered())
                .resultSetType(mappedStatement.getResultSetType()).statementType(mappedStatement.getStatementType()).timeout(mappedStatement.getTimeout()).useCache(mappedStatement.isUseCache());
        if(mappedStatement.getKeyColumns() != null) {
            newMappedStatementBuilder.keyColumn(StringUtils.collectionToDelimitedString(Arrays.asList(mappedStatement.getKeyColumns()), ","));
        }
        if(mappedStatement.getKeyProperties() != null) {
            newMappedStatementBuilder.keyProperty(StringUtils.collectionToDelimitedString(Arrays.asList(mappedStatement.getKeyProperties()), ","));
        }
        if(mappedStatement.getResultSets() != null) {
            newMappedStatementBuilder.resultSets(StringUtils.collectionToDelimitedString(Arrays.asList(mappedStatement.getResultSets()), ","));
        }
        return newMappedStatementBuilder.build();
}

到這裡為止,已經完全實現了修改原始SQL、修改結果對映的工作了,將修改後的MappedStatement物件往下傳入到invoke()即可但是還能改進。

改進

在Mybatis攔截器中可以透過MappedStatement.getConfiguration()拿到整個Mybatis的上下文,在這個裡面可以拿到所有Mybatis的所有SQL操作的對映結果以及SQL,可以一次性修改完後,將Configuration作為一個快取使用,每次有請求進入攔截器後就從Configuration獲取被修改的MappedStatement後直接invoke,效率會提升不少。
經給改進後,除了應用啟動後執行的第一個SQL請求由於需要構建Configuration會慢一些,之後的請求幾乎沒有產生效能方面的影響。

現在唯一的效能消耗是每次執行請求前Mybatis會呼叫我們自己重新定義的SqlSource.getBoundSql()將原始SQL解析為AST後重新構建生成新SQL的過程了,這點開銷幾乎可忽略不計。如果想更進一步的最佳化,可以考慮將原始SQL做key,使用Caffeine、Guava快取工具等方式將重新構建後的查詢SQL快取起來(Update/Insert由於追加有時間引數的原因,不能被快取),避免多次重複構建SQL帶來的開銷

完整實現

經過最佳化後,整個外掛已經比較完善了,能夠滿足日常使用,無論是單表查詢,還是多表聯查,巢狀查詢都能夠實現無侵入的引數追加,目前僅實現了建立人、建立時間、修改人、修改時間的引數追加&對映繫結,如有需要的可以自行修改。

我把它放到了GitHub上,並附帶有示例專案:https://github.com/Random-pro/ExtParamInterctptor
覺得好用的歡迎點點Star

使用的人多的話,後續會將追加哪些引數做成動態可配置的,等你們反饋

外掛使用示例

所有的新增操作均會被自動新增建立人、建立時間。更新操作則會被自動新增更新人、更新時間。正常使用Mybatis操作即可,與原先無任何差別就不在這裡給出示例了,如果需要示例請前往我在GitHub上的示例專案。

  1. 單表查詢

    // 實體類Child(類名對應具體的表名 使用駝峰命名法,如表名為user_role,則類名應寫為UserRole)
    @Data
    public class Child extends BaseDomain {
      private int childId;
      private int parentId;
      private String childName;
      private String path;
    }
    
    // 公共欄位
    @Data
    public class BaseDomain {
      private String createBy;
      private Date createTime;
      private String updateBy;
      private Date updateTime;
    }
    
    // Mapper介面
    @Mapper
    public interface TestMapper {
      @Select("SELECT id as childId, name as childName, parent_id as parentId, path FROM child")
      List<Child> getChildList();
    }
    
    // Controller
    @RestController
    @RequestMapping("user")
    public record UserController(TestMapper testMapper) {
      @GetMapping("getChildList")
      public List<Child> getChildList() {
        return testMapper.getChildList();
      }
    }
    

    訪問user/getChildList獲取結果:

    [
        {
            "createBy": "sun11",
            "createTime": "2023-12-18T07:58:58.000+00:00",
            "updateBy": "random",
            "updateTime": "2023-12-18T07:59:19.000+00:00",
            "childId": 1,
            "parentId": 1,
            "childName": "childName1_1",
            "path": "childPath1_1"
        },
        {
            "createBy": "sun12",
            "createTime": "2023-12-18T07:58:59.000+00:00",
            "updateBy": "RANDOM",
            "updateTime": "2023-12-18T07:59:20.000+00:00",
            "childId": 2,
            "parentId": 1,
            "childName": "childName1_2",
            "path": "childPath1_2"
        },
        {
            "createBy": "sun21",
            "createTime": "2023-12-18T07:59:00.000+00:00",
            "updateBy": "randompro",
            "updateTime": "2023-12-18T07:59:21.000+00:00",
            "childId": 3,
            "parentId": 2,
            "childName": "childName2_1",
            "path": "childPath2_2"
        }
    ]
    
  2. 多表查詢

    // 實體類Base(類名對應具體的表名 使用駝峰命名法,如表名為user_role,則類名應寫為UserRole) 注意:當關聯多個表時,需要取哪個表裡的公共欄位(建立人、建立時間等欄位)則將對映實體類名命名為該表的表名
    @Data
    public class Base extends BaseDomain {
      private int id;
      private String baseName;
      private String basePath;
      private List<Child> pathChildList;
    }
    
    @Data
    public class Child extends BaseDomain {
      private int childId;
      private int parentId;
      private String childName;
      private String path;
    }
    
    // 公共欄位
    @Data
    public class BaseDomain {
      private String createBy;
      private Date createTime;
      private String updateBy;
      private Date updateTime;
    }
    
    // Mapper介面
    @Mapper
    public interface TestMapper {
      @Select("SELECT BASE.ID as id , BASE.BASE_NAME as baseName, CHILD.PATH as basePath FROM BASE, CHILD WHERE BASE.ID = CHILD.PARENT_ID")
      List<Base> getBaseAndChildPath();
    }
    
    // Controller
    @RestController
    @RequestMapping("user")
    public record UserController(TestMapper testMapper) {
      @GetMapping("getBaseAndChildPath")
      public List<Base> getBaseAndChildPath() {
        return testMapper.getBaseAndChildPath();
      }
    }
    

    訪問user/getBaseAndChildPath獲取結果:

    [
        {
            "createBy": "sun_base",
            "createTime": "2023-12-18T07:59:29.000+00:00",
            "updateBy": "random_base",
            "updateTime": "2023-12-18T08:00:09.000+00:00",
            "id": 1,
            "baseName": "baseName1",
            "basePath": "childPath1_1",
            "pathChildList": null
        },
        {
            "createBy": "sun_base",
            "createTime": "2023-12-18T07:59:29.000+00:00",
            "updateBy": "random_base",
            "updateTime": "2023-12-18T08:00:09.000+00:00",
            "id": 1,
            "baseName": "baseName1",
            "basePath": "childPath1_2",
            "pathChildList": null
        },
        {
            "createBy": "sun2_base",
            "createTime": "2023-12-18T07:59:30.000+00:00",
            "updateBy": "randompro_base",
            "updateTime": "2023-12-18T08:00:09.000+00:00",
            "id": 2,
            "baseName": "baseName2",
            "basePath": "childPath2_2",
            "pathChildList": null
        }
    ]
    
  3. 多表巢狀查詢

    // 實體類Base(類名對應具體的表名 使用駝峰命名法,如表名為user_role,則類名應寫為UserRole) 巢狀查詢中使用到的多個實體若均可對映到對應表中的如上四個欄位的值(只要該實體透過繼承、直接新增的方式獲取到了以上宣告的四個實體屬性的getter/setter方法即可)
    @Data
    public class Base extends BaseDomain {
      private int id;
      private String baseName;
      private String basePath;
      private List<Child> pathChildList;
    }
    
    @Data
    public class Child extends BaseDomain {
      private int childId;
      private int parentId;
      private String childName;
      private String path;
    }
    
    // 公共欄位
    @Data
    public class BaseDomain {
      private String createBy;
      private Date createTime;
      private String updateBy;
      private Date updateTime;
    }
    
    // Mapper介面
    @Mapper
    public interface TestMapper {
      List<Base> getPathList();
    }
    
    // Controller
    @RestController
    @RequestMapping("user")
    public record UserController(TestMapper testMapper) {
      @GetMapping("getPathList")
      public List<Base> getPathList() {
        return testMapper.getPathList();
      }
    }
    

    Mapper.xml:

    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper
            PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
            "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="com.live.mapper.TestMapper">
    
        <resultMap type="com.live.domian.Base" id="PathDomainMap">
            <result property="id"    column="id"    />
            <result property="baseName" column="base_name"/>
            <result property="basePath" column="base_path"/>
    
            <collection property="pathChildList" ofType="com.live.domian.Child">
                <id property="childId" column="child_id"/>
                <result property="parentId" column="parent_id"/>
                <result property="childName" column="child_name"/>
                <result property="path" column="path"/>
            </collection>
        </resultMap>
    
        <select id="getPathList" resultMap="PathDomainMap">
            SELECT base.id, base.base_name, base.base_path, child.id AS child_id, child.name AS child_name,
                  child.path, child.parent_id FROM base LEFT JOIN child ON base.id = child.parent_id
        </select>
    </mapper>
    

    訪問user/getPathList獲取結果,可見巢狀查詢中每個層次都取到了公共欄位createBy、createTime、updateBy、updateTime的值:

    [
        {
            "createBy": "sun_base",
            "createTime": "2023-12-18T07:59:29.000+00:00",
            "updateBy": "random_base",
            "updateTime": "2023-12-18T08:00:09.000+00:00",
            "id": 1,
            "baseName": "baseName1",
            "basePath": "basePath1",
            "pathChildList": [
                {
                    "createBy": "sun12",
                    "createTime": "2023-12-18T07:58:59.000+00:00",
                    "updateBy": "RANDOM",
                    "updateTime": "2023-12-18T07:59:20.000+00:00",
                    "childId": 2,
                    "parentId": 1,
                    "childName": "childName1_2",
                    "path": "childPath1_2"
                },
                {
                    "createBy": "sun11",
                    "createTime": "2023-12-18T07:58:58.000+00:00",
                    "updateBy": "random",
                    "updateTime": "2023-12-18T07:59:19.000+00:00",
                    "childId": 1,
                    "parentId": 1,
                    "childName": "childName1_1",
                    "path": "childPath1_1"
                }
            ]
        },
        {
            "createBy": "sun2_base",
            "createTime": "2023-12-18T07:59:30.000+00:00",
            "updateBy": "randompro_base",
            "updateTime": "2023-12-18T08:00:09.000+00:00",
            "id": 2,
            "baseName": "baseName2",
            "basePath": "basePath2",
            "pathChildList": [
                {
                    "createBy": "sun21",
                    "createTime": "2023-12-18T07:59:00.000+00:00",
                    "updateBy": "randompro",
                    "updateTime": "2023-12-18T07:59:21.000+00:00",
                    "childId": 3,
                    "parentId": 2,
                    "childName": "childName2_1",
                    "path": "childPath2_2"
                }
            ]
        }
    ]
    

    巢狀查詢中,如果只希望獲取到特定的表的那四個公共屬性,則把不希望獲取公共屬性的表對應的實體類中的四個對映屬性去掉(若使用BaseDomain繼承來的四個屬性的的話去掉繼承BaseDomain)即可

相關文章