使用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语句是我们的重中之重,大家可以借鉴上述代码进行业务的编写。

相关推荐
哎呦没27 分钟前
Spring Boot OA:企业办公自动化的高效路径
java·spring boot·后端
真心喜欢你吖28 分钟前
Spring Boot与MyBatis-Plus的高效集成
java·spring boot·后端·spring·mybatis
2401_8576363929 分钟前
实验室管理技术革新:Spring Boot系统
数据库·spring boot·后端
2401_8576009531 分钟前
实验室管理流程优化:Spring Boot技术实践
spring boot·后端·mfc
2402_8575893632 分钟前
企业办公自动化:Spring Boot OA管理系统开发与实践
java·spring boot·后端
恬淡虚无真气从之1 小时前
go interface(接口)使用
开发语言·后端·golang
程序猿毕设源码分享网2 小时前
基于springboot停车场管理系统源码和论文
数据库·spring boot·后端
程序员学姐2 小时前
基于SpringBoot+Vue的高校社团管理系统
java·开发语言·vue.js·spring boot·后端·mysql·spring
.生产的驴2 小时前
Docker Seata分布式事务保护搭建 DB数据源版搭建 结合Nacos服务注册
数据库·分布式·后端·spring cloud·docker·容器·负载均衡
2401_857439693 小时前
企业OA管理系统:Spring Boot技术应用与优化
java·spring boot·后端