如何优雅地记录操作日志?(3)

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
云解析DNS,个人版 1个月
全局流量管理 GTM,标准版 1个月
简介: 如何优雅地记录操作日志?

4.2.2 解析逻辑


模板解析Spring 3 中提供了一个非常强大的功能:SpEL,SpEL 在 Spring 产品中是作为表达式求值的核心基础模块,它本身是可以脱离 Spring 独立使用的。举个例子:


public static void main(String[] args) {
        SpelExpressionParser parser = new SpelExpressionParser();
        Expression expression = parser.parseExpression("#root.purchaseName");
        Order order = new Order();
        order.setPurchaseName("张三");
        System.out.println(expression.getValue(order));
}

这个方法将打印 “张三”。LogRecord 解析的类图如下:



解析核心类LogRecordValueParser 里面封装了自定义函数和 SpEL 解析类 LogRecordExpressionEvaluator


public class LogRecordExpressionEvaluator extends CachedExpressionEvaluator {
   private Map<ExpressionKey, Expression> expressionCache = new ConcurrentHashMap<>(64);
   private final Map<AnnotatedElementKey, Method> targetMethodCache = new ConcurrentHashMap<>(64);
   public String parseExpression(String conditionExpression, AnnotatedElementKey methodKey, EvaluationContext evalContext) {
       return getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);
    }
}

LogRecordExpressionEvaluator 继承自 CachedExpressionEvaluator 类,这个类里面有两个 Map,一个是 expressionCache 一个是 targetMethodCache。在上面的例子中可以看到,SpEL 会解析成一个 Expression 表达式,然后根据传入的 Object 获取到对应的值,所以 expressionCache 是为了缓存方法、表达式和 SpEL 的 Expression 的对应关系,让方法注解上添加的 SpEL 表达式只解析一次。下面的 targetMethodCache 是为了缓存传入到 Expression 表达式的 Object。核心的解析逻辑是上面最后一行代码。


getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);


getExpression 方法会从 expressionCache 中获取到 @LogRecordAnnotation 注解上的表达式的解析 Expression 的实例,然后调用 getValue 方法,getValue 传入一个 evalContext 就是类似上面例子中的 order 对象。其中 Context 的实现将会在下文介绍。日志上下文实现下面的例子把变量放到了 LogRecordContext 中,然后 SpEL 表达式就可以顺利的解析方法上不存在的参数了,通过上面的 SpEL 的例子可以看出,要把方法的参数和 LogRecordContext 中的变量都放到 SpEL 的 getValue 方法的 Object 中才可以顺利的解析表达式的值。下面看看如何实现:


@LogRecord(content = "修改了订单的配送员:从“{deveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.getUserId()}}”",
            bizNo="#request.getDeliveryOrderNo()")
public void modifyAddress(updateDeliveryRequest request){
   // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
   // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

在 LogRecordValueParser 中创建了一个 EvaluationContext,用来给 SpEL 解析方法参数和 Context 中的变量。相关代码如下:


EvaluationContext evaluationContext = expressionEvaluator.createEvaluationContext(method, args, targetClass, ret, errorMsg, beanFactory);


在解析的时候调用 getValue 方法传入的参数 evalContext,就是上面这个 EvaluationContext 对象。下面是 LogRecordEvaluationContext 对象的继承体系:

LogRecordEvaluationContext 做了三个事情:


  • 把方法的参数都放到 SpEL 解析的 RootObject 中。
  • 把 LogRecordContext 中的变量都放到 RootObject 中。
  • 把方法的返回值和 ErrorMsg 都放到 RootObject 中。


LogRecordEvaluationContext 的代码如下:


public class LogRecordEvaluationContext extends MethodBasedEvaluationContext {
   public LogRecordEvaluationContext(Object rootObject, Method method, Object[] arguments,
                                      ParameterNameDiscoverer parameterNameDiscoverer, Object ret, String errorMsg) {
      //把方法的参数都放到 SpEL 解析的 RootObject 中
      super(rootObject, method, arguments, parameterNameDiscoverer);
      //把 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());
            }
        }
       //把方法的返回值和 ErrorMsg 都放到 RootObject 中
        setVariable("_ret", ret);
        setVariable("_errorMsg", errorMsg);
    }
}

