iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Software Development

Codetopia 新手日記:設計模式與原則的 30 天學習之旅系列 第 13

Day 13:Proxy:資產大門的警衛——該放行?該攔截?還是遠端代打?

  • 分享至 

  • xImage
  •  

Codetopia 創城記 (13)|Proxy:資產大門的警衛——該放行?該攔截?還是遠端代打?

1. 今日熱點 (故事開場 & 痛點)

Codetopia 的週一早晨,總是充滿了咖啡香與……新的災難。城市地圖 City Map 2.0 計畫才剛慶祝完用 Flyweight 模式大幅降低了記憶體壓力,都還沒來得及開香檳,警報就響徹了整個監控中心。

「報告!公共景點的圖資熱點被不明爬蟲刷爆了!」

「等等!合作廠商的測試機器怎麼直接把我們內部的圖示原檔,拉到他們外網的 CDN 上了?!」

「天啊,還有未授權的下載請求正大量湧入!」

地圖前端工程師 Dylan 額頭冒著冷汗,眼前的儀表板一片血紅。前兩天,我們才剛用 Facade 清理了邊界,用 Flyweight 共享了內蘊,今天,我們的大門口就直接被攻破了。

砰! 一聲巨響,門禁代理隊長 Iris|門禁代理隊長 用力拍在會議桌上。「夠了!各自為政的時代結束了。」她的眼神像掃描器一樣掃過在場的每一個人,「從現在起,所有對資產庫(AssetRepo)的請求,一律不准直連!全部給我先過代理層!

她迅速在白板上劃下三條鐵律:

  1. 公有資產:可以匿名讀取,但必須加上速率限制與快取,擋住惡意爬蟲。

  2. 私有資產:必須憑藉有效的權杖(Token)與細粒度的存取策略(ACL)才能核發。

  3. 大檔案或跨區請求:全部交給「遠端代理」在邊緣節點(Edge Node)代為傳輸,不准再讓這些流量把市中心的網路給拖垮!

(旁白:「看來,繼『門面要清爽』、『內存要共享』之後,我們 Codetopia 的第三條生存法則是——『大門要有腦』。」)

驗收的軍令狀也隨之而來,而且這次附上了明確的服務等級目標(SLO):

  • Given:一個 /assets/icons/{key} 請求,帶著 partner:read 的角色,來自 APAC 區域,但同時正遭受每秒超過 1000 次的 QPS 攻擊。

  • When:客戶端(例如 Dylan 的地圖程式)的程式碼維持不變,僅調整其依賴注入(DI)綁定,將 IAssetRepo 的實作從 RealAssetRepo 指向 GatekeeperProxy

  • Then:未授權請求 → 403;超速請求 → 429(黑名單 1h);合法請求命中快取或由最近 Edge 代傳。目標:核心倉 QPS 峰值下降 ≥ 50%、P95 延遲 ≤ 400ms、公共資產誤封率 ≤ 0.1%。

2. 術語卡 🧭

Proxy(代理):一個控制對象(Object)存取的「替身」。它和真實對象實作相同的介面,讓客戶端感覺不到它的存在,但在中間卻能執行額外的任務,像是門禁(Protection Proxy)、延遲載入(Virtual Proxy)、遠端代打(Remote Proxy),或是增加快取/計數等功能的智慧代理(Smart Proxy)。

3. 笑中帶淚 (反例/壞味道)

讓我們把時間倒轉回 Iris 拍桌前的三十分鐘。當時,我們焦頭爛額的英雄 Dylan 是這樣寫他的前端讀取邏輯的:

# ❌ 反例:Dylan 直連真實資產庫,既曝密又拖垮中心
def get_asset_directly(key):
    # 風險1:將管理員權杖硬編碼在客戶端,形同虛設。
    token = "hardcoded-super-secret-admin-token"

    # 風險2:直接暴露內部核心服務的 URL。
    url = f"https://core.assets.codetopia/raw/{key}"

    # 風險3:所有請求都用同一最高權限,缺乏分級。
    blob = http.get(url, headers={"Authorization": f"Bearer {token}"}).content

    # 風險4:客戶端自行實作快取,但缺乏一致性與清理機制。
    local_cache.write(key, blob)

    # 風險5:同步下載大檔案,導致 UI 嚴重卡頓。
    render_map_tile(blob)

# 後果:
# 1. 管理員權杖外洩,整個資產庫形同虛設。
# 2. QPS 雪崩直接衝擊核心資料庫,沒有任何緩衝。
# 3. UI 延遲飆升,市民怨聲載道。
# 4. 任何合作夥伴或內部人員都可以輕易繞過所有治理規則。

