ARTICLE DETAIL

资讯详情

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

Spring AOP原理与实战:从动态代理到失效场景全解析

Spring AOP原理与实战:从动态代理到失效场景全解析 做Java开发这些年AOP是我在Spring里用得最多、也是面试时最常被问到的核心机制之一。很多人觉得AOP就是加一个Aspect注解、写几个Around方法调用时日志就出来了事务就生效了限流就拦上了。但真要问一句AOP的原理是什么或者为什么同一个类里两个方法互相调用时AOP不生效不少人就卡住了。这篇文章我把AOP从概念到源码、从入门实战到面试高频坑一条线讲透读完你不仅能熟练使用还能在面试时把底层逻辑说清楚。1. 先搞清楚AOP到底在解决什么问题1.1 一段重复代码引发的思考我先抛一个特别常见的场景。假设你要给一批业务方法加操作日志第一版代码可能是这样的public Order createOrder(OrderDTO dto) { log.info(创建订单开始, 参数:{}, dto); long start System.currentTimeMillis(); try { // 核心业务逻辑 Order order orderService.create(dto); log.info(创建订单成功, 订单号:{}, order.getOrderNo()); return order; } catch (Exception e) { log.error(创建订单失败, 参数:{}, dto, e); throw e; } finally { log.info(创建订单耗时:{}ms, System.currentTimeMillis() - start); } }再加一个取消订单方法同样的日志逻辑再复制一遍。然后还有改订单、查询订单、批量关闭订单……十几个方法下来日志代码量已经超过业务代码了。更麻烦的是如果产品经理提了个需求所有接口的日志里都要加一个traceId方便排查问题你得把所有方法全部改一遍。这种存在于多个业务方法中、和核心业务逻辑无关、但又必须执行的逻辑在AOP里被称为横切关注点。日志、事务、鉴权、限流、异常处理全是典型的横切关注点。而AOPAspect Oriented Programming面向切面编程就是专门把这类逻辑集中管理、统一织入的编程范式。1.2 AOP核心术语切面、切点、通知、连接点AOP里有几个概念我建议你用安检通道来做类比会好理解很多。连接点JoinPoint程序执行过程中的某个位置比如方法调用前、调用后、抛出异常时。类比就是每一个进站的旅客理论上每个旅客每个方法都可以被检查。切点Pointcut真正要执行增强逻辑的连接点集合通常用表达式来匹配比如所有带有RateLimit注解的方法或者com.example.service包下的所有public方法。类比就是安检人员决定穿深色衣服的旅客要抽查这就是筛选条件。通知Advice要织入的具体逻辑比如打印日志这个动作本身分为Before前置通知、AfterReturning返回通知、AfterThrowing异常通知、After最终通知、Around环绕通知。切面Aspect切点通知的组合体一个Aspect类就是一个切面模块。类比就是完整的安检流程。织入Weaving把增强逻辑应用到目标对象的连接点上的过程。Spring AOP的织入发生在运行时通过动态代理实现。用一个最简单的代码示例来对照Aspect Component public class LogAspect { // 切点匹配controller包下所有类的所有方法 Pointcut(execution(* com.example.controller..*.*(..))) public void controllerPointcut() {} // 通知环绕通知打印接口耗时 Around(controllerPointcut()) public Object around(ProceedingJoinPoint joinPoint) throws Throwable { long start System.currentTimeMillis(); Object result joinPoint.proceed(); long cost System.currentTimeMillis() - start; System.out.println(方法 joinPoint.getSignature() 耗时 cost ms); return result; } }1.3 为什么不用继承或者工具类来解决这时候可能有朋友要问既然日志逻辑要复用我抽一个LogUtil工具类每个方法里调用一下不行吗行但这是侵入式的——业务代码里仍然需要手动调用工具方法忘记调了就没有日志改动时还是要一个方法一个方法去动。用继承解决更别扭总不能为了记日志让所有业务类都继承同一个LogBase类这会把类继承关系搅得一团糟Java又是单继承为了日志把唯一的继承机会用掉太不划算。AOP把横切逻辑和业务逻辑在源代码层面完全分离业务方法只需要写自己的订单逻辑AOP在运行时通过代理机制把日志逻辑织入进去。业务代码里看不到任何日志调用的影子但日志确实执行了。这才是面向切面的核心价值所在。2. 动态代理Spring AOP的底层地基2.1 JDK动态代理基于接口的实现AOP听起来玄乎但Spring AOP的底层其实就两样东西JDK动态代理和CGLIB。先说JDK动态代理。它要求目标类必须实现接口运行时通过java.lang.reflect.Proxy为接口生成一个代理类这个代理类实现了同样的接口并在调用方法时把请求转发给一个InvocationHandler。public interface OrderService { void createOrder(String orderNo); } public class OrderServiceImpl implements OrderService { Override public void createOrder(String orderNo) { System.out.println(创建订单: orderNo); } } public class LogInvocationHandler implements InvocationHandler { private final Object target; public LogInvocationHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(【JDK代理】方法前置逻辑: method.getName()); Object result method.invoke(target, args); System.out.println(【JDK代理】方法后置逻辑); return result; } } // 生成代理对象 OrderService proxy (OrderService) Proxy.newProxyInstance( OrderService.class.getClassLoader(), new Class[]{OrderService.class}, new LogInvocationHandler(new OrderServiceImpl()) ); proxy.createOrder(NO.001);运行结果会依次打印前置逻辑、创建订单、后置逻辑。这里的proxy根本不是OrderServiceImpl的实例而是JVM动态生成的一个Proxy子类实例。所有调用都会进入invoke方法由我们决定什么时候去调用真实目标对象的方法。2.2 CGLIB代理基于继承的实现JDK动态代理有个硬约束目标类必须实现接口。那没有接口的类怎么办Spring引入了CGLIBCode Generation Library它通过生成目标类的子类来实现代理这个子类会重写目标类的非final方法在重写方法里完成增强逻辑。CGLIB底层用的是ASM字节码操作技术生成字节码的能力非常强性能上也不差。// 没有实现任何接口的类 public class PaymentService { public void pay(double amount) { System.out.println(支付金额: amount); } } // 使用CGLIB的Enhancer创建代理 Enhancer enhancer new Enhancer(); enhancer.setSuperclass(PaymentService.class); enhancer.setCallback(new MethodInterceptor() { Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { System.out.println(【CGLIB代理】支付前校验); Object result proxy.invokeSuper(obj, args); System.out.println(【CGLIB代理】支付后记录); return result; } }); PaymentService proxy (PaymentService) enhancer.create(); proxy.pay(99.9);CGLIB之所以能拦截没有实现接口的类就是因为它直接造了一个子类把目标方法给覆盖了。因此被代理的类和方法不能是final的否则CGLIB会报错或者静默失效。2.3 Spring怎么选代理方式为什么Spring Boot默认用CGLIB很多人背过这个结论Spring优先用JDK动态代理如果目标类没有实现接口就退化为CGLIB。但这个说法在Spring Boot 2.x之后已经不准确了。Spring Boot从2.0开始默认把spring.aop.proxy-target-class设置为true也就是说默认优先使用CGLIB。哪怕目标类实现了接口默认也是用CGLIB生成子类代理而不是JDK动态代理。想切回JDK代理在配置里改一下即可spring.aop.proxy-target-classfalse为什么Spring Boot要这么做直接原因是JDK动态代理有个特别坑的限制只能代理接口中声明的方法。假如你的实现类里有一个接口上没有的扩展方法而这个方法又被加上了Transactional注解用JDK代理时这个事务注解不会生效因为代理类只知道接口里的方法。为了减少这类代理不生效的诡异问题默认换成CGLIB更省心。2.4 多个切面怎么生效拦截器链与Order一个方法可能同时被日志切面、限流切面、事务切面命中那多个切面的执行顺序是什么Spring把每个切面封装成一个MethodInterceptor然后用一个拦截器链把这些增强逻辑串联起来。调用时从链头依次执行每个切面在proceed()时把调用传递给链条上的下一个切面直到最后一个切面真正调用目标方法。切面顺序可以用Order注解控制数值越小优先级越高。比如Aspect Component Order(1) public class RateLimitAspect { ... } Aspect Component Order(2) public class LogAspect { ... }这样限流逻辑会先执行限流不通过就直接抛异常根本不会走到日志切面去打印业务日志更不会执行目标方法。设计切面时一定要考虑执行顺序比如开启事务和操作日志通常是先开事务再记日志还是先记日志再开事务这个顺序直接决定了日志里能不能拿到事务回滚后的数据。注意Spring AOP基于代理实现代理对象只能拦截通过代理对象发起的调用。如果切面顺序搞错了或者代码里直接new目标对象AOP都会静默失效这是后面第五章要重点展开的坑。3. 实战基于注解的AOP接口限流3.1 为什么接口限流适合用AOP做网上搜aop基于注解的接口限流的文章特别多因为它确实是AOP的最佳实践之一。限流逻辑本身就是典型的横切关注点——它和业务逻辑无关但每个接口都想加如果每个接口里手写限流代码那代码会被污染得非常严重。用AOP做限流业务代码上只加一个注解干净整洁限流规则改了只动切面不需要动任何业务类。我曾在一个活动秒杀项目里给20多个接口加过限流如果不用注解切面每个接口里加一段Redis计数代码光是重复代码就能把人写吐而且特别容易漏掉try-catch导致Redis异常直接打挂业务。3.2 定义一个可扩展的限流注解先定义一个注解字段设计得灵活一点支持接口级限流、用户级限流、IP级限流Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RateLimit { // 限流key前缀一般是接口语义名比如order:create String key() default ; // 时间窗口单位秒默认1秒 int window() default 1; // 窗口内最大请求数 int limit() default 100; // 限流后的提示信息 String message() default 系统繁忙请稍后再试; }Retention(RetentionPolicy.RUNTIME)这行非常关键因为AOP是在运行时通过反射读取注解的如果保留策略是SOURCE或者CLASS运行时就拿不到注解了。3.3 编写限流切面配合Redis实现限流算法我推荐先用固定窗口计数实现简单、够用。核心思路用Redis的INCR命令给每个限流key计数如果是第一次请求计数为1同时设置过期时间等于窗口大小如果计数超过limit直接拒绝。Aspect Component public class RateLimitAspect { private static final String KEY_PREFIX rate:limit:; Autowired private StringRedisTemplate redisTemplate; Around(annotation(rateLimit)) public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { // 1. 组装限流key String methodName joinPoint.getSignature().toShortString(); String key KEY_PREFIX (rateLimit.key().isEmpty() ? methodName : rateLimit.key()); // 2. 使用Redis计数加上Lua保证原子性 Long current executeLimitScript(key, rateLimit.window(), rateLimit.limit()); if (current ! null current rateLimit.limit()) { throw new RuntimeException(rateLimit.message()); } // 3. 限流通过执行目标方法 return joinPoint.proceed(); } private Long executeLimitScript(String key, int window, int limit) { // Lua脚本如果key不存在设置值并设置过期时间否则自增并返回当前计数 String lua local c redis.call(get, KEYS[1]) if c false then redis.call(set, KEYS[1], 1) redis.call(expire, KEYS[1], ARGV[1]) return 1 else local n tonumber(c) if n tonumber(ARGV[2]) then redis.call(incr, KEYS[1]) return n 1 else return n end end; DefaultRedisScriptLong script new DefaultRedisScript(lua, Long.class); return redisTemplate.execute(script, Collections.singletonList(key), String.valueOf(window), String.valueOf(limit)); } }这里一定要把判断自增放进Lua脚本保证原子性。如果不这么做两个并发请求同时读到count99、limit100然后同时放行最终放行量会超过限流值。这在秒杀场景就是超卖事故。3.4 限流参数设置与关键细节限流切面里有三个细节值得单独说。第一key的设计要包含维度。接口级限流用方法名就行但要按用户控制频率比如一个用户一秒最多请求一次key就必须加上userId或IPString userId UserContext.getUserId(); String key KEY_PREFIX rateLimit.key() : userId;第二Redis不可用时的降级策略。我在生产环境一般会把Redis异常catch住打印告警日志然后放行请求。限流本质是保护系统的手段如果限流组件本身挂了导致全部业务不可用那就本末倒置了。降级的代价是可能短暂超卖但比全站宕机好得多。第三本地限流还是分布式限流。如果是单机应用用Guava的RateLimiter更轻量不依赖Redis性能也更好。一旦服务多实例部署本地限流就失效了——每个节点各放行100个加起来可能是300个必须改用Redis等分布式限流方案。选型表可以参考维度本地限流Guava分布式限流Redis适用场景单机部署、单实例内部保护多实例集群、接口网关层依赖无外部依赖依赖Redis存在网络开销数据一致性节点独立各限制各的全局统一计数性能毫秒级无网络IO有网络IO慢于本地但可接受典型实现RateLimiter、SemaphoreLua脚本INCR、固定窗口、滑动窗口使用限流注解后业务代码干净得像这样RateLimit(key order:create, window 1, limit 10) PostMapping(/order/create) public Result createOrder(RequestBody OrderDTO dto) { // 核心业务无需关心限流逻辑 return orderService.create(dto); }4. AOP五大高频使用场景4.1 操作日志与审计日志自动记录调用轨迹操作日志是AOP最常见的使用场景之一。业务系统里谁在什么时间调用了什么接口、传了什么参数、返回了什么结果这类审计诉求用一个AOP切面就能全局搞定Aspect Component public class OperationLogAspect { Autowired private OperationLogMapper logMapper; Around(annotation(operationLog)) public Object around(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable { long start System.currentTimeMillis(); Object result null; Throwable error null; try { result joinPoint.proceed(); return result; } catch (Throwable t) { error t; throw t; } finally { OperationLogEntity entity new OperationLogEntity(); entity.setMethod(joinPoint.getSignature().toShortString()); entity.setArgs(JSON.toJSONString(joinPoint.getArgs())); entity.setResult(error ! null ? error.getMessage() : JSON.toJSONString(result)); entity.setCost(System.currentTimeMillis() - start); entity.setOperator(UserContext.getUserId()); entity.setOperateTime(new Date()); logMapper.insert(entity); } } }这个切面有两个实操心得一是在finally里记录日志这样成功和失败都能记录下来二是记录操作人要从UserContext里取不能从前端传的参数里取否则用户可以伪造。这是审计系统最基本的防篡改要求。4.2 Spring事务的本质就是AOP很多人天天用Transactional但没想过它的底层是什么。Spring声明式事务本质就是一个事务切面方法执行前开启事务方法正常返回后提交事务方法抛出RuntimeException时回滚事务。这个事务切面是Spring框架内置的由TransactionInterceptor实现。Transactional(rollbackFor Exception.class) public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) { accountMapper.decrease(fromUserId, amount); accountMapper.increase(toUserId, amount); // 如果这里抛出异常前面两步都会回滚 }注意rollbackFor Exception.class这个细节这也是Spring事务最经典的坑。默认情况下Spring只对运行时异常RuntimeException和Error回滚对受检异常Checked Exception不回滚。如果业务代码里抛了个IOException事务照样提交数据就错了。用AOP思维理解事务只有通过代理对象调用的方法才走事务切面同类内直接调用this调用时Transactional会失效这也是AOP代理生效范围的经典问题。4.3 登录态校验与权限控制很多团队用拦截器HandlerInterceptor做登录校验但在非Web层或者更细粒度的方法上拦截器就管不了。用AOP实现权限校验可以精确到类、方法甚至参数。比如基于自定义注解控制接口权限Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequirePermission { String value(); } Aspect Component public class PermissionAspect { Before(annotation(requirePermission)) public void checkPermission(JoinPoint joinPoint, RequirePermission requirePermission) { String permission requirePermission.value(); if (!PermissionChecker.hasPermission(permission)) { throw new BusinessException(无权限访问); } } }实际项目里可以用AOP实现越权校验——通过解析方法参数里的userId判断当前登录用户是否有权操作该数据。这类逻辑写在每个接口里会疯掉的但放切面里就是一次配置全部接口统一生效。4.4 性能监控与慢接口告警做性能优化时需要统计每个接口的耗时、吞吐量、错误率。手动在每个方法里埋点太累了AOP一发入魂Aspect Component public class PerformanceMonitorAspect { Around(execution(* com.example.service..*.*(..))) public Object monitor(ProceedingJoinPoint joinPoint) throws Throwable { String methodName joinPoint.getSignature().toShortString(); long start System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { long cost System.currentTimeMillis() - start; if (cost 500) { System.err.println(慢接口告警: methodName 耗时 cost ms); } MetricsCollector.record(methodName, cost); } } }配合Micrometer或者Prometheus可以把耗时指标上报到监控系统接口一慢就能自动告警。线上排查性能问题的时候这份从切面拿到的全局方法论据最管用。4.5 幂等控制与缓存穿透防护接口幂等也可以让AOP兜底。比如重复提交问题用一个防重注解切面里基于Redis的SETNX实现分布式锁Aspect Component public class IdempotentAspect { Around(annotation(idempotent)) public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable { String key buildKey(idempotent, joinPoint.getArgs()); // SETNX 加锁key不存在才能加锁成功 Boolean success redisTemplate.opsForValue() .setIfAbsent(key, 1, Duration.ofSeconds(idempotent.expire())); if (!Boolean.TRUE.equals(success)) { throw new BusinessException(请勿重复提交); } try { return joinPoint.proceed(); } finally { redisTemplate.delete(key); } } }这样压测和并发环境下的重复提交问题就自动解决了。AOP在这类场景中的价值是一样的业务接口零入侵公共逻辑统一控制。5. 面试八股与实战避坑AOP失效场景大盘点5.1 同类内部调用导致AOP失效这个坑我估计十个新手九个踩。看下面这个例子Service public class OrderService { Transactional public void createOrder() { // 业务逻辑... updateStock(); } Transactional(propagation Propagation.REQUIRES_NEW) public void updateStock() { // 扣减库存 } }从外部调用orderService.createOrder()时事务切面确实生效了。但createOrder()方法内部调用updateStock()时注意这个this调用——它调用的是目标对象的真实方法不是代理对象的方法。而AOP增强是织在代理对象上的所以updateStock()上的REQUIRES_NEW事务根本不会生效它会被并入createOrder()这个方法的事务中。解决办法很简单从容器中直接注入自身代理或者用AopContext.currentProxy()((OrderService) AopContext.currentProxy()).updateStock();注意使用AopContext.currentProxy()需要先配置EnableAspectJAutoProxy(exposeProxy true)否则取不到当前代理对象。这个坑在Async注解、Cacheable注解上同样存在本质都是代理对象方法调用链问题。5.2 切点表达式匹配不到导致全部失效一个切面配好了但就是不生效最常见的原因是execution表达式写错。execution(* com.example.service..*.*(..))这串表达式很多人只是死记硬背不清楚每个部分的含义第一个*方法返回任意类型com.example.service..*匹配service包及其子包下的所有类最后一个*匹配任意方法名(..)匹配任意参数列表注意..的含义是包及其子包如果你写com.example.service.*就只匹配service包下的类子包里的不匹配。还有代理只能拦Spring容器管理的bean自己new出来的对象不可能被AOP拦截因为它根本不是Spring容器里的代理对象。5.3 Async注解的代理失效与线程边界问题AOP还容易和异步场景打架。比如一个方法标了Async但它是从同类其他方法里调用的那异步效果不会体现——因为走的是this调用不是代理对象调用切面没机会介入。即使是通过代理调用的也要注意异步线程里的AOP上下文问题比如RequestContextHolder取不到当前请求信息因为子线程没有继承父线程的请求上下文。这类问题我建议从设计上规避异步方法单独提取成一个独立Bean拆分时把代理调用链理顺。这也是为什么我写代码时习惯把异步子任务放到单独类里的原因。5.4 高频面试问法拆解结合上面这些内容我整理一个面试中经常出现的问题清单每个问题其实就是对你AOP掌握程度的测验面试问题回答要点AOP是什么解决了什么问题面向切面编程把日志、事务、鉴权等横切逻辑与业务逻辑解耦动静分离什么是JoinPoint、Pointcut、Advice连接点是被拦截的方法切点是筛选连接点的表达式通知是织入的具体逻辑JDK动态代理和CGLIB区别JDK代理基于接口CGLIB基于继承JDK代理只能代理接口方法CGLIB可以代理普通类但final类/方法不能被代理Spring AOP默认用哪种代理Spring Boot 2.x之后默认CGLIB通过spring.aop.proxy-target-class可以切换AOP和拦截器、过滤器有什么区别过滤器在Servlet层拦截器在Handler层AOP可以到任意Spring Bean的方法粒度三者层级不同、使用场景不同Spring事务失效的常见原因同类调用不经过代理、异常被吞没、方法不是public、rollbackFor没配受检异常、多线程调用等多个切面执行顺序如何控制通过Order注解值越小优先级越高内部通过拦截器链串联这些问题串起来看你会发现AOP的面试内核其实就一条主线代理机制。理解了Spring AOP全靠代理对象转发方法调用这一点所有失效场景都能自己推导出来。最后分享一个我在实际排查问题时的小技巧。当你怀疑某个切面不生效时直接在应用启动时打印所有代理类的类名看一眼目标Bean到底是Proxy还是原始的xxxServiceImplApplicationContext ctx SpringApplication.run(App.class, args); Object bean ctx.getBean(orderService); System.out.println(bean.getClass().getName()); // jdk代理com.sun.proxy.$Proxy72 // cglib代理com.example.service.OrderService$$EnhancerBySpringCGLIB$$1类名直接暴露代理类型这个信息能帮你快速定位是JDK代理的接口方法问题还是同类调用的失效问题。AOP这东西原理通了写起来和排查起来都会顺手很多。
返回列表