iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
自我挑戰組

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

Day 18|Proxy (代理模式) 天啊!是替身攻擊!

  • 分享至 

  • xImage
  •  

昨天用 Facade 把結帳流程包成一個簡單入口,今天我們繼續留在結構型 Pattern(Structural)

來聊聊 Proxy 代理模式

原文的定義,一樣來自 Design Patterns: Elements of Reusable Object-Oriented Software

Provide a surrogate or placeholder for another object to control access to it.

用中文理解可以是

提供一個替身或占位物件,來控制對另一個物件的存取

是不是很像替身攻擊(?)

Proxy 讓呼叫端拿到一個看起來跟物件一模一樣的東西,但實際上每次呼叫都先經過這個替身,由替身決定要不要真的把工作轉交給本尊、什麼時候轉交

我們來看看 UML 圖
https://ithelp.ithome.com.tw/upload/images/20260916/20183470672T6RQXsU.jpg

核心結構與觀念

  1. Subject(共同介面):呼叫端只認識這個介面,分不出來拿到的是本尊還是代理
  2. RealSubject(本尊):真正做事的物件,本業邏輯都寫在這裡
  3. Proxy(代理):實作跟 RealSubject 相同的介面,內部持有一個 RealSubject,自己決定要不要轉呼叫、什麼時候轉呼叫

以前讀大學的時候,偶爾會辦校園晚會,辦校園晚會,就會需要請藝人(誰想看學生在台上跳舞)

但大多數藝人不會自己去接每一通商演邀約的電話,中間都會有一個經紀人

預算不夠的案子,經紀人直接回絕,藝人根本不會知道有這通邀約

預算夠的案子,經紀人才會真的去找藝人談檔期

// 1. 共同介面 (Subject)
public interface IPerformer
{
    void Perform(string eventName, int budget);
}

// 2. 真實藝人 (RealSubject):出場成本高,專注演出
public class Celebrity : IPerformer
{
    private readonly string _name;

    public Celebrity(string name)
    {
        _name = name;
        Console.WriteLine($"[{_name}] 搭專車抵達現場,準備登台!");
    }

    public void Perform(string eventName, int budget)
    {
        Console.WriteLine($"[{_name}] 正在「{eventName}」熱情開唱!\n");
    }
}
// 3. 經紀人 (Proxy):控制存取、過濾預算、延遲建立藝人
public class AgentProxy : IPerformer
{
    private readonly string _celebrityName;
    private readonly int _minBudget;
    private Celebrity? _realCelebrity; // 延遲建立,未接案前不會有這個物件

    public AgentProxy(string celebrityName, int minBudget)
    {
        _celebrityName = celebrityName;
        _minBudget = minBudget;
    }

    public void Perform(string eventName, int budget)
    {
        Console.WriteLine($"經紀人收到商演邀約:「{eventName}」,開價:{budget} 萬");

        // 保護代理:預算不夠,直接擋下,藝人完全不用出面
        if (budget < _minBudget)
        {
            Console.WriteLine($"❌ 經紀人回絕:預算不足 {_minBudget} 萬,不打擾藝人休息\n");
            return;
        }

        // 虛擬代理:真的成案了,才建立真正的藝人物件 new Celebrity("彭于晏")
        if (_realCelebrity == null)
        {
            _realCelebrity = new Celebrity(_celebrityName);
        }

        _realCelebrity.Perform(eventName, budget); // 這裡就是物件委派
    }
}

呼叫端只認識 IPerformer,完全不知道背後隔著一個經紀人

// 廠商面對的是經紀人介面
IPerformer agent = new AgentProxy("周杰倫", minBudget: 100);

agent.Perform("小型尾牙", 30);       // 開價不夠,被擋下,藝人完全不需要建立(30萬還想請杰倫?)
agent.Perform("大型跨年晚會", 600);   // 達標,藝人這時候才第一次被建立、上台
agent.Perform("大巨蛋演唱會", 750);     // 藝人已經建立過,直接叫本尊演出

AgentProxy 在這裡同時做了兩件事:預算不夠直接擋下(保護代理),成案後才建立本尊(虛擬代理)

兩件事都不是藝人自己該煩惱的,藝人只管把歌唱好

