Spring Boot中如何使用AOP统一记录接口调用耗时与异常

Spring Boot AOP统一记录接口调用耗时与异常

大家好,今天我们来聊聊Spring Boot中如何利用AOP(面向切面编程)统一记录接口调用耗时与异常。在实际项目中,监控接口性能和异常情况至关重要,可以帮助我们快速定位问题、优化性能。AOP提供了一种优雅的方式来实现这种横切关注点,避免代码重复,提高可维护性。

1. 为什么选择AOP?

传统的接口调用耗时和异常记录方式通常是在每个Controller方法中手动添加代码,这样做存在以下问题:

  • 代码冗余: 每个方法都要重复编写相同的记录逻辑。
  • 侵入性强: 代码与业务逻辑耦合,修改记录逻辑需要修改业务代码。
  • 维护困难: 当需要修改或增加记录逻辑时,需要修改所有相关方法。

AOP可以将这些横切关注点从业务逻辑中分离出来,通过配置方式将它们织入到目标方法中,从而解决以上问题。

2. AOP的基本概念

在深入代码之前,我们需要了解AOP的一些基本概念:

  • Aspect(切面): 一个模块化的横切关注点,例如日志记录、性能监控等。它包含Advice和Pointcut。
  • Join Point(连接点): 程序执行过程中可以插入Advice的点,例如方法调用、异常抛出等。
  • Advice(通知): 在特定的Join Point执行的动作。常见的Advice类型包括:
    • @Before:在Join Point之前执行。
    • @After:在Join Point之后执行(无论是否发生异常)。
    • @AfterReturning:在Join Point成功返回后执行。
    • @AfterThrowing:在Join Point抛出异常后执行。
    • @Around:包围Join Point,可以控制Join Point的执行时机和返回值。
  • Pointcut(切点): 一个表达式,用于指定哪些Join Point应该被Advice拦截。
  • Weaving(织入): 将Aspect应用到目标对象并创建代理对象的过程。

3. 项目搭建与依赖引入

首先,创建一个Spring Boot项目,并在pom.xml文件中引入AOP相关的依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

<!-- 引入slf4j的api和logback的实现,用于日志打印 -->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>1.7.32</version> <!-- 请使用最新的版本 -->
</dependency>

<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.6</version> <!-- 请使用最新的版本 -->
    <scope>runtime</scope>
</dependency>
  • spring-boot-starter-web:提供Web开发所需的依赖。
  • spring-boot-starter-aop:提供AOP支持。
  • lombok:简化JavaBean的开发,自动生成getter、setter等方法。
  • slf4j-apilogback-classic:用于日志记录。

4. 创建Controller示例

为了演示,我们创建一个简单的Controller,模拟一些接口:

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Slf4j // 使用Lombok的Slf4j注解,自动生成log对象
public class DemoController {

    @GetMapping("/hello/{name}")
    public String hello(@PathVariable String name) throws InterruptedException {
        Thread.sleep(100); // 模拟耗时
        log.info("Executing hello method with name: {}", name);
        return "Hello " + name;
    }

    @GetMapping("/error")
    public String error() {
        throw new RuntimeException("Simulated error");
    }

    @GetMapping("/slow")
    public String slow() throws InterruptedException {
        Thread.sleep(500); // 模拟较长的耗时
        return "Slow response";
    }
}

这个Controller包含三个接口:

  • /hello/{name}:一个简单的问候接口,模拟100ms的耗时。
  • /error:一个会抛出RuntimeException的接口。
  • /slow:一个模拟500ms耗时的接口。

5. 创建Aspect类

接下来,我们创建一个Aspect类,用于记录接口调用耗时和异常:

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
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.util.Arrays;

@Aspect
@Component
@Slf4j
public class LogAspect {

    /**
     * 定义切点: 拦截所有Controller层的方法
     */
    @Pointcut("execution(* com.example.demo.controller.*.*(..))") // 修改为你的Controller包路径
    public void controllerPointcut() {
    }

    @Around("controllerPointcut()")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();

        String url = request.getRequestURL().toString();
        String httpMethod = request.getMethod();
        String classMethod = joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint.getSignature().getName();
        Object[] args = joinPoint.getArgs();

