可運行的Java RMI示例和踩坑總結

簡述

資料參考:

RMI特點

  • Java原生提供
  • 可以根據一個名字來獲取遠程對象
  • 調用遠程對象的方法時,RMI屏蔽了底層通信細節,與遠程通信就像調用本地方法一樣
  • 遠程動態加載類的定義,這是RMI非常獨特的功能

遠程對象

面向接口,接口的實現類可以位於不同的JVM,這些用於遠程調用的實現類稱為 remote objects (遠程對象)。

遠程對象有如下特徵:

  • 實現接口java.rmi.Remote
  • 對象中的每個方法必須聲明可能拋出java.rmi.RemoteException

RMI對於從另一個虛擬機傳遞過來的遠程對象視為和本地對象一樣。客戶端使用stub來作為遠程對象的代理,對stub進行方法調用,會反映到遠程對象的方法調用上,stub對象實現了與遠程對象相同的接口。

使用RMI構建分佈式應用

後續簡稱提供遠程調用服務的為服務端,使用遠程服務的為客戶端。

有如下步驟:

  • 設計和實現應用中的組件,確定哪些對象需要被遠程訪問,然後定義遠程接口(接口中是可被遠程調用的方法),客戶端僅僅存在接口的定義而沒有實現,服務端提供實現。
  • 編譯資源
  • 對類進行標記,使其可以通過網絡傳輸,類的定義會通過網絡進行傳輸到另一個JVM上
  • 啟動RMI倉庫、服務端和客戶端

實戰

本示例旨在使用RMI技術來構造一個通用的計算引擎,接收多個客戶端的自定義任務,運算後返回結果。任務由一個特定接口來抽象,具體要做什麼由客戶端來定義。RMI動態加載任務代碼到計算引擎的JVM中,再執行任務,這種系統通常叫做behavior-based application面向行為的應用

後續為了使條理更清晰,會在標題指出類所在的工程和包名。

示例運行環境:openjdk1.8

編寫RMI服務端程序

設計遠程接口(位於server程序中的com.test.rmi.common包)

// 描述了客戶端的任務
// RMI使用jdk序列化來傳輸對象,所以這個Task的實現類必須要實現 java.io.Serializable 標記接口
public interface Task<T> {
    T execute();
}

// 接收遠程任務Task,執行後返回結果
// 這個接口拓展了Remote,實現了這個接口的對象就稱為遠程對象
public interface Compute extends Remote {
    // 支持被遠程調用的方法,這個方法必須要聲明,可能會拋出 RemoteException,當出現協議錯誤或通信錯誤時,RMI框架會拋出這個異常
    <T> T executeTask(Task<T> t) throws RemoteException;
}

實現遠程接口(位於server程序中的com.test.rmi.server包)

RMI服務端需要在運行時創建和實例化這些遠程對象,並把他們暴露出去

// 實現遠程任務接口,當前實現類就是遠程對象了
public class ComputeEngine implements Compute {
    // 實現遠程接口中的方法
    @Override
    public <T> T executeTask(Task<T> t) {
        // 返回的可能是任意類型,這些類型必須要實現Serializable接口
        // 除了 static 或 transient 以外的字段都會被序列化傳輸
        return t.execute();
    }
    public static void main(String[] args) {
        if (System.getSecurityManager() == null) {
            // 註冊 SecurityManager,用於保護本地資源
            // 因為RMI會下載遠程的類到本地來運行,SecurityManager會判斷這些代碼是否有權限執行某些操作
            // 如果不註冊這個,RMI不會執行遠程代碼
            System.setSecurityManager(new SecurityManager());
        }
        try {
            // 創建和導出遠程對象
            // 只有導出之後的對象才可以被其他客戶端遠程調用
            String name = "Compute";
            Compute engine = new ComputeEngine();
            // 指定監聽的服務端口為0,即運行時會隨機選中一個可用的端口來使用
            // 導出成功後返回的stub對象必須是遠程接口類型
            Compute stub = (Compute) UnicastRemoteObject.exportObject(engine, 0);
            // 註冊遠程對象到RMI倉庫中(或其他命名服務)
            // RMI倉庫是一種特殊的遠程對象,用於根據名字查找其他遠程對象,可以使客戶端根據名字獲取遠程對象的引用
            // LocateRegistry有其他靜態方法可以創建一個新的RMI倉庫,這裡先不用
            // getRegistry方法不指定參數的話,則默認從本地的1099端口中獲取RMI倉庫,可以指定為其他端口
            Registry registry = LocateRegistry.getRegistry();
            // rebind是一個對RMI倉庫的遠程調用,所以這個方法可能拋出 RemoteException
            registry.rebind(name, stub);
            System.out.println("ComputeEngine bound");
            // 這裡不需要使用阻塞來保持main線程的存活
            // 因為只要 ComputeEngine 註冊到了外部的RMI倉庫上(RMI倉庫持有了這個對象的引用), 這個遠程對象就不會被GC,RMI框架就會保持當前線程的存活
        } catch (Exception e) {
            System.err.println("ComputeEngine exception:");
            e.printStackTrace();
        }
    }
}

