Mybatis Plus轻松实现数据库变更全局审计日志

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> maps = change.getJdbcTemplate().queryForList(change.getQuerySql());
                change.setNewObject(maps);
            }
            /**
             * 

        * 业务方法执行前记录 *

        * * @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 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 list) { List 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 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> 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 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 list = new ArrayList<>(set); list.sort(Comparator.naturalOrder()); return list.get(0); } }

        4. 保存审计日志

        通过全局操作监听和AOP技术,取得了审计日志所需要的相关内容,然后在AOP的执行完成后增加记录审计日志的相关内容,可以直接调用相关方法,将审计日志保存到数据库,也可以将审计日志放入MQ,然后通过消费消息异步将审计日志入库保存,建议使用MQ异步的方式,这样做尽可能的降低审计日志的记录对业务系统性能的影响。

        需要详细代码,请关注VX公.众.号:“字节跑动”, 发送"审计日志"获取源码工程。

        总结

        使用Mybatis Plus实现全局的审计日志并不难,本章以更新操作为例,详细说明的实现步骤,有了审计日志,我们就能非常方便地追踪每一条数据的变化过程。希望这篇文章能对您有所帮助。