设计模式 · 适配器模式 (Adapter Pattern)

前言

在软件工程的实际演进中,我们经常会面临一种进退两难的局面:

系统需要引入一个非常核心的现存组件或第三方库,但它的接口标准与我们当前系统的主流架构完全不兼容。

如果我们为了迎合这个外部组件而大规模修改核心代码,不仅会打破现有的稳定性,还会造成严重的逻辑污染。

适配器模式就是为了这种“亡羊补牢”或“新老交替”的场景而生的。

它就像是一个软件层面的“扩展坞”,优雅地在不兼容的接口之间建立起一座桥梁,让系统能够无缝地复用既有资产。

本文参考博客:

本文代码链接:https://github.com/likerhood/CodeDesignWork/tree/main/codedesign5.0-0 到codedesign5.1-2都是

一、 核心定义

适配器模式(Adapter Pattern) 旨在将一个类的接口转换成客户希望的另外一个接口。它使得原本由于接口不兼容而不能一起工作的那些类可以协同工作。

  • 本质: 接口转换与兼容性适配。
  • 分类: 主要分为“对象适配器”(基于组合机制)和“类适配器”(基于继承机制)。在 Java 生态中,由于遵循“组合优于继承”的原则,我们绝大多数情况下采用的是对象适配器,因为它更加灵活,且能突破单继承的限制。

image-20260501151256192


二、 标准体系结构图

在适配器模式的标准体系中,通常包含以下四个关键角色:

  1. Target(目标接口): 当前系统业务所期待的统一标准接口。客户端只认这个接口。
  2. Adaptee(被适配者): 已经存在的、包含核心逻辑但接口与 Target 不兼容的类或第三方组件。
  3. Adapter(适配器): 模式的核心枢纽。它实现了 Target 接口,并在内部持有一个 Adaptee 的实例。它负责接收客户端的请求,并将其“翻译”成 Adaptee 能够理解的特定方法调用。
  4. Client(客户端): 针对 Target 接口进行编程的调用方,对底层的 Adaptee 完全无感知。

三、 场景推演

假设我们正在构建一个用于特定垂直领域的问答系统,系统底层基于检索增强生成(RAG)架构。

在系统初期,我们定义了一个统一的现代化大模型接口标准。

但现在,我们需要接入一个早年开发、专门针对某些特定维护手册微调过的老旧本地化模型服务。

1. 定义 Target(目标接口)

这是我们系统内部统一的调用标准,客户端都基于此接口开发。

1
2
3
4
5
6
7
8
// Target: 现代 RAG 系统的统一大模型接口
public interface StandardRAGClient {
/**
* @param prompt 用户的提问
* @param context 检索到的上下文
*/
String generateAnswer(String prompt, String context);
}

2. 引入 Adaptee(被适配者)

这是一个老旧的模型服务,它的方法签名完全不一样,甚至要求将所有输入打包成一个特定的 JSON 字符串。

1
2
3
4
5
6
7
// Adaptee: 外部老旧的模型服务,接口不兼容
public class LegacyLocalModelService {
public String executeInference(String combinedPayload) {
System.out.println("LegacyLocalModelService: 正在解析合并后的复杂 Payload 并执行推理...");
return "基于特定维护手册生成的回答...";
}
}

3. 构建 Adapter(适配器)

我们创建一个适配器,实现目标接口,并在内部包装老旧服务,完成输入输出的“翻译”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Adapter: 适配器类,通过组合引入 Adaptee
public class LegacyModelAdapter implements StandardRAGClient {

private LegacyLocalModelService legacyService;

public LegacyModelAdapter(LegacyLocalModelService legacyService) {
this.legacyService = legacyService;
}

@Override
public String generateAnswer(String prompt, String context) {
// 1. 将现代接口的参数“翻译”成老旧服务需要的格式
String payload = String.format("{\"context\": \"%s\", \"question\": \"%s\"}", context, prompt);

// 2. 委托给 Adaptee 执行真正的推理逻辑
String rawResult = legacyService.executeInference(payload);

// 3. (可选) 将 Adaptee 的返回结果解析适配回系统期待的格式
return "[已适配处理] " + rawResult;
}
}

4. 客户端调用

系统的主干逻辑依然保持纯净,完美接入了旧模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
public static void main(String[] args) {
// 1. 存在一个老旧的本地模型服务
LegacyLocalModelService legacyModel = new LegacyLocalModelService();

// 2. 将其套上适配器,转换成系统标准的 RAG 客户端
StandardRAGClient llmClient = new LegacyModelAdapter(legacyModel);

// 3. 客户端按标准接口规范发起调用,完全不知道底层是老旧服务
String response = llmClient.generateAnswer("如何排查主机排气温度过高?", "相关维护手册段落...");
System.out.println(response);
}
}

四、实战案例一:MQ消息适配

4.1 需求分析

随着公司业务发展,营销系统需要对接越来越多的 MQ 消息(注册开户、商品下单、第三方订单等),以及不同来源的服务接口,来发放奖励(裂变、首单返利等)。

假设系统原本只接入一种订单消息,后来陆续接入开户 MQ、内部订单 MQ、POP 订单 MQ。它们的字段名并不统一:

  • 开户消息:number
  • 内部订单消息:uid
  • POP 订单消息:uId

