使用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:
- Insert操作:自動為Insert語句添加公共字段并賦值
- Update操作:自動為Update語句添加公共字段并賦值
- 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上的示例項目。
-
單表查詢
// 實體類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" } ] -
多表查詢
// 實體類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 } ] -
多表嵌套查詢
// 實體類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)即可
總結
以上是生活随笔為你收集整理的使用Mybatis自定义插件实现不侵入业务的公共参数自动追加的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 少女前线2追放公测兑换码怎么获得?少女前
- 下一篇: 迎战黑暗:《致命公司》飞船灯不亮全面解决