        log.info("Request URL: {}, HTTP Method: {}, Class Method: {}, Args: {}", url, httpMethod, classMethod, Arrays.toString(args));

        long startTime = System.currentTimeMillis();
        Object result;
        try {
            result = joinPoint.proceed();
            long endTime = System.currentTimeMillis();
            long elapsedTime = endTime - startTime;
            log.info("Response: {}, Time taken: {}ms", result, elapsedTime);
            return result;
        } catch (Throwable e) {
            long endTime = System.currentTimeMillis();
            long elapsedTime = endTime - startTime;
            log.error("Exception: {}, Time taken: {}ms", e.getMessage(), elapsedTime, e);
            throw e; // 重新抛出异常,保证异常能够被Controller层捕获并处理
        }
    }
}

这个Aspect类做了以下事情:

  • @Aspect:声明这是一个切面。
  • @Component:将这个类注册为Spring Bean。
  • @Slf4j:使用Lombok的Slf4j注解,自动生成log对象。
  • @Pointcut("execution(* com.example.demo.controller.*.*(..))"):定义切点,拦截com.example.demo.controller包下所有类的所有方法。请务必根据你的实际Controller包路径修改此处的表达式。
  • @Around("controllerPointcut()"):定义环绕通知,包围切点对应的方法。
  • logAround(ProceedingJoinPoint joinPoint):通知的具体实现,它做了以下事情:
    • 获取HttpServletRequest,记录请求URL、HTTP方法、类名、方法名和参数。
    • 记录开始时间。
    • 执行目标方法 joinPoint.proceed()
    • 记录结束时间,计算耗时。
    • 如果目标方法没有抛出异常,记录返回结果和耗时。
    • 如果目标方法抛出异常,记录异常信息和耗时,并重新抛出异常,确保异常能够被Controller层捕获并处理。

6. AOP配置

Spring Boot默认开启AOP支持,无需额外配置。如果你的项目没有开启AOP,可以在启动类上添加@EnableAspectJAutoProxy注解。

7. 测试

启动Spring Boot应用,访问以下接口:

  • http://localhost:8080/hello/World
  • http://localhost:8080/error
  • http://localhost:8080/slow

观察控制台输出的日志,可以看到接口的调用耗时和异常信息。

8. 增强切点表达式

上面的例子中,切点表达式拦截了所有Controller的方法,这可能不是我们想要的。我们可以使用更精确的切点表达式来拦截特定的方法。

  • 根据注解拦截: 我们可以自定义一个注解,然后使用切点表达式拦截带有该注解的方法。

    首先,定义一个注解:

    import java.lang.annotation.ElementType;
    import java.lang.annotation.Retention;
    import java.lang.annotation.RetentionPolicy;
    import java.lang.annotation.Target;
    
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface Loggable {
    }

    然后,在需要记录日志的方法上添加该注解:

    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    public class DemoController {
    
        @GetMapping("/hello")
        @Loggable
        public String hello() {
            return "Hello";
        }
    
        @GetMapping("/world")
        public String world() {
            return "World";
        }
    }

    最后,修改切点表达式:

    @Pointcut("@annotation(com.example.demo.annotation.Loggable)")
    public void loggablePointcut() {
    }
    
    @Around("loggablePointcut()")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        // ...
    }

    现在,只有带有@Loggable注解的方法才会被拦截。

  • 根据方法签名拦截: 我们可以使用切点表达式根据方法签名拦截特定的方法。例如,只拦截参数类型为String的方法:

    @Pointcut("execution(* com.example.demo.controller.*.*(String))")
    public void stringArgumentPointcut() {
    }
    
    @Around("stringArgumentPointcut()")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        // ...
    }

9. 更详细的日志信息

除了记录耗时和异常信息,我们还可以记录更详细的日志信息,例如:

  • 请求参数: 已经记录了,但可以格式化输出。
  • 请求头: 可以记录一些重要的请求头,例如User-Agent、Content-Type等。
  • Session信息: 如果使用了Session,可以记录Session ID等信息。
  • 用户信息: 如果用户已经登录,可以记录用户ID、用户名等信息。
  • 返回值类型和内容: 记录更详细的返回值信息,方便排查问题。

以下代码展示了如何记录更详细的请求头信息:

import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.util.Enumeration;

// 在logAround方法中添加以下代码
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();

Enumeration<String> headerNames = request.getHeaderNames();
StringBuilder headers = new StringBuilder();
while (headerNames.hasMoreElements()) {
    String headerName = headerNames.nextElement();
    String headerValue = request.getHeader(headerName);
    headers.append(headerName).append(": ").append(headerValue).append(", ");
}

log.info("Request Headers: {}", headers.toString());

10. 异常处理增强

除了记录异常信息,我们还可以对异常进行更进一步的处理,例如:

  • 自定义异常转换: 将底层异常转换为业务异常,方便上层处理。
  • 发送告警: 当发生特定类型的异常时,发送告警邮件或短信。
  • 记录异常堆栈: 完整记录异常堆栈信息,方便排查问题。

以下代码展示了如何自定义异常转换:

import org.springframework.dao.DataAccessException;

// 在logAround方法中修改catch块
catch (Throwable e) {
    long endTime = System.currentTimeMillis();
    long elapsedTime = endTime - startTime;
    log.error("Exception: {}, Time taken: {}ms", e.getMessage(), elapsedTime, e);

    if (e instanceof DataAccessException) {
        // 将数据库访问异常转换为业务异常
        throw new BusinessException("Database error", e);
    }

    throw e; // 重新抛出异常
}

11. 性能优化注意事项

虽然AOP提供了便利性,但过度使用AOP可能会影响性能。以下是一些性能优化建议:

  • 精简切点表达式: 避免使用过于宽泛的切点表达式,只拦截需要监控的方法。
  • 避免在Advice中执行耗时操作: 尽量将耗时操作异步执行,例如使用线程池。
  • 合理选择Advice类型: @Around通知功能强大,但性能损耗也最大,尽量使用@Before@After等更轻量级的通知。
  • 避免循环依赖: AOP可能会导致循环依赖问题,需要仔细设计Aspect和Bean之间的关系。

12. 代码示例:完整的LogAspect类

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
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.util.Arrays;
import java.util.Enumeration;

@Aspect
@Component
@Slf4j
public class LogAspect {

    /**
     * 定义切点: 拦截所有Controller层的方法
     */
    @Pointcut("execution(* com.example.demo.controller.*.*(..))") // 修改为你的Controller包路径
    public void controllerPointcut() {
    }

    @Around("controllerPointcut()")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();

        String url = request.getRequestURL().toString();
        String httpMethod = request.getMethod();
        String classMethod = joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint.getSignature().getName();
        Object[] args = joinPoint.getArgs();

        // 记录请求头
        Enumeration<String> headerNames = request.getHeaderNames();
        StringBuilder headers = new StringBuilder();
        while (headerNames.hasMoreElements()) {
            String headerName = headerNames.nextElement();
            String headerValue = request.getHeader(headerName);
            headers.append(headerName).append(": ").append(headerValue).append(", ");
        }

        log.info("Request URL: {}, HTTP Method: {}, Class Method: {}, Args: {}, Headers: {}", url, httpMethod, classMethod, Arrays.toString(args), headers.toString());

        long startTime = System.currentTimeMillis();
        Object result;
        try {
            result = joinPoint.proceed();
            long endTime = System.currentTimeMillis();
            long elapsedTime = endTime - startTime;
            log.info("Response: {}, Time taken: {}ms", result, elapsedTime);
            return result;
        } catch (Throwable e) {
            long endTime = System.currentTimeMillis();
            long elapsedTime = endTime - startTime;
            log.error("Exception: {}, Time taken: {}ms", e.getMessage(), elapsedTime, e);
            throw e; // 重新抛出异常,保证异常能够被Controller层捕获并处理
        }
    }
}

总结:高效监控,清晰日志,优化性能

通过AOP,我们可以优雅地实现接口调用耗时和异常的统一记录,避免代码冗余,提高可维护性。同时,需要注意性能优化,选择合适的切点表达式和Advice类型,避免过度使用AOP。清晰的日志信息可以帮助我们快速定位问题,优化性能。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注