少女祈祷中...
残云的学习笔记

适配器模式:类适配器与对象适配器的取舍,以及它在 JDK 与 Spring 里的真实形态

一、问题的起点:两边都不该改

写业务代码时,接口对不上是常态。下面三个场景几乎每个项目都会遇到:

场景一:老接口还在服役。 系统里几十处调用点都在用 OldLogger.log(level, msg)。你新引入的日志规范是 info/warn/error,语义更清楚,但你不可能一夜之间改掉全公司的调用点。

场景二:第三方 SDK 只能听它的。 支付 SDK 暴露的是 doPay(long orderId, long cents, String currency)——金额用「分」、订单号是数字。而你的核心域里,金额是 BigDecimal(元)、订单号是 "A-2026" 这样的字符串。SDK 是 jar 包,改不了。

场景三:新旧两套 API 并存。 老代码到处用 Enumeration,新代码统一用 Iterator。两边都在跑,谁也不想动。

这三个场景的共同点是:调用方想要的接口是对的,被调用方提供的接口也是对的,两边都不该改。

  • 想改调用方?几十个调用点、已有测试、别的团队在用——改造成本和风险都在你这边。
  • 想改被调用方?第三方 jar 包改不了;老系统动了可能连带一片。
  • 想复制一份代码、把逻辑抄成两套?那从此之后两个版本会各自演化,修 bug 要修两遍。

既然两边都不动,那就只能在接缝处加一层:把「一边的接口」翻译成「另一边能听懂的形式」。这一层就是适配器(Adapter)。

二、适配器的三种形态

适配器不是只有一种写法。按「怎么拿到被适配者」来分,有三种形态;再加一种 Java 特有的历史形态:

adapter-1-three-forms

形态怎么拿到 Adaptee典型场景
类适配器extends Adaptee + implements Target被适配者是一个类的实现,且你希望适配器以 Adaptee 的身份出现在某些调用里
对象适配器implements Target + 字段持有 Adaptee绝大多数场景,默认选它
双向适配器同时 implements Target, Adaptee新老系统双向调用、过渡期
默认适配器(缺省适配器)为大接口提供全部空实现MouseAdapter、WindowAdapter 这类 JDK 里的"填平"类

前三者的差别只在类之间的关系上,行为上完全等价——它们对调用方暴露的都是同一个 Target 接口。所以选型的重点不是"能不能用",而是"哪一种更好改、更好测、更不容易出事故"。

三、结构:三个角色与一次调用

不管你选哪种形态,结构都由三个角色组成:

  • Target(目标接口):调用方唯一认识的东西。
  • Adaptee(被适配者):已经存在、接口不兼容的那个实现。
  • Adapter(适配器):实现 Target,把请求翻译成 Adaptee 听得懂的形式。

以场景二为例(支付 SDK),对象适配器的结构是这样:

adapter-2-structure

一次调用里发生了什么:

adapter-3-sequence

要点只有一个:转换只发生在适配器里。 客户端不知道 LegacyPaySdk 存在,LegacyPaySdk 也不知道自己正在被适配——两边都是干净的。

下面是可直接运行的单文件版本(对象适配器):

import java.math.BigDecimal;

/** ① 目标接口:核心域只认识它 */
interface PaymentGateway {
    void pay(String orderNo, BigDecimal amount);
}

/** ② 被适配者:第三方 SDK,金额用「分」、订单号是 long,签名完全对不上 */
class LegacyPaySdk {
    public boolean doPay(long orderId, long cents, String currency) {
        System.out.printf("[SDK] 订单 %d,金额 %d %s分%n", orderId, cents, currency);
        return true;
    }
}

/** ③ 对象适配器:实现目标接口,持有被适配者,负责翻译 */
class PaySdkAdapter implements PaymentGateway {
    private final LegacyPaySdk sdk;              // 组合,而不是继承

    PaySdkAdapter(LegacyPaySdk sdk) {
        this.sdk = sdk;                          // 运行时可以换实现(甚至可以传子类)
    }

    @Override
    public void pay(String orderNo, BigDecimal amount) {
        long orderId = Long.parseLong(orderNo.substring(2));        // "A-2026" → 2026
        long cents = amount.movePointRight(2).longValueExact();     // 19.90 元 → 1990
        if (!sdk.doPay(orderId, cents, "CNY")) {
            throw new IllegalStateException("支付失败:" + orderNo);  // 把 SDK 的失败码翻译成异常
        }
    }
}

public class ObjectAdapterDemo {
    public static void main(String[] args) {
        PaymentGateway gateway = new PaySdkAdapter(new LegacyPaySdk());
        gateway.pay("A-2026", new BigDecimal("19.90"));   // 调用方全程只认识 PaymentGateway
    }
}

注意 pay() 里做的事:参数转换(订单号、金额单位)、调用转换(方法名与返回值)、错误转换(false → 异常)。这三件事就是适配器的全部职责,也是它容易藏 bug 的地方——尤其是金额这种单位换算,一定要有测试兜住。

四、类适配器:继承能给你什么,又会拿走什么

类适配器用继承拿 Adaptee 的实现,同时用实现(interface)对上 Target:

