在我们工作的日常开发中,我们经常会遇到需要记录用户的操作日志(如上图所示)的需求,这个需求本身不是很难,但是如何让操作日志不和业务逻辑耦合?如何优雅的记录操作日志?这就是本文分享的主要内容:优雅的记录操作日志。
与业务系统解耦,相信很多小伙伴已经想到了Spring AOP ,没错,本文分享的方案就是使用 AOP 生成动态的操作日志。(ps:如果有小伙伴对Spring AOP有疑问,可以先去看看这篇文章 彻底搞懂Spring AOP)
基于Spring AOP+SpEL 生成动态的操作日志
这里先给出本文实现的一个效果,然后再看是怎么实现这样功能的 从图上看,通过 SpEL (Spring Expression Language,Spring表达式语言)表达式实现了动态模板,SpEL表达式引用方法上的参数,可以让变量填充到模板中达到动态的操作日志文本内容。
这里说明一下,正常情况下像业务单号这类数据应该是从入参或者其它地方获取,写固定是为了演示方便,操作人也是不应该从入参中获取,而是从登陆信息里获取,这里作者就偷懒了...
代码实现
AOP拦截
- 定义注解
@LogRecordAnnotation
java
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface LogRecordAnnotation {
//操作是否成功
boolean succeed() default true;
//操作人
String operator() default "";
//业务单号
String bizNo() default "";
//操作日志的种类
String category() default "";
//扩展参数,记录操作日志的修改详情
String detail() default "";
//记录日志的条件
String condition() default "";
}
注解中的参数,应该满足大部分的使用场景,在需要记录操作日志的方法上加上该注解,以便后续进行拦截处理。
- 切面增强逻辑
LogRecordAspect
java
@Slf4j
@Aspect
@Component
public class LogRecordAspect {
@Pointcut("@annotation(com.tx.operating.annotation.LogRecordAnnotation)")
private void method() {
}
@Around("method()")
public Object divAround(ProceedingJoinPoint joinPoint) throws Throwable {
Map<String, Object> resultMap = new HashMap<>();
Object proceed = null;
try {
//获取注解信息
LogRecordAnnotation annotation = LogRecordOperationSource.getAnnotation(joinPoint);
//获取SPEL表达式
Map<String, Object> spelMap = LogRecordOperationSource.getBeforeExecuteFunctionTemplate(annotation);
//执行SPEL表达式和执行自定义函数
AnnotatedElementKey methodKey=new AnnotatedElementKey(((MethodSignature) joinPoint.getSignature()).getMethod(),joinPoint.getTarget().getClass());
resultMap = LogRecordOperationSource.processBeforeExecuteFunctionTemplate(spelMap,methodKey,joinPoint.getArgs()[0]);
} catch (Exception e) {
log.info("/// log record exec error",e);
}
try {
proceed = joinPoint.proceed();
} catch (Exception e) {
//目标方法执行异常,设置操作状态为失败
resultMap.put(CommonConstants.SUCCEED,false);
throw new OrsRuntimeException("//// 目标方法执行异常,"+ e.getMessage());
}finally {
//通过SPI加载自定义持久化日志的方法进行日志持久化
ServiceLoader<KeepOperatingRecordSpi> loader = ServiceLoader.load(KeepOperatingRecordSpi.class);
Iterator<KeepOperatingRecordSpi> it = loader.iterator();
if (it.hasNext()) {
KeepOperatingRecordSpi keepOperatingRecordSpi = it.next();
keepOperatingRecordSpi.keepRecord(resultMap);
}
}
return proceed;
}
}
核心逻辑:在业务的方法执行之前,会提前解析的自定义函数和SpEL表达式求值,操作日志的记录持久化是在方法执行完之后执行的,当方法抛出异常时,继续将操作日志持久化完成。
解析模板
LogRecordOperationSource
里面封装了自定义函数和 SpEL 解析OperateRecordExpressionParse
java
public class OperateRecordExpressionParse extends CachedExpressionEvaluator {
private final static OperateRecordExpressionParse operateRecordExpressionParse=new OperateRecordExpressionParse();
public OperateRecordExpressionParse(){}
private Map<ExpressionKey, Expression> expressionCache = new ConcurrentHashMap<>(64);
public String parseExpression(String conditionExpression, AnnotatedElementKey methodKey, EvaluationContext evalContext) {
return getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);
}
public static OperateRecordExpressionParse getInstance(){
return operateRecordExpressionParse;
}
}
OperateRecordExpressionParse
继承自 CachedExpressionEvaluator
类,这个类里面有一个Map是 expressionCache。在上面的例子中可以看到,SpEL 会解析成一个 Expression 表达式,然后根据传入的 Object 获取到对应的值,所以 expressionCache 是为了缓存方法、表达式和 SpEL 的 Expression 的对应关系,让方法注解上添加的 SpEL 表达式只解析一次。
java
getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);
getExpression
方法会从 expressionCache 中获取到 @LogRecordAnnotation 注解上的表达式的解析 Expression 的实例,然后调用 getValue
方法,getValue
传入一个 evalContext 上下文对象。在上面的例子中方法的入参就是上下文。
自定义函数
为什么会有自定义函数呢?
在上面的例子中可以看到有这样一条操作日志记录:用户A将地址从云南修改为深圳,新地址深圳从入参中很容易就获取到了,旧地址云南似乎是也可以,但是在我之前所想到的方法都没那么优雅。而通过自定义函数,我们可以根据数据Id转化为我们需要的对象信息,当然通过自定义函数我们还能做很多事,这里就不一一列举了。
定义IParseFunction
接口
java
public interface IParseFunction {
//自定义函数名 用于@LogRecordAnnotation中进行模板配置
String functionName();
// 执行逻辑
String apply(Object value);
}
ParseFunctionFactory
代码比较简单,它的功能是把所有的 IParseFunction 注入到函数工厂中。
java
public class ParseFunctionFactory {
private Map<String, IParseFunction> allFunctionMap;
public ParseFunctionFactory() {
// 通过反射获取所有IParseFunction接口的实现类
List<Object> parseFunctions = FindImplementationsUtil.findImplementations(IParseFunction.class);
if (CollectionUtils.isEmpty(parseFunctions)) {
return;
}
allFunctionMap = new HashMap<>();
for (Object obj : parseFunctions) {
if (!(obj instanceof IParseFunction)) {
continue;
}
IParseFunction parseFunction = (IParseFunction) obj;
if (StringUtils.isEmpty(parseFunction.functionName())) {
continue;
}
allFunctionMap.put(parseFunction.functionName(), parseFunction);
}
}
public IParseFunction getFunction(String functionName) {
return allFunctionMap.get(functionName);
}
}
日志持久化
KeepOperatingRecordSpi
SPI接口,业务可以实现这个保存接口,然后把日志保存在任何存储介质上(mysql、redis、mongoDB等等)
java
public interface KeepOperatingRecordSpi {
<T> void keepRecord(T t);
}
对于java SPI机制不了解的小伙伴,可以看看这篇文章彻底搞懂JAVA SPI,这里就不做太多介绍了。
至此,本文实现操作日志记录的人核心方法与逻辑就介绍完啦,如果有小伙伴需要源码的可以去GitHub上获取opreating-record,也可以私信我。
总结
这篇文章主要分享了基于Spring Aop+SpEL实现操作日志记录,通过AOP与业务逻辑解耦,通过自定义函数、SpEL表达式实现日志的动态模板,使得我们可以优雅的记录操作日志。当然作者在实现的时候,也有瑕疵,也有一些东西没考虑到,欢迎小伙伴们留言,让其更加完善。