编码 · 设计模式
适配器模式详解与示例
简短回答
适配器是一种结构型设计模式,它把现有类的接口转换成客户端代码所期望的接口。适配器包装不兼容的对象并翻译每一次调用,例如把 charge(amountCents) 转换成遗留的 makePayment("12.50")。这样无需修改第三方或遗留组件,也不会让它别扭的 API 扩散到你的代码里,就能复用它。适配器也称为包装器(Wrapper)。
意图
将一个类的接口转换成客户端期望的另一个接口。适配器使原本因接口不兼容而无法一起工作的类可以协同工作。它是《设计模式》(四人组,GoF)一书中七种结构型模式之一。
它要解决的问题
你的商城代码使用一个很小的 PaymentGateway 接口:charge(amountCents, currency) 返回包含 ok 标志和交易 ID 的结果。现在公司和一家支付服务商签了合同,它的 SDK 完全是另一种风格:金额是十进制字符串,币种属于请求对象的一部分,错误则以数字状态码返回。
你改不了这个 SDK,也不想把每个调用方都改成新的形式。如果 SDK 的风格渗透到结账、退款和报表中,以后更换服务商的代价就会很高。适配器把这种转换集中在一个类里。
结构与参与者
| 参与者 | 职责 | 示例中的对应 |
|---|---|---|
| 目标(Target) | 客户端所期望的接口。 | PaymentGateway |
| 被适配者(Adaptee) | 接口不兼容的现有类。 | LegacyPayClient |
| 适配器(Adapter) | 实现目标接口,并把调用翻译给被适配者。 | LegacyPayAdapter |
| 客户端(Client) | 只使用目标接口。 | checkout |
TypeScript 实现
// Target: what our code expects.
interface PaymentGateway {
charge(amountCents: number, currency: string): { ok: boolean; id: string };
}
// Adaptee: a third-party SDK we cannot change.
class LegacyPayClient {
makePayment(req: { amount: string; curr: string }): { status: number; ref: string } {
return { status: req.curr === "USD" ? 200 : 422, ref: `TX-${req.amount}` };
}
}
// Adapter: implements the Target by translating to the Adaptee.
class LegacyPayAdapter implements PaymentGateway {
constructor(private client: LegacyPayClient) {}
charge(amountCents: number, currency: string) {
const res = this.client.makePayment({
amount: (amountCents / 100).toFixed(2),
curr: currency.toUpperCase(),
});
return { ok: res.status === 200, id: res.ref };
}
}
// Client code knows nothing about LegacyPayClient.
function checkout(gateway: PaymentGateway, totalCents: number): string {
const result = gateway.charge(totalCents, "usd");
return result.ok ? `paid ${result.id}` : "payment failed";
}
console.log(checkout(new LegacyPayAdapter(new LegacyPayClient()), 1250)); // paid TX-12.50以后换用其他服务商,只需再写一个适配器。checkout 和其他所有调用方都保持不变,测试时也可以传入一个假的 PaymentGateway。
对象适配器与类适配器
GoF 一书描述了两种形式:
| 形式 | 工作方式 | 说明 |
|---|---|---|
| 对象适配器 | 持有被适配者的引用并委托给它(组合)。 | 可以配合被适配者及其所有子类使用。是 TypeScript、Java 和 C# 中常用的选择。 |
| 类适配器 | 继承被适配者,并实现目标接口。 | 需要继承被适配者,因此只能适配那一个类。常见于支持多重继承的 C++。 |
在 TypeScript 和 JavaScript 中,适配一个函数往往就够了。Node.js 自带了一个这类小适配器:util.promisify() 能把回调风格的函数转换成返回 promise 的函数。
import { promisify } from "node:util";
// Adaptee: old callback style (error first, then result).
function readConfig(name: string, cb: (err: Error | null, value?: string) => void): void {
setTimeout(() => cb(null, `config for ${name}`), 10);
}
// Adapted: the same function, now with a promise-based interface.
const readConfigAsync = promisify(readConfig);
console.log(await readConfigAsync("app")); // config for app何时使用,何时不用
以下情况适合使用适配器:
- 你想使用一个现有的类,但它的接口与你的代码所需要的不匹配。
- 你要集成第三方库或遗留系统,并希望把它们的 API 限制在程序的边缘。
- 你希望把多个不同的实现(比如两家支付服务商)放在同一个接口之后。
以下情况应避免使用:
- 两边的代码都归你管。修改其中一个接口,通常比增加一层转换更简单。
- 适配器要伪造被适配者其实无法支持的大部分目标接口。这样只是掩盖了真正的不匹配,而不是解决它。
实际应用
- Node.js:
util.promisify()把基于回调的函数适配成 promise。 - Java:
Arrays.asList()让数组可以通过List接口来使用,InputStreamReader让基于字节的InputStream可以当作基于字符的Reader来读取。 - 数据库库和 ORM 为每种数据库使用一个适配器或驱动,使同一套查询 API 能用于 PostgreSQL、MySQL 或 SQLite。
- 前端代码出于同样的原因,常常把浏览器 API 或统计 SDK 包装在项目自己定义的小接口后面。
相关模式
| 模式 | 与适配器的区别 |
|---|---|
| 装饰 | 保持接口不变并增加行为。适配器改变接口,不增加新行为。 |
| 代理 | 保持接口不变,并控制对对象的访问(懒加载、缓存、权限)。 |
| 外观 | 为由许多类组成的整个子系统定义一个新的、更简单的接口。适配器则是让一个现有接口匹配另一个现有接口。 |
| 桥接 | 在设计之初就让抽象与实现可以各自独立变化。适配器通常是事后添加的,用来让现有的类能够配合使用。 |
常见问题
适配器与外观有什么区别?
适配器让一个现有的类匹配客户端已经期望的接口。外观则是在由许多类组成的复杂子系统前面,创建一个新的、更简单的接口。
适配器与装饰有什么区别?
两者都包装一个对象。装饰保持接口不变并增加行为;适配器则改变接口,使被包装的对象能用在原本无法使用的地方。
适配器和包装器是一回事吗?
在 GoF 一书中,包装器(Wrapper)就是适配器的另一个名字。日常说法里,“包装器”也用来指装饰和代理,所以要靠意图来判断指的是哪个模式。
一个适配器可以适配多个类吗?
对象适配器可以配合一个被适配者及其所有子类使用。要适配互不相关的类,通常是每个类一个适配器,并且都实现同一个目标接口。