設計模式之代理模式


代理模式為另一個對象提供一個替身以控制對這個對象的訪問。從定義可以看出,1. 代理模式提供了一個替身,即代理對象 2. 代理對象是為了控制對另一個對象(真實對象)的訪問,控制可以理解為做權限檢查、可行性判斷等。舉個例子,代理對象 = 經紀人,真實對象 = 明星,如果某劇組想邀請明星出演電影,先將劇本給經紀人,經紀人先判斷劇組的真實性以及劇本的價值,如果是無良劇組或者垃圾劇本直接懟回去,這便是控制。如果劇組和劇本靠譜便轉交給明星處理,明星確定演不演把結果反饋給經紀人,經紀人再反饋結果給劇組,這個過程就可以理解為代理模式。代理模式有很多種,包括動態代理、遠程代理、虛擬代理等等,本章我們詳細介紹動態代理(Java版),簡單介紹遠程代理和虛擬代理。

動態代理

之所以叫動態是因為運行時才將代理類創建出來。我們先由一個簡單的需求引入動態代理技術,同時也會介紹面向對象設計原則。需求如下:在我們的業務當中,需要將某些數據寫入本地磁盤做持久化,因此程序中需要封裝一個寫文件的類來滿足業務需求。前期的業務很簡單,我們只需要定義能夠提供寫入文件的方法即可。因此,首先定義一個 Writer 接口,包含不同的寫方法,其次定義一個該接口的實現類,實現該接口定義的方法。

package com.cnblogs.duma.dp.proxy.dynamic;

public interface Writer {
    public void write(String fileName, String str);
    public void write(String fileName, byte[] bs);
}
package com.cnblogs.duma.dp.proxy.dynamic;

public class FileWriter implements Writer {
    @Override
    public void write(String fileName, String str) {
        System.out.println("call write str in FileWriter");
    }

    @Override
    public void write(String fileName, byte[] bs) {
        System.out.println("call write bytes in FileWriter");
    }
}

之后我們用 Writer writer = new FileWriter();  就可以完成向本地文件寫數據的功能了。這里其實不定義接口也能實現這個功能,至於為什么要定義接口下文會有解釋。至此,我們的小需求完成了,也上線了並能正常運行。突然有一天運維小哥說了,為了保證 xxx ,需要在服務器預留 100G 磁盤空間, 也就是說我們的應用程序寫磁盤的時候要判斷已有的磁盤空間,如果快到了 100G 臨界值,就不能再寫了。因此,我們需要改代碼,寫之前加上一個判斷當前可用的磁盤空間的邏輯,本來我們可以直接改 FileWriter 的代碼。但存在兩個問題 1. 改現有代碼風險高,可能改動過程中影響原有邏輯,並且要重新進行單元測試 2. 這個需求比較牽強,跟我們的實際業務無關,直接放在業務代碼里面導致耦合度比較大,不利於維護。因此,我們可以考慮使用代理模式解決這個問題,即可以保證現有代碼不動,又可以低耦合地實現目前的需求。

package com.cnblogs.duma.dp.proxy.dynamic;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

public class FileWriterInvocationHandler implements InvocationHandler {
    Writer writer = null;

