做支付渠道接入的那段日子,我算是把接口不兼容这件事体会透了。业务方说"再加一个微信支付吧",你以为就是多调一个 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对transMoney,refund对refundMoney - 入参类型完全不同:
BigDecimal对double,PayRequest对一堆散落的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(Map<String, 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(Map<String, 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(Map<String, 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(Map<String, 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适配成Enumeration- Spring 里的
HandlerAdapter:Spring MVC 用统一的HandlerAdapter接口去适配不同的Handler(@Controller方法、HttpRequestHandler等),所以不同风格的处理器能在一个框架里共存
有这些例子打底,你再遇到"两个类不兼容"的场景,就会条件反射地想到适配器。
6.3 我踩过的/见过的坑
第一个坑是我自己掉的:适配器里做状态管理。早期写的一个适配器,为了让调用方能少传参,在适配器内部缓存了一些调用方的业务状态。结果多线程一出问题,A 请求的参数被 B 请求覆盖了。适配器应该是无状态的,或者最多持有 SDK 客户端这种线程安全的对象。业务状态永远放在上下文对象里传进来,而不是在适配器里缓存。
第二个坑是我们团队踩的:适配器把异常吞掉。对方 SDK 抛了个我们没见过的异常类型,适配器里 catch 住之后 print 一行日志就返回成功了。结果财务对账对不上,排查了整整三天才发现是微信渠道的退款接口在特定金额下会抛异常,适配器把它吞了。记住:适配器是翻译层,不是容错层。你不能理解对方的异常,就把异常原样抛出去,让上层去决定怎么处理。
第三个坑是命名不统一。项目里有人写WechatPayAdapter、有人写WxPayServiceImple、有人写WechatUtil。结果同一个支付渠道在代码库里有三个不同封装,新来的同事根本不知道该用哪个。后来我们统一了约定:凡是做接口适配的类,一律叫XxxAdapter,普通业务服务不带 Adapter 后缀。命名规则看起来不起眼,但在中大型项目里能省下大量沟通成本。
第四个坑是适配器和业务缓存混在一起。有同学为了性能,直接在适配器里加了本地缓存,把渠道返回的交易号缓存起来。这样确实快了,但一旦对账系统需要实时数据,缓存里的数据就是个雷。适配器保持纯净,如果要加缓存,请用装饰器模式在外部包装,或者明确在文档里写清楚这个缓存属于哪个层。
如果你在自己的项目里也发现了类似的问题——接口对不上、调用方被 SDK 绑架、老代码不敢动又需要新接口,可以试着用适配器去解。先画清楚三个角色,选对象适配器,保持适配器纯净,你的代码会清爽很多。我实际写下来的体会是,适配器模式是所有设计模式里性价比最高的几个之一,因为它解决的是每天都在发生的现实问题,而且实现成本极低。最后分享一个小技巧:当你开始写一个新增的 Adapter 类时,如果发现需要复制的超过二十行"翻译代码",说明目标接口和对方接口的差异已经大到该考虑用防腐层甚至独立模块来隔离了。适配器适合隔离小差异,大差异需要更大的边界。