使用SpringAOP解决日志记录问题+获取MyBatis执行的SQL语句(企业中常用的日志审计功能)

前言

需求是这样的:每个接口都有不同的数据库操作。想要将这些请求和数据库操作放到日志当中,方便管理员查看有哪些操作被执行了。这里排除查询操作,只在日志中记录 update、insert、delete 这三个操作。期望的日志表中应该有每次执行的 sql 语句,所以就要获取到SQL语句。

一. 思路

首先我们不能变更原有的代码,并且我们的需求是在进行数据库操作的时候才进行记录,那么我们就想到可以使用Spring AOP,定义一个切面:在每次执行到 Dao 层(也就是数据库操作)的时候,我们在这个切面类中获取到当前的 SQL 语句和其他的一些参数,然后在切面类中将当前操作插入到日志表中。

二.具体代码

1.建表(UnifiedLog)

scss 复制代码
CREATE TABLE UnifiedLog (
    Id BIGINT PRIMARY KEY IDENTITY(1,1),
    LogType VARCHAR(10) NOT NULL,
    SqlStatement NVARCHAR(MAX),
    Parameters NVARCHAR(MAX),
    UpdateCount INT,
    RequestUri NVARCHAR(500),
    RequestMethod VARCHAR(20),
    RequestBody NVARCHAR(MAX),
    ResponseBody NVARCHAR(MAX),
    SpendTime BIGINT,
    CreatedAt DATETIME2 NOT NULL
);

对应的实体类 MyBatisLog.java

typescript 复制代码
import java.sql.Timestamp;

@Data
public class MyBatisLog {
    private Long id;
    private String logType;
    private String sqlStatement;
    private String parameters;
    private String updateCount;
    private String requestUri;
    private String requestMethod;
    private String requestBody;
    private String responseBody;
    private Long spendTime;
    private Timestamp createdAt;
    
}

这样,我们就有了表和对应的Java实体类

2.编写插入Mapper

这里的功能是给日志表中插入数据的

ruby 复制代码
@Mapper
public interface MyBatisLogMapper {
    @Insert("INSERT INTO UnifiedLog (LogType, SqlStatement, Parameters, UpdateCount, RequestUri, RequestMethod, RequestBody, ResponseBody, SpendTime, CreatedAt) " +
            "VALUES (#{logType}, #{sqlStatement}, #{parameters}, #{updateCount}, #{requestUri}, #{requestMethod}, #{requestBody}, #{responseBody}, #{spendTime}, CURRENT_TIMESTAMP)")
    void insertMyBatisLog(MyBatisLog myBatisLog);
}

这个Mapper后面我们需要在 AOP 切面中调用,以达到插入数据库的效果。

3.引入SQLUtils工具包

这个工具包能够有效提取出将要执行的 SQL 语句 代码如下:

通过调用这个类中的方法可以获取到sql语句,其中还有通过正则表达式判断一个 SQL 是不是查询语句。

ini 复制代码
import com.sun.deploy.util.ArrayUtil;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ParameterMapping;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.type.TypeHandlerRegistry;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.text.DateFormat;
import java.util.*;

public class SqlUtils {

    /**
     * 获取aop中的SQL语句
     * @param pjp
     * @param sqlSessionFactory
     * @return
     * @throws IllegalAccessException
     */
    public static String getMybatisSql(ProceedingJoinPoint pjp, SqlSessionFactory sqlSessionFactory) throws IllegalAccessException {
        Map<String,Object> map = new HashMap<>();
        //1.获取namespace+methdoName
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        Method method = signature.getMethod();
        String namespace = method.getDeclaringClass().getName();
        String methodName = method.getName();
        //2.根据namespace+methdoName获取相对应的MappedStatement
        Configuration configuration = sqlSessionFactory.getConfiguration();
        MappedStatement mappedStatement = configuration.getMappedStatement(namespace+"."+methodName);
//        //3.获取方法参数列表名
//        Parameter[] parameters = method.getParameters();
        //4.形参和实参的映射
        Object[] objects = pjp.getArgs(); //获取实参
        Annotation[][] parameterAnnotations = method.getParameterAnnotations();
        for (int i = 0;i<parameterAnnotations.length;i++){
            Object object = objects[i];
            if (parameterAnnotations[i].length == 0){ //说明该参数没有注解,此时该参数可能是实体类,也可能是Map,也可能只是单参数
                if (object.getClass().getClassLoader() == null && object instanceof Map){
                    map.putAll((Map<? extends String, ?>) object);
                    System.out.println("该对象为Map");
                }else{//形参为自定义实体类
                    map.putAll(objectToMap(object));
                    System.out.println("该对象为用户��定义的对象");
                }
            }else{//说明该参数有注解,且必须为@Param
                for (Annotation annotation : parameterAnnotations[i]){
                    if (annotation instanceof Param){
                        map.put(((Param) annotation).value(),object);
                    }
                }
            }
        }
        //5.获取boundSql
        BoundSql boundSql = mappedStatement.getBoundSql(map);
        return showSql(configuration,boundSql);
    }

