ARTICLE DETAIL

资讯详情

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

适配器模式实战:解决接口不匹配与第三方SDK整合难题

适配器模式实战:解决接口不匹配与第三方SDK整合难题 1. 同一个“对不上”三种完全不同的解法做后端开发的这些年我几乎每个项目都会碰到“接口对不上”的尴尬。第三方SDK的返回结构和我们的领域模型对不上老系统的报文格式和新接口对不上就连同一个团队里两个微服务的参数习惯都可能对不上。这时候适配器模式Adapter Pattern往往是我第一个想到的设计模式。它不是最炫技的模式却是最能救场的模式之一。接口不匹配这件事我在不同的项目里见过至少三种典型场景。第一种是接入外部供应商比如支付渠道、物流服务商、短信平台接口文档是对方定的字段命名、调用方式、错误码全由不得我们而业务层又不希望代码里到处都是wxpay_sdk.doXxx和alipay_sdk.doYyy这种碎片化调用。第二种是历史遗留系统老系统已经跑了好几年核心表结构不能动接口签名也不能动但新系统需要以另一种方式消费它。第三种其实是团队内部的接口演化新框架把返回结构变了老调用方又多不可能让所有调用方都跟着改一遍。面对这三种情况很多人第一反应是硬改。改调用方、改数据格式、甚至在 Service 里写一堆 if-else 判断渠道类型。短期看是能跑长期看就是在给项目埋债。接入第三个渠道的时候代码里会出现第三个 if 分支等到接入第五个渠道的时候维护的人已经分不清哪些逻辑是业务规则、哪些逻辑是渠道差异。适配器模式解决的就是这个“接口形状不一致”的问题。它不去改老代码也不去污染业务逻辑而是单独加一层转换器把外部接口翻译成我们期望的接口。接口变形的部分被集中在一个类里业务层只跟稳定的目标接口打交道。理解这一点是正确使用适配器模式的前提。很多初学者会把适配器理解成一种“对接工具”觉得只要两个东西连不起来就套个适配器。但其实适配器的核心不在“连”而在“翻译”。它把一种消息形式转换成另一种消息形式把一种调用语义转换成另一种调用语义把一种异常模型转换成另一种异常模型。翻译这件事说简单也简单说难也难因为翻译得不好业务层就会被迫去理解渠道的细节。2. 适配器模式的三个角色以及对象/类两种写法怎么选2.1 三个角色的定位适配器模式涉及三个角色分别是目标接口Target、被适配者Adaptee和适配器Adapter。目标接口是调用方真正想使用的接口它应当体现业务语义而不是渠道语义。被适配者是已经存在的、接口不匹配的组件通常来自第三方SDK或者老系统。适配器则负责把被适配者的接口转换成目标接口。举个例子。假设团队里有一套统一的对外推送服务我们希望在业务层只需要做一件事把消息发给某个用户。于是定义一个目标接口:public interface MessageSender { void send(String target, String title, String content); }老系统里已经有一个负责发邮件的组件接口却是这么写的public class MailSender { public void sendMail(String to, String subject, String body) { // 真实的邮件发送逻辑 } }要让MessageSender能调通MailSender就写一个适配器public class MailSenderAdapter implements MessageSender { private final MailSender mailSender; public MailSenderAdapter(MailSender mailSender) { this.mailSender mailSender; } Override public void send(String target, String title, String content) { mailSender.sendMail(target, title, content); } }这样业务层只需要注入MessageSender根本不知道底层是邮件、短信还是App推送。将来要换渠道多写一个适配器即可业务代码不用动。2.2 为什么我一直推崇对象适配器上面的写法是对象适配器也叫组合适配器。适配器内部持有一个被适配者的实例通过组合的方式完成调用。我绝大多数情况下都这么写原因有三个。第一组合比继承更安全。适配器只需要依赖被适配者的公开接口不关心它的内部实现因此被适配者的改动对适配器的影响会被限制在最小的范围内。第二对象适配器可以适配一个被适配者的任意子类。如果MailSender有QqMailSender、AliMailSender等多个子类适配器只要持有父类型引用就能通用。第三Java 是单继承语言类适配器一旦继承了被适配者就没法再继承其他基类这在一个复杂的工程里是很大的限制。还有一个从实践中得到的体会对象适配器更符合依赖倒置原则。适配器面向接口编程而不是面向具体类编程。它依赖的是MailSender的公开能力而不是把它内部的方法翻个底朝天。这一点在后期的维护中特别重要。2.3 C 的类适配器多继承带来的另一个选项C 在设计上有多继承所以存在一种和对象适配器并列的实现方式叫类适配器。它的写法是让适配器同时继承目标接口和被适配者原理上利用了两个类的接口合并class Target { public: virtual ~Target() default; virtual void send(const std::string target, const std::string title, const std::string content) 0; }; class LegacyMailSender { public: void sendMail(const std::string to, const std::string subject, const std::string body) { // 老系统的邮件发送逻辑 } }; class MailSenderAdapter : public Target, private LegacyMailSender { public: void send(const std::string target, const std::string title, const std::string content) override { sendMail(target, title, content); } };这段代码里private继承很关键。它表示LegacyMailSender的实现细节不对外暴露只是被适配器当作内部工具来用。类适配器的优点是适配器能直接访问被适配者的 protected 成员在某些特殊场景下能省去手动转发的样板代码。缺点也很明显。第一它把适配器和被适配者的具体类绑死了没法适配被适配者的子类。第二继承意味着强耦合被适配者内部逻辑一旦变化适配器很可能跟着一起编译失败。第三多继承会让类的职责变得模糊阅读代码的人看到继承关系时很难立刻判断出哪一种父类是真正的“接口”哪一种只是“实现工具”。所以我在实际项目里的选择标准很简单默认用对象适配器只有遇到被适配者提供了大量 protected 方法、并且需要重写部分逻辑的极端情况时才考虑类适配器。C 工程里这种场景确实存在但比例不高。3. 支付渠道统一改造适配器模式的实际落地样本讲了原理得落到一个真实场景里才有说服力。这里分享一次我做支付渠道统一改造的经历这个案例非常能体现适配器模式的价值也最能暴露使用不当的问题。3.1 背景一个接口六个渠道当时的业务系统接入了微信支付、支付宝、银联和一款海外支付SDK每个业务线都是自己直接调渠道SDK。代码里散落着各种渠道特有的参数构造逻辑、签名逻辑、回调解析逻辑新来一个同学要维护支付相关代码得同时打开几份文档对照着看。我们要做的是构建一个支付中台把上游所有业务方对支付的调用统一到一个入口。这个入口就是目标接口它必须把“付款”“退款”“查单”“验签”四个核心动作抽象出来屏蔽各渠道的差异。接口大致是这样的public interface PaymentGateway { PaymentResult pay(PayRequest request); PaymentResult refund(RefundRequest request); PaymentQueryResult query(String orderNo); boolean verifyCallback(String payload, String signature); }这个接口没有把微信的特有字段、支付宝的特有字段暴露出去因为业务方不关心这些。他们只关心订单号、金额、商品描述这些业务层面的信息。渠道差异全部由适配器在内部消化。3.2 每个渠道一个适配器各自翻译差异以微信支付的适配器为例。微信的统一下单接口要求金额以“分”为单位参数名也和我们的领域模型不一样。业务层传入的是订单号orderNo微信要求的是out_trade_no。业务层传的是元微信收的是分。这些翻译逻辑不能散落在 Service 里而应该放在适配器内部。Component(wxPay) public class WxPayAdapter implements PaymentGateway { private final WxPaySdk wxPaySdk; private final WxPayConfig wxPayConfig; public WxPayAdapter(WxPaySdk wxPaySdk, WxPayConfig wxPayConfig) { this.wxPaySdk wxPaySdk; this.wxPayConfig wxPayConfig; } Override public PaymentResult pay(PayRequest request) { MapString, String params new HashMap(); params.put(out_trade_no, request.getOrderNo()); params.put(total_fee, MoneyUtil.yuanToFen(request.getAmount())); params.put(body, request.getSubject()); params.put(notify_url, wxPayConfig.getNotifyUrl()); String sign WxPaySignUtil.sign(params, wxPayConfig.getApiKey()); params.put(sign, sign); WxPayUnifiedOrderResponse response wxPaySdk.unifiedOrder(params); return PaymentResult.of(response.getPrepayId(), response.getCodeUrl()); } // refund、query、verifyCallback 类似处理 }支付宝的适配器又是另一套逻辑。它的参数拼接方式、签名算法、回调验签方式都和微信不一样但这些差异都被封装在AlipayAdapter里。业务层拿到的是PaymentResult不需要关心prepay_id和trade_no之间的区别。各渠道的主要差异点我在适配器里专门做过一张对照表方便排查问题差异维度微信支付宝银联金额单位分元分参数格式Map XMLMap JSON表单签名算法HMAC-SHA256RSA2证书签名异步通知验签二次MD5校验公钥验签证书验签同步返回值prepay_idtrade_notn这张表写下来之后团队里没人再为“这个字段到底是分还是元”而吵架了。因为每个渠道的差异都已经被适配器收口剩下的只是渠道文档的对照阅读。3.3 渠道选择靠类型匹配不靠 if-else适配器写完之后下一步是让业务方能够按需选择渠道。我见过很多同学在这里又开始写 if-else这等于把适配器带来的好处又丢了回去。正确的做法是维护一个渠道类型到适配器的映射用一个注册表来管理。Component public class PaymentGatewayRegistry { private final MapString, PaymentGateway gatewayMap new HashMap(); public PaymentGatewayRegistry(ListPaymentGateway gateways) { for (PaymentGateway gateway : gateways) { ChannelType channelType resolveChannelType(gateway); gatewayMap.put(channelType.getCode(), gateway); } } public PaymentGateway get(String channelCode) { PaymentGateway gateway gatewayMap.get(channelCode); if (gateway null) { throw new IllegalArgumentException(Unsupported channel: channelCode); } return gateway; } }业务传一个渠道编码进来注册表返回对应的适配器。加新渠道的时候只需要加一个适配器实现再注册到容器里业务代码零改动。我在 Spring 环境下直接依赖容器自动注入ListPaymentGateway这样可以避免手动维护注册表时漏掉某个实现类。3.4 支付场景特有的三个坑支付适配器有一套独有的坑是普通 CRUD 项目里很难遇到的。第一是异常翻译。微信SDK抛的是WxPayException支付宝SDK抛的是AlipayApiException如果适配器不对异常做统一处理业务方就要针对每个渠道 catch 一遍。我在适配器里把渠道异常统一翻译成PaymentException保留渠道错误码和错误信息但类型统一。第二是回调验签。微信、支付宝、银联的验签方式完全不同且验签一旦不过渠道会认为通知发送失败反复重试。因此适配器的verifyCallback必须严格按渠道文档实现绝不能为了省事直接返回 true。我在这个坑上吃过亏回调地址被渠道连续重试了一个小时下游订单状态被打得乱七八糟。第三是幂等。同一个业务订单号可能因为网络重试被提交多次渠道会返回“订单已存在”。适配器要识别这种异常把它翻译成“幂等成功”而不是“系统异常”否则上游会误以为支付失败给用户退款或者重复创建订单。3.5 适配器的测试策略支付适配器不能等联调再发现问题。我为每个适配器都写了同一套契约测试把统一接口的每个方法都测一遍。测试基类里定义了标准业务场景比如“金额为0.01元的支付请求必须成功”“使用非法金额校验必须返回参数错误”。每个渠道适配器继承基类补上各自渠道特有的 mock 数据。这样某一渠道接入出错时测试能第一时间定位到是适配器翻译问题而不是业务逻辑问题。契约测试还有一个好处就是能倒逼目标接口设计得合理。如果某个渠道的方法在测试基类里根本没法实现说明接口设计过度依赖某一个渠道的语义。遇到这种情况我会先回头修改目标接口而不是在适配器里硬塞不支持的逻辑。4. 适配器模式在框架源码里的用法你能认出几个适配器模式最有趣的地方在于很多人天天在用却不知道那些 API 就是适配器。看框架源码时多看一层对模式的理解会完全不一样。4.1 Java IO字节流到字符流的适配器InputStreamReader是一个教科书级别的适配器。它把一个字节流InputStream适配成字符流Reader。调用方原来只能按字节读取经过InputStreamReader之后可以按字符读取还顺手处理了字符集解码。try (Reader reader new InputStreamReader( new FileInputStream(data.txt), StandardCharsets.UTF_8)) { // 按字符读取内容 }这里的FileInputStream就是被适配者Reader是目标接口InputStreamReader是适配器。它没有改变文件读取的本质只是把字节到字符的翻译逻辑封装在了一个类里调用方不需要自己处理字节拼接和字符集问题。同理OutputStreamWriter做的是反向适配。java.util.Arrays.asList也是一个典型的适配器用法。数组和List的接口完全不兼容但asList把数组“包装”成了一个List的视图让数组可以以列表的形式参与后续逻辑。虽然它没有改变数据的存储结构但它完成了接口形态的转换本质上也是适配器思想。4.2 Spring MVCHandlerAdapter 撑起整个 MVC 分发体系看 Spring MVC 源码时HandlerAdapter是一个非常重要的接口。DispatcherServlet不直接调用具体的Controller而是面向HandlerAdapter编程public interface HandlerAdapter { boolean supports(Object handler); ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception; }HandlerAdapter的作用是把不同类型的 handlerHandlerMethod、HttpRequestHandler、Controller接口等统一适配成DispatcherServlet能调用的形态。RequestMappingHandlerAdapter负责适配RequestMapping方法HttpRequestHandlerAdapter负责适配HttpRequestHandlerSimpleControllerHandlerAdapter负责适配老的Controller接口。这就是适配器模式在框架层的典型应用。框架的作者不知道使用者会写出多少种 handler 类型但他们知道要通过一个稳定接口把这个不确定的“外部世界”隔离掉。你平时写RestController时请求能顺利到达方法背后就是RequestMappingHandlerAdapter在默默做翻译。Spring AOP 里的AdvisorAdapter也是同样思路。MethodBeforeAdviceAdapter把一个前置通知适配成MethodInterceptorAfterReturningAdviceAdapter把一个返回后通知适配成拦截器。这样抽象的通知类型和拦截器执行链才能被统一管理。4.3 日志框架的适配器一套接口到处兼容日志框架是适配器模式的集中营。MyBatis 的LogAdapter对 JDK Logging、Log4j、Log4j2、Slf4j、Commons Logging 都做了适配。底层日志库可能有完全不同的接口但 MyBatis 只通过org.apache.ibatis.logging.Log这一个小小的接口去输出日志具体用哪个日志实现由适配器在运行时决定。Spring 的spring-jcl模块也有类似的适配逻辑它能把 JCL、Slf4j、Log4j2 等日志框架适配到统一的抽象上。这也是为什么很多项目里换日志框架只需要调整依赖不需要改业务代码。适配器层把框架内部的结构组织成了一个可替换的积木换一块积木其他部分完全不受影响。4.4 C STL 里的容器适配器和函数适配器C 标准库里直接有“适配器”这个词的地方是容器适配器。std::stack和std::queue并不自己维护数据而是基于某种底层容器改造出来的。它们默认用std::deque作为底层容器但只暴露出栈或队列特有的接口删除掉底层容器里那些不符合语义的操作。std::stackint st; st.push(1); st.push(2); int top st.top(); // 只能看到栈顶 st.pop();stack和deque之间就是适配器关系。deque能支持任意位置插入删除但栈的语义要求只能在一端操作。容器适配器把这个不合适的接口裁剪掉让使用方不容易犯错。函数适配器也是 C 里重要的一块。旧标准里的std::bind1st、std::bind2nd可以把一个二元函数适配成一元函数。新版标准里虽然推荐用std::bind和 lambda 表达式但背后的思想依然是“把一个可调用对象的接口变换成另一个可调用对象的接口”。C 的灵活在于它可以在类型层面做大量的适配工作这也是为什么这类模式在 C 里比在 Java 里更常见。5. 适配器、装饰器、代理、外观四个包装型模式别再混淆很多刚学设计模式的人会把适配器和另外几个结构型模式搞混因为它们都涉及“在一个类外面再包一层”。但它们的意图完全不同用错地方会导致设计越来越别扭。5.1 四个模式的本质区别我从一个非常朴素的角度来区分它们适配器是换接口装饰器是加功能代理是控访问外观是简化调用。模式核心目的接口是否改变典型场景适配器把不兼容的接口翻译成目标接口改变对接第三方SDK、兼容老系统装饰器在不改变接口的前提下动态增强不改变加缓存、加日志、加校验代理控制对真实对象的访问可能增加间接层不改变延迟加载、权限控制、远程调用外观为多个子系统提供统一的简化入口重新定义高层接口封装复杂子系统降低使用成本适配器的关键词是“不兼容”装饰器的关键词是“增强”代理的关键词是“控制”外观的关键词是“简化”。这四个词代表了四种完全不同的需求。如果你只是在两个接口之间做字段翻译那是在写适配器如果你在调用前后插入一段额外逻辑那是在写装饰器或代理如果你把十多个子系统的调用浓缩成一个门面接口那是在写外观。5.2 用一个日志组件实例讲清楚假设现在要给系统里的日志组件做一次升级。如果底层要换日志实现旧调用方已经写死了log4j的Logger接口这时候用一个适配器把新日志库的接口翻译成Logger接口调用方不用改。如果调用方接口不变但想给所有日志输出增加 JSON 格式化、敏感字段脱敏那应该用装饰器。装饰器保持Logger接口不变只是在info、error等方法内部加了一层额外的处理。它不改变调用方的任何代码。如果日志组件需要上报到远程日志系统但担心上报任务阻塞主流程也不想让业务方感知上报细节可以用代理。代理拦截日志方法把上报动作放到线程池里异步执行调用方以为自己在打本地日志实际上经过了远程转发。如果业务方要同时记录操作日志、审计日志、异常报表三个模块分别有各自的接口调用方写起来很繁琐这时候用一个外观类把三个模块统一包装成recordOperation方法调用方只需要一行代码。一套日志需求四个模式全都能用上但各自的定位完全不同。理解意图之后代码才不会被“包装”两个字给带偏。5.3 一个常见的误用例子我在 Code Review 里见过一种典型误用为了给一个远程服务加访问频率控制开发同学发现远程服务的接口参数和本地接口不一致于是把代理和接口转换混在了一起。他在代理类里同时做了字段转换和频率控制结果这个代理类既没法复用也没法替换。新增一个调用方要传不同的参数结构时代理类就变成了一个大杂烩。正确的做法是把两件事拆开。接口转换交给适配器访问控制交给代理两者可以嵌套使用但不要写进同一个类。代码结构上没有“我是适配器还是代理”的纠结只有“这个类是干什么的”清晰职责。6. 工程落地的选型标准和这些年踩过的坑设计模式不是越用越好适配器也一样。该用的时候它是救火队员不该用的时候它是把代码复杂度往上堆的元凶。这里分享一下我的选型标准。6.1 什么时候真的需要适配器外部依赖不可变是使用适配器最明确的前提。第三方SDK是对方发布的我们没有改源码的权限接口也不由我们控制。业务上又有统一接入的需求这时候适配器是合理的选择。历史接口无法大改是另一个前提。老系统可能同时服务着十几个调用方直接改接口意味着所有调用方必须同步升级风险太大。用一个适配器让老接口逐渐过渡到新接口可以做到柔性切换。渐进式替换老系统时适配器也很有价值。老系统和新系统并存上游调用方要平滑切换。先把老系统的接口适配成新系统的目标接口等所有调用方都切换完再下线老系统这是我在重构项目中用过多次的策略。不需要用适配器的情况也很明确如果接口是自己团队维护的并且调用方能同步修改那直接改接口和调用方比加一层适配器更干净。如果只是字段命名不一致用重命名就可以解决。如果调用点只有一个直接改调用点没必要为了“用模式”而写一个适配器。6.2 六个踩过的坑第一个坑是把适配器当成“万能胶”。有一回我以为两个模块接口能对得上只是语义略有差异就顺手加了适配器。结果发现适配器里除了参数映射还塞了一堆业务判断因为两个模块的“订单状态”其实含义完全不一样。适配器越写越长最后变成了一层模糊业务边界。正确做法是先搞清楚语义是否真的兼容语义不同就应该是新接口加新实现而不是用适配器硬凑。第二个坑是异常信息被吞掉。适配器负责做异常翻译时如果不小心把渠道的原始错误信息丢了排障会非常痛苦。我后来强制要求适配器里的 catch 块必须把原始异常作为 cause 传出去或者至少保留错误码和信息不允许直接return null或者打印完就完事。第三个坑是把重试、缓存这些横切逻辑写进了适配器。适配器的定位是翻译不是增强和治理。重试和缓存应该由装饰器、代理或者专门的拦截器处理。混在一起之后一个适配器既要做接口转换又要做故障恢复职责重叠后面想换一个渠道时这些横切逻辑全都变成负担。第四个坑是目标接口设计过宽。我最早设计的PaymentGateway把所有方法都塞进去了后来接入一个海外渠道时对方不支持退款适配器只能在refund方法里抛异常。接口越宽适配器就越难实现。现在的做法是把接口做小能用组合就用组合不支持的能力在适配器里返回明确的错误码而不是往下游调用方抛一个UnsupportedOperationException了事。第五个坑是没有给适配器做单元测试。适配器的逻辑一般不复杂但它处在边界位置最容易受外部接口变化影响。等联调时再发现问题排查链路非常长。我现在对凡是写了适配器的地方都要求有单元测试测试哪怕只覆盖参数翻译的若干关键分支也能在渠道升级时帮上大忙。第六个坑是命名混乱。适配器类必须有清晰的命名规范例如WxPayAdapter、AlipayAdapter。不要在类名里写WxUtil、PayHelper这类没有区分度的名字。清晰命名是最便宜的文档。6.3 适配器和其他模式的组合套路适配器不孤立使用工程里我经常把它和其他模式组合起来。工厂模式负责创建适配器策略模式负责按场景选择不同适配器模板方法模式可以把“验签、转参数、调渠道、转响应”的骨架固定下来让具体渠道只实现差异部分。外观模式则用于把一组适配器再封装成一个更高层的业务门面让上游调用方只面对两三个方法。这些组合不是为了堆砌模式而是为了应对真实项目的复杂度。支付场景里一个渠道可能有多个产品一个适配器内部还要再区分扫码支付、公众号支付、App支付这时候模板方法模式能很好地把公共流程抽出来。适配器只是其中的一环其他模式负责另外的问题。7. 最后一点个人体会用了这么多年适配器模式我最大的体会是它真正的价值不在代码量上而在“隔离”这两个字上。外部世界的接口会变第三方的SDK会升级老系统会维护如果不做隔离开这些噪声会全部冲进核心业务代码里。适配器层就像一块海绵把外部世界的不稳定统统吸走让核心代码相对干净。在选择对象适配器还是类适配器时我默认总是对象适配器。Java里写起来很自然C里也尽量如此。只有在极其特殊的场景下才考虑类适配器的多继承能力而那种场景一年到头可能遇不到一次。曾经有个同事问我适配器模式是不是太简单了不值得单独拿出来讲。我说你先去把支付渠道统一项目里的WxPayAdapter看一遍再决定这句话要不要收回。适配器模式的简单是表象它背后承载的是接口设计、异常处理、兼容性治理这些工程里最难搞明白的事。把一个“翻译”做好比把一堆业务逻辑堆在一起要难得多。
返回列表