1. 同一个“对不上”,三种完全不同的解法
做后端开发的这些年,我几乎每个项目都会碰到“接口对不上”的尴尬。第三方SDK的返回结构和我们的领域模型对不上,老系统的报文格式和新接口对不上,就连同一个团队里两个微服务的参数习惯都可能对不上。这时候适配器模式(Adapter Pattern)往往是我第一个想到的设计模式。它不是最炫技的模式,却是最能救场的模式之一。
接口不匹配这件事,我在不同的项目里见过至少三种典型场景。第一种是接入外部供应商,比如支付渠道、物流服务商、短信平台,接口文档是对方定的,字段命名、调用方式、错误码全由不得我们,而业务层又不希望代码里到处都是wxpay_sdk.doXxx和alipay_sdk.doYyy这种碎片化调用。第二种是历史遗留系统,老系统已经跑了好几年,核心表结构不能动,接口签名也不能动,但新系统需要以另一种方式消费它。第三种其实是团队内部的接口演化,新框架把返回结构变了,老调用方又多,不可能让所有调用方都跟着改一遍。
面对这三种情况,很多人第一反应是硬改。改调用方、改数据格式、甚至在 Service 里写一堆 if-else 判断渠道类型。短期看是能跑,长期看就是在给项目埋债。接入第三个渠道的时候,代码里会出现第三个 if 分支,等到接入第五个渠道的时候,维护的人已经分不清哪些逻辑是业务规则、哪些逻辑是渠道差异。
适配器模式解决的就是这个“接口形状不一致”的问题。它不去改老代码,也不去污染业务逻辑,而是单独加一层转换器,把外部接口翻译成我们期望的接口。接口变形的部分被集中在一个类里,业务层只跟稳定的目标接口打交道。理解这一点,是正确使用适配器模式的前提。
很多初学者会把适配器理解成一种“对接工具”,觉得只要两个东西连不起来,就套个适配器。但其实适配器的核心不在“连”,而在“翻译”。它把一种消息形式转换成另一种消息形式,把一种调用语义转换成另一种调用语义,把一种异常模型转换成另一种异常模型。翻译这件事,说简单也简单,说难也难,因为翻译得不好,业务层就会被迫去理解渠道的细节。
2. 适配器模式的三个角色,以及对象/类两种写法怎么选
2.1 三个角色的定位
适配器模式涉及三个角色,分别是目标接口(Target)、被适配者(Adaptee)和适配器(Adapter)。目标接口是调用方真正想使用的接口,它应当体现业务语义,而不是渠道语义。被适配者是已经存在的、接口不匹配的组件,通常来自第三方SDK或者老系统。适配器则负责把被适配者的接口转换成目标接口。
举个例子。假设团队里有一套统一的对外推送服务,我们希望在业务层只需要做一件事:把消息发给某个用户。于是定义一个目标接口:
public interface MessageSender { void send(String target, String title, String content); }老系统里已经有一个负责发邮件的组件,接口却是这么写的:
public class MailSender { public void sendMail(String to, String subject, String body) { // 真实的邮件发送逻辑 } }要让MessageSender能调通MailSender,就写一个适配器:
public class MailSenderAdapter implements MessageSender { private final MailSender mailSender; public MailSenderAdapter(MailSender mailSender) { this.mailSender = mailSender; } @Override public void send(String target, String title, String content) { mailSender.sendMail(target, title, content); } }这样业务层只需要注入MessageSender,根本不知道底层是邮件、短信还是App推送。将来要换渠道,多写一个适配器即可,业务代码不用动。
2.2 为什么我一直推崇对象适配器
上面的写法是对象适配器,也叫组合适配器。适配器内部持有一个被适配者的实例,通过组合的方式完成调用。我绝大多数情况下都这么写,原因有三个。
第一,组合比继承更安全。适配器只需要依赖被适配者的公开接口,不关心它的内部实现,因此被适配者的改动对适配器的影响会被限制在最小的范围内。第二,对象适配器可以适配一个被适配者的任意子类。如果MailSender有QqMailSender、AliMailSender等多个子类,适配器只要持有父类型引用就能通用。第三,Java 是单继承语言,类适配器一旦继承了被适配者,就没法再继承其他基类,这在一个复杂的工程里是很大的限制。
还有一个从实践中得到的体会:对象适配器更符合依赖倒置原则。适配器面向接口编程,而不是面向具体类编程。它依赖的是MailSender的公开能力,而不是把它内部的方法翻个底朝天。这一点在后期的维护中特别重要。
2.3 C++ 的类适配器:多继承带来的另一个选项
C++ 在设计上有多继承,所以存在一种和对象适配器并列的实现方式,叫类适配器。它的写法是让适配器同时继承目标接口和被适配者,原理上利用了两个类的接口合并:
class Target { public: virtual ~Target() = default; virtual void send(const std::string& target, const std::string& title, const std::string& content) = 0; }; class LegacyMailSender { public: void sendMail(const std::string& to, const std::string& subject, const std::string& body) { // 老系统的邮件发送逻辑 } }; class MailSenderAdapter : public Target, private LegacyMailSender { public: void send(const std::string& target, const std::string& title, const std::string& content) override { sendMail(target, title, content); } };这段代码里private继承很关键。它表示LegacyMailSender的实现细节不对外暴露,只是被适配器当作内部工具来用。类适配器的优点是适配器能直接访问被适配者的 protected 成员,在某些特殊场景下能省去手动转发的样板代码。
缺点也很明显。第一,它把适配器和被适配者的具体类绑死了,没法适配被适配者的子类。第二,继承意味着强耦合,被适配者内部逻辑一旦变化,适配器很可能跟着一起编译失败。第三,多继承会让类的职责变得模糊,阅读代码的人看到继承关系时,很难立刻判断出哪一种父类是真正的“接口”,哪一种只是“实现工具”。
所以我在实际项目里的选择标准很简单:默认用对象适配器,只有遇到被适配者提供了大量 protected 方法、并且需要重写部分逻辑的极端情况时,才考虑类适配器。C++ 工程里这种场景确实存在,但比例不高。
3. 支付渠道统一改造:适配器模式的实际落地样本
讲了原理,得落到一个真实场景里才有说服力。这里分享一次我做支付渠道统一改造的经历,这个案例非常能体现适配器模式的价值,也最能暴露使用不当的问题。
3.1 背景:一个接口,六个渠道
当时的业务系统接入了微信支付、支付宝、银联和一款海外支付SDK,每个业务线都是自己直接调渠道SDK。代码里散落着各种渠道特有的参数构造逻辑、签名逻辑、回调解析逻辑,新来一个同学要维护支付相关代码,得同时打开几份文档对照着看。
我们要做的是构建一个支付中台,把上游所有业务方对支付的调用统一到一个入口。这个入口就是目标接口,它必须把“付款”“退款”“查单”“验签”四个核心动作抽象出来,屏蔽各渠道的差异。接口大致是这样的:
public interface PaymentGateway { PaymentResult pay(PayRequest request); PaymentResult refund(RefundRequest request); PaymentQueryResult query(String orderNo); boolean verifyCallback(String payload, String signature); }这个接口没有把微信的特有字段、支付宝的特有字段暴露出去,因为业务方不关心这些。他们只关心订单号、金额、商品描述这些业务层面的信息。渠道差异全部由适配器在内部消化。
3.2 每个渠道一个适配器,各自翻译差异
以微信支付的适配器为例。微信的统一下单接口要求金额以“分”为单位,参数名也和我们的领域模型不一样。业务层传入的是订单号orderNo,微信要求的是out_trade_no。业务层传的是元,微信收的是分。这些翻译逻辑不能散落在 Service 里,而应该放在适配器内部。
@Component("wxPay") public class WxPayAdapter implements PaymentGateway { private final WxPaySdk wxPaySdk; private final WxPayConfig wxPayConfig; public WxPayAdapter(WxPaySdk wxPaySdk, WxPayConfig wxPayConfig) { this.wxPaySdk = wxPaySdk; this.wxPayConfig = wxPayConfig; } @Override public PaymentResult pay(PayRequest request) { Map<String, String> params = new HashMap<>(); params.put("out_trade_no", request.getOrderNo()); params.put("total_fee", MoneyUtil.yuanToFen(request.getAmount())); params.put("body", request.getSubject()); params.put("notify_url", wxPayConfig.getNotifyUrl()); String sign = WxPaySignUtil.sign(params, wxPayConfig.getApiKey()); params.put("sign", sign); WxPayUnifiedOrderResponse response = wxPaySdk.unifiedOrder(params); return PaymentResult.of(response.getPrepayId(), response.getCodeUrl()); } // refund、query、verifyCallback 类似处理 }支付宝的适配器又是另一套逻辑。它的参数拼接方式、签名算法、回调验签方式都和微信不一样,但这些差异都被封装在AlipayAdapter里。业务层拿到的是PaymentResult,不需要关心prepay_id和trade_no之间的区别。
各渠道的主要差异点,我在适配器里专门做过一张对照表,方便排查问题:
| 差异维度 | 微信 | 支付宝 | 银联 |
|---|---|---|---|
| 金额单位 | 分 | 元 | 分 |
| 参数格式 | Map + XML | Map + JSON | 表单 |
| 签名算法 | HMAC-SHA256 | RSA2 | 证书签名 |
| 异步通知验签 | 二次MD5校验 | 公钥验签 | 证书验签 |
| 同步返回值 | prepay_id | trade_no | tn |
这张表写下来之后,团队里没人再为“这个字段到底是分还是元”而吵架了。因为每个渠道的差异都已经被适配器收口,剩下的只是渠道文档的对照阅读。
3.3 渠道选择:靠类型匹配,不靠 if-else
适配器写完之后,下一步是让业务方能够按需选择渠道。我见过很多同学在这里又开始写 if-else,这等于把适配器带来的好处又丢了回去。正确的做法是维护一个渠道类型到适配器的映射,用一个注册表来管理。
@Component public class PaymentGatewayRegistry { private final Map<String, PaymentGateway> gatewayMap = new HashMap<>(); public PaymentGatewayRegistry(List<PaymentGateway> gateways) { for (PaymentGateway gateway : gateways) { ChannelType channelType = resolveChannelType(gateway); gatewayMap.put(channelType.getCode(), gateway); } } public PaymentGateway get(String channelCode) { PaymentGateway gateway = gatewayMap.get(channelCode); if (gateway == null) { throw new IllegalArgumentException("Unsupported channel: " + channelCode); } return gateway; } }业务传一个渠道编码进来,注册表返回对应的适配器。加新渠道的时候,只需要加一个适配器实现,再注册到容器里,业务代码零改动。我在 Spring 环境下直接依赖容器自动注入List<PaymentGateway>,这样可以避免手动维护注册表时漏掉某个实现类。
3.4 支付场景特有的三个坑
支付适配器有一套独有的坑,是普通 CRUD 项目里很难遇到的。第一是异常翻译。微信SDK抛的是WxPayException,支付宝SDK抛的是AlipayApiException,如果适配器不对异常做统一处理,业务方就要针对每个渠道 catch 一遍。我在适配器里把渠道异常统一翻译成PaymentException,保留渠道错误码和错误信息,但类型统一。
第二是回调验签。微信、支付宝、银联的验签方式完全不同,且验签一旦不过,渠道会认为通知发送失败,反复重试。因此适配器的verifyCallback必须严格按渠道文档实现,绝不能为了省事直接返回 true。我在这个坑上吃过亏,回调地址被渠道连续重试了一个小时,下游订单状态被打得乱七八糟。
第三是幂等。同一个业务订单号可能因为网络重试被提交多次,渠道会返回“订单已存在”。适配器要识别这种异常,把它翻译成“幂等成功”而不是“系统异常”,否则上游会误以为支付失败,给用户退款或者重复创建订单。
3.5 适配器的测试策略
支付适配器不能等联调再发现问题。我为每个适配器都写了同一套契约测试,把统一接口的每个方法都测一遍。测试基类里定义了标准业务场景,比如“金额为0.01元的支付请求必须成功”“使用非法金额校验必须返回参数错误”。每个渠道适配器继承基类,补上各自渠道特有的 mock 数据。这样某一渠道接入出错时,测试能第一时间定位到是适配器翻译问题,而不是业务逻辑问题。
契约测试还有一个好处,就是能倒逼目标接口设计得合理。如果某个渠道的方法在测试基类里根本没法实现,说明接口设计过度依赖某一个渠道的语义。遇到这种情况我会先回头修改目标接口,而不是在适配器里硬塞不支持的逻辑。
4. 适配器模式在框架源码里的用法,你能认出几个
适配器模式最有趣的地方在于,很多人天天在用,却不知道那些 API 就是适配器。看框架源码时多看一层,对模式的理解会完全不一样。
4.1 Java IO:字节流到字符流的适配器
InputStreamReader是一个教科书级别的适配器。它把一个字节流InputStream适配成字符流Reader。调用方原来只能按字节读取,经过InputStreamReader之后,可以按字符读取,还顺手处理了字符集解码。
try (Reader reader = new InputStreamReader( new FileInputStream("data.txt"), StandardCharsets.UTF_8)) { // 按字符读取内容 }这里的FileInputStream就是被适配者,Reader是目标接口,InputStreamReader是适配器。它没有改变文件读取的本质,只是把字节到字符的翻译逻辑封装在了一个类里,调用方不需要自己处理字节拼接和字符集问题。同理,OutputStreamWriter做的是反向适配。
java.util.Arrays.asList也是一个典型的适配器用法。数组和List的接口完全不兼容,但asList把数组“包装”成了一个List的视图,让数组可以以列表的形式参与后续逻辑。虽然它没有改变数据的存储结构,但它完成了接口形态的转换,本质上也是适配器思想。
4.2 Spring MVC:HandlerAdapter 撑起整个 MVC 分发体系
看 Spring MVC 源码时,HandlerAdapter是一个非常重要的接口。DispatcherServlet不直接调用具体的Controller,而是面向HandlerAdapter编程:
public interface HandlerAdapter { boolean supports(Object handler); ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception; }HandlerAdapter的作用,是把不同类型的 handler(HandlerMethod、HttpRequestHandler、Controller接口等)统一适配成DispatcherServlet能调用的形态。RequestMappingHandlerAdapter负责适配@RequestMapping方法,HttpRequestHandlerAdapter负责适配HttpRequestHandler,SimpleControllerHandlerAdapter负责适配老的Controller接口。
这就是适配器模式在框架层的典型应用。框架的作者不知道使用者会写出多少种 handler 类型,但他们知道要通过一个稳定接口把这个不确定的“外部世界”隔离掉。你平时写@RestController时,请求能顺利到达方法,背后就是RequestMappingHandlerAdapter在默默做翻译。
Spring AOP 里的AdvisorAdapter也是同样思路。MethodBeforeAdviceAdapter把一个前置通知适配成MethodInterceptor,AfterReturningAdviceAdapter把一个返回后通知适配成拦截器。这样抽象的通知类型和拦截器执行链才能被统一管理。
4.3 日志框架的适配器:一套接口,到处兼容
日志框架是适配器模式的集中营。MyBatis 的LogAdapter对 JDK Logging、Log4j、Log4j2、Slf4j、Commons Logging 都做了适配。底层日志库可能有完全不同的接口,但 MyBatis 只通过org.apache.ibatis.logging.Log这一个小小的接口去输出日志,具体用哪个日志实现,由适配器在运行时决定。
Spring 的spring-jcl模块也有类似的适配逻辑,它能把 JCL、Slf4j、Log4j2 等日志框架适配到统一的抽象上。这也是为什么很多项目里换日志框架只需要调整依赖,不需要改业务代码。适配器层把框架内部的结构组织成了一个可替换的积木,换一块积木,其他部分完全不受影响。
4.4 C++ STL 里的容器适配器和函数适配器
C++ 标准库里直接有“适配器”这个词的地方是容器适配器。std::stack和std::queue并不自己维护数据,而是基于某种底层容器改造出来的。它们默认用std::deque作为底层容器,但只暴露出栈或队列特有的接口,删除掉底层容器里那些不符合语义的操作。
std::stack<int> st; st.push(1); st.push(2); int top = st.top(); // 只能看到栈顶 st.pop();stack和deque之间就是适配器关系。deque能支持任意位置插入删除,但栈的语义要求只能在一端操作。容器适配器把这个不合适的接口裁剪掉,让使用方不容易犯错。
函数适配器也是 C++ 里重要的一块。旧标准里的std::bind1st、std::bind2nd可以把一个二元函数适配成一元函数。新版标准里虽然推荐用std::bind和 lambda 表达式,但背后的思想依然是“把一个可调用对象的接口变换成另一个可调用对象的接口”。C++ 的灵活在于它可以在类型层面做大量的适配工作,这也是为什么这类模式在 C++ 里比在 Java 里更常见。
5. 适配器、装饰器、代理、外观,四个包装型模式别再混淆
很多刚学设计模式的人会把适配器和另外几个结构型模式搞混,因为它们都涉及“在一个类外面再包一层”。但它们的意图完全不同,用错地方会导致设计越来越别扭。
5.1 四个模式的本质区别
我从一个非常朴素的角度来区分它们:适配器是换接口,装饰器是加功能,代理是控访问,外观是简化调用。
| 模式 | 核心目的 | 接口是否改变 | 典型场景 |
|---|---|---|---|
| 适配器 | 把不兼容的接口翻译成目标接口 | 改变 | 对接第三方SDK、兼容老系统 |
| 装饰器 | 在不改变接口的前提下动态增强 | 不改变 | 加缓存、加日志、加校验 |
| 代理 | 控制对真实对象的访问,可能增加间接层 | 不改变 | 延迟加载、权限控制、远程调用 |
| 外观 | 为多个子系统提供统一的简化入口 | 重新定义高层接口 | 封装复杂子系统,降低使用成本 |
适配器的关键词是“不兼容”,装饰器的关键词是“增强”,代理的关键词是“控制”,外观的关键词是“简化”。这四个词代表了四种完全不同的需求。如果你只是在两个接口之间做字段翻译,那是在写适配器;如果你在调用前后插入一段额外逻辑,那是在写装饰器或代理;如果你把十多个子系统的调用浓缩成一个门面接口,那是在写外观。
5.2 用一个日志组件实例讲清楚
假设现在要给系统里的日志组件做一次升级。如果底层要换日志实现,旧调用方已经写死了log4j的Logger接口,这时候用一个适配器,把新日志库的接口翻译成Logger接口,调用方不用改。
如果调用方接口不变,但想给所有日志输出增加 JSON 格式化、敏感字段脱敏,那应该用装饰器。装饰器保持Logger接口不变,只是在info、error等方法内部加了一层额外的处理。它不改变调用方的任何代码。
如果日志组件需要上报到远程日志系统,但担心上报任务阻塞主流程,也不想让业务方感知上报细节,可以用代理。代理拦截日志方法,把上报动作放到线程池里异步执行,调用方以为自己在打本地日志,实际上经过了远程转发。
如果业务方要同时记录操作日志、审计日志、异常报表,三个模块分别有各自的接口,调用方写起来很繁琐,这时候用一个外观类把三个模块统一包装成recordOperation方法,调用方只需要一行代码。
一套日志需求,四个模式全都能用上,但各自的定位完全不同。理解意图之后,代码才不会被“包装”两个字给带偏。
5.3 一个常见的误用例子
我在 Code Review 里见过一种典型误用:为了给一个远程服务加访问频率控制,开发同学发现远程服务的接口参数和本地接口不一致,于是把代理和接口转换混在了一起。他在代理类里同时做了字段转换和频率控制,结果这个代理类既没法复用,也没法替换。新增一个调用方要传不同的参数结构时,代理类就变成了一个大杂烩。
正确的做法是把两件事拆开。接口转换交给适配器,访问控制交给代理,两者可以嵌套使用,但不要写进同一个类。代码结构上没有“我是适配器还是代理”的纠结,只有“这个类是干什么的”清晰职责。
6. 工程落地的选型标准,和这些年踩过的坑
设计模式不是越用越好,适配器也一样。该用的时候它是救火队员,不该用的时候它是把代码复杂度往上堆的元凶。这里分享一下我的选型标准。
6.1 什么时候真的需要适配器
外部依赖不可变,是使用适配器最明确的前提。第三方SDK是对方发布的,我们没有改源码的权限,接口也不由我们控制。业务上又有统一接入的需求,这时候适配器是合理的选择。
历史接口无法大改,是另一个前提。老系统可能同时服务着十几个调用方,直接改接口意味着所有调用方必须同步升级,风险太大。用一个适配器让老接口逐渐过渡到新接口,可以做到柔性切换。
渐进式替换老系统时,适配器也很有价值。老系统和新系统并存,上游调用方要平滑切换。先把老系统的接口适配成新系统的目标接口,等所有调用方都切换完,再下线老系统,这是我在重构项目中用过多次的策略。
不需要用适配器的情况也很明确:如果接口是自己团队维护的,并且调用方能同步修改,那直接改接口和调用方,比加一层适配器更干净。如果只是字段命名不一致,用重命名就可以解决。如果调用点只有一个,直接改调用点,没必要为了“用模式”而写一个适配器。
6.2 六个踩过的坑
第一个坑,是把适配器当成“万能胶”。有一回我以为两个模块接口能对得上,只是语义略有差异,就顺手加了适配器。结果发现适配器里除了参数映射,还塞了一堆业务判断,因为两个模块的“订单状态”其实含义完全不一样。适配器越写越长,最后变成了一层模糊业务边界。正确做法是先搞清楚语义是否真的兼容,语义不同就应该是新接口加新实现,而不是用适配器硬凑。
第二个坑,是异常信息被吞掉。适配器负责做异常翻译时,如果不小心把渠道的原始错误信息丢了,排障会非常痛苦。我后来强制要求适配器里的 catch 块必须把原始异常作为 cause 传出去,或者至少保留错误码和信息,不允许直接return null或者打印完就完事。
第三个坑,是把重试、缓存这些横切逻辑写进了适配器。适配器的定位是翻译,不是增强和治理。重试和缓存应该由装饰器、代理或者专门的拦截器处理。混在一起之后,一个适配器既要做接口转换,又要做故障恢复,职责重叠,后面想换一个渠道时,这些横切逻辑全都变成负担。
第四个坑,是目标接口设计过宽。我最早设计的PaymentGateway把所有方法都塞进去了,后来接入一个海外渠道时,对方不支持退款,适配器只能在refund方法里抛异常。接口越宽,适配器就越难实现。现在的做法是把接口做小,能用组合就用组合,不支持的能力在适配器里返回明确的错误码,而不是往下游调用方抛一个UnsupportedOperationException了事。
第五个坑,是没有给适配器做单元测试。适配器的逻辑一般不复杂,但它处在边界位置,最容易受外部接口变化影响。等联调时再发现问题,排查链路非常长。我现在对凡是写了适配器的地方,都要求有单元测试,测试哪怕只覆盖参数翻译的若干关键分支,也能在渠道升级时帮上大忙。
第六个坑,是命名混乱。适配器类必须有清晰的命名规范,例如WxPayAdapter、AlipayAdapter。不要在类名里写WxUtil、PayHelper这类没有区分度的名字。清晰命名是最便宜的文档。
6.3 适配器和其他模式的组合套路
适配器不孤立使用,工程里我经常把它和其他模式组合起来。工厂模式负责创建适配器,策略模式负责按场景选择不同适配器,模板方法模式可以把“验签、转参数、调渠道、转响应”的骨架固定下来,让具体渠道只实现差异部分。外观模式则用于把一组适配器再封装成一个更高层的业务门面,让上游调用方只面对两三个方法。
这些组合不是为了堆砌模式,而是为了应对真实项目的复杂度。支付场景里一个渠道可能有多个产品,一个适配器内部还要再区分扫码支付、公众号支付、App支付,这时候模板方法模式能很好地把公共流程抽出来。适配器只是其中的一环,其他模式负责另外的问题。
7. 最后一点个人体会
用了这么多年适配器模式,我最大的体会是:它真正的价值不在代码量上,而在“隔离”这两个字上。外部世界的接口会变,第三方的SDK会升级,老系统会维护,如果不做隔离开,这些噪声会全部冲进核心业务代码里。适配器层就像一块海绵,把外部世界的不稳定统统吸走,让核心代码相对干净。
在选择对象适配器还是类适配器时,我默认总是对象适配器。Java里写起来很自然,C++里也尽量如此。只有在极其特殊的场景下才考虑类适配器的多继承能力,而那种场景一年到头可能遇不到一次。
曾经有个同事问我,适配器模式是不是太简单了,不值得单独拿出来讲。我说你先去把支付渠道统一项目里的WxPayAdapter看一遍,再决定这句话要不要收回。适配器模式的简单是表象,它背后承载的是接口设计、异常处理、兼容性治理这些工程里最难搞明白的事。把一个“翻译”做好,比把一堆业务逻辑堆在一起要难得多。