但发券业务真正需要的是统一的:

  • userId
  • bizId
  • bizTime
  • desc

如果业务代码里到处写 if-else 判断消息类型,就会越来越乱。每新增一种 MQ,主流程就要继续修改。

适配器模式的思路是:把不同消息先转换成统一模型 RebateInfo,主业务只处理 RebateInfo

适配器模式的价值:

  • 定义统一的目标结构
  • 通过适配器屏蔽各 MQ/接口的差异,上层业务代码只与统一接口打交道

image-20260501152116768

三种 MQ 消息的字段各不相同:

MQ 来源 用户ID字段 业务ID字段 时间字段
create_account number number accountDate
OrderMq uid orderId createOrderTime
POPOrderDelivered uId orderId orderTime

RebateInfo 定义了一套标准字段,所有 MQ 消息经过 MQAdapter 适配后,都转成这个统一格式:

字段 含义 对应原始字段
userId 用户ID 各 MQ 里叫法不同的用户字段
bizId 业务单号 订单号 / 开户编号
bizTime 业务时间 下单时间 / 开户时间
desc 描述 业务描述

4.2 架构图

4.2.1 面条代码架构图

image-20260510170027158

4.2.2 适配器模式架构图

image-20260510170220542

4.3 时序图

4.3.1 面条代码时序图

4.3.2 适配器模式类图


五、实战案例二:redis缓存集群适配

5.1 需求分析

在缓存集群适配案例中,项目最开始只有一套本地封装好的缓存工具 RedisUtils,业务通过统一的 ICacheService 使用缓存能力:

1
2
3
cacheService.set(key, value);
cacheService.get(key);
cacheService.del(key);

这种设计在只有一套 Redis 工具时没有问题。业务代码只关心“我要读缓存、写缓存、删除缓存”,并不需要关心底层缓存工具是如何实现的。

但是随着系统发展,缓存能力不再只依赖原来的 RedisUtils,而是需要接入新的缓存集群组件,例如新加入的EGMIIR组件,

这两个缓存集群组件本质上都能完成缓存读写,但它们对外暴露的方法名并不完全一致。

它们表达的是类似的缓存能力,但接口形式并不统一:

操作 RedisUtils(原来) EGM(新集群1) IIR(新集群2)
取值 get() gain() get()
写入 set() set() set()
带超时写入 set(key,val,timeout,unit) setEx() setExpire()
删除 del() delete() del()

这就产生了一个典型的接口不兼容问题:业务想要的是统一的缓存服务接口,但底层不同缓存组件提供的方法名称和调用方式并不一致。

如果不使用设计模式,最直接的做法就是在缓存服务实现类中增加 redisType 参数,然后通过 if 判断:

1
2
3
4
5
6
7
8
9
if (1 == redisType) {
return egm.gain(key);
}

if (2 == redisType) {
return iir.get(key);
}

return redisUtils.get(key);

这种写法短期能跑,但会把底层缓存差异暴露给业务层。

  1. redisType 进入了业务接口。调用方不仅要知道 keyvalue,还要知道 1 代表 EGM、2 代表 IIR,业务代码被迫理解缓存集群选择规则。

  2. if-else 会在多个方法中重复出现。get、set、带过期时间的 setdel 都要判断一次,后续缓存操作越多,重复分支越多。

  3. 扩展成本高。新增一套缓存 SDK 时,必须修改 CacheClusterServiceImpl,继续增加分支逻辑,容易让实现类变成臃肿的分发器。

所以本案例真正要解决的问题是:

让业务继续面向统一缓存接口编程,把 EGMIIRRedisUtils 的方法差异隔离到适配层。

适配器模式改造后:

  • EGMCacheAdapter 负责适配 EGM
  • IIRCacheAdapter 负责适配 IIR
  • JDKProxyFactory 负责创建业务可用的 ICacheService 代理对象。

改造后的业务调用变成:

1
2
3
ICacheService proxyEGM = JDKProxyFactory.getProxy(ICacheService.class, EGMCacheAdapter.class); 
proxyEGM.set("user_name_01", "like");
String value = proxyEGM.get("user_name_01");

此时业务不再传 redisType,也不需要知道 EGM.gainIIR.setExpire 这些底层方法名。新增缓存集群时,只需要新增对应适配器即可。

5.2 架构图

5.2.1 面条代码架构图

image-20260510171625471

5.2.2 适配器模式架构图

image-20260510173149994

5.3 时序图

5.3.1 面条代码架构图

5.3.2 适配器模式架构图


总结

适配器模式的核心价值是隔离变化。

codedesign5.0-* 中,它隔离了不同 MQ 消息体和不同订单服务接口的差异;在 codedesign5.1-* 中,它隔离了不同缓存 SDK 的方法差异。

适配器模式适合用在这些场景:

  • 外部系统字段和内部模型不一致。
  • 第三方 SDK 方法名、参数、返回值和业务接口不一致。
  • 老接口不能改,但新业务希望用统一接口。
  • 系统中出现大量 if-else 处理接口兼容问题。

最终效果是:

适配器模式不是为了让代码“看起来高级”,而是为了让业务主流程少知道一点外部混乱,多保持稳定。