以上指定了SecurityManager,所以需要再創建一個文件,如名為server.policy,內容如下:

grant codeBase "file:C:\\Users\\94713\\Downloads\\decorator-master\\target\\classes" {
    permission java.security.AllPermission;
};

以上指定的路徑為我本地idea工程的輸出類目錄,實際運行時指定為jar包所在的路徑即可。

指定這個的用途是使JVM對特定路徑下的代碼文件進行權限控制,如上面就賦予所有執行權限,因為這是我本地的代碼,所以完全信任是沒有問題的。

編寫RMI客戶端程序

復用遠程接口(位於client程序中的com.test.rmi.common包)

復用與Server端相同的遠程接口,直接拷貝server端的com.test.rmi.common

// 描述了客戶端的任務
// RMI使用jdk序列化來傳輸對象,所以這個Task的實現類必須要實現 java.io.Serializable 標記接口
public interface Task<T> {
    T execute();
}

// 接收遠程任務Task,執行後返回結果
// 這個接口拓展了Remote,實現了這個接口的對象就稱為遠程對象
public interface Compute extends Remote {
    // 支持被遠程調用的方法,這個方法必須要聲明,可能會拋出 RemoteException,當出現協議錯誤或通信錯誤時,RMI框架會拋出這個異常
    <T> T executeTask(Task<T> t) throws RemoteException;
}

定義客戶端任務(位於client程序中的com.test.rmi.client包)

// 因為任務需要被傳輸,所以除了要實現Task接口以外,還要實現序列化接口
public class Pi implements Task<BigDecimal>, Serializable {
    private int taskData;
    public Pi(int taskData) {
        this.taskData = taskData;
    }
    private static final long serialVersionUID = 227L;

    @Override
    public BigDecimal execute() {
        System.out.println("這裡進行複雜計算");
        // 模擬複雜任務,對數據+2返回
        return BigDecimal.valueOf(taskData + 2);
    }
}

開始調用

public class ComputePi {
    public static void main(String args[]) {
        // 和服務端一樣,也是為了安全,因為客戶端也會下載服務端中的代碼來執行,如獲取調用的返回值
        if (System.getSecurityManager() == null) {
            System.setSecurityManager(new SecurityManager());
        }
        try {

            String name = "Compute";
            // 根據一個host來獲取RMI倉庫,默認端口為1099
            Registry registry = LocateRegistry.getRegistry("127.0.0.1");
            // 根據名字從RMI倉庫中查找遠程對象
            Compute comp = (Compute) registry.lookup(name);
            Pi task = new Pi(54);
            BigDecimal pi = comp.executeTask(task);
            System.out.println(pi);
        } catch (Exception e) {
            System.err.println("ComputePi exception:");
            e.printStackTrace();
        }
    }    
}

和server端一樣,客戶端這裡也需要創建一個權限文件,我這裡名為client.policy,內容為

grant codeBase "file:C:/Users/94713/Desktop/demo/target/classes" {
    permission java.security.AllPermission;
};

指定了client工程的輸出類目錄,作用在server端已解釋過,這裡不再贅述。

這裡存在三者關係:客戶端、服務端、RMI倉庫

  • 客戶端從RMI倉庫中獲取遠程對象
  • 遠程對象實際存在於服務端
  • 客戶端對遠程對象進行方法調用,本質上是觸發了服務端內的運算

