Mybatis Plus轻松实现数据库变更全局审计日志
- Mybatis Plus轻松实现数据库变更全局审计日志
- 引言
- 实现审计日志
- 1.创建审计日志表
- 2.创建AuditLogAspect用于记录请求日志
- 4. 保存审计日志
- 总结
Mybatis Plus轻松实现数据库变更全局审计日志
引言
在日常的业务开发中,监控与记录数据库的变化是非常必要的操作,特别是当出现数据异常时,我们可以通过审计日志追溯数据变化的具体情况。Mybatis Plus作为一款优秀的持久层框架,其强大的功能可以轻松帮助我们实现全局审计日志。接下来,我将向大家介绍如何利用Mybatis Plus实现数据库变更的全局审计日志。
实现审计日志
首先,我们这里说的审计日志,主要包括审计需要的四大元素包括操作用户、操作时间、操作类型以及操作前后的数据对比,本文主要基于这个要求进行实现,实现效果如下图所示:
接下来介绍具体实现步骤:
1.创建审计日志表
首先,我们需要创建一个审计日志数据库表用于存储日志记录,商品表用于测试变更操作。这个表需要包含用户ID、操作时间、请求ID、操作表名称、变更前,变更后,具体变更内容,操作人员等字段。以下是SQL语句:
create table `audit-log`
(
id bigint not null comment '主键' primary key,
`request_id` varchar(50) not null comment '请求ID',
`data_change` varchar(1000) null comment '变更项',
`before_value` varchar(1000) null comment '对象变更前json',
`after_value` varchar(1000) null comment '对象变更后json',
`table_name` varchar(50) null comment '变更表名',
`create_time` datetime null comment '变更时间',
`user_id` bigint null comment '操作人员'
-- 可以适当冗余相关用户名,机构ID,机构名称等。
)
charset = utf8mb4;
create table goods
(
id bigint not null
primary key,
name varchar(100) not null,
price decimal(18, 2) not null,
description varchar(255) null,
brand varchar(100) not null,
image_url varchar(255) not null,
create_time datetime not null,
update_time datetime not null
)
charset = utf8mb4;
2.创建AuditLogAspect用于记录请求日志
这里我们需要创建一个AOP,用于在指定的包或注解进行拦截,执行前和执行后记录相应的数据,用于构造审计日志,如下代码有详细注释:
@Aspect
@Component
public class AuditLogAspect {
Logger logger = LoggerFactory.getLogger(AuditLogAspect.class);
@Autowired
private ThreadAuditService threadAuditService;
private static void accept(DomainChangeAction change) {
List<?> oldObject = change.getOldObject();
if (CollectionUtils.isEmpty(oldObject)) {
return;
}
List<Map<String, Object>> maps = change.getJdbcTemplate().queryForList(change.getQuerySql());
change.setNewObject(maps);
}
/**
* <p>
* 业务方法执行前记录
* </p>
*
* @param auditLogTag AuditLogTag
* @return void
*/
@Before("@annotation(auditLogTag)")
public void beforeDataOperate(JoinPoint joinPoint, AuditLogTag auditLogTag) {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
AuditLogDTO auditLogDTO = threadAuditService.getAuditLogDTO();
if (auditLogDTO == null) {
auditLogDTO = new AuditLogDTO(IdWorker.getTimeId()); // 创建线程DTO,设置惟一请求ID
threadAuditService.setAuditLogDTO(auditLogDTO); // 放入线程作用域
}
// 获取当前线程中审计日志DTO
String ClassName = methodSignature.getDeclaringTypeName();
auditLogDTO.setExecuteMethod(ClassName + "#" + joinPoint.getSignature().getName());
// 如果要全局进行业务审计的话,则切面就不需要对指定的注解进行了,可以根据实体对象上增加注解标记
auditLogDTO.setModel(auditLogTag.model());
auditLogDTO.setTag(auditLogTag.model());
// TODO: userId可以从上下文中获取,本例没有集成登录,暂无法获取 auditLogDTO.setUserId();
}
/**
* 业务方法执行后记录
*/
@AfterReturning("@annotation(com.atshuo.audit.aop.dto.AuditLogTag)")
public void afterDataOperate() {
try {
List<DomainChangeAction> domainChanges = threadAuditService.getAuditLogDTO().getDomainChanges();
if (CollectionUtils.isEmpty(domainChanges)) {
return;
}
domainChanges.forEach(AuditLogAspect::accept);
this.compareAndTransfer(domainChanges);
} catch (Exception e) {
logger.error("获取变更前后内容出错", e);
}
}
/**
* 对比保存
*/
public void compareAndTransfer(List<DomainChangeAction> list) {
List<AuditLog> auditLogs = new ArrayList<>();
list.forEach(change -> {
List<?> oldObject = change.getOldObject();
List<?> newObject = change.getNewObject();
// 更新前后数据量,无法对应,不做处理,应该属于逻辑删除。
if (newObject == null) {
return;
}
if (oldObject == null) {
return;
}
if (oldObject.size() != newObject.size()) {
return;
}
for (int i = 0; i < oldObject.size(); i++) {
try {
String oldDataJson = JSON.toJSONString(oldObject.get(i));
String newDataJson = JSON.toJSONString(newObject.get(i));
String differenceJson = CompareObjUtil.campareJsonObject(oldDataJson, newDataJson);
AuditLog auditLog = new AuditLog();
auditLog.setDataChange(differenceJson);
auditLog.setTransferData(JSON.toJSONString(change.getTransferData()));
auditLog.setTableName(change.getTableName());
auditLog.setRequestId(threadAuditService.getAuditLogDTO().getRequestId());
auditLog.setId(IdWorker.getId());
auditLog.setBeforeValue(oldDataJson);
auditLog.setNewValue(newDataJson);
auditLog.setExecuteMethod(threadAuditService.getAuditLogDTO().getExecuteMethod());
auditLogs.add(auditLog);
} catch (Exception e) {
logger.error("解析变更封装审计日志对象时出错", e);
}
}
});
logger.info("要保存的操作记录数据:{}", JSON.toJSONString(auditLogs));
// TODO: 可以保存审计日志到数据库, 或者将该审计模块封装成starter,引入使用,并通过feign接口导步保存处理,等,具体根据使用场景进行处理。
threadAuditService.clear();
}
}```
### 3.Mybatis拦截器用于拦截更新操作
这里我们需要创建一个全局的监听器,用于监听任何增删改的数据库操作。在Mybatis Plus中,可以通过实现Interceptor接口来实现,具体代码如下:
```java
/**
* 业务操作 mybatis 拦截器,拦截所有 update操作。
* 被 @com.atshuo.audit.aop.AuditLogAspect 中切面切到的方法,有更新操作,会被处理,生成审计日志,并记录数据库
*/
@Slf4j
@Component
@Intercepts({@Signature(type = StatementHandler.class, method = "update", args = {Statement.class})})
public class BussinessOperationInterceptor extends AbstractSqlParserHandler implements Interceptor {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private ThreadAuditService threadAuditService;
@Override
public Object intercept(Invocation invocation) throws Exception {
// 判断是否需要记录审计日志,如果AOP没有切到的业务,当前线程中就不存在审计日志对象
if (threadAuditService.getAuditLogDTO() == null) {
return invocation.proceed();
}
Statement statement;
Object firstArg = invocation.getArgs()[0];
if (Proxy.isProxyClass(firstArg.getClass())) {
statement = (Statement) SystemMetaObject.forObject(firstArg).getValue("h.statement");
} else {
statement = (Statement) firstArg;
}
MetaObject stmtMetaObj = SystemMetaObject.forObject(statement);
if (stmtMetaObj.hasGetter("delegate")) {
statement = (Statement) stmtMetaObj.getValue("delegate");
} else if (stmtMetaObj.hasGetter("stmt.statement")) {
statement = (Statement) stmtMetaObj.getValue("stmt.statement");
}
String originalSql = statement.toString();
originalSql = originalSql.replaceAll("[\\s]+", StringPool.SPACE);
int index = indexOfSqlStart(originalSql);
if (index > 0) {
originalSql = originalSql.substring(index);
}
StatementHandler statementHandler = PluginUtils.realTarget(invocation.getTarget());
MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
this.sqlParser(metaObject);
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
if (mappedStatement.getSqlCommandType() != null) {
try {
// 获取执行Sql
String sql = originalSql.replace("where", "WHERE");
// 使用mybatis-plus 工具解析sql获取表名
Collection<String> tables = new TableNameParser(sql).tables();
if (CollectionUtils.isEmpty(tables)) {
return invocation.proceed();
}
String tableName = tables.iterator().next();
//更新数据
if (SqlCommandType.UPDATE.equals(mappedStatement.getSqlCommandType())) {
DomainChangeAction change = new DomainChangeAction();
change.setTableName(tableName);
change.setJdbcTemplate(jdbcTemplate);
// 设置sql用于执行完后查询新数据
String selectSql = sql.substring(sql.lastIndexOf("WHERE") + 5);
// 同表对同条数据操作多次只进行一次对比
if (threadAuditService.getAuditLogDTO().getDomainChanges().stream().anyMatch(c -> tableName.equals(c.getTableName())
&& selectSql.equals(c.getWhereSql()))) {
return invocation.proceed();
}
change.setWhereSql(selectSql);
// 获取请求时object
Object parameterObject = statementHandler.getParameterHandler().getParameterObject();
change.setTransferData(Arrays.asList(parameterObject));
String querySql = "select * from " + tableName + " where " + selectSql;
change.setQuerySql(querySql);
List<Map<String, Object>> maps = jdbcTemplate.queryForList(querySql);
change.setOldObject(maps);
change.setEntityClass(parameterObject.getClass());
threadAuditService.getAuditLogDTO().getDomainChanges().add(change);
}
} catch (Exception e) {
log.error("获取变更前数据时出错。", e);
}
}
return invocation.proceed();
}
/**
* 获取sql语句开头部分
*
* @param sql ignore
* @return ignore
*/
private int indexOfSqlStart(String sql) {
String upperCaseSql = sql.toUpperCase();
Set<Integer> set = new HashSet<>();
set.add(upperCaseSql.indexOf("SELECT "));
set.add(upperCaseSql.indexOf("UPDATE "));
set.add(upperCaseSql.indexOf("INSERT "));
set.add(upperCaseSql.indexOf("DELETE "));
set.remove(-1);
if (CollectionUtils.isEmpty(set)) {
return -1;
}
List<Integer> list = new ArrayList<>(set);
list.sort(Comparator.naturalOrder());
return list.get(0);
}
}
4. 保存审计日志
通过全局操作监听和AOP技术,取得了审计日志所需要的相关内容,然后在AOP的执行完成后增加记录审计日志的相关内容,可以直接调用相关方法,将审计日志保存到数据库,也可以将审计日志放入MQ,然后通过消费消息异步将审计日志入库保存,建议使用MQ异步的方式,这样做尽可能的降低审计日志的记录对业务系统性能的影响。
需要详细代码,请关注VX公.众.号:“字节跑动”, 发送"审计日志"获取源码工程。
总结
使用Mybatis Plus实现全局的审计日志并不难,本章以更新操作为例,详细说明的实现步骤,有了审计日志,我们就能非常方便地追踪每一条数据的变化过程。希望这篇文章能对您有所帮助。