资讯动态

适配器模式实战:从接口转换到多通道消息推送的工程实践

发布时间:2026/9/14 21:08:35 来源:尧图企业网站定制
接手过不下十个做过半截又跑路的老项目之后我越来越觉得“设计模式”这回事被教材讲歪了。一提适配器模式大部分同学第一反应是“把A接口转换成B接口”然后背个类图就完事。但实际开发里适配器模式远不止“接口转换”四个字——它是系统从混乱走向有序过程中最便宜、最稳妥的一块垫脚石。这篇文章我不打算复述教科书概念而是用一次真实的代码评审场景切入把对象适配器、类适配器的取舍JDK和Spring里的经典适配案例以及我自己踩过的几个深度坑一次性讲透。1. 一次代码评审让所有人吵起来的适配器场景1.1 当时的需求和堆出来的if-else场景是这样的公司要做统一消息推送平台需要同时接入阿里云短信、腾讯推送、极光推送后期可能再加个自研IM通道。原始代码长这样public void pushMessage(String channel, String phone, String content) { if (ali.equals(channel)) { AliSmsSDK sdk new AliSmsSDK(); sdk.init(); sdk.sendSms(phone, content); } else if (tencent.equals(channel)) { TencentPushSDK sdk new TencentPushSDK(); sdk.config(appId, appKey); sdk.pushMessage(phone, content); } else if (jpush.equals(channel)) { JPushClient client new JPushClient(masterSecret); client.sendPush(phone, content); } // 以后再加一个渠道就再堆一个else if }这段代码出现在线上服务里伴随的问题是每次新增一个推送通道核心业务类就要跟着改一次、重新测试一次测试同学要回归所有相关case。还有一个隐藏问题——每个SDK的初始化方式不同有的要init()有的要config()有的直接在构造器里塞appKey这些差异全被暴露在业务代码层换任何一个SDK版本升级都可能让服务启动时立刻爆炸。这几乎是所有系统演进过程中的共同痛点**底层组件不同、接口风格不同但上层业务根本不在乎短信是阿里发的还是极光发的。**业务真正关心的事情只有一件给我把这条消息发出去并告诉我发没发成功。1.2 适配器模式解决的是“接口不匹配”的矛盾教科书会告诉你适配器模式解决的是“接口不匹配”的矛盾。这句话对但它没有说清楚“不匹配”具体长什么样。从我多年的维护经验看接口不匹配至少分三层第一层是方法名不匹配——这边叫sendSms那边叫pushMessage业务层为了调用被迫记住每一个SDK的方法名。第二层是参数结构不匹配——这个SDK接受phone content两个独立参数那个SDK要求传一个封装好的Message对象业务层要为每个SDK做一次参数组装。第三层是调用方式不匹配——初始化方式、线程安全性、返回值粒度都不一样有的返回boolean有的返回traceId有的直接抛异常。适配器模式做的事情说白了就是在这些“不匹配”中间加一层转换器把A的语言翻译成B的语言把C的调用方式包装成D的调用方式。就像你从国内带了个两脚插头去国外用得着拆开墙上的插座改电路吗不用屋里插一个转换头就行。这个类比其实很贴切我们不会去改第三方SDK的源码也不应该让业务代码去适配第三方SDK的脾气改动应该被集中在一个独立的、可替换的适配层里。1.3 加入适配层之后代码长什么样同样是上面的推送场景引入适配器后业务层的核心代码变成了这样MessageSender sender MessageSenderFactory.getSender(ali); PushResult result sender.send(new PushRequest(phone, content));一个统一的接口一个工厂获取实例一行调用。后面接10个渠道这行代码都不需要再改。所有渠道差异、SDK细节全部被关进了各自对应的适配器里。这就是适配器模式真正的价值它不只帮你解决“今天这个接口调不通”的问题更重要的它帮你把变和不变做了切割把核心业务从底层变化里解放出来。这不是什么高深架构思想就是一次很务实的“谁的东西谁来管”。2. 对象适配器与类适配器我为什么主力推前者关于适配器模式面试最爱问的就是类适配器和对象适配器有什么区别。但大多数资料只给了类图没讲清楚在实际工程里该怎么选以及选了之后代码风格会产生什么差异。2.1 对象适配器组合大于继承的典型对象适配器是工程上绝对主力它的核心是持有目标对象的实例public class AliSmsAdapter implements MessageSender { private AliSmsSDK sdk; public AliSmsAdapter(AliSmsSDK sdk) { this.sdk sdk; } Override public PushResult send(PushRequest request) { boolean ok sdk.sendSms(request.getPhone(), request.getContent()); return new PushResult(ok, ali); } }这种写法是标准的“组合优于继承”实践。适配器自己没有业务状态它的唯一职责是“转译”它依赖一个已经存在的SDK实例把这个SDK的原始能力翻译成目标接口能理解的语言。对象适配器好处非常明显不破坏原有类结构不会为了适配去继承一个本不该被继承的类可以适配多个不同来源的对象如果阿里SDK有两种不同版本、不同初始化方式的实例适配器可以复用同一个转换逻辑实例由外部注入单元测试容易写你只需要mock一个SDK实例就能单独测适配器的转换逻辑不需要为测试去搭一个完整的SDK环境2.2 类适配器靠继承实现的多线程目的地类适配器在Java里是少数派因为Java的单继承限制你只能继承一个类同时去实现一个接口。下面这个案例所有Java程序员一定见过public class TwoPinSocketAdapter extends ChineseTwoPinSocket implements StandardSocket { Override public void connect(SocketRequest request) { super.power(request.getV()); } }但类适配器真正的“用武之地”其实在C。C支持多继承所以类适配器可以同时继承Adaptee和Target接口内部转发不需要维护任何对象引用class TwoPinAdapter : public ChineseSocket, public StandardSocket { public: void connect(int standardVoltage) override { ChineseSocket::power(standardVoltage); } };代码看上去很简洁但简洁背后有代价。C多继承本来就容易引入菱形继承、二义性等问题所以现代C工程反而更倾向用组合。Java里类适配器最大的限制就是“Adaptee不能被继承”很多SDK设计成了final类或者需要复杂初始化流程根本没法走继承这条路。2.3 我的选型判断逻辑给你一个我比较常用的选型表判断维度对象适配器类适配器耦合方式依赖注入组合继承 实现接口是否需要知道Adaptee内部实现只需要公开方法可能需要重写方法要更了解内部灵活度高一个适配器可适配任意实例低绑定具体父类测试友好度高可mock依赖较低父类方法难以mock适用场景绝大多数业务场景需要同时覆盖父类多个方法的情形一句话总结**默认选对象适配器除非你非常清楚自己在干什么才考虑类适配器。**在Java这种单继承语言里把类适配器写出来本身就不符合主流代码风格反而给后续维护者添麻烦。3. 从零写一个多通道消息适配器Java版不拿纸面示例糊弄人我直接给出一个我实际项目中能用、能跑的完整样例。这个样例围绕“消息推送平台”展开但你能直接改成任何多供应商对接场景。3.1 第一步定义目标接口目标接口就是“业务希望的样子”这个接口要有语义、要稳定、要让调用方不需要知道任何渠道细节public interface MessageSender { PushResult send(PushRequest request); } public class PushRequest { private String phone; private String content; private String templateId; // 选填有的渠道需要 // 省略构造器和getter/setter } public class PushResult { private boolean success; private String channel; private String traceId; private String errorMsg; // 省略构造器和getter/setter }目标接口的设计原则是“最小可用”不要一开始就把以后可能的场景全部塞进去。比如不要在设计的时候因为想着未来所有渠道都能支持模板ID就把模板ID做成必填结果适配阿里云的时候发现极光根本不支持——这个字段就变成适配器永远要处理的一个空值。3.2 第二步编写第三方SDK适配器每个第三方SDK写一个适配器类它们实现同一个MessageSender接口public class AliSmsAdapter implements MessageSender { private final AliSmsSDK sdk; public AliSmsAdapter(AliSmsSDK sdk) { this.sdk sdk; } Override public PushResult send(PushRequest request) { boolean ok sdk.sendSms(request.getPhone(), request.getContent()); return new PushResult(ok, ali, null, ok ? null : unknown); } } public class TencentPushAdapter implements MessageSender { private final TencentPushSDK sdk; public TencentPushAdapter(TencentPushSDK sdk) { this.sdk sdk; } Override public PushResult send(PushRequest request) { TencentMessage msg new TencentMessage(request.getPhone(), request.getContent()); String traceId sdk.pushMessage(msg); return new PushResult(traceId ! null, tencent, traceId, traceId ! null ? null : Missing traceId); } }这块有一个非常关键的实操点适配器不要处理业务规则。比如“不同的电话号码前缀要路由到不同渠道”这种逻辑就不该写在适配器里面否则适配器会越来越胖最后变成“业务规则渠道适配”的混合体。适配器只做一件事把PushRequest翻译成对应SDK能理解的结构再调用它最后把SDK返回的结果翻译成统一的PushResult。3.3 第三步用工厂方法管理适配器实例有了接口和适配器接下来需要一种方式让业务层能拿到适配器实例。最朴素的做法是简单工厂public class MessageSenderFactory { private static final MapString, MessageSender SENDERS new ConcurrentHashMap(); static { SENDERS.put(ali, new AliSmsAdapter(new AliSmsSDK())); SENDERS.put(tencent, new TencentPushAdapter(new TencentPushSDK())); } public static MessageSender getSender(String channel) { MessageSender sender SENDERS.get(channel); if (sender null) { throw new IllegalArgumentException(Unsupported channel: channel); } return sender; } }工程实践里静态初始化块的方式适合通道不多、初始化成本低的场景。如果SDK初始化成本高改成懒加载或者交给Spring容器管理更好。我这里用一个类直接管理。3.4 第四步业务层调用调用方受损最小化是适配器模式的价值体现。业务层代码不用关心底层是哪个渠道只要拿到一个MessageSender调用的时候传入统一的PushRequest最后拿到PushResultpublic class PushService { public void push(String channel, String phone, String content) { PushRequest request new PushRequest(phone, content); PushResult result MessageSenderFactory.getSender(channel).send(request); if (!result.isSuccess()) { // 统一的失败处理逻辑比如补发、记录日志、告警 } } }我实测下来最大的变化就是新增一个推送渠道开发人员只需要做三件事——写一个新适配器、注册到工厂里、写对应的单元测试。核心Service代码零改动。整体新增工作量从原来爬一堆if-else改成只碰新增文件回归成本也低很多。4. 在JDK、Spring这些源码里找适配器理解会深一半纸上写一百个示例不如去源码里认认真真看一个真实案例。适配器模式在Java生态里存在感极强只是很多框架不会在javadoc里标注“这里用了适配器模式”它们藏在类名和代码结构里。4.1 JDK IO流最经典的一对所有Java程序员都写过BufferedReader br new BufferedReader(new InputStreamReader(new FileInputStream(test.txt), UTF-8));这一行里就有适配器模式。InputStreamReader就是个适配器它的目标接口是Reader被适配的对象是InputStreamTargetReaderAdapteeInputStreamAdapterInputStreamReader它做了什么事把一个字节流适配成字符流同时负责字符集编码和解码。如果没有它你每次想要读文件时都得自己处理字节到字符的转换。JDK的这个例子很好地说明了适配器的另一层价值当你无法直接改变被适配对象的接口时适配器能帮你补齐这层转换。还有一个容易被忽略的StringReader、StringWriter本质上也是“把String适配到字符流的读写场景”只是它的Adaptee本身就是个字符串结构更简单。4.2 SLF4J日志门面如何适配各家日志库如果你用slf4j记录日志那你的代码里几乎每一行都依赖于适配器模式。SLF4J定义了自己的Logger接口然后在运行时通过不同适配器把调用转发给Logback、log4j或JDK LoggingTargetorg.slf4j.LoggerAdapteech.qos.logback.classic.Logger或org.apache.log4j.LoggerAdapterLogbackLoggerAdapter或Log4jLoggerAdapterSLF4J官网给的原理图画的就是适配器模式。它的最大价值就是让应用层的日志API不受底层日志实现影响。当年log4j爆出漏洞的时候依赖SLF4J而不是直接依赖log4j的项目能快速切换实现就因为“适配层”隔离了变化。这个案例给我们的启发很实在当你的系统里存在多个同功能但不同接口的组件时通过适配器统一它们的外壳比让核心业务代码同时依赖多个组件要好得多。4.3 Spring MVC的HandlerAdapter可以说很精髓如果只看名字你可能想象不到Spring MVC的核心机制里也藏着适配器模式。DispatcherServlet拿到请求后要先找到能处理这个请求的Handler然后调用它。问题是——Handler的类型可以是Controller方法可以是HttpRequestHandler也可以是古老的Controller接口实现它们的调用方式完全不同。为了让统一分发逻辑不用写一堆if (handler instanceof XXX)Spring定义了HandlerAdapter接口public interface HandlerAdapter { boolean supports(Object handler); ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception; }每一种Handler都对应一个AdapterRequestMappingHandlerAdapter适配RequestMapping方法HttpRequestHandlerAdapter适配HttpRequestHandlerSimpleControllerHandlerAdapter适配老式Controller接口DispatcherServlet拿着request去遍历所有HandlerAdapter找到supports()返回true的就交给它去handle。这个设计把“Handler类型”和“Handler调用方式”彻底解耦新增一种Handler类型只需要新增一个Adapter完全不用动主流程。说实话我第一次理解Spring MVC的这套机制时感慨很大。适配器模式不是只能用来“对接外部SDK”它同样适用于“内部多种类型需要统一处理”的场景本质都是为了让调用方不感知类型差异。4.4 从源码案例反推设计技巧这些框架级案例里有个共同的规律它们都让适配器实现一个目标接口这个接口往往是稳定的、面向场景的而不是面向具体实现类的。你看到的Target接口比如Reader、Logger、HandlerAdapter它们都定义得非常克制方法不多语义清晰场景感极其明确。这提醒我们写适配器之前第一件事是把目标接口设计好那个接口才是核心资产。5. 适配器、代理、装饰器三个模式到底差在哪这是我被问得最多的高频问题也是代码评审中最容易被挑刺的点是改用适配器还是该用代理是不是加一层包装就叫适配器要把这块讲清楚得先明确一件事三种模式的意图根本不是一回事。模式核心意图接口是否变化典型场景适配器模式转换接口让原本不兼容的类能协作接口变对接第三方SDK、老系统改造代理模式控制对目标对象的访问接口不变延迟加载、权限控制、远程调用装饰器模式动态增强对象行为接口不变IO流扩展、附加日志、缓存增强5.1 控制访问还是转换接口这是代理和适配器的分水岭代理模式的核心是“控制访问”。比如Spring AOP里生成的对象接口跟原类一模一样不同在于访问它时会先经过一系列拦截器。代理对外表现得和目标对象几乎没有差别它是“替你出面办理但办的还是你的事”。适配器的核心是“转换接口”。适配器一定改变了目标接口——把smsSdk.sendSms(String,String)变成了MessageSender.send(PushRequest)。业务代码拿到适配器后根本感知不到原始SDK的存在它面对的完全是另一个接口定义。所以一个简单的自检方法如果我新写的这个类接口和原来一模一样只是加了点控制逻辑那它是代理如果接口变了那它是适配器。这个判断90%的情况都准。5.2 增强功能还是改变外观装饰器和适配器的区别BufferedInputStream是装饰器——它和InputStream接口一致通过包装另外的InputStream在读写时加上缓冲增强。我们也经常见到这样的代码public class CacheWrapper { private final DataSource dataSource; // 方法名、参数、返回值都和DataSource一致只是里面加了缓存 }接口不变加增强是装饰器。但如果有个老系统里查询方法的返回结构很乱你用另一个类把它包装成漂亮的统一结构接口变了就是适配器。有不少脚手架项目里有人硬把一个加了缓存的service叫做CacheAdapter从模式命名的严谨度来说那其实是装饰器。平时念法无所谓但出问题时会误导排查思路。5.3 适配器和门面模式的边界门面模式也很容易混淆。门面模式面向的是“整合”把一堆子系统接口拧成一个大接口。适配器面向的是“转换”把一个现有接口改写成另一个接口。拿泡茶举例门面模式像茶艺师——他把烧水、洗杯、泡茶、斟茶一整套动作整合成“给我来杯茶”这一个接口适配器更像转换插头——你不是整合整个系统只是让现有插头能插进目标插座。它们使用场景有明显差异门面服务于“简化复杂度”适配器服务于“对接不兼容”。6. 适配器模式使用中容易踩的坑和我的应对办法6.1 适配器只“转发了”核心方法Adaptee新增能力被架空适配器写完后有一个很常见的隐患Adaptee后续版本增加了新能力比如阿里云SDK新增了定时短信但适配器的接口里没有这个方法导致新能力业务想用也用不了。这不是适配器模式本身的Bug而是接口抽象的固有代价。我的应对办法是给目标接口预留扩展点比如在PushRequest里增加extra(MapString,String)字段或者直接提供sendRaw(PushRequest, Map)的能力让新兴能力还能走通。如果某个渠道的新能力真的是高频需求、需要纳入业务能力再升级目标接口。核心原则适配器不要追求100%覆盖Adaptee的方法只覆盖业务当下的需要但要有逃逸通道。6.2 异常被“适配掉”问题最后查不出来这是我认为最危险的一个坑。有人为了让调用方代码简洁在适配器里把异常全捕获了吞掉后返回一个默认值甚至直接返回null。写的时候很爽但线上出了问题核心服务看到的永远是“successfalseerrorMsg空”根本没线索排查。适配器一定要做异常类型翻译而不是吞异常Override public PushResult send(PushRequest request) { try { boolean ok sdk.sendSms(request.getPhone(), request.getContent()); return new PushResult(ok, ali, sdk.getTraceId(), ok ? null : sdk.getErrorMsg()); } catch (SDKInitException e) { throw new PushChannelException(ali sdk init failed, e); } catch (SDKTimeoutException e) { throw new PushChannelException(ali sdk timeout, e); } }把第三方SDK的受检异常翻译成本系统异常体系里的运行时异常外层统一知道这是渠道调用失败但不会丢失原始异常链。既保证了上层接口的清爽又保留了排障能力。6.3 双向适配器的成本陷阱有的系统改造场景会设计双向适配器新老系统互相调用两边都加转换层。比如老系统的A接口和新系统的B服务要打通你都写适配器去转发。短期内双向适配器确实能让两个系统不直接互相依赖。但代价是一旦新老系统都需要持续迭代适配器会处于“两边都在变你永远要同步改”的状态。我见过一个项目里双向适配器维护成本占到整体维护成本的三分之一最后实在扛不住负责人果断推动老系统接口下线删掉了一整片适配代码。这段时间写下来我的经验是适配器主要用于单向桥接稳定接口如果两个系统都在快速演化重点不是写更多适配器而是尽快收敛到统一协议上。比如通过消息中间件、标准Restful API去对接而不是用适配器做点对点硬拼。6.4 适配器膨胀成“万能胶水层”还有最后一个很容易被忽视的反模式适配器数量越来越多、里面塞的业务判断越来越多最后变成一个庞大但谁也不敢动的“胶水代码”。我有一个比较朴素的判断标准如果新增一个适配器时你要连改另外三个已有的适配器那说明你的目标接口设计得有问题如果单个适配器超过200行那说明你往里面塞了不该塞的业务逻辑。适配器应该是一个足够小的类小到一个review的人一眼就能看出它在转发什么是否有异常转换逻辑是否漏掉了traceId透传。到了这个状态适配器模式才算真正发挥出了它的全部价值。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价