iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
自我挑戰組

一杯咖啡的設計課:30 天 Design Pattern 的自我修煉系列 第 16

Day 16|Adapter (轉接器模式) 用萬國轉接頭收服規格打架

  • 分享至 

  • xImage
  •  

昨天用 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.

用中文理解可以是

把一個既有類別原本的介面,轉換成客戶端預期使用的另一種介面,讓原本因為介面不相容、沒辦法搭配使用的類別,可以互相合作

核心角色

  1. Target(目標介面):用戶端目前定義好、期望使用的標準規格

  2. Adaptee(被轉接者):功能齊全但介面格式不合的既有類別或第三方函式庫

  3. 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,程式碼會越疊越高

這時候該是 Adapter 上場的時候了

我們定義一個統一的轉接介面 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 作為橋樑,讓雙方透過既有介面順暢協同運作


https://ithelp.ithome.com.tw/upload/images/20260914/20183470PW9A9Tp5bd.jpg
也恭喜柴咖啡正式接洽第三方的窗口,明天來聊柴咖啡的結帳流程,收銀、庫存、發票、通知,一次要串好幾個子系統,這種「把複雜流程包成一個簡單入口」的情境,輪到 Facade 外觀模式上場

參考資料

Refactoring Guru - Adapter

深入淺出設計模式(Design Pattern)-轉接器模式(7-Adapter-Pattern)

[ Day 12 ] 隨心所欲地重用不相容的類別~ - 轉接器模式 ( Adapter Pattern )


上一篇
Day 15|Decorator(裝飾器模式) 解開扣款後續任務的組合難題
下一篇
Day 17|Facade(外觀模式) 如何透過 Facade 打造收銀的單一窗口
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言