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

506 阅读7分钟

前言

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

 一. 思路

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

二.具体代码

1.建表(UnifiedLog)

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

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 

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

@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 是不是查询语句。 ​



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 切面类

@Aspect
@Component
@Slf4j
public class WebLogAspect  {

    @Autowired
    private MyBatisLogMapper myBatisLogMapper;

    @Autowired
    private SqlSessionFactory sqlSessionFactory;

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

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

    }

}

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

 @Pointcut("execution(* com.xxx.xxxxx.dao.mapper..*(..))")
public void saveLog() {

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

​编辑

4. 完整代码


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