ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring AOP实战:从动态代理到切面编程的完整指南

Spring AOP实战:从动态代理到切面编程的完整指南 Spring AOP 这个东西Java 后端干了几年的人基本都绕不开。很多人面试前背了一堆概念动态代理、切面、通知、切入点张口就来真到了项目里要自己写一个切面去搞定日志或者权限校验却又不知道怎么下手或者写完了发现怎么不生效。这篇不打算从教科书定义开始念我直接结合自己实际项目里的用法把 Spring AOP 的原理、常用场景、代码写法、还有那些容易埋坑的细节一次说清楚。如果你正在用 Spring Boot 做开发或者准备面试想把这部分彻底弄懂这篇应该能帮你省不少事。AOP 全称是 Aspect Oriented Programming面向切面编程。它解决的核心问题是把那些分散在各处的共性逻辑从业务代码里抽出来。最典型的例子就是日志记录、权限校验、事务管理、性能监控这些东西和核心业务没有直接关系但每个接口又都得有。不用 AOP 的时候你只能在一个个方法里重复写同样的代码改一处要动十几个地方。用了 AOP你只需要把公共逻辑写在一个地方然后通过规则告诉 Spring 哪些方法需要套上这套逻辑剩下的事情框架帮你完成。1. 先从代理模式说起AOP 能跑起来的根基很多人学 AOP 觉得难其实是被“切面”“通知”“连接点”这些术语吓到了。抛开这些名词AOP 底层的实现机制就一句话动态代理。Spring 在运行时帮你创建一个代理对象这个代理对象包裹了你的原始 Bean在调用你的方法之前和之后可以插入额外的逻辑。调用方拿到的根本不是你的原始对象而是那个代理对象但因为它实现了同样的接口或者继承了同样的类调用方完全感知不到区别。1.1 JDK 动态代理和 CGLIB 代理到底怎么选Spring 实现 AOP 有两种代理方式。如果你的 Bean 实现了接口Spring 默认使用 JDK 动态代理也就是 java.lang.reflect.Proxy 配合 InvocationHandler 来工作。JDK 动态代理的特点是代理对象和目标对象实现相同的接口所以只能代理接口中定义的方法。如果你的类没有实现任何接口Spring 会退而使用 CGLIB通过生成目标类的子类来创建代理对象。CGLIB 的原理是继承所以它可以代理类中的所有非 final 方法但代价是目标类不能是 final 的。这里有个细节容易被人忽略。Spring Boot 2.x 之后官方把 CGLIB 代理设为了默认策略即使目标类实现了接口默认走的也是 CGLIB。为什么要这么改因为实际项目里经常遇到只实现了接口、但没把接口方法都列全的情况或者方法上标注的注解在接口和实现类上不一致JDK 动态代理容易出现注解丢失的问题。CGLIB 直接操作目标类这些坑会少很多。你可以在配置文件中通过 spring.aop.proxy-target-classfalse 强制切回 JDK 动态代理但绝大多数场景我不建议这么干。1.2 从代理展开连接点、切点、通知、切面一次看懂搞清楚代理之后AOP 里那些术语就很好理解了。连接点Join Point目标对象中可以被拦截的方法。理论上所有方法都能成为连接点但实际能拦截到哪一步要看代理方式。切点Pointcut真正被选中的连接点。通过表达式来定义比如“所有 controller 包下以 Controller 结尾的类的所有方法”。通知Advice拦截到方法之后要执行的逻辑分为前置通知、后置通知、环绕通知、异常通知、最终通知。切面Aspect切点加通知的组合体一个切面定义了一套规则和一套逻辑。用个生活化的例子打比方。食堂打饭窗口所有来打饭的同学是连接点你通过“穿红衣服的同学”这个规则筛选出一批人这个规则是切点“给这群人每人多加一勺菜”这个动作是通知而“哪个窗口、按什么规则、加什么菜”这套完整安排就是切面。理解这个类比后面写代码就不会晕。2. 核心实操从零开始写一个完整切面光讲概念没有用直接上代码。我假设你有一个 Spring Boot 项目用的版本是 2.7 或者 3.x这都不影响因为 Spring AOP 的用法在这些版本里非常稳定。2.1 引入依赖和最简单的切面代码Spring Boot 项目里使用 AOP第一步是引入 starter。这个依赖是 spring-boot-starter-aop如果你用的是 Spring Boot 3.x直接加下面的坐标dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency引入依赖之后Spring Boot 的自动配置会帮你把 AnnotationAwareAspectJAutoProxyCreator 注册到容器中这个类就是 AOP 的核心它负责扫描所有标了 Aspect 的类然后为核心 Bean 生成代理对象。你不用手动做任何配置。接下来写一个最简单的切面。比如我想给所有 controller 的方法加上执行时间统计代码可以这样写Component Aspect public class MethodTimerAspect { private static final Logger log LoggerFactory.getLogger(MethodTimerAspect.class); Around(execution(* com.example.demo.controller..*.*(..))) public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { long start System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { long cost System.currentTimeMillis() - start; log.info(方法 {} 执行耗时 {} ms, joinPoint.getSignature().toShortString(), cost); } } }最核心的就是 Around 注解和里面的表达式。execution(* com.example.demo.controller...(..)) 表示拦截 controller 包及其子包下所有类的所有方法不管方法参数是什么、返回值是什么。joinPoint.proceed() 是放行让原始方法继续执行。这个操作最容易被忽略很多新手写了 Around 却忘了调用 proceed()结果业务方法压根不执行。2.2 切点表达式的几种常用写法execution 表达式是 AOP 用的最多的一种但很多人只会写全路径匹配换个场景就卡住。我这里整理几种常见的execution(public * com.example.service..(..)) 匹配 service 包下所有类的公共方法。execution(* com.example.service.UserService.*(..)) 匹配 UserService 里的所有方法。execution(* com.example.service..get(..)) 匹配 service 包下所有以 get 开头的方法。execution(* com.example...(..)) 匹配 com.example 包及其子包下所有类的所有方法。除了 execution还有 within、annotation 这两种也经常用到。annotation 是按注解匹配它的使用场景很灵活。比如你自定义了一个 OperateLog 注解想做到“哪个方法标了这个注解就记录操作日志”切点表达式写成 annotation(com.example.annotation.OperateLog)然后在方法上标注解就行。这种按注解匹配的方式在实际项目中比按包名匹配更精准因为它的控制粒度是方法级别的而不是把所有类都圈进来。另外提一下切点复用。如果同一个切面里多个通知用同一个切点表达式不要每次都复制一遍长字符串可以抽出一个空方法并标上 Pointcut代码会清爽很多Aspect Component public class LogAspect { Pointcut(annotation(com.example.annotation.OperateLog)) public void operateLogPointcut() {} Before(operateLogPointcut()) public void doBefore() { // 前置逻辑 } AfterReturning(operateLogPointcut()) public void doAfterReturning() { // 后置逻辑 } }Pointcut 里定义的就是切点被注解标记的空方法只是为了给切点一个名字运行时不执行任何逻辑。2.3 五种通知类型各自的用途和坑Spring AOP 总共提供了五种通知注解。Before 在目标方法执行前执行适合做校验、埋点等操作。它有一个特点执行完后目标方法一定会继续执行除非你在前置通知里抛了异常。因此不能在前置通知里通过修改返回值来影响业务结果。AfterReturning 在目标方法正常返回后执行。它可以拿到返回值通过 returning 参数指定返回值绑定到哪个变量但这个变量名必须和通知方法参数名一致否则 Spring 会报找不到 returnName 的错误。这个细节很不显眼经常有人在这里翻车。注意 AfterReturning 是拿不到异常场景的如果方法抛了异常它不会执行。AfterThrowing 在目标方法抛出异常后执行。如果你在项目里做了全局异常处理这里其实不需要再记录异常日志了很容易造成日志重复。我的习惯是只在没有全局异常处理的场景下用它。After 无论目标方法正常返回还是抛异常都会执行类似 finally。适合释放资源、记录最终状态这种操作。Around 是功能最全面的它可以在方法执行前后、异常捕获后完成任何操作甚至可以完全替代前面几种通知。但正因为它能力最强坑也最多最典型的就是前面说的忘了调用 proceed()。还有一点如果环绕通知自己捕获了异常那么目标方法对于调用方来说就不会再抛异常这种“吞异常”的行为有时候是有意的有时候是错误的需要你自己控制好。3. 那些价值最大的使用场景AOP 最能体现价值的地方一定不是“用 AOP 打印日志”这种演示而是那些真正帮业务省了大量重复代码的场景。我按自己实际用过的经验从高频到低频说一下。3.1 操作日志记录把每个关键方法的入参和结果都留下痕迹这种场景几乎是 AOP 的标准应用。尤其是后台管理系统里谁在什么时间改了哪条数据、改之前是什么值、改之后是什么值都是刚需。我设计过一套基于注解的操作日志方案。先定义一个注解比如 OperateLog它可以接收一个参数描述操作内容。然后在切面里配合 Around 使用记录操作人、IP、调用方法、入参、返回结果、耗时等。核心要点有两个。第一个是入参不要直接序列化有些对象里有密码、令牌或者各种二进制的字段你直接把整个对象序列化存库会泄密也可能因为对象里的循环引用触发 StackOverflowError。我一般会自定义一个规则只保留基础类型字段或者显式指定记录哪些参数。第二个是操作人怎么获取如果用户信息是放在 ThreadLocal 里那切面里可以直接拿到如果通过拦截器每次重新查一遍数据库那就要注意不要因为切面里的逻辑造成性能损耗。3.2 自定义权限校验比拦截器更灵活的一种方式你可能说权限校验不是用 Spring Security 或者拦截器就行了吗确实Spring Security 能解决大部分问题但有些场景下它的粒度控制不如 AOP 灵活。比如接口层面加权限判断你可以在方法上加一个 RequirePermission(order:delete) 注解AOP 切面在方法执行前读取当前用户的权限列表判断是否包含 order:delete。这种方式的好处是权限规则和业务代码完全解耦新增一个接口的权限控制时只需要在方法上加一个注解就行。这里有一个容易被忽略的细节权限校验必须放在 Before 或者 Around 里并且校验失败时要直接抛异常不能只是返回一个错误结构。因为切面如果吞掉了校验结果目标方法还是会继续执行业务逻辑就被执行了这是安全漏洞。我在实际项目里就是把权限校验放在 Around 里校验不通过直接 throw new ForbiddenException(无权限访问)让全局异常处理器统一处理。3.3 异常重试给脆弱的外部调用加一层兜底做微服务的同学一定会遇到外部接口偶发超时的情况。你调第三方 HTTP 接口偶尔网络抖一下超时了但对方其实已经处理成功了。直接抛异常给上层用户就要重新操作体验很差。通常做法是加重试机制。用 AOP 实现重试的开始思路是自定义一个 Retryable 注解里面可以配置最大重试次数和重试间隔Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface Retryable { int maxRetries() default 3; long retryInterval() default 1000; }然后在切面里做循环调用。注意重试不是简单的 for 循环就可以。你要判断哪些异常值得重试比如连接超时、网络异常这种短暂故障而业务异常比如参数错误是不值得重试的重试多少次都会失败反而浪费时间。所以我一般会在注解里加一个参数指定可重试的异常类型只有当捕获到的异常属于配置类型时才继续重试。通过注解配合 AOP 做重试比在每个方法里自己写 try-catch 循环干净很多而且配置项在方法上一眼就能看到运维成本低很多。3.4 性能监控与慢接口统计监控方法执行时间是 AOP 最直接的用法。但你的监控不能只是打一条日志那样等到接口真的慢了日志早就滚没了。我做过一个方案在切面里统计执行耗时超过阈值时输出一个完整链路的信息方法名、入参、耗时、当前线程、调用来源同时把这些数据发送到日志采集系统。这样当线上出现慢接口时直接按关键字查就能定位问题。性能监控切面里有一个细节值得注意统计时间的时候要减去锁等待的时间还有因 IO 阻塞的时间否则一个方法里调了多次数据库耗时统计出来会非常高但问题可能不在你的方法逻辑里而是在数据库查询上。当然AOP 的粒度做不到这么细真正要做链路耗时分析应该结合 Tracing 工具比如 SkyWalking、Zipkin。AOP 做性能监控的价值在于快速发现“哪个方法慢了”而不是回答“为什么慢”。3.5 结合 Spring AI 的扩展玩法近期 Spring AI 这个项目火了起来它同样可以用 AOP 来增强。Spring AI 里的核心组件是 ChatClient 和 ChatModel它们承担对大模型接口的调用。你可以给这些方法加上切面自动记录每次调用的 prompt 和返回结果或者说自动统计一次对话的 token 消耗。具体写法上和前面说的日志切面没什么区别切点指向 ChatClient 或者 ChatModel 的调用方法即可。如果是基于 spring-ai-alibaba 的扩展思路也一样。不过这属于 AOP 在新场景下的应用如果你目前还没接触到 AI 相关的开发可以先跳过不影响你理解 AOP 本身。4. 高级避坑指南常见问题与排查实录AOP 用起来不算难但实际生产中遇到的问题往往不是“怎么写”而是“为什么不生效”。我挑几个踩过频率最高的坑来说。4.1 同一个类里方法调用AOP 不生效这是咨询量最大的一个问题。比如你在 UserService 里有一个方法 methodA它调用了同类里的 methodBmethodB 上标了 Cacheable 或者其他 AOP 注解结果发现注解没有生效。原因很简单。你从 Spring 容器里拿到的是代理对象不是原始对象。当你调用代理对象的方法时进入的是 AOP 增强逻辑。但 methodA 的内部this 指向的是原始对象本身而不是代理对象。所以当 methodA 里调用 this.methodB() 时使用的是原始对象的方法自然不会经过 AOP 代理。解决办法有几种。最简单粗暴的是把 methodB 拆分到另一个 Service 里通过注入的方式调用这样调用方就是代理对象AOP 会生效。另一个办法是把自己注入进来也就是通过 Spring 容器获取到代理对象循环依赖在 Spring 里默认是允许的。还有一种方式是用 AopContext.currentProxy() 获取当前代理对象但需要在启动类加上 EnableAspectJAutoProxy(exposeProxy true) 开启暴露代理对象否则会拿到 null。4.2 切面内部调用自身方法导致递归这个坑比较隐蔽。如果你在 Around 通知里为了拿到代理对象而主动调用了 joinPoint.getTarget() 之后又调用了目标方法就可能导致无限递归。另外如果环绕通知内部的逻辑不小心再次触发了同一切点的匹配也会造成递归调用。我遇到过一个有意思的情况一个方法上标了 Transactional另一个切面又对它做了环绕增强。在执行写操作时环绕切面里的逻辑又调用了另一个有同样切点的方法结果两个代理相互嵌套事务提交时机变得非常诡异。排查这类问题最好的方式是启动服务时把 AOP 的相关日志打开观察代理生成的过程确认哪些类被代理了哪些没有。在 application.properties 里加上 logging.level.org.springframework.aopDEBUG 可以看到比较详细的代理信息。4.3 Transactional 和自定义切面的顺序问题同一个方法上既标了自定义切面注解又标了 Transactional它们的执行顺序不是随机的而是由 Order 决定的。默认情况下事务切面的 order 是 Ordered.LOWEST_PRECEDENCE也就是优先级最低这意味着如果自定义切面没有标注 Order它会先执行事务后执行。如果你想先开启事务再走自定义切面逻辑可以在自定义切面类上标 Order(Ordered.LOWEST_PRECEDENCE - 1)让它的优先级比事务高这样它会先执行但事务会在外层先开启。这个顺序问题不是小事。比如你在环绕切面里先做权限校验然后开启事务执行方法如果权限校验失败事务还没来得及开启数据库没有产生任何锁这样可以避免不必要的连接占用。反过来如果你希望某些操作必须在事务内完成顺序调换就会出现连接过早释放或者加锁时机不对的问题。4.4 代理对象未生成注解完全不生效的排查清单如果你发现 AOP 注解完全没生效排查顺序应该是检查切面类是否被 Spring 管理有没有标 Component。检查切面类是否被扫描到包路径是否在启动类的子包下。检查切点表达式是否匹配目标方法可以用在通知方法里打印 joinPoint 来确认。检查框架是否引入了 spring-boot-starter-aop。有些项目只引入了 spring-context里面没有 AspectJ 的实现AOP 功能缺失。检查代理方式如果目标类没有实现接口且是 final 类CGLIB 无法生成代理。检查是否用了 static 方法Spring AOP 无法增强静态方法。检查自调用问题同类内部调用天生不会走代理。4.5 事务失效可能不是 AOP 的问题导致 Transactional 失效的原因里有一部分其实和 AOP 密切相关。最典型的场景是方法在同类内部被调用表面上看是事务失效实际上是因为 AOP 代理没有触发事务切面压根没有介入。另一个场景是方法被标成 privateSpring AOP 无法代理 private 方法事务注解自然也不会生效。还有一个坑是异常被切面或者方法内部捕获并吞掉了事务管理器看不到异常自然就不会回滚。如果你遇到事务不生效可以先反思一下代码里有没有同类自调用、方法是否 public、异常是否被捕获。这三个问题查完大概率能定位原因。5. 原理再深入一点AOP 与 Spring Bean 生命周期、三级缓存的关系现在网上关于 Spring 面试题里出现频率最高的一组就是 AOP、Bean 生命周期、三级缓存。其实这三者的关系非常紧密搞懂了面试的时候你可以把整个 Spring 容器的核心逻辑串起来讲。5.1 Spring 在哪个步骤创建代理对象Spring 容器在创建一个 Bean 时大致会经过实例化、属性填充、初始化、完成。代理对象的创建发生在 Bean 初始化完成之后也就是 InitializingBean 接口回调、PostConstruct 初始化方法执行完毕之后Spring 会调用 AbstractAutoProxyCreator 的 postProcessAfterInitialization 方法检查这个 Bean 是否匹配切点。如果匹配就会用动态代理生成一个代理对象替换掉原来的 Bean。这个顺序很关键。它意味着如果你的切面逻辑里需要用到目标 Bean 的某个属性这个属性在代理生成之前已经被注入了。所以切面里可以放心使用目标对象的字段不会出现空指针。5.2 AOP 和三级缓存怎么协作Spring 解决循环依赖依赖的是三级缓存。一级缓存存放完整 Bean二级缓存存放早期暴露的 Bean三级缓存存放的是 ObjectFactory也就是生成早期 Bean 的工厂。它的设计意图是当循环依赖发生时可以从三级缓存中获取工厂提前把对象创建出来放到二级缓存让依赖它的其他 Bean 先拿到引用。那么 AOP 和三级缓存是怎么配合的Spring 在 Bean 实例化后、属性填充前就会把原始 Bean 封装成 ObjectFactory 放到三级缓存里。如果这个 Bean 在后续被循环依赖了那么会从三级缓存取出工厂执行 getEarlyBeanReference 方法。这个方法会判断当前 Bean 是否需要被 AOP 代理如果需要就提前创建代理对象然后放入二级缓存。后续其他 Bean 拿到的是代理对象整个链路就保证了单例对象的一致性。但是要注意一点如果 Bean 没有被提前暴露没有发生循环依赖那么代理创建会发生在初始化完成后。同样的 Bean在不循环依赖和循环依赖两种场景下代理对象生成时机不同这是 Spring 为了支持循环依赖采取的妥协方案。也正因如此Spring 官方明确说构造器注入的循环依赖是不支持的因为构造器注入在实例化阶段就要拿到依赖而此时还没有可用的缓存对象。5.3 手写 Spring 时如何实现一个最简 AOP如果你尝试过手写 Spring你会发现实现一个最简的 AOP 并没有想象中那么难。思路就是管理好 Bean 的生命周期在 Bean 的初始化之后增加一个代理生成阶段。具体来做先定义一个切面模型包含切点表达式和通知逻辑。创建 Bean 的时候根据切面配置判断是否要生成代理对象如果需要就利用动态代理生成代理对象并返回。如果你的手写 Spring 是 JDK 代理实现的那代理对象的创建逻辑非常简单加载目标类所有接口传入 InvocationHandler在 invoke 方法里执行切点判断匹配就执行通知逻辑否则直接调用真实方法。这个手写过程中最大的难度其实不在于动态代理本身而在于管理好目标对象和代理对象之间的关系以及解决循环依赖和 AOP 的先后顺序。这也解释了为什么 Spring 这么成熟、这么复杂的容器它的设计里充满了各种细节上的权衡和兼容。6. 实战经验分享AOP 编码的几个好习惯说了这么多最后分享几个我长期写 AOP 项目总结出来的习惯不一定在教科书上能看到但对实际开发非常有帮助。第一切面尽量只做横切逻辑的编排不要写具体的业务逻辑。比如记录日志切面它的职责就是拼参数、组织日志内容、落库至于日志要记录哪些字段、哪些信息不应该藏在切面代码里而应该通过注解参数或外部配置来决定。如果切面里塞了业务判断后期维护会非常痛苦。第二优先使用 Around 而不是一堆 Before 和 After 的组合。虽然 Before、AfterReturning、AfterThrowing 这些注解语义清晰但当同一个切点上的逻辑需要同时处理前置、返回、异常多个阶段时拆成多个注解方法会很难维护而且多个通知方法的执行顺序容易混乱。我在实际项目里大部分切面都只写一个 Around内部自己 try-catch-finally逻辑一目了然。第三切面里要有防护性代码。比如入参序列化失败时不能影响主流程切面本身抛了异常一定要谨慎。最理想的状态是切面异常不影响业务逻辑尤其日志切面这类非核心功能一旦日志切面抛了异常导致接口返回失败那属于严重事故。我习惯在切面里做 try-catch记录一个 error 日志后继续执行不让横切逻辑成为系统的单点故障。第四多模块项目里切面类的路径要规划好。如果你把切面类放在 common 模块里那么所有依赖 common 的服务都会被这个切面影响。有时候你只是想在自己的服务里开启日志切面但由于 common 模块被其他服务依赖别人也会莫名其妙地被记录日志甚至参数里带有敏感信息被落库。为了避免这种问题切面类最好放在各自的应用模块里并通过配置开关控制是否启用。最后再说一个我比较喜欢的技巧。AOP 配合自定义注解可以做到非常优雅的“无侵入式”功能扩展。比如你要给一批接口增加接口幂等性校验不用改任何业务代码自定义一个 Idempotent 注解加上一个切面新接口只需要加上注解就自动拥有了幂等能力。这种方式把公共能力做成了基础设施业务同学只需要关心业务逻辑扩展性和可维护性都非常好这也是 AOP 真正的价值所在。
返回列表