示例中很關鍵的特點是:服務端要執行Pi這個運算任務,卻不需要Pi這個任務類的定義,因為它運行時會從網絡傳遞到服務端,實現了服務端與具體任務類的解耦

編譯和運行程序

啟動RMI倉庫服務程序(jdk1.7以後需要指定參數useCodebaseOnly為false,否則會提示類找不到)

rmiregistry -J-Djava.rmi.server.useCodebaseOnly=false

遠程調用過程中需要提供類定義的下載,所以需要再啟動一個靜態文件服務。

我這裡使用nodejs的一個第三方靜態服務anywhere,能將指令運行的目錄作為根目錄,端口默認為8000

anywhere

此時的靜態文件服務中沒有文件,先不用放文件進去,等下再放。

指定參數運行服務端程序:

-Djava.rmi.server.codebase=//127.0.0.1:8000/  -Djava.security.policy=C:\Users\94713\Desktop\p\server.policy
  • codebase指定的路徑為剛剛部署的靜態服務根目錄
  • policy指定的路徑為服務端的權限文件

運行起來之後會報錯,提示有些類找不到,把對應缺少的類從服務端拷貝到靜態文件服務的根目錄上即可,如把com\test\rmi\common\Task.class連同包名目錄一起拷貝過去,因為下載時就是根據類的全限定名轉換成目錄層級來查找下載的。

指定參數運行客戶端

-Djava.security.policy=C:\Users\94713\Desktop\p\client.policy

也把提示缺少的類從客戶端程序拷貝到靜態服務上即可,此時程序能正常運行。

過程總結

以上忽略了一些我采坑的過程,這裡直接給出結論。

服務端往RMI倉庫中註冊遠程時,是先進行jdk序列化,傳輸到RMI倉庫,傳輸的數據僅僅是對象的成員屬性,而沒有類的定義,所以RMI倉庫反序列化時必須從某個地方下載類的定義,才能反序列成功。這個下載的地方就是服務端指定的運行參數-Djava.rmi.server.codebase=//127.0.0.1:8000/

沒有這個靜態服務或者靜態服務中沒有對應的class文件的話,則服務端運行會報錯,提示類找不到。其實這個錯誤的堆棧信息不是對應服務端的,而是對應RMI倉庫程序的,倉庫那邊報錯了,收集好堆棧信息後反饋給服務端,服務端再拋出來而已。

服務端和客戶端存在一些公共的接口,他們的全限定名必須一致,否則運行過程中進行類型轉換就會報錯:

java.lang.ClassCastException: com.sun.proxy.$Proxy0 cannot be cast to com.example.agent.rmi.Compute
	at com.example.agent.rmi.ComputePi.main(ComputePi.java:19)

所以更好的做法是將公共的類打成一個jar包,然後將jar包拷貝給服務端和客戶端。直接拷貝java文件和對應的包層級到客戶端或服務端中容易出錯

啟動RMI倉庫時,官方的運行示例是不帶參數的,而我的示例中添加了一個參數-Djava.rmi.server.useCodebaseOnly=false,這是因為jdk7以後有了變化,不加這個參數會導致RMI倉庫程序要反序列類時不會從我指定的codebase路徑中去下載,就會提示類找不到。(網上對此的解決辦法是將類添加到RMI倉庫程序的classpath上也能解決,但是在是太不優雅了而且麻煩)

關於SecurityManager。以上客戶端和服務端都指定了,這是為了安全考慮,如服務端要接受任務來執行、客戶端接收任務的返回值,這兩個過程都可能需要從外部下載類的定義,並且運行類。被運行的類可能是很不安全的,所以直接運行可能導致出現嚴重後果,所以需要對這些代碼做權限控制。

具體的控制辦法就是在權限文件中完全信任本地的代碼,除了本地的代碼以外不信任。這樣外部的代碼運行權限就會小很多。如此時外部的代碼想要連接某個外部服務,程序不會執行連接行為,並且會報錯:

java.security.AccessControlException: access denied ("java.net.SocketPermission" "127.0.0.1:1099" "connect,resolve")

從而限制了外部代碼的行為,保障本地程序的安全。

這樣權限很低的代碼具體能做什麼,這點我還沒去研究。但從以上示例中可以看出,執行簡單的數字運算和控制台輸出是沒問題的。