前言
需求是这样的:每个接口都有不同的数据库操作。想要将这些请求和数据库操作放到日志当中,方便管理员查看有哪些操作被执行了。这里排除查询操作,只在日志中记录 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 语句
详情请见:springboot对mapper切面,获取mybatis执行的sql_mapper层可以作切面么-CSDN博客https://blog.csdn.net/sdzhangshulong/article/details/104393244代码如下:
通过调用这个类中的方法可以获取到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);
}
}
4. 正式编写 AOP 切面类
@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. 完整代码
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语句是我们的重中之重,大家可以借鉴上述代码进行业务的编写。