ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

适配器模式完全指南:从原理到实战,解决接口不兼容

适配器模式完全指南:从原理到实战,解决接口不兼容 适配器模式这个老话题网上讲的人很多但大部分都在念定义、贴UML图看完还是一头雾水遇到真实场景照样不知道怎么用。我这几年在项目里跟这个模式打过不少交道踩过坑也填过坑今天干脆把适配器模式掰开揉碎从本质原理到实战落地再到那些只有真正动手写过才会懂的细节一次性讲清楚。1. 适配器模式到底在解决什么问题1.1 从插座转换头说起你出差住酒店带了笔记本电脑到了房间发现墙上的插座是欧标的两孔圆头你的电源适配器是国标的三脚扁头插不进去。怎么办去前台借一个转换头一头插进墙上的欧标插座另一头让你手上的国标插头插进来充电问题就解决了。适配器模式的本质就是代码世界里的转换头。日常开发中我们经常遇到接口对不上的情况A系统暴露出来的接口是老的XML格式B系统只认新的JSON格式某个第三方SDK返回的数据结构和我们内部定义的数据模型对不上或者一个新引入的组件方法名和参数跟我们业务层的调用习惯完全不一样。如果直接在业务代码里做格式转换、方法适配业务逻辑就会跟外部依赖纠缠在一起改一个外部接口业务层跟着遭殃这个依赖就像狗皮膏药一样撕都撕不开。适配器模式的核心作用是在不修改现有对象结构的前提下把一个类的接口转换成客户端期望的另一个接口。换句话说它让两个原本不兼容的结构通过一个中间层协同工作而这个中间层就是适配器。1.2 三个角色的职责划分适配器模式一共有三个角色。目标接口Target是客户端所期望的那个接口。它定义了客户端依赖的上层契约比如我需要一个能返回UserInfo对象的方法。被适配者Adaptee是那个已经存在的、接口不兼容的类。它可能是老代码、第三方库也可能是一个实现了完整逻辑但方法签名难用的类你现在没法改也不敢改。适配器Adapter是中间人。它实现了目标接口内部持有一个被适配者的引用把客户端发起的调用翻译成被适配者能理解的语言再返回给客户端。这个三角关系理解清楚了适配器模式就没有秘密了。客户端只跟目标接口打交道完全不知道背后其实藏了一个接口完全不同的旧类。你的业务层面对的是稳定契约底层是啥烂摊子适配器全部兜住了。2. 适配器的两种实现方式与核心取舍2.1 类适配器继承实现的利与弊类适配器通过多重继承来实现在Java这种单继承语言里就是用extends继承被适配者同时implements目标接口。// 被适配者老旧的第三方短信服务 public class LegacySmsService { public boolean sendText(String phoneNumber, String content) { // 第三方旧版API逻辑 System.out.println(Legacy SMS sent to phoneNumber); return true; } } // 目标接口业务侧期望的统一消息发送接口 public interface UnifiedMessageSender { boolean send(String target, String message); } // 类适配器继承旧实现实现新接口 public class SmsAdapter extends LegacySmsService implements UnifiedMessageSender { Override public boolean send(String target, String message) { return sendText(target, message); } }这种写法简洁直白缺点也很明显。Java和C#都不支持多继承所以只能继承一个类灵活性受限。而且extends把适配器和被适配者强绑定了适配器会继承被适配者所有公开方法哪怕有些方法你根本不想暴露给客户端它也裸奔在外面。再加上继承本身就是一种高耦合的关系父类一改动子类跟着受牵连。所以在现代Java开发里类适配器用得越来越少。2.2 对象适配器组合优先的正确打开方式对象适配器不玩继承而是持有被适配者的一个实例通过组合来转发调用。public class SmsAdapter implements UnifiedMessageSender { private final LegacySmsService legacyService; public SmsAdapter(LegacySmsService legacyService) { this.legacyService legacyService; } Override public boolean send(String target, String message) { // 在适配器里做参数转换、返回值适配 return legacyService.sendText(target, message); } }这段代码我觉得是理解适配器模式的关键。它遵守了组合优于继承的原则适配器和被适配者之间是松耦合的你想适配哪个实现构造的时候传进去就行甚至可以用代理对象、mock对象来替换单元测试方便得一塌糊涂。对象适配器还有一个隐藏优势它能适配被适配者的子类。因为持有一个基类引用实际传入任何子类实例都能工作。类适配器就没有这个灵活性继承关系写死了。所以在实际项目中95%以上的场景我都推荐对象适配器。类适配器更多出现在教学示例和某些特定框架底层日常业务代码里遇到的机会很少。2.3 双向适配器的进阶玩法还有一种比较少见的双向适配器Two-Way Adapter同时实现两个接口让两个系统互相通过适配器访问对方。这种玩法在系统间深度整合、双方接口都不可修改的场景下非常有用两边都用同一个Adapter实例往哪个方向调用都能转译。public class BidirectionalAdapter implements CompanyAInterface, CompanyBInterface { private CompanyAInterface companyA; private CompanyBInterface companyB; // 构造方法省略 Override public void doCompanyAThing() { companyB.doCompanyBThing(); } Override public void doCompanyBThing() { companyA.doCompanyAThing(); } }双向适配器的问题在于职责混乱一旦业务逻辑复杂起来Adapter内部就会膨胀维护成本直线上升。除非两个系统都很稳定、交互方向明确不然我不建议轻易引入。3. 从理论到实战几个让我真正理解适配器模式的场景3.1 老系统改造接住那些改不动的Legacy代码我之前参与过一个电商老系统的重构项目。订单模块是十几年前的老代码里面有一个OrderService类方法返回的是Mapkey是字符串value是Object调用方拿到Map还要自己强转类型。新系统里我们定义了一套干净的订单接口比如OrderDTO getOrderById(String orderId)。老系统不可能重写里面的逻辑虽然烂但稳定运行了十几年你敢动它它就敢给你出线上事故。但是新系统又不能强依赖老接口不然新代码也变成了垃圾堆。我当时做的事情就是写了一个NewOrderAdapter实现新目标接口内部转发调用老OrderService然后把Map转成OrderDTO。整个适配层控制在两三百行以内业务侧从老接口中完全解放出来。这一步操作给我最大的感触是适配器模式不是银弹但它给了你一个在不推翻旧世界的前提下构建新世界的窗口。老系统慢慢拆新系统稳步建中间靠一层适配器缓冲比推倒重来安全得多。3.2 第三方SDK适配把不顺手的工具变成顺手的能力另一个高频场景是第三方SDK。公司接了一个支付的SDK功能是齐全的但它的API设计跟我们的代码风格完全是两个世界。那边要求传一个PayRequest对象里面字段命名是amt、cur、mrchntNo这种缩写返回是PayResponse里面各种状态码。我们的业务层期望的是PaymentCommand和PaymentResult语义清晰字段规范。如果不用适配器业务代码里到处都是SDK的PayRequest、PayResponse哪天换了支付服务商全公司代码上下来一次大扫除想想都头皮发麻。用了适配器之后定义一个WechatPayAdapter把所有SDK相关的类型转换、状态码映射、异常处理全封装在适配器内部业务层永远只跟PaymentCommand打交道。更妙的是以后就算从微信支付切到支付宝支付只需要再写一个AlipayAdapter实现同一个PaymentGateway接口然后在配置中心切换一下实例就行。这个价值不是省几行代码的问题是给系统留下了从容应对变化的余地。3.3 单元测试中的隐形式适配器Mock对象的本质很多人没意识到日常写单元测试时用的Mock框架Mockito、MockPython其实就是在执行适配器模式的逻辑。Mock对象实现了目标接口内部没有真实逻辑但你指定了when(x).thenReturn(y)这就相当于给真实的依赖类做了一个适配层。我第一次意识到这层关系的时候还是挺震撼的。原来我一直在写适配器只是没有意识到它属于适配器模式这个体系。这也说明这个模式的思想早就深入到了开发的各个角落。在测试里用接口 Mock而不是直接用具体类 真实依赖测试的隔离性、速度、稳定性都更好。因为你的代码依赖的是接口Mock才能生效。如果代码里到处都是new ConcreteClass()那测试就寸步难行了。4. 适配器模式在主流框架中的身影4.1 Spring 里的适配器无处不在Spring框架本身就是适配器模式的最佳实践者。Spring MVC中的HandlerAdapter它的作用就是让DispatcherServlet统一处理不同类型的处理器Controller方法、HttpRequestHandler、Servlet每一种处理器对应一个具体适配器实现。DispatcherServlet不用关心控制器长什么样反正通过HandlerAdapter就能统一调度。还有一个经典例子是Spring AOP中的AdvisorAdapter。Spring的AOP支持多种通知类型MethodBeforeAdvice、AfterReturningAdvice、ThrowsAdviceSpring内部通过MethodBeforeAdviceAdapter、AfterReturningAdviceAdapter等适配器把这些不同类型的Advice统一转换成MethodInterceptor从而统一调度。这些框架内部的适配器普通开发者平时可能感知不到但它们就是保证框架高扩展性的地基。你看Spring明明发布了这么多年新增各种特性都从从容容就是因为底层的适配层设计得很好。4.2 Java标准库的Adapter痕迹JDK中也有大量适配器应用。最典型的是java.io包里的InputStreamReader和OutputStreamWriter它们把字节流适配成字符流。FileReader、BufferedReader这些我们天天用的类底层都是这么一层套一层的流适配。还有Java的事件监听机制比如MouseAdapter、WindowAdapter这些空实现类存在的意义就是让你不用实现接口里所有方法——这本质上是适配器模式的一个变种它提供了接口的默认空实现让客户端只覆写关心的方法。4.3 自己实现一个极简框架级适配器理解了框架的做法自己写一个通用适配层就很轻松了。比如我做过一个消息推送中心支持短信、邮件、站内信、APP推送等多种渠道。我定义了一个统一接口public interface MessageChannel { String channelName(); void send(MessageEnvelope envelope); }然后给每个渠道写一个适配器实现Component public class SmsChannelAdapter implements MessageChannel { private final LegacySmsService smsService; Override public String channelName() { return sms; } Override public void send(MessageEnvelope envelope) { smsService.sendText(envelope.getPhone(), envelope.getContent()); } }然后用一个ChannelRouter统一分发Component public class ChannelRouter { private final MapString, MessageChannel channelMap; public ChannelRouter(ListMessageChannel channels) { channelMap channels.stream() .collect(Collectors.toMap(MessageChannel::channelName, Function.identity())); } public void dispatch(String channel, MessageEnvelope envelope) { Optional.ofNullable(channelMap.get(channel)) .orElseThrow(() - new IllegalArgumentException(Unsupported channel: channel)) .send(envelope); } }以后加一个新渠道只需要写一个新Adapter类注入Spring容器Router自动就认识它了。这条流水线一搭好整个消息发送模块的扩展成本降到最低。5. 常见问题与排查技巧实录5.1 别让适配器变成垃圾中转站我发现很多开发者在写适配器的时候容易把适配器当成一个万能垃圾箱。什么格式化、校验、日志、缓存、甚至业务规则判断都往里面塞。等到你反应过来这个Adapter已经七八百行了本来只该做接口转换的类变成了一个失控的大杂烩。经验法则一个适配器只做一件事——接口适配。格式转换是必要的但业务规则、缓存逻辑、复杂校验都不是适配器的职责该抽出去就抽出去。5.2 接口设计不佳适配器也跟着遭殃有些场景适配器写起来特别别扭本质上是目标接口设计不合理。比如目标接口的粒度太细调用方要调5个方法才能完成一个业务动作适配器就得在这5个方法之间维护一些中间状态搞得非常不优雅。遇到这种情况该考虑的是重构目标接口而不是硬着头皮在适配器里各种操作。设计目标接口时我会尽量让接口的粒度贴近业务语义而不是贴近技术实现。如果一个接口方法名是doStep1、doStep2、doStep3那这个接口大概率是设计失败了。5.3 适配器的超时与容错处理适配器内部往往要调用外部系统这就意味着IO操作、网络请求、异常发生都是常态。适配器不能只做翻译它还需要处理调用异常、超时、零值返回等情况把底层的错误翻译成业务侧能理解的错误类型。Override public PaymentResult pay(PaymentCommand command) { try { PayResponse response sdkClient.pay(buildRequest(command)); return convertResponse(response); } catch (SDKTimeoutException e) { throw new PaymentTimeoutException(支付网关超时, e); } catch (SDKBusinessException e) { throw new PaymentGatewayException(buildErrorCode(e), e.getMessage(), e); } }如果适配器不处理这些底层SDK的异常直接抛到业务层业务层就被迫去catch第三方SDK的异常类型依赖泄漏就发生了。适配器作为防线必须把外部异常挡在内部转换成业务侧熟悉的异常体系。5.4 断开适配器后怎么排查问题适配器引入之后排查问题的链路变长了。以前一个调用链路是业务 - 第三方SDK现在是业务 - 适配器 - 第三方SDK中间多了一层。排查思路我一般是这样先看业务侧报错类型。如果报错是业务侧自己的异常体系说明适配器已经做了错误转换问题在适配器内部或下游SDK直接看适配器日志即可。如果报错是第三方SDK的原生异常类型那说明适配器没有把异常拦住要么适配器漏处理了一种异常分支要么适配器本身就有问题。日志记录是适配器的命脉。我要求适配器必须有入参日志、出参日志和异常堆栈日志这是排查问题的最低要求。一个没有日志的适配器线上出了问题就像一个黑盒子无从查起。6. 适配器模式与其他模式的边界与配合6.1 适配器 vs 装饰器 vs 代理这三个结构型模式经常让人纠结。我用三句话概括它们的核心区别适配器解决的是接口不匹配问题目标是兼容。装饰器解决的是功能增强问题目标是叠加。代理解决的是访问控制问题目标是隔离。代码形态上三者有相似之处都包装了一层但意图完全不同。适配器返回的是目标接口装饰器返回的是同一接口的增强版代理返回的是同一接口受控版。举个例子你就明白了BufferedReader其实是个增强版的Reader属于装饰器InputStreamReader把字节流转成字符流属于适配器Proxy.newProxyInstance()生成的对象属于代理。6.2 适配器 vs 门面模式门面模式外观模式也长得很像适配器都是一层封装。但门面的核心是给一个复杂子系统提供一个简化的访问入口它不在乎接口兼容问题它在乎的是简化。适配器则是为了解决接口兼容而存在的。门面可以没有目标接口的概念它本身就是那个入口适配器必须挂在一个明确的目标接口下面。6.3 适配器与策略模式的联合作战在我那个消息推送中心的例子里适配器和策略模式是天然组合。每个渠道适配器就是一个策略Router 通过channelName()来路由选策略。策略模式负责选哪个适配器模式负责怎么兼容两者一配合系统的扩展性和整洁度直接拉满。这种组合在实际系统中很常见比如对接多家物流平台、多家支付通道、多家短信服务商本质上都是策略适配器的联合作战。7. 动态代理与泛型适配适配器模式的高阶玩法7.1 用JDK动态代理写一个泛化适配器如果有一批相似但接口不同的类都要适配手写一个个Adapter类就显得很蠢。这时候可以用JDK动态代理做一个泛化适配器在运行期动态生成Adapter。public class GenericAdapterFactory { SuppressWarnings(unchecked) public static T T createAdapter(ClassT targetInterface, Object adaptee, FunctionObject[], Object methodTranslator) { return (T) Proxy.newProxyInstance( targetInterface.getClassLoader(), new Class?[]{targetInterface}, (proxy, method, args) - methodTranslator.apply(args) ); } }这种玩法的风险也不小因为绕过了编译期检查方法名、参数对不上的问题只能在运行期炸出来。所以我只在接口非常统一、转换逻辑高度相似的场景下使用业务代码里还是老老实实写显式Adapter更稳妥。7.2 Spring的适配器扩展体系如果你用的是Spring还可以借助AdapterFactory这类扩展点把适配器的创建过程纳入Spring容器管理。写一个自定义的Adaptable接口配合AdapterRegistry可以实现类似于BeanFactory一样灵活的适配器查找能力。不过总的来说这种高阶扩展一般是框架级需求中小项目里意义不大。我提出来是让读者知道适配器模式的可塑性很强但也要克制别为了炫技把简单事情复杂化。8. 适配器模式的设计决策清单与个人经验8.1 什么时候该用什么时候不该用适配器模式不是万金油滥用同样会带来灾难比如一个系统里硬塞了十几个Adapter业务层倒是干净了但整个适配层变成了一片沼泽。我用一张表来总结判断标准场景该不该用原因老代码接口与新系统接口不兼容老代码改不了应该用适配器是唯一的低成本隔离方案第三方SDK接口与业务期望不一致应该用防止底层依赖穿透业务层新项目所有接口自己设计想清楚再用直接用新接口更干净适配器可有可无为了模式而模式强烈不建议模式必须服务于实际需求不是表演道具期望适配器嵌入业务规则重新设计适配器做接口适配不做业务逻辑8.2 我从适配器模式里悟到的几个通用经验代码写多了之后很多模式之间的界限会慢慢模糊最后沉淀下来的是一种隔离变化的思维方式。适配器模式教会我的东西其实很简单代码与代码之间不是血肉相连的关系而是通过契约进行协作的关系。这套思维不仅在写代码的时候有用在架构设计、系统集成、微服务划分里都有体现。你设计系统的时候其实就是在设计接口与实现的关系如果你每一个边界都用接口的思维去想你的系统天然就会有适配的空间不会把所有东西焊死。8.3 最后分享一个实际操作心得我刚工作那两年写适配层的时候总喜欢顺手改一改被适配者的代码觉得既然逻辑有问题顺手改掉算了。后来被导师批评过他说适配器模式的核心规矩就是不改动被适配者哪怕你觉得它的实现很烂也应该以适配层的方式去消化差异而不是直接去动底层。这个规矩到现在我都很认同。底层代码往往经过了复杂的演进和历史沉淀直接改动它风险极大。你通过适配层去调整和隔离既保护了底层稳定性也让新系统的演进路径保持清晰。如果你已经理解了适配器模式的原理建议找一个真实项目里的接口不兼容的位置亲手写一个Adapter感受一下契约隔离带来的清爽感。用不用的好都是在实践中打磨出来的功夫。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进