4. 王牌出手 (核心觀念/何時用/不適用)

Proxy 模式就像是在你的豪宅門口,雇用了一位訓練有素、十八般武藝樣樣精通的警衛。客人按的門鈴(介面)是一樣的,但警衛(代理)會根據訪客身分、時間、甚至訪客的意圖,決定是直接開門、請他稍後、或是叫他滾蛋。

何時用 (When to Use) ✅

  • 需要存取控制:當你想對一個物件的存取加上權限檢查、審計日誌、或是流量限制,但又不希望污染原始物件的商業邏輯,更不想改動所有呼叫端的程式碼時。

  • 隱藏遠端呼叫的複雜性:當你的真實物件在另一台機器,你可以用一個 Remote Proxy 假裝它就在本地。代理會幫你處理網路通訊、序列化,甚至是熔斷與回退(fallback)機制。

  • 延遲載入昂貴資源:當一個物件的初始化成本極高,你可以先給客戶端一個 Virtual Proxy,等到真正需要使用它時,代理才去建立真實的物件。

  • 智慧操作與加值服務:在呼叫前後增加快取、引用計數、執行緒安全鎖等。此外,針對大檔案下載,Remote Proxy 可選擇「代理傳輸」或「僅簽發附帶短時效(TTL)的簽名 URL」兩條路徑,依成本與延遲需求動態切換。

何時不要用 (When NOT to Use) ⛔

  • 只是介面長得不一樣:如果只是想把一個介面轉換成另一個,那用 Adapter(轉接站)模式更單純、語意也更清晰。

  • 只想動態疊加功能,且不需要攔截:如果你只是想為物件動態地增加新功能,且不打算控制或拒絕存取,那 Decorator(裝修工)模式是更好的選擇。Decorator 讓請求一定會穿透到真實物件,而 Proxy 則可能會攔截它。

  • 代理變成萬能上帝:如果你的 Proxy 開始做太多不相干的事情,比如處理複雜的商業規則、流程編排,那它就變成了「上帝代理」,這是一種壞味道。這些邏輯應該回歸到服務內部,或是由 Facade 來負責編排。

5. 導播切景 (表格+兩張 Mermaid)

導播,鏡頭拉一下!讓我們從三個不同的尺度,看看 Proxy 是如何扮演好它的守門員角色的。

視角 觀念/模式 在 Codetopia 的說法
微觀 (GoF) Proxy 實作 Subject 介面,控制對 RealSubject 的存取 GatekeeperProxy (門禁) 和 EdgeRemoteProxy (遠端) 都偽裝成 RealAssetRepo,站在客戶端前面。
中觀 (EIP/EDA) Ambassador/Sidecar, Throttler, Cache 代理就像一個邊車或大使,在服務邊界執行節流、快取和路由策略。
宏觀 (MAS) GatekeeperAgent (門禁), EdgeRelayAgent (邊緣代傳) 分工明確的代理人:一個負責審計與配額,另一個負責跨區資料中繼。

微觀 GoF 結構圖:

https://ithelp.ithome.com.tw/upload/images/20250927/20178500TH8rFEJdBp.png

中觀資訊流時序圖:

https://ithelp.ithome.com.tw/upload/images/20250927/20178500VaTBuBfw5R.png

6. 最小實作 (程式碼範例)

現在,讓我們看看 Iris 隊長提出的正解。注意,這個版本將易變的邏輯(如限速、快取 TTL)都抽離成可注入的「策略」,並加入了優雅的失敗路徑處理。

# --- 簡易退避重試 helper(jitter 防止同步尖峰) ---
import random, time
def retry_with_jitter(fn, *, max_attempts=3, base=0.05):
    attempt = 0
    while True:
        try:
            return fn()
        except Exception as e:
            attempt += 1
            if attempt >= max_attempts:
                raise e
            time.sleep(base * (2 ** (attempt - 1)) + random.random() * base)

# --- 自定義錯誤型別,便於上層映射為 HTTP 狀態碼 ---
class ForbiddenError(Exception): pass      # --> 對應 403 Forbidden
class RateLimitedError(Exception): pass   # --> 對應 429 Too Many Requests
class EdgeUnavailableError(Exception): pass # --> 對應 503 Service Unavailable

# --- 介面定義 (Subject) ---
class IAssetRepo:
    def get(self, key, *, cred=None):
        raise NotImplementedError("Subclasses must implement this method")

# --- 真實的物件 (RealSubject) ---
class RealAssetRepo(IAssetRepo):
    def get(self, key, *, cred=None):
        print(f"CORE: Reading '{key}' from deep storage...")
        return f"BLOB_DATA_FOR_{key}"