舉簡單的例子很容易,那如果實際在應用上,大多會是怎麼樣子? 主要可以是

  1. 保護代理:做權限與過濾(身分驗證、RBAC)
  2. 虛擬代理:做效能優化(延遲建立、節省記憶體)
  3. 遠端代理:做分散式系統通訊封裝

回到柴咖啡

小黑最近很得意:「柴哥,我們在 Qber Eats 上也有柴咖啡的分店,很多熟客同時是 Qber Eats 的會員,我想讓他們拿著 Qber Eats 累積的點數,直接來門市換折扣,這樣他們比較有黏著度」

阿柴查了一下,Qber Eats 開放了一支會員查詢 API,只要給一組手機號碼,就能查到這支手機在 Qber Eats 上累積了多少點數

阿柴決定這樣規劃

public interface IMemberPointsService
{
    int GetPoints(string phoneNumber);
}

// 模擬 Qber Eats API 實際會回傳的會員資料格式
public class QberEatsMemberInfo
{
    public string Name { get; set; }
    public int Points { get; set; }
}

// 真正會打 Qber Eats 對外開放的會員查詢 API
public class QberEatsMemberService : IMemberPointsService
{
    public int GetPoints(string phoneNumber)
    {
        Console.WriteLine($" 呼叫 Qber Eats 會員 API 查詢 {phoneNumber} 的點數...");
        // 呼叫外部平台,但網路來回一趟,比店內查詢慢上不少
        var memberInfo = CallQberEatsApi(phoneNumber);
        return memberInfo.Points;
    }

    private QberEatsMemberInfo CallQberEatsApi(string phoneNumber)
    {
        // 模擬呼叫外部 API 拿到的回傳值
        return new QberEatsMemberInfo { Name = "王小明", Points = 350 };
    }
}

門市結帳的 Controller,直接注入這支服務來用

public class CheckoutController
{
    private readonly IMemberPointsService _memberPointsService;

    public CheckoutController(IMemberPointsService memberPointsService)
    {
        _memberPointsService = memberPointsService;
    }

    public void ApplyMemberDiscount(string phoneNumber)
    {
        var points = _memberPointsService.GetPoints(phoneNumber);
        Console.WriteLine($"這支手機號碼有 {points} 點,可折抵 {points / 100} 元");
    }
}

上線之後,兩個問題陸續浮現:

  1. Qber Eats API 有流量限制,中午買咖啡人潮多的時候常常被拒絕或逾時,結帳排隊排得更長
  2. 只要輸入一組手機號碼,不管是不是本人,都查得到對方累積的點數,店員拿客人隨口報的號碼都能查,沒有做任何身份驗證

阿柴發現,「要不要去問 Qber Eats」這一步,沒有做管理
一個是效能考量(客人問三次,我就查三次,不受限制)
一個是權限考量(不管是誰都能問)

Proxy 上場

阿柴學習了Proxy,決定在 IMemberPointsService 前面加一層 MemberPointsProxy,把這兩件事一次處理掉

先加一個小介面,用來確認這支手機號碼是不是已經在店內完成過綁定驗證

public interface IBindingVerifier
{
    bool IsVerified(string phoneNumber);
}
public class MemberPointsProxy : IMemberPointsService
{
    // Proxy 內部持有這個物件
    private readonly IMemberPointsService _realService;


    private readonly IBindingVerifier _bindingVerifier;
    private readonly Dictionary<string, int> _cache = new();

    public MemberPointsProxy(IMemberPointsService realService, IBindingVerifier bindingVerifier)
    {
        _realService = realService;
        _bindingVerifier = bindingVerifier;
    }

    public int GetPoints(string phoneNumber)
    {
        // 保護代理:沒完成綁定驗證,直接擋下,連 Qber Eats 的 API 都不用打
        if (!_bindingVerifier.IsVerified(phoneNumber))
        {
            Console.WriteLine($"{phoneNumber} 尚未完成店內綁定驗證,拒絕查詢");
            return 0;
        }

        // 快取代理:短時間內重複查詢同一支手機號碼,直接用快取,不用再問一次 Qber Eats
        if (_cache.TryGetValue(phoneNumber, out var cachedPoints))
        {
            Console.WriteLine($"命中快取,{phoneNumber} 的點數不用再問一次 Qber Eats");
            return cachedPoints;
        }

        var points = _realService.GetPoints(phoneNumber);
        _cache[phoneNumber] = points;
        return points;
    }
}

