コーディング · デザインパターン
Adapter パターンを例でわかりやすく解説
結論
Adapter パターンは、既存のクラスのインターフェイスを、クライアントコードが期待するインターフェイスに変換する構造パターンです。Adapterは互換性のないオブジェクトをラップして、すべての呼び出しを変換します。たとえば charge(amountCents) を、レガシーな makePayment("12.50") に変えます。サードパーティやレガシーのコンポーネントを、変更せずに再利用でき、その扱いにくいAPIをコード全体に広げずに済みます。Wrapperとも呼ばれます。
目的
クラスのインターフェイスを、クライアントが期待する別のインターフェイスに変換します。Adapter パターンは、インターフェイスの互換性がないために共に動作できなかったクラス同士を、協調して動作できるようにします。Gang of Four(GoF)の書籍にある7つの構造パターンの1つです。
解決する問題
ショップのコードは、小さな PaymentGateway インターフェイスで動いています。charge(amountCents, currency) は、ok フラグと取引IDを含む結果を返します。ここで、ビジネス側が決済プロバイダーと契約しましたが、そのSDKはまったく違う形をしています。金額は10進数の文字列で、通貨はリクエストオブジェクトの一部であり、エラーは数値のステータスコードで返ってきます。
SDKは変更できませんし、新しい形に合わせてすべての呼び出し元を書き直したくもありません。SDKのスタイルが、チェックアウト、返金、レポートにまで漏れ出すと、後でプロバイダーを切り替えるときにコストが高くつきます。Adapterなら、変換を1つのクラスにとどめられます。
構造と登場要素
| 要素 | 役割 | 例では |
|---|---|---|
| Target | クライアントが期待するインターフェイスです。 | PaymentGateway |
| Adaptee | 互換性のないインターフェイスを持つ既存のクラスです。 | LegacyPayClient |
| Adapter | Targetを実装し、呼び出しをAdapteeに変換します。 | LegacyPayAdapter |
| Client | Targetインターフェイスだけを使います。 | 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後で別のプロバイダーに切り替えるときは、Adapterをもう1つ書くだけです。checkout とほかのすべての呼び出し元は変わらず、テストでは PaymentGateway のフェイクを渡せます。
オブジェクトAdapterとクラスAdapter
GoFの書籍は2つの形を紹介しています。
| 形 | 仕組み | 備考 |
|---|---|---|
| オブジェクトAdapter | Adapteeへの参照を保持して、委譲します(合成)。 | Adapteeとそのすべてのサブクラスで動作します。TypeScript、Java、C#では通常こちらを選びます。 |
| クラスAdapter | Adapteeを継承し、Targetのインターフェイスを実装します。 | Adapteeからの継承が必要なので、そのクラスだけに対応します。多重継承のあるC++でよく見られます。 |
TypeScriptやJavaScriptでは、関数を適応させるだけで十分なことが多くあります。Node.jsにはこの種の小さなAdapterが付属しています。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使うべきとき、使わないとき
次の場合はAdapter パターンを使います。
- 既存のクラスを使いたいが、そのインターフェイスがコードの必要とするものと合わない場合。
- サードパーティのライブラリやレガシーシステムを統合し、そのAPIをプログラムの境界に留めておきたい場合。
- 2つの決済プロバイダーのように、複数の異なる実装を1つのインターフェイスの背後に置きたい場合。
次の場合は避けます。
- 両側を自分で所有している場合。変換層を追加するより、インターフェイスの一方を変更するほうが通常は簡単です。
- Adapteeが本当にはサポートできないTargetインターフェイスの大部分を、Adapterが偽装しなければならない場合。本当の不一致を解決せず、隠すことになります。
実際の利用例
- Node.js:
util.promisify()は、コールバックベースの関数をPromiseに適応させます。 - Java:
Arrays.asList()は配列をListインターフェイスで使えるようにし、InputStreamReaderはバイトベースのInputStreamを文字ベースのReaderとして読めるようにします。 - データベースライブラリやORMは、データベースごとに1つのAdapterまたはドライバーを使うので、同じクエリAPIがPostgreSQL、MySQL、SQLiteで動きます。
- フロントエンドのコードでも、同じ理由で、ブラウザーAPIや分析SDKを、プロジェクト固有の小さなインターフェイスの背後にラップすることがよくあります。
関連するパターン
| パターン | Adapter パターンとの違い |
|---|---|
| Decorator パターン | 同じインターフェイスを保ち、振る舞いを追加します。Adapter パターンはインターフェイスを変え、新しい振る舞いは追加しません。 |
| Proxy パターン | 同じインターフェイスを保ち、オブジェクトへのアクセスを制御します(遅延読み込み、キャッシュ、権限)。 |
| Facade パターン | 多くのクラスからなるサブシステム全体に、新しいより単純なインターフェイスを定義します。Adapter パターンは、既存のインターフェイスを別の既存のインターフェイスに合わせます。 |
| Bridge パターン | 抽象とその実装を別々に変化させられるよう、最初から設計します。Adapter パターンは通常、既存のクラスを合わせるために後から追加します。 |
よくある質問
Adapter パターンとFacade パターンの違いは何ですか?
Adapterは、既存の1つのクラスを、クライアントがすでに期待しているインターフェイスに合わせます。Facadeは、多くのクラスからなる複雑なサブシステムの前に、新しいより単純なインターフェイスを作ります。
Adapter パターンとDecorator パターンの違いは何ですか?
どちらもオブジェクトをラップします。Decoratorは同じインターフェイスを保ち、振る舞いを追加します。Adapterはインターフェイスを変え、ラップされたオブジェクトを、本来使えない場所で使えるようにします。
Adapter パターンとラッパーは同じものですか?
GoFの書籍では、WrapperはAdapterの別名です。日常会話では、「ラッパー」はDecoratorやProxyにも使われるので、どのパターンを指すかは意図で判断します。
1つのAdapterで複数のクラスに対応できますか?
オブジェクトAdapterは、Adapteeとそのすべてのサブクラスで動作します。無関係なクラスを適応させる場合は、通常、クラスごとに1つのAdapterを用意し、すべてが同じTargetインターフェイスを実装します。