adapter-4-class-adapter

/** 被适配者:老日志实现 */
class OldLogger {
    public void log(int level, String msg) {
        System.out.println("[LV" + level + "] " + msg);
    }
}

/** 目标接口:新代码想用的样子 */
interface AppLogger {
    void info(String msg);
    void error(String msg);
}

/** 类适配器:继承拿到实现,实现接口对上签名 */
class LoggerClassAdapter extends OldLogger implements AppLogger {
    @Override public void info(String msg)  { log(3, msg); }
    @Override public void error(String msg) { log(1, msg); }
}

public class ClassAdapterDemo {
    public static void main(String[] args) {
        AppLogger logger = new LoggerClassAdapter();
        logger.info("启动完成");
        logger.error("连接失败");

        // 代价一:适配器在类型上"是"一个 OldLogger,
        // 调用方可以绕过 Target 直接调老接口 —— "只依赖目标接口"的约束被绕过了
        ((OldLogger) logger).log(2, "绕过目标接口直接调用老方法");
    }
}

类适配器在支持多重继承的语言(C++)里最自然:既可以继承 Adaptee 的实现,又可以继承 Target 的抽象。Java 只有单继承,于是只能「继承 Adaptee + 实现 Target」,这就带来四个具体代价:

  1. 只能适配一个 Adaptee。 单继承意味着 extends 后面只能写一个类。要同时适配两个老接口,就得写两个适配器类;对象适配器可以用一个类持有多个 Adaptee。
  2. Adaptee 的全部公开方法都会暴露。 适配器是 Adaptee 的子类型,调用方拿到 LoggerClassAdapter 后可以直接调 log(),适配层形同虚设。而对象适配器只暴露 Target 上的方法。
  3. 无法在运行时替换被适配者。 继承了谁,编译期就定死了。对象适配器可以构造器/Setter 注入,测试时换成 mock、线上换成真实实现。
  4. 覆写 Adaptee 的方法会踩"脆弱基类"的坑。 Adaptee 内部的自调用(this.xxx())会落到你覆写后的版本上,行为可能悄悄改变——这是继承体系里最经典的事故来源。

所以:除非你确实需要"适配器同时是 Adaptee"这个身份(比如某些框架要求你传入 Adaptee 类型的实例),否则用对象适配器。

五、对象适配器:组合才是默认答案

对象适配器的写法就是前面支付 SDK 那段代码,这里把它的好处集中列一下:

好处说明
可以适配多个 Adaptee一个类里持有多个字段,把多个老接口收拢到一个目标接口下
可以适配 Adaptee 的子类字段类型声明为 Adaptee,运行时传任意子类进去
不暴露 Adaptee 的方法适配器只实现 Target,调用方看不到老接口
便于测试构造器注入,单测里传一个假的 Adaptee,不需要真实 SDK
不依赖继承层次Adaptee 内部怎么改、怎么继承,都不影响适配器

一句话总结取舍:类适配器是"我变成你",对象适配器是"我拿着你"。 前者省一次转发,后者省一堆麻烦。

六、双向适配器:新老并行的过渡期

还有一种少见但有用的形态:新系统要通过回调调用老系统,老系统也要主动调用新系统——两个方向都要通。

/** 新系统这一侧的接口(我们实现的) */
interface NewService {
    void handle(String event);
}

/** 老系统那一侧的接口(别人期待的) */
interface OldListener {
    void onEvent(String code, int type);
}

/** 双向适配器:两边的接口都实现,互相转发 */
class TwoWayAdapter implements NewService, OldListener {
    private final NewService self;        // 新系统一侧
    private final OldListener legacy;     // 老系统一侧

    TwoWayAdapter(NewService self, OldListener legacy) {
        this.self = self;
        this.legacy = legacy;
    }

    @Override public void handle(String event) {          // 新 → 老
        legacy.onEvent(event, 1);
    }

    @Override public void onEvent(String code, int type) { // 老 → 新
        self.handle(code);
    }
}

一个经验判断:双向适配器出现的地方,通常就是一段过渡期设计。 它本身没问题,但要给它写清楚退役条件(比如"老系统下线后删除"),否则它会变成两套系统之间永久的耦合点。

七、默认适配器:MouseAdapter 与它的现代替代

JDK 里有一类类名叫 XXXAdapter 的东西,它们遵循的是同一种思路:用一个类把大接口的全部方法填成空实现,让实现者只覆写自己关心的方法。这就是「默认适配器(缺省适配器)」。

adapter-5-default-adapter

// 没有 MouseAdapter 的话,只监听一个点击事件也要写满 5 个方法
JButton button = new JButton("点我");
button.addMouseListener(new MouseAdapter() {          // 只覆写关心的那个
    @Override public void mouseClicked(MouseEvent e) {
        System.out.println("clicked");
    }
});

// Java 8 之后:接口可以给 default 实现,这类空实现类就不再必要了
interface MyListener {
    default void onStart() { }
    default void onStop()  { }
    default void onTick()  { }
}