CheckoutController 不用改,建構子拿到的是 IMemberPointsService

    public CheckoutController(IMemberPointsService memberPointsService)
    {
        _memberPointsService = memberPointsService;
    }
IMemberPointsService pointsService = new MemberPointsProxy(
    new QberEatsMemberService(),
    new StoreBindingVerifier() // 查店內綁定資料表的實作
);

var controller = new CheckoutController(pointsService);

controller.ApplyMemberDiscount("0900-000-001");
// ❌ 0900-000-001 尚未完成店內綁定驗證,拒絕查詢

controller.ApplyMemberDiscount("0900-000-002"); // 綁定過,第一次查,真的打一次 Qber Eats
// 呼叫 Qber Eats 會員 API 查詢 0900-000-002 的點數...
// 這支手機號碼有 350 點,可折抵 3 元

controller.ApplyMemberDiscount("0900-000-002"); // 同一支手機號碼再查一次,命中快取
// 命中快取,0900-000-002 的點數不用再問一次 Qber Eats
// 這支手機號碼有 350 點,可折抵 3 元

我們來看看 Mermaid 的類別架構圖(Class Diagram)會怎麼呈現
https://ithelp.ithome.com.tw/upload/images/20260916/20183470q2vtDShrFM.png
這裡補充一點,跟原始 UML 不同的是,這裡是持有 interface,非 RealSubject,在 C# 中,我們常會搭配 DI 容器去讓 Proxy 內部持有抽象介面,不依賴具體的物件(會違反依賴反轉原則),我們改依賴抽象介面

朋友如果你第一次看 Mermaid 架構圖,這是小指南

方框內部三層結構:
上層:類別或介面名稱(標註 <<interface>> 代表介面合約)
中層:屬性與私有欄位(- / +  代表 private / public)
下層:對外提供的方法 (- / +  代表 private / public)

箭頭符號的意義:
◄--(虛線箭頭):實作介面,代表類別遵守合約規定
—►(實線箭頭):依賴/呼叫,箭頭指向被使用的一方
⟣—►(實線菱形):菱形持有/組合箭頭方的實例,代表內部包含該物件的參考

今天學到的事

Proxy 要解決的問題:

在不改變呼叫端介面的前提下,替一個物件的存取加上額外的控制邏輯(例如權限檢查、延遲建立、結果快取、遠端呼叫包裝),讓控制邏輯與物件本身的邏輯分開

優點

  • 可以把 RealSubject 換成 Proxy 而不用改動呼叫端的任何程式碼,因為兩者實作同一介面
  • 存取控制邏輯集中在 Proxy 一處,不會散落在 RealSubject 或各個呼叫端裡
  • 可以只挑需要的控制機制加(保護、快取、遠端、延遲建立),不必一次全包

缺點

  • 多一層轉呼叫,呼叫路徑變成「呼叫端 → Proxy → RealSubject」,追蹤與除錯的成本都會增加
  • 快取類的 Proxy 容易有資料新鮮度問題,過期機制沒設計好,反而會製造出新的 bug
  • 沒有明確的存取控制需求就預先包一層 Proxy,只是徒增一層間接層,沒帶來實質好處

這時候我想到的是先前的 Decorator 也是實作介面,包住本尊,Proxy 也是實作介面,那區別在哪裡?

我的想法是:
Decorator(裝飾器):目的在於「增強功能」,通常由呼叫端自己決定怎麼包、包幾層
Proxy(代理人):目的在於「控制存取」(擋掉請求、延遲加載、做快取),呼叫端通常根本不知道自己拿到的是代理


明天,小黑想把菜單分類整理清楚,飲料底下還要再分冰的熱的、點心底下還要再分鹹的甜的,這種「一個大類別底下還能再包小類別」的樹狀結構,換 Composite 合成模式上場

參考資料

Refactoring Guru - Proxy

Design Pattern: Structural Patterns — Proxy Design Pattern (代理人模式)

[Day 17] 控制與物件的接觸 — 代理模式 (Proxy Pattern)

Proxy Pattern

C# Proxy Design Pattern


上一篇
Day 17|Facade(外觀模式) 如何透過 Facade 打造收銀的單一窗口
下一篇
Day 19|Composite (合成模式 ) 深度不固定怎麼辦?把遞迴邏輯藏進節點的物件導向魔法
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言