【SpringBoot】SpringBoot中使用AOP实现日志记录功能

前言

在开发企业级应用时,完善的日志记录系统对于问题排查、系统监控和用户行为分析都至关重要。传统的日志记录方式往往需要在每个方法中手动添加日志代码,这不仅增加了代码量,也使得业务逻辑与日志记录代码耦合在一起。Spring

AOP(面向切面编程)为我们提供了一种优雅的解决方案,可以无侵入式地实现日志记录功能。

本文将详细介绍如何在SpringBoot项目中利用AOP实现统一的日志记录功能。

一、AOP基本概念

在开始实现之前,我们先了解几个AOP的核心概念:

  • 切面(Aspect):横切关注点的模块化,如日志记录就是一个切面

  • 连接点(Joinpoint):程序执行过程中的某个特定点,如方法调用或异常抛出

  • 通知(Advice):在切面的某个连接点上执行的动作

  • 切入点(Pointcut):匹配连接点的谓词,用于确定哪些连接点需要执行通知

  • 目标对象(Target Object):被一个或多个切面通知的对象

二、项目准备

需要创建一个SpringBoot项目,添加以下依赖:

java 复制代码
<dependencies>
    <!-- Spring Boot Starter Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Spring Boot Starter AOP -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
    
    <!-- Lombok 简化代码 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

三、实现日志记录切面

1、创建自定义日志注解

java 复制代码
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
    /**
     * 操作名称
     * @return
     */
    String operation() default "";
 
    /**
     * 操作的类型
     * @return
     */
    BusinessType businessType() default BusinessType.OTHER;
}

2、实现日志切面

创建一个切面类,实现日志切面功能。

java 复制代码
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Arrays;

@Aspect
@Component
public class LoggingAspect {
    private static final Logger logger = LoggerFactory.getLogger(LoggingAspect.class);

    /**
     * 定义切入点:所有带有@Loggable注解的方法
     */
    @Pointcut("@annotation(com.yourpackage.Loggable)")
    public void loggableMethods() {}

    /**
     * 环绕通知:记录方法执行前后的日志
     */
    @Around("loggableMethods()")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Loggable loggable = method.getAnnotation(Loggable.class);
        
        String methodName = joinPoint.getTarget().getClass().getName() + "." + method.getName();
        
        // 记录方法开始日志
        if (loggable.recordParams()) {
            logger.info("===> 开始执行 {},参数: {}", methodName, Arrays.toString(joinPoint.getArgs()));
        } else {
            logger.info("===> 开始执行 {}", methodName);
        }
        
        long startTime = System.currentTimeMillis();
        
        try {
            // 执行目标方法
            Object result = joinPoint.proceed();
            
            // 记录方法结束日志
            long elapsedTime = System.currentTimeMillis() - startTime;
            
            if (loggable.recordResult()) {
                logger.info("<=== 执行完成 {},耗时: {}ms,结果: {}", methodName, elapsedTime, result);
            } else {
                logger.info("<=== 执行完成 {},耗时: {}ms", methodName, elapsedTime);
            }
            
            return result;
        } catch (Exception e) {
            // 记录异常日志
            long elapsedTime = System.currentTimeMillis() - startTime;
            logger.error("<=== 执行异常 {},耗时: {}ms,异常: {}", methodName, elapsedTime, e.getMessage(), e);
            throw e;
        }
    }

    /**
     * 对Controller层的方法进行日志记录
     */
    @Pointcut("execution(* com.yourpackage.controller..*.*(..))")
    public void controllerLog() {}

    @Before("controllerLog()")
    public void doBefore(JoinPoint joinPoint) {
        // 获取请求信息
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes == null) {
            return;
        }
        
        HttpServletRequest request = attributes.getRequest();
        
        // 记录请求信息
        logger.info("==============================请求开始==============================");
        logger.info("请求URL: {}", request.getRequestURL().toString());
        logger.info("HTTP方法: {}", request.getMethod());
        logger.info("IP地址: {}", request.getRemoteAddr());
        logger.info("类方法: {}.{}", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName());
        logger.info("请求参数: {}", Arrays.toString(joinPoint.getArgs()));
    }

    @AfterReturning(returning = "result", pointcut = "controllerLog()")
    public void doAfterReturning(Object result) {
        logger.info("返回结果: {}", result);
        logger.info("==============================请求结束==============================");
    }
}

3、配置AOP

确保SpringBoot应用启用了AOP支持(默认就是启用的),如果需要自定义配置,可以在application.properties中添加。

java 复制代码
# AOP配置
spring.aop.auto=true
spring.aop.proxy-target-class=true

四、使用示例

1. 在Controller中使用