    /**
     * 解析BoundSql,生成不含占位符的SQL语句
     * @param configuration
     * @param boundSql
     * @return
     */
    private  static String showSql(Configuration configuration, BoundSql boundSql) {
        Object parameterObject = boundSql.getParameterObject();
        List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
        String sql = boundSql.getSql().replaceAll("[\s]+", " ");
        if (parameterMappings.size() > 0 && parameterObject != null) {
            TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();
            if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
                sql = sql.replaceFirst("\?", getParameterValue(parameterObject));
            } else {
                MetaObject metaObject = configuration.newMetaObject(parameterObject);
                for (ParameterMapping parameterMapping : parameterMappings) {
                    String propertyName = parameterMapping.getProperty();
                    String[] s =  metaObject.getObjectWrapper().getGetterNames();
                    s.toString();
                    if (metaObject.hasGetter(propertyName)) {
                        Object obj = metaObject.getValue(propertyName);
                        sql = sql.replaceFirst("\?", getParameterValue(obj));
                    } else if (boundSql.hasAdditionalParameter(propertyName)) {
                        Object obj = boundSql.getAdditionalParameter(propertyName);
                        sql = sql.replaceFirst("\?", getParameterValue(obj));
                    }
                }
            }
        }
        return sql;
    }

    /**
     * 若为字符串或者日期类型,则在参数两边添加''
     * @param obj
     * @return
     */
    private static String getParameterValue(Object obj) {
        String value = null;
        if (obj instanceof String) {
            value = "'" + obj.toString() + "'";
        } else if (obj instanceof Date) {
            DateFormat formatter = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.CHINA);
            value = "'" + formatter.format(new Date()) + "'";
        } else {
            if (obj != null) {
                value = obj.toString();
            } else {
                value = "";
            }
        }
        return value;
    }

    /**
     * 获取利用反射获取类里面的值和名称
     *
     * @param obj
     * @return
     * @throws IllegalAccessException
     */
    private static Map<String, Object> objectToMap(Object obj) throws IllegalAccessException {
        Map<String, Object> map = new HashMap<>();
        Class<?> clazz = obj.getClass();
        System.out.println(clazz);
        for (Field field : clazz.getDeclaredFields()) {
            field.setAccessible(true);
            String fieldName = field.getName();
            Object value = field.get(obj);
            map.put(fieldName, value);
        }
        return map;
    }

    /**
     * 正则表达式 判断一个sql语句是不是select语句
     *
     */
    public static boolean isSelectStatement(String sql) {
        String selectRegex = "^\s*(select|SELECT)\s+.*";
        return sql.matches(selectRegex);
    }
}
  1. 正式编写 AOP 切面类
less 复制代码
@Aspect
@Component
@Slf4j
public class WebLogAspect  {

    @Autowired
    private MyBatisLogMapper myBatisLogMapper;

    @Autowired
    private SqlSessionFactory sqlSessionFactory;

    @Pointcut("execution(* com.joysonsafety.joysonoperationplatform.dao.mapper..*(..))")
    public void saveLog() {
    }

    @Around("com.joysonsafety.joysonoperationplatform.aspect.WebLogAspect.saveLog()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {

    }

}

