ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

适配器模式实战:接口转换、对象适配器与第三方支付接入

适配器模式实战:接口转换、对象适配器与第三方支付接入 接手过老项目的人应该都有这种体会系统里那些七零八落的接口没有一个能和你新写的代码严丝合缝地对接上改老的怕出事改新的又觉得亏。这时候最稳的办法往往不是强行改某一方的代码而是在中间加一层“转接头”让双方各干各的互不干扰。这件事放到设计模式里就是适配器模式Adapter Pattern。我最早接触这个名字是在大学里看《Head First 设计模式》当时觉得它不过是“把一个接口转换成另一个接口”而已直到在真实项目里用了几次才发现这个模式真正解决的问题不是接口长什么样而是代码和代码之间的关系怎么处理。这篇文章不打算堆概念我会从为什么需要适配器开始讲清楚两种实现方式的取舍再拿一个接第三方支付的完整例子带大家走一遍最后聊聊它在框架里的身影、配得上的坑以及和外观模式、代理模式的边界。内容适合正在学设计模式的学生也适合写业务代码写到快崩溃的工程师尤其适合那些想在老系统改造、多厂商对接场景里少走弯路的同学。1. 适配器模式到底在解决什么问题1.1 从电源插头说起适配器这个词其实从生活里来的。你从国外带回来一个笔记本插头是欧标的两个圆脚国内排插是国标的三扁脚或者两扁脚直接怼进去基本不可能。这时候你不会把墙上的插座拆了重做更不会把笔记本的电源线剪了换头而是去一趟超市买个转换插头十几块钱插上就能充。设计模式里的适配器干的就是这件事客户Client面对的插座是标准接口外部系统Adaptee提供的插头是另一种规格适配器Adapter就是中间那个转换头。它做的事情是让原本因接口不匹配而无法协作的类可以一起工作并且在这个过程中双方都不需要修改自己。放到设计模式的定义里适配器模式属于结构型模式结构型模式关注的核心是“类和对象怎么组合”。组合得好系统就松耦合改A不炸B组合得烂牵一发而动全身。适配器模式就是为了解决“接口不匹配时的组合问题”而存在的。注意适配器不是万能的。如果外部系统的行为逻辑和你期望的完全不同光靠接口转换解决不了根本矛盾后面我会讲到什么时候不该用适配器。1.2 需求场景与设计目标用代码场景来描述可能更直接。假设你现在写了一个订单服务内部调用一个PaymentService接口public interface PaymentService { void pay(String orderId, double amount); }你的业务代码到处都在依赖这个接口方法调用、参数类型、返回值全是按这个接口来设计的。结果后端突然告诉你公司的支付通道要换新通道SDK提供的接口长这样public class NewPaySDK { public boolean charge(String bizOrderNo, BigDecimal money, String currency) { // 真实支付逻辑 return true; } }注意这里的问题不只是方法名不一样还牵扯到了参数类型String对Long、double对BigDecimal、返回值语义void对boolean、甚至业务概念orderId对bizOrderNo。如果你选择直接改PaymentService和所有调用方那订单模块、支付回调、对账逻辑全部要跟着动改完还得全量回归代价大到离谱。这时候适配器模式的思路就出来了保持PaymentService接口不动写一个适配器类实现PaymentService内部包住NewPaySDK在pay()方法里完成参数转换和调用。对外你依然在用原来的接口对内你已经无缝切到了新SDK。整个过程调用方一行代码都不用改。这个例子其实引出了适配器模式最重要的设计目标把“变化的实现”和“稳定的接口”隔离开。适配器模式允许你在两边都稳定的前提下引入变化稳定性来自接口不变变化性来自适配器可替换。这是它和很多模式最大的不同——它不是为了设计出新的交互方式而是为了在不改变现有交互方式的情况下把不兼容的东西接进来。2. 两种主流实现方式类适配器与对象适配器2.1 类适配器用继承实现转换适配器模式在Gof书里分两种类适配器和对象适配器。类适配器通过继承来复用被适配者的代码它让适配器同时继承目标接口和被适配类然后在目标接口的方法里调用被适配类的逻辑。直接看代码会更清楚。还是用上面的支付场景如果用类适配器实现// 目标接口订单模块依赖的稳定接口 public interface PaymentService { void pay(String orderId, double amount); } // 被适配者新的支付SDK public class NewPaySDK { public boolean charge(String bizOrderNo, BigDecimal money, String currency) { // 真实发起支付 return true; } } // 类适配器继承SDK实现目标接口 public class NewPayAdapter extends NewPaySDK implements PaymentService { Override public void pay(String orderId, double amount) { // 继承来的 charge 方法在这里被重新包装 boolean success charge(orderId, BigDecimal.valueOf(amount), CNY); if (!success) { throw new RuntimeException(支付失败); } } }因为NewPayAdapter继承了NewPaySDK所以charge()方法是直接继承过来的不需要额外持有对象代码看起来非常精简。这种写法在C里常见一些因为C支持多继承一个适配器可以同时继承接口和被适配类写起来很顺。Java里也可以用代价是只能继承一个类而且继承关系是强耦合的一旦SDK类有改动适配器也要跟着变。类适配器和对象适配器的核心区别是“复用方式”。类适配器用继承对象适配器用组合这是面向对象设计里老生常谈的两条路线实际开发中绝大多数情况下应该选组合。2.2 对象适配器用组合实现转换对象适配器不继承被适配者而是拿着被适配者的引用在目标接口的方法内部去调用被适配者的方法。上面那个支付例子重写成对象适配器是这样public class NewPayAdapter implements PaymentService { private final NewPaySDK newPaySDK; public NewPayAdapter(NewPaySDK newPaySDK) { this.newPaySDK newPaySDK; } Override public void pay(String orderId, double amount) { boolean success newPaySDK.charge(orderId, BigDecimal.valueOf(amount), CNY); if (!success) { throw new RuntimeException(支付失败); } } }从实现上看就是构造函数传入被适配者方法里转一手调用。但别小看这个区别。组合的方式让适配器只依赖接口不依赖SDK的具体类型哪怕SDK内部再怎么换实现只要SDK对外方法不变适配器都不用动。另外组合的方式允许适配器同时适配多个被适配者甚至可以在运行期动态切换继承做不到这件事。下面这张表可以帮你快速决策对比维度类适配器对象适配器复用方式继承被适配类持有被适配对象引用耦合度强约束了被适配类的类型弱面向接口编程灵活性低继承关系静态固定高可以在运行期动态切换实现复杂度简单代码少稍微多一点但可读性更好适用语言C等多继承语言更方便所有面向对象语言通用推荐程度只在特殊场景使用绝大多数场景的首选我自己在实际项目里几乎没有用过类适配器Java单继承限制摆在那里为了接一个SDK牺牲一个继承位容易后面想扩展时没地方站。对象适配器虽然多写几行构造和委托但换来的是“被适配者可以被替换、可以被Mock、可以传参配置”的灵活性这笔账怎么算都不亏。3. 一个完整的实战案例接入第三方支付3.1 需求背景与原始接口前面断断续续提到了支付场景但真正要理解适配器模式的实操价值还是得看一个完整的上下文。假设你所在的项目是一个供应链管理系统里面的订单模块从三个业务方共用分别是对账小程序、运营后台、客户自助门户。这三个入口都调一个公共的支付服务public interface TradePayService { void pay(String tradeNo, double amount) throws PayException; }如果我说“现在要把底层支付渠道从A换到B”你可能觉得直接换SDK调用就行。但现实里没这么简单渠道B的SDK不只方法名不一样它对订单号有格式要求金额只能传BigDecimal而且有币种参数最关键的是它采用回调确认机制pay()方法只是提交预支付单真正支付成功要等回调。光凭这一点你那个void pay()的接口就没法直接映射过去。这时候适配器模式就派上用场了。因为它本质上允许你在“目标接口不许改”和“被适配者没法改”两个约束并存的情况下找到一个中间落点。这里目标接口是TradePayService被适配者是渠道B的ChannelBSdk。适配器做的事情包括业务订单号转换成渠道要求的格式、金额类型转换、立即提交预支付单、返回时把“已受理”解释成业务意义上的“已提交”。代码结构大概是这样的// 渠道B的SDK public class ChannelBSdk { public String submitPrepay(String outTradeNo, BigDecimal totalAmount, String currency) { // 返回渠道流水号同时等待异步回调 return CHANNEL_B_20240601XXXX; } } // 适配器 public class ChannelBPayAdapter implements TradePayService { private final ChannelBSdk channelBSdk; public ChannelBPayAdapter(ChannelBSdk channelBSdk) { this.channelBSdk channelBSdk; } Override public void pay(String tradeNo, double amount) throws PayException { // 1. 订单号转换 String outTradeNo transformTradeNo(tradeNo); // 2. 金额类型转换 BigDecimal targetAmount BigDecimal.valueOf(amount); // 3. 调用渠道B接口 try { String channelFlowNo channelBSdk.submitPrepay(outTradeNo, targetAmount, CNY); // 4. 把渠道流水号保存起来后续回调要用 saveChannelFlowNo(tradeNo, channelFlowNo); } catch (Exception e) { throw new PayException(渠道B支付提交失败, e); } } private String transformTradeNo(String tradeNo) { // 渠道B要求订单号必须是数字且不超过32位这里做脱敏和格式化 return tradeNo.replaceAll([^0-9], ); } }3.2 适配器实现与测试写适配器不是把代码塞进去就行它有两个问题必须处理干净。第一是异常语义。渠道B的SDK抛出的异常五花八门有网络异常、签名异常、余额不足直接让它往上抛会让上层业务难以判断。适配器里要统一转换成业务侧异常同时保留原始异常信息这样崩溃时才能追根溯源。第二是返回值语义。submitPrepay只代表“受理成功”不代表“支付成功”。适配器必须把这两个概念分开否则订单系统会因为“提交了”就以为“钱到账了”后面对账必然出错。处理完这两个问题适配器才能从“接口转换工具”升级成“业务边界清晰的对象”。在真实项目中我会把这种适配器放在独立的adapter包下并且只依赖目标接口和底层SDK不依赖任何上游业务逻辑。这样做的好处是以后渠道B再升级或者出问题我可以单独替换适配器而不需要动订单模块。单元测试也是适配器开发里很重要的一环。因为适配器是纯转化层是最适合做单测的位置。我通常会Mock掉ChannelBSdk然后验证适配器是否正确转换参数、是否抛异常、是否保存了流水号public class ChannelBPayAdapterTest { Test void test_pay_should_convert_params_and_save_flow_no() { ChannelBSdk sdk mock(ChannelBSdk.class); when(sdk.submitPrepay(anyString(), any(), any())).thenReturn(CHANNEL_B_123456); ChannelBPayAdapter adapter new ChannelBPayAdapter(sdk); adapter.pay(ORD20240601-A01, 158.50); verify(sdk).submitPrepay(ORD20240601A01, BigDecimal.valueOf(158.50), CNY); // 再验证保存流水号的逻辑 verify(flowNoRepository).save(ORD20240601-A01, CHANNEL_B_123456); } }这个测试跑起来不需要任何外部服务也不需要数据库连真库。适配器的价值就在这它把一层最容易出错的转换逻辑独立出来让所有业务方都在它后面安稳工作而你只需要保证这层转换逻辑是正确的。3.3 缺省适配器一种很有用的变体用到真实项目里适配器模式还有一个变体叫缺省适配器Default Adapter很多书本没细讲但实战里非常实用。场景是这样的目标接口定义了十几个方法你的业务方只关心其中一两个如果直接去实现这个接口你被迫写一堆空方法又丑又容易漏。缺省适配器的做法是先写一个抽象类把接口里所有方法都实现成空操作或默认值让真正的适配器去继承这个抽象类只覆写自己关心的那部分。Java 8之后有了default方法这个模式的必要性下降了一些但如果你在维护老项目或者目标接口是第三方包里的接口不能加default方法缺省适配器依然是很好的过渡手段。比如某旧版回调接口有onSuccess、onFail、onRetry、onSign、onTimeout五个方法你的业务只关心成功和失败就可以用一个中间抽象类把后面三个空实现掉具体适配器只覆写前两个。代码可读性立刻提升不少也不会因为漏实现某个方法导致编译器报错后随便补一个空壳。4. 适配器模式在真实框架里的身影4.1 JDBC就是典型的适配器模式很多教材讲适配器模式都会拿JDBCJava Database Connectivity当例子。JDBC定义了一套统一的数据库访问接口比如Connection、Statement、ResultSet数据库厂商负责提供各自的驱动实现。你的业务代码只面向JDBC接口写换来换去只需要改注册驱动的类名和URL。这个架构里的每个驱动本质上就是一个适配器负责把MySQL的协议翻译成JDBC接口能够理解的调用把Oracle的差异封装在驱动内部。你写业务代码的人不需要关心MySQL和Oracle是怎么通信的也不需要关心它们的SQL方言差异JDBC接口就是那个稳定的目标接口。JDBC这套设计之所以能长盛不衰靠的就是适配器模式的核心思想面向稳定接口编程变化隔离在驱动层。如果JDBC直接要求所有数据库厂商提供完全一致的内部实现Oracle、MySQL、PostgreSQL早就分道扬镳了。因为接口稳定驱动可以各自迭代进化这是适配器模式在系统架构层面最有说服力的例子。4.2 Spring MVC里的HandlerAdapterSpring MVC里也藏着一个适配器模式的经典案例。DispatcherServlet拿到请求后要找到对应的Handler来处理。但Handler可以是注解式的Controller方法可以是实现Controller接口的类也可以是HttpRequestHandler甚至可以是Servlet。这几种类型的方法签名完全不一样DispatcherServlet不可能针对每种类型写死调用逻辑。于是Spring抽象了一个HandlerAdapter接口每个具体的HandlerAdapterRequestMappingHandlerAdapter、SimpleControllerHandlerAdapter、HttpRequestHandlerAdapter适配一种Handler类型。DispatcherServlet只依赖HandlerAdapter接口通过HandlerMapping找到合适的Adapter再调用适配器的handle()方法完成请求处理。这个设计里DispatcherServlet是客户HandlerAdapter是目标接口各种Handler是适配者。每引入一种新的Handler类型只需要新增一个对应的HandlerAdapterDispatcherServlet本身完全不需要改。Spring MVC能支撑这么多扩展点离不开这个适配器架构。4.3 日志门面与缓存工具中的适配痕迹类似的例子还有日志门面。SLF4J本身不实现日志功能它定义了一套日志接口然后通过slf4j-log4j12、slf4j-simple、logback这些绑定包把不同的日志实现适配到统一接口上。你代码里只写LoggerFactory.getLogger(...)底层是logback还是log4j对上层完全透明。这也是适配器模式在开源生态里最常见的用法。框架里到处都是适配器反倒说明适配器模式不是某种只有面试才会问的“纸面模式”。它的核心价值在于给系统提供一个稳定的调用层让底层实现可以后续无限替换。不过要注意框架里的适配器往往比我们自己写的更复杂牵扯到生命周期管理、线程模型、异步处理等众多因素理解框架适配器的思路和直接模仿代码是两回事后者需要更多上下文。5. 适配器、外观模式和代理模式的边界5.1 三个模式容易混淆的根源很多初学者会把适配器模式和外观模式Facade、代理模式Proxy混在一起因为它们都是“多了一层”。这一层里不是随便写的每种模式出现的原因和改造的侧重点完全不同。适配器模式的出发点是接口转换。它解决的是“客户想要A接口但实际只有B接口且两边都不能改”的问题重点是让不兼容的接口变得兼容。外观模式的出发点是简化调用。它把一堆复杂的子系统接口整理成一个简单的入口重点不是解决兼容性而是降低客户的使用成本你不需要“转接口”只需要“一键调用”。代理模式的出发点是控制访问。代理和被代理对象实现的是同一个接口代理不改变接口而是在调用前后加一层控制逻辑比如权限校验、缓存、懒加载、日志。这三者的区分可以用一个生活化类比。适配器是电源转换头接口形状变了外观是酒店前台你不用逐一联系客房服务、餐饮部、维修工给前台打一个电话就有人去协调代理是明星经纪人外界联系明星的方式还是打电话但电话先被经纪人接了经纪人在中间决定该不该转接。5.2 实际选型时怎么判断该用哪一个选型的问题我一般会问自己三个问题。第一客户期待的接口和现有类提供的接口是不是真的不兼容如果不兼容走适配器。第二客户是不是被一堆子系统接口搞得晕头转向我是不是只需要整理一个更友好的门面如果是走外观模式。第三我要不要在不改接口的前提下控制对某个对象的访问或者补充一些横切逻辑如果是走代理模式。最后还有个经验之谈适配器模式很容易被滥用成“万能胶水”。如果一个系统里到处都是适配器每个适配器都在做各种奇怪的转换那你真的要停下来想一想是不是系统本身的接口设计出了问题。适配器模式有一句经典的话“别过度设计。”当一个系统新来的模块都要求通过适配器接入而不是每个模块本身就面向稳定的接口分工时适配器已经从解决方案变成了技术债。我见过一个项目因为早期接口设计太随意后面接了几十个适配器来互相转那已经不是设计模式了是打补丁。正确做法是用适配器赢得一段时间然后推动底层接口收敛而不是让适配器一直躺在那边“接烂摊子”。6. 适配器模式里的常见坑与排查技巧6.1 接口方法太多导致的适配器臃肿适配器最大的痛点之一是目标接口方法太多而适配者根本不关心其中一大半。Java 8之前写个适配器要被迫实现所有方法很多人在不需要的方法里抛UnsupportedOperationException或者直接写空实现结果后续调用某个方法时才发现没有真正实现排查半天才看到适配器里躺着一堆空壳。这个问题有几种解法。如果目标是自己的接口优先在接口里加default方法处理兜底逻辑如果目标是第三方接口用缺省适配器中间层接一下。还有一种思路是拆分接口把大接口拆成多个小接口让适配器只实现相关的那部分这样既符合接口隔离原则也让适配器本身轻量很多。我自己在重构老系统时最喜欢用拆分接口的方式虽然一开始要动的代码多一些但后期每个适配器的职责都一目了然。6.2 异常转换丢了原始堆栈适配器在捕获异常后重新包装成新异常时很容易犯的一个错误是只传异常信息不传原始异常对象。比如很多人写完throw new PayException(渠道B失败)就结束了底层SDK抛出的NetworkException的堆栈信息全部丢失线上排查时只能看到一个干巴巴的业务异常完全不知道是哪个网络环节出的问题。正确的写法是给新异常传入原始异常作为cause也就是throw new PayException(渠道B支付提交失败, e)这样异常链才能完整保留。如果你用的异常类没有带cause的构造函数把原始异常信息记录下来也是好的。排查线上问题时一个完整的堆栈链能让你省去至少半小时的瞎猜时间。6.3 适配器嵌套和过度包装适配器套适配器是我在评审代码时最头疼的场景之一。A适配器把第三方SDK转换成内部接口B适配器把内部接口再转换一次C适配器又加了一层参数转换三层嵌套下来每个层都有自己的一堆转换规则代码完全没法追踪出了Bug只能一层一层断点进去看。适配器模式的初衷是“加一层少改一处”但层数一旦超过两层就需要重新审视。大多数情况下只应该有一层适配器去对接外部接口。如果目标接口和外部接口差距太大那要先反思自己定义的目标接口是不是设计得有问题而不是靠在外部再加一层适配器来填坑。这里我有个个人经验代码评审时只要看到超过两层的适配调用我基本会提一个重构意见哪怕重构会引起一些暂时性的改动也好过长期维护一个套娃结构。6.4 适配器真的需要单测吗很多人觉得适配器只是“转一手”不值得写单测。这个认知恰恰是错的。适配器是所有转换逻辑汇聚的地方最容易出参数量级错误、空指针、格式问题。我遇到过适配器里漏转了一个字段导致生产环境的订单状态一直对不上排查了两天才定位到就是因为那段代码既没有人审出问题也没有测试覆盖。所以我的建议是适配器必须写单测而且重点覆盖几个点正常参数转换是否符合预期、异常情况下是否抛出对应异常、边界值比如金额为0、负数、超大金额是否处理正确、被适配者的返回值异常时如何应对。这些测试写起来成本很低Mock一下被适配者就行但它们能把适配器从整个链路里最不可控的环节变成最可靠的环节。7. 学习适配器模式时的一点个人体会设计模式这种东西光看书没有用光背定义更没用。我在实际项目中真正“开窍”是遇到一个老系统改造任务。旧系统用一个自己封装的短信发送组件接口设计得乱七八糟新系统要接入阿里云短信和腾讯云短信两边参数格式、重试机制、返回结果全都不一样。当时我没有直接改旧接口而是写了三个适配器分别对应旧的短信组件、阿里云SDK和腾讯云SDK。结果旧系统所有上游服务一行代码没改很轻松就切到了新的短信通道。那个任务做完之后我才真正理解了适配器模式的价值它不是为了应付设计模式考试而是为了让你在真实世界里能够用最小的代价把变化引进来。如果你现在正在准备考试或者面试我建议你别死记定义先把“接口转换、双方不动、组合优先”这十二个字吃透。然后打开一个真实项目找一个对接外部SDK的入口思考一下如果直接改业务流程会有什么后果再试着写一个适配器把不兼容的部分挡在外面。设计模式从来不是背出来的是改出来的。适配器尤其如此它看起来简单但在真实系统里的应用深度真的可以差得很远。
RELATED READING

延伸阅读

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