java 复制代码
@RestController
@RequestMapping("/api/user")
public class UserController {
    
    @GetMapping("/{id}")
    @Loggable("根据ID获取用户信息")
    public User getUser(@PathVariable Long id) {
        // 业务逻辑
        return userService.getUserById(id);
    }
    
    @PostMapping
    @Loggable(value = "创建新用户", recordParams = false)
    public User createUser(@RequestBody User user) {
        // 业务逻辑
        return userService.createUser(user);
    }
}

2. 在Service中使用

java 复制代码
@Service
public class UserService {
    
    @Loggable("根据ID查询用户")
    public User getUserById(Long id) {
        // 业务逻辑
    }
    
    @Loggable(value = "创建用户", recordResult = false)
    public User createUser(User user) {
        // 业务逻辑
    }
}

六、高级配置

1. 日志内容格式化

我们可以创建一个工具类来美化日志输出:

java 复制代码
public class LogFormatUtils {
    
    public static String formatMethodCall(String className, String methodName, Object[] args) {
        StringBuilder sb = new StringBuilder();
        sb.append(className).append(".").append(methodName).append("(");
        
        if (args != null && args.length > 0) {
            for (int i = 0; i < args.length; i++) {
                if (i > 0) {
                    sb.append(", ");
                }
                sb.append(formatArg(args[i]));
            }
        }
        
        sb.append(")");
        return sb.toString();
    }
    
    private static String formatArg(Object arg) {
        if (arg == null) {
            return "null";
        }
        
        // 对于基本类型和字符串直接返回
        if (arg instanceof Number || arg instanceof Boolean || arg instanceof Character || 
            arg instanceof String) {
            return arg.toString();
        }
        
        // 对于集合和数组,只显示大小
        if (arg.getClass().isArray()) {
            return "array[" + Array.getLength(arg) + "]";
        }
        if (arg instanceof Collection) {
            return "collection[" + ((Collection<?>) arg).size() + "]";
        }
        if (arg instanceof Map) {
            return "map[" + ((Map<?, ?>) arg).size() + "]";
        }
        
        // 其他复杂对象只显示类名
        return arg.getClass().getSimpleName();
    }
}

然后在切面中使用:

java 复制代码
@Around("loggableMethods()")
public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    String methodCall = LogFormatUtils.formatMethodCall(
        joinPoint.getTarget().getClass().getSimpleName(),
        signature.getName(),
        joinPoint.getArgs()
    );
    
    logger.info("===> 调用: {}", methodCall);
    // ... 其他逻辑
}

2. 异步日志记录

对于一些并发场景,可以考虑异步记录日志以减少性能影响:

java 复制代码
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("AsyncLogger-");
        executor.initialize();
        return executor;
    }
}

// 然后在切面方法上添加@Async注解
@Async
@Around("loggableMethods()")
public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
    // 日志记录逻辑
}

3. 日志脱敏处理

对于敏感信息如手机号、身份证号等,需要进行脱敏处理:

java 复制代码
public class SensitiveInfoUtils {
    
    private static final String PHONE_REGEX = "(\\d{3})\\d{4}(\\d{4})";
    private static final String ID_CARD_REGEX = "(\\d{4})\\d{10}(\\w{4})";
    
    public static String desensitize(Object arg) {
        if (arg == null) {
            return null;
        }
        
        String str = arg.toString();
        
        // 手机号脱敏
        if (str.matches("\\d{11}")) {
            return str.replaceAll(PHONE_REGEX, "$1****$2");
        }
        
        // 身份证号脱敏
        if (str.matches("\\d{18}|\\d{17}[xX]")) {
            return str.replaceAll(ID_CARD_REGEX, "$1**********$2");
        }
        
        // 其他敏感信息处理...
        
        return str;
    }
}

// 在LogFormatUtils中使用
private static String formatArg(Object arg) {
    // ... 其他逻辑
    return SensitiveInfoUtils.desensitize(arg);
}

七、代理类生成的核心逻辑

问题1: 既然是代理类,那么像@Transaction 注解标注在方法上怎么搞?给方法生成代理类?(显然不是)

@Transactional 虽然通常标注在方法上,但 Spring 的代理生成策略会综合考虑 类级别和方法级别 的注解。

以下是其完整工作原理:

Spring 处理 @Transactional 时,代理生成分为两步:

步骤 1:扫描 Bean 的代理需求

  • 类级别检查:Spring 在创建 Bean 时,会检查 类或父类 是否有 @Transactional 注解(类级别注解会影响所有方法)。

  • 方法级别检查:如果类未被注解,则扫描所有 公有方法(public),发现任意方法有 @Transactional 时,整个类会被代理

