ARTICLE DETAIL

资讯详情

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

适配器模式实战:化解支付渠道接口不兼容,从翻译到隔离

适配器模式实战:化解支付渠道接口不兼容,从翻译到隔离 做支付渠道接入的那段日子我算是把接口不兼容这件事体会透了。业务方说再加一个微信支付吧你以为就是多调一个 SDK 的事结果文档一翻人家的回调签名、验签方式、退款接口的入参跟现有系统完全不是一个路子。硬在业务代码里 if 判断渠道一个月后你自己都不愿意看那段代码。这时候适配器模式Adapter Pattern就该上场了。它是结构型设计模式里最实用、也最容易被误解的一个。它的核心作用就一句话把不兼容的接口翻译成调用方看得懂的接口。这篇文章我会把适配器模式的三个角色、两种实现方式、代码怎么落、以及和几个相似模式的边界讲透适合正在做系统集成、接手遗留系统、或者刚学完设计模式不知道怎么用的同学。你不需要背定义跟我走一遍真实场景就懂了。1. 接口不兼容这件事比你想的更常见1.1 先从支付渠道的经典场景说起我在前面的文章里提到过支付接入这里展开细说。假设你的系统已经接好了支付宝所有业务代码都对着一个PayService接口写public interface PayService { PayResult pay(String orderId, BigDecimal amount); RefundResult refund(String orderId, BigDecimal amount); }业务层不管底层是支付宝还是别的反正对着PayService调。现在产品经理说下周要上线微信支付。你打开微信支付的 Java SDK发现对方的核心类是这样的public class WechatPayClient { public String transMoney(String outTradeNo, double totalFee, String tradeType) { // 微信支付逻辑返回的是微信侧的订单号 } public String refundMoney(String transactionId, double refundAmount) { // 微信退款逻辑 } }注意几个冲突点方法名完全不同pay对transMoneyrefund对refundMoney入参类型完全不同BigDecimal对doublePayRequest对一堆散落的String返回值完全不同标准返回对象对裸String你可能说这有什么难的我在PayService的实现类里调微信 SDK 不就行了谁说不行你完全可以直接写一个WechatPayServiceImpl实现PayService然后在里面调WechatPayClient。你实际上已经写了适配器只是你自己没意识到。但这里有个隐藏问题如果微信 SDK 升级了方法签名变了或者你要替换成别的支付渠道改动会直接蔓延到PayService的实现类里。适配器模式要解决的就是这个——把对方 SDK 的细节和我方业务接口彻底隔离开。1.2 适配器模式的三个角色适配器模式一共有三个参与者。我们先不管教科书里那堆术语用大白话拆解目标接口Target调用方希望的那个接口长什么样。在上面的例子里就是PayService它代表的是我方系统的支付能力契约。适配者Adaptee那个已经存在、但接口跟你对不上的类。就是微信的WechatPayClient也可能是老系统的遗留接口、第三方 SDK、甚至是你自己团队里另一个项目写错了风格的代码。适配器Adapter夹在中间做翻译的类。它实现目标接口内部持有适配者的引用把目标接口的每个调用翻译成适配者能理解的调用。关系长这样调用方只认识PayService适配器实现PayService适配器内部持有WechatPayClient所有脏活累活都在适配器内部消化。调用方完全不需要知道微信 SDK 的存在。1.3 对象适配器和类适配器的选择模式教科书里通常提两种实现方式类适配器和对象适配器。类适配器用继承来实现让Adapter同时继承Adaptee并实现Target接口。Java 是单继承这意味着你只能适配一个类而且把继承关系用在了实现接口这种地方本质上是在滥用继承。一旦Adaptee是 final 类直接没法玩。我很少在真实项目里写类适配器除非是极端场景比如对方类是一个抽象类且你必须复用它的受保护方法。对象适配器用组合来实现Adapter持有Adaptee的引用这是我在所有项目里推荐的方式。组合的好处是灵活你可以随时替换掉持有的那个对象可以适配多个不同的 Adaptee而且没有强行继承带来的耦合。记住一个原则能用组合就别用继承。适配器模式里的对象适配器版本就是这条原则的典型应用。下面整篇我都会基于对象适配器来讲。2. 三个角色怎么落代码不只是翻译那么简单2.1 目标接口把变化点收敛成契约很多同学写适配器的时候目标接口设计得特别随意——反正就这几个方法照着抄就行。但目标接口才是整个适配器模式里最核心的设计决策因为它决定了未来所有适配器都要遵守什么规则。真实的业务里不同支付渠道的回调、退款、查询逻辑差异极大。设计目标接口时你是在定义我方系统视角下一个支付渠道必须具备什么能力。这个能力集合不是越多越好而是刚好够用、语义稳定。我在设计支付目标接口时候踩过一个坑一开始把queryOrderStatus、downloadBill、batchRefund这些方法全塞进接口。结果接第三个渠道的时候发现人家压根不做对账单下载接口里杵着一个空实现特别难堪。后来学乖了目标接口只保留当前业务真正会用到的能力那些不确定的、边角的能力宁可让调用方单独依赖具体适配器也不要污染目标接口。public interface PayService { // 只保留现在业务确定需要的能力 PayResult pay(String orderId, BigDecimal amount); RefundResult refund(String orderId, BigDecimal amount); }接口小适配器就好写接口大了每个适配器都是负担。这是第一个经验。2.2 适配者你动不了的那段代码适配者可能有几种情况。第一种是第三方 SDK比如微信、支付宝、短信服务商你没法改人家的代码第二种是你们公司老系统里的核心类那代码已经跑了好几年没人敢动测试也不敢随便碰第三种是你自己团队早期写的类接口风格已经固化改起来要动几十处调用点。适配器模式的妙处在于它默认你动不了适配者。你不需要去改微信 SDK 的transMoney方法签名不需要给老系统的方法改名字不需要和别的团队吵架。所有不兼容都在适配器这一层消化掉。2.3 适配器翻译官如何组织代码继上面的例子我写出最标准的对象适配器长什么样public class WechatPayAdapter implements PayService { private final WechatPayClient wechatPayClient; public WechatPayAdapter(WechatPayClient wechatPayClient) { this.wechatPayClient wechatPayClient; } Override public PayResult pay(String orderId, BigDecimal amount) { // 翻译把我们的参数翻译成微信 SDK 的参数 String outTradeNo orderId; double totalFee amount.setScale(2, RoundingMode.HALF_UP).doubleValue(); // 调用 String transactionId wechatPayClient.transMoney(outTradeNo, totalFee, NATIVE); // 把返回值翻译回我们的结果对象 return new PayResult(true, transactionId); } Override public RefundResult refund(String orderId, BigDecimal amount) { String transactionId wechatPayClient.refundMoney(orderId, amount.doubleValue()); return new RefundResult(true, transactionId); } }然后使用方只需要这样组装PayService payService new WechatPayAdapter(new WechatPayClient());业务代码里永远只有PayService没有人知道WechatPayClient这个东西存在。这就是适配器模式的价值——它的核心不是多写了一个类而是把变化隔离到了一个类里。以后微信 SDK 升级你只需要改WechatPayAdapter一个文件以后要接抖音支付加一个DouyinPayAdapter业务代码零改动。3. 别把Adapter用成Facade和三兄弟的区分真要区分几个容易搞混的模式了。很多人学了适配器模式之后看什么都像适配器然后就写出了四不像的代码。这里挨个捋。3.1 适配器 vs 外观模式Facade这两个是重灾区。他们都做包装这件事但动机完全不一样。适配器解决的是接口不兼容。前提是目标接口和适配者接口客观上不一致必须做翻译才能对接。比如你调微信 SDK方法名都不一样这就是不兼容。外观模式解决的是子系统复杂。前提是接口其实是兼容的但调用方用起来太费劲你给封装一个更简单的门面。比如一个电商下单系统要调库存、优惠券、积分、物流四个子系统四个子系统接口各自好好的但业务方不想关心调用顺序和协作细节。这时你写一个OrderFacade把四个子系统的协作封装成placeOrder()一个方法。判断方法很简单如果适配者接口和目标接口语言不通这是 Adapter如果语言通但太繁琐这是 Facade。把 Adapter 写成 Facade 的结果是你为了让调用方省事直接改了目标接口的语义把一堆东西塞进去最后适配器内部干了大量和翻译无关的活。3.2 适配器 vs 装饰器Decorator装饰器模式的正面开口我见过不少次。它和适配器的区别在于装饰器不改变接口它做的是增强。同样一个PayService加个日志装饰器、加个重试装饰器、加个缓存装饰器接口不变功能一层层叠上去。适配器是改变接口的。它把 A 接口变成 B 接口让两边能对上。如果有人说我要给这个类加缓存用适配器包一下吧这属于滥用。缓存该用装饰器或者代理不是适配器。还有一个细节装饰器在实现上通常持有的是同一个接口类型构造函数接收PayService自己也是PayService而适配器持有的是另一个类型构造函数接收WechatPayClient自己实现PayService。从构造函数的参数类型一眼就能分辨。3.3 适配器 vs 桥接Bridge桥接模式解决的是抽象和实现各自独立变化。比如Notification抽象类分EmailNotification、SmsNotification实现层分WindowsSender、LinuxSender两个维度都能独立扩展。适配器解决的是已有的东西接口放不进已有的框架。桥接是设计阶段就规划好的双方都在你的掌控下适配器是事后的、补救性质的适配者往往你动不了。3.4 快速判断表模式接口是否改变解决什么问题适配者归属适配器改变A翻译成B接口不兼容通常动不了对方外观不改变简化调用子系统复杂子系统都是自己的装饰器不变增强行为增加功能同类接口叠加桥接不改变分离维度多维变化设计阶段规划写代码之前先拿这个表对照一遍写出来的东西不会跑偏。4. 完整走一遍支付渠道接入的实战演示之前讲角色讲概念现在把手上的真实代码贴全了。模拟一个业务场景订单系统需要支持支付宝和微信两种支付方式。支付宝 SDK 的接口风格和微信完全不同早期接入支付宝时没做适配直接在业务代码里写死了。现在接微信正好借这个机会重构一版。4.1 定义统一目标接口public interface PayService { PayResult pay(String orderId, BigDecimal amount); RefundResult refund(String orderId, BigDecimal amount); // 回调验签方法不同渠道回调逻辑差太多这里抽象处理 boolean verifyCallback(MapString, String params, String sign); }这个接口定了业务层的代码就全部对着它写。注意里面的verifyCallback—— 这是我觉得最值得抽象的方法。不同支付渠道的回调参数、验签算法天差地别但业务方不在乎你是 MD5 还是 RSA只管这笔回调是不是真的。这一层抽象能在后面省掉大量 if-else。4.2 被适配方两个不一样的 SDK模拟支付宝 SDK老旧风格方法名和参数都是中文拼音风格public class AliPayClient { public String zhiFu(String orderNo, String amountYuan, String productDesc) { // 模拟调用支付宝返回支付宝交易号 return ALI orderNo; } public boolean yanQian(MapString, String bizParams, String signText) { // 模拟验签直接返回 true return true; } }模拟微信 SDK前面已经出现过public class WechatPayClient { public String transMoney(String outTradeNo, double totalFee, String tradeType) { return WX outTradeNo; } public boolean checkSignature(String xmlData, String appId, String mchId) { return true; } }注意看这两个 SDK 不仅和PayService不兼容它们两个之间也互相不兼容。适配器模式的价值在这里体现得更明显每个渠道一个适配器互不干扰各自翻译各自的。4.3 创建两个适配器public class AliPayAdapter implements PayService { private final AliPayClient aliPayClient; public AliPayAdapter(AliPayClient aliPayClient) { this.aliPayClient aliPayClient; } Override public PayResult pay(String orderId, BigDecimal amount) { // 金额统一转成元传字符串 String amountYuan amount.toPlainString(); String aliTransNo aliPayClient.zhiFu(orderId, amountYuan, 测试商品); return new PayResult(true, aliTransNo); } Override public RefundResult refund(String orderId, BigDecimal amount) { // 假设支付宝退款用的同一个 zhiFu 方法传负数金额 String aliTransNo aliPayClient.zhiFu(orderId, - amount.toPlainString(), 退款); return new RefundResult(true, aliTransNo); } Override public boolean verifyCallback(MapString, String params, String sign) { return aliPayClient.yanQian(params, sign); } }public class WechatPayAdapter implements PayService { private final WechatPayClient wechatPayClient; public WechatPayAdapter(WechatPayClient wechatPayClient) { this.wechatPayClient wechatPayClient; } Override public PayResult pay(String orderId, BigDecimal amount) { double fee amount.setScale(2, RoundingMode.HALF_UP).doubleValue(); String wxTransNo wechatPayClient.transMoney(orderId, fee, NATIVE); return new PayResult(true, wxTransNo); } Override public RefundResult refund(String orderId, BigDecimal amount) { String wxTransNo wechatPayClient.refundMoney(orderId, amount.doubleValue()); return new RefundResult(true, wxTransNo); } Override public boolean verifyCallback(MapString, String params, String sign) { // 微信的验签方式和支付宝完全不同在适配器里消化差异 String xmlData buildXmlData(params); return wechatPayClient.checkSignature(xmlData, appId, mchId); } }这时你再接一个新的支付渠道工作流是固定的读新 SDK 的文档找方法名、参数、返回值写一个新的 Adapter 类实现PayService在工厂或配置中心把新 Adapter 注册进去业务代码零改动我之前带过一个实习生第一次接触适配器模式他问了个特别好的问题那我为什么不直接在PayService的实现类里写 if 判断是哪个渠道答案很简单你写 if 也能跑但下一次加渠道你还要去改PayService的实现类改了还可能影响老渠道。有了适配器加渠道就是新增一个文件不碰任何旧代码。这正是开闭原则——对扩展开放对修改关闭。4.4 组合优于继承这件事在适配器里的体现我在前面说了对象适配器、类适配器。现在拿微信这个例子讲透如果让你用继承写适配器你会写// 类适配器写法不推荐 public class WechatPayAdapter extends WechatPayClient implements PayService { Override public PayResult pay(String orderId, BigDecimal amount) { double fee amount.doubleValue(); String wxTransNo transMoney(orderId, fee, NATIVE); return new PayResult(true, wxTransNo); } // ... }貌似也行但问题来了你继承了WechatPayClient同时实现了PayService。如果WechatPayClient内部有final方法、有复杂的构造器需要传参、有不能继承的约束这套就玩不转。而且WechatPayClient和AliPayClient两个类你想同时适配继承完全做不到。组合写法通过构造函数把 SDK 对象传进来你想在测试时传入 mock 对象、想运行时动态替换渠道、想在适配器里额外做点日志记录都非常自然。这也是为什么 GoF 那本书虽然同时讲了两种方式但实际项目里大家几乎都选对象适配器——不是书里写得不够好而是组合的灵活性在现代软件开发里的优先级更高。5. 遗留系统改造里适配器最好用老代码的马甲思路5.1 面对老接口动不得怎么办适配器模式另一个大价值是在遗留系统里。我接过一个老项目核心订单接口叫submitOrder(String userId, String skuId, int count)参数靠逗号分隔、返回值是String带了各种状态码。新系统里我们想要一个面向业务的OrderService.placeOrder(OrderRequest req)接口返回结构化OrderResult。理论上你应该重构老代码让老接口统一变成新接口。但现实是老接口被全公司十几个系统调用着你改它的签名等于给所有人找不痛快。这时候适配器就是最优解保留老实现不动让它继续服务旧的调用方写一个NewOrderServiceAdapter实现新的OrderService接口在适配器内部调用老接口并把老接口的参数、返回值做翻译这样新系统用新接口、老系统继续用老接口两者并存。等老调用方逐步迁移完毕老接口才能慢慢下线。适配器在这里相当于给老代码披了一件新马甲让新旧系统能在同一个进程里共存过渡。5.2 适配器的命名习惯代码里命名就是文档。我有自己的命名习惯用下来特别顺手适配器类名 被适配对象名 Adapter比如WechatPayAdapter、LegacyOrderAdapter尽量不要直接叫PayAdapter这种。因为可能出现支付宝适配器、微信适配器两个类你想区分它们还得回头翻包名如果适配器还附带了一些渠道相关的配置参数可以加后缀比如WechatPayAdapterV2接口的命名我用业务能力来命名而不是渠道名。比如一个系统要接多个短信服务商我不会建AliSmsService、TencentSmsService我会建一个SmsSender然后AliSmsAdapter和TencentSmsAdapter分别实现它。这样调用方只认识SmsSender新增渠道就是加个实现类。5.3 适配器的依赖方向还有一个很多人会忽略的点适配器应该依赖接口而不是依赖具体的调用方场景。一个支付适配器不应该知道我是被订单系统用还是被会员系统用——它只做接口翻译不掺业务逻辑。我见过一个反面案例适配器里为了满足某个报表需求偷偷在pay()方法里插入了一段往数据库写日志的逻辑。刚开始没问题后来报表需求变了这个适配器就变成了支付结算的混合体别人怎么读都别扭。适配器只做翻译不做业务这是铁律。如果确实需要在调用前后做点什么请用装饰器或者基于事件监听机制别塞进适配器。6. 适配器模式的边界和坑6.1 什么时候不该用适配器不是所有接口不一致都要引入适配器。如果不符合下面几个条件适配器反而会让你代码更碎变化频率高如果对方的接口稳定到十年不变你硬写一个适配器等于为空转的轮子加轴承接入方数量多如果只有一段代码使用对方接口直接在调用处写逻辑问题不大没有团队边界如果那个不兼容的类本来就是你自己写的、能随便改你应该直接改接口而不是包一层适配器还有一种情况是适配器套娃。有人为了让代码看起来高级给适配器也写了个接口然后适配器工厂的工厂最后谁也看不懂。适配器模式的核心价值是简单、直观、职责单一一旦开始层层包装说明你把简单问题复杂化了。6.2 JDK 和常用库里的适配器例子多看看现实世界的适配器能帮你加深理解。JDK 里经典的有InputStreamReader把InputStream字节流适配成Reader字符流构造时传字节流对外表现是字符流OutputStreamWriter同理字节流到字符流的桥Arrays.asList()把数组适配成List让你能用List的 API 操作数组Collections.enumeration()把Iterator适配成EnumerationSpring 里的HandlerAdapterSpring MVC 用统一的HandlerAdapter接口去适配不同的HandlerController方法、HttpRequestHandler等所以不同风格的处理器能在一个框架里共存有这些例子打底你再遇到两个类不兼容的场景就会条件反射地想到适配器。6.3 我踩过的/见过的坑第一个坑是我自己掉的适配器里做状态管理。早期写的一个适配器为了让调用方能少传参在适配器内部缓存了一些调用方的业务状态。结果多线程一出问题A 请求的参数被 B 请求覆盖了。适配器应该是无状态的或者最多持有 SDK 客户端这种线程安全的对象。业务状态永远放在上下文对象里传进来而不是在适配器里缓存。第二个坑是我们团队踩的适配器把异常吞掉。对方 SDK 抛了个我们没见过的异常类型适配器里 catch 住之后 print 一行日志就返回成功了。结果财务对账对不上排查了整整三天才发现是微信渠道的退款接口在特定金额下会抛异常适配器把它吞了。记住适配器是翻译层不是容错层。你不能理解对方的异常就把异常原样抛出去让上层去决定怎么处理。第三个坑是命名不统一。项目里有人写WechatPayAdapter、有人写WxPayServiceImple、有人写WechatUtil。结果同一个支付渠道在代码库里有三个不同封装新来的同事根本不知道该用哪个。后来我们统一了约定凡是做接口适配的类一律叫XxxAdapter普通业务服务不带 Adapter 后缀。命名规则看起来不起眼但在中大型项目里能省下大量沟通成本。第四个坑是适配器和业务缓存混在一起。有同学为了性能直接在适配器里加了本地缓存把渠道返回的交易号缓存起来。这样确实快了但一旦对账系统需要实时数据缓存里的数据就是个雷。适配器保持纯净如果要加缓存请用装饰器模式在外部包装或者明确在文档里写清楚这个缓存属于哪个层。如果你在自己的项目里也发现了类似的问题——接口对不上、调用方被 SDK 绑架、老代码不敢动又需要新接口可以试着用适配器去解。先画清楚三个角色选对象适配器保持适配器纯净你的代码会清爽很多。我实际写下来的体会是适配器模式是所有设计模式里性价比最高的几个之一因为它解决的是每天都在发生的现实问题而且实现成本极低。最后分享一个小技巧当你开始写一个新增的 Adapter 类时如果发现需要复制的超过二十行翻译代码说明目标接口和对方接口的差异已经大到该考虑用防腐层甚至独立模块来隔离了。适配器适合隔离小差异大差异需要更大的边界。
返回列表