ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

适配器模式实战:从接口不兼容到优雅兼容的Java实现

适配器模式实战:从接口不兼容到优雅兼容的Java实现 我这些年看过的设计模式文章不少但真正能在项目里用上、能讲明白“为什么这么写”的并不多。适配器模式属于结构型模式里出镜率极高的一个几乎每个稍微上点规模的项目都有它的身影。这篇文章不打算照着教科书念定义我想用一个老开发的口吻从实际遇到的问题出发把适配器模式的思想、两种代码实现方式、典型应用场景和踩坑经验一次讲透。这套内容特别适合三类人准备面试的Java开发这几乎是必问的八股之一、正在做系统重构或对接第三方服务的人、以及刚学完Java语法想搞懂设计模式怎么落地的初学者。适配器模式本身不难难的是在正确的地方用它以及分清它和代理模式、装饰器模式的边界。我会把这几块都说清楚。1. 适配器模式的核心思想与使用场景1.1 适配器模式到底在解决什么问题适配器模式解决的是接口不兼容的问题。打个比方你从国内买了一个两脚插头的电饭煲到了国外发现墙上插座是三相圆形插孔插不进去。你有两个选择把电饭煲拆了换插头改源码或者买一个转换插头适配器。转换插头不用改电饭煲也不用改墙上的插座它自己在中间做了一个转接。代码世界里的情况一模一样。你有一个已经写好的第三方SDK它的方法签名长这样String doAction(String param)。但你的业务系统里其他所有模块调用的是另一个接口void execute(Object input)。如果你直接改SDK就得动别人的源码或者重新编译风险高且不现实。如果你改自己系统里的所有调用点那就是牵一发动全身的灾难。适配器模式的做法是引入一个中间类让它同时“知道”目标接口和已有的被适配者把目标接口的调用翻译成被适配者的实际调用。调用方只跟适配器打交道它不知道背后换过多少次实现。这种“不改现有代码加一层转接”的思路就是适配器模式的核心价值。它完美契合开闭原则对扩展开放对修改关闭。你不用动老代码也能让新旧接口和平共处。1.2 三种角色和两种实现方式适配器模式涉及三个角色Target目标抽象业务系统期望的接口或抽象类。Adapter适配器把源接口转换为目标接口的实现类。Adaptee被适配者已有的、接口不兼容的类。Java里适配器模式有两种实现方式类适配器和对象适配器。类适配器通过继承来获取被适配者的行为对象适配器通过组合来持有被适配者的引用。这两种方式各有优劣下面两章我分别给完整的代码实现。1.3 什么时候应该用适配器不是所有接口不匹配都要上适配器。我总结了几条判断标准你不能修改被适配者的源码比如第三方SDK、老系统里的代码。你不想改动现有调用方的代码。你希望新增的实现可以灵活替换而不是绑死在继承树上。你需要统一多个不同接口的调用方式简化上层逻辑。第一个场景最常见。我做过一个电商项目要从老库存系统切换到新库存系统老系统的接口是getStock(String skuId)新系统是queryAvailableQuantity(String skuId, String warehouseId)。如果改所有调用方工作量巨大且容易遗漏。用适配器包一层调用方无感知切换成本就低很多。2. 类适配器模式代码实现2.1 场景设定我用一个充电器相关的例子来演示贴近生活好理解。假设现在有一个TypeC接口是手机和笔记本都用它充电它有一个chargeWithTypeC()方法。但市面上的充电器有Micro USB和Type-C两种规格。你手里只有一个Micro USB的旧充电器需要通过一个转换头给Type-C口手机充电。这个场景中TargetTypeC接口是我们期望的充电方式。AdapteeMicroUsbCharger已有的充电器。AdapterTypeCAdapter让旧充电器能充新手机。2.2 类适配器完整代码类适配器的核心是用继承关系做转接。来看代码实现。老朋友到了先定义目标接口public interface TypeC { void chargeWithTypeC(); }已有的被适配者不支持Type-C只有Micro USBpublic class MicroUsbCharger { public void chargeWithMicroUsb() { System.out.println(使用 Micro USB 接口充电中...); } }适配器继承已有的充电器同时实现Type-C接口public class TypeCAdapter extends MicroUsbCharger implements TypeC { Override public void chargeWithTypeC() { chargeWithMicroUsb(); } }客户端的使用方式public class Client { public static void main(String[] args) { TypeC phone new TypeCAdapter(); phone.chargeWithTypeC(); } }代码就这么简单。适配器继承了MicroUsbCharger因此拥有chargeWithMicroUsb()方法。它又实现了TypeC接口因此对外呈现出Type-C的能力。调用方只看到TypeC类型完全不感知底部拿Micro USB充电器在充。2.3 类适配器的优缺点类适配器的最大优点是代码量少、方法分发直接因为继承可以访问父类的protected成员在某些场景下比组合更省事。但它的缺点非常致命Java是单继承。一旦TypeCAdapter继承了MicroUsbCharger它就无法再继承任何其他类。如果被适配者是一个类而不是接口你的继承机会就用掉了。而且类适配器直接暴露了父类的方法破坏了封装性调用方理论上可以把适配器强转回MicroUsbCharger类型绕过Type-C接口直接调用chargeWithMicroUsb()这就破坏了适配器的隔离效果。我的建议是除非被适配者只有单一行为且你不会再扩展否则别用类适配器。实际项目中对象适配器出现频率高得多。3. 对象适配器模式代码实现3.1 对象适配器完整代码对象适配器放弃了继承改用组合。适配器持有被适配者的引用在实现目标接口方法时把调用委托给它。还是充电器的例子public class TypeCObjectAdapter implements TypeC { private MicroUsbCharger microUsbCharger; public TypeCObjectAdapter(MicroUsbCharger microUsbCharger) { this.microUsbCharger microUsbCharger; } Override public void chargeWithTypeC() { microUsbCharger.chargeWithMicroUsb(); } }客户端使用public class Client { public static void main(String[] args) { MicroUsbCharger charger new MicroUsbCharger(); TypeC phone new TypeCObjectAdapter(charger); phone.chargeWithTypeC(); } }注意到区别没有对象适配器不继承MicroUsbCharger而是通过构造函数拿到实例在chargeWithTypeC()内部调用它的方法。类适配器是“我是那个充电器”对象适配器是“我有一个充电器”。3.2 对比类适配器和对象适配器对比维度类适配器对象适配器实现方式继承实现接口组合实现接口Java单继承限制受影响无法再继承其他类不受影响可自由扩展耦合度强耦合继承关系弱耦合依赖注入灵活性低无法运行时切换被适配者高构造函数可传入不同实现代码量更少略多封装性差父类行为对外可见好只暴露目标接口树上的理论不说了直接讲实践。对象适配器最让我喜欢的一点是它支持依赖注入。这意味着你可以在运行时决定适配器内部接哪个被适配者甚至可以在测试时传入Mock对象方便做单元测试。类适配器做不到这一点它的父类在编译期就固定了。另外对象适配器虽然外面包了一层但它的内存开销和调用链几乎可以忽略不计。JIT编译器在热点路径上会做内联优化多一层委托的性能损耗非常低完全不用为性能担心。3.3 适配器和代理模式、装饰器模式的辨析这是一个高频面试题也是个重要的设计判断。适配器模式的目的是把A接口转换成B接口转换之后A接口的原始方法可能就隐藏了。它解决的是接口不兼容问题。代理模式不改变接口它在现有接口上增加控制逻辑。比如安全校验、延迟加载、远程调用代理。它解决的是访问控制问题。装饰器模式也不改变接口它增加的是行为而且是层层叠加的像给咖啡加糖加奶。它解决的是增强功能的问题。简单记忆口诀适配器改接口形状代理管访问装饰器加功能。这三种模式代码长得都有点像都是包一层但意图天差地别。面试时能把这个边界讲清楚比背十遍定义都管用。4. 适配器模式在实际项目中的应用4.1 统一封装第三方支付SDK支付系统是适配器模式的经典应用场景。每个支付渠道提供的SDK接口完全不一样微信支付一个签名方法一个回调结构支付宝又是另一套。如果业务代码直接跟每个渠道耦合一旦渠道接口变了或者要新增渠道就是灾难。我做过一个交易系统将不同支付渠道统一封装。先用一个目标接口定义统一的支付行为public interface UnifiedPayService { String pay(PayRequest request); String queryOrder(String orderId); void handleCallback(CallbackRequest request); }然后针对每个渠道写适配器public class WechatPayAdapter implements UnifiedPayService { private WechatPaySDK wechatSDK; // 构造函数注入... Override public String pay(PayRequest request) { // 将统一请求参数转为微信SDK需要的格式 WxPayRequest wxRequest convert(request); return wechatSDK.doPayment(wxRequest); } // 其他方法... }public class AlipayAdapter implements UnifiedPayService { private AlipaySDK alipaySDK; // 构造函数注入... Override public String pay(PayRequest request) { // 将统一请求参数转为支付宝SDK需要的格式 AliPayRequest aliRequest convert(request); return alipaySDK.doPay(aliRequest); } // 其他方法... }业务代码只依赖UnifiedPayService通过工厂或者Spring容器根据参数获取具体实现切换渠道、新增渠道都不影响上层业务逻辑。这套设计本质上是典型的对象适配器模式。4.2 SLF4J日志门面背后的适配器思想Java生态里最典型的适配器案例其实是SLF4JSimple Logging Facade for Java。你写代码时调用的是LoggerFactory.getLogger()但底层的日志实现可能是logback、log4j2、java.util.logging它们的方法签名完全不同。SLF4J的Logger接口就是TargetLogbackLogbackLoggerAdapter、Log4j2Adapter这些类就是适配器各自的日志框架是被适配者。这也是为什么你只要引入对应的适配器依赖不需要改一行业务代码就能切换日志实现。下次有人说适配器模式没什么实际用你可以让他看看SLF4J的源码全网几乎每个Java服务都在用这种模式。4.3 老系统接口兼容改造还有一个高频场景是老系统接口兼容。公司内部经常有老系统要升级但不能一次性把旧的调用方全部迁移。一个稳妥的做法是保留旧的接口签名内部用适配器委托到新实现。这样老调用方无感知新调用方直接使用新接口系统可以平滑过渡甚至做到新旧共存。我在一个供应链项目里用过这种方案老的OrderService.getOrder(String orderId)返回XML字符串新的OrderQueryService返回JSON对象。我写了一个LegacyOrderServiceAdapter实现老接口内部调用新的查询服务返回前把JSON转成老格式的XML。结果十几个老调用方一行没改系统就完成了底层数据源切换。这就是适配器模式在“让代码继续工作”这件事上的价值。5. 常见问题与排查技巧实录5.1 类适配器伪多继承的坑我在评审代码时经常看到有人用类适配器实现“伪装多继承”让一个适配器类同时继承多个被适配者。这在Java里根本做不到因为单继承。有些人绕道用内部类或者接口默认方法搞得继承层级很深一不留神就成了面试官口中“过度设计”的反面案例。还有一个隐蔽的坑类适配器如果父类构造函数有必传参数子类的构造函数就必须显示调用super(参数)。有时候被适配者是第三方SDK构造函数签名复杂类适配器写起来就很别扭。而对象适配器完全不存在这个问题你只需要把创建好的被适配者实例传进来跟构造函数签名无关。5.2 适配器不要夹带业务逻辑这是最让我头疼的一个问题。很多同事写着写着一个适配器就把参数转换、状态判断、甚至数据持久化的逻辑全塞进来了。适配器只应该做“把调用翻译过去”这一件事不该有复杂业务判断。适配器一旦膨胀你就没法测试也没法复用它去适配第二个接口。正确的做法是适配器内部只做参数映射和调用委托其他任何事情都拆分到独立的类里。比如支付渠道的适配器只做PayRequest到WxPayRequest的转换和SDK调用订单号的生成规则、金额校验、风控判断统统放业务服务层。5.3 适配器模式的面试八股快问快答我之前整理过几个面试官常问的适配器相关问题这里把参考答案也给你。问适配器模式和策略模式有什么区别答适配器解决的是接口不兼容策略解决的是算法可替换。适配器必须有一个明确的被适配者它的存在是为了适配那个对象。策略模式没有“被适配者”这个概念它的实现类是平级的各自封装一类算法运行时可以互相替换。如果一个类名里带Adapter但它的构造函数可以接收任意类型的对象那你很可能把它写成了策略模式。问适配器和门面模式Facade有什么区别答适配器是接口级别的转换它面向的是两个已有接口之间的不匹配问题。门面模式是子系统级别的整合它封装一组复杂的子系统交互给外部提供一个更简单的高层接口。适配器通常只适配一个被适配者门面往往组合多个子系统的交互。问什么时候用类适配器什么时候用对象适配器答类适配器适用于被适配者行为简单、且不需要继承其他类的场景。对象适配器是更通用的做法因为组合优于继承它不占用继承机会、支持依赖注入、封装性更好实际项目里我建议优先使用对象适配器。5.4 实操中的调试验证技巧适配器的代码虽然简单调试时有几个小技巧能帮你快速定位问题。我在适配器里每次委托调用前会打印一行明确的日志标注“Adapter转发”这类关键词。这样排查链路问题时一眼就能看出调用是否经过适配器。另一个技巧是对适配器做单元测试时替身对象用起来非常方便。因为对象适配器接收的是构造参数你完全可以在测试里传入一个Mock对象验证方法是否被正确调用。Test public void testChargeWithTypeC() { MicroUsbCharger mockCharger Mockito.mock(MicroUsbCharger.class); TypeC adapter new TypeCObjectAdapter(mockCharger); adapter.chargeWithTypeC(); Mockito.verify(mockCharger, Mockito.times(1)).chargeWithMicroUsb(); }这段测试验证了适配器确实把chargeWithTypeC()的调用转给了被适配者的chargeWithMicroUsb()。如果哪天重构改坏了适配逻辑这种测试会第一时间拉响警报。适配器模式学习到这里基本就够用了。最后再从经验角度分享一点体会设计模式最忌讳生搬硬套。适配器的本质是解决“新旧接口共存”的问题如果你没有接口不匹配的问题就不要为了用模式而硬造出两个接口来。在真实项目里我见过太多“先定义一个接口再写一个适配器最后发现这个适配器从来只有一种实现”的代码纯属给自己增加维护负担。判断用不用适配器的标准很简单代码面前是否存在改不动、又想接进来、又不想大范围的旧系统或第三方依赖如果有就用它没有那你的代码已经够干净了不需要为了用而用。
RELATED READING

延伸阅读

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