先说个背景。我在过去几年里接过不少对接外部系统的活儿,有支付渠道、短信服务、物流查询、地图定位等等。每次接到这类需求,第一反应不是看对方文档,而是先想一个问题:这块东西将来会不会变?只要答案是“会”,我就知道,代码里得留一道墙。而这道墙最常见的实现方式,就是适配器。这篇文章不聊那些教科书里的定义,我想结合自己真实的项目经历,把“隔离变化的实现方法之适配器”这件事拆开讲透,包括什么时候要上、怎么设计接口、怎么避坑。
1. 先从一次重构说起:适配器到底在隔离什么变化
有一次我接手一个老系统,里面直接在某业务service的方法里,硬编码调用了某家支付渠道的SDK。两百多行代码里,既有签名逻辑,又有拼接下单参数,还夹杂着把对方返回的字符串切成自己的状态码。麻烦的地方在于,那家渠道的接口一年升级了两次,每次升级,我们都要全局搜索它的类名,然后一处处改调用点。最头疼的一次,对方把回调通知里的字段名改了,我凌晨三点被叫起来排查,改完这个字段,又发现另一个字段也不对,整套联调折腾了两天。那次之后,我下定决心,凡是接外部系统的入口,一律加适配器。
适配器这东西,说白了就是中间夹一层。它隔离的变化,是两类:一类是外部系统的接口变化,包括方法签名、参数结构、返回格式、异常类型、通信协议;另一类是外部系统的行为变化,比如某个接口从同步变成异步、从即时返回变成回调通知。外部系统变的时候,你的核心业务代码不应该跟着震,这是适配器存在的全部理由。
用生活里的例子会更好理解一点。你去买手机,充电器插头插在插座上,插座标准是固定的。手机原装充电线可能跟家里的插座不匹配,怎么办?一个转换插头就解决了。你换手机、换充电线,只要转换插头这个标准还在,墙里的电线永远不用动。适配器就是那个转换插头:一边是外部系统五花八门的接口,另一边是你业务代码里的稳定标准,它站在中间,把两边的差异吃掉。
这里有一个常见的误区,以为适配器只是为了让“能跑”变成“好跑”,为了代码美观而存在。实际情况是,适配器真正解决的问题是:把变化隔离在一个可控的范围内,让变化发生的时候,改动成本是可控的、影响面是明确的。没有这层隔离,外部一变,你的service、DAO、甚至controller全都要跟着改,排查起来就像大海捞针。而有了适配器,外部变了,你只需要动适配器这一个文件,业务代码几乎不用碰。
所以,我把这套东西总结成一句话:适配器不是“锦上添花”的设计,它是依赖边界处的防震垫。外部系统越不稳定、越不可控,这个防震垫就越重要。
2. 适配器模式的两种实现姿势与取舍
适配器模式的设计思路并不复杂,但落地时有两种典型的写法,各有各的适用场景。理解这两种写法的取舍,比背模式定义重要得多。
2.1 类适配器:通过继承来匹配
类适配器的主角是继承。假设外部SDK里有个类叫LegacyPaymentClient,它的方法叫payByOldWay(...),而我们内部期望的接口是PaymentGateway.pay(...)。类适配器的做法是,让适配器直接继承LegacyPaymentClient,同时实现PaymentGateway接口。伪代码长这样:
public class LegacyPaymentAdapter extends LegacyPaymentClient implements PaymentGateway { @Override public PayResult pay(PayRequest request) { // 把内部 PayRequest 转成旧SDK需要的参数 OldPayParam param = convert(request); // 调用继承来的旧方法 OldResult oldResult = payByOldWay(param); // 把旧结果转成统一的结果 return convert(oldResult); } }这种写法的优点是,代码写起来简练,不需要在适配器里额外持有一个被适配者的实例。如果你还想重写被适配者的某些行为,直接覆写方法就行。但缺点也比较明显,Java是单继承,你继承了LegacyPaymentClient,就没法再继承别的东西;适配器跟外部SDK的具体类绑死了,如果SDK构造方式复杂(比如需要很多配置参数),继承体系会让事情变得别扭。而且继承会让外部SDK的内部细节暴露在子类里,本来就该被隔离的东西,又蔓延出来了。
2.2 对象适配器:通过组合来匹配
对象适配器是我更常用的方式。适配器不继承外部类,而是在内部持有外部类的一个实例,通过组合转发调用。同样一个场景,对象适配器长这样:
public class LegacyPaymentAdapter implements PaymentGateway { private final LegacyPaymentClient legacyClient; public LegacyPaymentAdapter(LegacyPaymentClient legacyClient) { this.legacyClient = legacyClient; } @Override public PayResult pay(PayRequest request) { OldPayParam param = convert(request); OldResult oldResult = legacyClient.payByOldWay(param); return convert(oldResult); } }组合比继承灵活得多。适配器可以同时适配多个外部对象,也可以在不同构造函数里接收不同风格的SDK实例,测试的时候还能轻松地注入一个mock的LegacyPaymentClient。而且,从语义上看,适配器和外部SDK之间是“has-a”的关系,不是“is-a”的关系,这更符合隔离变化的初衷——你的适配器并不属于外部SDK的家族体系,它只是一个翻译官。
我把两种方式的对比整理成了一个表格,方便你根据项目情况做选择:
| 对比维度 | 类适配器(继承) | 对象适配器(组合) |
|---|---|---|
| 实现方式 | 继承被适配者 + 实现目标接口 | 持有被适配者实例 + 实现目标接口 |
| 灵活性 | 低,单继承限制 | 高,可组合多个实例 |
| 测试便捷性 | 难,依赖于继承体系 | 容易,可注入mock实例 |
| 与外部SDK耦合 | 高,类关系绑定 | 低,只依赖实例接口 |
| 代码量 | 少,相对简洁 | 稍多,需要显式转发 |
| 适用场景 | 被适配者方法不多、类结构稳定、你确实需要重写部分行为 | 大多数第三方SDK对接场景 |
我个人的实践偏向非常明显:优先选对象适配器。原因就一条,隔离变化的本质是降低耦合,而继承本身就是在制造更强的耦合,方向就反了。不过,如果你的被适配者是一套古老的自研代码,方法不多、类也很简单,而且你确实需要覆写它的一些默认行为,类适配器也能用。关键是别混着来,一个项目里统一选一种,否则后来维护的人会看得一头雾水。
3. 适配器的接口设计才是真正的分水岭
很多人在写适配器时,第一个动作是把外部SDK的方法名稍微改一下,原封不动地搬到自家接口里。比如外部SDK有个sendSms(String phone, String content, String signName),他就定义一个接口方法sendSms(String phone, String content, String signName),然后适配器里一行转发。这样做了一百次,也就等于没做隔离——外部SDK的参数结构、语义、甚至异常方式,全都通过这个接口漏到了业务里面。一旦外部SDK升级,改参数,你的接口也得跟着改,业务代码还是遭殃。
判断一个适配器接口设计得好不好,标准只有一个:看你这个接口是不是站在“业务语义”的角度来定义,而不是站在“外部SDK”的角度。接口里的每个方法,应当是业务需要的能力,而不是外部SDK功能的复读。
拿短信服务举例。外部供应商的SDK可能定义方法sendTemplateSms(phone, templateCode, paramsJson),但你的业务真正关心的是“给用户发送一条订单即将签收的通知”。如果接口设计成前者,你的业务代码就得知道模板码、参数JSON结构这些跟渠道强相关的东西。如果接口设计成后者,比如:
public interface MessageNotifier { void notifyOrderDelivering(User user, Order order); }那业务代码就可以完全专注于“什么时候发、发给谁、发什么内容”,而把“用哪个模板、走哪个渠道、拼什么参数”全部丢给适配器。这才是真正的隔离。顺着这个思路,我把适配器接口设计拆成了四步:
第一步,列出业务真正需要的能力。别看外部SDK有什么方法,先想你的业务在这个边界上需要做什么动作。支付场景,业务需要的是“发起支付”“查询订单状态”“申请退款”“处理回调”,而不是“调用CreatePayment”“调用QueryPayResult”“调用DoRefund”。
第二步,用你自己的领域语言定义输入输出。接口方法里只准出现你自己的领域对象和基础类型,绝不允许出现外部SDK的DTO、枚举、异常类。外部SDK的PayResult,到了你的接口里应该叫PayResultDTO或者直接被映射成你自己的状态枚举。这个规矩定死了,业务层就从根上跟外部依赖切开了。
第三步,识别变化点,把变化关在适配器里。问自己:这个外部系统哪些地方可能变?是方法名变、参数结构变、还是状态码含义变?不管哪一个变化,都应该只发生在适配器内部。适配器负责把外部变化翻译成稳定输出,而不是把变化往上抛。比如外部状态码从数字改成字符串,适配器认得出来,业务层那边感觉不到。
第四步,让接口尽量小,小到刚好够用。好的适配器接口,方法数量通常不超过几个。接口方法越多,业务代码和适配器之间的耦合面就越大。我见过有人把一个外部系统的二十多个方法全部平铺到适配器接口里,美其名曰“完整封装”,实际上就是把外部系统原样暴露。业务根本不会用到那么多方法,多出来的每一个方法,都是将来的维护负担。
这里还要强调一个细节:异常设计。外部SDK抛出的异常类型,在适配器边界上必须被转换成你自己的异常体系。外部系统抛SmsSdkTimeoutException,适配器应该抛NotifyServiceException,并保留根因。这样业务层的catch逻辑才不会被外部SDK的异常类绑架。你想想,如果业务代码里到处catch的是第三方SDK的异常类,那改造一个渠道,你的catch逻辑全得跟着改,隔离就前功尽弃了。
4. 经验法则:什么时候该用适配器,什么时候不该用
适配器是个好东西,但绝不是什么地方都适合套。我在项目里见过有人为了“设计”而设计,内部两个方法都还没理清楚,就硬塞一层适配器,结果代码绕来绕去,大家看得晕头转向。所以,判断“该不该用适配器”的分寸感,可能比掌握模式本身更重要。
我在实践中总结了一套信号判断法,碰到几种情况,基本可以确定需要适配器:
第一,你正在对接第三方SDK,而且这个SDK不在你的掌控内。支付、短信、物流、地图、存储、消息推送,这些都属于外部依赖,它们的接口说变就变,你没有谈判的余地。这种场景,适配器几乎是必须的,而且越早加越好——最好是在第一行写业务逻辑之前加。
第二,同一种能力存在多家供应商,你可能需要切换或者并存。比如一开始用渠道A发短信,后来因为价格或到达率问题,要接入渠道B。如果你一开始就写了适配器,切换就是新增一个类、改一行配置的事。如果没写,那就准备熬夜全军搜索吧。
第三,你的系统需要被测试,而外部依赖不好模拟。没有适配器的时候,你测试业务service,要么连真实的外部服务,要么用高级mock工具去拦截SDK调用。有了适配器,你只需要在测试里构造一个简单的假实现,可复用性极高。这一点在大团队里特别有用,别的同学想写个单元测试,一看你的业务代码只依赖自家接口,轻松就mock出来了。
第四,外部接口的升级频率明显高于你业务的稳定程度。有些外部系统每年迭代好几个大版本,你的系统只要跟着升几次就知道痛苦了。
反过来,也有不适合硬套适配器的情况:
其一,内外两个类只是方法名不同,功能大小完全一致,而且调用点就一两个,将来也没有扩展的迹象。这时候为了“规范”加适配器,等于多绕一层。改动成本没降多少,阅读成本倒是上去了。
其二,你只是调一个外部工具类处理一次数据转换,比如某个开源的加密工具,调用一次就完事。这种一次性调用,不需要适配器,直接调用即可,真要隔离,用个简单工具方法包一下就足够了。
其三,适配器跟门面模式被混用的时候,要格外小心。门面模式的目的是简化复杂子系统的使用入口,把一堆杂乱调用聚合成一个简单方法;适配器的目的是解决“接口不匹配”。两者意图完全不同,但在代码里很容易被滥用成同一件事。我见过有人把外部系统的一个粗粒度接口拆成多个细粒度方法,美其名曰“适配器更灵活”,结果业务代码要拼装三次调用才能完成一个操作。适配器不是让你重新设计外部系统的API粒度,它只是做“翻译”,不是做“重组”。
说到这儿,我把适配器和几个容易混淆的概念放在一起比一比,方便你对照:
| 设计手段 | 核心目的 | 典型使用位置 | 常见误区 |
|---|---|---|---|
| 适配器 | 匹配不兼容的接口 | 外部依赖与业务代码之间 | 重排粒度、过度封装 |
| 门面模式 | 简化复杂子系统的入口 | 子系统入口处 | 把适配器当门面用 |
| 策略模式 | 支持算法/渠道动态替换 | 行为选择处 | 与适配器混用无边界 |
| 模板方法 | 固定流程骨架,子类补充细节 | 流程固定但步骤多变时 | 为复用继承而强行抽骨架 |
一句话总结我的经验:适配器是为“你不拥有的东西”准备的。你自己能控制的内部模块,用设计模式去解耦可以,但大可直接改接口。真正需要适配器的,是那些改不动、猜不透、不稳定、还躲不开的外部依赖。
5. 实战复盘:一个支付渠道对接的完整案例
前面的理论说了不少,但我觉得最有价值的,还是拿一个真实场景把过程走一遍。我用一个模拟项目X里接入支付渠道的案例来复盘。这个案例我在好几个系统里都复用同一套思路,落地效果都比较稳。
需求背景是这样的:交易系统要支持三家不同的支付渠道,分别是A渠道、B渠道、C渠道,各家SDK风格迥异。A渠道提供一个重量级SDK客户端,B渠道走REST接口但签名方式特殊,C渠道则要求异步回调为主。业务侧要求:上层下单和退款逻辑必须完全不知道渠道差异,换渠道只能通过配置切换,禁止动业务代码。
5.1 定义目标接口
按照前面说的四步法,我先把业务真正需要的能力列出来:发起支付、查询状态、申请退款、处理渠道回调。于是目标接口设计成:
public interface PaymentGateway { PaymentCreateResult createPayment(PaymentOrder order); PaymentStatus queryPaymentStatus(String outTradeNo); RefundResult refund(String outTradeNo, BigDecimal amount); PaymentCallbackResult handleCallback(Map<String, String> rawCallback); }注意,接口里完全没有出现A渠道的AuniPayOrder、B渠道的bizMap、C渠道的cCallbackDTO这一类外部类型。PaymentOrder、PaymentCreateResult、PaymentStatus、RefundResult都是系统自己的领域模型。这样设计,上层代码可以完全围绕业务语义写。
5.2 为每个渠道写一个适配器
接下来是对应每个渠道写适配器。A渠道适配器核心职责就是三步:把PaymentOrder翻译成A渠道SDK的下单参数;调用SDK方法;把SDK返回结果翻译成PaymentCreateResult或PaymentStatus。
public class ChannelAPaymentAdapter implements PaymentGateway { private final AftSdkClient aftClient; // A渠道SDK客户端 @Override public PaymentCreateResult createPayment(PaymentOrder order) { AftSdkOrder sdkOrder = new AftSdkOrder(); sdkOrder.setOutTradeNo(order.getOutTradeNo()); sdkOrder.setTotalAmount(order.getPayAmount().movePointRight(2).intValue()); sdkOrder.setSubject(order.getSubject()); AftCreateResult sdkResult = aftClient.create(sdkOrder); return PaymentCreateResult.of(sdkResult.getPrepayId()); } // 其他方法类似省略 }B渠道适配器则要处理签名和HTTP调用,但这些逻辑都被锁在适配器内部,接口的调用方完全无感。C渠道适配器最特殊,它的createPayment可能只是登记一笔待发起支付,真正的支付结果全靠回调异步推送。这时候适配器的handleCallback方法就要把C渠道回调数据解析成统一的PaymentCallbackResult,然后交给上层统一处理。
5.3 用工厂封装渠道选择
适配器本身解决了“接口不匹配”,但还差最后一环:到底用哪个适配器?我习惯用一个简单工厂来管这件事,配置里写好当前渠道标识,工厂根据标识返回对应的适配器实例。
public class PaymentGatewayFactory { public static PaymentGateway getGateway() { String channel = AppConfig.get("payment.channel"); return switch (channel) { case "A" -> new ChannelAPaymentAdapter(new AftSdkClient(...)); case "B" -> new ChannelBPaymentAdapter(new BRestClient(...)); case "C" -> new ChannelCPaymentAdapter(); default -> throw new IllegalStateException("unsupported channel"); }; } }这样,上层业务拿到的始终是PaymentGateway,调用方式永远一样。切渠道的时候,改的就是一行配置,加渠道的时候,新增一个适配器类、扩展工厂,业务代码一行不动。这个效果,正是适配器模式的最终目的。
5.4 回调和同步语义的统一
这个案例里还有一个小细节值得展开。C渠道的支付结果靠异步回调,而A、B渠道都是同步返回,这造成了行为差异。如果直接把这种差异暴露给上层,上层就要为C渠道写一套额外的异步处理逻辑,这又破坏了隔离。
我的做法是,在内部分配一个专用的回调处理器,统一所有渠道的回调格式。适配器把各家回调数据解析后,统统转成一个统一的PaymentCallbackResult,里面包含订单号、最终状态、渠道流水号等关键字段。上层代码只认这个统一结果,不管是同步轮询拿到的、还是异步回调拿到的,处理逻辑都一样。这样,行为差异也被适配器“消化”了,业务层的复杂度没有增加。
6. 避坑指南:适配器实战里的六个坑
适配器看着简单,但真正写起来,容易踩的坑一个不少。我把这些年见过和亲身踩过的坑整理成一份避坑清单,每条都用真金白银换过教训。
6.1 坑一:默默吞异常
有人为了让适配器接口干净,把外部SDK的异常全catch住,然后return null,美其名曰“调用方不用关心外部异常”。结果就是线上出现一批空指针,排查半天才发现是外部服务超时了。适配器可以转译异常,但绝不能吞异常。正确的做法是,把外部异常转成自己的异常类型后原样抛出,同时把原始异常作为cause保留,日志里打清楚渠道标识、请求参数和时间点。
6.2 坑二:过度封装
刚说完该加适配器的信号,但反向的坑也同样常见。有些人为了凑一个“优雅”的结构,把外部SDK里所有方法都包成适配器方法,包括那些根本没人用的、甚至只有调试作用的方法。适配器是给业务用的,不是给外部SDK做镜像的。只封住业务需要的那几个能力就够了,接口越小越好。
6.3 坑三:适配器接口跟着外部SDK变动
这是最隐蔽的坑。适配器接口的第一版可能设计得挺好,但后来外部SDK升级,新加了字段,操作者图省事,直接在适配器接口里加了个setXxx方法,或者在返回值里加了个外部类型字段。几次迭代之后,适配器接口就变得跟外部SDK一样“脏”了。防这个问题的办法只有一个:代码评审的时候,对适配器接口的变更要保持高度敏感。接口里的任何新增方法、新增参数类型,都要问一句“这是业务需要,还是又被外部带偏了”。
6.4 坑四:只重命名,不收敛行为
我刚才提到,适配器不是简单换个方法名。假如外部SDK的一个查询接口,传入空订单号时返回空字符串,线上新版本可能改成返回null,这种细微的行为差异,如果适配器只是机械转发,上层代码就要被迫同时处理两种边界。真正的适配器应该把行为收敛好:不管外部返回什么奇怪的值,适配器出来后都应该是一个标准行为,比如统一返回一个空对象或统一抛异常。
6.5 坑五:适配器里塞业务逻辑
适配器的职责是翻译,不是什么活儿都往里装。我见过一个适配器里,里面居然夹杂着优惠计算、风控判断、库存扣减。等到要换渠道的时候,才发现这些业务逻辑全卡在适配器里,根本挪不动。适配器该干的事只有四件:参数转换、调用外部、结果转换、异常转译。除此之外,多一行业务逻辑都不要写。业务逻辑理应属于业务层,不然隔离的就不是变化,而是混乱。
6.6 坑六:忘了给适配器写测试
适配器是整个系统里最容易出问题、也最值得写测试的部分。它每一个转换逻辑都值得用测试锁住。我常用的做法是:构造外部SDK的假实现,注入适配器,然后验证适配器的输入输出。这样外部系统升级时,只要适配器测试全绿,说明翻译逻辑没有被破坏;一旦变红,也能精确定位是哪个参数映射出了问题。
下面是一个排查清单的速查表,实际遇到适配器相关问题时可以直接对照:
| 问题现象 | 可能原因 | 排查动作 |
|---|---|---|
| 业务层收到外部SDK异常 | 异常没有在适配器内转译 | 检查适配器catch逻辑是否只吞不抛 |
| 切换渠道后部分功能异常 | 适配器复用了旧渠道参数映射 | 检查新适配器参数转换是否完整 |
| 回调数据格式对不上 | 外部回调字段变更,适配器未同步 | 检查适配器回调解析方法 |
| 测试无法mock外部依赖 | 业务代码直接依赖外部SDK而非适配器接口 | 检查调用点是否绕过适配器 |
7. 适配器之外:隔离变化的扩展思路
适配器是隔离变化的基石,但它不是唯一的手段。把适配器放到一个更大的图景里看,你会发现它跟几个伙伴模式经常搭配使用,理解它们的边界和配合方式,能让你的设计更从容。
策略模式是我用得最频繁的搭档。适配器解决了“接口不匹配”,策略模式解决的是“选哪个算法或渠道”。典型的场景是:你有多个PaymentGateway的适配器实现,业务运行中根据订单金额、用户类型、渠道成本动态选择用哪一个。这时候适配器负责适配每个渠道,策略负责选择,两者的职责边界很清晰。我自己通常用一个工厂来完成策略选择,就像上面案例里的PaymentGatewayFactory。
模板方法模式则是用来固定流程骨架的。如果你想确保所有渠道的适配器都遵循同一个步骤——比如“先验签、再鉴权、再发起调用、最后记录日志”,就可以在一个抽象基类里定义好这个流程,让每个渠道适配器只实现其中变化的小步骤。这样做的好处是,流程统一了,新增渠道时不容易漏步骤。不过要注意,这只适用于流程确实高度一致的场景,渠道差异太大的话,模板会被各种分支打满补丁,那还不如各自独立实现。
防腐层(ACL)是另一个值得延伸的概念。它比适配器更高一层,通常出现在两个子系统之间,专门用来防止外部系统的“坏模型”污染内部领域模型。适配器的“翻译”是防腐层的核心动作之一,但防腐层往往会附带额外的职责,比如协议转换、权限校验、数据缓存。在设计时,我会先画清楚边界:哪些是纯翻译,放适配器;哪些带了业务策略,放更上层的防腐层服务。不要把防腐层当成一个大筐,什么都往里塞。
再说说宏观的边界感。适配器是隔离变化的第一道门,但它不是铺满全场的草皮。正确的做法是在依赖边界处有选择地铺设。支付、短信、物流、存储、地图、推送这些真正外部的依赖,优先做适配器;内部模块之间的协作,更多靠领域设计和事件驱动来解耦,而不是到处加适配器。分清内外,适配器才能发挥它最大的价值。
最后想说的话
写了这么多,也许你能感受到,适配器这个模式,本质上是一种“认怂”的设计——它承认外部系统我们管不了,变化注定会发生,于是干脆把这部分失控区域划定出来,用一层翻译官挡住冲击。我这些年最大的体会是,隔离变化的关键不在于用了什么模式,而在于你愿不愿意在写第一行业务代码之前,多花点时间想清楚依赖边界在哪里。每当你看到需求文档里出现“对接”“接入”“集成”这类词,就是适配器该出场的时候。提前把它设计好,后面能省掉无数个凌晨三点的排查电话。希望这篇心得能给你一点参考,让你在遇到那些不太讲道理的外部系统时,能少踩几个坑。