我们首先要做好的是正确的创建出切面类,那么我们就要将切点自定义到mapper层,我的Mapper层如下:

@Pointcut("execution(* com.joysonsafety.joysonoperationplatform.dao.mapper..*(..))")

public void saveLog() {

}

这个代码就做到了将切面定义到了整个mapper中,这样每次执行到这些数据库操作,我们就能进入到切面类中执行我们想要的操作了。

​编辑

4. 完整代码

ini 复制代码
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil;
import cn.hutool.json.JSON;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.joysonsafety.joysonoperationplatform.dao.mapper.MyBatisLogMapper;
import com.joysonsafety.joysonoperationplatform.entity.MybatisLog.MyBatisLog;

import com.joysonsafety.joysonoperationplatform.util.SqlUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.session.SqlSessionFactory;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Objects;
import java.util.Properties;

@Aspect
@Component
@Slf4j
public class WebLogAspect  {

    @Autowired
    private MyBatisLogMapper myBatisLogMapper;
    @Autowired
    private SqlSessionFactory sqlSessionFactory;

    @Pointcut("execution(* com.joysonsafety.joysonoperationplatform.dao.mapper..*(..))")
    public void saveLog() {
    }

    private static final ThreadLocal < Boolean > isInsideLogMethod = new ThreadLocal < > ();


    @Around("com.joysonsafety.joysonoperationplatform.aspect.WebLogAspect.saveLog()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        ServletRequestAttributes sra =  (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (!Objects.isNull(sra)){
            HttpServletRequest request = sra.getRequest();
            System.out.println("url: " + request.getRequestURI());
            System.out.println("method: "+request.getMethod());      //post or get? or ?
            System.out.println("class.method: " + joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint.getSignature().getName());
            System.out.println("args: "+joinPoint.getArgs());
        }
        //Object proceed = joinPoint.proceed();
        //3.获取SQL
        String sql = SqlUtils.getMybatisSql(joinPoint, sqlSessionFactory);
        

        long startTime = System.currentTimeMillis();
        //获取当前请求对象
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        Object result = joinPoint.proceed();

        try {
            //生成web日志
            generatorWebLog(joinPoint, startTime, request, result,sql);
        }catch (Exception e){
            log.error("web请求日志异常",e);
        }
        return result;

    }



    private void generatorWebLog(ProceedingJoinPoint joinPoint, long startTime, HttpServletRequest request, Object result,String sql) throws IllegalAccessException {
       if(!SqlUtils.isSelectStatement(sql)) {
           //检查当前线程是否已经在进行日志插入操作
           if(Boolean.TRUE.equals(isInsideLogMethod.get())){
               return;
           }
           try {
               //进入这个代码块中,先将当前线程设置为TRUE 这样第二个方法来的时候不会执行下面的代码
               isInsideLogMethod.set(true);
               MyBatisLog myBatisLog = new MyBatisLog();
               long endTime = System.currentTimeMillis();
               String urlStr = request.getRequestURL().toString();
               myBatisLog.setLogType("Web");
               myBatisLog.setSqlStatement(sql);
               myBatisLog.setUpdateCount(String.valueOf((Integer) result));
               myBatisLog.setRequestUri(request.getRequestURI());
               myBatisLog.setRequestMethod(request.getMethod());
               myBatisLog.setRequestBody(JSONUtil.toJsonPrettyStr(getParameter(joinPoint))); // Assumes this method extracts parameters
               String ret = "本次成功更新的条数:" + result.toString();
               myBatisLog.setResponseBody(ret);
               myBatisLog.setSpendTime(endTime - startTime);
//           myBatisLogMapper.insertMyBatisLog(new MyBatisLog("web",sql,null,ret,request.getRequestURI(),request.getMethod(),JSONUtil.toJsonPrettyStr(getParameter(joinPoint)),ret
//           ,endTime - startTime);
               myBatisLogMapper.insertMyBatisLog(myBatisLog);
               log.info("web请求日志:{}", JSONUtil.parse(myBatisLog));
           } finally {
               //清除标记
               isInsideLogMethod.remove();
           }

       }


    }

    /**
     * 根据方法和传入的参数获取请求参数
     */
    private Object getParameter(ProceedingJoinPoint joinPoint) {
        //获取方法签名
        MethodSignature signature =(MethodSignature) joinPoint.getSignature();
        //获取参数名称
        String[] parameterNames = signature.getParameterNames();
        //获取所有参数
        Object[] args = joinPoint.getArgs();
        //请求参数封装
        JSONObject jsonObject = new JSONObject();
        if(parameterNames !=null && parameterNames.length > 0){
            for(int i=0; i<parameterNames.length;i++){
                jsonObject.put(parameterNames[i],args[i]);
            }
        }
        return jsonObject;
    }

    /**
     * 获取方法描述
     */
    private String getDescription(ProceedingJoinPoint joinPoint) {
        //获取方法签名
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        //获取方法
        Method method = signature.getMethod();
        //获取注解对象
        //ApiOperation annotation = method.getAnnotation(ApiOperation.class);
//        if (Objects.isNull(annotation)) {
//            return "";
//        }
//        return annotation.value();
        return "";
    }


}