    public FileWriterInvocationHandler(Writer writer) {
        this.writer = writer;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Exception {
        boolean localNoSpace = false;
        System.out.println("check local filesystem space."); //檢測磁盤空間代碼,返回值可以更新 localNoSpace 變量
        if (localNoSpace) {
            throw new Exception("no space."); //如果空間不足,拋出空間不足的異常
        }
        return method.invoke(writer, args); //調用真實對象(FileWriter)的方法
    }
}

可以看到只增加了一個類,這個類有個特點 1. 它實現了 InvocationHandler 接口 2. 它的 invoke 方法實現了我們的需求並控制是否要調用真實對象。InvocationHandler 是 Java 動態代理定義的一個接口,接口中定義了一個 invoke 方法,我們調用代理對象的任何方法都會變成對 FileWriterInvocationHandler 對象的 invoke 方法的調用, invoke 方法就是代理要做的事情。如果看到你覺得一頭霧水,沒關系繼續向下看將豁然開朗。

到目前為止我們只看到新增了一個 InvocationHandler 接口的實現類,並沒有看到代理對象。之前說過之所以是動態代理是因為在運行時才創建代理類,因此我們需要編寫一個驅動程序,動態創建代理對象,完成動態代理的后半部分。

package com.cnblogs.duma.dp.proxy.dynamic;

import java.lang.reflect.Proxy;

public class DynamicProxyDriver {
    public static void main(String[] args) {
        /**
         * Proxy.newProxyInstance 包括三個參數
         * 第一個參數:定義代理類的 classloader,一般用被代理接口的 classloader
         * 第二個參數:需要被代理的接口列表
         * 第三個參數:實現了 InvocationHandler 接口的對象
         * 返回值:代理對象
         */
        Writer writer = (Writer) Proxy.newProxyInstance(
                Writer.class.getClassLoader(),
                new Class[]{Writer.class},
                new FileWriterInvocationHandler(new FileWriter())); //這就是動態的原因,運行時才創建代理類

        try {
            writer.write("file1.txt", "text"); //調用代理對象的write方法
        } catch (Exception e) {
            e.printStackTrace();
        }
        writer.write("file2.txt", new byte[]{}); //調用代理對象的write方法
    }
}

最關的一步是 Proxy.newProxyInstance ,該調用會創建代理對象,該代理對象會將我們需要代理的接口(Writer)和 InvocationHandler 實現類關聯起來。這樣代理對象就會有 Writer 接口的 2 個方法,針對我們的業務邏輯調用過程為:調用代理對象 writer 的 write 方法寫數據 -> 轉到 FileWriterInvocationHandler 對象的 invoke 方法,判斷磁盤空間是否夠用 -> 拋出磁盤空間不足異常或調用 FileWriter 對象的 write 方法寫數據。在這里動態代理涉及到了 Writer 接口及其實現類、InvocationHandler 接口及其實現類、代理類。動態代理 UML 類圖如下:

可以看到代理類 Proxy 實現了 Writer 接口,因此可以調用 write 方法,同時代理類關聯 FileWriterInvocationHandler ,因此對 write 方法的調用會變成對 invoke 方法的調用。

至此,新的需求就完成了,我們結合代理模式談談此次需求變更我們用到了哪些好的設計原則。

1. 我們沒有在原有 FileWriter 實現類中修改代碼, 而是新增了 FileInvocationHandler 實現新需求,這符合設計原則中的開閉原則,即:對擴展開發對修改封閉。改動現有代碼容易影響已有的正常代碼

2. 我們增加代理之后只是把 Writer writer = new FileWriter() 改為 Writer writer = Proxy.newProxyInstance(...),由於都繼承了 Writer 接口,因此不需要修改 writer 的類型, 這符合面向接口的設計原則,讓我們盡量少的改動現有代碼

動態代理還有一個重要的應用場景,我們可以在 invoke 方法中把待調用的方法名(method)和參數(args)發送到遠程服務器,在遠程服務器中完成調用並返回一個結果,這其實就是 RPC (remote procedure call),即:遠程過程調用。我在閱讀 Hadoop 源碼過程中發現 Hadoop RPC 將動態代理技術應用在上述場景中。

遠程代理 

個人覺得上述動態代理第二個應用場景算是遠程代理的一個特例,因為遠程代理不一定非要動態創建代理對象。接下來我們以 Java RMI 為例, 簡單看下遠程代理。RMI(remote method invocation)即:遠程方法調用,與 RPC 類似,可以讓我們像調用 Java 本地方法一樣,調用遠程的方法。這里就需要一個代理對象,它實現了本地的接口,其中序列化/反序列化以及網絡傳輸都在代理對象中實現, 對我們透明,這也是控制了我們對遠程對象的訪問。代碼如下:

import java.rmi.Remote;
import java.rmi.RemoteException;

/**
 * 定義一個接口,接口中的方法要在遠程調用
 */
public interface MyRemote extends Remote {
    public String sayHello() throws RemoteException;
}
import java.net.MalformedURLException;
import java.rmi.Naming;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;

/**
 * 定義一個接口的遠程實現類
 * 為了讓遠程對象擁有 “遠程的” 功能,需要繼承 UnicastRemoteObject 類
 */
public class MyRemoteImpl extends UnicastRemoteObject implements MyRemote {

    protected MyRemoteImpl() throws RemoteException {
    }

    /**
     * 客戶端通過 rmi 代理對象調用 sayHello 方法,將會進入到此方法
     * @return
     * @throws RemoteException
     */
    @Override
    public String sayHello() throws RemoteException {
        System.out.println("req from client.");
        return "Server says, 'Hey'";
    }

