Java 遠程方法調用(Remote Method Invocation, RMI)使得運行在一個 JAVA 虛擬機器(Java Virtual Machine, JVM)的對象可以調用運行另一個 JVM 之上的其他對象的方法,從而提供了程式間進行遠程通訊的途徑。RMI 是 J2EE 的很多分布式技術的基礎,比如 RMI-IIOP 乃至 EJB。本文是 RMI 的一個入門指南,目的在於協助讀者快速建立對 Java RMI 的一個感性認識,以便進行更深層次的學習。事實上,如果你瞭解 RMI 的目的在於更好的理解和學習 EJB,那麼本文就再合適不過了。通過本文所瞭解的 RMI 的知識和技巧,應該足夠服務於這個目的了。
1. 簡介
我們知道遠端程序呼叫(Remote Procedure Call, RPC)可以用於一個進程調用另一個進程(很可能在另一個遠程主機上)中的過程,從而提供了過程的分布能力。Java 的 RMI 則在 RPC 的基礎上向前又邁進了一步,即提供分布式 對象間的通訊,允許我們獲得在遠程進程中的對象(稱為遠程對象)的引用(稱為遠端參照),進而通過引用調用遠程對象的方法,就好像該對象是與你的用戶端代碼同樣運行在本地進程中一樣。RMI 使用了術語"方法"(Method)強調了這種進步,即在分布式基礎上,充分支援物件導向的特性。
RMI 並不是 Java 中支援遠程方法調用的唯一選擇。在 RMI 基礎上發展而來的 RMI-IIOP(Java Remote Method Invocation over the Internet Inter-ORB Protocol),不但繼承了 RMI 的大部分優點,並且可以相容於 CORBA。J2EE 和 EJB 都要求使用 RMI-IIOP 而不是 RMI。儘管如此,理解 RMI 將大大有助於 RMI-IIOP 的理解。所以,即便你的興趣在 RMI-IIOP 或者 EJB,相信本文也會對你很有協助。另外,如果你現在就對 API 感興趣,那麼可以告訴你,RMI 使用 java.rmi 包,而 RMI-IIOP 則既使用 java.rmi 也使用擴充的 javax.rmi 包。
本文的隨後內容將僅針對 Java RMI。
2. 分布式對象
在學習 RMI 之前,我們需要瞭解一些基礎知識。首先需要瞭解所謂的分布式對象(Distributed Object)。分布式對象是指一個對象可以被遠程系統所調用。對於 Java 而言,即對象不僅可以被同一虛擬機器中的其他客戶程式(Client)調用,也可以被運行於其他虛擬機器中的客戶程式調用,甚至可以通過網路被其他遠程主機之上的客戶程式調用。
下面的圖示說明了客戶程式是如何調用分布式對象的:
從圖上我們可以看到,分布式對象被調用的過程是這樣的:
客戶程式調用一個被稱為 Stub (有時譯作存根,為了不產生歧義,本文將使用其英文形式)的用戶端代理對象。該代理對象負責對用戶端隱藏網路通訊的細節。Stub 知道如何通過網路通訊端(Socket)發送調用,包括如何將調用參數轉換為適當的形式以便傳輸等。
Stub 通過網路將調用傳遞到伺服器端,也就是分布對象一端的一個被稱為 Skeleton 的代理對象。同樣,該代理對象負責對分布式對象隱藏網路通訊的細節。Skeleton 知道如何從網路通訊端(Socket)中接受調用,包括如何將調用參數從網路傳輸形式轉換為 Java 形式等。
Skeleton 將調用傳遞給分布式對象。分布式對象執行相應的調用,之後將傳回值傳遞給 Skeleton,進而傳遞到 Stub,最終返回給客戶程式。
這個情境基於一個基本的法則,即行為的定義和行為的具體實現相分離。,用戶端代理對象 Stub 和分布式對象都實現了相同的介面,該介面稱為遠程介面(Remote Interface)。正是該介面定義了行為,而分布式對象本身則提供具體的實現。對於 Java RMI 而言,我們用介面(interface)定義行為,用類(class)定義實現。
3. RMI 架構
RMI 的底層架構由三層構成:
首先是 Stub/Skeleton 層。該層提供了客戶程式和服務程式彼此互動的介面。
然後是遠端參照(Remote Reference)層。這一層相當於在其之上的 Stub/Skeleton 層和在其之下的傳輸協議層之前的中介軟體,負責處理遠程對象引用的建立和管理。
最後是傳輸協議(Transport Protocol) 層。該層提供了資料協議,用以通過線路傳輸客戶程式和遠程對象間的請求和應答。
這些層之間的互動可以參照下面的:
和其它分布式對象機制一樣,Java RMI 的客戶程式使用用戶端的 Stub 向遠程對象要求方法調用;伺服器對象則通過伺服器端的 Skeleton 接受請求。我們深入進去,來看看其中的一些細節。
注意: 事實上,在 Java 1.2 之後,RMI 不再需要 Skeleton 對象,而是通過 Java 的反射機制(Reflection)來完成對伺服器端的遠程對象的調用。為了便於說明問題,本文以下內容仍然基於 Skeleton 來講解。
當客戶程式調用 Stub 時,Stub 負責將方法的參數轉換為序列化(Serialized)形式,我們使用一個特殊的術語,即編列(Marshal)來指代這個過程。編列的目的是將這些參數轉換為可移植的形式,從而可以通過網路傳輸到遠端服務物件一端。不幸的是,這個過程沒有想象中那麼簡單。這裡我們首先要理解一個經典的問題,即方法調用時,參數究竟是傳值還是傳引用呢?對於 Java RMI 來說,存在四種情況,我們將分別加以說明。
對於基本的原始類型(整型,字元型等等),將被自動的序列化,以傳值的方式編列。
對於 Java 的對象,如果該對象是可序列化的(實現了 java.io.Serializable 介面),則通過 Java 序列化機制自動地加以序列化,以傳值的方式編列。對象之中包含的原始類型以及所有被該對象引用,且沒有聲明為transient 的對象也將自動的序列化。當然,這些被引用的對象也必須是可序列化的。
絕大多數內建的 Java 對象都是可序列化的。 對於不可序列化的 Java 對象(java.io.File 最典型),或者對象中包含對不可序列化,且沒有聲明為 transient 的其它對象的引用。則編列過程將向客戶程式拋出異常,而宣告失敗。
客戶程式可以調用遠程對象,沒有理由禁止調用參數本身也是遠程對象(實現了 java.rmi.Remote 介面的類的執行個體)。此時,RMI 採用一種類比的傳引用方式(當然不是傳統意義的傳引用,因為本地對記憶體的引用到了遠程變得毫無意義),而不是將參數直接編列複製到遠程。這種情況下,互動的雙方發生的戲劇性變化值得我們注意。參數是遠程對象,意味著該參數對象可以遠程調用。當客戶程式指定遠程對象作為參數調用伺服器端遠程對象的方法時,RMI 的運行時機制將向伺服器端的遠程對象發送作為參數的遠程對象的一個 Stub 對象。這樣伺服器端的遠程對象就可以回調(Callback)這個 Stub 對象的方法,進而調用在用戶端的遠程對象的對應方法。通過這種方法,伺服器端的遠程對象就可以修改作為參數的用戶端遠程對象的內部狀態,這正是傳統意義的傳引用所具備的特性。是不是有點暈?這裡的關鍵是要明白,在分布式環境中,所謂伺服器和用戶端都是相對的。被請求的一方就是伺服器,而發出請求的一方就是用戶端。
在調用參數的編列過程成功後,用戶端的遠端參照層從 Stub 那裡獲得了編列後的參數以及對伺服器端遠程對象的遠端參照(參見 java.rmi.server.RemoteRef API)。該層負責將客戶程式的請求依據底層的 RMI 資料轉送協議轉換為傳輸層請求。在 RMI 中,有多種的可能的傳輸機制,比如點對點(Point-to-Point)以及廣播(Multicast)等。不過,在當前的 JMI 版本中只支援點對點通訊協定 (PPP),即遠端參照層將產生唯一的傳輸層請求,發往指定的唯一遠程對象(參見 java.rmi.server.UnicastRemoteObject API)。
在伺服器端,伺服器端的遠端參照層接收傳輸層請求,並將其轉換為對遠程對象的伺服器端代理對象 Skeleton 的調用。Skeleton 對象負責將請求轉換為對實際的遠程對象的方法調用。這是通過與編列過程相對的反編列(Unmarshal)過程實現的。所有序列化的參數被轉換為 Java 形式,其中作為參數的遠程對象(實際上發送的是遠端參照)被轉換為伺服器端本地的 Stub 對象。
如果方法調用有傳回值或者拋出異常,則 Skeleton 負責編列傳回值或者異常,通過伺服器端的遠端參照層,經傳輸層傳遞給用戶端;相應地,用戶端的遠端參照層和 Stub 負責反編列並最終將結果返回給客戶程式。
整個過程中,可能最讓人迷惑的是遠端參照層。這裡只要明白,本地的 Stub 對象是如何產生的,就不難理解遠端參照的意義所在了。遠端參照中包含了其所指向的遠程對象的資訊,該遠端參照將用於構造作為本地代理對象的 Stub 對象。構造後,Stub 對象內部將維護該遠端參照。真正在網路上傳輸的實際上就是這個遠端參照,而不是 Stub 對象。
4. RMI 物件服務
在 RMI 的基本架構之上,RMI 提供服務與分布式應用程式的一些物件服務,包括對象的命名/註冊(Naming/Registry)服務,遠程對象啟用(Activation)服務以及分布式垃圾收集(Distributed Garbage Collection, DGC)。作為入門指南,本文將指介紹其中的命名/註冊服務,因為它是實戰 RMI 所必備的。其它內容請讀者自行參考其它更加深入的資料。
在前一節中,如果你喜歡刨根問底,可能已經注意到,用戶端要調用遠程對象,是通過其代理對象 Stub 完成的,那麼 Stub 最早是從哪裡得來的呢?RMI 的命名/註冊服務正是解決這一問題的。當伺服器端想向用戶端提供基於 RMI 的服務時,它需要將一個或多個遠程對象註冊到本地的 RMI 註冊表中(參見java.rmi.registry.RegistryAPI)。每個對象在註冊時都被指定一個將來用於客戶程式引用該對象的名稱。客戶程式通過命名服務(參見java.rmi.Naming API),指定類似 URL 的對象名稱就可以獲得指向遠程對象的遠端參照。在 Naming 中的lookup() 方法找到遠程對象所在的主機後,它將檢索該主機上的 RMI 註冊表,並請求所需的遠程對象。如果註冊表發現被請求的遠程對象,它將產生一個對該遠程對象的遠端參照,並將其返回給用戶端,用戶端則基於遠端參照產生相應的 Stub 對象,並將引用傳遞給調用者。之後,雙方就可以按照我們前面講過的方式進行互動了。
注意: RMI 命名服務提供的 Naming 類並不是你的唯一選擇。RMI 的註冊表可以與其他命名服務綁定,比如 JNDI,這樣你就可以通過 JNDI 來訪問 RMI 的註冊表了。
5. 實戰 RMI
理論離不開實踐,理解 RMI 的最好辦法就是通過例子。開發 RMI 的分布式對象的大體過程包括如下幾步:
定義遠程介面。這一步是通過擴充 java.rmi.Remote 介面,並定義所需的業務方法實現的。
定義遠程介面的實作類別。即實現上一步所定義的介面,給出業務方法的具體實現邏輯。
編譯遠程介面和實作類別,並通過 RMI 編譯器 rmic 基於實作類別產生所需的 Stub 和 Skeleton 類。
RMI 中各個組件之間的關係如下面這個所示:
回憶我們上一節所講的,Stub 和 Skeleton 負責代理客戶和伺服器之間的通訊。但我們並不需要自己產生它們,相反,RMI 的編譯器 rmic 可以幫我們基於遠程介面和實作類別產生這些類。當用戶端對象通過命名服務向伺服器端的 RMI 註冊表請求遠程對象時,RMI 將自動構造對應遠程對象的 Skeleton 執行個體對象,並通過 Skeleton 對象將遠端參照返回給用戶端。在用戶端,該遠端參照將用於構造 Stub 類的執行個體對象。之後,Stub 對象和 Skeleton 對象就可以代理客戶對象和遠程對象之間的互動了。
我們的例子展現了一個簡單的應用情境。伺服器端部署了一個計算引擎,負責接受來自用戶端的計算任務,在伺服器端執行計算任務,並將結果返回給用戶端。用戶端將發送並調用計算引擎的計算任務實際上是計算指定精度的 π 值。
重要: 本文的例子改編自 The Java Tutorial Trail:RMI。所有權利屬於相應的所有人。
6. 定義遠程介面
定義遠程介面與非分布式應用中定義介面的方法沒有太多的區別。只要遵守下面兩個要求:
注意: 在 Java 1.2 之前,上面關於拋出異常的要求更嚴格,即必須拋出java.rmi.RemoteExcption,不允許類似 java.io.IOException 這樣的超類。現在之所以放寬了這一要求,是希望可以使定義既可以用於遠程對象,也可以用於本機物件的介面變得容易一些(想想 EJB 中的本地介面和遠程介面)。當然,這並沒有使問題好多少,你還是必須聲明異常。不過,一種觀點認為這不是問題,強制聲明異常可以使開發人員保持清醒的頭腦,因為遠程對象和本機物件在調用時傳參的語意是不同的。本機物件是傳引用,而遠程對象主要是傳值,這意味對參數內部狀態的修改產生的結果是不同的。
對於第一個要求,java.rmi.Remote 介面實際上沒有任何方法,而只是用作標記介面。RMI 的運行環境依賴該介面判斷對象是否是遠程對象。第二個要求則是因為分布式應用可能發生任何問題,比如網路問題等等。
例 1 列出了我們的遠程介面定義。該介面只有一個方法:executeTask() 用以執行指定的計算任務,並返回相應的結果。注意,我們用尾碼 Remote 表明介面是遠程介面。
例 1. ComputeEngineRemote 遠程介面
package rmitutorial; import java.rmi.Remote; import java.rmi.RemoteException; public interface ComputeEngineRemote extends Remote { public Object executeTask(Task task) throws RemoteException; }
例 2 列出了計算任務介面的定義。該介面也只有一個方法:execute() 用以執行實際的計算邏輯,並返回結果。注意,該介面不是遠程介面,所以沒有擴充 java.rmi.Remote 介面;其方法也不必拋出 java.rmi.RemoteException異常。但是,因為它將用作遠程方法的參數,所以擴充了 java.io.Serializable 介面。
例 2. Task 介面
package rmitutorial; import java.io.Serializable; public interface Task extends Serializable { Object execute(); }
7. 實現遠程介面
接下來,我們將實現前面定義的遠程介面。例 3給出了實現的原始碼。
例 3. ComputeEngine 實現
package rmitutorial; import java.rmi.RemoteException; import java.rmi.server.UnicastRemoteObject; public class ComputeEngine extends UnicastRemoteObject implements ComputeEngineRemote { public ComputeEngine() throws RemoteException { super(); } public Object executeTask(Task task) throws RemoteException { return task.execute(); } }
類 ComputeEngine 實現了之前定義的遠程介面,同時繼承自 java.rmi.server.UnicastRemoteObject 超類。UnicastRemoteObject 類是一個便捷類,它實現了我們前面所講的基於 TCP/IP 的點對點通訊機制。遠程對象都必須從該類擴充(除非你想自己實現幾乎所有 UnicastRemoteObject 的方法)。在我們的實作類別的建構函式中,調用了超類的建構函式(當然,即使你不顯式的調用這個構建函數,它也一樣會被調用。這裡這樣做,只是為了突出強調這種調用而已)。該建構函式的最重要的意義就是調用 UnicastRemoteObject 類的 exportObject() 方法。匯出(Export)對象是指使遠程對象準備就緒,可以接受進來的調用的過程。而這個過程的最重要內容就是建立伺服器通訊端,監聽特定的連接埠,等待用戶端的調用請求。
8. 引導程式
為了讓客戶程式可以找到我們的遠程對象,就需要將我們的遠程對象註冊到 RMI 的註冊表。這個過程有時被稱為"引導"過程(Bootstrap)。我們將為此編寫一個獨立的引導程式負責建立和註冊遠程對象。例 4 給出了引導程式的原始碼。
例 4. 引導程式
package rmitutorial; import java.rmi.Naming; import java.rmi.RMISecurityManager; public class Bootstrap { public static void main(String[] args) throws Exception { String name = "ComputeEngine"; ComputeEngine engine = new ComputeEngine(); System.out.println("ComputerEngine exported"); Naming.rebind(name, engine); System.out.println("ComputeEngine bound"); } }
可以看到,我們首先建立了一個遠程對象(同時匯出了該對象),之後將該對象綁定到 RMI 註冊表中。Naming 的rebind() 方法接受一個 URL 形式的名字作綁定之用。其完整格式如下:
protocol://host:port/object
其中,協議(Protocol)預設為 rmi;主機名稱預設為 localhost;連接埠預設為 1099。注意,JDK 中提供的預設Naming 實現只支援 rmi 協議。在我們的引導程式裡面只給出了對象綁定的名字,而其它部分均使用預設值。
9. 用戶端程式
例 5 給出了我們的用戶端程式。該程式接受兩個參數,分別是遠程對象所在的主機地址和希望獲得的 π 值的精度。
例 5. Client.java
package rmitutorial; import java.math.BigDecimal; import java.rmi.Naming; public class Client { public static void main(String args[]) throws Exception { String name = "rmi://" + args[0] + "/ComputeEngine"; ComputeEngineRemote engineRemote = (ComputeEngineRemote)Naming.lookup(name); Pi task = new Pi(Integer.parseInt(args[1])); BigDecimal pi = (BigDecimal)(engineRemote.executeTask(task)); System.out.println(pi); } }
例 6. Pi.java
package rmitutorial; import java.math.*; public class Pi implements Task { private static final BigDecimal ZERO = BigDecimal.valueOf(0); private static final BigDecimal ONE = BigDecimal.valueOf(1); private static final BigDecimal FOUR = BigDecimal.valueOf(4); private static final int roundingMode = BigDecimal.ROUND_HALF_EVEN; private int digits; public Pi(int digits) { this.digits = digits; } public Object execute() { return computePi(digits); } public static BigDecimal computePi(int digits) { int scale = digits + 5; BigDecimal arctan1_5 = arctan(5, scale); BigDecimal arctan1_239 = arctan(239, scale); BigDecimal pi = arctan1_5.multiply(FOUR).subtract( arctan1_239).multiply(FOUR); return pi.setScale(digits, BigDecimal.ROUND_HALF_UP); } public static BigDecimal arctan(int inverseX, int scale) { BigDecimal result, numer, term; BigDecimal invX = BigDecimal.valueOf(inverseX); BigDecimal invX2 = BigDecimal.valueOf(inverseX * inverseX); numer = ONE.divide(invX, scale, roundingMode); result = numer; int i = 1; do { numer = numer.divide(invX2, scale, roundingMode); int denom = 2 * i + 1; term = numer.divide(BigDecimal.valueOf(denom), scale, roundingMode); if ((i % 2) != 0) { result = result.subtract(term); } else { result = result.add(term); } i++; } while (term.compareTo(ZERO) != 0); return result; } }
10. 編譯樣本程式
編譯我們的樣本程式和編譯其它非分布式的應用沒什麼區別。只是編譯之後,需要使用 RMI 編譯器,即 rmic 產生所需 Stub 和 Skeleton 實現。使用 rmic 的方式是將我們的遠程對象的實作類別(不是遠程介面)的全類名作為參數來運行 rmic 命令。參考下面的樣本:
E:/classes/rmic rmitutorial.ComputeEngine
編譯之後將產生 rmitutorial.ComputeEngine_Skel 和 rmitutorial.ComputeEngine_Stub 兩個類。
11. 運行樣本程式
遠程對象的引用通常是通過 RMI 的註冊表格服務以及 java.rmi.Naming 介面獲得的。遠程對象需要匯出(註冊)相應的遠端參照到註冊表格服務,之後註冊表格服務就可以監聽並服務於用戶端對遠程對象引用的請求。標準的 Sun Java SDK 提供了一個簡單的 RMI 註冊表格服務程式,即 rmiregistry 用於監聽特定的連接埠,等待遠程對象的註冊,以及用戶端對這些遠程對象引用的檢索請求。
在運行我們的樣本程式之前,首先要啟動 RMI 的註冊表格服務。這個過程很簡單,只要直接運行 rmiregistry 命令即可。預設的情況下,該服務將監聽 1099 連接埠。如果需要指定其它的監聽連接埠,可以在命令列指定希望監聽的連接埠(如果你指定了其它連接埠,需要修改樣本程式以適應環境)。如果希望該程式在後台運行,在 Unix 上可以以如下方式運行(當然,可以預設連接埠參數):
$ rmiregistry 1099 &
在 Windows 作業系統中可以這樣運行:
C:/> start rmiregistry 1099
我們的 rmitutorial.Bootstrap 類將用於啟動遠程對象,並將其綁定在 RMI 註冊表中。運行該類後,遠程對象也將進入監聽狀態,等待來自用戶端的方法調用請求。
$ java rmitutorial.Bootstrap ComputeEngine exported ComputeEngine bound
啟動遠程對象後,開啟另一個命令列視窗,運行用戶端。命令列的第一個參數為 RMI 註冊表的地址,第二個參數為期望的 π 值精度。參考下面的樣本:
$ java rmitutorial.Client localhost 50 3.14159265358979323846264338327950288419716939937511
12. 其它資訊
在示範樣本程式時,我們實際上是在同一主機上啟動並執行伺服器和用戶端,並且無論是伺服器和用戶端所需的類都在相同的類路徑上,可以同時被伺服器和用戶端所訪問。這忽略了 Java RMI 的一個重要細節,即動態類裝載。因為 RMI 的特性(包括其它幾個特性)並不適用於 J2EE 的 RMI-IIOP 和 EJB 技術,所以,本文將不作詳細介紹,請讀者自行參考本文給出的參考資料。不過,為了讓好奇的讀者不至於過分失望,這裡簡單介紹一下動態類裝載的基本思想。
RMI 運行時系統採用動態類裝載機制來裝載分布式應用所需的類。如果你可以直接存取應用所涉及的所有包括伺服器端用戶端在內的主機,並且可以把分布式應用所需的所有類都安裝在每個主機的 CLASSPATH 中(上面的樣本就是極端情況,所有的東西都在本地主機),那麼你完全不必關心 RMI 類裝載的細節。顯然,既然是分布式應用,情況往往正相反。對於 RMI 應用,用戶端需要裝載用戶端自身所需的類,將要調用的遠程對象的遠程介面類以及對應的 Stub 類;伺服器端則要裝載遠程對象的實作類別以及對應的 Skeleton 類(Java 1.2 之後不需要 Skeleton 類)。RMI 在處理遠程調用涉及的遠端參照,參數以及傳回值時,可以將一個指定的 URL 編碼到流中。互動的另一端可以通過 該 URL 獲得處理這些對象所需的類檔案。這一點類似於 Applet 中的 CODEBASE 的概念,互動的兩端通過 HTTP 伺服器發布各自控制的類,允許互動的另一端動態下載這些類。以我們的樣本為例,用戶端不必部署 ComputeEngine_Stub 的類檔案,而可以通過伺服器端的 HTTP 伺服器獲得類檔案。同樣,伺服器端也不需要用戶端實現的定製任務 Pi 的類檔案。
注意,這種動態類裝載將需要互動的兩端載入定製的安全管理器(參見 java.rmi.RMISecurityManager API),以及對應的策略檔案。
原文:http://www.blogjava.net/site120/archive/2007/11/08/RMI-Remote-Method-Invocation.html