ARTICLE DETAIL

资讯详情

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

代理模式详解:五种类型、选型依据与Spring AOP落地实践

代理模式详解:五种类型、选型依据与Spring AOP落地实践 1. 代理模式到底解决什么问题先理解“中间层”的价值干了这几年后端开发我发现一个很有意思的现象很多刚工作的同学一听到“代理”两个字第一反应就是“加一层转发”至于为什么要加、加在哪一层、加了会不会带来新问题往往说不清楚。尤其是当需求从“打个日志”演进到“梳理权限、做缓存、搞远程调用”之后代理模式就成了绕不开的核心手段。这篇文章我想认真聊聊代理模式——它是什么、有哪些常见类型、每种类型适合什么场景、怎么“明智地选”。如果你是个正在读源码、写业务、或者准备面试的Java工程师这篇文章应该能帮你把“代理”这个看似简单的概念真正消化掉。1.1 第一性原理代理就是“间接层”代理模式的英文名是Proxy Pattern属于GoF二十三种设计模式之一。它解决的问题用一句话就能概括当你不想或者不能直接访问目标对象时创建一个替身对象由替身去控制对目标的访问。这里的关键是“控制”。装饰器模式也在目标外面加一层但装饰器关心的是“给目标增加功能”代理模式关心的是“什么时候该让目标做事、什么时候不该让目标做事”以及“在做事前后还要做什么”。二者边界很微妙后面我会单独开一节讲清楚。为什么需要间接层我用个生活化的例子帮你记住你想去医院挂号但没有直接去窗口而是先在App上预约、付费、取号然后到点直接找医生。App就是代理你调用方并不直接操作医生目标对象而是通过App完成排队、鉴权、通知、取消等一系列控制动作。没有App你也能挂号但很难受有了App流程被统一管理了。代码世界的代理思路一模一样。1.2 角色划分Subject、RealSubject、Proxy代理模式有三个核心角色角色英文名职责抽象主题Subject定义真实对象和代理对象都遵守的公共接口真实主题RealSubject真正执行业务逻辑的对象代理Proxy持有真实主题的引用替调用方控制对真实主题的访问这个结构决定了代理模式的第一个铁律代理必须和真实对象实现同一个接口。如果代理和真实对象暴露的方法不一致调用方就没法做到“无缝切换”代理模式就变味了。我用三段代码把最基础的形态写出来。先定义接口public interface TicketService { void buyTicket(); }再看真实主题public class RailwayTicketService implements TicketService { Override public void buyTicket() { System.out.println(铁路票务系统出票成功); } }最后是静态代理public class TicketProxy implements TicketService { private final TicketService target; public TicketProxy(TicketService target) { this.target target; } Override public void buyTicket() { System.out.println(代理校验用户登录态); System.out.println(代理检查余票); target.buyTicket(); System.out.println(代理发送购票通知); } }调用方拿到的是TicketProxy但它以为自己操作的是TicketService。真实对象不需要知道代理的存在代理也不需要修改真实对象的代码。这就是“对扩展开放、对修改关闭”最直观的体现。1.3 代理模式的三个核心收益把结构说清楚之后你应该能感受到代理模式给业务带来的三个价值第一隔离复杂性。真实对象本身不需要知道什么叫做“权限校验”“流量控制”“日志记录”这些横切逻辑放到代理里真实对象保持干净。第二控制访问时机。有些资源创建成本很高比如读取大图、建立数据库连接、初始化重量级服务。代理可以先不触发真实对象的创建等到真正需要时才动手这就是虚代理的雏形。第三让调用方无感知。调用方只用接口编程它不关心背后到底是真实对象还是代理。这一点特别重要它让代理可以安全地替换真实对象不需要改动上游代码。但这三个收益不是白来的。代价是每多一层代理就多一层调用开销和排查难度。所以“加中间层”不是目的“通过中间层换取控制力”才是这一点会在选型时反复碰到。2. 五种常见的代理类型与适用场景代理模式之所以让人容易混淆是因为它底下还套着好几层子类型。面试时经常考项目里也经常混着用。我按实战频率排个序把它们一个个说清楚。2.1 静态代理最直白也是理解一切代理的基础静态代理就是上一节代码里那种写法代理类的代码在编译期就已经确定一个代理类只服务一个真实对象或者一类接口。静态代理的优点是非常直观逻辑看得见摸得着调试时直接打断点就能看。缺点是如果系统里有几十个接口需要加日志你就得写几十个代理类哪怕逻辑完全一样也得重复编写。代码一旦膨胀维护成本比收益还大。所以静态代理适合那些接口数量少、功能相对稳定、不需要频繁扩展的场景。比如对接外部短信平台的客户端接口就三五个包一层静态代理做签名和重试足够了。2.2 动态代理运行时生成代理对象解决样板代码问题动态代理是为了解决静态代理的重复性问题。它的特点是在运行时根据接口动态生成代理类不需要你手写一个类。Java原生提供了基于接口的JDK动态代理核心是java.lang.reflect.InvocationHandler。来个最常用的日志切面例子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 { System.out.println(代理调用前记录方法名 method.getName()); Object result method.invoke(target, args); System.out.println(代理调用后记录返回结果 result); return result; } }创建代理对象的代码是这样public class DynamicProxyFactory { public static Object create(Object target) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new LogInvocationHandler(target) ); } }这里有个重要前提JDK动态代理只认接口不认实现类。如果你的目标对象没有实现任何接口、只有具体类原生动态代理就用不了这时候需要借助CGLIB或ByteBuddy一类的字节码库让生成的代理类去继承目标类来实现拦截。动态代理最大的价值在于“横切逻辑集中管理”——因为所有代理逻辑都收敛到同一个invoke方法里你再也不用给每个接口写代理类了。Spring AOP的底层就是这么干的一句话总结Transactional之所以能生效就是因为Spring在运行时帮你生成了一层动态代理事务逻辑统一在invoke前后执行。2.3 虚代理把重量级对象的创建推迟到真正使用时虚代理Virtual Proxy解决的痛点是“创建太贵、但用得又晚”。典型场景是加载大图一个相册应用打开后如果立刻把目录里所有照片原图加载进内存内存立刻被打爆。合理做法是先用虚代理占个位用户真正点开某张图时代理才去加载真实图片。虚代理的代码核心是懒加载判断public class LazyImageProxy implements Image { private HighResolutionImage realImage; private final String filePath; public LazyImageProxy(String filePath) { this.filePath filePath; } Override public void display() { if (realImage null) { realImage new HighResolutionImage(filePath); System.out.println(代理首次访问开始加载真实图片); } realImage.display(); } }调用方从头到尾都只依赖Image接口不知道真实图片是不是已经被加载了。这个模式跟单例模式的懒加载有点像但区别在于单例关注的是“全局唯一实例”虚代理关注的是“控制一个大对象初始化的时机”。使用虚代理时要特别注意线程安全问题。如果多个线程第一次同时触发display()就可能出现重复创建真实对象。简单做法是给创建逻辑加synchronized或者使用双重检查锁具体取舍看并发压力。我不建议为了省这点初始化时间引入复杂的并发优化大多数场景下加一个锁就够用了。2.4 保护代理把权限校验从业务代码里剥离出来保护代理Protection Proxy是最常见也最容易被忽视的代理类型。它控制的是“谁可以调用目标对象的方法”说白了就是权限校验。我见过太多把权限判断写在Service方法第一行的代码public void deleteOrder(Long orderId) { if (!currentUser.isAdmin()) { throw new PermissionDeniedException(无权限); } orderRepository.deleteById(orderId); }这种写法的问题在于权限逻辑散落各处加了新接口容易漏校验改权限规则要动业务方法。用保护代理重构之后业务方法里只写纯粹的删除逻辑权限判断统一放到代理里public class OrderServiceProxy implements OrderService { private final OrderService target; private final UserContext userContext; public OrderServiceProxy(OrderService target, UserContext userContext) { this.target target; this.userContext userContext; } Override public void deleteOrder(Long orderId) { if (!userContext.isAdmin()) { throw new PermissionDeniedException(无权限); } target.deleteOrder(orderId); } }保护代理与动态代理经常组合使用你可以在InvocationHandler里检查方法名和当前用户角色实现一套注解驱动的权限控制。很多框架里的PreAuthorize本质上就是这种组合思路。等到后来接手代码的人看到业务方法里干干净净一定会感谢你当初的这层设计。2.5 远程代理让网络调用像本地调用一样自然远程代理Remote Proxy解决的核心问题是“本地代码与远端服务的通信细节隔离”。典型历史实现是Java RMI、EJB现代微服务里的Feign、Dubbo在思想上也是这个路子。前些年我用Java RMI写过一个分布式计算模块印象很深。客户端拿到的接口对象其实是一个代理对象你在本地调用这个代理的方法代理把方法名、参数序列化成字节流通过网络发给服务端服务端执行后再把结果序列化传回来。调用方完全感知不到网络存在以为是本地执行。不过远程代理是所有代理类型里最复杂的一种因为它不仅要处理接口匹配还要处理序列化协议、网络超时、服务发现、负载均衡、异常恢复。如果没有完整的框架支撑不建议自己从零写远程代理。现在主流做法是直接用成熟的RPC框架框架内部已经帮你把远程代理做好了。你需要理解的是它背后的原理而不是重新造轮子。3. 代理类型选择的判断依据别一上来就写动态代理很多同学学完动态代理之后恨不得把所有类的访问都换成动态代理觉得这样很“高级”。但真实项目的经验是代理类型的选择应该由具体问题倒推而不是由技术偏好正推。3.1 从“控制目标”出发反向选型你先问自己一个问题我加这层代理到底想控制什么如果是控制“创建成本”选虚代理把初始化延后到第一次真正使用如果是控制“谁能调用”选保护代理把权限判断单独剥离如果是控制“调用前后要做什么统一动作”比如日志、事务、监控选动态代理如果是控制“底层通信细节”选远程代理让调用方只看接口如果以上都不是仅仅是想给某个方法加一段固定逻辑静态代理反而是最清晰的选择。这条判断链很重要。它逼着你先定义“问题域”再选择“解法”。我曾经见过一个项目为了给三个方法记录耗时引入了完整的Spring AOP和自定义注解最终效果确实不错但团队里新人都要花时间理解切面规则。后来我们评估后改成静态代理代码量稍微多了一点可读性却直线上升。好用的方案不等于合适的方案。3.2 静态与动态的维护成本权衡静态代理和动态代理的选择本质是在“量”和“变”之间做权衡维度静态代理动态代理代码生成时机编译期运行期代理类数量每个接口一个类一个handler处理所有接口可读性高直接看类中要理解反射和handler性能一次直接调用反射调用略有损耗约束条件无特殊要求JDK方式要求目标有接口适合场景接口少、逻辑稳定横切逻辑多、接口频繁增加这里想提醒你一个容易踩的坑动态代理由于是通过反射method.invoke执行目标方法如果目标方法调用极其频繁比如每秒数十万次反射带来的额外开销就不能忽略。不要只看“动态代理很优雅”就无脑用遇到高并发热路径我会优先考虑代码生成等方式或者直接用静态代理把反射省掉。当然如果是普通业务系统反射的性能损耗完全可以忽略不用提前优化。3.3 一张表理清五种代理的取舍组合起来看五种代理类型不是什么难懂的东西。下面这张表可以帮你快速定位需求代理类型核心控制点典型应用关键成本静态代理调用前后追加逻辑客户端封装、固定日志类数量膨胀动态代理横切逻辑统一处理Spring AOP、通用拦截器反射开销、调试复杂虚代理大对象初始化时机懒加载图片、延迟创建连接并发控制复杂保护代理访问权限控制权限校验、黑白名单权限规则需要维护远程代理本地调用透明化RPC、Feign、RMI网络异常处理复杂我建议你在做技术选型时把这张表打印出来贴在工位上。不是因为表有多高级而是它能把“我要干件事”对应到“该用哪个角色”上避免跟同事开一小时的会还停留在“我觉得应该用代理”这种模糊层面。4. 代理与周边模式的边界装饰器、适配器别再傻傻分不清代理模式被聊得最多的问题之一就是和装饰器模式Decorator Pattern太像了。无论从代码结构还是调用方式看二者都很接近但设计意图有本质区别。4.1 代理 vs 装饰器控制与增强的区别装饰器的目标是把新功能叠加在原有对象之上而且可以多层叠加比如“带加密的文件流”套上“带缓冲的文件流”套上“带压缩的文件流”。装饰器不会阻拦你对原始对象的访问只是在调用链上逐层增加能力。代理的目标是控制对目标对象的访问它往往希望调用方压根接触不到真实对象所有访问都必须经过代理这层关卡。代理可以在目标方法执行前就返回结果也可以拒绝调用这是装饰器很少做的事情。用一个最简单的比喻来区分装饰器是“把炸鸡加上辣粉”它还是那块炸鸡只是口味变了代理是“自动售货机”你只能通过机器买零食不能直接伸手进仓库拿。实际写代码时如果发现自己引入了代理但又没有任何“控制”逻辑不需要拦截、不需要权限、不需要延迟只是在任务前后追加处理那大概率应该用装饰器或者简单包装类。别为了“美观”硬凑代理模式。4.2 代理 vs 适配器接口不变与接口转换的区别适配器Adapter Pattern解决的是“接口不匹配”三方SDK的方法名和你们系统定义的接口不一样适配器负责把它翻译过来。适配器的结果往往是改变了调用接口。代理则始终保持与真实对象相同的接口调用方以为自己操作的是真实对象不需要做任何调整。换句话说适配器改变接口让旧接口适配新环境代理保持接口不变只是控制访问过程。有人会说“适配器不是也包了一层吗”对适配器也包了一层但包装的目的相去甚远。判断标准就是你作为调用方拿到的接口是否还是原来的那个接口。是原来的接口靠代理不是原来的接口是适配。把这两个边界想清楚之后你再看Spring、MyBatis等框架的源码会发现很多类包了很多层但每一层都有明确的职责有的是适配有的是增强有的是控制。读源码时先判断每一层的设计意图会顺畅很多。5. 代理模式落地时最容易踩的坑代理模式代码不难写难的是“落地不出幺蛾子”。下面这几个坑是我在真实项目里踩过或者看见同事踩过的拿出来分享给你。5.1 代理里异常被吞掉原始栈丢失动态代理里最常见的写法是这样try { return method.invoke(target, args); } catch (Exception e) { log.error(调用失败, e); throw new ServiceException(调用失败); }问题出在InvocationTargetException——通过反射调用方法如果业务方法抛了异常反射框架会把原始异常包装成InvocationTargetException。如果你处理不当拿到的是包装后的异常原始异常栈信息真正报错的那一行就丢了。正确做法是提取原始异常再抛出try { return method.invoke(target, args); } catch (InvocationTargetException e) { Throwable cause e.getCause(); log.error(调用真实对象失败, cause); throw cause; }这个小细节很多工作三五年的同学都会忽略。排查问题的时候异常栈里只能看到代理层找不到业务真实失败点白白浪费半天的排查时间。5.2 代理链顺序错误导致逻辑错乱或循环调用代理可以套代理比如先打了日志再来权限校验还是先权限校验再打日志这在单层代理看不出差别一旦多层代理叠加顺序问题就放大了。我曾经见过一个项目日志切面里又调用了自身因为外层代理拦截到目标方法后method.invoke再次触发了代理逻辑造成日志重复打了三次。排查了很久才发现代理对象被重复包装。规避方法有两个第一包装代理时不要对同一个对象重复包裹。每次生成代理确认target是原始对象而不是已经代理过的对象。第二如果你确实需要多层代理明确每一层的关注点用统一的顺序规范比如从上到下依次是安全校验、参数校验、日志、事务、缓存、真实业务。5.3 动态代理的调试体验差可读性受影响动态代理生成的代理类在编译期不存在IDE里打断点看到的调用栈是$Proxy0这样的类名非常抽象。很多新同事在调试切面相关的代码时根本不知道“这个代理最终会调用哪个真实对象”。我的建议是动态代理逻辑不适合塞太多重量级业务。代理里只做“统一、薄层”的处理比如记录日志、开启事务、权限校验。任何超过十行的复杂逻辑都抽到独立组件里让代理代码保持一眼能看懂。调试时配合“目标对象方法名参数”的日志输出能极大减少定位成本。5.4 事务代理失效的问题this调用陷阱Spring里有著名的代理失效场景在一个被Service注解的类里方法A调用了同一个类里的方法BB上有Transactional结果事务没生效。原因就是内部this调用直接走了真实对象绕过了Spring生成的代理。这个问题表面上跟代理模式无关但如果你理解了代理模式就很好理解原因Spring让容器拿到的Bean是经过代理包装的this却是真实对象自身。真实对象当然没有事务逻辑。解决方式有很多比如注入自身代理、把B拆到另一个类里、或者用编程式事务。最关键的还是理解“代理对象能拦截外部调用但拦截不了内部this调用”。这个坑我单独拿出来讲是因为它几乎是代理模式在框架应用中最经典的一个翻车点。你能在面试时把原理说到这个深度一定会让面试官眼前一亮。6. 一个完整的选型案例我如何给统一日志平台做代理把原理讲完了最后分享一个我实际做过的案例让你看看选型的完整思考过程。有一年我们团队要自研一套统一日志平台业务侧希望在自己已有的Service类里增加“全量接口耗时统计和调用链ID注入”。当时面临的选择有三个静态代理、动态代理、装饰器。最初不少人倾向于写静态代理理由是“简单直观”。但我们的Service接口有六十多个新业务几乎每周都在增加静态代理意味着每个新接口都要配一个代理类后续维护已经可以预见到是灾难。装饰器其实可以做增强但它不适合解决我们的另一个需求——调用链ID。调用链ID需要在一个线程上下文里贯穿整条调用链它本质上是对方法调用的拦截与控制而不是简单地给一个方法增加能力。于是我们最终决定用动态代理。实现方案是这样的定义统一的InvocationHandler里面做了四件事生成调用链ID、计算耗时、判断是否输出日志、异常时记录错误码。所有Service接口通过同一个工厂方法创建代理对象业务代码只需要把Bean替换成代理对象一行都不用改。这个方案上线后新增接口的成本从“写一个代理类”降到“零代码”。代价是当调用链出现异常时排查需要看一层反射栈。为了解决这个问题我们在代理统一增加了方法名参数的日志输出把定位时间压缩到几乎可以忽略。回头看这次选型核心决策逻辑就一句话按照接口数量、变化频率和控制需求三者结合判断。静态代理的量、动态代理的变、保护代理的权限边界各自都有不可替代的价值关键是你是否想清楚了要解决的核心矛盾。代理模式的用处远不止上文这些缓存代理、单例代理、容错代理都是它延伸出来的变体。你在项目里先把这五种基础类型用顺了再看延伸变体基本都能举一反三。碰到设计模式的问题不用先想“用什么模式”而要先想“要控制什么”然后代理模式自然会在合适的时候跳出来。
返回列表