# --- 代理物件 (Proxy),採用策略注入 ---
class GatekeeperProxy(IAssetRepo):
    def __init__(self, real_repo, *,
                 acl, limiter, cache, auditor, circuit_breaker,
                 edge_proxy=None,
                 large_file_strategy,
                 cache_ttl_strategy):
        self._real_repo = real_repo
        self._acl = acl
        self._limiter = limiter
        self._cache = cache
        self._auditor = auditor
        self._circuit = circuit_breaker
        self._edge_proxy = edge_proxy
        self._large_file_strategy = large_file_strategy
        self._cache_ttl_strategy = cache_ttl_strategy

    def get(self, key, *, cred=None):
        # ... (權限檢查與速率限制如前) ...
        if not self._acl.is_allowed(cred, key): raise ForbiddenError("Access Denied")
        if not self._limiter.try_acquire(cred["id"], key): raise RateLimitedError("Too Many Requests")

        if (blob := self._cache.get(key)) is not None:
            self._auditor.log("CACHE_HIT", key, cred)
            return blob

        self._auditor.log("CACHE_MISS", key, cred)

        # 關卡 4: 以策略決定是否走遠端代理,並加入熔斷與回退
        if self._edge_proxy and self._large_file_strategy.is_large(key):
            try:
                # 只有在熔斷器關閉時才嘗試遠端呼叫
                if self._circuit.is_closed():
                    # 帶有抖動的退避重試
                    blob = retry_with_jitter(lambda: self._edge_proxy.get(key, cred=cred), max_attempts=3)
                else:
                    raise EdgeUnavailableError("Circuit is open")
            except EdgeUnavailableError:
                self._circuit.open(ttl_seconds=60)  # 熔斷 60 秒
                self._auditor.log("EDGE_FAIL_FALLBACK", key, cred)
                blob = self._real_repo.get(key, cred=cred) # 回退到本地核心庫
        else:
            blob = self._real_repo.get(key, cred=cred)

        ttl = self._cache_ttl_strategy.ttl_for(key)
        self._cache.set(key, blob, ttl=ttl)
        return blob

# --- 審計治理建議(示例,用於實際記錄點附近) ---
# 建議在呼叫 self._auditor.log(...) 的地方,搭配下列策略設定:
# self._auditor.set_sampling(rate=0.1)        # 高 QPS 場景取樣 10%
# self._auditor.set_retention(days=30)        # 保存 30 天
# # 例:去識別化(示意,於實際 log 時計算)
# actor_hash = hash(str(cred.get("id"))) % 10000 if cred else None
# self._auditor.log("FORBIDDEN", key, {"actor_hash": actor_hash})

7. 反模式紅旗 🚩

當你在城市中巡邏時,看到以下場景,請立刻亮起紅旗,這通常是 Proxy 被誤用的跡象:

  • 🚩 客戶端的秘密判斷:把權杖、角色判斷邏輯寫死在客戶端。這等於把警衛的工作交給了訪客自己,一換就全部外洩。

  • 🚩 紙老虎代理:一個 Proxy 只是單純地將請求轉發給真實物件,沒有做任何權限控制、快取或審計。這不是警衛,這是傳聲筒。

  • 🚩 洩漏的實作細節:代理應該完美偽裝成真實物件。如果它回傳了內部的錯誤碼、資料型別,或是讓客戶端能以某種方式繞過它直接聯繫到真實物件,那它的抽象就被破壞了。

  • 🚩 萬能的上帝代理:代理的職責應該是「跨領域關注點」(Cross-cutting Concerns),如安全、快取、日誌。如果它開始處理起「核心商業邏輯」,那它就越權了,正在變成一個難以維護的「微型神物件」。

8. 城市望遠鏡 (進階視野)

將 Proxy 的概念放大,它在更宏觀的架構中扮演著關鍵角色。

  • EIP/EDA/Actor 架構:在事件驅動或微服務架構中,GatekeeperProxy 的角色很像 AmbassadorSidecar 模式。它作為一個獨立的進程部署在服務旁邊,專門處理服務邊界的網路流量、安全認證、熔斷、節流和監控,而核心服務則可以專注於商業邏輯。任何異常都可以發布為事件,通知監控中心。

  • MAS (多代理系統):在多代理的世界裡,我們可以將其具現化為兩個高度分工的代理人:GatekeeperAgent 負責權杖校驗、配額管理和審計,而 EdgeRelayAgent 則負責跨網路邊界的資料拉取與中繼。這些代理會將自己的服務註冊到 DF (黃頁) 上,讓其他代理知道可以從哪裡找到最近的邊緣節點。

