资讯详情

Spring Boot AOP实战:切面日志、切点表达式与动态代理全解析

📅 2026/10/13 11:35:55 | 华诺云谱 👁 阅读
Spring Boot AOP实战:切面日志、切点表达式与动态代理全解析
1. 没写AOP之前日志代码是怎么让人崩溃的先说说我最初被 AOP 逼着去研究它的经历。那时候我在做一个内部管理系统产品要求所有接口都记录调用日志谁调的、传了什么参数、耗时多久、有没有报错。图省事我直接在 controller 方法里复制粘贴一个方法三行 log二十个接口就是六十行几乎一模一样的代码。后来需求稍微变一下——耗时超过 500ms 的要单独告警我就得把所有接口翻一遍漏改一个就是隐患。这种体验我相信做后端的人多少都经历过。这种每个业务方法都要做但跟业务逻辑本身又没关系的操作在工程上叫横切关注点Cross-Cutting Concern。日志、鉴权、限流、事务、参数校验、埋点统计全是典型的横切关注点。用传统 OOP 的思路处理无非三种复制粘贴、写个基类让所有业务类继承、或者封装工具类然后手动调用。第一种污染代码第二种强行绑架继承关系第三种依然靠人自觉去调用漏一次就是事故。AOPAspect-Oriented Programming面向切面编程就是专门来解决这个问题的。思路其实很朴素把要在很多方法上重复执行的逻辑单独抽出来写再声明规则告诉框架哪些方法需要套上这段逻辑。框架在运行时创建代理对象当你调用目标方法时自动把这段逻辑织入进去。这里面有几个术语你得先眼熟一下被抽出来的逻辑类叫切面Aspect匹配规则的表达式叫切点Pointcut具体要执行的逻辑叫通知Advice代理对象的生成过程叫织入Weaving。后文我会反复用这些词。在 Spring 生态里SpringBoot 已经把 AOP 支持做得非常开箱即用引一个 starter、写一个切面类、配一条切点表达式剩下的交给代理机制。需要注意SpringBoot 里的 AOP 默认是指 Spring AOP基于动态代理的运行时织入不是完整的 AspectJ 编译期织入。引入 aspectjweaver 只是为了支持更丰富的切点表达式写法这点先搞清楚后面才不会概念混乱。这篇文章我就用一个最简单、能直接抄作业的例子把环境配置、切面代码、切点表达式、执行顺序以及我踩过的坑全部串一遍。2. 搭环境一个 starter 依赖就够了2.1 pom.xml 里到底要加什么我分别用 SpringBoot 2.7.x 和 3.2.x 验证过AOP 部分的写法完全一致依赖也一样dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency就这么一行。这个 starter 会传递引入 spring-aop 和 aspectjweaver。SpringBoot 的自动配置类 AopAutoConfiguration 会检测 classpath 下是否有 AspectJ 相关类如果有就自动注册一个关键组件AnnotationAwareAspectJAutoProxyCreator。这个组件负责扫描带Aspect注解的 Bean并给匹配切点表达式的对象生成代理。所以正常项目里你不需要手动加EnableAspectJAutoProxy。很多老教程里写这个东西要么是历史遗留的 Spring 配置习惯要么是为了打开exposeProxy true这个开关后文讲自调用问题时会提到。如果你用的是 SpringBoot 1.x或者是在纯 Spring 项目里手搓配置那才需要考虑。提示如果项目里确实没引入任何 AOP 相关依赖启动时通常不会立刻报错而是等你开始调用被拦截的方法时才发现没有任何效果。排查顺序里第一步永远是确认 starter 有没有真的加进去。2.2 最小工程结构我习惯把切面类单独放在一个包下面别混进 controller 或 service 包。src/main/java/com/example/demo/ ├── DemoApplication.java ├── aspect/ │ └── LogAspect.java ├── controller/ │ └── UserController.java └── service/ └── UserService.java切面类必须放在能被SpringBootApplication组件扫描到的包路径下因为它本身要被注册成 Spring 管理的 Bean。另外建议类名简洁、职责单一比如LogAspect、PermissionAspect一个切面只干一类事项目大了搜索起来也方便。3. 手写第一个切面接口入参、耗时、返回值的完整记录3.1 先准备一个能被拦截的接口为了演示我建一个最普通的 controller 和 service没有任何特殊处理RestController RequestMapping(/user) public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService userService; } GetMapping(/{id}) public User getUser(PathVariable Long id) { return userService.getUserById(id); } }Service public class UserService { public User getUserById(Long id) { return new User(id, 张三); } }User 就是一个带 id 和 name 字段的普通 POJO这里就不占篇幅了。现在如果我在不动这两个类的前提下统一给 controller 的所有方法加上日志AOP 正好派上用场。3.2 核心代码LogAspectpackage com.example.demo.aspect; import java.util.Arrays; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; Aspect Component public class LogAspect { private static final Logger log LoggerFactory.getLogger(LogAspect.class); Around(execution(* com.example.demo.controller..*.*(..))) public Object logController(ProceedingJoinPoint joinPoint) throws Throwable { String clazz joinPoint.getTarget().getClass().getSimpleName(); String method joinPoint.getSignature().getName(); Object[] args joinPoint.getArgs(); long start System.currentTimeMillis(); log.info(进入 {}.{}参数{}, clazz, method, Arrays.toString(args)); try { Object result joinPoint.proceed(); long cost System.currentTimeMillis() - start; log.info(结束 {}.{}返回值{}耗时{} ms, clazz, method, result, cost); return result; } catch (Throwable ex) { long cost System.currentTimeMillis() - start; log.error(异常 {}.{}原因{}耗时{} ms, clazz, method, ex.getMessage(), cost); throw ex; } } }这段代码是整个 AOP 最核心的骨架。几个关键点拆开说Aspect标记这是一个切面类Component让它被 Spring 扫描到。Around是通知类型表示全流程包裹你要在方法执行前后做什么都可以。被包裹的方法引用叫ProceedingJoinPoint注意必须带Proceeding前缀它比普通的JoinPoint多一个proceed()方法。joinPoint.proceed()是灵魂。只有执行到这一行才会真正进入目标方法相当于一个放行操作。你要是不调它目标方法就永远不执行。我见过刚接触 AOP 的同学在Around里忘了调 proceed接口直接返回 null排查半天。日志里我同时拿到了类名、方法名、参数、返回值、耗时。joinPoint.getTarget()拿到的实际目标对象getSignature()拿到的是方法签名getArgs()是调用参数。这几个 API 是切面里最高频的背下来不亏。catch 到异常后我选择throw ex重新抛出去这个习惯很重要。如果吞掉异常当前接口虽然不会报错但事务、上游调用方都会以为方法执行成功了对日志审计类切面来说是严重误导。后文我会再单独展开。3.3 跑起来验证效果启动应用请求curl http://localhost:8080/user/1然后看控制台INFO 进入 UserController.getUser参数[1] INFO 结束 UserController.getUser返回值User(id1, name张三)耗时3 ms到这一步最小的 AOP 例子已经跑通了。你没动 controller 一行代码却在所有接口上统一加上了日志能力。这种不动业务代码却给大量方法增加统一行为的感觉就是 AOP 的核心价值。3.4 通知类型怎么选Around不是唯一的通知注解Spring 提供了五种我整理成一张表通知类型注解触发时机适用场景前置通知Before目标方法执行前参数校验、权限检查后置通知After目标方法执行后无论成功失败释放资源返回通知AfterReturning目标方法正常返回后记录返回值、更新状态异常通知AfterThrowing目标方法抛出异常后异常告警、兜底处理环绕通知Around全流程包裹耗时统计、事务、限流等复杂逻辑我的建议是简单场景能用Before、AfterReturning就用简单的代码更清晰一旦你需要方法前后关联起来做事比如计时或者需要手动控制流程直接上Around。Around能覆盖前四种的大部分场景但也是最容易写错的尤其是 proceed 调用和异常处理。4. 切点表达式execution、annotation、自定义注解三种玩法4.1 execution 表达式逐段拆解刚才切面里的Around(execution(* com.example.demo.controller..*.*(..)))这一串字符的语法是execution( 修饰符? 返回类型 类路径.方法名(参数) 异常? )其中修饰符和异常部分可以省略。我们挨个拆*表示返回类型任意。com.example.demo.controller..*表示 controller 包及其子包下的所有类。两个点..在切点表达式里是匹配子包的意思。.*(..)最后的*表示任意方法名(..)表示任意数量、任意类型的参数。经常有人把controller..*和controller.*搞混。前者包含 controller 包和它的子包后者只匹配 controller 包下的直接类。如果子包里的 controller 没被拦截到先检查是不是这里少写了一个点。常见的 execution 模式我列一下方便 Copy表达式含义execution(public * *(..))所有 public 方法execution(* set*(..))所有 set 开头的方法execution(* com.example.demo.service.*.*(..))service 包下的所有方法不含子包execution(* com.example.demo.service..*.*(..))service 包及子包下的所有方法execution(* com.example.demo.controller.*.save(..))controller 包下所有类的 save 方法提示切点表达式在 Spring AOP 里是基于代理的只有通过 Spring 容器获取的代理对象的调用才能被拦截。static、private、final方法不参与动态代理这个我在第 6 节详细说。4.2 annotation按注解匹配execution 适合按位置拦截但很多时候你只想拦截打了某个标记的方法这时候用annotation更精准Around(annotation(com.example.demo.annotation.OperationLog)) public Object record(ProceedingJoinPoint joinPoint) throws Throwable { // 只有标注了 OperationLog 的方法才会进到这里 return joinPoint.proceed(); }这种写法不用关心方法在哪个包、叫什么名字只认注解。它比 execution 的颗粒度细得多也更符合按需拦截的直觉。4.3 自定义注解玩法把参数传进切面annotation最常见的进阶用法是配合自定义注解。比如我要记录哪个模块、哪个操作产生了日志定义注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface OperationLog { String module() default ; String action() default ; }然后在切面里通过方法签名拿到注解里的值Aspect Component public class OperationLogAspect { private static final Logger log LoggerFactory.getLogger(OperationLogAspect.class); Pointcut(annotation(com.example.demo.annotation.OperationLog)) public void operationLogPointcut() { } Around(operationLogPointcut()) public Object record(ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature signature (MethodSignature) joinPoint.getSignature(); OperationLog annotation signature.getMethod().getAnnotation(OperationLog.class); long start System.currentTimeMillis(); try { Object result joinPoint.proceed(); log.info(模块 [{}]动作 [{}]参数{}耗时{} ms, annotation.module(), annotation.action(), Arrays.toString(joinPoint.getArgs()), System.currentTimeMillis() - start); return result; } catch (Throwable throwable) { log.error(模块 [{}]动作 [{}] 执行失败{}, annotation.module(), annotation.action(), throwable.getMessage()); throw throwable; } } }这里我用Pointcut单独声明了一个公共切点方法好处是如果以后要新增多个通知共用一个切点或者要和其他切面组合只需要改这一处表达式。业务方法上只需要一行注解OperationLog(module 用户模块, action 查询用户) public User getUserById(Long id) { return new User(id, 张三); }这种方式特别适合做审计日志、权限校验、幂等控制这类选择性横切逻辑。注解本身就是一种声明式配置一眼就能看出方法具备什么额外能力比在 xml 里写一堆正则清爽得多。4.4 三种方式怎么选我的经验是全局、无差别的横切逻辑比如给所有 controller 统一计时、统一异常兜底用execution表达式写一次一劳永逸。只针对特定方法、需要携带参数的横切逻辑比如审计、操作记录用自定义注解代码可读性最好。需要拦截外部库里的方法、或者你没法改源码的方法时只能依赖execution或within这类基于位置的表达式。5. 多个切面叠加时执行顺序怎么控制5.1 Order 控制切面的嵌套顺序真实项目里不可能只有一个切面。我经常同时遇到日志切面、权限校验切面、耗时统计切面、甚至事务切面。它们都作用在 controller 层方法上时执行顺序就得说清楚。Spring 的规则是通过Order注解或者Ordered接口指定优先级数值越小优先级越高。多个Around切面叠加时优先级高的切面在外层它的前置逻辑先执行后置逻辑反而最后执行就像一层层套娃一样。Aspect Component Order(1) public class PermissionAspect { Around(execution(* com.example.demo.controller..*.*(..))) public Object checkPermission(ProceedingJoinPoint joinPoint) throws Throwable { System.out.println(第一步权限校验开始); return joinPoint.proceed(); } } Aspect Component Order(2) public class PerformanceAspect { Around(execution(* com.example.demo.controller..*.*(..))) public Object record(ProceedingJoinPoint joinPoint) throws Throwable { long start System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { System.out.println(耗时 (System.currentTimeMillis() - start) ms); } } }执行顺序是第一步权限校验开始 目标方法执行... 耗时XX ms如果把两个 Order 调换输出顺序就反过来了。理解这个嵌套关系能避免很多为什么我日志顺序不对的困惑。注意不写 Order 的切面默认优先级是 Ordered.LOWEST_PRECEDENCE也就是 Integer.MAX_VALUE在叠加时会跑到最内层。如果你发现某个切面莫名其妙的执行顺序不对先检查它有没有被 Order 约束。5.2 事务切面和自定义切面的相对位置很多人忽略的是Transactional本身也是通过 AOP 实现的。它的 order 默认也是 LOWEST_PRECEDENCE。当你自定义切面没有设置 Order或者设置了较大的值理论上会影响切面和事务的相对顺序。举个实际场景如果自定义切面里要做在数据库事务提交前记录一条日志而事务切面在更内层那你记录日志的时机可能比预期早。反过来如果你希望切面在事务内部执行常规做法是把自定义切面的 Order 调小让它作为外层先包住事务然后在 Around 里通过TransactionSynchronizationManager注册事务同步回调在真正提交后执行逻辑。这个细节比较深我提一句是想提醒多切面叠加时事务本身也是一个看不见的切面排顺序的时候别忽略它。5.3 同一切面里多个通知不要依赖执行顺序有一种很隐蔽的坑同一个切面类里既写了Before又写了Around、After。官方文档明确说过同一个切面内不同通知的执行顺序是没有严格保证的。我实测不同版本表现还不一样有的先执行Before有的先执行Around的前半段。所以我的原则是一个切面类只做一件事通知之间不要有顺序假设。比如耗时统计就只放Around权限校验就只放Before不要混在一起否则排查问题的时候会非常痛苦。6. 实战踩坑切面不生效、行为诡异多半是这些问题6.1 自调用导致切面被绕过去这是 AOP 新手最容易踩的坑没有之一。看这段代码Service public class UserService { OperationLog(module 用户模块, action 创建用户) public void createUser(String name) { // 业务逻辑... this.validateUser(name); } OperationLog(module 用户模块, action 校验用户) public void validateUser(String name) { // 校验逻辑... } }外部其他类调用userService.createUser(张三)切面生效。但createUser内部通过this.validateUser(name)自调用时validateUser上的切面不会生效。原因是 Spring AOP 的代理机制外部拿到的userService是一个代理对象调用方法时会先经过代理但方法内部的this指向的是原始对象不是代理。想绕过代理直接执行了你写的原始方法切面自然被跳过了。解决思路有几种把validateUser拆到另一个 Bean比如ValidationService通过注入的代理对象调用这是最干净、最推荐的做法。注入自身把UserService注入到自己字段里通过selfService.validateUser()调用。使用AopContext.currentProxy()注意这需要启动类上加EnableAspectJAutoProxy(exposeProxy true)并且有 ThreadLocal 开销不推荐默认使用。补充一句SpringBoot 2.6 之后默认禁止循环依赖自注入本质上也是一种特殊依赖注入某些版本下会报错所以拆 Bean 是长期最稳的选择。6.2 JDK 动态代理和 CGLIB 的差异Spring AOP 的底层有两种代理方式代理方式原理限制JDK 动态代理基于接口生成实现类目标必须实现接口只能代理接口方法CGLIB基于继承生成子类final类、final方法无法代理SpringBoot 2.x 之后默认spring.aop.proxy-target-classtrue也就是默认走 CGLIB。这意味着如果你的某些被拦截类或方法是final的CGLIB 也无能为力切面会静默失效。这种失效不会报错只会让你怀疑人生。我排查时有个习惯临时打印一下注入对象的真实类型看是不是代理类System.out.println(userService.getClass().getName());如果输出里带着$$EnhancerBySpringCGLIB$$说明代理生效了切面没触发就是表达式或方法可见性的问题如果输出就是原始类名com.example.demo.service.UserService说明整个代理压根没生成直接检查 starter 依赖和组件扫描。6.3 切面不生效的完整排查链路遇到我写了切面但就是不执行按这个顺序查90% 的问题能定位确认spring-boot-starter-aop在 pom 里并且依赖确实下载下来、没有 exclusions 把传递依赖排掉。确认切面类上有Aspect和Component并且类所在包能被启动类扫描到。确认切点表达式里的包路径、类名、方法名写得对。最常见的是..少写一个点或者包名写成了别人的包。确认目标方法是public且非static、非final。private方法永远无法被 Spring AOP 拦截。确认调用方拿到的对象是代理对象而不是通过new直接创建的原始对象也不是在目标类内部用this自调用。如果以上都正常把日志级别调大看代理生成细节logging: level: org.springframework.aop: debug com.example.demo.aspect: debug启动时观察日志里有没有类似 Creating implicit filter for pointcut expression 的字样能帮助判断表达式有没有被识别。6.4 切面里的坏味道吞异常、干重活最后说几个写切面时的原则第一Around里捕获到异常后如果不是故意要吞掉比如某些熔断降级场景一定要重新抛出。否则上游感知不到失败事务也可能误判为成功。我写过一版统计切面catch 里忘了throw ex结果有几天线上接口连续报错日志里却是调用结束排查了很久才发现是切面把异常吞了。第二切面不要干重活。不要在里面做数据库查询、调用远程接口这类耗时操作因为切面包裹的是所有匹配方法这些额外开销会叠加到每个业务接口上。耗时统计切面本身是轻量的但如果你在切面里去查库做调用次数统计接口性能会肉眼可见地下降。第三多个切面职责分离。日志归日志、权限归权限不要写一个万能切面把所有横切逻辑堆在一起。切面的可维护性靠的是职责单一和清晰命名。第四切点表达式适合宁窄勿宽。能精确匹配到目标方法就别图省事写一个宽泛的*.*(..)。表达式太宽可能把不该拦截的内部方法也套上切面性能损耗和误伤都很难查。最后分享一点体会这套 AOP 配置我在多个项目里用了好几年从最早的纯 Spring 时代到现在的 SpringBoot 3.x核心套路几乎没变过。每次有同事问我为什么接口里什么都没写却有日志、有权限校验我都会带他过一遍切面代码。AOP 最妙的地方不是省了几行代码而是改变了一种思维方式以前你改一个横切需求要动几十个业务类现在你只需要改一个切面类所有匹配的方法自动生效。当然代价是代码的显式性变弱了——控制流不再直白地写在业务方法里所以切面要写清楚、职责要单一Debug 经验要积累。希望这篇实战记录能让你少走一点弯路。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