2024了,我不想再用AOP收集业务操作日志了 | 京东云技术团队
0.背景
在近期的项目中,系统涉及到针对系统的业务操作日志统计功能,由于本系统位于业务链路的中心环节,负责接收上游系统的数据,并将基于用户操作产生的数据传递至下游系统,鉴于业务链路的复杂性和操作场景的多样性,我们计划通过对核心业务数据进行全生命周期的日志记录来提升问题解决的效率,并为用户提供更多的工具支持。
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2023-12-20-19-24QG7mKwQNxq8szb16.png)
0.1 收益
业务操作日志给我们带来的收益有哪些呢?区别于传统的系统日志,业务操作日志是软件系统中用于记录和跟踪用户对业务数据执行的操作的日志。这些日志提供了对系统活动的见解,有助于审计、监控、分析和重构业务流程。
0.2 目标
增强数据的可追踪性和透明度,确保业务流程的顺畅和可监控。系统侧效果最终实现如下:
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2023-12-20-19-24TkA07CT24Xg9Lurw.png)
👉👉👉要解决的核心问题:「谁」在「什么时间」对「什么」做了「什么事」
1.方案1.0:AOP切面+注解
作为一名对Spring重度使用者😎😎,基于上面的需求目标马上想到了基于AOP切面+注解的传统方案,AOP切面和注解来设计业务操作日志是一种非常自然和高效的方法,我们基于AOP切面和注解的方法来实现我们系统中的业务操作日志记录,这种方案允许我们以最小的侵入性来捕获核心业务操作,并在执行前后自动记录相关数据。通过定义注解,我们可以轻松地标记那些需要记录日志的业务方法。万能的AOP一定可以实现!😏
让我们开始编码:👇
1.1 定义日志注解
首先,你需要定义一个或多个注解,用于标记哪些方法需要记录业务操作日志。
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Loggable {
String value() default "";
// 可以添加更多的配置属性,如操作类型、级别等
}
1.2 创建AOP切面
接下来,创建一个切面类来处理带有@Loggable
注解的方法。
@Aspect
@Component
public class LoggingAspect {
@Around("@annotation(loggable)")
public Object logExecutionTime(ProceedingJoinPoint joinPoint, Loggable loggable) throws Throwable {
long start = System.currentTimeMillis();
Object proceed // 执行目标方法
long executionTime = System.currentTimeMillis() - start;
// 记录日志的逻辑
logger.info(joinPoint.getSignature() + " executed in " + executionTime + "ms");
return proceed;
}
}
1.3 配置Spring AOP+标记注解
@Configuration
@EnableAspectJAutoProxy
public class AopConfig {
// 可能还需要其他的配置或Bean定义
}
public class SomeService {
@Loggable
public void someBusinessMethod(Object someParam) {
// 业务逻辑
}
}
1.4 定义日志记录逻辑
在切面中,我们就可以自定义日志记录逻辑,可以记录更多的上下文信息,如方法参数、返回值、执行时间、异常信息等。
@Autowired
private Logger logger; // 例如,通过SLF4J获取的Logger
@Around("@annotation(loggable)")
public Object logBusinessOperation(ProceedingJoinPoint joinPoint, Loggable loggable) throws Throwable {
// 方法执行前的逻辑,例如记录开始时间、方法参数等
long start = System.currentTimeMillis();
try {
Object result // 执行目标方法
// 方法执行后的逻辑,例如记录结束时间、返回值等
return result;
} catch (Exception e) {
// 异常处理逻辑,如记录异常信息
throw e;
} finally {
long executionTime = System.currentTimeMillis() - start;
// 构建日志信息并记录
logger.info("{} executed in {} ms", joinPoint.getSignature(), executionTime);
}
}
1.5 方案总结
至此,实现的大框架已经编码实现,下面就是上线后坐等用户操作日志入库了!😎😎
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2023-12-20-19-25IVKsGue725GdLbUY.png)
这时我们发现方案1.0确实略显粗糙,主要是它很难实现以下几点:
那以上问题有什么好的解决办法吗?我们来升级方案!😎
2.方案升级2.0:AOP切面+SpEL
通过方案1,我们发现记录到的日志数据都是固定的模板数据,如:XXX 修改了项目,XXX新建了问题数据,XXX删除了风险问题,因为我们无法通过每个切面对具体参数内容和业务场景进行捕获。那么如果我们想要在日志内容中添加更多的业务上下文信息,如:XXX 修改了项目ID=001的数据,XXX删除了产品ID=002的数据,这时候就可以通过使用 AOP + SpEL 表达式来实现。
2.1 SpEL表达式
首先简单介绍下SpEL,并是不很复杂的一种新技术,我们在日常的开发中其实大家都隐形的在使用SpEL。
官方的定义:SpEL(Spring Expression Language)是 Spring 中的表达式语言,用于在运行时评估和处理表达式。它提供了一种灵活的方式来访问和操作对象的属性、方法和其他表达式。SpEL可以用于配置文件、注解、XML 配置等多种场景。
举个例子就是我们日常项目中最熟悉的@Value注解,在注解中使用是支持常见的表达式操作的,如算术运算、逻辑运算、条件判断、集合操作等,并且可以与 Spring 框架的其他功能整合使用。
@Value("#{mq.topic}")
public String mqTopic;
@Value("#{T(java.lang.Math).random() * 100.0}")
private double randomNumber;
我们先来了解下他的一些核心概念:
// 解析器
ExpressionParser parser = new SpelExpressionParser();
// 使用解析器,解析 SpEL 表达式
// 该表达式中定义了字符串 'Hello ' 常量,并且调用它的 .concat 方法,参数是一个引用变量
Expression expression // 在表达式中,使用 #param 访问上下文中的参数
// 在上下文中执行表达式,获取 String 类型的结果
String result = expression.getValue(context, String.class);
通过对SpEL的了解我们发现,可以在方案1的注解基础上,我们对注解的内容进行扩展,扩展后就可以通过标记注解的方法对方法的全部上下文信息进行获取了!这样就可以弥补方案1中注解捕获到信息量小且无法自定义的不足,那么具体该如何使用呢?
2.2 表达式定义
首先对方案1中的注解内容进行扩展,把我们业务场景所需要涉及的操作类型,数据内容等信息进行定义:
@Repeatable(LogRecords.class)
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface LogRecord {
String success();
String fail() default "";
String operator() default ""; //业务操作场景人
String type(); // 业务场景 模块范围
String subType() default ""; //业务子场景,主要是模块下的功能范围
String bizNo(); //业务场景的业务编号,
String extra() default "";//一些操作的扩展操作
String actionType(); //业务操作类型,比如编辑、新增、删除
}
然后我们基于注解进行定义SpEL的解析器来对注解中的字段进行解析和使用:
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.TypeResolutionContext;
import java.lang.reflect.Annotation;
import java.util.Map;
public class LogRecordParser {
public static Map<String, Object> parseLogRecord(Annotation logRecordAnnotation) {
Map<String, Object> result ();
ExpressionParser parser = new ExpressionParser(new SpelFunction("parseLogRecord", LogRecordParser.class, "parseLogRecord"));
for (String attribute : logRecordAnnotation.getAttributeNames()) {
Object value = logRecordAnnotation.getAttribute(attribute);
Expression expression = parser.parseExpression(attribute);
TypeResolutionContext typeResolutionContext = new TypeResolutionContext();
typeResolutionContext.setMethod(new Method(null, null, null));
Object parsedValue = expression.getValue(typeResolutionContext);
result.put(attribute, parsedValue);
}
return result;
}
}
当面的解析部分只是比较核心的数据解析部分,具体解析后的业务处理逻辑大家可按需处理。那么基于上面的AOP+SpEL的方式,我们就可以手动在我们需要标记的业务进行注解的使用了。
2.3 表达式使用
下图是系统中实际业务操作的使用场景,大家可以看到我们标记后在注解的内容中填充了很多业务操作场景的数据,如果需要涉及操作前后数据的内容记录,我们还可以再次进行扩充SpEL的字段及解析逻辑,可以说是有了它,我们可以做的更多了!(但是注解也越来越长了)
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2023-12-22-12-02y126Q12HwhpIYsMzF.png)
2.4 方案总结
本方案在落地效果来看确实解决了业务操作日志的一个收集问题,能够清晰的记录各类操作场景、动作、数据前后的内容等,但总还是觉得不够优雅,还能有更好的方案解决方案2的缺点吗?
=======================✍️思考的分割线✍️========================
思考:我们一直在想怎么从应用层对操作场景、数据进行抓包、处理逻辑、保存,所以复杂度都会集中到应用层。既然是这样我们能不能直接基于底层的MySQL本身来处理这件事儿呢?它对数据的变化感知比我们要敏锐呢?
3.方案升级3.0:Binlog+时间窗口
3.1 Binlog
Binlog大家都不陌生,是数据库中二进制格式的文件,用于记录用户对数据库更新的SQL语句信息,例如更改数据库表和更改内容的SQL语句都会记录到binlog里。那么Binlog能用来记录业务层面的数据变化内容吗?
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2023-12-22-12-04GMz84lDY0GtaCS9.png)
《引用:MySQL主从及binlog日志同步结构》
3.2 方案问题及思路
问题:
方案:
解决问题1:由于本身binlog的无序性,所以无法对大量binlog进行有序组合,如果本身是一个事务提交的还可以根据事务KEY进行组合,如果不是呢?这里可以考虑借鉴Flink的时间窗口机制:滚动的时间窗口将每个元素指定给指定窗口大小的窗口,滚动窗口具有固定大小,且不重叠。
例如,我们指定一个大小为 1 分钟的滚动窗口,在这种情况下,我们将每隔 1 分钟开启一个新的窗口,其中每一条数都会划分到唯一一个 1 分钟的窗口中,如下图所示:
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2023-12-26-10-24CLAwH10JVUmb32QlJ.png)
基于以上的窗口机制,我们就可以对数据先进行范围的框定,通过窗口的滑动机制和补偿机制对窗口中的数据进行关联处理。但光靠时间窗口还是无法对binlog进行关联,那我们就从关联数据本身下手,这类数据关联复杂主要是涉及表之间的引用关系,那我们在进行定义binlog解析时就把前后数据+表之间的引用字段都进行指定,这样在窗口中进行滑动关联时,就可以进行子表的引用字段关联了!这样关联字段补偿更新的机制就可以解决问题1 了。部分的binlog数据变动结构的RowChange定义如下:
@Data
public static class RowChange {
private int tableId;
private List<RowDatas> rowDatas;
private String eventType;
private boolean isDdl;
}
@Data
public static class RowDatas {
private List<DataColumn> afterColumns;
private List<DataColumn> beforeColumns;
}
@Data
public static class DataColumn {
private int sqlType;
private boolean isNull;
private String mysqlType;
private String name;
private boolean isKey;
private int index;
private boolean updated;
private String value;
}
解决问题2:关于更新人的问题其实是各系统需要自己排除解决的问题,因为本身业务在进行数据操作时就是需要留痕更新人信息,比较统一的方案就是基于底层的ORM框架来统一进行拦截处理,大家可以自行GPT。
3.3 方案架构
思路和问题都清晰了,那我们来画一画整体的方案架构:
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2023-12-25-19-14tnCACzT32ztyQmhq.png)
3.4 方案总结
整体方案设计完后,有一种非常轻快的感觉,它!优!雅!了! 确实需要应用层做的不多了,而且本身架构还可以复用到其他系统,实现日志收集的中心化管理。那么它完美了吗?还是有缺陷。
4.写在最后
通过以上的业务背景及实现落地过程,我感受到其实在日常业务需求对接和实现过程中,会伴随着各种各样的问题出现,会有很多因素让你对某技术方案进行舍弃,但是舍弃了A可能单来了B C D等一连串的问题,我们在做系统架构时,其实也是一个慢慢演进的过程,对技术实现的思路和方案要拥抱变化,摒弃自己的一些“技术极致”追求,做好兜底方案,完美的方案可能是少数,努力适配自己的技术架构和方案才是我们长期要做的。
技术并不是银弹,我们的思考过程才是,感谢您的阅读!😋
作者:京东科技 李辉
来源:京东云开发者社区 转载请注明来源