    /**
     * 啟動遠程進程的 main 方法
     * @param args
     */
    public static void main(String[] args) {
        try {
            MyRemote service = new MyRemoteImpl();
            Naming.rebind("RemoteHello", service); //將服務名和對應的服務進行綁定,客戶端會根據 RemoteHello 找到遠程服務
        } catch (RemoteException e) {
            e.printStackTrace();
        } catch (MalformedURLException e) {
            e.printStackTrace();
        }
    }
}

這樣我們的遠程服務已經寫好了,還需要做以下 3 個工作來啟動遠程服務

1. 生成客戶端代理類,需要在 MyRemoteImpl.class 所在的目錄中執行 rmic MyRemoteImpl 命令,將會生成 MyRemoteImpl_Stub.class 類。首先,rmic 命令是 jdk 自帶命令,所在的目錄與 java 和 javac 所在的目錄一樣;其次,我用的 Idea 創建的普通 Java 工程,我的 MyRemoteImpl.class 文件在“E:\backends\java-backends\java-ex\out\production\java-ex”目錄中,以我的工程為例,路徑以及命令執行如下:

E:\backends\java-backends\java-ex\out\production\java-ex>rmic MyRemoteImpl

2. 啟動 rmiregistry,為了遠程服務可以注冊服務名,在我們的 class 所在的目錄(“項目目錄\out\production\java-ex”)中執行 rmiregistry 命令

E:\backends\java-backends\java-ex\out\production\java-ex>rmiregistry

3. 運行 MyRemoteImpl 類,啟動遠程服務進程

繼續編寫客戶端訪問代碼,客戶端代碼主要是找到剛剛注冊的 RemoteHello 遠程服務,並獲得代理對象,調用代理對象上的方法。我們可以在同一個工程下,創建 MyRemoteClient 類

import java.net.MalformedURLException;
import java.rmi.Naming;
import java.rmi.NotBoundException;
import java.rmi.RemoteException;

public class MyRemoteClient {
    public static void main(String[] args) {
        try {
            /**
             * 找到遠程服務,並返回代理對象
             * 該代理對象就是 MyRemoteImpl_Stub 且實現了 MyRemote 接口
             */
            MyRemote service = (MyRemote) Naming.lookup("rmi://127.0.0.1/RemoteHello");
            /**
             * 調用代理對象的 sayHello 方法,便會通過代理將調用發送到遠程服務進程並返回結果
             */
            String ret = service.sayHello();
            System.out.println(ret); 
        } catch (RemoteException e) {
            e.printStackTrace();
        } catch (NotBoundException e) {
            e.printStackTrace();
        } catch (MalformedURLException e) {
            e.printStackTrace();
        }
    }
}

我們可以直接運行 MyRemoteClient 類,可以看到在剛啟動的 MyRemoteImpl 進程中,控制台打印了 

req from client.

在 MyRemoteClient 進程的控制台中打印了

Server says, 'Hey'

至此我們的遠程代理已經介紹完畢。

虛擬代理

虛擬代理是作為創建開銷大的對象的替身。舉一個我們常見的例子,在 Web 開發或者移動端開發的時候經常會用到 Image 組件,Image 組件一般要傳入一個 URL 參數,從網絡上下載圖片到本地展示。假設這個組件要等到圖片下載完成才有顯示,那如果圖片較大或者網絡較慢,給用戶造成不好的體驗。解決方法是我們可以先顯示一個 loading 狀態的默認的本地圖片,當遠程圖片下載完成后重新渲染,替換掉當前的 laoding 狀態的圖片。用虛擬代理來實現這個技術就可以定義一個 ImageProxy 類型,在該類中初始時候先展示一個默認圖片,啟動線程創建 Image 對象,Image 對象創建完畢,再重新渲染,替換默認圖片。虛擬代理也是控制了對 Image 對象的訪問。

總結

本章主要介紹了代理模式,並且我們看到了代理模式常用的幾種變形,同時也接觸了面向對象的基本的設計原則

動態代理 - 程序運行時動態地創建代理對象,所有的對代理對象方法的調用都會變成對 InvocationHandler 的 invoke 方法的調用

遠程代理 - 本地調用代理對象訪問遠程的方法,無需關心網絡通信細節,跟調用本地方法一樣

虛擬代理 - 為了創建開銷大的對象而存在

可以看到代理模式最核心就是控制,代理對象的目的就是控制對真實對象的訪問。

本章主要參考《Head First 設計模式》


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM