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-api和logback-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/Worldhttp://localhost:8080/errorhttp://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。清晰的日志信息可以帮助我们快速定位问题,优化性能。