ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

适配器模式详解:从接口不兼容到源码实践

适配器模式详解:从接口不兼容到源码实践 适配器模式是那种“看名字觉得很简单但真正用起来才发现细节挺多”的设计模式。很多初学者第一次接触它往往是在学 IO 流或者集合框架的时候看到InputStreamReader、Arrays.asList这类类隐约感觉“这玩意儿好像就是把一个东西包装成另一个东西”但要说清楚它到底解决了什么问题、为什么非要这样设计又讲不出个所以然。这篇文章我想换个讲法不直接甩 UML 图和代码而是从“为什么需要适配器”这个最朴素的问题入手把一个真实场景从头到尾走一遍老代码动不了、新接口又必须对接这时候适配器模式是怎么救场的。类适配器、对象适配器、接口适配器三种形态都会讲到每种都配上可运行的 Java 代码最后再聊聊 JDK 源码里的经典应用以及面试里关于这个模式的高频考点。如果你正在准备 Java 面试或者写项目时经常遇到“接口不兼容”的尴尬局面这篇内容应该能帮你把适配器模式从“听过”变成“真会用”。1. 适配器模式从生活类比到软件设计1.1 一个插座引发的思考适配器这个词本身就来自生活。你去外地出差带的笔记本是三孔插头酒店墙上只有两孔插座怎么办要么找前台借一个转换插头要么直接去买一个。这个转换插头做的事情就是把“三孔插头”转换成“两孔插座能接受的形式”让原本不匹配的双方能够协作。软件设计里的适配器模式思路一模一样。它解决的核心问题就是两个原本不兼容的接口如何在不修改各自代码的前提下协同工作。举一个开发中特别常见的例子。你的项目里早就写好了OldLogger输出日志用的是 log4j 1.x 的 APIpublic class OldLogger { public void log(String level, String message) { System.out.println([ level ] message); } }现在公司要求统一日志规范引入了一个新的日志框架新框架的接口长这样public interface NewLogger { void debug(String message); void info(String message); void error(String message); }问题来了你有一百多处业务代码都在调OldLogger.log()不可能全改新框架的接口是标准也不可能为你的老代码改设计。这时候适配器模式就派上用场了——写一个适配器让它实现NewLogger接口内部委托给OldLogger去执行真正的日志输出。业务代码面对的是新接口底层跑的却是老实现两边都不用改。这个例子虽然简单但它把适配器模式的核心价值展示得非常清楚不改变已有代码通过一个中间层来弥合接口差异。1.2 适配器模式的三个角色要理解适配器模式的代码结构先记住三个角色目标接口Target客户端期望使用的接口。在例子里就是NewLogger。被适配者Adaptee已经存在的、接口不兼容的类。在例子里就是OldLogger。适配器Adapter核心角色把被适配者的接口转换成目标接口。它实现了目标接口内部持有被适配者的引用调用时转调被适配者的方法。三个角色对应到代码就是一张非常清晰的依赖关系。客户端只依赖目标接口完全感知不到被适配者的存在这正是依赖倒置原则的体现。1.3 什么时候该用适配器模式适配器模式不是银弹用错场合反而会增加代码复杂度。根据我的实践经验下面这几种情况比较适合引入适配器集成第三方SDK。比如支付功能你封装了一个统一的PaymentService接口然后支付宝、微信、银联各自写一个适配器业务代码永远只面对PaymentService。老系统改造。系统要升级但老模块短期内无法重写通过适配器让新旧代码先共存再逐步替换。这是很多大项目演进时的常规操作。统一多种实现。比如你对接了多个短信服务商阿里云、腾讯云、容联云每个厂商的 SDK 接口都不一样用一个适配器把差异挡住上层代码只需要一个sendSms()方法。反过来如果接口本身就是你自己设计的而且可以随意修改那就没必要用适配器直接改接口才是更优解。适配器是“为既成事实打补丁”的手段不是面向未来的设计工具。2. 设计思想剖析适配器模式的本质与选型2.1 封装与隔离适配器模式的两大支柱适配器模式看起来简单但它的设计思想值得细品。在我看来它同时体现了两个重要的软件设计原则。一个是封装变化。适配器把“接口不兼容”这个变化点单独抽出来封装在一个类里。将来第三方 SDK 升级了、接口变了你只需要改适配器内部代码上层调用方毫发无损。这种隔离能力在对接外部系统时价值极大。另一个是面向接口编程。适配器让客户端永远只依赖抽象接口而不是具体类。这样做的好处是客户端代码不会被某个具体实现绑死你要替换底层实现只需要换一个适配器实例客户端代码一行都不用动。用一个我在实际项目中遇到的例子来说明。某个系统需要对接多家地图服务商最初只接了高德后来要加百度。如果业务代码直接调用高德 SDK那加百度的时候就得改所有业务代码。后来我引入了适配器业务层只依赖一个GeocodingService接口高德适配器和百度适配器各自封装 SDK 差异。结果加百度地图的时候只花了半个多小时写了个适配器类注册一下就完事了。2.2 类适配器与对象适配器两种实现路线适配器模式有两种经典实现方式类适配器和对象适配器。对象适配器用的是组合适配器持有被适配者的引用通过委托调用其方法。这是最常用、也是我推荐优先使用的方式。public class NewLoggerAdapter implements NewLogger { private final OldLogger oldLogger; public NewLoggerAdapter(OldLogger oldLogger) { this.oldLogger oldLogger; } Override public void debug(String message) { oldLogger.log(DEBUG, message); } Override public void info(String message) { oldLogger.log(INFO, message); } Override public void error(String message) { oldLogger.log(ERROR, message); } }类适配器用的是继承适配器同时继承被适配者和实现目标接口。由于 Java 是单继承类适配器意味着只能适配一个被适配者灵活性差不少。public class NewLoggerClassAdapter extends OldLogger implements NewLogger { Override public void debug(String message) { log(DEBUG, message); } Override public void info(String message) { log(INFO, message); } Override public void error(String message) { log(ERROR, message); } }为什么我更推荐对象适配器三个理由。第一组合优于继承这是面向对象设计的老生常谈组合不会破坏封装性继承反而会暴露父类实现细节。第二对象适配器可以适配被适配者的子类类适配器则把被适配者锁死了。第三对象适配器依赖注入非常灵活你可以在运行时动态选择被适配者而类适配器在编译期就确定了继承关系。2.3 适配器、装饰器、代理三个容易混淆的模式面试中经常有人把适配器模式和装饰器模式、代理模式搞混因为它们在代码结构上都是“持有一个对象然后调用它的方法”。但它们的意图完全不同这里我放到一起对比一下。适配器模式的关注点是接口转换。它把 A 接口转换成 B 接口让原本不兼容的代码能协作。核心词是“转接”。装饰器模式的关注点是增强功能。它保持原接口不变在调用原方法前后增加新的行为。核心词是“增强”。代理模式的关注点是控制访问。它控制客户端对真实对象的访问常见的应用场景有延迟加载、访问控制、日志记录。核心词是“控制”。用一句话总结适配器改接口不改功能装饰器改功能不改接口代理控制访问但功能和接口都不改。这三句话记熟了面试被问到就能快速区分。3. 代码实现手写一个完整的适配器示例3.1 场景复原老系统充电接口对接新设备前面讲了一堆理论现在我们来一个完整、可运行的实战案例。这个案例我尽量做得贴近真实业务而不是那种“为了讲模式而编造”的玩具代码。假设你维护着一个老旧的充电桩管理系统这个系统里有一个OldCharger类提供传统的充电接口public class OldCharger { public void chargeWithMicroUSB() { System.out.println(使用 Micro-USB 接口充电速度 5W); } }现在公司采购了一批新款充电桩它们只支持 Type-C 接口而且还有快充协议。新充电桩的接口定义如下public interface TypeCCharger { void chargeWithTypeC(); void fastCharge(); }问题很现实业务系统里有大量代码依赖OldCharger直接改不现实但公司又要求新充电桩必须能接入系统。这时候适配器就是唯一的答案。3.2 对象适配器最推荐的第一种写法先写一个适配器类让它实现TypeCCharger接口内部持有OldCharger的引用public class ChargerAdapter implements TypeCCharger { private final OldCharger oldCharger; public ChargerAdapter(OldCharger oldCharger) { this.oldCharger oldCharger; } Override public void chargeWithTypeC() { System.out.println(适配器转换Type-C 接口 - Micro-USB 接口); oldCharger.chargeWithMicroUSB(); } Override public void fastCharge() { System.out.println(适配器转换启用快充协商协议); System.out.println(适配器转换Type-C 接口 - Micro-USB 接口); oldCharger.chargeWithMicroUSB(); System.out.println(快充协商完成实际充电速度 18W); } }客户端的调用方式变成了这样public class Client { public static void main(String[] args) { // 老系统里已有的充电桩对象 OldCharger oldCharger new OldCharger(); // 用适配器包装老充电桩暴露新接口 TypeCCharger typeCCharger new ChargerAdapter(oldCharger); // 业务代码只需要面向 TypeCCharger 编程 typeCCharger.chargeWithTypeC(); typeCCharger.fastCharge(); } }运行一下输出适配器转换Type-C 接口 - Micro-USB 接口 使用 Micro-USB 接口充电速度 5W 适配器转换启用快充协商协议 适配器转换Type-C 接口 - Micro-USB 接口 使用 Micro-USB 接口充电速度 5W 快充协商完成实际充电速度 18W代码逻辑很直白适配器把新接口的调用转成对老对象的调用同时可以在转换过程中做“翻译”工作。比如fastCharge()就不仅仅是转发还模拟了快充协商的流程。这里有一个细节值得注意适配器并不修改OldCharger的任何代码它只是在中间做了一层“翻译”。这就是适配器模式“不改变既有代码”的精髓——真正符合开闭原则。3.3 类适配器单继承限制下的备选方案类适配器用继承代替组合。看代码public class ClassChargerAdapter extends OldCharger implements TypeCCharger { Override public void chargeWithTypeC() { System.out.println(类适配器转换Type-C 接口 - Micro-USB 接口); super.chargeWithMicroUSB(); } Override public void fastCharge() { System.out.println(类适配器转换启用快充协商协议); super.chargeWithMicroUSB(); System.out.println(快充协商完成实际充电速度 18W); } }因为 Java 单继承的限制ClassChargerAdapter只能继承OldCharger一个类无法再继承别的父类。这在某些场景下会带来麻烦。比如如果OldCharger是一个abstract类而且它内部依赖了很多需要注入的服务使用类适配器就非常别扭因为继承让你被迫面对父类的所有细节。相比之下对象适配器只依赖一个接口或者一个具体类的公开方法耦合度低得多。我的建议是除非你明确知道OldCharger是一个简单的、没有复杂继承关系的类否则一律优先使用对象适配器。类适配器更适合用来理解原理而不是进入生产代码。3.4 接口适配器当一个接口有太多用不到的方法除了类适配器和对象适配器还有一种不那么出名但在实际开发中很常用的形态接口适配器也叫缺省适配器模式。它的适用场景是这样的某个接口定义了十几个方法但你只需要用到其中一个或两个。如果直接实现这个接口你被迫写一堆空方法代码整洁度很差。public interface MediaPlayer { void playAudio(); void playVideo(); void pause(); void stop(); void seekTo(int position); void setVolume(int volume); }现在老板让你做一个“只负责放音频的播放器”按普通做法你得实现所有方法public class AudioPlayer implements MediaPlayer { Override public void playAudio() { System.out.println(播放音频); } Override public void playVideo() { // 不需要留空 } Override public void pause() { // 不需要 } Override public void stop() { // 不需要 } Override public void seekTo(int position) { // 不需要 } Override public void setVolume(int volume) { // 不需要 } }接口适配器的思路是先写一个抽象类空实现接口的所有方法然后让具体类继承这个抽象类只覆盖自己需要的方法public abstract class MediaPlayerAdapter implements MediaPlayer { Override public void playAudio() {} Override public void playVideo() {} Override public void pause() {} Override public void stop() {} Override public void seekTo(int position) {} Override public void setVolume(int volume) {} }public class AudioPlayer extends MediaPlayerAdapter { Override public void playAudio() { System.out.println(播放音频); } }这样写AudioPlayer类非常干净只重写了它关心的playAudio()方法。Java 8 的接口默认方法在很大程度上解决了这个问题但在老代码或者一些特殊场景下接口适配器依然有用武之地。3.5 源码中的适配器JDK 和 Spring 的经典应用适配器模式在 Java 源码和主流框架中无处不在读懂这些源码案例才能真正理解这个模式的价值。案例一java.io.InputStreamReaderInputStreamReader是一个从字节流到字符流的适配器。它把InputStream字节流适配成了Reader字符流。你传入一个字节流它负责按照指定字符集解码成字符流。这就是对象适配器的典型用法。// 字节流适配成字符流 FileInputStream fis new FileInputStream(data.txt); InputStreamReader isr new InputStreamReader(fis, StandardCharsets.UTF_8); BufferedReader br new BufferedReader(isr);类似地OutputStreamWriter是OutputStream到Writer的适配器。案例二java.util.Arrays.asList()Arrays.asList()把数组适配成List接口。这也是适配器思想在 JDK 中的体现——数组没有实现List接口但通过一个适配器视图数组就能以List的形式被访问。不过这是一个“有限适配”因为底层还是数组所以不能调用add()和remove()方法。这告诉我们适配器不是万能的适配后的能力受限于被适配者本身的特性。案例三Spring MVC 的HandlerAdapterSpring MVC 中DispatcherServlet依赖HandlerAdapter接口来调用不同类型的处理器Handler。RequestMappingHandlerAdapter负责RequestMapping方法HttpRequestHandlerAdapter负责HttpRequestHandler。这样DispatcherServlet不需要关心处理器具体长什么样只需要面向HandlerAdapter接口编程。这就是适配器模式在框架设计中的大型应用。案例四java.util.Collections.enumeration()这个方法把Iterator适配成Enumeration让老代码可以继续使用Enumeration接口遍历集合。这些源码案例反复验证了一个观点适配器模式最重要的价值是让新旧代码能够平滑共存而不是推倒重来。4. 实操经验与踩坑实录4.1 适配器模式最常见的三个误区写了这么多年代码看过不少人在用适配器模式时掉进坑里我整理了几个高频问题。误区一把适配器和业务逻辑混在一起。适配器只负责接口转换不应该包含复杂的业务逻辑。如果你在适配器里写了一大堆 if-else 判断、数据加工、规则校验那这个适配器就变味了。适配器要保持“薄”要做的事情只有一件转发调用最多做简单的参数格式转换。误区二过度使用适配器。有些同学学了适配器模式之后见到什么都要套一下哪怕接口本来就是自己设计的也要硬写一个适配器。这完全没有必要。适配器是为“不可修改的既成事实”设计的比如第三方库、老系统。自己能改的接口直接改接口就好。误区三忽略了适配器带来的性能损耗。适配器多了一层间接调用虽然通常性能影响微乎其微但如果被适配的方法在循环里被高频调用这层委托也可能会成为热点。当然大多数业务场景不需要关心这种级别的问题但做高性能中间件的时候还是要注意。4.2 设计模式对比适配器 vs 策略 vs 门面选型问题是面试和实际设计中的高频痛点。适配器模式经常和策略模式、门面模式放在一起比较。适配器模式解决的是“接口不兼容问题”它的目标是让原本无法协作的类协作起来。重点是转换。策略模式解决的是“算法族切换问题”它把每种算法封装成一个策略类运行时可以灵活替换。重点是多态。门面模式解决的是“复杂子系统简化问题”它提供一个统一的高层接口隐藏子系统的复杂性。重点是简化。举个例子你做文件上传支持本地存储、阿里云 OSS、腾讯云 COS。如果你是为了让三个存储服务的接口统一用适配器如果你是为了在不同存储策略之间灵活切换用策略模式如果你是为了让调用方不需要关心存储的细节用一个高层门面接口那就是门面模式。三者关注点不同可以组合使用但别混为一谈。4.3 实际项目重构用适配器模式消灭 if-else我在项目里做过一次印象很深的适配器重构。当时有一个消息推送中心对接了短信、邮件、站内信、企业微信四个渠道代码写成了这样public void push(String channel, String message) { if (sms.equals(channel)) { // 调用短信SDK } else if (email.equals(channel)) { // 调用邮件SDK } else if (web.equals(channel)) { // 调用站内信服务 } else if (wecom.equals(channel)) { // 调用企业微信API } }新渠道越来越多这个类越来越臃肿每次改动都要动核心类风险很大。后来我引入适配器模式做了一次重构。先定义一个统一的消息渠道接口public interface MessageChannel { void send(String message); boolean supports(String channelType); }然后为每个渠道写一个适配器public class SmsChannel implements MessageChannel { private final SmsSdk smsSdk; public SmsChannel(SmsSdk smsSdk) { this.smsSdk smsSdk; } Override public void send(String message) { smsSdk.sendSms(message); } Override public boolean supports(String channelType) { return sms.equals(channelType); } }推送服务的核心逻辑变成了public class PushService { private final ListMessageChannel channels; public PushService(ListMessageChannel channels) { this.channels channels; } public void push(String channelType, String message) { channels.stream() .filter(channel - channel.supports(channelType)) .findFirst() .ifPresent(channel - channel.send(message)); } }重构之后新增渠道只需要增加一个实现类推送核心代码一行都不用改完全符合开闭原则。这个例子说明适配器模式不仅能解决接口不兼容还能作为一种“插件化”的设计思路来使用。4.4 面试考点这些适配器问题你一定要会答面试中关于适配器模式的考察主要集中在以下几个方面。第一解释适配器模式及其应用场景。要能从生活类比讲到软件案例再讲到 JDK 源码中的实际应用。第二类适配器和对象适配器的区别。重点说清楚组合优先于继承的原因以及 Java 单继承导致类适配器灵活性受限的问题。第三适配器模式和代理模式、装饰器模式的区别。我上面给过一句总结适配器改接口不改功能装饰器改功能不改接口代理控制访问。第四手写一个简单的适配器场景。这可能包括给一个老接口写适配器或者指出给定代码中使用了哪种设计模式。建议平时就练熟一个完整案例比如日志适配器或者充电器适配器。第五结合实际项目谈适配器的价值。单纯背概念是不够的面试官更想听到你说“我在对接支付宝支付的时候用适配器模式封装了不同支付渠道的差异”这种真实经验远比背诵定义有说服力。5. 写在最后适配器模式的一点点个人体会适配器模式是我在项目中最常用的模式之一。它不像单例、工厂那样名气大但它的实用性极强尤其在系统集成、老代码维护这类场景中几乎是必需品。我个人的体会是使用适配器模式时要注意把握一个度它适合解决“既成事实”的接口不匹配问题但不应该成为你设计新系统的首选思路。好的设计应该从源头让接口统一而不是等到不兼容了再到处写适配器补救。另外想提醒一句适配器模式的代码虽然简单但它对架构思维的训练很有价值。你能不能在面对一个看似无解的不兼容问题时想到通过中间层来解耦这是一种非常核心的软件设计能力。多琢磨几个适配器的源码案例再在自己的项目里找机会实践一次才能真正把它变成自己的东西。
返回列表