Spring Boot 集成 AOP 实现日志记录与接口权限校验

Spring Boot 集成 AOP 实现日志记录与接口权限校验

摘要:想象一栋写字楼,如果每个房间都自己配锁、拉监控,既费钱又容易漏;更聪明的做法是装一套统一的门禁和中控,访客一刷卡,全楼的安全和记录都被接管。AOP 就是这套"中央管家"------把日志与权限从每个接口中抽离,统一织入,既轻量又可追溯。

1. 场景与概念对照

  • 痛点:每个接口都写日志与权限,像每个房间各自装锁和摄像头,重复又易漏。
  • AOP 角色类比:切面=管家,通知=动作(餐前消毒/餐后清洁),连接点=每次上菜瞬间,切入点=哪些桌子需要清洁。
  • 概念与代码速查表:
    • 切面@Aspect
    • 通知@Around/@Before/@AfterReturning
    • 连接点 → 目标方法调用
    • 切入点@Pointcut 表达式

2. 环境准备与版本差异

spring-boot-starter-aopspring-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

9. 小结

把日志与权限交给 AOP 这位"统一管家",再加上空指针兜底、JSON 异常防护、脱敏与多角色校验,就能在生产环境稳稳落地。后续可平滑接入 Spring Security 或 ELK,持续进化。

相关推荐
Hx_Ma1610 小时前
Map集合的5种遍历方式
java·前端·javascript
小手cool10 小时前
Java 列表中查找最小值和最大值最有效率的方法
java
惊讶的猫11 小时前
多线程同步问题及解决
java·开发语言·jvm
wfsm11 小时前
工厂模式创建动态代理实现类
java·开发语言
好好研究11 小时前
总结SSM设置欢迎页的方式
xml·java·后端·mvc
Hui Baby11 小时前
java -jar 启动原理
java·pycharm·jar
weixin_5112552111 小时前
更新jar内资源和代码
java·jar
木井巳11 小时前
【递归算法】验证二叉搜索树
java·算法·leetcode·深度优先·剪枝
不当菜虚困11 小时前
windows下HSDB导出class文件报错【java.io.IOException : 系统找不到指定的路径。】
java·开发语言
小马爱打代码11 小时前
Spring Boot:第三方 API 调用的企业级容错设计
java·spring boot·后端