这里有两点值得记住:

  • AWT/Swing 里的 MouseAdapter、KeyAdapter、WindowAdapter 都是这个套路(WindowListener 有 7 个方法,不填平没法用)。它们让"接口方法多"这件事不再痛苦。
  • Java 8 之后优先用 default 方法,不要再新增 XXXAdapter 空实现类。JDK 自己没有把 MouseListener 改成 default(为了二进制兼容,改了会让老实现类编译报错),但你新写的接口没必要再走老路。

八、JDK 与框架里的适配器

适配器是框架里出现频率最高的结构型模式之一——因为框架永远要面对"多种实现,一个入口"。

位置目标接口(统一的样子)被适配者(各式各样的实现)说明
InputStreamReader / OutputStreamWriterReader / Writer(字符流)InputStream / OutputStream(字节流)字节 ↔ 字符 + 编码转换,构造器接收 Charset
Arrays.asList(T...)List数组数组 → List 视图;长度固定,add/remove 抛 UnsupportedOperationException
Collections.enumeration(Collection)Enumeration(老 API)Collection / Iterator(新 API)为兼容老接口而存在
JDBC java.sql.DriverDriver各厂商驱动实现MySQL、PostgreSQL、Oracle 的驱动各自适配到同一套接口
Spring MVC HandlerAdapterHandlerAdapter@Controller、HttpRequestHandler、Servlet 等多种处理器DispatcherServlet 只认识 HandlerAdapter,其余交给适配器去调
SLF4J 绑定(logback、log4j-slf4j)org.slf4j.Loggerlogback / log4j 的实现门面 + 适配/绑定,换日志实现不动业务代码
// 字节流 → 字符流:同一个 InputStream,套上适配器就变成 Reader
try (Reader reader = new InputStreamReader(
        new FileInputStream("data.txt"), StandardCharsets.UTF_8)) {
    char[] buf = new char[1024];
    while (reader.read(buf) != -1) {
        // 这里处理的是字符,不再是字节
    }
}

// 数组 → List 视图
List<String> list = Arrays.asList("a", "b", "c");
// list.add("d");   // 运行期抛 UnsupportedOperationException:它只是视图,不是新集合

怎么判断一个类名带 Adapter 的类是不是适配器模式? 别看名字,看它有没有在转换接口:如果它把一个接口的调用翻译成另一个接口能听懂的调用,就是适配器;如果它只是给现有接口加了点行为(签名没变),那是装饰器;如果它把一堆子系统收成一个门面(接口变少、变简单),那是外观。

九、适配器 vs 装饰器 vs 外观

这三个模式最容易混,因为它们都表现为"包一层"。区分它们的唯一标准是:接口签名有没有变、往哪个方向变。

adapter-6-three-patterns

模式接口签名目的关系与形态
适配器改变(翻译成另一边认得的形式)让不兼容的接口能协作1 个被适配者(双向适配器是 2 个方向)
装饰器不变在不改签名的前提下叠加行为1 个被装饰者,可层层嵌套
代理不变控制对真实对象的访问1 个真实对象,通常单层
外观简化(多个接口收成一个)降低调用复杂度N 个子系统

拿上一篇代理模式里那张三列图对照着看会更清楚:代理和装饰器的接口都不变(一个管"能不能进来",一个管"进来之后多做什么");适配器一定会改接口(因为它面对的就是两个不兼容的接口);外观是把多个接口收成一个(它不翻译细节,只是少暴露)。

十、什么时候不要用适配器

适配器是"给改不动的代码兜底"的工具,不是"让代码看起来整齐"的工具。下面几种情况,加适配器是错的方向:

  1. 两边都能改。 那直接统一接口,别加中间层。适配器的价值全部来自"有一边改不动"。
  2. 只是想遮住一个丑接口。 那是外观(Facade)或直接重构的活,不是适配器——适配器的目标是"协作",不是"好看"。
  3. 在同一个接缝上叠了三层以上适配器。 调用链变长、异常栈难读、调试成本上升。性能一般可以忽略(一次方法转发),但 IO 密集路径上要实测。
  4. 用适配器掩盖接口设计问题。 如果五个适配器都围绕同一个接口转,说明真正该抽象的是领域层的接口,而不是在每个调用点加转换。
  5. 双向适配器没有退役计划。 过渡期设计要写清楚"什么条件满足后删除",否则两套系统的耦合会永久固化。

一个反过来的判断标准:如果删掉适配器,改动量是否只需几行? 是的话,直接改;只有当改动会波及大量调用点、别人的模块、或不可修改的第三方代码时,适配器才划算。

十一、参考资料

  • GoF《设计模式:可复用面向对象软件的基础》第 4 章 Adapter(类适配器与对象适配器的原始讨论)
  • 《Head First 设计模式》第 7 章 Adapter and Facade
  • JDK 源码:java.io.InputStreamReader、java.util.Arrays#asList、java.util.Collections#enumeration、java.awt.event.MouseAdapter
  • Spring Framework:org.springframework.web.servlet.HandlerAdapter 及其多个实现
  • SLF4J 官方文档中关于「绑定(binding)/适配」的说明
  • 本系列相关篇目:《设计模式(Java)· 代理模式》——其中有代理、装饰器、适配器三者的三列对比图
分享到

评论