9. ✅ 回到現場

讓我們回到故事的開頭。當 Dylan 團隊的部署流程更新,僅調整了依賴注入的設定,將 IAssetRepo 的實作從 RealAssetRepo 指向 GatekeeperProxy 後,監控中心的警報聲戛然而止,取而代之的是一片代表「正常」的綠燈。

軍令狀上的驗收項目,逐一通過:

  • 未授權→403:那些不明爬蟲收到了冰冷的 403 Forbidden 回應。

  • 超速→429:QPS 攻擊在速率限制器前撞得頭破血流,收到了 429 Too Many Requests,並被暫時封鎖一小時。

  • 已授權→就近服務:合法的合作夥伴請求大檔案時,流量被無縫導向最近的 APAC 邊緣節點,P95 延遲從數秒降至幾百毫秒,市中心核心庫的 QPS 壓力也應聲下降。

最美妙的是,Dylan 的地圖渲染程式碼,一行都沒改。這完美呼應了前兩日的設計精神:「門面不變、共享不變」,今天則是「門禁升級,呼叫端無感」。

10. 測試指北

Iris 隊長在交付 GatekeeperProxy 時,附上了一份嚴謹的測試指南,確保這道大門固若金湯:

  • 授權矩陣測試:驗證不同的使用者角色(subject)、對不同的資源(resource)、執行不同的操作(action)時,是否能得到預期的成功回應或 ForbiddenError

  • 限速/配額測試:在一個時間窗內連續發動 M 次請求,驗證第 M+1 次請求是否確實拋出 RateLimitedError;並驗證等待時間窗過去後,請求是否能恢復正常。

  • 快取一致性測試:驗證第一次請求是 MISS,第二次是 HIT。可透過比對 ETag 或版本號來設計快取失效策略(Cache Invalidation),確保資料更新後能正確地再次 MISS

  • 遠端代理路徑測試:透過模擬網路故障,驗證當遠端節點不可用時,代理是否能觸發熔斷機制,並優雅地回退(fallback)到本地核心庫,或回傳適當的錯誤訊息。

  • 契約穩定性測試:確保 ProxyRealSubject 的方法簽名完全一致,且回傳值的語義是等價的,不能破壞客戶端的預期。

11. 鄉民出題 (動手+思辨)

  1. 實作題:請為 GatekeeperProxy 加上一個更具體的速率限制器。例如,一個基於滑動時間窗(Sliding Window)的限速邏輯,並能區分不同角色的配額(如 partner 每分鐘 100 次,employee 每分鐘 1000 次,public 每分鐘 10 次)。撰寫測試案例來驗證窗口過期後速率恢復,以及配額耗盡時的回應。

  2. 思辨題(二選一):假設最近 Codetopia 的邊緣節點(Edge)維護成本急劇上升。你作為總設計師,會選擇:

    • A: 保留 Remote Proxy 的架構,但將大檔案的策略從「代理傳輸」改為「代理只生成帶有簽名的 CDN URL」,讓客戶端自己去 CDN 下載。

    • B: 徹底關閉 Remote Proxy,所有流量都回到市中心的核心庫,但強制要求客戶端必須使用分段續傳(Chunked Transfer)來下載大檔案。

    請選擇 A 或 B,並簡要說明你在總成本、延遲體驗、治理權限這三者之間的權衡。

12. 結語 & 預告

Proxy 模式為我們在混亂的邊界上,建立了一道智慧的防線。它讓我們在不驚動內部居民(核心邏輯)和外部訪客(客戶端)的前提下,完成了城市安全的重大升級。

本日摘要:同介面攔放行,權控限速擋濫用;遠端代打降延遲,門內安全門外順。

明日預告:Day 14|Observer:城市廣播、訂閱更新——一呼百應的事件之城即將登場!


13. 附錄:ASCII 版圖示

為了確保在不支援 Mermaid 渲染的環境中也能正常閱讀,以下提供文中圖表的 ASCII 替代版本:

微觀 GoF 結構圖 (ASCII 版)

                    ┌─────────────────┐
                    │    <<Subject>>  │
                    │    Interface    │
                    │                 │
                    │ + get(key): Blob│
                    └─────────┬───────┘
                              │
               ┌──────────────┼──────────────┐
               │              │              │
               │              │              │
               ▼              ▼              ▼
    ┌─────────────────┐ ┌──────────────┐  ┌──────────────────┐
    │  RealAssetRepo  │ │GatekeeperProxy│ │ EdgeRemoteProxy │
    │                 │ │              │  │                  │
    │ + get(key): Blob│ │ + get(key):  │  │ + get(key): Blob │
    │                 │ │      Blob    │  │                  │
    │ [核心資產庫]     │ │              │  │ [邊緣代理]       │
    │                 │ │ ┌──────────┐ │  │                  │
    └─────────────────┘ │ │ AuthZ    │ │  └──────────────────┘
              ▲         │ │ RateLimit│ │           │
              │         │ │ Cache    │ │           │
              │         │ │ Audit    │ │           │
              └─────────┤ └──────────┘ │           │
                        │              │           │
                        └──────────────┘           │
                                 │                 │
                                 └─────────────────┘
                                  (遠端呼叫/中繼)

