我在之前的一个老项目里接手过一段性能很差的报表导出功能。每次导出都要全量查询一次数据库,再逐条计算汇总指标,用户点一次按钮,接口要跑三四十秒。原本想着加个缓存就能解决,结果发现业务代码里到处散落着统计逻辑,改起来牵一发动全身。后来把统计这块单独抽出来,用代理模式在前面挡了一层,按条件决定是走缓存还是重算,改动量小,效果却立竿见影。
那是我第一次意识到,代理模式不是教科书上一个需要背的UML图,而是真正能在不改动业务代码的前提下,把横切逻辑从核心逻辑里摘干净的利器。
这篇内容我围绕"代理模式"这个经典设计模式展开,不讲空泛理论,主要结合我实际写代码、改代码、排查线上问题的经验,把它是什么、怎么用、三种实现方式的取舍、生产环境里的高频场景,以及特别容易踩的坑一次说清楚。不管你是刚学设计模式的新人,还是写了好几年业务代码但没系统梳理过的老手,这篇都值得花十分钟看完。
1. 代理模式到底解决什么问题:从一次"不得不改代码"的经历说起
很多人学代理模式,第一眼看到的定义是"为其他对象提供一种代理,以控制对这个对象的访问"。这个说法没错,但太抽象了。我先讲一个具体的经历。
1.1 没有代理时,我们是怎么被"横切需求"反复折腾的
还是上面说的那个报表导出功能。当时的代码长什么样呢,简略一下就是这样:
public class ReportService { public ReportData export(ReportQuery query) { // 1. 记录日志:谁在什么时间导出了什么报表 logger.info("export report, query={}", query); long start = System.currentTimeMillis(); // 2. 权限校验:判断当前用户是否有权查看这些数据 if (!permissionService.check(query)) { throw new PermissionDeniedException(); } // 3. 核心业务:查询数据、汇总计算 List<Row> rows = statisticDao.query(query); ReportData data = doCalculate(rows); // 4. 性能监控:统计这次导出耗时 long cost = System.currentTimeMillis() - start; monitor.record("report.export", cost); return data; } }一开始只有导出报表这一个入口,代码虽然挤在一起,但也能跑。后来加了定时推送、免登录分享、移动端预览等多个入口,每个入口都复制粘贴了一份类似的"日志+权限+监控"代码。再后来需求方说,部分角色导出的时候不需要实时计算,可以接受十分钟前的缓存;又有人说,某些报表要记录导出次数用于配额管理……
每个新需求进来,业务方法内部就要多塞一段逻辑。核心的统计计算被埋在层层横切代码下面,改一个查询条件都提心吊胆。
这就是没有代理时我们的处境:跨多个业务对象的通用逻辑,被迫重复写在每一个业务方法内部,代码膨胀、逻辑耦合、改一处崩一片。
1.2 代理模式登场:把"控制"从"业务"里剥离出来
代理模式的核心思想其实特别朴素:核心业务类只干核心业务的事,其他那些"访问前后要做的控制",全部丢给一个代理对象去干。
客户端调用的不再是真正的业务对象,而是代理对象。代理对象在调用真实对象之前和之后,可以插入各种逻辑——权限检查、日志记录、参数校验、缓存判断、性能统计、事务控制等等。真实业务对象对此毫不知情,它依旧只关注自己的核心职责。
用代理模式改造上面的报表逻辑,结构就变成:
public class ReportServiceProxy implements ReportService { private final ReportService target; public ReportServiceProxy(ReportService target) { this.target = target; } public ReportData export(ReportQuery query) { logger.info("export report, query={}", query); long start = System.currentTimeMillis(); try { if (!permissionService.check(query)) { throw new PermissionDeniedException(); } return target.export(query); } finally { long cost = System.currentTimeMillis() - start; monitor.record("report.export", cost); } } }调用方只要拿到的是ReportServiceProxy,其他什么都不用改。真正的ReportServiceImpl里只剩核心计算逻辑,干净得让人舒服。
所以我个人的理解是:代理模式真正的价值不在于"包装了一个对象",而在于"把调用方和真实对象之间的访问控制逻辑,从两边都拆了出来,单独放进一个中间层"。它解决的核心痛点是业务代码与控制代码的混杂,而不是简单的功能增强。
2. 代理模式的四个角色和一份最朴素的代码骨架
教科书里讲代理模式一定会提到几个角色:抽象主题、真实主题、代理、客户端。我结合自己的理解,用大白话拆一遍。
2.1 四个角色的实际分工
- 抽象主题(Subject):定义业务方法的接口。它存在的意义是让客户端只面向接口编程,这样代理和真实对象可以随时互换,客户端完全无感知。这是代理能不能透明工作的关键。
- 真实主题(RealSubject):真正干活的对象,实现核心业务逻辑。它不需要知道代理的存在,也不需要关心日志、权限、缓存这些事情。
- 代理(Proxy):持有真实主题的引用,实现抽象主题的接口。对外看起来和真实主题一模一样,但内部会在调用真实主题前后插入控制逻辑。
- 客户端(Client):面向抽象主题编程,持有的是代理对象。客户端完全不感知自己操作的到底是代理还是真实对象。
2.2 一段能直接跑起来的静态代理代码
理论说多了容易晕,我直接贴一段最朴素的静态代理实现。场景是下载文件,需求是下载前检查用户是否有VIP权限。
首先是抽象主题接口:
public interface FileDownloader { File download(String fileName); }真实主题,真正执行下载逻辑:
public class LocalFileDownloader implements FileDownloader { @Override public File download(String fileName) { System.out.println("正在从本地磁盘读取文件: " + fileName); return new File("/data/files/" + fileName); } }代理类,在调用真实下载器之前做了权限校验:
public class VipProxy implements FileDownloader { private final FileDownloader target; private final User currentUser; public VipProxy(FileDownloader target, User currentUser) { this.target = target; this.currentUser = currentUser; } @Override public File download(String fileName) { if (!"vip".equals(currentUser.getLevel())) { throw new SecurityException("只有VIP用户才能下载文件"); } System.out.println("权限校验通过,开始下载"); return target.download(fileName); } }客户端使用时只需要这样:
FileDownloader downloader = new VipProxy(new LocalFileDownloader(), currentUser); File file = downloader.download("设计模式笔记.pdf");核心点在于:LocalFileDownloader没有任何权限判断的代码,新增的"VIP校验"完全被隔离在了VipProxy里。以后如果想把VIP校验改成"付费用户校验",只需要改VipProxy,或者换一个代理实现,业务下载类一行都不用动。
静态代理虽然结构简单、逻辑直观,但它有一个绕不开的毛病:每一个业务接口都要写一个对应的代理类。系统里有二十个接口,就得写二十个代理类,而且代理类里大量代码是重复的——都是"调用前后插入逻辑"的模板代码。这也是为什么实际生产环境里,我们更多使用动态代理。
3. 静态代理之外:JDK动态代理和CGLIB的选型真相
静态代理的问题在于"一个接口一个代理类",代码量膨胀得让人崩溃。动态代理则是在运行时动态生成代理类,一个代理逻辑可以复用到任意多个接口上。Java生态里最常见的就是 JDK 动态代理和 CGLIB,Spring AOP 的底层就是这两者的组合。我分别说一下。
3.1 JDK动态代理:只认接口
JDK动态代理基于接口实现,核心是java.lang.reflect.Proxy和InvocationHandler。代理对象在运行时生成,实现了指定接口,并且把方法调用统一转发到InvocationHandler.invoke()方法里。
还是拿文件下载举例,用JDK动态代理改造:
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; } }创建代理对象的逻辑可以封装成一个工具方法:
public class ProxyFactory { public static Object createProxy(Object target, InvocationHandler handler) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), handler ); } }用的时候:
FileDownloader downloader = new LocalFileDownloader(); FileDownloader proxy = (FileDownloader) ProxyFactory.createProxy( downloader, new LogHandler(downloader)); proxy.download("设计模式笔记.pdf");JDK动态代理这里有个硬性条件:目标对象必须有接口。因为动态生成的代理类是通过java.lang.reflect.Proxy创建的,它在 Java 的类继承体系里已经继承了Proxy类,Java 是单继承,所以代理类只能通过实现接口的方式来扩展目标类型。如果目标类没有实现任何接口,JDK动态代理直接歇菜。
3.2 CGLIB:没有接口时的救星
CGLIB 走的是另一条路:它直接在运行时生成目标类的子类,通过覆写方法来实现代理逻辑。因为用的是继承,所以不需要接口,目标类只要不是final的,方法也不是final的,就可以被代理。
public class CglibProxyFactory { public static Object createProxy(Class<?> targetClass, MethodInterceptor interceptor) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(targetClass); enhancer.setCallback(interceptor); return enhancer.create(); } }对应的拦截器逻辑:
MethodInterceptor interceptor = (obj, method, args, proxy) -> { System.out.println("CGLIB方法执行前: " + method.getName()); Object result = proxy.invokeSuper(obj, args); System.out.println("CGLIB方法执行后: " + method.getName()); return result; };注意这里调用目标方法用的是proxy.invokeSuper(obj, args),不是method.invoke(obj, args)。因为直接反射 invoke 到目标对象会绕过 CGLIB 生成的增强逻辑,而且可能引发递归调用问题。这个细节我见过挺多人踩坑的。
3.3 三种方式怎么选:一张表说清楚
我把静态代理、JDK动态代理、CGLIB的差异整理成一张表,方便你对照着选:
| 对比维度 | 静态代理 | JDK动态代理 | CGLIB |
|---|---|---|---|
| 代理类生成时机 | 编译期手动编写 | 运行期动态生成 | 运行期动态生成 |
| 目标对象要求 | 需要接口 | 必须实现接口 | 无需接口,类不能是final |
| 是否需要额外依赖 | 不需要 | JDK自带 | 需要引入CGLIB库 |
| 性能(调用开销) | 最快 | 反射调用有一定开销 | 生成子类+方法拦截,略慢 |
| 代码维护成本 | 高,接口多了类爆炸 | 低,一套Handler通用 | 低,一套Interceptor通用 |
| 使用场景 | 接口少、逻辑简单 | 以接口设计为主的Spring项目 | 没有接口的遗留系统或第三方类 |
一个比较实用的判断逻辑是:如果是新写的代码,优先面向接口设计,用JDK动态代理;如果是老系统,类没有接口,或者要代理的是第三方库里的具体类,那就用CGLIB。
4. 生产环境里代理模式用得最多的四个场景
理论讲完,我重点说说生产环境里代理模式到底在哪些地方扛大梁。你会发现很多你天天在用但没意识到的东西,底层都是代理模式。
4.1 事务管理:Spring @Transactional 的幕后功臣
这是代理模式在Java后端最广为人知的应用。Spring 容器里你写的那个 Service 类,实际放进容器里的并不是它本身,而是一个代理对象。当你调用带有@Transactional注解的方法时,代理对象会先开启事务,再调用真实方法。如果方法抛了 RuntimeException,代理会执行回滚;如果正常返回,代理会提交事务。
@Service public class OrderService { @Transactional public void createOrder(OrderDTO dto) { orderDao.insert(dto); inventoryDao.deduct(dto.getSkuId(), dto.getCount()); } }Spring 容器里的 orderService 实际上是OrderService的一个代理。如果该类实现了接口,Spring 默认用 JDK 动态代理;如果没有实现接口,Spring 自动切换为 CGLIB。调 createOrder 方法的整体执行路径是:
进入代理 invoke → 开启数据库事务 → 调用真实 createOrder 方法 → 方法正常返回 → 提交事务;方法抛异常 → 回滚事务。
这就是为什么你在一个 Service 内部自己调自己另一个@Transactional方法时,事务经常不生效,因为这次调用发生在真实对象内部,根本没有经过代理对象。
4.2 缓存与懒加载:别让业务代码自己判断该不该查库
缓存是代理模式特别适合的场景。把"是否命中缓存"的判断放在代理层,业务方法永远只写"从数据库查数据"这一件事。
public class CacheProxy<T> implements InvocationHandler { private final T target; private final Cache cache; private final String prefix; @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { String cacheKey = buildKey(method, args); Object cached = cache.get(cacheKey); if (cached != null) { return cached; } Object result = method.invoke(target, args); cache.put(cacheKey, result); return result; } }这样做的好处是,缓存策略是集中管理的。今天用本地 Caffeine,明天想换成 Redis,改代理层一个类就行,所有业务方法都不用动。懒加载的原理也类似,代理对象被创建时并不真正初始化内部的重资源对象,只有当你第一次调用某个方法时,代理才去创建真实对象并转发请求。很多ORM框架的延迟加载就是这样做的。
4.3 访问控制与审计日志:把安全逻辑收敛到一处
我在文章开头提到的报表导出,后来就是通过代理模式统一做了权限校验和操作审计。所有涉及导出的入口,不管是从 Web 页面来的,还是从定时任务来的,统一经过同一个代理,做什么校验、记什么日志,都在一个地方维护。
这个思路对安全审计特别有意义。审计要求"谁在什么时候做了什么操作",如果你在每个业务方法里手动写一遍日志代码,漏记是必然的。把审计逻辑放到代理层,所有被代理的方法天然就有了操作记录能力,完全靠机制保障而不是靠人肉自觉。
4.4 远程调用代理:像调本地方法一样调远程服务
RPC 框架(比如 Dubbo、Feign)的调用方拿到的其实就是一个远程代理对象。你在代码里调用userService.getUserById(id),这个 userService 并不是本地实现,而是一个代理。代理内部把方法名和参数序列化,通过网络发送到远程服务器,再反序列化拿到返回结果。
这个过程完美符合代理模式的定义:客户端完全感知不到远程通信的存在,就像在调用一个本地对象。网络传输、序列化、负载均衡、超时重试这些复杂度,全被代理对象隐藏了。
5. 线上项目里代理最容易出问题的三个坑
代理模式好用,但用不好会带来很隐蔽的问题。这些问题不看源码基本发现不了,我遇到过好几次,每次排查都要花不少时间,这里直接分享给你。
5.1 Spring 代理失效:this 自调用是最经典的坑
这是我在代码评审里见过最多的问题,没有之一。很多人写了这样的代码:
@Service public class OrderService { public void handleOrder(OrderDTO dto) { updateStatus(dto); sendNotify(dto); } @Transactional public void updateStatus(OrderDTO dto) { // 更新订单状态 } }调handleOrder方法时,从 Spring 容器里拿到的是代理对象,代理会正常开启。但handleOrder方法体内部调updateStatus时,这个this指向的是真实对象,而不是代理对象,所以@Transactional根本不会生效。
解决办法有三种:
- 把需要代理的方法拆分到另一个 Spring Bean 中,注入后通过代理对象调用;
- 注入
ApplicationContext,每次从容器里获取代理对象再调用; - 在类内部注入自身代理(Spring Cloud 之前的版本可以用
@Lazy注入自己,或者用AopContext.currentProxy(),但AopContext默认不开启)。
我个人最推荐第一种,因为拆分之后类的职责也更清楚了,updateStatus和sendNotify各管各的,不容易再踩别的坑。
5.2 动态代理的类加载器问题
JDK 动态代理创建代理对象时,需要传入类加载器:
Proxy.newProxyInstance(target.getClass().getClassLoader(), ...);如果传入的类加载器和目标接口的类加载器不一致,运行时会报ClassCastException或IllegalArgumentException。这种情况在 Web 应用里尤其常见,因为 Tomcat 等容器会对不同应用使用不同的类加载器。经验是:和接口、目标对象一样使用它们自己的类加载器,而不是随便用ProxyFactory.class.getClassLoader()。我见过有人图省事写死成某个工具类的类加载器,结果在测试环境没事、部署到线上就翻车。
5.3 CGLIB 代理目标类被 final 修饰导致启动失败
CGLIB 通过生成子类来代理,所以目标类不能被final修饰,目标方法也不能是final。如果 Spring 启动时发现某个需要代理的 Bean 是 final 类,会直接抛出异常。这个问题常见于把第三方 JAR 里的 final 类交给 Spring 管理,然后又想在它上面加事务或切面逻辑。
遇到这种情形,老老实实做一层封装,写一个非 final 的门面类去组合这个第三方 final 类,再对门面类做代理。硬着头皮用 CGLIB 去代理 final 类,路是走不通的。
6. 代理模式与易混模式的边界:一句话就能分清楚
设计模式学多了之后,容易把长得像的混在一起。代理、装饰器、适配器这仨是最容易让人犯迷糊的,因为它们代码结构上都是"内部持有一个对象,外部看起来像另一个对象"。我分享一个我的判别方法。
6.1 代理模式 vs 装饰器模式:目的不同,姿态不同
装饰器模式的核心是"增强",它加入的是新功能,而且通常是递归叠加的。比如给文件流套缓冲、套加密、套压缩,一层套一层,每一层都给原始对象增加新的能力,而且新增的能力是给原对象"锦上添花"。
代理模式的核心是"控制",它不一定给原对象增加什么新功能,更多时候是在控制"能不能调用""什么时候调用""调用前后还要做什么"。代理通常只做一层的隔离,不会像装饰器那样层层嵌套。
一个比较直白的判断方式:装饰器对外暴露的能力比原对象更丰富,代理对外暴露的能力往往和原对象一样,但在访问过程上做了管控。比如上面文件下载的例子,代理和原对象的方法列表一模一样,但代理加了权限控制,这就是代理。如果你在 IO 流上套一层 BufferedReader,让它多了按行读的能力,那就是装饰器。
6.2 代理模式 vs 适配器模式:接口形态变没变
适配器模式是为了解决"接口形态不兼容"的问题。老接口和新接口长得不一样,适配器在中间做翻译转换,让它俩能对接上。代理模式里,代理对象和真实对象实现的是同一个接口,接口形态没有变化。
所以判断标准也很简单:代理模式中,代理和原对象接口是同一个,客户端无感知;适配器模式中,适配器和被适配对象接口不同,客户端通过适配器获得了不同的接口能力。一个是"同接口改行为",一个是"异接口做转换",方向完全不同。
6.3 再送你一个综合判断清单
以后写代码犹豫不决的时候,可以按这个清单过一遍:
- 是想在调用前后加控制逻辑,但不改变对外接口?→ 代理模式
- 是想给对象动态增加新能力,可能一层层叠加?→ 装饰器模式
- 是因为接口不兼容,需要做转换让两边接上?→ 适配器模式
- 是想屏蔽一堆子系统,对外只暴露一个简单门面?→ 门面模式
这个清单帮我厘清过很多次思路,尤其是在做代码重构的时候,目标不明确很容易把模式用串。
7. 把代理模式真正用好的几条实战建议
最后这部分,分享几个我在实际落地中沉淀下来的习惯。这些不属于教科书内容,但真的能帮你少走弯路。
7.1 不要把代理做成上帝类
代理层很容易写失控,什么都往里面塞:日志也要、权限也要、缓存也要、监控也要、限流也要。最后代理类比真实业务类还大,横切逻辑再一次混乱。我的习惯是一个代理只负责一件事,比如CacheProxy只管缓存,AuditProxy只管审计,PermissionProxy只管权限。如果确实多个关注点都需要,那就让多个代理嵌套叠加,或者直接用 AOP 切面的多切面编排,也不要写成一个万能代理。
7.2 动态代理的类,命名要能看出痕迹
动态代理在运行期生成的类,打印全类名时往往是类似com.sun.proxy.$Proxy0这种,很难从名字上看出代理了什么。排查线上问题时,如果日志里出现了这种类名,你应该立刻意识到这里是动态代理在生效。
7.3 优先想清楚"是不是真的需要代理"
代理模式是个好工具,但不是万金油。如果你的业务方法只有一个入口、横切逻辑只有一种、也不太可能再加新入口,那直接在方法里写几行日志和权限校验问题也不大。过度设计同样是技术债。我见过不少新人为了展示自己懂设计模式,非要把简单的业务代码套上代理、套上工厂、套上策略,最后维护的人骂娘。代理模式用得最合理的地方,永远是一套逻辑要服务多个目标、且这些目标在验证环境里各自需要不同控制的时候。
以我个人经验来说,真正理解代理模式靠的不是背定义,而是多问自己几次:哪些逻辑其实不属于核心业务?它有没有可能被抽离?抽离之后用什么机制塞回去?带着这些问题去看 Spring AOP、看 RPC 框架的调用链、看 ORM 的延迟加载,你会发现代理模式早已无处不在。希望这篇内容能帮你把这条线彻底打通。