【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() 调用原始方法。

相关推荐
书源丶17 分钟前
三十六、File 类与 IO 流基础——文件操作的「第一步」
java
刀法如飞22 分钟前
Go数组去重的20种实现方式,AI时代解决问题的不同思路
后端·算法·go
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题】【Java基础篇】第30题:JDK动态代理和CGLIB动态代理有什么区别
java·开发语言·后端·面试·代理模式
swipe1 小时前
别再把 AI 聊天做成纯文本:从 agui 这个前后端项目,拆解“可感知工具调用”的流式 AI UI
后端·langchain·llm
GetcharZp1 小时前
GitHub 爆火!纯 Go 编写的文件同步神器 Syncthing,凭什么成为程序员的标配?
后端
hERS EOUS1 小时前
SpringBoot 使用 spring.profiles.active 来区分不同环境配置
spring boot·后端·spring
DFT计算杂谈1 小时前
wannier90 参数详解大全
java·前端·css·html·css3
LucianaiB1 小时前
我用飞书多维表做了一个 AI 活动推荐智能体:每天自动催我别错过截止日期!
后端
marsh02061 小时前
43 openclaw熔断与降级:保障系统在异常情况下的可用性
java·运维·网络·ai·编程·技术
张健11564096481 小时前
临界区和同一线程上锁
java·开发语言·jvm