Proxy

December 30, 2021

如果你寫了個 HelloSpeaker,可以指定招呼語,有個 hello 方法可以指定使用者名稱:

class HelloSpeaker {
    private String hello;

    HelloSpeaker(String hello) {
        this.hello = hello;
    }

    void hello(String name) {
        out.printf("%s, %s%n", this.hello, name); 
    }
}

若有個方法接受 HelloSpeaker,其中會呼叫 hello 方法:

void doSomething(HelloSpeaker helloSpeaker) {
    ...
    helloSpeaker.hello("Justin");
    ...
}

現在如果想在 hello 方法執行前後留下日誌(logging),你會怎麼寫呢?修改 HelloSpeaker

import java.util.logging.*; 

class HelloSpeaker {
    private String hello;

    HelloSpeaker(String hello) {
        this.hello = hello;
    }

    void hello(String name) {
        log("hello 方法開始....");     	// 日誌服務
        out.printf("%s, %s%n", this.hello, name); 
        log("hello 方法結束....");     	// 日誌服務
    }

    private void log(String msg) {
        Logger.getLogger(HelloProxy.class.getName()).log(Level.INFO, msg);
    }
}

還是修改 doSomething

void doSomething(HelloSpeaker helloSpeaker) {
    ...
    Logger.getLogger(HelloProxy.class.getName())
          .log(Level.INFO, "hello 方法開始....");
    helloSpeaker.hello("Justin");
    Logger.getLogger(HelloProxy.class.getName())
          .log(Level.INFO, "hello 方法結束....");
    ...
}

若之後不用日誌了呢?改回來嗎?唔…不覺得麻煩嗎?

日誌代理人

有沒有可能將 HelloSpeaker 的日誌抽取出來成為物件,需要日誌的時候,由該物件代為處理,真正的呼叫 hello 時再委話給 HelloSpeaker 實例呢?如果是這樣的話,doSomething 怎麼辦呢?不就需要對日誌代理與 HelloSpeaker 一視同仁嗎?

喔!一視同仁的意思,就表示從 doSomething 角度來看,日誌代理與 HelloSpeaker 要有相同的行為,那就來定義一個 Hello 介面,然後讓日誌代理與 HelloSpeaker 都實作該介面:

interface Hello {
    void hello(String name);
}

class HelloSpeaker implements Hello {
    private String hello;

    HelloSpeaker(String hello) {
        this.hello = hello;
    }

    public void hello(String name) {
        out.printf("%s, %s%n", this.hello, name); 
    }
}

class HelloLoggingProxy implements Hello {
    private Hello hello;

    HelloLoggingProxy(Hello hello) {
        this.hello = hello;
    }

    public void hello(String name) {
        log("hello 方法開始....");     	// 日誌服務
        out.printf("%s, %s%n", hello.hello, name); 
        log("hello 方法結束....");     	// 日誌服務
    }

    private void log(String msg) {
        Logger.getLogger(HelloProxy.class.getName()).log(Level.INFO, msg);
    }
}

doSomething 接受 Hello 實例,

void doSomething(Hello hello) {
    ...
    hello.hello("Justin");
    ...
}

那麼需要日誌的時候,就 doSomething(new HelloLoggingProxy(new HelloSpeaker("哈囉"))),不需要日誌的時候就 doSomething(new HelloSpeaker("哈囉")),看來方便多了。

在 Gof 模式中,稱這樣的概念為 Proxy 模式,因為代理人與被代理者會具有相同的行為,從客戶端的角度來看,不會知道它在操作的是代理人,還是被代理者。

乍看跟〈Decorator〉有點像,畢竟都是種 wrapper,然而不同點之一方才就講過了,從客戶端來看,代理人與被代理者會具有相同的行為,而 Decorator,作為 decorator 角色的物件,會基於被包裹物件的結果進一步處理,因此會提供的方法可能比被包裹的物件多或者高階,也就是行為不同,另外,代理人做的事,不見得與被代理者處理的事有關,像是日誌,與被代理者就沒有關係。

從原則來看,Proxy 基本上就是里氏替換(Liskov substitution),讓代理人與被代理者可以替換,從客戶端角度來看,替換前有什麼行為,用代理人替換後那些行為不應該改變,若是去除代理人,也只是少了代理人的服務。

Proxy 的實現

只不過,這邊特別定義了一個專用的 Hello 介面,如果其他物件需要日誌服務時,也需要定義專用的介面嗎?這也太麻煩了吧!

定義專用介面的方式,通常被稱為靜態代理,實際上根據你使用的語言或工具而定,Proxy 的實現可以很魔法。

例如,透過 Java 標準 API 的 Proxy.newProxyInstance 等機制,可以動態地建立實現代理時的介面,有興趣可以參考〈動態代理〉。

動態定型語言的話,因為鴨子定型,要實現簡單的靜態代理,不用大費周章,建立一個包裹器,具有相同的方法就可以了:

class HelloSpeaker:
    def __init__(self, hello):
        self.hello = hello

    def hello(self, name):
        print(f'{self.hello}, {name}')

class HelloLoggingProxy:
    def __init__(self, helloSpeaker):
        self.helloSpeaker = helloSpeaker
    
    def hello(self, name):
        self.log('hello 方法開始....')
        self.helloSpeaker.hello(name)
        self.log('hello 方法結束....')

    def log(self, msg):
        logging.getLogger(__name__).log(logging.INFO, msg)

然而複雜的代理需求,還是得透過 __getattribute____getattr____setattr__ 等特殊方法或內省機制之類來實現;類似地,JavaScript 若要實現複雜的代理需求,可以透過 ES6 以後的 Proxy API,來看個簡單的:

let array = [1, 2, 3];

let proxy = new Proxy(array, {
    get(target, prop, receiver) {
        console.log('get on', target, prop);
        return Reflect.get(target, prop, receiver);
    },

    set(target, prop, value, receiver) {
        console.log('set on', target, prop, value);
        return Reflect.set(target, prop, value, receiver);
    }
});

透過 proxy[0]proxy[1] = 10 之類的操作,你會看到主控台有相關對應的訊息,為了將操作委託給被代理者,Proxy API 通常搭配 ES6 以後的 Reflect API,像是上面看到的 Reflect.setReflect.get,會對目標陣列進行存取。

也就是說,不管你的語言環境,是否有提供 Proxy 字樣的 API,如果你曾經用過反射、自省之類的機制,或者曾經實現過攔截器(interceptor)之類的東西,或許你早就實現過 Proxy 模式了。