资讯详情

代理模式从静态代理到Spring AOP:原理、实战与避坑指南

📅 2026/10/4 4:05:13 | 华诺云谱 👁 阅读
代理模式从静态代理到Spring AOP:原理、实战与避坑指南
从一次线上小事故说起。当时我们有个订单服务核心的支付接口里临时加了一段签名校验逻辑结果上线不到半小时订单成功率掉了一个点。定位到最后问题出在支付接口被另一个团队直接 new 出来调用压根没走我们后来加的那层校验。那一刻我意识到很多 Java 设计模式课堂上讲过的概念在真实项目里全是血泪教训——比如本文要聊的代理模式Proxy Pattern它本质上是在你和目标对象之间插一层但这一层插得好不好决定了你的代码是优雅还是事故现场。代理模式是 Java 面试里绕不开的常客也是 Spring AOP、MyBatis、Retrofit 这些框架背后的基础设计思路。这篇我不打算照着教科书念定义而是从代理模式到底解决了什么问题讲起把静态代理、JDK 动态代理、CGLIB 动态代理、Spring AOP 里的代理机制全部串起来最后再聊聊我实际踩过的坑。适合正在准备面试的 Java 开发也适合那些学了设计模式但不知道怎么用的朋友。1. 代理模式到底解决了什么问题1.1 从一次线上事故说起我先说那次的细节。我们订单服务里有个OrderService其中createOrder方法会调用PaymentService.pay()。早期这个接口很简单只是把支付请求转发给第三方。后来安全团队要求所有支付请求必须带上新的防重放签名而且这个校验不能放在第三方那边做必须在我们自己系统里做。我当时的第一反应是直接改PaymentServiceImpl把签名逻辑写在pay()方法开头。改完自测没问题上线后却发现支付成功率掉了。原因很蠢另一个团队对账系统为了做故障模拟直接new PaymentServiceImpl()绕过 Spring 容器调用了一次老版本的pay()。因为他们是本地 new 的对象压根不会走 Spring AOP也拿不到新加的校验逻辑。这个事故让我彻底明白一件事你在目标类内部加逻辑只能保证通过这个类调用时生效但别人完全可以绕过这个类、或者绕过 Spring 容器去调用底层实现。代理模式的价值就是给你一个唯一受控入口把横切逻辑从业务代码里抽出来。如果你把校验、日志、事务这些逻辑写在代理层那么无论哪个调用方只要走代理就必须经过这道坎。1.2 代理模式的三种角色和核心思路教科书里说代理模式有三个角色抽象主题Subject、真实主题RealSubject、代理Proxy。对应到代码里就是抽象主题定义业务方法的接口比如PaymentService。真实主题真正干活的类比如PaymentServiceImpl里面只写支付的核心业务。代理持有真实主题的引用在调用真实方法前后插入额外逻辑比如签名校验、日志记录。打个生活化的比方你找明星拍广告你不会直接联系明星本人而是联系经纪人。经纪人负责谈价格、排档期、收钱、审核合同明星只负责最后出镜。这里经纪人的角色就是代理明星就是真实主题而拍广告这件事的业务规范就是抽象主题。这个模式最核心的思路是控制访问和增强功能。控制访问的意思是代理可以决定这个请求到底要不要转发给真实对象比如权限校验失败就直接拒绝根本不用碰业务代码增强功能的意思是代理可以在转发前后做点额外的事比如打日志、做缓存、加事务。1.3 为什么不能直接改目标类很多人会问既然要加日志直接写在业务类里不行吗行小项目没问题。但一旦你面临下面几种情况直接改目标类就非常痛苦第一你改不了别人的类。比如第三方 SDK 里的类你没有源码只能用反射或者继承做扩展代理模式就是一个干净的手段。第二横切逻辑会污染业务代码。日志、鉴权、事务、性能统计这些逻辑和创建订单支付这种核心业务没有关系。全堆在业务方法里代码会变得非常难看而且这些逻辑往往要横跨几十个类复制粘贴就是噩梦。代理模式把这些横切逻辑集中到一处业务类继续保持干净。第三调用方可能绕过你。就像我开头那个事故如果校验逻辑写在PaymentServiceImpl里面别人直接 new 一个实现类也能调。而代理如果作为唯一对外入口就能从架构上强制所有调用方都经过统一的处理链路。所以我理解代理模式就是一句话给真实对象找个经纪人把业务以外的琐事全部拦在经纪人手里。接下来看看静态代理怎么实现。2. 静态代理最朴素也最容易被低估的实现2.1 一个带日志的支付接口静态代理静态代理是最直观的代理实现手动写一个代理类让它实现和真实主题相同的接口内部持有真实主题的引用然后在方法里围绕真实调用加逻辑。假设有个支付接口public interface PaymentService { boolean pay(String orderId, double amount); } public class PaymentServiceImpl implements PaymentService { Override public boolean pay(String orderId, double amount) { // 真实的第三方支付调用 System.out.println(调用第三方支付订单 orderId 金额 amount); return true; } }现在我要加日志和性能统计又不想改PaymentServiceImpl于是写一个静态代理public class PaymentServiceLogProxy implements PaymentService { private final PaymentService target; public PaymentServiceLogProxy(PaymentService target) { this.target target; } Override public boolean pay(String orderId, double amount) { long start System.currentTimeMillis(); System.out.println([日志] 开始支付订单 orderId 金额 amount); try { boolean result target.pay(orderId, amount); System.out.println([日志] 支付完成结果 result); return result; } finally { long cost System.currentTimeMillis() - start; System.out.println([性能] 耗时 cost ms); } } }调用方只要这样写PaymentService service new PaymentServiceLogProxy(new PaymentServiceImpl()); service.pay(20250101, 99.9);通过这种方式PaymentServiceImpl完全不需要改日志逻辑在代理里面。而且代理类本身可以单独测试业务类保持单一职责。这种实现方式的优点是简单、直观、容易理解适合代理逻辑固定、目标接口很少变化的场景。我常跟刚入门的朋友说静态代理是理解一切代理模式的基石先把它写熟再谈动态代理。2.2 静态代理的致命伤静态代理的缺点用一个字总结就是累。每增加一个接口你就要手动写一个对应的代理类接口每增加一个方法代理类就要同步增加一个方法如果多个接口都需要日志代理你就得为每个接口各写一个XxxLogProxy。举个例子如果系统里有PaymentService、RefundService、UserService三个接口而且都要加日志、都要做权限校验你可能要写六个代理类。更痛苦的是业务接口加了一个新方法所有代理类都得跟着改。这种重复劳动一旦多了代理类和业务类的代码量几乎一样大维护成本直线上升。我在一个老项目里见过类似情况有人为了避免这种重复直接在代理类里用反射逐个方法处理结果写出了一大坨又长又难读的代码还经常因为反射性能问题被抱怨。这也让我后来强烈偏爱动态代理——既然为每个类手写代理是重复劳动那就应该让程序在运行时自动生成代理类。3. JDK动态代理基于接口的运行时织入3.1 InvocationHandler和Proxy的核心机制JDK 动态代理是 Java 原生支持的一种代理实现它的核心是java.lang.reflect.Proxy和java.lang.reflect.InvocationHandler。先看一下典型的用法。我们还以PaymentService为例写一个通用的日志代理import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; 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 { long start System.currentTimeMillis(); System.out.println([日志] 调用方法 method.getName()); try { Object result method.invoke(target, args); System.out.println([日志] 方法执行完成结果 result); return result; } finally { long cost System.currentTimeMillis() - start; System.out.println([性能] 耗时 cost ms); } } }使用的时候只需要一行PaymentService service (PaymentService) Proxy.newProxyInstance( PaymentService.class.getClassLoader(), new Class[]{PaymentService.class}, new LogInvocationHandler(new PaymentServiceImpl()) ); service.pay(20250101, 99.9);整个过程理解起来不难Proxy.newProxyInstance会在运行时根据传入的接口列表动态生成一个代理类。这个代理类实现了PaymentService接口并且把所有方法调用统一转发给LogInvocationHandler的invoke方法。你在invoke里拿到Method对象就能在调用真实方法前后插入任意逻辑。这里有个很多人没搞明白的点代理对象并不是真实对象的子类它和真实对象是兄弟关系都实现了同一个接口。method.invoke(target, args)这一句才是真正把调用转发给真实对象的关键。代理对象自己内部并没有PaymentServiceImpl的逻辑它只是调用转发器。3.2 自己写一个缓存代理我再举个例子用 JDK 动态代理实现一个简单的缓存功能这是代理模式非常适合的场景。import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class CacheInvocationHandler implements InvocationHandler { private final Object target; private final MapString, Object cache new ConcurrentHashMap(); public CacheInvocationHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 只对无参方法做缓存避免 key 构造复杂化 if (args null || args.length 0) { String key method.getName(); Object cached cache.get(key); if (cached ! null) { System.out.println([缓存] 命中 key); return cached; } Object result method.invoke(target, args); cache.put(key, result); return result; } return method.invoke(target, args); } }这个例子能体现代理模式的一个好处缓存逻辑被隔离在代理层业务类完全无感知。将来想去掉缓存直接不套代理即可业务代码一行都不用动。当然真实项目里的缓存 key 往往要考虑参数组合这里只是演示核心思路。3.3 强转为什么会报ClassCastExceptionJDK 动态代理有个硬性约束它只能为接口生成代理。如果你为一个具体的类生成代理比如Proxy.newProxyInstance( PaymentServiceImpl.class.getClassLoader(), new Class[]{PaymentServiceImpl.class}, handler );这里传入PaymentServiceImpl.class作为接口列表运行时会直接报错因为newProxyInstance的第二个参数必须是接口数组。就算你传了接口但外部代码试图把代理对象强转成PaymentServiceImpl同样会抛ClassCastException因为代理类是 JVM 动态生成的和PaymentServiceImpl没有继承关系只实现了PaymentService接口。我见过不少面试题专门考这一点JDK 动态代理为什么只能代理接口答案要从生成原理说起。JDK 动态代理生成的代理类已经继承了java.lang.reflect.Proxy由于 Java 是单继承它不可能再继承其他类所以只能依靠接口来实现伪装成目标类型的效果。这也是后面要讲的 CGLIB 能补位的原因——CGLIB 走的是继承路线不依赖接口。4. CGLIB动态代理没有接口时的替代方案4.1 继承方式的原理和限制CGLIBCode Generation Library是一个第三方字节码生成库它不是 JDK 自带的。它生成代理类的思路和 JDK 动态代理完全不同CGLIB 会生成目标类的一个子类作为代理类然后通过重写父类方法来实现增强。写一个最简单的 CGLIB 用法这里用 Spring 集成的版本或者直接引入cglib依赖import net.sf.cglib.proxy.Enhancer; import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; public class LogMethodInterceptor implements MethodInterceptor { Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { long start System.currentTimeMillis(); System.out.println([日志] 调用方法 method.getName()); try { // 注意这里用的是 proxy.invokeSuper不是 method.invoke Object result proxy.invokeSuper(obj, args); return result; } finally { long cost System.currentTimeMillis() - start; System.out.println([性能] 耗时 cost ms); } } } Enhancer enhancer new Enhancer(); enhancer.setSuperclass(PaymentServiceImpl.class); enhancer.setCallback(new LogMethodInterceptor()); PaymentServiceImpl proxy (PaymentServiceImpl) enhancer.create(); proxy.pay(20250101, 99.9);这里有一个关键区别MethodProxy.invokeSuper是直接调用父类的方法性能比反射的method.invoke更好。因为 CGLIB 生成子类时也生成了一些快速调用父类方法的辅助字节码减少了反射的开销。这一点在 Spring 的 AOP 底层被大量使用。CGLIB 的局限也十分明显final 类无法被代理因为无法生成子类。final 方法无法被代理因为子类无法重写 final 方法。private 方法无法被代理因为在 Java 里私有方法是静态分派的子类看不到父类的私有方法也就谈不上重写。所以如果你发现自己的类被 Spring 代理后某个方法走了代理居然没有增强效果先检查一下这个方法是不是 final。4.2 与JDK代理的对比选型我在技术选型时基本遵循一个原则能面向接口编程就用 JDK 动态代理类没有接口或者需要更高性能时再考虑 CGLIB。两者对比如下维度JDK 动态代理CGLIB 动态代理生成方式运行时生成实现接口的代理类运行时生成目标类的子类依赖条件目标必须有接口目标类不能被 final 修饰调用方式通过 InvocationHandler 转发通过 MethodInterceptor 拦截性能特点反射调用相对慢一点字节码直接调用通常更快适用范围接口驱动的框架如 MyBatis类驱动的框架如 Spring AOP 某些场景不过话说回来这点性能差异在绝大多数业务系统里根本不是瓶颈我见过很多项目过度纠结这块性能结果真正影响系统的都是数据库查询和网络 IO。我更推荐你按照代码结构是否清晰、维护是否方便来选择而不是单纯为了那零点几毫秒的反射开销去引入 CGLIB。5. Spring AOP中的代理模式面试高频考点和实战避坑5.1 默认用哪种代理Spring AOP 的核心底层就是代理模式。当你在某个Service类的方法上标注Transactional或Aspect相关注解时Spring 会为这个类生成一个代理对象并放进容器里。调用方从容器里拿到的其实已经是代理对象。Spring AOP 的默认代理策略根据版本不同有差别。Spring Boot 2.x 之后默认spring.aop.proxy-target-classtrue也就是默认使用 CGLIB 生成代理老版本里默认优先使用 JDK 动态代理如果目标类没有接口再退化为 CGLIB。这个演进对开发者的影响是Spring Boot 2.x 以后你注入一个业务类而不是接口时代理也不容易出问题这大大减少了以前没有接口就报错的麻烦。但无论用哪种代理核心思想没有变Transactional之所以能自动开启事务是因为代理对象在调用业务方法前帮你开启了事务在方法返回后提交或回滚。这些逻辑你肉眼看不到但它在代理层真实地执行着。5.2 自调用失效问题这是 Spring 事务和 AOP 场景里非常经典的坑。看下面这段代码Service public class OrderService { Transactional public void createOrder() { System.out.println(创建订单主逻辑); this.sendMessage(); } Transactional(propagation Propagation.REQUIRES_NEW) public void sendMessage() { System.out.println(发送消息); } }调用方从容器里拿到OrderService的代理对象调用createOrder()时事务注解是生效的。但createOrder方法内部通过this.sendMessage()调用sendMessage时这个this指的是谁仔细想一下你在代理对象里执行方法代理对象转发给真实对象的createOrder方法真实对象里的this是PaymentServiceImpl或者OrderServiceImpl这样的真实类对象而不是代理对象。所以this.sendMessage()是直接调用真实对象的sendMessage完全绕过了代理层REQUIRES_NEW的传播属性根本不会生效也不会被 AOP 拦截。正确做法有几种在createOrder里注入代理对象自己通过代理对象调用sendMessage()。使用AopContext.currentProxy()需要配置EnableAspectJAutoProxy(exposeProxy true)。把sendMessage放到另一个类/另一个 Service 里通过容器注入调用。这个问题的本质就是直击了代理模式的核心代理只有在通过代理调用时才生效真实对象内部自己调用自己永远不会被代理感知。我见过生产环境里事务神秘失效的案例查到最后基本都是这个原因。5.3 同类方法调用为什么绕不过代理接着上面的话题多说一句。为什么框架界会专门讨论自调用问题就是因为 Spring 的依赖注入默认只注入了一次代理而代理的内部转发链路上真实对象内部的this引用不会自动替换成代理对象。这是 Java 语言本身的限制不是 Spring 的设计缺陷。如果你想在同一个类内部强制走代理有个简单方案是注入自身Service public class OrderService { Autowired private OrderService self; // 注入的是代理对象 Transactional public void createOrder() { self.sendMessage(); } }有些同学觉得这样不够优雅但它在解决自调用问题上非常直接而且代码可读性不差。另一个方案是拆类把需要独立事务的方法拆到别的 Service 里这是很多项目推荐的做法因为它让事务边界更清晰。6. 代理模式在开源框架中的经典身影6.1 MyBatis的MapperProxy如果你用过 MyBatis那你每天都在代理模式构建的世界里工作。你定义一个 Mapper 接口比如public interface UserMapper { Select(SELECT * FROM user WHERE id #{id}) User selectById(Long id); }MyBatis 在启动时会扫描所有 Mapper 接口通过MapperProxyFactory为每个接口生成一个 JDK 动态代理对象。你调用userMapper.selectById(1)时真实执行的是MapperProxy的invoke方法。invoke做的事情包括根据方法名找到对应的 SQL 语句、创建SqlSession、执行 SQL、把结果集映射成User对象。这里非常典型地体现了代理模式的威力接口本身没有任何实现类所有的逻辑都在代理层完成。这也是 JDK 动态代理只能代理接口这个限制反而为 MyBatis 所用的原因——没有接口就生成不了这样的代理。6.2 Retrofit的动态代理再比如 Android 开发里常用的 Retrofit它让你定义一个 HTTP 接口public interface ApiService { GET(user/list) CallListUser getUserList(); }Retrofit 的create()方法内部也是用 JDK 动态代理生成的代理对象。当你调用getUserList()时代理会把方法注解、参数转换成 HTTP 请求交给 OkHttp 执行再把响应解析成ListUser。你和真实服务器之间没有任何业务实现类全靠代理在背后翻译协议。这些事情用代理模式来实现最大的优势是调用方体验极其干净——你看到的只是一个普通的接口方法调用复杂的网络细节全部被代理吞掉了。6.3 代理与装饰器、适配器的区别面试里经常会问代理模式和装饰器模式、适配器模式有什么区别这个问题如果只背概念容易混我说点自己的理解。代理模式注重控制访问和增强代理对象与真实对象实现相同接口。调用方感知不到代理的存在它以为自己调的就是真实对象。装饰器模式注重功能叠加装饰器和被装饰对象也实现相同接口但它会对被装饰对象的功能做一层层包装常用于 IO 流比如BufferedInputStream装饰FileInputStream。适配器模式注重接口转换把 A 接口转换成 B 接口让原本不兼容的类能一起工作。它的核心是转换而不是增强。区分的关键不是代码长什么样而是意图。如果你只是想在调用前加个权限校验这是代理如果你要把Enumeration转成Iterator这是适配器如果你在给一个组件不断加功能每次包装都增强一部分那更像装饰器。实际项目里这三种模式的代码结构有时长得非常像但理解了意图你设计系统时思路就会清晰很多。7. 关于代理模式我最后想说的几件事代理模式是我认为 Java 设计模式里性价比极高的一种它的概念简单代码量不大却在框架层面到处发光发热。但正如这篇博文里反复提到的只有真正踩到自调用导致事务失效绕过代理导致校验失效这类坑你才会明白理解代理的运行时行为有多重要。我个人的几条实操建议供你参考。第一面向接口编程是使用 JDK 动态代理的前提。如果你发现自己的类没法用动态代理先别急着上 CGLIB先想想是不是设计上就该把接口抽出来。这往往能顺带提升系统的可扩展性。第二自定义注解结合 AOP 是代理模式在业务系统里最常见的落地方式。比如你定义一个OperationLog注解在需要记录操作日志的方法上标注然后写一个 AOP 切面统一记录。代码里看不到代理的影子但代理模式在背后默默工作这种以声明代替过程的体验非常舒服。第三不要把业务写死在代理层。代理应该只做横切逻辑比如日志、事务、权限、缓存而不要塞入大量业务判断。否则代理类和业务类之间的职责边界会模糊代码很快就变得不可维护。我见过有人在代理里面写了大量 if-else 业务分支最后删都删不动那已经偏离了代理模式的初衷。第四排查代理相关问题时先确认你拿到的对象是不是代理对象。在 Spring 工程里启动时打一个断点看变量类型带不带$$EnhancerBySpringCGLIB或者$Proxy之类的后缀基本就能判断代理是否生效。如果确实没生效检查注解有没有漏标、类是不是 final、方法是不是 private、有没有自调用。我按这个顺序排查绝大多数 AOP 失效问题都能快速定位。最后再分享一个实用小技巧。如果你在实现一个通用日志切面尽量在方法入口打印入参时做个对象转字符串保护否则某些实体对象重写了toString()后可能打印出大段敏感信息。代理层是横切逻辑的集中地安全、性能、可观测性的问题都会在这里暴露多花点心思打磨这一层整个系统都会受益。代理模式不是一个能让你写出炫技代码的模式但它是一个能让系统变得干净、稳定、可扩展的模式。希望这篇从静态代理一路讲到 Spring AOP 的文章能帮你把它真正用起来。
📝

华诺云谱内容团队

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

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

你可能需要的服务

订阅华诺云谱资讯周报

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

↑