使用Mybatis自定义插件实现不侵入业务的公共参数自动追加

背景

后台业务开发的过程中,往往会遇到这种场景:需要记录每条记录产生时间、修改时间、修改人及添加人,在查询时查询出来。

以往的做法通常是手动在每个业务逻辑里耦合上这么一块代码,也有更优雅一点的做法是写一个拦截器,然后在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修改

java 复制代码
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),只要遵守这个命名规则即可实现查询结果中自动携带公共参数值

如下为添加公共参数结果映射的代码

java 复制代码
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为先前重新创建的对象。

java 复制代码
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. 单表查询

    java 复制代码
    // 实体类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获取结果:

    json 复制代码
    [
        {
            "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. 多表查询

    java 复制代码
    // 实体类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获取结果:

    json 复制代码
    [
        {
            "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. 多表嵌套查询

    java 复制代码
    // 实体类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 复制代码
    <?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的值:

    json 复制代码
    [
        {
            "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)即可