下面是 LogRecordContext 的实现,这个类里面通过一个 ThreadLocal 变量保持了一个栈,栈里面是个 Map,Map 对应了变量的名称和变量的值。


public class LogRecordContext {
   private static final InheritableThreadLocal<Stack<Map<String, Object>>> variableMapStack = new InheritableThreadLocal<>();
  //其他省略....
}


上面使用了 InheritableThreadLocal,所以在线程池的场景下使用 LogRecordContext 会出现问题,如果支持线程池可以使用阿里巴巴开源的 TTL 框架。那这里为什么不直接设置一个 ThreadLocal<Map<String, Object>> 对象,而是要设置一个 Stack 结构呢?我们看一下这么做的原因是什么。


@LogRecord(content = "修改了订单的配送员:从“{deveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.getUserId()}}”",
        bizNo="#request.getDeliveryOrderNo()")
public void modifyAddress(updateDeliveryRequest request){
   // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
   // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

上面代码的执行流程如下:



看起来没有什么问题,但是使用 LogRecordAnnotation 的方法里面嵌套了另一个使用 LogRecordAnnotation 方法的时候,流程就变成下面的形式:



可以看到,当方法二执行了释放变量后,继续执行方法一的 logRecord 逻辑,此时解析的时候 ThreadLocal<Map<String, Object>>的 Map 已经被释放掉,所以方法一就获取不到对应的变量了。方法一和方法二共用一个变量 Map 还有个问题是:如果方法二设置了和方法一相同的变量两个方法的变量就会被相互覆盖。所以最终 LogRecordContext 的变量的生命周期需要是下面的形式:



LogRecordContext 每执行一个方法都会压栈一个 Map,方法执行完之后会 Pop 掉这个 Map,从而避免变量共享和覆盖问题。默认操作人逻辑在 LogRecordInterceptor 中 IOperatorGetService 接口,这个接口可以获取到当前的用户。下面是接口的定义:


public interface IOperatorGetService {
   /**
     * 可以在里面外部的获取当前登陆的用户,比如 UserContext.getCurrentUser()
     *
     * @return 转换成Operator返回
     */
   Operator getUser();
}


下面给出了从用户上下文中获取用户的例子:


public class DefaultOperatorGetServiceImpl implements IOperatorGetService {
   @Override
   public Operator getUser() {
   //UserUtils 是获取用户上下文的方法
        return Optional.ofNullable(UserUtils.getUser())
                        .map(a -> new Operator(a.getName(), a.getLogin()))
                        .orElseThrow(()->new IllegalArgumentException("user is null"));
    }
}


组件在解析 operator 的时候,就判断注解上的 operator 是否是空,如果注解上没有指定,我们就从 IOperatorGetService 的 getUser 方法获取了。如果都获取不到,就会报错。


String realOperatorId = "";
if (StringUtils.isEmpty(operatorId)) {
    if (operatorGetService.getUser() == null || StringUtils.isEmpty(operatorGetService.getUser().getOperatorId())) {
        throw new IllegalArgumentException("user is null");
    }
    realOperatorId = operatorGetService.getUser().getOperatorId();
} else {
    spElTemplates = Lists.newArrayList(bizKey, bizNo, action, operatorId, detail);
}


自定义函数逻辑自定义函数的类图如下:



下面是 IParseFunction 的接口定义:executeBefore 函数代表了自定义函数是否在业务代码执行之前解析,上面提到的查询修改之前的内容。


public interface IParseFunction {
default boolean executeBefore(){
   return false;
  }
String functionName();
String apply(String value);
}


ParseFunctionFactory 的代码比较简单,它的功能是把所有的 IParseFunction 注入到函数工厂中。


public class ParseFunctionFactory {
private Map<String, IParseFunction> allFunctionMap;
public ParseFunctionFactory(List<IParseFunction> parseFunctions) {
   if (CollectionUtils.isEmpty(parseFunctions)) {
     return;
    }
    allFunctionMap = new HashMap<>();
   for (IParseFunction parseFunction : parseFunctions) {
     if (StringUtils.isEmpty(parseFunction.functionName())) {
       continue;
      }
      allFunctionMap.put(parseFunction.functionName(), parseFunction);
    }
  }
public IParseFunction getFunction(String functionName) {
   return allFunctionMap.get(functionName);
  }
public boolean isBeforeFunction(String functionName) {
   return allFunctionMap.get(functionName) != null && allFunctionMap.get(functionName).executeBefore();
  }
}


DefaultFunctionServiceImpl 的逻辑就是根据传入的函数名称 functionName 找到对应的 IParseFunction,然后把参数传入到 IParseFunction 的 apply 方法上最后返回函数的值。


public class DefaultFunctionServiceImpl implements IFunctionService {
private final ParseFunctionFactory parseFunctionFactory;
public DefaultFunctionServiceImpl(ParseFunctionFactory parseFunctionFactory) {
   this.parseFunctionFactory = parseFunctionFactory;
  }
@Override
public String apply(String functionName, String value) {
    IParseFunction function = parseFunctionFactory.getFunction(functionName);
   if (function == null) {
     return value;
    }
   return function.apply(value);
  }
@Override
public boolean beforeFunction(String functionName) {
   return parseFunctionFactory.isBeforeFunction(functionName);
  }
}
相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
相关文章
|
11月前
|
关系型数据库 MySQL 数据库
MYSQL查看操作日志
MYSQL查看操作日志
474 0
|
1月前
|
Java
如何实现一个高效的二叉搜索树(BST)?请给出时间复杂度分析。 要求:设计一个二叉搜索树,支持插入、删除和查找操作。要求在平均情况下,这些操作的时间复杂度为O(log n)。同时,考虑树的平衡性,使得树的高度保持在对数级别。
如何实现一个高效的二叉搜索树(BST)?请给出时间复杂度分析。 要求:设计一个二叉搜索树,支持插入、删除和查找操作。要求在平均情况下,这些操作的时间复杂度为O(log n)。同时,考虑树的平衡性,使得树的高度保持在对数级别。
43 0
|
18天前
|
弹性计算 Serverless 应用服务中间件
Serverless 应用引擎操作报错合集之集成sls时出现报错,是什么导致的
Serverless 应用引擎(SAE)是阿里云提供的Serverless PaaS平台,支持Spring Cloud、Dubbo、HSF等主流微服务框架,简化应用的部署、运维和弹性伸缩。在使用SAE过程中,可能会遇到各种操作报错。以下是一些常见的报错情况及其可能的原因和解决方法。
|
1月前
|
分布式计算 DataWorks 关系型数据库
DataWorks操作报错合集之在DataWorks中设置了一个任务节点的调度时间,并将其发布到生产环境,但到了指定时间(例如17:30)却没有产生运行实例和相关日志如何解决
DataWorks是阿里云提供的一站式大数据开发与治理平台,支持数据集成、数据开发、数据服务、数据质量管理、数据安全管理等全流程数据处理。在使用DataWorks过程中,可能会遇到各种操作报错。以下是一些常见的报错情况及其可能的原因和解决方法。
|
5天前
|
存储 运维 Java
Spring运维之boot项目开发关键之日志操作以及用文件记录日志
Spring运维之boot项目开发关键之日志操作以及用文件记录日志
17 2
|
23天前
|
Java 数据库连接 数据库
Spring日志完结篇,MyBatis操作数据库(入门)
Spring日志完结篇,MyBatis操作数据库(入门)
|
10天前
|
监控 Java 数据安全/隐私保护
使用 AOP 记录操作日志
使用 AOP 记录操作日志
13 0
|
1月前
|
Dubbo Java Serverless
Serverless 应用引擎操作报错合集之Nacos中nacos启动正常,访问白页,启动日志显示正常如何解决
Serverless 应用引擎(SAE)是阿里云提供的Serverless PaaS平台,支持Spring Cloud、Dubbo、HSF等主流微服务框架,简化应用的部署、运维和弹性伸缩。在使用SAE过程中,可能会遇到各种操作报错。以下是一些常见的报错情况及其可能的原因和解决方法。
Serverless 应用引擎操作报错合集之Nacos中nacos启动正常,访问白页,启动日志显示正常如何解决
|
1月前
|
监控 JavaScript Java
|
1月前
|
存储 弹性计算 Linux
Kibana+云上ES访问SLS的操作文档
本文介绍了如何用Kibana+云上ES访问SLS的方案
143 2
Kibana+云上ES访问SLS的操作文档