昨天用 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 圖
核心結構與觀念
以前讀大學的時候,偶爾會辦校園晚會,辦校園晚會,就會需要請藝人(誰想看學生在台上跳舞)
但大多數藝人不會自己去接每一通商演邀約的電話,中間都會有一個經紀人
預算不夠的案子,經紀人直接回絕,藝人根本不會知道有這通邀約
預算夠的案子,經紀人才會真的去找藝人談檔期
// 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 在這裡同時做了兩件事:預算不夠直接擋下(保護代理),成案後才建立本尊(虛擬代理)
兩件事都不是藝人自己該煩惱的,藝人只管把歌唱好
舉簡單的例子很容易,那如果實際在應用上,大多會是怎麼樣子? 主要可以是
小黑最近很得意:「柴哥,我們在 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} 元");
}
}
上線之後,兩個問題陸續浮現:
阿柴發現,「要不要去問 Qber Eats」這一步,沒有做管理
一個是效能考量(客人問三次,我就查三次,不受限制)
一個是權限考量(不管是誰都能問)
阿柴學習了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)會怎麼呈現
這裡補充一點,跟原始 UML 不同的是,這裡是持有 interface,非 RealSubject,在 C# 中,我們常會搭配 DI 容器去讓 Proxy 內部持有抽象介面,不依賴具體的物件(會違反依賴反轉原則),我們改依賴抽象介面
朋友如果你第一次看 Mermaid 架構圖,這是小指南
方框內部三層結構:
上層:類別或介面名稱(標註 <<interface>> 代表介面合約)
中層:屬性與私有欄位(- / + 代表 private / public)
下層:對外提供的方法 (- / + 代表 private / public)
箭頭符號的意義:
◄--(虛線箭頭):實作介面,代表類別遵守合約規定
—►(實線箭頭):依賴/呼叫,箭頭指向被使用的一方
⟣—►(實線菱形):菱形持有/組合箭頭方的實例,代表內部包含該物件的參考
Proxy 要解決的問題:
在不改變呼叫端介面的前提下,替一個物件的存取加上額外的控制邏輯(例如權限檢查、延遲建立、結果快取、遠端呼叫包裝),讓控制邏輯與物件本身的邏輯分開
優點:
缺點:
這時候我想到的是先前的 Decorator 也是實作介面,包住本尊,Proxy 也是實作介面,那區別在哪裡?
我的想法是:
Decorator(裝飾器):目的在於「增強功能」,通常由呼叫端自己決定怎麼包、包幾層
Proxy(代理人):目的在於「控制存取」(擋掉請求、延遲加載、做快取),呼叫端通常根本不知道自己拿到的是代理
明天,小黑想把菜單分類整理清楚,飲料底下還要再分冰的熱的、點心底下還要再分鹹的甜的,這種「一個大類別底下還能再包小類別」的樹狀結構,換 Composite 合成模式上場
Design Pattern: Structural Patterns — Proxy Design Pattern (代理人模式)
[Day 17] 控制與物件的接觸 — 代理模式 (Proxy Pattern)