昨天用 Decorator 把「扣款成功後」的收尾動作(開發票、印明細單)一層層包起來,不去動原本的扣款流程,今天我們繼續留在結構型 Pattern(Structural)
來聊聊 Adapter 轉接器模式
原文的定義,來自 Design Patterns: Elements of Reusable Object-Oriented Software
Convert the interface of a class into another interface clients expect. Adapter lets classes work together that couldn't otherwise because of incompatible interfaces.
用中文理解可以是
把一個既有類別原本的介面,轉換成客戶端預期使用的另一種介面,讓原本因為介面不相容、沒辦法搭配使用的類別,可以互相合作
核心角色
Target(目標介面):用戶端目前定義好、期望使用的標準規格
Adaptee(被轉接者):功能齊全但介面格式不合的既有類別或第三方函式庫
Adapter(轉接器):實作 Target 介面,並在內部呼叫 Adaptee 的方法進行轉接
先舉個生活化的例子,身為一個專業的社畜,出國也一定會帶筆電
台灣筆電充電器是 Type A 扁腳規格,只認得台灣 110V 插座
到了歐洲飯店,牆上全都是 Type C 雙圓孔插座
兩者孔洞形狀不符,但我們不會為了一趟旅行把筆電充電器的線剪斷、重做一個歐規插頭(不會吧?)
這時只要在中間加一顆萬國轉接頭,問題就解決了
轉接器模式的核心精神就是:在不修改現有程式碼的前提下,讓原本因介面不相容而無法合作的物件能夠正常協同運作
using System;
// 1. Target(呼叫端(Client)眼中認定的標準規格:台灣插座孔 110v )
public interface ITwSocket
{
void ProvidePowerTypeA();
}
// 2. Adaptee(既有的國外物件:歐洲插座,方法名稱與規格完全不同)
public class EuropeSocket
{
public void SupplyPowerTypeC()
{
Console.WriteLine("歐洲插座(Type C)輸出電源");
}
}
// 3. Adapter(轉接頭:對外提供台灣孔洞,內部實際對接歐洲插座)
public class EUSocketAdapter : ITwSocket
{
private readonly EuropeSocket _europeSocket;
public EUSocketAdapter(EuropeSocket europeSocket)
// 透過依賴注入(DI)持有被轉接者:在建構子傳入 EuropeSocket 並以私有欄位 _europeSocket 保存,代表轉接器「擁有」這個外部物件
{
_europeSocket = europeSocket;
}
public void ProvidePowerTypeA()
{
// 將「台灣 Type A 的用電請求」轉送給「歐洲 Type C 插座」處理
_europeSocket.SupplyPowerTypeC();
}
}
// 4. Client(呼叫端:筆電的充電器,它只認得 ITwSocket)
public class LaptopCharger
{
public void Charge(ITwSocket socket)
{
// 筆電插上插座,要求供電
socket.ProvidePowerTypeA();
Console.WriteLine("筆電成功充飽電!");
}
}
實際運作
我身在台灣家裡,充電器直接插上台灣插座 110v;到了歐洲,充電器不需要做任何改動,只要把轉接頭遞給它即可
class Program
{
static void Main()
{
LaptopCharger myLaptop = new LaptopCharger();
// 到了歐洲:牆上只有歐洲插座
EuropeSocket hotelWallSocket = new EuropeSocket();
// 套上轉接頭,轉接頭包裝了歐洲插座,對外偽裝成台灣插座(ITwSocket)
ITwSocket adapter = new EUSocketAdapter(hotelWallSocket);
// 筆電充電器照常使用它熟悉的介面,順利在歐洲充電
myLaptop.Charge(adapter);
}
}
這樣寫,也符合開放封閉原則(OCP),未來如果要飛英國(Type G 插座),只需要再寫一個 UKSocketAdapter : ITwSocket
筆電(client)的 LaptopCharger 程式碼都不用改動
柴咖啡生意越來越好了,為了讓業績提高,小黑也談成了新生意:跟 Qber Eats、foodDog 合作上架,開始接外送訂單
阿柴內部的訂單處理邏輯,一直以來只認得自己定義的 Order
// 內部訂單狀態,只有這三種
public enum OrderStatus
{
Pending, // 待處理
Preparing, // 製作中
Completed // 已完成
}
// 柴咖啡內部的標準訂單
public class Order
{
public string OrderId { get; set; }
public OrderStatus Status { get; set; }
public int TotalPrice { get; set; }
public List<OrderItem> Items { get; set; }
}
public class OrderItem
{
public string DrinkName { get; set; }
public int Quantity { get; set; }
}
// 廚房只認得自己內部的 Order,不管訂單是從哪裡來的,專注處理出單與扣庫存
public class KitchenService
{
public void ReceiveOrder(Order order)
{
Console.WriteLine($"廚房收到訂單 {order.OrderId},共 {order.Items.Count} 項,總金額 {order.TotalPrice} 元,狀態:{order.Status}");
// 這裡會扣庫存、印出單、通知製作
}
}
但 Qber Eats、foodDog 傳過來的訂單資料格式,長得完全不一樣,連「訂單狀態」的表示方式都各自不同
// Qber Eats 傳來的格式: 用數字代碼代表狀態
public class QberEatsOrderPayload
{
public string qe_order_id { get; set; }
public int total_price { get; set; }
public int status { get; set; } // 數字代碼:0 待處理 / 1 製作中 / 2 已完成
public List<QberEatsProduct> products { get; set; }
}
public class QberEatsProduct
{
public string title { get; set; }
public int qty { get; set; }
}
// foodDog 傳來的格式: 用英文字串代表狀態
public class FoodDogOrderPayload
{
public string order_no { get; set; }
public int amount_twd { get; set; }
public string status { get; set; } // 英文字串:"pending" / "preparing" / "completed"
public List<FoodDogItem> items { get; set; }
}
public class FoodDogItem
{
public string name { get; set; }
public int count { get; set; }
}
離上架時間很近,阿柴為了先求有,直接在接單的 Controller 裡面,用 if-else 依平台各自解析
public class OrderController
{
public void HandleIncomingOrder(string platform, object payload)
{
Order order;
if (platform == "Qbereats")
{
var uePayload = (QberEatsOrderPayload)payload;
order = new Order
{
OrderId = uePayload.qe_order_id,
Items = uePayload.products
.Select(p => new OrderItem { DrinkName = p.title, Quantity = p.qty })
.ToList(),
TotalPrice = uePayload.total_price,
Status = uePayload.status switch // 數字代碼轉內部 enum,得自己記得對照表
{
0 => OrderStatus.Pending,
1 => OrderStatus.Preparing,
2 => OrderStatus.Completed,
_ => throw new Exception("未知的狀態代碼")
}
};
}
else if (platform == "foodDog")
{
var fpPayload = (FoodDogOrderPayload)payload;
order = new Order
{
OrderId = fpPayload.order_no,
Items = fpPayload.items
.Select(i => new OrderItem { DrinkName = i.name, Quantity = i.count })
.ToList(),
TotalPrice = fpPayload.amount_twd,
Status = fpPayload.status switch // 換一套字串又要再對照一次
{
"pending" => OrderStatus.Pending,
"preparing" => OrderStatus.Preparing,
"completed" => OrderStatus.Completed,
_ => throw new Exception("未知的狀態字串")
}
};
}
else
{
throw new Exception("不支援的平台");
}
new KitchenService().ReceiveOrder(order);
}
}
雖然準時上線,但阿柴後來自己看了都皺眉:格式轉換邏輯跟「收單後要通知廚房」這件業務邏輯,全部黏在同一支 HandleIncomingOrder 裡
整套乾淨的核心邏輯馬上就會被各家外部格式污染得一塌糊塗
若下個月小黑又談成了第三家外送平台,這代表 HandleIncomingOrder 又要多一個 else if,程式碼會越疊越高
我們定義一個統一的轉接介面 IOrderAdapter,讓每一家外送平台都有一個專屬的轉接器,負責把外部 Payload 翻譯清洗成乾淨的內部 Order
public interface IOrderAdapter
{
Order ToOrder();
}
// 針對每個平台,各自寫一個轉接器,只做「格式轉換」這一件事
// Qber Eats 專用轉接器:數字轉 Enum
public class QberEatsOrderAdapter : IOrderAdapter
{
private readonly QberEatsOrderPayload _payload;
public QberEatsOrderAdapter(QberEatsOrderPayload payload)
{
_payload = payload;
}
public Order ToOrder()
{
return new Order
{
OrderId = _payload.qe_order_id,
Items = _payload.products
.Select(p => new OrderItem { DrinkName = p.title, Quantity = p.qty })
.ToList(),
TotalPrice = _payload.total_price,
Status = _payload.status switch
{
0 => OrderStatus.Pending,
1 => OrderStatus.Preparing,
2 => OrderStatus.Completed,
_ => throw new Exception("未知的狀態代碼")
}
};
}
}
// foodDog 專用轉接器:字串轉 Enum
public class FoodDogOrderAdapter : IOrderAdapter
{
private readonly FoodDogOrderPayload _payload;
public FoodDogOrderAdapter(FoodDogOrderPayload payload)
{
_payload = payload;
}
public Order ToOrder()
{
return new Order
{
OrderId = _payload.order_no,
Items = _payload.items
.Select(i => new OrderItem { DrinkName = i.name, Quantity = i.count })
.ToList(),
TotalPrice = _payload.amount_twd,
Status = _payload.status switch
{
"pending" => OrderStatus.Pending,
"preparing" => OrderStatus.Preparing,
"completed" => OrderStatus.Completed,
_ => throw new Exception("未知的狀態字串")
}
};
}
}
廚房出單系統只需要向轉接器要資料,不需要理會這張單來自哪個平台:
class Program
{
static void Main()
{
KitchenService kitchen = new KitchenService();
// 1. 模擬收到 Qber Eats 的 Webhook 資料
var qberData = new QberEatsOrderPayload
{
qe_order_id = "QE-88899",
status = 1,
total_price = 160,
products = new List<QberEatsProduct>
{
new QberEatsProduct { title = "美式咖啡", qty = 1 },
new QberEatsProduct { title = "肉桂捲", qty = 1 }
}
};
IOrderAdapter qberAdapter = new QberEatsOrderAdapter(qberData);
kitchen.ReceiveOrder(qberAdapter.ToOrder());
// 2. 模擬收到 foodDog 的 Webhook 資料
var foodDogData = new FoodDogOrderPayload
{
order_no = "FD-12345",
status = "pending",
amount_twd = 120,
items = new List<FoodDogItem>
{
new FoodDogItem { name = "柴拿鐵", count = 1 }
}
};
IOrderAdapter foodDogAdapter = new FoodDogOrderAdapter(foodDogData);
kitchen.ReceiveOrder(foodDogAdapter.ToOrder());
}
}
假設下個月真的要串第三家平台,也只是新增一個 XxxOrderAdapter,不用回頭改任何既有程式碼
Adapter 只做格式轉換,不要把業務邏輯塞進去:不能在 QberEatsOrderAdapter.ToOrder() 裡面順手扣庫存或發通知,那是 KitchenService 的責任
Adapter 一旦身兼轉換又管業務邏輯,以後平台格式一改版,連業務邏輯都要跟著重新測試一次
代碼、格式這類對照規則,要收斂在 Adapter 內部處理乾淨
像「Qber Eats 的 2 代表已完成、foodDog 的 "completed" 也代表已完成」這種對照表
一定要包在 Adapter 裡面完成,不能讓 KitchenService 還要自己記得每個平台各自的代碼規則,不然這種隱性規則遲早會被漏掉
多用組合,少用繼承:C# 沒辦法多重繼承,上面的 Adapter 都是用組合(建構子把原始物件包進來)去實作,而不是嘗試去繼承第三方類別再想辦法改介面
Adapter 出手的時機,是呼叫端的介面必須保持穩定,但外部依賴無法控制、且預期會持續變動
外送平台的資料格式是對方公司說改就改,我們無法控制
這種「沒辦法控制、又預期會變動」的外部依賴,就很適合用一層 Adapter 作為橋樑,讓雙方透過既有介面順暢協同運作

也恭喜柴咖啡正式接洽第三方的窗口,明天來聊柴咖啡的結帳流程,收銀、庫存、發票、通知,一次要串好幾個子系統,這種「把複雜流程包成一個簡單入口」的情境,輪到 Facade 外觀模式上場
深入淺出設計模式(Design Pattern)-轉接器模式(7-Adapter-Pattern)
[ Day 12 ] 隨心所欲地重用不相容的類別~ - 轉接器模式 ( Adapter Pattern )