1.前言
在后端业务中,对每次请求的入参、被请求类、方法,以及出参、执行耗时等信息进行日志打印,是很有必要的,有了这些信息,当某个接口出现问题时,可以帮助我们快速完成问题的追踪。那么,Spring Boot 中要如何实现呢? 相对于这种与主流程有一定的功能解耦的相对独立小功能可以使用springboot的切面来在关键地方织入我们的程序。
2.什么是自定义注解 (Custom Annotations)?
Java 注解是从 Java 5 开始引入的,它为我们提供了一种元编程的方法,允许我们在不改变代码逻辑的情况下为代码添加元数据。这些元数据可以在编译时或运行时通过反射被访问。
自定义注解就是用户定义的,用于为代码提供元数据的注解。例如,本小节中自定义的 @ApiOperationLog 注解,它用来表示一个方法在执行时需要被记录日志。
3.什么是 AOP (面向切面编程)?
AOP(Aspect-Oriented Programming,面向切面编程)是一个编程范式,它提供了一种能力,让开发者能够模块化跨多个对象的横切关注点(例如日志、事务管理、安全等)。
主要概念包括:
- 切点 (Pointcuts): 定义在哪里应用切面(即在哪里插入横切关注点的代码)。
- 通知 (Advices): 定义在特定切点上要执行的代码。常见的通知类型有:前置通知、后置通知、环绕通知等。
- 切面 (Aspects): 切面将切点和通知结合起来,定义了在何处和何时应用特定的逻辑。
例如,使用AOP,我们可以为所有使用 @ApiOperationLog 注解的方法自动添加日志逻辑,而不需要在每个方法中手动添加。
4.开始动手
4.1 添加依赖
添加 jackson 工具,它用于将出入参转为 json 字符串:
<properties> ...省略 <jackson.version>2.15.2</jackson.version> </properties>
<dependencies> ...省略 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>
<dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>${jackson.version}</version> </dependency>
</dependencies>
|
4.2 自定义注解
新建一个名为 aspect 的包,用于放置切面相关的功能类接着,在其中创建一个名为 ApiOperationLog 的自定义注解:
package com.wind.weblog.common.aspect;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD}) @Documented public @interface ApiOperationLog {
String description() default "";
}
|
元注解说明:
- @interface用来声明这是一个注解,接口是interface来声明
- @Retention(RetentionPolicy.RUNTIME): 这个元注解用于指定注解的保留策略,即注解在何时生效。RetentionPolicy.RUNTIME 表示该注解将在运行时保留,这意味着它可以通过反射在运行时被访问和解析。
- @Target({ElementType.METHOD}): 这个元注解用于指定注解的目标元素,即可以在哪些地方使用这个注解。ElementType.METHOD 表示该注解只能用于方法上。这意味着您只能在方法上使用这个特定的注解。
- @Documented: 这个元注解用于指定被注解的元素是否会出现在生成的Java文档中。如果一个注解使用了 @Documented,那么在生成文档时,被注解的元素及其注解信息会被包含在文档中。这可以帮助文档生成工具(如 JavaDoc)在生成文档时展示关于注解的信息。
4.3 日志切面
aspectj 注解说明
在配置 AOP 切面之前,我们需要了解下 aspectj 相关注解的作用:
- @Aspect:声明该类为一个切面类;
- @Pointcut:定义一个切点,后面跟随一个表达式,表达式可以定义为切某个注解,也可以切某个 package 下的方法;
切点定义好后,就是围绕这个切点做文章了:
- @Before: 在切点之前,织入相关代码;
- @After: 在切点之后,织入相关代码;
- @AfterReturning: 在切点返回内容后,织入相关代码,一般用于对返回值做些加工处理的场景;
- @AfterThrowing: 用来处理当织入的代码抛出异常后的逻辑处理;
- @Around: 环绕,可以在切入点前后织入代码,并且可以自由的控制何时执行切点;
创建 JSON 转换方法
在定义日志切面之前,我们先来创建一个 JSON 转换方法,这在日志切面中打印出入参为 JSON 字符串会用到。
private String toJson(JoinPoint joinPoint) throws JsonProcessingException { ObjectMapper mapper = new ObjectMapper(); return mapper.writeValueAsString(joinPoint.getArgs()); }
|
完整的切面类:
package com.wind.weblog.common.aspect; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.wind.weblog.common.domain.dos.VisitorRecordDO; import com.wind.weblog.common.domain.mapper.VisitorMapper; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes;
import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import java.lang.reflect.Method; import java.util.Date; import java.util.concurrent.ConcurrentHashMap;
import static com.wind.weblog.common.utils.AgentRegionUtils.getIpAddress; import static com.wind.weblog.common.utils.AgentRegionUtils.getIpRegion;
@Aspect @Component @Slf4j @EnableScheduling public class ApiOperationLogAspect {
private static final String LINE_SEPARATOR = System.lineSeparator();
private static final ConcurrentHashMap<String, String> AGENT_IP = new ConcurrentHashMap<>(8);
private static final String TASK_SCHEDULE = "1 0 0 * * ?" ;
@Value("${xdb.profile}") private String xdbPath;
@Resource private VisitorMapper visitorMapper;
@Pointcut("@annotation(com.wind.weblog.common.aspect.ApiOperationLog)") public void apiOperationLog() { }
@Before("apiOperationLog()") public void doBefore(JoinPoint joinPoint) throws Throwable { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest();
String methodDescription = getAspectLogDescription(joinPoint);
log.info("========== 请求开始: [{}], 入参: {} =================================== ", methodDescription, toJson(joinPoint)); log.warn("请求的类: {}, 方法: {}", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName());
String ipAddress = getIpAddress(request); if (!AGENT_IP.containsKey(ipAddress)) {
VisitorRecordDO visitorRecordDO = new VisitorRecordDO(); String region = getIpRegion(ipAddress, xdbPath); AGENT_IP.put(ipAddress, region);
Date date = new Date(); visitorRecordDO.setVisitor("agent"); visitorRecordDO.setIpAddress(ipAddress); visitorRecordDO.setIpRegion(region); visitorRecordDO.setVisitTime(date); visitorRecordDO.setIsNotify(0); visitorMapper.insert(visitorRecordDO); log.info("ipAddress: {}, Region: {}", ipAddress, region);
} }
@Scheduled(cron = TASK_SCHEDULE) private void scheduledClearTask() { AGENT_IP.clear(); }
@After("apiOperationLog()") public void doAfter() throws Throwable { }
@Around("apiOperationLog()") public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { long startTime = System.currentTimeMillis(); Object result = proceedingJoinPoint.proceed(); long timeConsuming = System.currentTimeMillis() - startTime; String methodDescription = getAspectLogDescription(proceedingJoinPoint);
log.info("========== 请求结束: [{}], 耗时: {}ms, 出参: {} =================================== {}", methodDescription, timeConsuming, new ObjectMapper().writeValueAsString(result), LINE_SEPARATOR); return result; }
public String getAspectLogDescription(JoinPoint joinPoint) throws Exception { String targetName = joinPoint.getTarget().getClass().getName(); String methodName = joinPoint.getSignature().getName(); Object[] arguments = joinPoint.getArgs(); Class targetClass = Class.forName(targetName); Method[] methods = targetClass.getMethods(); StringBuilder description = new StringBuilder(""); for (Method method : methods) { if (method.getName().equals(methodName)) { Class[] clazzs = method.getParameterTypes(); if (clazzs.length == arguments.length) { description.append(method.getAnnotation(ApiOperationLog.class).description()); break; } } } return description.toString(); }
private String toJson(JoinPoint joinPoint) throws JsonProcessingException { ObjectMapper mapper = new ObjectMapper(); return mapper.writeValueAsString(joinPoint.getArgs()); }
}
|
添加包扫描
在启动类 WeblogWebApplication 中,手动添加包扫描 @ComponentScan , 指定扫描 com.wind.weblog 包下面的所有类:
5. 测试一下
运行后的执行效果
