在Spring Boot项目里,API请求日志通常用于追踪调用链路、记录参数和响应、统计接口耗时。传统做法是在每个Controller方法中手动拼接日志,一旦接口数量增多,重复代码会大量出现,参数遗漏和格式不统一也难以避免。AOP(面向切面编程)可以把这类横切逻辑集中到一个切面中,对Controller层方法进行统一拦截,业务代码不需要任何改动。

为什么用AOP而不是拦截器或过滤器
过滤器和拦截器本质上都能在请求进入Controller之前执行一段逻辑。过滤器工作在Servlet容器层,拦截器工作在Spring MVC层,它们适合做登录校验、字符编码、跨域处理等与HTTP请求直接相关的任务。但如果要记录Controller方法接收到的业务参数、返回结果以及方法执行耗时,就需要从HandlerMethod中反射获取参数名和值,代码相对繁琐,而且对方法内部的异常捕获也不够自然。
AOP的切入点可以直接定位到Controller的public方法,环绕通知天然包含方法执行前、执行后和抛出异常三个时机。结合ProceedingJoinPoint可以拿到方法签名、参数数组,还能控制方法是否继续执行。日志逻辑集中在切面类中后,Controller代码保持干净,后续调整日志格式或增加脱敏规则也不需要改动业务方法。
不过AOP并非全场景适用。它只能拦截Spring容器中Bean的方法调用,静态方法、私有方法以及同类内部this调用不会经过代理。遇到这些情况时,可以把需要拦截的逻辑拆成独立Bean,或者结合过滤器补充HTTP入口日志。实际项目中常用过滤器记录请求URI和耗时,再用AOP记录更细粒度的业务入参和返回值,形成完整链路。
从依赖到切面代码的完整实战
先确认项目已经引入Spring Boot Web,再单独添加AOP起步依赖。Maven配置如下。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
日志实体类用于承载要输出的字段。设计时建议保留请求路径、HTTP方法、客户端IP、方法签名、请求参数、响应结果、异常信息和耗时,后续如果要接入ELK等日志平台,结构化字段会比拼接字符串更好解析。
public class ApiLog {
private String uri;
private String httpMethod;
private String clientIp;
private String classMethod;
private Object requestArgs;
private Object response;
private String errorMessage;
private long costTime;
public String getUri() { return uri; }
public void setUri(String uri) { this.uri = uri; }
public String getHttpMethod() { return httpMethod; }
public void setHttpMethod(String httpMethod) { this.httpMethod = httpMethod; }
public String getClientIp() { return clientIp; }
public void setClientIp(String clientIp) { this.clientIp = clientIp; }
public String getClassMethod() { return classMethod; }
public void setClassMethod(String classMethod) { this.classMethod = classMethod; }
public Object getRequestArgs() { return requestArgs; }
public void setRequestArgs(Object requestArgs) { this.requestArgs = requestArgs; }
public Object getResponse() { return response; }
public void setResponse(Object response) { this.response = response; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public long getCostTime() { return costTime; }
public void setCostTime(long costTime) { this.costTime = costTime; }
@Override
public String toString() {
return "ApiLog{" +
"uri='" + uri + '\'' +
", httpMethod='" + httpMethod + '\'' +
", clientIp='" + clientIp + '\'' +
", classMethod='" + classMethod + '\'' +
", requestArgs=" + requestArgs +
", response=" + response +
", errorMessage='" + errorMessage + '\'' +
", costTime=" + costTime +
'}';
}
}
切面类的核心是@Around环绕通知。切点表达式建议收窄到controller包,避免把Service和Repository方法也拦截进来,否则接口稍微一调用,日志量就会成倍增加。下面的代码在方法执行前记录开始时间,随后从RequestContextHolder中获取当前HTTP请求信息,再执行原方法,最后在finally中统一输出日志。
@Aspect
@Component
public class ApiLogAspect {
private static final Logger log = LoggerFactory.getLogger(ApiLogAspect.class);
@Around("execution(* com.example.demo.controller..*.*(..))")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
ApiLog apiLog = new ApiLog();
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
apiLog.setUri(request.getRequestURI());
apiLog.setHttpMethod(request.getMethod());
apiLog.setClientIp(getClientIp(request));
}
apiLog.setClassMethod(joinPoint.getSignature().toShortString());
apiLog.setRequestArgs(joinPoint.getArgs());
try {
Object result = joinPoint.proceed();
apiLog.setResponse(result);
return result;
} catch (Throwable e) {
apiLog.setErrorMessage(e.getMessage());
throw e;
} finally {
apiLog.setCostTime(System.currentTimeMillis() - start);
log.info("api_log: {}", apiLog);
}
}
private String getClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isEmpty()) {
return xff.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
这里在catch块中记录异常信息后继续抛出,是为了不改变接口原有的异常处理流程。环绕通知必须把joinPoint.proceed()的返回值原样返回给调用方,否则接口响应体会丢失。如果希望统一处理异常并返回固定结构,也可以在catch块中构造统一返回对象,但要注意不要吞掉异常导致上层无法感知。
客户端IP的获取不能只依赖request.getRemoteAddr()。当服务前面有Nginx或API网关时,getRemoteAddr()拿到的是代理IP。可以先读取X-Forwarded-For头,取第一个IP,再回退到RemoteAddr。不过这个头可以被客户端伪造,生产环境应当配合网关配置,只信任来自固定代理的头信息。
日志脱敏和异步输出优化
直接记录joinPoint.getArgs()可能把用户密码、身份证号、手机号等敏感数据写进日志文件,带来合规风险。比较稳妥的做法是自定义敏感字段注解,配合Jackson序列化时对标记字段做掩码处理。如果不想引入复杂序列化逻辑,也可以在切面中先判断参数类型,只记录基本类型、字符串和自定义DTO的摘要,或者对字符串中的手机号、银行卡号进行正则替换。
日志输出本身也会消耗IO资源。API请求日志属于高频追加操作,同步写文件在高并发场景下会放大响应时间。建议将日志框架的Appender配置为异步模式,例如logback的AsyncAppender或log4j2的AsyncLogger。切面中只负责组装日志对象,不直接做文件写入,实际输出交给后台线程处理。
响应体过大时也需要截断。如果Controller返回的是大列表或复杂嵌套对象,toString可能非常长。可以在日志切面中判断响应对象的字符串长度,超过阈值只记录前若干字符,并标注已截断。不同环境可以通过配置项控制是否输出完整参数,开发环境便于调试,生产环境则侧重性能和隐私。
常见问题与注意事项
切面不生效通常和Bean扫描有关。切面类要放在主启动类所在包或其子包下,并标注@Component。如果使用自定义切点注解,还要确认注解的保留策略和切入点表达式书写正确。Spring Boot自动配置已经启用AOP代理,一般不需要再手动写@EnableAspectJAutoProxy,但如果项目里引入了多套AOP配置,需要检查代理模式和切面注册顺序。
同类内部方法调用不会触发AOP是比较容易踩的坑。Controller的methodA直接调用methodB时,这种调用是this调用,不会经过Spring生成的代理对象,所以切面逻辑不会执行。解决方法可以拆到另一个Bean,也可以通过AopContext.currentProxy()获取代理对象再调用,不过第二种方式会让代码与Spring耦合,建议优先拆分职责。
在异步线程或定时任务中访问RequestContextHolder可能得到null,因为请求上下文只绑定在Web请求线程上。遇到这种情况需要先判空,只记录方法名和耗时。文件上传接口的MultipartFile参数也不能直接写入日志,否则可能输出二进制内容或触发序列化异常。最后要控制切点范围,排除健康检查、静态资源和探活接口,避免关键日志被无用信息淹没。
Spring Boot AOPAPI请求日志切面编程修改时间:2026-10-06 16:26:46