ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AOP核心原理与实战:JDK动态代理、CGLIB与Spring AOP全解析

AOP核心原理与实战:JDK动态代理、CGLIB与Spring AOP全解析 1. 为什么你写的业务代码总在重复同一件事如果你打开一个真实的后端项目随便翻几个Service方法大概率会看到这样的画面每个方法开头先判断权限然后打一行日志开始执行中间写真正的业务逻辑最后在结束位置再打一行执行完毕顺手算个耗时。要是方法里抛了异常还得有个try-catch把异常记录下来再往外抛。一周下来你发现自己写了四十遍记录日志三十遍校验权限二十遍统计耗时。这些逻辑和用户下单“查询订单详情”这些核心业务没有任何关系但它们就是赖在每个方法里不走。更麻烦的是今天产品经理说所有写操作都要加审计记录你新建一个方法时还记得要加老方法就漏了。我们管这类逻辑叫横切关注点——它们像一把刀横着切过所有业务方法与业务本身垂直。AOPAspect Oriented Programming面向切面编程就是专门解决这个问题的把那些横切逻辑从业务代码里抽出来统一写在一个地方再通过某种机制“织入”到目标方法的前后左右。这篇内容是写给刚接触AOP的小白的我会把AOP原理里最绕的JDK动态代理和CGLIB动态代理讲明白也会聊透Spring AOP到底怎么落地、适合用在哪些场景。我默认你写过几行Java看过SpringBoot的基本用法但不要求你懂代理模式跟着代码一步一步来就行。先给个直观结论AOP不是什么黑魔法它的本质就是我帮你包了一层方法。你看别人的博客天天讲面向切面其实拆开看就三部分——切在哪里、做什么、什么时候做。后面全部内容都在展开这三件事。2. AOP的三板斧切点、通知、切面是怎么配合的2.1 从外卖订单理解AOP的核心概念我第一次学AOP被一堆术语砸晕了Aspect、JoinPoint、Pointcut、Advice、Weaving、Target。这些词翻译过来分别是切面、连接点、切点、通知、织入、目标对象。看着高大上换成外卖场景就好懂了。假设你是一家餐厅核心工作是做菜业务逻辑。但你发现每个订单都得分三步接单时记录顾客要求出餐时检查有没有漏放餐具送走时记录出餐时间。后厨里每个做菜师傅目标对象都得干这些活儿师傅们烦得要死。AOP的做法是你在后厨门口装一个出餐流水线处理器只认订单连接点即程序执行过程中的某个位置比如方法调用。然后你规定所有做菜方法的调用点就是切点Pointcut也就是流水线的感应范围。处理器里配置的那些动作——记录要求、检查餐具、记录时间——就是通知Advice。处理器本体就是切面Aspect。把处理器装到后厨门口让每个订单自动过一遍这个安装动作叫织入Weaving。术语表给小白放这里术语一句人话解释目标对象 Target被AOP改造的那个Bean你的业务Service连接点 JoinPoint程序执行中能插入逻辑的位置在Spring里通常指方法调用切点 Pointcut通过表达式描述哪些连接点要管比如所有以Service结尾的类里的所有方法通知 Advice干活逻辑本身比如打日志、算耗时切面 Aspect切点通知的组合体通常是一个带Aspect注解的类织入 Weaving把通知应用到切点所匹配的方法上的过程2.2 五种通知你打算在什么时候插手搞清楚了切哪里下一件事就是什么时候做。AOP通知一共五种Before方法执行前跑。适合做权限校验、初始化参数、记录开始时间。AfterReturning方法正常返回后跑。适合记录结果、生成审计日志。AfterThrowing方法抛异常后跑。适合统一异常记录、发送告警。After不管正常还是异常方法结束后都要跑。类似finally块适合释放资源。Around最强的一种把方法的整个调用包在中间你可以决定要不要执行原来方法“执行前干什么”执行后干什么。适合统计耗时、加缓存、做重试。这五种通知在Spring里都是注解形式写在切面类的方法上。我用得最多的就是Around。因为它能完全控制目标方法的生命周期像这样Around(execution(* com.example.service.*.*(..))) public Object around(ProceedingJoinPoint joinPoint) throws Throwable { long start System.currentTimeMillis(); try { Object result joinPoint.proceed(); // 调用真正的方法 return result; } finally { System.out.println(耗时: (System.currentTimeMillis() - start)); } }这里joinPoint.proceed()是灵魂——它就是继续执行原方法的意思。你在它前后写什么就相当于在原方法前后插入了什么逻辑。2.3 切点表达式到底怎么写很多小白卡在切点表达式上。Spring AOP最常用的切点表达式是execution格式是execution(修饰符 返回类型 类路径.方法名(参数) 异常)实际操作时修饰符和异常经常省略常见三种写法// 匹配com.example.service包下所有类的所有方法 execution(* com.example.service.*.*(..)) // 匹配某个具体方法callOrder是方法名第一个参数是Long execution(* com.example.service.OrderService.callOrder(Long)) // 匹配所有public方法 execution(public * *(..))..代表任意数量参数*代表任意返回类型或任意类名。初次记不住没关系IDE里有AspectJ插件能帮你校验表达式。另一个常用切点类型是annotation匹配方法上带了某个注解的方法。比如你自定义一个AuditLog注解然后在需要审计的接口方法上打这个注解切点表达式就写annotation(com.example.annotation.AuditLog)。这种方式比execution精确得多也是我在真实项目里推荐的方式——切点语义直接跟着注解走方法加不加审计一目了然。3. 织入的秘密JDK动态代理和CGLIB动态代理到底在干嘛3.1 为什么Spring非要用代理搞懂前面概念你可能会问Spring是怎么把通知逻辑塞进目标方法里的它不可能真的改掉你的class文件也不想去重写你的Service类。答案就四个字动态代理。Spring会为你的目标对象生成一个代理对象Proxy然后让你注入的其实是这个代理。当你调用orderService.callOrder()时外界的引用指向的是代理对象代理对象负责先执行你的切面逻辑再通过反射或继承机制调用真实目标方法。原代码一行没改行为却变了。动态代理有两种实现路线JDK动态代理和CGLIB动态代理。这两个词在AOP面试里几乎必考理解它们就是理解AOP原理的钥匙。3.2 JDK动态代理只能代理有接口的类JDK动态代理从Java 1.3就有核心就两个东西java.lang.reflect.Proxy和java.lang.reflect.InvocationHandler。它要求目标类必须实现接口代理对象也只能以接口形式暴露给外部。原理是运行时用Proxy.newProxyInstance()生成一个实现该接口的类实例这个实例内部持有InvocationHandler每次调用接口方法时都会走到invoke()方法里。我写一个最小示例。先定义接口和实现类public interface UserService { String getUserName(Long userId); } public class UserServiceImpl implements UserService { Override public String getUserName(Long userId) { System.out.println(真实方法执行); return 用户 userId; } }然后建一个InvocationHandler在这里插入日志逻辑public class LogHandler implements InvocationHandler { private final Object target; public LogHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(调用前: method.getName()); Object result method.invoke(target, args); System.out.println(调用后: method.getName()); return result; } }最后生成代理对象并调用UserServiceImpl impl new UserServiceImpl(); UserService proxyInstance (UserService) Proxy.newProxyInstance( impl.getClass().getClassLoader(), impl.getClass().getInterfaces(), new LogHandler(impl) ); proxyInstance.getUserName(1001);控制台会依次输出调用前: getUserName、真实方法执行、调用后: getUserName。请注意我们从头到尾没改过UserServiceImpl的代码只是给它套了一层处理器。这里有个关键点代理对象只能强制转换成UserService接口不能直接转成UserServiceImpl。因为JDK代理生成的类只实现接口并不继承实现类。你在Spring里注入时要是按实现类类型注入就会拿到一个ClassCastException。3.3 CGLIB动态代理不需要接口靠生成子类CGLIBCode Generation Library走的是另一条路——生成目标类的子类通过方法重写来织入逻辑。它代理的核心类是Enhancer配合MethodInterceptor接口使用。同样是上面的场景不定义接口直接一个普通类public class OrderService { public String createOrder(String productId) { System.out.println(创建订单业务逻辑); return 订单 productId; } }CGLIB代理写法public class CglibProxyFactory { public static Object createProxy(Object target) { Enhancer enhancer new Enhancer(); enhancer.setSuperclass(target.getClass()); enhancer.setCallback((MethodInterceptor) (obj, method, args, methodProxy) - { System.out.println(CGLIB调用前: method.getName()); Object result methodProxy.invokeSuper(obj, args); System.out.println(CGLIB调用后: method.getName()); return result; }); return enhancer.create(); } }调用OrderService proxy (OrderService) CglibProxyFactory.createProxy(new OrderService()); proxy.createOrder(A100);注意这里methodProxy.invokeSuper(obj, args)这句话的意思是调用父类也就是目标类的原方法。CGLIB生成的代理是OrderService的子类所以可以向上转型成OrderService使用方式上比JDK代理更自由。但继承这条路是有代价的final类无法被代理final方法也无法被重写。你一旦在Service方法上加了final关键字CGLIB就绕不过去只能失效。这个坑我后面还会提到。3.4 一张表说清两者怎么选网上对比JDK动态代理和CGLIB动态代理的文章很多我结合实测和经验给你一张实用表格对比维度JDK动态代理CGLIB动态代理必要条件目标类必须实现至少一个接口无需接口但类/方法不能是final代理生成方式实现接口运行时动态生成代理类继承目标类生成子类生成代理对象速度相对快相对慢要生成子类字节码方法调用速度通过反射调用相对慢通过FastClass直接调用相对快SpringBoot 2.x默认不再优先使用默认使用典型局限只能用接口接收代理对象final类/方法失效关于速度对比过去很多文章说CGLIB调用速度优于JDK这是基于老版本JDK的结论。JDK版本升级后反射调用也在持续优化实际差距在小方法热调用场景并不明显。真正影响选择的是有没有接口和是否兼容final而不是性能数字。Spring的旧版本策略是有接口就JDK没接口就CGLIB。SpringBoot 2.x之后默认强制使用CGLIB就算你的类实现了接口也会优先用CGLIB。想切回JDK代理可以在配置里设置spring.aop.proxy-target-classfalse。我在生产项目里基本开默认值只有在和某些字节码增强框架冲突时才会改动。4. Spring AOP/SpringBoot里的真实配置从注解到落地4.1 最小依赖和切面类的完整骨架原理讲完必须落到SpringBoot工程里跑通一个真实切面。先把依赖加上SpringBoot的web和aop两个starterdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency注意spring-boot-starter-web本身会带AOP相关的传递依赖但为了保险我还是建议显式加spring-boot-starter-aop确保切面能力完整可用。然后写一个切面类我拿统计每个Service方法耗时记录参数做例子Aspect Component public class PerformanceLogAspect { private static final Logger log LoggerFactory.getLogger(PerformanceLogAspect.class); Around(execution(* com.example.demo.service.*.*(..))) public Object logPerformance(ProceedingJoinPoint joinPoint) throws Throwable { String methodName joinPoint.getSignature().getDeclaringTypeName() . joinPoint.getSignature().getName(); Object[] args joinPoint.getArgs(); long start System.currentTimeMillis(); try { Object result joinPoint.proceed(); log.info(方法[{}] 参数[{}] 返回[{}] 耗时[{}ms], methodName, Arrays.toString(args), result, System.currentTimeMillis() - start); return result; } catch (Throwable throwable) { log.error(方法[{}] 参数[{}] 异常[{}] 耗时[{}ms], methodName, Arrays.toString(args), throwable.getMessage(), System.currentTimeMillis() - start); throw throwable; } } }Aspect表示Spring要把它识别成切面Component保证它能被扫描注册成Bean。Around里用joinPoint.getSignature()拿到方法名用getArgs()拿到入参proceed()执行真实方法。异常时记录了错误信息后必须throw throwable重新抛出去否则业务方拿不到异常事务回滚也会失灵。4.2 切点复用用Pointcut把表达式抽出来如果同一个切点表达式在多处出现比如前置通知、返回通知、异常通知都要匹配同一个范围每个注解里写一遍表达式又长又容易抄错。可以用Pointcut定义一个空方法其他通知引用它Aspect Component public class AuditAspect { Pointcut(execution(* com.example.demo.service.*.*(..))) public void servicePointcut() {} Before(servicePointcut()) public void beforeCheck() { System.out.println(前置校验); } AfterReturning(servicePointcut()) public void afterReturn() { System.out.println(正常返回); } }定义Pointcut的方法本身是个空方法它只是切点表达式的载体不会被业务调用。这种方式在切面逻辑复杂时能让代码整洁不少。4.3 通知方法的参数怎么拿你可能注意到不同通知拿方法信息、参数、返回值、异常的方式不一样。我整理了一组对应关系通知想要的信息方法参数写法Before方法签名、参数JoinPoint joinPointAfterReturning返回值AfterReturning(pointcut servicePointcut(), returning result) 形参Object resultAfterThrowing异常AfterThrowing(pointcut servicePointcut(), throwing ex) 形参Exception exAround全部信息继续执行ProceedingJoinPoint joinPointBefore拿参数很简单joinPoint.getArgs()拿参数数组joinPoint.getSignature()拿方法签名。AfterReturning里要拿返回值必须在注解里用returning属性指定一个名字然后让方法形参也叫这个名字Spring才会通过反射把返回值塞进来。名字对不上是拿不到的这是个非常容易踩的细节。Around因为自己控制整个执行流程所以能同时拿到参数、返回值和异常不用分别写三个通知这也是我偏爱它的原因。4.4 Spring AOP切点表达式中容易踩的细节切点表达式写在注解里看起来是字符串实际上它对包名类名参数匹配有自己一套严格约定。我总结三个常见坑第一个*匹配的是单个包不是多级包。execution(* com.example.service.*.*(..))这个表达式只匹配com.example.service包下的类不匹配com.example.service.sub包下的类。想匹配子包得写com.example.service..*中间两个点代表匹配当前包及任意子包。第二个参数匹配写..会匹配任意参数写(String)会精确匹配只有一个String参数的签名。execution(* com.example.service.OrderService.createOrder(Long))这是精确匹配一个Long参数。如果你写createOrder(String, ..)意思是第一个参数是String后续参数任意。弄混了会导致切面不生效排查时又看不出来因为日志和异常都没有。第三个切点表达式写错了不一定启动报错。有些写错的表达式在应用启动时没有任何异常只有调用不匹配的方法时切面静默不执行。排查这类问题最有效的办法是开AOP的调试日志在application.yml里加logging: level: org.springframework.aop: DEBUG这样启动时Spring会把匹配到的切点和方法关系打出来一眼就能看到你的切点有没有被正确解析。5. AOP适合干什么、不适合干什么使用场景与边界5.1 从我实际项目看AOP用得最爽的几个地方统一日志与审计。财务系统的所有订单金额变化都要留下谁在什么时间改了什么的记录。用annotation切点自定义一个AuditLog注解在有敏感操作的方法上打注解切面统一记录操作人、操作时间、修改前后值。这个场景AOP是绝对王者既不污染业务代码又能保证不会漏记。接口耗时与监控。我上家公司做过一个慢接口大屏所有Controller方法被AOP拦截后如果执行时间超过500ms就异步上报给监控系统。实现起来就是一个Around切面加一条ThreadPoolExecutor派发几十行代码搞定全站性能数据采集。权限校验。有些老项目没用Spring Security只要求管理员才能调某些接口。在切面里解析请求头里的token校验角色不通过就直接抛异常返回403。相比在每个Controller里写if判断切面统一管理明显更优雅。缓存与重试。如果某个方法的计算结果可以缓存可以写一个查缓存-没有就执行方法-存缓存的Around切面。RPC调用失败时的重试逻辑也可以放切面里代码一挂所有方法自动获得重试能力。事务管理。严格来说Spring事务就是AOP的经典应用Transactional背后就是通过代理对象提交/回滚连接。理解AOP之后回看Transactional莫名其妙的失效问题会豁然开朗。5.2 AOP不是万能的这几种情况千万别硬上AOP的横切能力很漂亮但滥用起来同样让人头疼。我在代码评审里见过不少用切面把业务逻辑包装成玄学的案例这部分是小白最容易跑偏的方向。不适合做复杂的跨模块业务编排。比如下单后要扣库存、发短信、推送消息、更新报表这些步骤有明确依赖关系和先后顺序业务语义非常强。你强行把它们拆成多个切面各种切入点代码看起来是优雅了但执行顺序、异常补偿、幂等控制全都变得难排查。这种场景应该老老实实写Service方法调用让逻辑可见。不适合在切面里做重活。切面里同步调用外部HTTP接口、跑复杂批量SQL、打印超大对象的toString都会拖慢目标方法而且因为代理关系调用链上多了一层隐形的慢操作。如果必须在切面里做非必须但耗时的事请异步化——丢线程池或者配合消息队列。切面执行有顺序风险。多个切面作用于同一个方法时它们之间的执行顺序由Order决定。默认情况下SpringBoot并没有保证切面执行的绝对顺序一旦两个切面相互依赖顺序结果就会飘忽不定。所以多切面场景一定要手动指定Order(1)、Order(2)数字小的先执行让顺序从随缘变成可控。异常处理的边界要格外小心。切面里catch Throwable后如果不重新抛出目标方法的异常就被吞掉了上游拿不到任何失败信号事务也可能不会回滚。很多人调完AOP发现方法没报错但数据不对多半就是切面里吞了异常。5.3 和拦截器Interceptor、过滤器Filter的对比这三者都具备在方法运行前后插入逻辑的能力很容易混。我给小白一个粗糙但有效的区分方式组件作用范围典型用途FilterServlet容器层面每个请求进出都会经过字符编码、跨域处理、接口签名验签InterceptorSpring MVC层面只拦截Controller请求登录态校验、请求头补充AOPSpring Bean方法层面可以拦截任意Service/Component方法日志、耗时、权限、缓存、事务选型建议凡是能通过Filter/Interceptor解决的别先想着上AOP。AOP虽然灵活但它介入的是Bean的方法调用比前面两者更底层排查问题也更难。比如跨域和编码问题用Filter天经地义事件日志记录用Interceptor或AOP都可以——如果面向的是纯后端Service层调用那AOP才是唯一选择。6. 小白最容易踩的坑代理失效、通知顺序、无限递归6.1 代理失效我切面明明写了方法却像没被拦这是AOP新手问得最多的问题症状通常是日志完全没打切面静悄悄。排除切点表达式写错之外最高频的三大元凶是元凶一同类内部调用。假设OrderService里有一个pay()方法内部调用了this.sendNotify()。你对sendNotify()配置了日志切面但调用是通过this直接发起的根本没有经过代理对象切面自然不触发。Spring的代理只能拦截从外部进入代理对象的调用代理内部的方法互调属于自调用代理对它爱莫能助。解决办法三个方向把内部调用改成通过注入自身的代理来调用不推荐容易循环依赖把需要被切的方法挪到另一个Service里或者像我一样干脆调整设计把sendNotify()做成切面自己调用的逻辑而不是让它作为被切目标。元凶二private方法。JDK动态代理基于接口CGLIB基于继承两者都操作不了private方法。private方法是类私有实现细节外界根本无法通过代理发起调用切点怎么可能拦到把切点表达式瞄准private方法是个典型误区。元凶三final类或final方法。CGLIB要生成子类才能代理final类不能继承final方法不能重写所以一旦碰到final代理直接失效。我在一个老项目里见过有人给Service类加final防继承保护结果所有AOP静默失效排查了一整个下午。想快速确认代理是否生效有个笨办法非常管用在目标方法里打印this.getClass()。如果输出的是class xxx.$$EnhancerBySpringCGLIB$$或者$Proxy之类的类名说明当前类已经被代理了如果输出的是你自己写的类名说明代理没缠上它问题就在代理链路的某个环节。6.2 自调用还有救吗用AopContext拿到原生代理如果你实在不想把内部调用拆出去Spring提供一个AopContext工具可以在目标方法内部获取当前代理对象然后通过代理调用另一个方法public void pay(Long orderId) { // 业务逻辑 OrderService proxy (OrderService) AopContext.currentProxy(); proxy.sendNotify(orderId); }但用它之前必须把spring.aop.expose-proxytrue打开否则会抛IllegalStateException: Cannot find current proxy。这个方案可行但说实话可读性比较差我在生产里只用来应急不推荐当常规写法。相比AopContext.currentProxy()更干净的解法是让两个方法分别处于不同Bean里天然形成代理调用。6.3 多个切面的顺序Order到底怎么用假设一个Service方法上同时挂了权限校验切面和耗时统计切面你希望先校验权限再统计耗时还是先统计耗时再校验权限语义差别很大。前者记录的是被拒绝的请求不需要统计耗时后者则是连被拒绝的请求都该有耗时数据可查。默认多个切面执行顺序不确定所以必须用Order控制。规则是Order(1)的执行在Order(2)之前如果不在不同切面上标注执行顺序依赖切面初始化顺序基本不受控Aspect Component Order(1) public class AuthAspect { ... } Aspect Component Order(2) public class PerformanceAspect { ... }注意一个细节Order控制的是切面之间嵌套的执行顺序。对Around通知来说编号小的切面的proceed()调用会包住编号大的切面。也就是说AuthAspect先执行它内部调用proceed时才轮到PerformanceAspect。如果你把顺序写反了效果完全相反。6.4 无限递归切面又拦截了切面自己给切面类的方法本身配上切点就会形成切面方法被代理拦截拦截后又记录日志日志方法又被代理拦截……的死循环。最典型的翻车是把切点表达式写成execution(* com.example.demo..*.*(..))范围太大把切面类自己所在的com.example.demo.aspect包也圈进去了。排查方法很简单切点范围不要贪大只锁定业务Service或Controller包另外把切面类单独放一个包用表达式明确排除它execution(* com.example.demo.service.*.*(..)) !within(com.example.demo.aspect..*)和!在AspectJ表达式里都是合法操作符within(com.example.demo.aspect..*)表示切面类所在范围内排除掉即可。6.5 事务注解突然失效先想想代理Transactional是AOP落地最典型的功能它的失效原因几乎都能用代理机制解释。比如同类中methodA()调用methodB()methodB上标了Transactional实际执行时因为走的是this直接调用事务切面根本没机会介入于是事务不生效。这个和自调用失效是同一个道理。所以排查Transactional失效问题时别急着调事务参数先打印一下类名确认当前对象是不是代理对象。如果是代理再看是不是自调用如果不是代理再看EnableTransactionManagement是否开启、事务管理器Bean是否配置正确。按这个顺序排查能省掉大量无用功。7. 从入门到能用我给你的三步建议看完这篇内容你已经把AOP原理、JDK动态代理、CGLIB动态代理、Spring AOP的配置和使用场景都走了一遍。最后我想结合自己实战带团队的经验给刚入门的朋友一段非常务实的建议。第一步先别追求一步到位理解所有内置原理而是把代码跑起来。建一个SpringBoot工程写一个最普通的Service加一个Around切面打日志故意制造一个自调用失效的现场用debug模式观察代理对象长什么样。自己亲眼看到代理对象比读十篇原理文章都管用。第二步把最常见的四个切点表达式背下来execution精确到类、execution精确到包、annotation注解方式、配合Pointcut复用。日常开发90%的场景就靠这四个组合。表达式不用全背IDE和文档都能查真正值钱的是你知道自己能控制到哪一层。第三步在你手头的存量项目里选一个真正重复的场景比如接口耗时统计用AOP重写一遍然后观察代码删减了多少。等你感受到几十个方法自动获得同一个能力的瞬间AOP算是真正入门了。最后留个实际体会AOP最考验人的地方不是技术本身而是边界感。它适合做横切逻辑不适合篡改业务语义。把切面当成一个能力补全层而不是业务放逐地你的代码会越写越干净。过阵子我再聊聊Spring AOP在这些代理机制之外的坑比如被final挡住的事务、异步切面线程上下文传递等等都是实战里能救命的细节。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进