# 通过 AOP 记录用户行为日志
操作日志几乎存在于每个系统中,而这些系统都有记录操作日志的一套 API。操作日志和系统日志不一样,操作日志必须要做到简单易懂。所以如何让操作日志不和业务逻辑耦合,如何让操作日志的内容易于理解,让操作日志的接入更加简单?本文将通过 Spring+AspectJ 的 AOP 实现方式解决以上问题。
# 操作日志的使用场景
系统日志和操作日志的区别
系统日志:系统日志主要是为开发排查问题提供依据,一般打印在日志文件中;系统日志的可读性要求没那么高,日志中会包含代码的信息,比如在某个类的某一行打印了一个日志。
操作日志:主要是对某个对象进行新增操作或者修改操作后记录下这个新增或者修改,操作日志要求可读性比较强,因为它主要是给用户看的,比如订单的物流信息,用户需要知道在什么时间发生了什么事情。再比如,客服对工单的处理记录信息。
操作日志的记录格式大概分为下面几种: * 单纯的文字记录,比如:2021-09-16 10:00 订单创建。 * 简单的动态的文本记录,比如:2021-09-16 10:00 订单创建,订单号:NO.11089999,其中涉及变量订单号 “NO.11089999”。 * 修改类型的文本,包含修改前和修改后的值,比如:2021-09-16 10:00 用户小明修改了订单的配送地址:从 “金灿灿小区” 修改到 “银盏盏小区” ,其中涉及变量配送的原地址 “金灿灿小区” 和新地址 “银盏盏小区”。 * 修改表单,一次会修改多个字段。
# 介绍
本文参考 如何优雅地记录操作日志? 文章,基于文章讲解思路,本章使用 Spring+AspectJ 实现日志记录功能。所以建议先阅读这篇文章前 3 大章节,有时间可用全文细品。
# 基本信息
AOP 实现之静态代理
编译阶段就可生成 AOP 代理类,因此也称为编译时增强,代表作:AspectJ。 | |
有三种基于静态代理的实现如下。 | |
1.编译器增强:java文件编译为class时 | |
2.自定义classloader:类加载到JVM中时 | |
3.Instrumentation字节码:JVM在执行时字节码时 |
AOP 实现之动态代理
动态代理则在运行时在内存中“临时”生成 AOP 动态代理类,因此也被称为运行时增强。代表作:Spring AOP | |
基于动态代理实现两种如下 | |
JDK 动态代理:原理是反射,性能低,只能代理接口实现类 | |
CGLIB 动态代理:底层使用的是asm框架来操作字节码来继承类实现,动态是生成某个类的子类,对类和接口都有效 |
Spring + AspectJ
使用了AspectJ 的 Annotation,通过 Aspect 来定义切面,使用 Pointcut 来定义切入点, Advice来定义增强处理,但是并没有使用它的编译器和织入器。其实现原理是JDK 或CGLIB,在运行时生成代理类。所以还是动态代理 |
SpEL 官方文档
Spring表达式语言,像@Value("${server.port:8080}")取值设置默认值,内部${}就是 SpEL 表达式。通过它我们可以获取参数,调用方法,创建对象,设置逻辑规则等。 |
# 代码实现
# 注解类
根据自己需要增加或删减属性,因为后面要持久化,所以根据自己情况来,我这个是照抄文章的那个对象的,因为我需求满足了。
import java.lang.annotation.*; | |
/** | |
* @author 昔日织 | |
*/ | |
@Target({ElementType.METHOD}) | |
@Retention(RetentionPolicy.RUNTIME) | |
@Inherited | |
@Documented | |
public @interface LogRecordAnnotation { | |
// 操作日志的文本模板 | |
String success(); | |
// 操作日志失败的文本版本 | |
String fail() default ""; | |
// 操作日志的执行人 | |
String operator() default ""; | |
// 操作日志绑定的业务对象标识 | |
String bizNo(); | |
// 操作日志的种类 | |
String category() default ""; | |
// 扩展参数,记录操作日志的修改详情 | |
String detail() default ""; | |
// 记录日志的条件 | |
String condition() default ""; | |
} |
# 切面类
用于处理解析被注解的方法。这个类很重要,所以我将尽量做到每行写上注释,便于理解。
import org.apache.commons.lang3.StringUtils; | |
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.slf4j.Logger; | |
import org.slf4j.LoggerFactory; | |
import org.springframework.beans.factory.BeanFactory; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.context.expression.AnnotatedElementKey; | |
import org.springframework.core.DefaultParameterNameDiscoverer; | |
import org.springframework.core.ParameterNameDiscoverer; | |
import org.springframework.expression.common.TemplateParserContext; | |
import org.springframework.stereotype.Component; | |
import java.lang.reflect.Method; | |
@Component | |
@Aspect | |
public class LogAspect{ | |
private static final Logger log = LoggerFactory.getLogger(LogAspect.class); | |
private static final String argsSplit = " - "; | |
// 缓存 Spring 表达式模板与解析。 | |
private LogRecordExpressionEvaluator logRecordExpressionEvaluator = new LogRecordExpressionEvaluator(); | |
// 用于获取方法的参数名 | |
private ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); | |
// 用户操作记录持久化实现 | |
private LogDataStorageConfig logDataStorageConfig = new DefaultLogDataStorageConfig(); | |
// 定义方法执行前 SqEL 解析模板 | |
private TemplateParserContext templateParserContextBefore = new TemplateParserContext("#{","}"); | |
// 定义方法执行后 SqEL 解析模板 | |
private TemplateParserContext templateParserContextAfter = new TemplateParserContext("${","}"); | |
private BeanFactory beanFactory; | |
// 操作的用户接口 | |
private IOperatorGetService iOperatorGetService; | |
@Autowired | |
void setBeanFactory(BeanFactory beanFactory){ | |
this.beanFactory = beanFactory; | |
} | |
@Autowired(required = false) | |
void setLogRecordOperationSource(ParameterNameDiscoverer parameterNameDiscoverer){ | |
this.parameterNameDiscoverer = parameterNameDiscoverer; | |
} | |
@Autowired(required = false) | |
void setLogDataStorageConfig(LogDataStorageConfig logDataStorageConfig){ | |
this.logDataStorageConfig = logDataStorageConfig; | |
} | |
@Autowired | |
void setIOperatorGetService(IOperatorGetService iOperatorGetService){ | |
this.iOperatorGetService = iOperatorGetService; | |
} | |
// 创建切点 | |
@Pointcut("@annotation(xxx.LogRecordAnnotation)") | |
public void anyMethod(){ | |
} | |
// 在切点前后执行方法,通过 @annotation (logRecordAnnotation) 绑定注解到第二个参数 | |
//ProceedingJoinPoint 必须要放在第一个参数 | |
@Around("anyMethod() && @annotation(logRecordAnnotation)") | |
public Object BeforeMethodStart(ProceedingJoinPoint proceedingJoinPoint, LogRecordAnnotation logRecordAnnotation) throws Throwable { | |
String data = this.getBeforeExecuteFunctionTemplate(logRecordAnnotation); | |
// 方法执行结果对象 | |
MethodExecuteResult methodExecuteResult = new MethodExecuteResult(true, null, ""); | |
// 获取方法入参 | |
Object[] arguments = proceedingJoinPoint.getArgs(); | |
// 获取方法自身对象 | |
Object aThis = proceedingJoinPoint.getThis(); | |
MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature(); | |
// 获取方法 | |
Method method = signature.getMethod(); | |
// 生成 SqEL 参数映射上下文 | |
LogRecordEvaluationContext logRecordEvaluationContext = new LogRecordEvaluationContext(aThis,method,arguments,parameterNameDiscoverer,beanFactory); | |
// 生成方法的唯一 Key,后面 SqEL 解析的时候可以缓存模板,减少资源消耗 | |
// 模板:指在注解属性内写的 Spring 表达式。在解析前需要创建一个对象。减少对象的创建 | |
AnnotatedElementKey annotatedElementKey = new AnnotatedElementKey(method, aThis.getClass()); | |
// 使用 try 包裹防止影响方法正常执行 | |
try { | |
// 在方法执行前使用指定的 SpEL 模板解析器解析 | |
data = logRecordExpressionEvaluator.parseExpression(data, annotatedElementKey, logRecordEvaluationContext, templateParserContextBefore); | |
}catch (Exception e){ | |
log.warn("log record parse before function exception"+e); | |
} | |
Object ret = null; | |
try { | |
// 执行这个方法 | |
ret = proceedingJoinPoint.proceed(); | |
} catch (Throwable e) { | |
methodExecuteResult = new MethodExecuteResult(false,e,e.getMessage()); | |
} | |
// 增加方法执行结果到 SqEL 参数映射上下文 | |
logRecordEvaluationContext.methodsInvokerAfterInit(ret,methodExecuteResult.getErrorMsg()); | |
try { | |
// TODO 不能使用缓存,因为这个 Spring 表达式字符串经过第一次解析是一个动态的值,每次的对象都将不一样。以后再优化 | |
// 在方法执行前使用指定的 SpEL 模板解析器解析 | |
data = logRecordExpressionEvaluator.getExpressionParser(data, templateParserContextAfter,logRecordEvaluationContext); | |
// 记录数据 | |
this.dataProcessingRecord(data,methodExecuteResult); | |
}catch (Exception e){ | |
log.warn("log record parse before function exception"+e); | |
} | |
// 如果是错误则继续抛出 | |
if(methodExecuteResult.getThrowable() != null){ | |
throw methodExecuteResult.getThrowable(); | |
} | |
// 返回方法调用结果 | |
return ret; | |
} | |
// 把所有 Spring 表达式拼接一起解析 | |
public String getBeforeExecuteFunctionTemplate(LogRecordAnnotation logRecordAnnotation){ | |
return logRecordAnnotation.success()+argsSplit+ | |
logRecordAnnotation.fail()+argsSplit+ | |
logRecordAnnotation.operator()+argsSplit+ | |
logRecordAnnotation.bizNo()+argsSplit+ | |
logRecordAnnotation.category()+argsSplit+ | |
logRecordAnnotation.detail()+argsSplit+ | |
logRecordAnnotation.condition(); | |
} | |
public void dataProcessingRecord(String data,MethodExecuteResult methodExecuteResult){ | |
String[] split = data.split(argsSplit,-1); | |
LogRecordData logRecordData = new LogRecordData(split[0], split[1], split[2], split[3], split[4], split[5], split[6]); | |
// 若操作人为空,获取用户信息接口实现方法返回的操作人 | |
if(StringUtils.isBlank(logRecordData.getOperator())){ | |
if(iOperatorGetService == null || StringUtils.isBlank(iOperatorGetService.getUser())){ | |
throw new IllegalArgumentException("operator info not Existence"); | |
} | |
logRecordData.setOperator(iOperatorGetService.getUser()); | |
} | |
// 调用持久化方法,将解析后的字符串写入到指定日志文件或者存储数据库 | |
logDataStorageConfig.storageConfig(logRecordData,methodExecuteResult); | |
} | |
} |
# 方法执行结果类
记录方法执行情况,以便于后续操作
/** | |
* 方法执行结果 | |
* @author 昔日织 | |
* @since 2021-12-07 | |
*/ | |
public class MethodExecuteResult { | |
private Boolean isSuccess; | |
private Throwable throwable; | |
private String errorMsg; | |
public MethodExecuteResult(Boolean isSuccess, Throwable throwable, String errorMsg){ | |
this.isSuccess = isSuccess; | |
this.throwable = throwable; | |
this.errorMsg = errorMsg; | |
} | |
public Boolean getSuccess() { | |
return isSuccess; | |
} | |
public void setSuccess(Boolean success) { | |
isSuccess = success; | |
} | |
public Throwable getThrowable() { | |
return throwable; | |
} | |
public void setThrowable(Throwable throwable) { | |
this.throwable = throwable; | |
} | |
public String getErrorMsg() { | |
return errorMsg; | |
} | |
public void setErrorMsg(String errorMsg) { | |
this.errorMsg = errorMsg; | |
} | |
} |
# 用户信息类
记录用户操作必然有用户标识,而且还是可视化便理解的标识,如:不能记录用户 ID ,而是用户名字。所以若可以编写一个统一的实现可以减少使用注解时频繁且重复 operator 属性的值。
/** | |
* 实现该接口设置默认操作人员 | |
* | |
* @author 昔日织 | |
* @since 2021-12-04 | |
*/ | |
public interface IOperatorGetService { | |
/** | |
* 可以在里面外部的获取当前登陆的用户 | |
* | |
* @return 转换成 Operator 返回 | |
*/ | |
String getUser(); | |
} |
如有些系统会将用户信息放在请求头
import org.springframework.web.context.request.RequestContextHolder; | |
import org.springframework.web.context.request.ServletRequestAttributes; | |
import javax.servlet.http.HttpServletRequest; | |
/** | |
* 默认操作人员获取方式 | |
* | |
* @author 昔日织 | |
* @since 2021-12-04 | |
*/ | |
public class DefaultOperatorGetServiceImpl implements IOperatorGetService { | |
@Override | |
public String getUser() { | |
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); | |
HttpServletRequest request = servletRequestAttributes.getRequest(); | |
return request.getAttribute("userName")+""; | |
} | |
} |
# 缓存 Spring 表达式模板与解析类
这个类没有什么重点,就是缓存 Spring 表达式字符串创建的模板对象,通过传递不同的 Spring 表达式参数映射上下文解析。使用缓存减少模板对象创建的消耗
import org.springframework.context.expression.AnnotatedElementKey; | |
import org.springframework.context.expression.CachedExpressionEvaluator; | |
import org.springframework.expression.EvaluationContext; | |
import org.springframework.expression.Expression; | |
import org.springframework.expression.ParserContext; | |
import org.springframework.lang.Nullable; | |
import org.springframework.util.Assert; | |
import org.springframework.util.ObjectUtils; | |
import java.util.Map; | |
import java.util.concurrent.ConcurrentHashMap; | |
/** | |
* 日志记录表达式求值程序 | |
* | |
* @author 昔日织 | |
* @since 2021-12-07 | |
*/ | |
public class LogRecordExpressionEvaluator extends CachedExpressionEvaluator { | |
private Map<ExpressionKey, Expression> expressionCache = new ConcurrentHashMap<>(64); | |
public String getExpressionParser(String expression, ParserContext parserContext,EvaluationContext evalContext){ | |
return getParser().parseExpression(expression,parserContext).getValue(evalContext,String.class); | |
} | |
public String parseExpression(String conditionExpression, AnnotatedElementKey methodKey, EvaluationContext evalContext,ParserContext parserContext) { | |
return getExpression(this.expressionCache, methodKey, conditionExpression,parserContext).getValue(evalContext, String.class); | |
} | |
protected Expression getExpression(Map<ExpressionKey, Expression> cache, | |
AnnotatedElementKey elementKey, String expression, ParserContext parserContext) { | |
ExpressionKey expressionKey = new ExpressionKey(elementKey, expression); | |
Expression expr = cache.get(expressionKey); | |
if (expr == null) { | |
expr = getParser().parseExpression(expression,parserContext); | |
cache.put(expressionKey, expr); | |
} | |
return expr; | |
} | |
/** | |
* An expression key. | |
*/ | |
protected static class ExpressionKey implements Comparable<ExpressionKey> { | |
private final AnnotatedElementKey element; | |
private final String expression; | |
protected ExpressionKey(AnnotatedElementKey element, String expression) { | |
Assert.notNull(element, "AnnotatedElementKey must not be null"); | |
Assert.notNull(expression, "Expression must not be null"); | |
this.element = element; | |
this.expression = expression; | |
} | |
@Override | |
public boolean equals(@Nullable Object other) { | |
if (this == other) { | |
return true; | |
} | |
if (!(other instanceof ExpressionKey)) { | |
return false; | |
} | |
ExpressionKey otherKey = (ExpressionKey) other; | |
return (this.element.equals(otherKey.element) && | |
ObjectUtils.nullSafeEquals(this.expression, otherKey.expression)); | |
} | |
@Override | |
public int hashCode() { | |
return this.element.hashCode() * 29 + this.expression.hashCode(); | |
} | |
@Override | |
public String toString() { | |
return this.element + " with expression \"" + this.expression + "\""; | |
} | |
@Override | |
public int compareTo(ExpressionKey other) { | |
int result = this.element.toString().compareTo(other.element.toString()); | |
if (result == 0) { | |
result = this.expression.compareTo(other.expression); | |
} | |
return result; | |
} | |
} | |
} |
# SqEL 参数映射上下文类
SqEL 字符串在解析是能通过字符串 (Key) 找到对应的源 (value)。是通过这个类进行绑定的,想要使用 Bean 对象,那它需要一个 BeanFactory 用于找到这个 Bean。参数等也是同理。
import org.springframework.core.ParameterNameDiscoverer; | |
import org.springframework.expression.spel.support.StandardEvaluationContext; | |
import java.lang.reflect.Method; | |
// StandardEvaluationContext 是 SqEL 默认的参数映射上下文 | |
public class MethodBasedEvaluationContext extends StandardEvaluationContext { | |
//rootObject 是一个 Map 数据结构 | |
public MethodBasedEvaluationContext(Object rootObject, Method method, Object[] arguments, | |
ParameterNameDiscoverer parameterNameDiscoverer){ | |
super(rootObject); | |
//parameterNameDiscoverer 就是前面传递进来用于解析方法的参数名的,不是方法类型 | |
String[] parameterNames = parameterNameDiscoverer.getParameterNames(method); | |
for (int i = 0; i < parameterNames.length; i++) { | |
setVariable(parameterNames[i],arguments[i]); | |
} | |
} | |
} |
import org.springframework.beans.factory.BeanFactory; | |
import org.springframework.context.expression.BeanFactoryResolver; | |
import org.springframework.core.ParameterNameDiscoverer; | |
import java.lang.reflect.Method; | |
import java.util.Map; | |
/** | |
* 日志记录 SpEL 解析器 | |
* | |
* @author 昔日织 | |
* @since 2021-12-04 | |
*/ | |
public class LogRecordEvaluationContext extends MethodBasedEvaluationContext { | |
public LogRecordEvaluationContext(Object rootObject, Method method, Object[] arguments, | |
ParameterNameDiscoverer parameterNameDiscoverer, | |
BeanFactory beanFactory) { | |
// 把方法的参数都放到 SpEL 解析的 RootObject 中 | |
super(rootObject, method, arguments, parameterNameDiscoverer); | |
setBeanResolver(new BeanFactoryResolver(beanFactory)); | |
// 把 LogRecordContext 中的变量都放到 RootObject 中 | |
Map<String, Object> variables = LogRecordContext.getVariables(); | |
if (variables != null && variables.size() > 0) { | |
for (Map.Entry<String, Object> entry : variables.entrySet()) { | |
setVariable(entry.getKey(), entry.getValue()); | |
} | |
} | |
} | |
public void methodsInvokerAfterInit(Object ret, String errorMsg){ | |
// 把方法的返回值和 ErrorMsg 都放到 RootObject 中 | |
setVariable("_ret", ret); | |
setVariable("_errorMsg", errorMsg); | |
} | |
} |
# 持久化日志类
与用户信息类一样,定义一个接口。用于存储结果。
import cn.tanzhou.origin.manager.utils.operate.LogRecordData; | |
/** | |
* 日志数据存储配置 | |
* | |
* @author 昔日织 | |
* @since 2021-12-07 | |
*/ | |
public interface LogDataStorageConfig { | |
/** | |
* 存储配置 | |
* | |
* @param logRecordData 日志记录数据 | |
* @param methodExecuteResult 方法执行结果 | |
* @return | |
*/ | |
void storageConfig(LogRecordData logRecordData,MethodExecuteResult methodExecuteResult); | |
} |
如存数据库
/** | |
* almp 日志数据存储配置 | |
* | |
* @author 昔日织 | |
* @since 2021-12-07 | |
*/ | |
@Component | |
public class AlmpLogDataStorageConfig implements LogDataStorageConfig { | |
private IOperationLogService iOperationLogService; | |
@Autowired | |
void setIOperationLogService(IOperationLogService iOperationLogService){ | |
this.iOperationLogService = iOperationLogService; | |
} | |
@Override | |
public void storageConfig(LogRecordData logRecordData, MethodExecuteResult methodExecuteResult) { | |
// 是否满足记录条件且方法调用正常 | |
if(StringUtils.isNotBlank(logRecordData.getCondition()) &&!Boolean.parseBoolean(logRecordData.getCondition())&& methodExecuteResult.getSuccess()){ | |
return; | |
} | |
// 要记录的日志 | |
String logInfo = ""; | |
if(methodExecuteResult.getSuccess()){ | |
logInfo = logRecordData.getSuccess(); | |
}else { | |
logInfo = StringUtils.isNotBlank(logRecordData.getFail())?logRecordData.getFail():methodExecuteResult.getErrorMsg(); | |
} | |
// 存储到数据库中 | |
// ... | |
iOperationLogService.insert(xx); | |
} | |
} |
# 如何使用
使用上遵循 SpEL 语法,可以参考官方文档
# 经常使用
因为对 SpEL 语法进行指定模板解析,所以要根据模板包裹原有字符串,#{} 包裹会在方法执行前解析,${} 包裹会在方法执行后解析。下面将的都是没被包裹时的平常用法
- 获取方法入参:# 参数名(如果参数是对象:# 参数名。属性名)(参数是数组:# 参数名 [0])
- 调用本身方法:默认是 root 为根。所以调用为 #root.getById(#appInfo.id).appName (调用 getById 方法获取应用名)
- 调用静态方法:T (类完整路径).getById (#appInfo.id).appName
- 调用 Bean 方法:@bean 名字.getById (#appInfo.id).appName
- 条件判断:@bean 名字.getById (#appInfo.id).appName?:' 应用名不存在’
- 条件判断:#status == 1?‘通过’:' 拒绝’
- …
# 总结
其实参考文章也有些地方没看懂,也是第一次搞 SpEL 语法,所以也走了不少弯路,以至于是实现上有些出入。但参考文章整体思路设计还是非常认同的。关于缓存那块后面想了想,用正则提取或者 for 循环提取最后替换是可以解决那地方的问题的,我建议使用 for 便利提取,因为我觉得正则这里不太稳定。
若你有什么问题欢迎留言 (●’◡’●),相互学习学习