Spring Boot 集成 AOP 实现日志记录与接口权限校验
摘要:想象一栋写字楼,如果每个房间都自己配锁、拉监控,既费钱又容易漏;更聪明的做法是装一套统一的门禁和中控,访客一刷卡,全楼的安全和记录都被接管。AOP 就是这套"中央管家"------把日志与权限从每个接口中抽离,统一织入,既轻量又可追溯。
1. 场景与概念对照
- 痛点:每个接口都写日志与权限,像每个房间各自装锁和摄像头,重复又易漏。
- AOP 角色类比:切面=管家,通知=动作(餐前消毒/餐后清洁),连接点=每次上菜瞬间,切入点=哪些桌子需要清洁。
- 概念与代码速查表:
- 切面 →
@Aspect类 - 通知 →
@Around/@Before/@AfterReturning - 连接点 → 目标方法调用
- 切入点 →
@Pointcut表达式
- 切面 →
2. 环境准备与版本差异
spring-boot-starter-aop 与 spring-boot-starter-web 示例使用 Spring Boot 2.7.15,代码同时兼容 1.5.x 和 3.x(3.x 需把 javax.servlet 换成 jakarta.servlet 包名即可,Boot 1.5.x 仍使用 javax.servlet,AOP 注解与用法保持一致):
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<version>2.7.15</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.7.15</version>
</dependency>
如果你在维护老项目(如 Spring Boot 1.5.x),只需把上面的 <version> 改成 1.5.x 系列(如 1.5.22.RELEASE),然后确保 JDK8 与 Spring AOP 版本匹配即可,切面与注解写法可直接复用;如果是新项目(Spring Boot 3.x+),将依赖版本升级到 3.x,同时把示例中的 javax.servlet.http.HttpServletRequest 改为 jakarta.servlet.http.HttpServletRequest 即可。
包结构建议:com.demo.aop.annotation / aspect / common / controller。
3. 注解定义(支持类与方法)
java
package com.demo.aop.annotation;
import java.lang.annotation.*;
// 简单日志级别枚举,用于控制切面日志输出级别
public enum LogLevel { TRACE, DEBUG, INFO, WARN, ERROR }
// 日志注解:可标在类或方法上,控制是否记录日志以及日志细节
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiLog {
// 业务含义描述,例如"查询订单""用户登录"
String value() default "";
// 是否忽略当前方法(即使类上加了 ApiLog)
boolean ignore() default false;
// 日志级别,默认为 INFO
LogLevel level() default LogLevel.INFO;
// 是否隐藏响应体(例如大对象或隐私数据)
boolean hideResp() default false;
}
// 权限注解:可标在类或方法上,声明允许访问的角色
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
// 支持多个角色,只要命中其中一个即可访问
String[] value();
}
类级别生效逻辑:切面中若方法未标注,回退检查类上的注解。
4. 通用返回体与异常
java
package com.demo.aop.common;
public class ApiResponse<T> {
// 业务状态码,0 表示成功,其他表示失败
private int code;
// 业务提示信息
private String message;
// 真实数据载体
private T data;
// 快捷构造成功返回
public static <T> ApiResponse<T> ok(T d){ return of(0,"OK",d); }
// 快捷构造失败返回
public static <T> ApiResponse<T> fail(String msg){ return of(-1,msg,null); }
private static <T> ApiResponse<T> of(int c,String m,T d){
ApiResponse<T> r=new ApiResponse<>(); r.code=c; r.message=m; r.data=d; return r;
}
}
// 自定义权限异常,统一由全局异常处理器拦截
public class PermissionDeniedException extends RuntimeException {
public PermissionDeniedException(String msg){ super(msg); }
}
全局异常处理(JSON 返回):
java
@RestControllerAdvice
public class GlobalExceptionHandler {
// 捕获权限异常,统一转成 ApiResponse JSON 返回
@ExceptionHandler(PermissionDeniedException.class)
public ApiResponse<Void> handlePermission(PermissionDeniedException e){
return ApiResponse.fail(e.getMessage());
}
}
5. 日志切面(含 NPE 防护、脱敏、TraceId)
java
package com.demo.aop.aspect;
import com.demo.aop.annotation.ApiLog;
import com.demo.aop.annotation.LogLevel;
import com.demo.aop.common.ApiResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.core.annotation.Order;
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.*;
@Aspect
@Component
@Order(2) // 权限优先,日志在后
public class ApiLogAspect {
// Slf4j 日志器
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ApiLogAspect.class);
// 统一 JSON 序列化
private final ObjectMapper mapper = new ObjectMapper();
// 切入点:匹配标了 @ApiLog 的方法或类
@Pointcut("@annotation(com.demo.aop.annotation.ApiLog) || @within(com.demo.aop.annotation.ApiLog)")
public void apiLogPointcut() {}
// 环绕通知:在目标方法前后插入日志逻辑
@Around("apiLogPointcut()")
public Object recordLog(ProceedingJoinPoint pjp) throws Throwable {
// 兼容方法级 / 类级注解
ApiLog ann = findAnnotation(pjp);
if (ann == null || ann.ignore()) { return pjp.proceed(); }
// 记录开始时间,用于计算耗时
long start = System.currentTimeMillis();
// 从请求上下文中获取 HttpServletRequest,非 Web 环境直接放行
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) { // 非 Web 环境兜底
return pjp.proceed();
}
HttpServletRequest req = attrs.getRequest();
// TraceId:从 header 取,没有则生成一个新的
String traceId = Optional.ofNullable(req.getHeader("X-Trace-Id"))
.orElse(UUID.randomUUID().toString());
// 基本请求信息
String url = req.getRequestURI();
String method = req.getMethod();
String user = Optional.ofNullable(req.getHeader("X-User")).orElse("anonymous");
// IP:优先使用 X-Forwarded-For 头(兼容网关 / 负载)
String ip = Optional.ofNullable(req.getHeader("X-Forwarded-For"))
.map(s -> s.split(",")[0].trim()).orElse(req.getRemoteAddr());
// 客户端标识
String ua = Optional.ofNullable(req.getHeader("User-Agent")).orElse("unknown");
// 请求参数 Map
Map<String, String[]> params = req.getParameterMap();
// 将参数转 JSON 并脱敏
String args = mask(toJsonSafe(params));
Object result = null; Throwable ex = null;
try {
// 继续执行真实业务方法
result = pjp.proceed();
return result;
} catch (Throwable t) {
// 记录异常并向上抛出
ex = t; throw t;
} finally {
long cost = System.currentTimeMillis() - start;
// 根据注解配置决定是否输出响应体
String resp = ann.hideResp() ? "<hidden>" : toJsonSafe(result);
// 按指定日志级别输出
logWithLevel(ann.level(), "[API-LOG] trace={} {} {} user={} ip={} ua={} cost={}ms args={} resp={} err={}",
traceId, method, url, user, ip, ua, cost, args, resp, ex == null ? "none" : ex.getMessage());
}
}
// 查找方法 / 类上的 ApiLog 注解
private ApiLog findAnnotation(ProceedingJoinPoint pjp) {
ApiLog methodAnn = org.springframework.core.annotation.AnnotationUtils
.findAnnotation(((org.aspectj.lang.reflect.MethodSignature) pjp.getSignature()).getMethod(), ApiLog.class);
ApiLog typeAnn = org.springframework.core.annotation.AnnotationUtils
.findAnnotation(pjp.getTarget().getClass(), ApiLog.class);
return methodAnn != null ? methodAnn : typeAnn;
}
// 安全 JSON 序列化,失败时给出占位字符串
private String toJsonSafe(Object obj){
try { return mapper.writeValueAsString(obj); }
catch (JsonProcessingException e){
log.warn("json serialize fail", e);
return "<json-error>";
}
}
// 简单脱敏:隐藏密码与手机号中间四位
private String mask(String json){
if (json == null) return null;
// password/pwd 字段统一替换为 ****
return json.replaceAll("(?i)(password|pwd)\":\"[^\"]+\"", "$1\":\"****\"")
// phone 字段保留前 3 位和后 4 位,中间打 ***
.replaceAll("(\"phone\"\\s*:\\s*\")\\d{3}\\d{4}(\\d{4}\")", "$1***$2");
}
// 根据注解上的日志级别动态选择输出方法
private void logWithLevel(LogLevel level, String msg, Object... args){
switch (level){
case TRACE: log.trace(msg, args); break;
case DEBUG: log.debug(msg, args); break;
case WARN: log.warn(msg, args); break;
case ERROR: log.error(msg, args); break;
default: log.info(msg, args);
}
}
}
要点:判空防 NPE;JSON 序列化异常兜底;脱敏密码/手机号;TraceId/IP/User-Agent 记录;支持 hideResp 与日志级别;类级别注解兼容。
6. 权限切面(多角色 + 自定义异常)
java
package com.demo.aop.aspect;
import com.demo.aop.annotation.RequireRole;
import com.demo.aop.common.PermissionDeniedException;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.core.annotation.Order;
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
@Order(1) // 先校验权限,再记录日志
public class AuthAspect {
@Pointcut("@annotation(com.demo.aop.annotation.RequireRole) || @within(com.demo.aop.annotation.RequireRole)")
public void authPointcut() {}
@Around("authPointcut() && @annotation(requireRole)")
public Object checkRole(ProceedingJoinPoint pjp, RequireRole requireRole) throws Throwable {
// 获取当前请求上下文,非 Web 环境直接略过权限校验
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) { return pjp.proceed(); } // 非 Web 环境直接放行
HttpServletRequest req = attrs.getRequest();
// 1) 从 Header 中读取角色(演示用,真实场景多为 JWT / Session)
String role = req.getHeader("X-Role");
// 2) JWT 极简示例(伪代码)
// String role = JwtUtil.parseRole(req.getHeader("Authorization"));
// 3) DB 极简示例(伪代码)
// List<String> roles = roleRepo.findRolesByUser(req.getHeader("X-User"));
// 只要当前角色命中注解声明的任一角色,就视为通过
boolean ok = role != null && Arrays.stream(requireRole.value())
.anyMatch(r -> r.equalsIgnoreCase(role));
// 未通过则抛出权限异常,由全局异常处理器统一返回 JSON
if (!ok) { throw new PermissionDeniedException("无访问权限,需角色: " + String.join("/", requireRole.value())); }
return pjp.proceed();
}
}
7. Controller 示例
java
package com.demo.aop.controller;
import com.demo.aop.annotation.ApiLog;
import com.demo.aop.annotation.LogLevel;
import com.demo.aop.annotation.RequireRole;
import com.demo.aop.common.ApiResponse;
import org.springframework.web.bind.annotation.*;
// 类级别加上 ApiLog:该 Controller 所有接口默认记录日志
@ApiLog(value="类级日志", level=LogLevel.DEBUG)
@RestController
@RequestMapping("/demo")
public class DemoController {
// 查询订单接口:单独指定日志描述,并要求 ADMIN/OPS 角色
@ApiLog(value="查询订单", hideResp=false)
@RequireRole({"ADMIN","OPS"})
@GetMapping("/order")
public ApiResponse<String> getOrder(@RequestParam String id) {
// 真实场景可查询数据库,这里仅返回一个拼接字符串
return ApiResponse.ok("order-" + id);
}
// 健康检查接口:可被监控系统频繁调用,隐藏响应体减少日志噪音
@ApiLog(value="健康检查", ignore=false, hideResp=true)
@GetMapping("/ping")
public ApiResponse<String> ping() {
return ApiResponse.ok("pong");
}
}
8. 优势、扩展与排查
- 优势:业务零侵入、格式统一、可配置(级别/忽略/隐藏响应),更易审计与追踪。
- 扩展:日志异步落盘/发 MQ;权限列表支持;与 Spring Security 配合(注解转 SecurityMetadataSource);接入 ELK 关联 traceId。
- 常见问题排查:
- 切面不生效:类未被 Spring 管理或方法是
private/final;调整为public并交给容器。 - 注解写在接口而实现类无代理:确保代理对象被调用,或把注解写到实现类。
- 未开启 AOP:确认引入 starter,未手动禁用
spring.aop.auto=true。
- 切面不生效:类未被 Spring 管理或方法是
9. 小结
把日志与权限交给 AOP 这位"统一管家",再加上空指针兜底、JSON 异常防护、脱敏与多角色校验,就能在生产环境稳稳落地。后续可平滑接入 Spring Security 或 ELK,持续进化。