1. 适配器模式到底在解决什么问题
先说个最常见的场景。你负责维护一个老系统,数据库里存的用户状态是数字:1 代表正常,2 代表禁用,3 代表已注销。系统里跑了三年的查询逻辑,到处都是if (status == 1)这种写法。结果产品经理过来说,用户状态要改成字符串枚举,前台展示要用"ACTIVE"、"DISABLED"这种形式。你怎么办?全局替换?那风险评估能写一晚上。改数据库?那数据迁移和兼容逻辑够你忙一个月。
适配器模式解决的就是这类问题——两套东西,接口对不上,但又不方便直接改其中任何一方,那就加一个中间层,把一方的调用翻译成另一方听得懂的语言。
其实不用把它想得太玄。生活里到处都是适配器。你手机充电头就是适配器:家用电是 220V 交流电,手机要的是 5V 直流电,充电头做的事情就是把两者对接起来。你买了一个 HDMI 转 VGA 的转接头,电脑只有 HDMI 口,显示器只有 VGA 口,转接头把信号格式转换了一下。适配器模式在代码里干的也是同样的事:把客户端的调用转换成目标接口能理解的格式,让原本不兼容的类可以协同工作。
这个词我第一次接触的时候觉得很高深,后来发现它其实就是"中间商"思维。客户端的代码只认目标接口,被适配的类只提供自己的原始接口,中间的适配器做一个转译动作。谁也不用改,谁也不用迁就谁,两边通过适配器对接。
这个模式属于结构型设计模式,跟装饰器模式、代理模式、外观模式是同一大类。它的核心特征在于"转换"而非"增强"。很多初学者容易把它跟装饰器搞混,后面我会专门讲两者的区别,这里先记住一点:适配器是为了让两个原本不兼容的东西能配合工作,装饰器是为了给原有对象增加新能力。出发点完全不一样。
2. 两种实现方式:类适配器与对象适配器
适配器模式在经典设计模式书籍里有两种实现形态:类适配器和对象适配器。这两种形态的实现思路不一样,适用场景也各有侧重,工程实践中对象适配器的出镜率要高得多。搞清楚它们的区别,是理解这个模式的第一个关键点。
2.1 类适配器:通过继承实现接口转换
类适配器的思路很直白:适配器同时继承被适配类和实现目标接口,相当于把两边合并成一个类。
还是拿前面的用户状态转换来说。假设老系统有个LegacyUserService,里面有个方法返回数字状态:
package legacy type LegacyUserService struct{} func (s *LegacyUserService) GetStatus(userID int) int { // 这里简化处理,假设总是返回 1 return 1 }新系统定义了一个接口,要求返回字符串状态:
package modern type StatusProvider interface { GetStatus(userID int) string }类适配器的实现方式是这样的:
package adapter import ( "legacy" ) type LegacyStatusAdapter struct { *legacy.LegacyUserService } func (a *LegacyStatusAdapter) GetStatus(userID int) string { status := a.LegacyUserService.GetStatus(userID) switch status { case 1: return "ACTIVE" case 2: return "DISABLED" default: return "UNKNOWN" } }通过嵌入LegacyUserService获得原始方法,然后实现StatusProvider接口做状态映射。客户端只需要依赖StatusProvider,完全不感知底层的LegacyUserService存在。
但这种做法有一个隐患:继承是静态的、单一的。Go 语言里虽然可以通过结构体嵌入来模拟继承,但本质上还是组合。如果LegacyUserService的构造函数需要参数,或者它本身是个接口而不是具体类型,类适配器用起来就会很别扭。Java 里更明显,Java 的继承是单继承,你一旦继承了LegacyUserService,就不能再继承其他类了,适配器本身的扩展空间就被锁死了。
2.2 对象适配器:通过组合实现接口转换
对象适配器的做法是持有被适配对象的引用,而不是继承它。适配器内部维护一个被适配者的实例,调用的时候委托给它,然后做格式转换。
package adapter import ( "legacy" ) type LegacyStatusAdapter struct { legacyService *legacy.LegacyUserService } func NewLegacyStatusAdapter(service *legacy.LegacyUserService) *LegacyStatusAdapter { return &LegacyStatusAdapter{legacyService: service} } func (a *LegacyStatusAdapter) GetStatus(userID int) string { status := a.legacyService.GetStatus(userID) switch status { case 1: return "ACTIVE" case 2: return "DISABLED" default: return "UNKNOWN" } }区别看起来不大,但工程意义完全不同。组合的方式让适配器可以灵活替换被适配对象,甚至可以在运行时动态更换。被适配对象有子类也没关系,只要你传入的实例符合预期,适配器一律照单全收。这在 Java 和 Go 这种强调组合优于继承的语言里,是更地道的写法。
从设计原则的角度看,对象适配器符合"开闭原则"的程度更高。新增一个适配器不需要修改已有代码,只要新建类即可。类适配器因为依赖继承,一旦父类的行为发生变化,子类很难完全规避影响——你继承它,就得接受它的一切。
2.3 为什么工程实践中对象适配器占绝对主流
我参与过的几个项目里,类适配器基本只出现在面试题和教科书里,真正在业务代码中落地的几乎都是对象适配器。原因有几个方面。
第一,继承的耦合性太强。类适配器把目标接口的实现和被适配类的内部结构绑定在一起,被适配类的内部实现变更可能影响适配器行为。而对象适配器通过接口或者抽象类型持有被适配对象,耦合面只限于公开方法。
第二,组合的灵活性高。对象适配器可以在构造函数里接收被适配对象,也可以提供 setter 方法动态注入。在单元测试里,你可以传入 mock 对象,完全不依赖真实实现。
第三,跟依赖注入的配合更顺畅。现代项目基本都会用依赖注入框架,对象适配器天然适配这种模式——把真实服务作为依赖注入适配器,再把适配器注入客户端。类适配器要 new 出具体实例,在这一套体系里就显得格格不入。
另外,Go 语言本身不支持传统意义上的继承,只有结构体嵌入这种语法糖,所以类适配器在 Go 里本来就是用组合来模拟的。与其模拟继承,不如直接用对象适配器,代码意图更清晰。
3. 一个完整的工程案例:对接外部短信服务
讲了这么多理论,来看一个我在模拟项目里实际做过的例子,把适配器模式的完整落地过程拆开讲。这个案例是接一个外部短信服务商,场景非常典型,很多项目都会遇到。
3.1 需求背景与接口冲突
项目需要给用户发送短信验证码,最开始接的是 A 公司的短信服务,代码里到处是SendSmsByA(message)这种调用。后来因为价格和到达率的问题,要切换到 B 公司的服务。B 公司的接口风格完全不同,它把短信拆成了几个参数:模板 ID、签名、模板参数,还要求用 POST + JSON 的格式,跟 A 公司那种直接传消息文本的方式天差地别。
最麻烦的是,老的调用点散落在注册模块、找回密码模块、风控通知模块等七八个地方。如果直接改成 B 公司的调用方式,每个调用点都要改一遍,而且一旦 B 公司的接口有变动,又要全局搜索修改。这就是典型的"不应该直接改"的场景。
我的思路是新增一个SmsSender接口,所有业务模块只依赖这个接口。然后写一个适配器,把旧的SendSmsByA调用转换成 B 公司接口能理解的参数格式。业务模块的代码基本不动,只换了一个注入的实现类。
3.2 定义目标接口
首先定义客户端真正依赖的接口,不要受限于任何一家服务商的接口格式。我当时定义了一个很简洁的接口:
package notify type SmsSender interface { Send(code string, phone string, scene string) error }code是验证码内容,phone是手机号,scene是业务场景标识。这个接口的业务语义非常清楚——不管底层是哪家服务商,业务关心的就是这三个信息。
3.3 实现针对 B 服务的适配器
接下来写适配器,把 B 公司的 SDK 封装到SmsSender接口后面。
package adapter import ( "fmt" "github.com/somecompany/sdk" ) type BSmsAdapter struct { client *sdk.BClient } func NewBSmsAdapter(apiKey string, secret string) *BSmsAdapter { client := sdk.NewBClient(apiKey, secret) return &BSmsAdapter{client: client} } func (a *BSmsAdapter) Send(code string, phone string, scene string) error { templateID := a.templateIDForScene(scene) params := map[string]string{ "code": code, } request := &sdk.SendSmsRequest{ TemplateID: templateID, SignName: "模拟项目签名", PhoneNumber: phone, TemplateParams: params, } if err := a.client.SendMessage(request); err != nil { return fmt.Errorf("send sms via B failed: %w", err) } return nil } func (a *BSmsAdapter) templateIDForScene(scene string) string { switch scene { case "register": return "SMS_001" case "reset_password": return "SMS_002" default: return "SMS_001" } }这段代码里,适配器做的事情就是三件:把场景映射到 B 公司的模板 ID、把验证码包装成模板参数、调用 B 公司的 SDK 发送请求。业务模块看到的还是Send(code, phone, scene),但底层已经完全是 B 公司的实现。
如果后续还要支持 C 公司服务,只需要再写一个CSmsAdapter,实现同样的SmsSender接口。切换的时候改一行注入代码,或者通过配置中心动态切换,业务模块零改动。这就是适配器模式在真实工程中最有价值的地方。
3.4 适配器的职责边界:格式转换还是额外逻辑?
写适配器的时候有一个容易走偏的地方:把业务逻辑塞进适配器里。适配器只负责"转换"和"适配",不负责"业务决策"。
比如,判断验证码是否过期、控制发送频率、记录发送日志,这些逻辑不应该放在适配器里。适配器解决的是"接口不兼容"的问题,不是"业务处理"问题。你可以在适配器里做参数格式的组装,但不要在里面做业务分支判断,除非这是接口适配本身的需求(比如根据场景选择模板 ID 属于适配范围,因为这是两个服务商接口格式差异导致的映射需求)。
我在实际开发中见过有人把短信发送频率限制的逻辑写在适配器里,后来换了一家服务商,适配器重写时差点把限流逻辑丢掉。这种边界不清晰的问题在长期维护中很致命。
合理的设计是:适配器持有目标接口,内部做转换;业务逻辑放在调用方或者专门的领域服务里;横切逻辑(比如日志、限流、重试)放在中间件或者装饰器里。职责越单一,换适配器的时候越敢动手。
4. 适配器模式与易混模式的硬核对比
设计模式学到一定阶段,最大的困扰不是看不懂单种模式,而是分不清几种模式的区别。适配器模式最容易被拿去跟装饰器模式和代理模式比较,这里把三个模式放到一起对比,讲透它们各自的服务对象和关注点。
4.1 跟装饰器模式的区分:转换接口 vs 增强能力
装饰器模式的核心是"增强"。它不改变接口,而是沿着原有接口不断包装新能力。比如有一个DataSource接口,你包一层加缓冲,再包一层加加密,再包一层加压缩。每一层装饰器实现的是同一个接口,调用方从头到尾看到的方法签名没有变化。
适配器模式的核心是"转换"。它把 A 接口转换成 B 接口,调用方最终面对的是 B 接口,完全看不到 A 接口的存在。
判断标准很简单:看接口变没变。如果目标接口和客户端原本使用的接口一致,只是增加了一些行为,那是装饰器。如果目标接口和客户端原本使用的接口都不一样,需要做方法签名转换,那是适配器。用一句话概括:装饰器解决"能力不足"的问题,适配器解决"语言不通"的问题。
4.2 跟代理模式的区分:转换接口 vs 控制访问
代理模式和服务对象共享同一个接口,它在调用链中插入一个中间层,用来控制访问——比如权限校验、日志记录、延迟加载。代理不改变核心功能的输入和输出格式,它只是在调用前后做拦截。
适配器改变的是接口本身。代理对象和真实对象是"同一件事",适配器和被适配对象是"不同接口之间的桥梁"。
比如给某个接口加缓存,你写出一个代理实现同一个接口,里面先查缓存再走真实实现。这不是适配器,因为接口形态没变。又比如把旧版 SDK 的SendRequest改造成新版接口标准的SendMessage,这是适配器,因为接口形态变了。这两个概念在实际代码里经常被混用,但抓住"接口变没变"这个判断点就能分得清清楚楚。
4.3 跟外观模式的区分:转换接口 vs 统一门面
外观模式关注的是"简化",它把一组复杂的子系统调用集合起来,对外暴露一个简化接口。适配器关注的是"转换",它把一个接口映射到另一个接口。
外观模式和适配器的区别在于:外观模式通常面对的是一个子系统,不是单个类。它把多个步骤的操作合并成一个方法,客户端不需要关心内部有几个类的协作。适配器面对的是单个不兼容接口,核心动作是格式转换和语义映射。
用大白话说,外观模式是"把一堆操作打包成一个简易操作",适配器是"把你的话翻译成对方能听懂的话"。两者出发点完全不同,但实现形式上确实有相似之处,都是中介层思维。
我做了个对比表,方便大家快速查阅:
| 对比维度 | 适配器模式 | 装饰器模式 | 代理模式 | 外观模式 |
|---|---|---|---|---|
| 接口是否变化 | 是,A转成B | 否,保持原接口 | 否,保持原接口 | 是,统一简化 |
| 核心目的 | 解决不兼容 | 增强能力 | 控制访问 | 简化调用 |
| 对象关系 | 一对一转换 | 层层包装 | 一对一代理 | 一对多聚合 |
| 典型场景 | 对接外部SDK | 加缓存/加日志 | 权限拦截/远程代理 | 复杂子系统封装 |
| 变化焦点 | 接口形态 | 行为叠加 | 访问控制 | 调用方式 |
5. 工程落地中的避坑清单与实战心得
适配器模式看起来简单,真正落地的时候坑不少。我整理了一些实际踩过或者看别人踩过的问题,分享出来供大家参考。
5.1 判断"要不要用适配器模式"的标准
很多初学者容易犯的错误是到处用适配器模式,好像不套个模式就不专业一样。我在评审代码的时候见到过把两个自己定义的、明明可以兼容的接口强行加适配器的反面案例。判断是否需要适配器,标准就三条:
第一,接口确实不兼容,而且双方都不能方便地修改。被适配的代码可能是第三方库,可能是不想动的老系统,或者改动成本太高不敢碰。
第二,复用现有实现比重写更划算。如果旧代码实现已经很成熟,测试覆盖也够,只是接口跟新系统对不上,适配器是性价比最高的方案。
第三,客户端应该以稳定接口为依赖。如果客户端直接依赖具体实现类,将来任何一个底层变化都会影响客户端,适配器可以隔离这种变化。
如果三条都不满足,直接改接口或者直接调用就行,别为了用模式而用模式。过度设计比不设计更难受,这一点我在项目里体会特别深。
5.2 适配器别包得太厚
适配器的核心职责是转换,因此要做到扁平。一个适配器对应一个被适配者和一个目标接口,不要在里面堆太多转换逻辑。如果适配器的代码超过几十行甚至上百行,就要警觉了。
常见的不良设计是把多个不相关的转换需求塞进一个适配器。比如一个适配器既做短信服务接口转换,又做支付接口转换,这种"万能适配器"会让代码很臃肿,改一个场景还会影响另一个场景。一个适配器最好只做一件事。
另外,适配器层数不要太深。一个适配器转换一次就够了,除非你有明确的理由,否则不要写"适配器的适配器"。层级越深,追查问题越难。我见过三层适配器嵌套的代码,出了问题定位成本极高,最终还是重构了。
5.3 命名与组织:让代码自解释
适配器的命名直接关系到可维护性。我的习惯是:某某Adapter,让名字直接告诉你"这个类在适配什么"。比如BSmsAdapter一眼就知道是适配 B 公司短信服务的,LegacyUserStatusAdapter就知道是适配老系统用户状态的。避免用SmsAdapterUtil、CommonAdapter这种毫无信息量的名字。
放置位置上,适配器类独立成包,不要散落在业务代码里。我习惯用单独的adapter包存放所有适配器,业务包和适配器包通过接口解耦。这样每次新增或替换外部服务时,改动范围锁定在adapter包内,对业务代码的影响面最小。
5.4 单元测试的侧重点
适配器的测试重点应该放在"转换逻辑的准确性"上。我一般会按这几个维度写测试:
- 参数映射是否正确。不同业务场景是否映射到正确的模板 ID、正确的方法调用。
- 被适配对象返回异常时,适配器是否正确包装和传播异常。
- 适配器对边界值的处理。比如传入空字符串、特殊字符、非法场景值时的行为。
- 与真实被适配对象的集成测试,验证提交到目标接口的请求确实是符合预期的格式。
用 mock 对象测试适配器本身时,要注意 mock 的行为要与真实对象尽量一致,否则测试通过但线上出问题的情况很容易发生。
5.5 适配器模式的性能与异常传播
适配器模式本身几乎没有性能损耗,只是一层方法调用和参数转换。但如果适配器内部做了效率很低的事——比如每次发送都重新初始化 SDK 客户端——那问题就大了。适配器创建时应尽量复用底层资源,把昂贵的连接、客户端初始化放在适配器构造阶段完成,不要放进每次调用的方法里。
异常处理也是我见过的热门坑。适配器在转换异常时,尽量保证异常信息不丢失。我习惯用fmt.Errorf加%w的包裹方式(Go 语言中),把原始异常作为 cause 保留下来。这样排查问题时,既能知道适配器这层发生了什么,也能看到原始第三方 SDK 抛出的底层错误。
5.6 适配器模式的局限性:不是所有问题都该用它
适配器模式不是银弹,它有明显的局限性。第一,适配器增加了间接层,虽然隔离了变化,但也增加了问题排查的链路。第二,适配器做的是接口转换,不是语义修正。如果被适配对象本身有设计缺陷,适配器不能弥补。第三,过多适配器会掩盖系统的真实复杂度——当所有接口不兼容都靠适配器解决时,应该回头看看是不是统一建模出了问题。
在项目里,我发现适配器模式最健康的状态是"存在感很低"。客户端完全不感知适配器的存在,替换适配器对业务代码零影响。如果你发现适配器代码经常要改动,或者每次换外部服务都动到业务代码,说明适配器没设计到位,要重新审视接口边界了。
就拿短信服务这个场景来说,换成 B 公司之后跑了三个月,我只需要改一个配置文件和适配器本身的实现类。注册模块、找回密码模块的代码一个字都没动。这就是适配器模式的工程价值——让变化限制在最小范围内,让稳定的部分保持稳定。
我个人在实际项目中最深的体会是:设计模式不是知识题,而是取舍题。适配器模式给你的不是一段能抄的模板代码,而是一个判断框架——当两边对不上的时候,什么情况下值得加中间层,什么情况下应该干脆改一边。把这个判断能力练起来,比背一百个 UML 图都管用。