拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

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

代理模式从静态代理到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 Map<String, 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 强转为什么会报ClassCastException

JDK 动态代理有个硬性约束:它只能为接口生成代理。如果你为一个具体的类生成代理,比如:

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 继承方式的原理和限制

CGLIB(Code 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-class=true,也就是默认使用 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") Call<List<User>> getUserList(); }

Retrofit 的create()方法内部也是用 JDK 动态代理生成的代理对象。当你调用getUserList()时,代理会把方法注解、参数转换成 HTTP 请求,交给 OkHttp 执行,再把响应解析成List<User>。你和真实服务器之间没有任何业务实现类,全靠代理在背后翻译协议。

这些事情用代理模式来实现,最大的优势是调用方体验极其干净——你看到的只是一个普通的接口方法调用,复杂的网络细节全部被代理吞掉了。

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 的文章,能帮你把它真正用起来。

返回列表