大家好,我是二哥呀。今天由我来给大家讲一下《技术派是如何通过 AOP 实现切面日志的》,同时,我们也会借这个机会深入探讨一下 Spring 中的 AOP 机制,毕竟这是一道很常见的面试题,简历中写专业技能的时候,一般会也写上“深入了解过 Spring 的 AOP 机制”,这样写也会给 HR / 面试官一个不错的印象。
技术派中关于 AOP 切面的应用目前有两处,一处是 MdcAspect 用于方法执行耗时统计,另外一处是 DsAspect 用于动态切换数据源。本节关注的重点是第一处,第二处会在“动态切换数据源”中详细讲解。
我会结合技术派的源码来帮大家彻底搞清楚 Spring AOP,不仅能让你掌握 AOP 实现切面日志的方式,还能让你理解其原理。
什么是 AOP?
AOP,也就是 Aspect-oriented Programming,译为面向切面编程,是计算机科学中的一个设计思想,旨在通过切面技术为业务主体增加额外的通知(Advice),从而对声明为“切点”(Pointcut)的代码块进行统一管理和装饰。
这种思想非常适用于,将那些与核心业务不那么密切关联的功能添加到程序中,就好比我们今天的主题——日志功能,就是一个典型的案例。
AOP 是对面向对象编程(Object-oriented Programming,俗称 OOP)的一种补充,OOP 的核心单元是类(class),而 AOP 的核心单元是切面(Aspect)。利用 AOP 可以对业务逻辑的各个部分进行隔离,从而降低耦合度,提高程序的可重用性,同时也提高了开发效率。
我们可以简单的把 AOP 理解为贯穿于方法之中,在方法执行前、执行时、执行后、返回值后、异常后要执行的操作。
AOP 的相关术语
来看下面这幅图,这是一个 AOP 的模型图,就是在某些方法执行前后执行一些通用的操作,并且这些操作不会影响程序本身的运行。
我们来了解下 AOP 涉及到的 5 个关键术语:
1)横切关注点,从每个方法中抽取出来的同一类非核心业务
2)切面(Aspect),对横切关注点进行封装的类,每个关注点体现为一个通知方法;通常使用 @Aspect 注解来定义切面。
3)通知(Advice),切面必须要完成的各个具体工作,比如我们的日志切面需要记录接口调用前后的时长,就需要在调用接口前后记录时间,再取差值。通知的方式有五种:
- @Before:通知方法会在目标方法调用之前执行
- @After:通知方法会在目标方法调用后执行
- @AfterReturning:通知方法会在目标方法返回后执行
- @AfterThrowing:通知方法会在目标方法抛出异常后执行
- @Around:把整个目标方法包裹起来,在被调用前和调用之后分别执行通知方法
4)连接点(JoinPoint),通知应用的时机,比如接口方法被调用时就是日志切面的连接点。
5)切点(Pointcut),通知功能被应用的范围,比如本篇日志切面的应用范围是所有 controller 的接口。通常使用 @Pointcut 注解来定义切点表达式。
切入点表达式的语法格式规范如下所示:
execution(modifiers-pattern? ret-type-pattern declaring-type-pattern?
name-pattern(param-pattern)
throws-pattern?)
modifiers-pattern?
为访问权限修饰符ret-type-pattern
为返回类型,通常用*
来表示任意返回类型declaring-type-pattern?
为包名name-pattern
为方法名,可以使用*
来表示所有,或者set*
来表示所有以 set 开头的类名param-pattern)
为参数类型,多个参数可以用,
隔开,各个参与也可以使用*
来表示所有类型的参数,还可以使用(..)
表示零个或者任意参数throws-pattern?
为异常类型?
表示前面的为可选项
举个例子(来自技术派中 com.github.paicoding.forum.core.mdc.MdcAspect
类):
@Pointcut("@annotation(MdcDot) || @within(MdcDot)")
public void getLogAnnotation() {
}
这个切入点定义的意思是:“拦截所有被 MdcDot 注解的方法,以及所有在被 MdcDot 注解的类中的方法。”
实操 AOP 记录接口访问日志
技术派中的 AOP 记录接口访问日志是放在 paicoding-core 模块下的 mdc 包下。
我们先来看一下这五个类。
1)SkyWalkingTraceIdGenerator
该类是从 SkyWalking 直接 copy 过来的,一种生成 traceId 的方式。
SkyWalking 是一款开源的应用性能监控系统,它支持对分布式系统中的服务进行追踪、监控和诊断。
最主要的方法就是一个静态的 generate 方法,用于生成 traceid,类的源码这里就不贴了,大家可以直接去看技术派的源码。
这个类生成的 traceid 包含三部分:
- 第一部分是应用实例ID,它是在类加载时生成的一个UUID,对于每个进程,它是唯一的。
- 第二部分是当前线程的ID。
- 第三部分是一个由时间戳和线程序列号组成的数字,时间戳是毫秒级的,而线程序列号是一个在0到9999之间的数。
这个工具类的设计思想主要是生成一个既唯一又能包含一些上下文信息的 traceid,帮助我们更好地追踪和理解分布式系统中的请求执行路径。
2)SelfTraceIdGenerator
这是技术派自定义的一个traceId生成器,可以来详细看一下其中的 generate 方法。
public static String generate() {
StringBuilder traceId = new StringBuilder();
try {
// 1. IP - 8
InetAddress ip = InetAddress.getLocalHost();
traceId.append(convertIp(IpUtil.getLocalIp4Address())).append(".");
// 2. 时间戳 - 13
traceId.append(Instant.now().toEpochMilli()).append(".");
// 3. 当前进程号 - 5
traceId.append(getProcessId());
// 4. 自增序列 - 4
traceId.append(getAutoIncreaseNumber());
} catch (Exception e) {
log.error("generate trace id error!", e);
return UUID.randomUUID().toString().replaceAll("-", "");
}
return traceId.toString();
}
生成的traceId包括以下四部分:
- IP地址(8位):取得当前机器的IP地址,并将其转换为十六进制格式。
- 时间戳(13位):使用Java 8的Instant类获取当前的毫秒级时间戳。
- 进程号(5位):使用Java的ManagementFactory类获取当前JVM进程的PID,并保证总长度为5位。
- 自增序列号(4位):一个在1000到9999之间循环自增的数。
我们来对比一下 SelfTraceIdGenerator(前两个)和 SkyWalkingTraceIdGenerator(后一个)生成的 traceid。
00000000.1686895888832.745811000
00000000.1686895888838.745811001
75e0cde204164cda98b0cca40b2999da.1.16868958889180000
3)为什么需要 traceid 呢?
这里加个餐,来简单说一下为什么需要 traceid。
当你的系统是分布式或者微服务时,一个球友可能会穿过多个服务,每个服务可能都会生成一些日志,但由于系统是微服务/分布式的,会运行在不同的物理机器上,如果没有一个统一的标识符来链接这些日志,就很难理解一个请求的完整过程。
traceid 就是这样一个标识符,它在请求进入系统时生成,然后沿着请求的执行路径传递给所有参与处理该请求的服务。这些服务在生成日志时,会把traceid包含在日志中。这样,通过搜索同一traceid的所有日志,就可以追踪到整个请求的执行过程。
4)MdcUtil
MDC 全称为 Mapped Diagnostic Context,可译为上下文诊断映射,也不知道标准不标准,大概就这么一个意思。主要用于在多线程环境中存储每个线程特定的诊断信息,比如 traceId。
该类主要提供了五个方法:
- add方法:往MDC中添加一个键值对。
- addTraceId方法:生成一个traceId并添加到MDC中。
- getTraceId方法:从MDC中获取traceId。
- reset方法:清除MDC中的所有信息,然后把traceId添加回去。
- clear方法:清除MDC中的所有信息。
如果你在技术派的源码中搜 MdcUtil 的话,可以在 ReqRecordFilter 中找得到,顾名思义,该类是对请求的一个过滤器,会在每个请求中加上全链路的 traceid。
在 req-dev.log 中可以找到。
5)MdcDot
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MdcDot {
String bizCode() default "";
}
这段代码定义了一个Java注解@MdcDot,直接搜可以在以下这些地方找得到。
6)MdcAspect
这是一个用于实现面向切面编程(AOP)的AspectJ切面。@Aspect 注解我们前面也讲了它的作用。
@Aspect
public class MdcAspect implements ApplicationContextAware {}
MdcAspect 切面的目的是处理添加了@MdcDot注解的方法或类。具体如何处理,由@Around注解标注的handle方法定义。
@Pointcut("@annotation(MdcDot) || @within(MdcDot)")
public void getLogAnnotation() {
}
@Around("getLogAnnotation()")
public Object handle(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
boolean hasTag = addMdcCode(joinPoint);
try {
Object ans = joinPoint.proceed();
return ans;
} finally {
log.info("方法执行耗时: {}#{} = {}", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName() , System.currentTimeMillis() - start);
if (hasTag) {
MdcUtil.reset();
}
}
}
@Pointcut 注解前面也讲了,我们直接来看 handle 方法。
在handle方法中,首先记录了方法调用的开始时间,然后检查是否存在@MdcDot注解并获取业务编码。
private boolean addMdcCode(ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
MdcDot dot = method.getAnnotation(MdcDot.class);
if (dot == null) {
dot = (MdcDot) joinPoint.getSignature().getDeclaringType().getAnnotation(MdcDot.class);
}
if (dot != null) {
MdcUtil.add("bizCode", loadBizCode(dot.bizCode(), joinPoint));
return true;
}
return false;
}
其中addMdcCode方法用于检查方法或类是否有@MdcDot注解并获取业务编码,loadBizCode方法用于解析@MdcDot注解的bizCode元素的值。
private String loadBizCode(String key, ProceedingJoinPoint joinPoint) {
if (StringUtils.isBlank(key)) {
return "";
}
StandardEvaluationContext context = new StandardEvaluationContext();
context.setBeanResolver(new BeanFactoryResolver(applicationContext));
String[] params = parameterNameDiscoverer.getParameterNames(((MethodSignature) joinPoint.getSignature()).getMethod());
Object[] args = joinPoint.getArgs();
for (int i = 0; i < args.length; i++) {
context.setVariable(params[i], args[i]);
}
return parser.parseExpression(key).getValue(context, String.class);
}
如果存在则将业务编码添加到MDC中。接着调用原方法并返回结果,最后记录方法的执行时间并打印日志,如果方法有@MdcDot注解则重置MDC。
7)具体怎么用?
我们找到 @MdcDot 注解,直接看 ArticleRestController 的 recommend 方法吧。
就是在方法上加上 @MdcDot(bizCode = "#articleId")
这段代码。
recommend 方法对应的业务,是点击文章详情的时候触发相应的推荐文章。
好,我们来看一下控制台的日志输出,内容如下所示。
2023-06-16 11:06:13,008 [http-nio-8080-exec-3] INFO |00000000.1686884772947.468581113|101|c.g.p.forum.core.mdc.MdcAspect.handle(MdcAspect.java:47) - 方法执行耗时: com.github.paicoding.forum.web.front.article.rest.ArticleRestController#recommend = 47
其中 traceid 为 00000000.1686884772947.468581113
。
handle 方法和 recommend 方法的执行顺序是这样的。
小结
给 3 道关于 Spring AOP 的面试题,大家可以自行回答一下。这可不是单纯的八股哈,需要结合实际的项目来回答最好。
- 说说什么是 AOP ?
- AOP 有哪些核心概念?
- AOP 有哪些环绕方式?
- 说说你平时都是怎么使用 AOP 的?
- 说说 Spring AOP 和 AspectJ AOP 有什么区别?
- 说说 JDK 动态代理和 CGLIB 代理?
答案可以在《二哥的 Java 进阶之路》上找到答案哦。
回复