步骤 2:生成代理对象

  • JDK 动态代理:如果类实现了接口,默认使用 JDK 代理(基于 InvocationHandler)。

  • CGLIB 代理:如果类未实现接口,则生成子类代理(通过字节码增强)。

问题2:自定义的注解@LogExecutionTime,也会生成代理类吗?

自定义的注解 @LogExecutionTime 是否会生成代理类,取决于如何实现这个注解的功能 。如果通过 Spring AOP 来实现 @LogExecutionTime 的功能(例如记录方法执行时间),那么确实会生成代理类。比如下面的代码

java 复制代码
//自定义一个注解
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME) // 注解在运行时保留
@Target(ElementType.METHOD) // 注解可以应用于方法
public @interface LogExecutionTime {
}


//定义切面类,使用 @Aspect 注解标记,并在其中定义切点(Pointcut)和通知(Advice)。
//切点通过 @annotation(LogExecutionTime) 匹配所有带有 @LogExecutionTime 注解的方法。
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect // 声明这是一个切面类
@Component // 确保切面类被 Spring 容器管理
public class LogExecutionTimeAspect {

    // 定义环绕通知,匹配所有带有 @LogExecutionTime 注解的方法
    @Around("@annotation(LogExecutionTime)")
    public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis(); // 记录方法开始执行时间

        // 执行目标方法
        Object result = joinPoint.proceed();

        long executionTime = System.currentTimeMillis() - startTime; // 计算方法执行时间

        // 输出日志
        System.out.println("Method " + joinPoint.getSignature().getName() + 
                          " executed in " + executionTime + "ms");

        return result; // 返回目标方法的执行结果
    }
}


//在业务方法上面使用这个自定义注解
@Service
public class MyService {

    @LogExecutionTime // 标记需要记录执行时间的方法
    public void myBusinessMethod() {
        // 模拟业务逻辑
        try {
            Thread.sleep(1000); // 模拟耗时操作
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Business logic executed.");
    }
}

那么生成代理类的流程是这样的,

  1. 切面类注册:Spring 扫描到 @Aspect 标注的类(如 LogExecutionTimeAspect ngAspect),将其注册为 Bean,并识别其中的切点和通知。(切面类不生成代理类)
  2. 接着根据切点,再扫描符合条件的bean,**@annotation(LogExecutionTime)**所有带有 @LogExecutionTime 注解的方法所在的类对应的bean都会生成代理类。

八、具体执行步骤

给符合条件的bean,生成代理类之后,就要开始执行了,由于不确定是哪一个方法加了@Transactional这种所谓的aop注解,所以代理类,会对每一个方法进行检查。具体步骤如下:

步骤 1:调用代理对象的方法

java 复制代码
// 这里实际调用的是代理对象的方法,代理对象会去调用目标对象的原方法
userService.getUserById(1L);

步骤 2:代理对象检查方法是否匹配切点

检查目标方法:代理对象会检查 UserService.getUserById() 是否匹配切面定义的切点(即是否有 @LogExecutionTime 注解)。

java 复制代码
// 伪代码:Spring 的切点匹配逻辑
if (方法有 @LogExecutionTime 注解 || 类有 @LogExecutionTime 注解) {
    将该方法加入拦截链;
}

步骤 3:执行拦截链

如果方法匹配切点,代理对象会:

找到所有匹配的 Advice(如 @Around 通知)。

按顺序执行通知链(如先执行 @Before,再执行 @Around)。

在 @Around 中,通过 ProceedingJoinPoint.proceed() 调用原始方法。

相关推荐
yyst_Serendipity1 分钟前
【hot100】bug指南记录1
java·bug
qq_3660862222 分钟前
hashMap一些不太常用但非常有用的方法及使用示例
java·开发语言
A~taoker31 分钟前
认识tomcat(了解)
java·tomcat
ABCDEEE732 分钟前
民宿管理系统6
java
天黑请闭眼35 分钟前
ShardingSphere:使用information_schema查询时报错:Table ‘数据库名称.tables‘ doesn‘t exist
java
请来次降维打击!!!41 分钟前
优选算法系列(8.多源BFS)
java·c++·算法·宽度优先
TextIn智能文档云平台44 分钟前
TextIn ParseX重磅功能更新:支持切换公式输出形式、表格解析优化、新增电子档PDF去印章
java·图像处理·人工智能·算法·自然语言处理·pdf·ocr
曾经的三心草1 小时前
RabbitMQ-springboot开发-应用通信
spring boot·rabbitmq·springboot·java-rabbitmq·应用通信
forestsea1 小时前
深入理解Java三大特性:封装、继承和多态
java·开发语言
阿达King哥2 小时前
JVM局部变量表和操作数栈的内存布局
java·jvm