cv.cm

編碼 · 設計模式

代理模式詳解與範例

閱讀約 6 分鐘

簡答

代理是一種結構型設計模式,它提供一個與真正物件具有相同介面的替代物件,讓代理能夠控制對真正物件的存取。用戶端把代理當成真正的物件來溝通,由代理決定何時、如何轉發每一次呼叫。常見的種類有虛擬代理(延遲建立)、保護代理(存取檢查)、快取代理和遠端代理。

意圖與它解決的問題

意圖:為另一個物件提供替身或佔位物件,以控制對它的存取。代理是《設計模式》(Gang of Four,簡稱 GoF)一書中的結構型模式之一。

假設某個應用程式透過網路呼叫氣象服務。每次呼叫要花數百毫秒,而且要付費,同一個城市每分鐘常常被查詢許多次。你無法更動這個服務類別,也不想在每個呼叫它的地方都加上快取程式碼。代理可以解決這個問題:它實作相同的介面、保存一份快取,只有在快取未命中時才呼叫真正的服務。呼叫端完全不必更動。

同樣的概念也能滿足其他需求:只在第一次使用時才建立昂貴的物件、在呼叫之前檢查權限、記錄每一次呼叫,或是隱藏真正的物件位在另一台機器上的事實。

結構與參與者

角色職責
主體(Subject)真正的物件與代理共用的介面,讓用戶端可以使用其中任何一個
真正的主體(Real subject)負責實際工作的物件
代理(Proxy)實作主體介面,持有或建立真正主體的參照,並在轉發之前或之後加入控制邏輯
用戶端只使用主體介面,並不知道自己是否拿到的是代理
代理的種類它控制的事項
虛擬代理延遲到真正需要時,才建立昂貴的物件
保護代理檢查呼叫者是否被允許執行某項操作
快取代理儲存結果,並在重複的請求時回傳
遠端代理代表位於另一個行程或另一台機器上的物件,並處理通訊
記錄代理在不更動真正物件的前提下,記錄呼叫與引數

TypeScript 實作:快取代理

WeatherService 是主體。CachedWeatherService 是代理:它有相同的方法,只有在快取的值不存在或太舊時,才轉發給真正的服務。

interface WeatherService {
  getTemperature(city: string): Promise<number>;
}
class RemoteWeatherService implements WeatherService {
  calls = 0;
  async getTemperature(city: string): Promise<number> {
    this.calls++; // imagine a slow HTTP request here
    return city.length * 3;
  }
}
class CachedWeatherService implements WeatherService {
  private cache = new Map<string, { value: number; time: number }>();
  constructor(private real: WeatherService, private maxAgeMs = 60_000) {}
  async getTemperature(city: string): Promise<number> {
    const hit = this.cache.get(city);
    if (hit && Date.now() - hit.time < this.maxAgeMs) return hit.value;
    const value = await this.real.getTemperature(city);
    this.cache.set(city, { value, time: Date.now() });
    return value;
  }
}
async function main() {
  const real = new RemoteWeatherService();
  const weather: WeatherService = new CachedWeatherService(real);
  await weather.getTemperature("Paris");
  await weather.getTemperature("Paris");
  console.log(await weather.getTemperature("Hanoi"), "real calls:", real.calls);
  // 15 real calls: 2
}
main();

用戶端變數的型別是 WeatherService,所以你可以在同一個地方切換真正的服務和代理,例如在依賴注入的設定中。

用 JavaScript Proxy 實作虛擬代理與保護代理

JavaScript 有內建的 Proxy 物件(ES2015),它透過稱為 trap 的處理函式,攔截屬性讀取、寫入與函式呼叫這類操作。這是一種方便的方式,讓你不必為每個介面都寫一個類別,就能建立代理。

type Account = { owner: string; balance: number };
function readOnly<T extends object>(target: T): T {
  return new Proxy(target, {
    set(_obj, prop) {
      throw new Error("Cannot change " + String(prop) + ": read-only");
    },
  });
}
const account = readOnly<Account>({ owner: "Ana", balance: 100 });
console.log(account.balance); // 100
try {
  account.balance = 0;
} catch (e) {
  console.log((e as Error).message); // Cannot change balance: read-only
}

何時使用、何時不該用

  • 當你想在不更動真正類別及其呼叫端的情況下,加入延遲載入、存取控制、快取、記錄或遠端存取時,就使用它。
  • 當真正的物件不在你的掌控之內,例如函式庫的類別或網路用戶端時,就使用它。
  • 當單純的函式包裝就夠用時,不要使用它。對於單一函式,像 memoize(fn) 這樣的高階函式,比一個類別更簡單。
  • 不要把緩慢或可能失敗的遠端呼叫,藏在看起來像本地的代理後面。呼叫端仍然需要處理延遲與錯誤,所以要在介面上讓這點看得見(例如回傳 Promise)。
  • 要留意物件的身分:代理與真正的主體是不同的物件,所以 proxy === real 為 false。

實際應用

  • Vue 3 的響應式系統是建立在 JavaScript 的 Proxy 之上:讀取屬性時記錄依賴,寫入屬性時觸發更新。
  • Java 提供 java.lang.reflect.Proxy,可以在執行時期建立動態代理類別;框架會用它在介面外加上交易、安全檢查或遠端呼叫。
  • Hibernate 這類物件關聯對映器(ORM)會為相關的實體回傳延遲代理,並在第一次存取時,才從資料庫載入真正的資料。
  • 遠端程序呼叫(RPC)函式庫會產生看起來像本地物件的用戶端存根(stub),但每一次呼叫都是透過網路送出。

相關模式

模式與代理的差異
裝飾者結構相同,目標不同:裝飾者新增行為,且常被堆疊使用;代理則控制存取,且通常自己管理真正物件的生命週期。
轉接器轉接器讓物件具備不同的介面;代理則保持完全相同的介面。
外觀外觀為整個子系統提供一個新的、較簡單的介面;代理則是替單一物件做替身。
享元享元藉由共用物件來節省記憶體;虛擬代理則是延遲建立物件,以節省時間與記憶體。

常見問題

代理和裝飾者有什麼差別?

兩者都以相同的介面包裝一個物件。裝飾者新增職責,通常由用戶端來組合;代理則控制對真正物件的存取,而且常常自己建立或管理那個物件。

JavaScript 的 Proxy 物件就是代理模式嗎?

它是讓這個模式容易實作的語言功能。JavaScript 的 Proxy 透過 trap 攔截對目標物件的操作,這正是 GoF 所說的代理所做的事。

什麼是虛擬代理?

虛擬代理是替一個建立成本高昂的物件(例如大型圖片)做替身。只有在第一次呼叫需要它的方法時,才會建立真正的物件。

反向代理伺服器是代理模式的例子嗎?

它在網路層級上遵循相同的概念:像 nginx 這樣的伺服器,以相同的 HTTP 介面擋在其他伺服器前面,並加入快取、存取控制或負載平衡。GoF 的模式則是為單一程式中的物件描述同樣的概念。

全部編碼教學