中觀資訊流時序圖 (ASCII 版)

Dylan     GatekeeperProxy    EdgeRemoteProxy    RealAssetRepo
  │             │                   │                │
  │ get(key,    │                   │                │
  │ cred) ──────►                   │                │
  │             │                   │                │
  │             │ [AuthZ Check]     │                │
  │             │ [Rate Limit]      │                │
  │             │ [Cache Lookup]    │                │
  │             │                   │                │
  │             ├─ Cache Hit? ──────┤                │
  │             │                   │                │
  │◄─── blob ───┤ (if cached)       │                │
  │             │                   │                │
  │             │ (else: cache miss)│                │
  │             │                   │                │
  │             │ fetchNear(key) ───►                │
  │             │                   │                │
  │             │                   │ get(key) ──────►
  │             │                   │                │
  │             │                   │◄──── blob ─────│
  │             │                   │                │
  │             │◄──── blob ────────│                │
  │             │                   │                │
  │             │ [Audit Log]       │                │
  │             │ [Cache Store]     │                │
  │             │                   │                │
  │◄─── blob ───│                   │                │
  │             │                   │                │

Proxy 守門系統架構圖 (ASCII 版)

                    🌐 外部網路
                         │
                    ┌────┴────┐
                    │ 🛡️ 防火牆 │
                    └────┬────┘
                         │
         ┌───────────────┴────────────────┐
         │        Codetopia 大門          │
         │     ┌──────────────────┐       │
         │     │ GatekeeperProxy  │       │
         │     │                  │       │
         │     │ ⚔️  AuthZ Check  │       │
         │     │ 🚦 Rate Limiter  │       │
         │     │ 💾 Cache Layer   │       │
         │     │ 📊 Audit Logger  │       │
         │     │ ⚡ Circuit Break │       │
         │     └─────────┬─────────┘       │
         └───────────────┼─────────────────┘
                         │
            ┌────────────┼────────────┐
            │            │            │
            ▼            ▼            ▼
     ┌───────────────┐ ┌────────┐ ┌─────────────┐
     │EdgeRemoteProxy│ │ Cache  │ │RealAssetRepo│
     │               │ │ Store  │ │             │
     │ APAC Node     │ │        │ │ 核心資產庫   │
     │ 🌏 就近服務   │ │ Redis  │ │             │
     └───────────────┘ └────────┘ └─────────────┘

代理模式狀態流轉圖 (ASCII 版)

   [請求進入]
        │
        ▼
   ┌──────────┐     ❌ 無權限       ┌─────────┐
   │ 權限檢查  │ ──────────────────► │ 403錯誤 │
   └─────┬────┘                     └─────────┘
         │ ✅ 有權限
         ▼
   ┌──────────┐     ❌ 超過速率     ┌─────────┐
   │ 速率檢查  │ ──────────────────► │ 429錯誤 │
   └─────┬────┘                     └─────────┘
         │ ✅ 未超速
         ▼
   ┌──────────┐     ✅ 快取命中     ┌─────────┐
   │ 快取查詢  │ ──────────────────►│ 回傳資料 │
   └─────┬────┘                     └─────────┘
         │ ❌ 快取未命中
         ▼
   ┌──────────┐     ✅ 大檔案       ┌────────────┐
   │ 檔案類型  │ ──────────────────► │ 邊緣代理處理 │
   │   判斷   │                      └─────┬──────┘
   └─────┬────┘                            │
         │ ❌ 小檔案                       │
         ▼                                 │
   ┌──────────┐                            │
   │核心庫處理 │                            │
   └─────┬────┘                            │
         │                                 │
         └──────────┬──────────────────────┘
                    ▼
              ┌──────────┐
              │ 更新快取  │
              └─────┬────┘
                    ▼
              ┌──────────┐
              │ 回傳資料  │
              └──────────┘

上一篇
Day 12:Flyweight:城市資產共享中心——一張圖示,千萬位置
系列文
Codetopia 新手日記:設計模式與原則的 30 天學習之旅13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言