上面的代码书写完毕,程序已经可以正常运行了!下面记录的是笔者遇到的问题和总结

需要特别注意的是:我们插入日志表的操作也会被进入到 AOP 切面中被执行,所以就造成了无限套娃的场景。 比如:generatorWebLog 方法中,第一次进来的可能是更新语句,然后将更新语句保存到日志表(Insert操作)中,也会触发AOP此操作。这样执行了一次插入操作,然后一次又称一次的执行,这样就无限循环了!!!

所以笔者在这里爬了很久的坑,最终帅气的同事给到了一个解决方案,那就是使用 ThreadLocal !

new ThreadLocal<>() : 这行代码创建了一个新的 ThreadLocal实例。ThreadLocal 是 Java 中的一个特殊的类,它可以为每个线程提供一个独立的变量副本。

它是全局共享的,每个线程都可以独立地访问和修改它的值,而不会相互干扰。这种设计通常用于需要保持线程独立状态的场景,比如日志记录、事务管理等。

那么我们是如何使用 ThreadLocal 保证每次只执行一次语句呢?

5.遇到的坑

解决套娃问题:确保 AOP 方法在当前线程中只执行一次。

如果第一次执行到了 update 语句,然后在将 update 语句保存到日志表之前,我们将当前线程的 ThreadLocal 设置标志为 True,然后我们执行到 插入日志表操作的 sql 时,会先进行判断,如果当前 ThreadLocal 已经是 True 了,那么说明当前有线程在使用 AOP ,那么此次就直接返回,然后他就去跑自己的正规业务了。

举例:update 语句被触发了 AOP,在保存日志表的时候,又一次触发了AOP的操作,此时这个AOP不生效,然后正确的执行了保存日志表操作。最后 update 语句执行完 AOP 后,正确的执行了自己的 update 操作。

三.总结

这个日志功能中,拿到SQL语句是我们的重中之重,大家可以借鉴上述代码进行业务的编写。

相关推荐
獨枭1 小时前
使用 163 邮箱实现 Spring Boot 邮箱验证码登录
java·spring boot·后端
维基框架1 小时前
Spring Boot 封装 MinIO 工具
java·spring boot·后端
秋野酱1 小时前
基于javaweb的SpringBoot酒店管理系统设计与实现(源码+文档+部署讲解)
java·spring boot·后端
☞无能盖世♛逞何英雄☜1 小时前
Flask框架搭建
后端·python·flask
进击的雷神2 小时前
Perl语言深度考查:从文本处理到正则表达式的全面掌握
开发语言·后端·scala
进击的雷神2 小时前
Perl测试起步:从零到精通的完整指南
开发语言·后端·scala
豌豆花下猫3 小时前
Python 潮流周刊#102:微软裁员 Faster CPython 团队(摘要)
后端·python·ai
秋野酱3 小时前
基于javaweb的SpringBoot驾校预约学习系统设计与实现(源码+文档+部署讲解)
spring boot·后端·学习
北辰浮光3 小时前
[springboot]SSM日期数据转换易见问题
java·spring boot·后端
木梓辛铭3 小时前
Spring Cache的详细使用
java·后端·spring