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

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