尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring AOP核心解密:静态代理、JDK动态代理与CGLIB

Spring AOP核心解密:静态代理、JDK动态代理与CGLIB AOP 这个词Spring 开发者几乎天天见但真到了面试或者排查线上问题时很多人的理解还停留在“AOP 就是拦截方法打个日志”这个层面。我见过不少简历上写着熟悉 Spring AOP结果一追问 JDK 动态代理和 CGLIB 有什么区别、静态代理和动态代理到底差在哪回答就变得支支吾吾。这篇文章不打算讲那种学院派的大道理就从实际项目里的问题出发把静态代理、JDK 动态代理、CGLIB、AOP 核心概念、应用场景这整套链路掰开揉碎讲清楚顺便把我在真实项目里踩过的坑也一并分享出来。先给这篇文章定个位如果你刚接触 Spring想搞明白 AOP 到底在解决什么问题可以看如果你有几年经验但是对代理选择和自调用失效这些细节一直没吃透这篇文章同样适合你。我会把代码、原理、排查经验混在一起讲尽量让每种机制都落到真实场景里看完能直接用得上。1. 为什么需要 AOP解决哪些真实的麻烦1.1 横切关注点是什么在很多业务系统里有大量的方法都在做同一类事情记录日志、校验权限、统计耗时、处理事务。你去翻一套老代码经常能看到这种模式public void createOrder(OrderDTO dto) { log.info(createOrder start, dto {}, dto); long start System.currentTimeMillis(); try { // 业务逻辑 orderService.save(dto); } catch (Exception e) { log.error(createOrder error, e); throw e; } finally { log.info(createOrder end, cost {} ms, System.currentTimeMillis() - start); } }一个两个方法这么写还能忍当你有几十个上百个接口都重复这段模板代码时问题就出来了代码极度冗余维护成本高而且很容易写漏。今天你想统一在方法入口加一个操作人信息就得把所有方法都改一遍稍不注意就漏掉某个异步线程池里的方法。这类逻辑有一个共同特点它们不属于核心业务却横跨了几乎所有业务方法。日志、安全、事务这些逻辑在垂直方向贴在了业务方法上所以叫横切关注点。AOP 要解决的核心问题就是把这类横切关注点从业务代码里剥离出来用一种统一的方式去处理让业务方法只关心自己的业务逻辑。1.2 面向对象解决不了什么很多人一开始的想法是那我抽一个工具类把日志逻辑封装起来然后每个方法里调用一下不就行了这确实能减少一部分重复代码但本质问题还在——你仍然需要在每个业务方法里主动调用这个工具类调用关系是显式写在业务代码里的。这属于侵入式的方案业务代码会被非业务逻辑污染。面向对象的核心封装是纵向的也就是我们通常说的自上而下的功能分解。但横切关注点是横向贴在很多类和方法上的OOP 很难优雅地表达“一堆方法在开始之前做 X、在结束之后做 Y”这种抽象。代理模式之所以成为解决方案的起点是因为它允许我们在不修改目标类源码的情况下通过一个代理对象把额外的逻辑插入到方法调用链路里。这也是为什么很多讲解 AOP 的文章都会先从代理模式讲起。因为 AOP 的实现底层本质上就是在代理对象上做文章Spring AOP 更是直接建立在动态代理之上的。2. 静态代理最笨但最扎实的代理方案2.1 静态代理的落地方式静态代理是最容易理解的代理方式我们手动创建代理类让代理类和目标类实现同一个接口代理类持有目标类的引用然后在代理方法里织入额外逻辑。用一个实际例子来说假设有一个用户查询接口public interface UserService { User getUserById(Long id); } public class UserServiceImpl implements UserService { Override public User getUserById(Long id) { // 模拟数据库查询 return new User(id, 张三); } } public class UserServiceProxy implements UserService { private final UserService target; public UserServiceProxy(UserService target) { this.target target; } Override public User getUserById(Long id) { long start System.currentTimeMillis(); try { System.out.println(before: 查询用户id id); return target.getUserById(id); } finally { System.out.println(after: 耗时 (System.currentTimeMillis() - start) ms); } } }使用的时候客户端不再直接持有 UserServiceImpl而是持有 UserServiceProxy。整个过程的精髓在于调用方感知的是 UserService 接口目标类的业务逻辑完全不用改代理类在方法前后插入逻辑。2.2 静态代理的致命伤这个方案的问题是显而易见的。第一每个需要代理的业务类都要手工创建一个对应的代理类代码量甚至超过业务类本身。第二一旦接口新增方法代理类和目标类都得同步改。第三如果代理逻辑本身也要扩展比如今天日志格式变了明天多了一个权限校验代理类就会越来越臃肿。静态代理最大的价值不是让你在生产环境用它而是帮你建立理解动态代理的必要认知代理的本质是间接访问是在调用链路上插入额外处理。没有这个认知后面看动态代理和 AOP 的时候容易一头雾水。实际上Spring AOP 做的事情和静态代理是类似的只是它把创建代理类的过程自动化了并且把插入逻辑的部分做成了可配置的切面。3. JDK 动态代理与 CGLIB动态代理的两条路线3.1 JDK 动态代理基于接口的代理静态代理要手动写代理类动态代理则可以运行时生成代理类。JDK 动态代理是 Java 自带的能力核心是java.lang.reflect.Proxy和InvocationHandler两个角色。它要求目标对象必须实现至少一个接口代理对象会在运行时动态生成并且同样实现目标对象的接口。public class UserServiceInvocationHandler implements InvocationHandler { private final Object target; public UserServiceInvocationHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { long start System.currentTimeMillis(); try { System.out.println(before: method.getName()); return method.invoke(target, args); } finally { System.out.println(after: method.getName() 耗时 (System.currentTimeMillis() - start) ms); } } } UserService proxy (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class[]{UserService.class}, new UserServiceInvocationHandler(new UserServiceImpl()) );从代码里可以看到我们不再需要为每个类手工创建代理类只需要写一个通用的 InvocationHandler拦截所有方法调用再在 invoke 方法里统一加逻辑即可。这就是动态代理比静态代理先进的核心代理逻辑和业务类解耦业务类增加方法时不需要同步修改代理类。3.2 CGLIB基于继承的代理JDK 动态代理好用但限制也很明显目标类必须实现接口。很多项目里确实存在只有实现类、没有接口的情况或者你引入了一个第三方 jar 包里面的类压根没接口。这时候就要上 CGLIB 了。CGLIB 的原理不是基于接口而是基于继承。它通过生成目标类的子类来创建代理对象并且在子类中重写父类的方法在重写逻辑里加入拦截处理。用代码来表示大致是这样的思路Enhancer enhancer new Enhancer(); enhancer.setSuperclass(UserServiceImpl.class); enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) - { long start System.currentTimeMillis(); try { System.out.println(before: method.getName()); return proxy.invokeSuper(obj, args); } finally { System.out.println(after: method.getName() 耗时 (System.currentTimeMillis() - start) ms); } }); UserServiceImpl proxy (UserServiceImpl) enhancer.create();因为 CGLIB 是生成目标类的子类所以目标类不能是 final 的被代理的方法也不能是 final 或 private 的否则无法重写。这是理解 Spring AOP 各种诡异问题的关键前提。3.3 Spring 如何选择代理方式Spring AOP 在创建代理时有一套自己的判断逻辑。在 Spring Boot 2.x 及之前默认策略是如果目标对象实现了接口优先使用 JDK 动态代理如果没有实现接口才使用 CGLIB。从 Spring Boot 2.x 开始官方把默认策略改成了强制使用 CGLIB也就是spring.aop.proxy-target-classtrue成为默认。这里有一个很容易被忽略的知识点Spring Boot 2.x 之后即使你的类实现了接口默认也不再走 JDK 动态代理而是直接用 CGLIB 生成子类代理。很多人拿老教程里的说法“Spring 默认接口用 JDK 代理”去套 Spring Boot 项目结果发现调试时代理对象的类型是xxx$$EnhancerBySpringCGLIB就觉得很奇怪其实就是默认策略变了。代理方式的选择还会影响注入方式。如果使用 JDK 动态代理代理对象是接口类型那么注入的时候就必须按接口类型注入如果强行按实现类类型注入启动时就会报类型不匹配。改用 CGLIB 之后因为代理对象是目标类的子类按实现类类型注入倒也能工作。这也是很多项目在迁移 Spring Boot 2.x 时踩到的坑。4. AOP 核心概念拆解4.1 连接点、切点、通知、切面在真正使用 Spring AOP 注解之前得先把这几个名词搞清楚。这些概念不是拿来背的它们是理解切面如何生效的地图。连接点Join Point是一个可以被拦截的候选位置。在 Spring AOP 里连接点通常就是方法调用比如userService.getUserById(id)这个方法调用。切点Pointcut则是一组连接点的集合条件它告诉 Spring“哪些方法需要被拦截”。通知Advice是切点匹配成功后要执行的逻辑比如打印日志、记录耗时。切面Aspect则是切点和通知的组合体通常一个切面类里定义了多个关注点。注意AOP 中的“切点”和“连接点”这个关系你可以类比成 SQL 里的 WHERE 条件与表记录。连接点是表里的数据切点是筛选条件通知是命中后执行的动作。Spring AOP 默认只能拦截 Spring 容器管理的 bean 的 public 方法。如果你试图对一个 private 方法做切点匹配或者对 Spring 管理的 bean 内部调用做拦截大概率会失败。这个限制的根源就在代理机制上代理只能代理可继承、可重写的方法。4.2 通知类型和常用注解Spring AOP 提供了五种通知类型分别对应方法执行链路的不同位置。实际开发中用的最多的是Before、AfterReturning、AfterThrowing、AroundAfter虽然也能用但通常能被Around更精细地替代。直接写一个完整切面类来展示Aspect Component public class LogAspect { Pointcut(execution(* com.example.service..*(..))) public void servicePointcut() {} Before(servicePointcut()) public void beforeLog(JoinPoint joinPoint) { System.out.println(before: joinPoint.getSignature().getName()); } AfterReturning(value servicePointcut(), returning result) public void afterReturningLog(Object result) { System.out.println(afterReturning: result); } AfterThrowing(value servicePointcut(), throwing ex) public void afterThrowingLog(Exception ex) { System.out.println(afterThrowing: ex.getMessage()); } Around(servicePointcut()) public Object aroundLog(ProceedingJoinPoint joinPoint) throws Throwable { long start System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { System.out.println(cost (System.currentTimeMillis() - start) ms); } } }需要注意Around里一定要调用joinPoint.proceed()否则目标方法根本不会执行业务会直接短路。我在代码评审里见过有人把 proceed 写在 if 分支里导致特定条件下方法静默不执行线上排查了很久才发现是切面逻辑的问题。4.3 切面执行顺序当一个方法匹配多个切面时切面的执行顺序需要明确。Spring 支持通过Order注解控制顺序数值越小优先级越高。比如事务切面的 order 通常设置为最高优先级这样它最先开启事务也最后提交事务保证业务切面执行在事务上下文里。默认情况下如果多个切面没有指定顺序Spring 的排序规则是先按 Order 注解的值排序如果没有注解则按切面类的名字或 bean 加载顺序来这个顺序是不稳定的别依赖它。我的经验是只要涉及事务、锁、多数据源切换这类操作就显式给切面设置 order不然不同版本升级后顺序可能悄无声息地变化产生非常隐蔽的 bug。5. 常见应用场景和实操案例5.1 日志记录日志记录是 AOP 最常见的入门场景。不过生产级日志切面没有上面那个例子那么简单它需要处理好几个细节参数序列化、敏感字段脱敏、异常堆栈保留、异步输出。举个例子一个健壮的日志切面对返回值和参数要做长度截断否则一个超大对象被打印出来日志系统直接被打爆。同时对于密码、手机号等敏感字段序列化前要做脱敏处理。我一般建议日志切面里不要直接打印整个 request 对象而是只打印方法名、入参的 hash 或摘要、执行耗时、异常类型这些关键信息。日志切面还有一个容易忽略的问题它本身会增加方法调用链的开销。如果你在流量很大的热点方法上也打全量日志性能损耗会很明显。实际项目中我会设计一个开关通过配置中心动态控制某些切点的日志是否输出而不是一刀切全打或者全不打。5.2 权限校验权限校验是 AOP 的另一个经典应用。把权限判断放在切面里可以避免在业务方法里散落一堆权限判断代码也能统一处理鉴权失败时的响应。常见做法是自定义一个注解比如RequirePermission(order:create)然后写一个切面拦截所有标注了该注解的方法。切面里获取当前登录用户信息检查用户权限集合里是否有对应权限如果没有就抛出业务异常。这样做的好处是权限规则和业务代码分离以后权限模型变更只需要修改切面逻辑不需要动业务方法。不过要小心一个问题切面里获取用户信息通常依赖上下文比如ThreadLocal。而真正生产环境里会有异步线程池或者内部调用其他服务ThreadLocal 里的用户信息可能传递不到子线程。我在设计权限切面的时候会先确认这些方法是否可能被异步调用如果会就需要做上下文传递或者修改检查方式。5.3 事务管理Spring 的声明式事务管理是很多开发者最容易忽视的 AOP 应用其实你天天在用的Transactional底层就是一个事务切面。你只需要在方法上打一个注解Spring 就会在方法执行前开启事务执行成功后提交出现异常时回滚。理解这一点对排查问题非常重要。比如事务失效的一个高频原因就是同类内部方法调用this.save()这种方式不会走代理对象所以事务切面拦截不到。解决办法通常是注入代理对象或者拆到另一个 bean 里调用又或者用AopContext.currentProxy()获取当前代理对象后再调用。还有一个细节是事务默认只回滚RuntimeException和Error。如果你抛了一个受检异常比如IOException事务是不会回滚的除非在Transactional里显式指定rollbackFor Exception.class。这个坑几乎每个人都会踩一次我建议团队里把rollbackFor Throwable.class作为默认约定。5.4 性能监控性能监控是 AOP 在运维层面的重要用途。利用Around通知可以在方法执行前后计算出耗时同时记录调用次数、异常率甚至可以配合 Micrometer 把这些指标直接暴露给 Prometheus 等监控系统。通常业务系统的性能监控分两个层级接口层监控和关键方法监控。接口层监控可以用拦截器或过滤器实现而方法级监控就需要 AOP 了。比如某个核心服务的某个方法经常出现慢调用你就给这个方法定义一个细粒度的切点单独采集耗时分布。采集到的数据可以打到日志里也可以直接写入时序数据库。这里有个实操心得方法耗时采集本身不能影响业务性能所以切面里的统计逻辑要尽量轻量。不要在切面里做复杂计算、不要同步发送网络请求、不要在热点切面里频繁创建对象否则监控逻辑会拖慢业务方法最终监控到的数据反而是失真的。6. 常见问题与排查技巧实录6.1 自调用失效自调用失效是指在一个类的内部一个方法调用另一个带事务或带切面的方法时切面不生效。比如Service public class OrderService { Transactional public void createOrder(OrderDTO dto) { // ... sendMessage(); } Transactional(propagation Propagation.REQUIRES_NEW) public void sendMessage() { // ... } }这里createOrder内部直接调用this.sendMessage()这实际上调用的是目标对象的原始方法而不是 Spring 生成的代理对象方法所以REQUIRES_NEW不会起作用。解决方式有三种把sendMessage拆到另一个Service类里注入ApplicationContext通过applicationContext.getBean(OrderService.class)获取代理对象再调用或者配置暴露代理对象用((OrderService) AopContext.currentProxy()).sendMessage()。我比较推荐拆类因为它简单清晰也最容易让其他同事理解。6.2 代理类型导致的问题Spring Boot 2.x 默认使用 CGLIB这本身不是问题但会带来一些连带影响。比如目标类是 final 类或者目标方法被 final 修饰这时代理对象生成会直接报错。另外如果你在代码里用instanceof判断某个 bean 是否是某个接口的实现要注意 Spring 容器里的 bean 可能是一个 CGLIB 子类对象类型判断可能会出现意料之外的结果。还有一种情况是Autowired注入接口类型时如果这个接口有多个实现类Spring 会因为无法确定具体注入哪个实现类而报错。这个锅不完全是 AOP 的但是加了代理之后错误信息会变得更难分析。遇到这类问题建议先把切面排除掉看看原始 bean 能否正常注入再逐步加回切面排查。6.3 Spring Boot 默认使用 CGLIB 的坑与应对刚说到 Spring Boot 2.x 默认使用 CGLIB如果你是老项目升级以前代码里大量Autowired注入实现类类型的写法可能在旧版本没暴露问题升级后突然启动报错。原因是旧版本接口类型用的 JDK 动态代理代理对象不是实现类的子类无法按实现类类型注入。应对方法是两个方向一是在配置文件里设置spring.aop.proxy-target-classfalse回到 JDK 动态代理模式但这不是长久之计二是规范注入类型尽量都按接口注入。从长远看按接口编程本来就是更稳的做法代理模式只是在背后放大了这个规范的价值。6.4 切点表达式常见错误切点表达式写错是 AOP 不生效的最常见原因之一。常见错误包括包路径写错、..用错位置、返回值类型写错等。举个例子execution(* com.example.service..*(..))表示com.example.service包及其子包下的所有方法注意是service..*不是service.*。如果是service.*就只会匹配该包下的直接类不会匹配子包。另一个容易踩的点是Spring AOP 的切点匹配是基于代理方法的因此接口上定义的 default 方法、静态方法、构造方法都无法被拦截。如果你确认切面没生效第一步先确认目标方法是不是 public、目标 bean 是不是 Spring 管理的第二步再检查切点表达式。如果还不行就打开 Spring 的 debug 日志观察切面匹配情况一般能快速定位。排查这些问题的时候建议写一个临时测试方法故意在方法里抛异常看切面有没有被触发这样比肉眼盯表达式高效得多。最后分享一个我在实际项目里的体会学习 AOP 最忌讳死记硬背概念最好的方式是亲手在公司代码或者开源项目里找一个现成切面把Around里的逻辑一行行拆开来读弄清楚切点匹配到了哪里、通知什么时候执行、代理对象是怎么生成的。你把它当成一个黑盒子去用和把它拆开看一遍再装回去效果完全不一样。遇到Transactional失效、权限切面不触发这些怪问题时先冷静判断现在你这个位置拿到的到底是不是代理对象这个是所有排查工作的起点。
返回列表