如果你從事過Jini開發,你會知道Jini客戶端是不需要知道服務的位置的;它們簡單地通過發現機制來獲得一個代理以訪問它們需要的服務。相反,在RMI(遠程方法調用)中,你必須知道你想訪問的服務器的URL。在本文中,我們將向你展示怎樣為RMI實現一個類Jini的發現機制,這將使得一些客戶端從必須知道RMI服務器URL的麻煩中解脫出來。
你可能首先會想,為什麼要這麼麻煩;為什麼不干脆用Jini?我們也同意這樣的邏輯,特別是對新的系統來說。不管怎樣,已經有許多基於RMI的系統存在,並且在Jini被Java開發的主流接受以前,我們仍然要提供更優雅的RMI解決方案。事實上,我們在這兒描述的工作,是這樣的需求的結果:開發一項Jini服務使它同時可以作為一個獨立的RMI服務器運行,但使用類Jini的發現機制。
本文主要是針對沒有用過Jini的RMI開發者。通過深入觀察Jini內部的運作,我們希望你能開始了解Jini的機制有多麼強大。我們當然不是希望你重新實現Jini,但這篇文章能幫助你理解這些機制是怎樣運作的。甚至可能幫助你說服你的經理或部門頭頭,該考慮將Jini作為一項可行的分布式系統技術。
我們不會太深入Jini的發現機制,所以如果你對此不是很熟悉,我們建議你快速浏覽一下Bill Venners的"Locate Services with the Jini Lookup Service."( http://www.javaworld.com/Javaworld/jw-02-2000/jw-02-jiniology.Html)
RMI基礎和Jini查找
在RMI中,客戶端必須知道它所要連接的服務器的位置。RMI服務器的地址是URI的形式rmi://:/,其中端口號是rmiregistry用來偵聽請求的端口。例如:
Translator service
=(Translator)Naming.lookup("rmi://theHost/SpanishTranslator");
在Jini中,客戶端通過一個Jini工具類來找到服務,比如ServiceDiscoveryManager。在下面的例子中,我們創建了一個ServiceTemplate的實例,該實例包含一個類列表;在我們的例子中,是我們要匹配的類??Translator.class:
Class [] classes=new Class[]{Translator.class};
ServiceTemplate tmpl=new ServiceTemplate(null,classes,null);
ServiceDiscoveryManager lmgr=new ServiceDiscoveryManager(null,null);
ServiceItem serviceItem =lmgr.lookup(tmpl,null);
Translator service=serviceItem.service;
正如我們從例子中可以看到,ServiceDiscoveryManager用lookup()方法來查找任何與ServiceTemplate匹配的可用的服務。你還可以在服務查找中使用任何數字或屬性;在這裡我們出於保持簡單和精練的考慮而沒有這樣做。
比較兩種查找機制,你會注意到在Jini版本中沒有指定服務的位置。值得一提的是,如果必要,你也可以指定一個查找服務的位置,但不是你想要訪問的實際服務的位置。Jini模型的強大之處是,我們不需要知道或關心服務位於何處。
比較了RMI和Jini的發現機制之後,現在我們可以考慮怎樣用類Jini的風格來訪問一個RMI服務器。
位置中立的RMI查找
理想地,我們考慮查找Translator所發現的第一個匹配的實例。
Translator service
=(Translator)RMIDiscovery.lookup(clazz,id);
在這裡clazz是RMI服務的接口,id是區分實現clazz接口的不同服務器實例的唯一字符串標識。例如,要找到一個西班牙語翻譯器,我們用下面的代碼:
Class clazz=Translator.class;
String id="Spanish";
現在我們對如何使用RMI發現機制有了一個更好的主意,我們來研究一下怎樣實現它。在我們嘗試實現我們“簡陋的”RMI發現機制以前,先來看看Jini是怎樣做的,再把這些原理/概念適用到RMI服務器和客戶端上。
發現機制
Jini的基本發現機制聯合使用多播UDP(用戶數據報協議)(multicast UDP 見文後的Resources)和單播TCP/IP。簡單來說,這意味著客戶端發出一個多播的請求數據包,然後數據包被監聽它的查找服務拾取。然後查找服務用單播連接連回客戶端,並把查找服務的代理串行化成流通過此連接發送出去。此後客戶端就可以和查找服務(的代理)交互以定位它需要的服務。
發現機制實際上比這要復雜得多,但我們只對其中多播UDP和單播TCP/IP的關鍵概念感興趣。我們並不打算實現一個等同的獨立運行的RMI查找服務。相反我們將實現一個簡單的多播監聽器/單播分發器(multicast listener/unicast dispatcher)供RMI服務器使用,實際上我們使得每個RMI服務器作為它自己的查找服務。在客戶端,我們為服務器端socket寫個配對物??一個多播分發器/單播監聽器(multicast dispatcher/unicast listener)。
下面的表更詳細地說明了RMI客戶端和RMI服務器端間的交互。
RMI客戶端和RMI服務器端的交互
服務器端客戶端
在多播地址上開始監聽
建立ServerSocket以監聽來自服務器的單播響應。
開始向多播地址發送UDP數據包
解析收到的UDP數據包。如果有效,通
過單播TCP/IP連回客戶端。
向客戶端發送遠程代理(remote stub)。
從流中讀取遠程對象。
關閉ServerSocket。停止發送UDP多播數據包
開始使用服務。
發現協議
前面我們已經大致勾勒了客戶端怎樣發現服務器:它會指定一個接口類和一個唯一名字來確認一個服務器實例。這是因為多個實現相同接口的服務器可以同時運行。
在實現我們的RMI發現機制之前,我們必須為在參與者之間傳遞的消息定義一個協議。簡單起見,我們用含定界符的字符串來包含RMI服務器對匹配的請求作出響應所需的全部信息。首先,我們定義一個協議頭。這防止了服務器類嘗試解析其他來源的數據包。消息數據包的剩余部分將包含一個單播響應端口,服務器的接口類名字,和服務器實例的唯一標識符。
下面是我們將使用的發現請求消息的格式:
,,,
現在我們看看一個消息數據包的例子,這個數據包是客戶端發送來發現Translator服務器的Spanish實例的。RMI-DISCOVERY是協議頭。5000是客戶端將監聽響應的端口號:
RMI-DISCOVERY,5000,Translator,Spanish
我們沒有在請求中包括客戶機的名字,因為這個信息可以從服務器收到的UDP包中獲得。定義了我們的消息格式,現在我們可以開始實現發現類了。
實現服務器端的類
我們的美好計劃是寫一個工具類,好讓RMI服務器用它來實現它們自己的查找服務:
//初始化RMI服務器
Remote server=new SpanishTranslator();
//初始化發現監聽器
RMILookup.bind(server,"Spanish")
Remote參數用於檢查服務器是否是實現了客戶端所要訪問的接口,和哪一個RMI stub將最終被串行化返回給客戶端。String參數用於比較服務器的名字和請求包中指定的名字。
在繼續之前,我們扼要重述一下服務器端的類的職責:
1. 建立一個多播UDP socket以監聽請求
2. 當數據包到達時檢查協議頭
3. 解析消息數據包
4. 匹配唯一服務器名字參數
5. 匹配接口參數
6. 如果步驟4和5匹配,將服務器的遠程代理(remote stub)通過單播TCP/IP socket串行化到客戶端
建立多播UDP監聽器
要建立一個多播監聽器,你必須使用一個確定的多播地址和端口;它的范圍在224.0.0.1到239.255.255.255之間(包括224.0.0.1和239.255.255.255)。有些廠商保留了一些地址/端口的聯合;例如,Sun為Jini保留了聯合224.0.1.85:4160。(被保留地址的列表可以在http://www.iana.org/assignments/multicast-addresses找到。)不推薦在和別的廠商相同的地址/端口聯合上運行,所以我們選擇了和MulticastSocket Javadoc(見文後Resources)例子相同的聯合:
int port=6789;
String multicastAddress="228.5.6.7";
MulticastSocket socket=new MulticastSocket(port);
InetAddress address=InetAddress.getByName(multicastAddress);
socket.joinGroup(address);
byte[] buf = new byte[512];
DatagramPacket packet=new DatagramPacket(buf, buf.length);
socket.receive(packet);
//parse packet etc
socket.leaveGroup(address);
從上面的例子可以看出,你要建立一個多播監聽器並在此地址/端口聯合上接收數據包有多麼簡單。在上面的例子中,只能處理單個數據包,所以我們必須在創建DatagramPacket和socket.receive()處建立循環;否則只有一個客戶端能夠發現這個服務器。
while(active){
byte[] buf=new byte[512];
DatagramPacket packet=new DatagramPacket(buf,buf.length);
socket.receive(packet);
//process packet
}
我們可以用一些策略來處理收到的數據包:
1. 每請求線程:為每個請求創建一個新的線程來處理
2. 來自線程池的線程:使用來自(可能固定的)資源線程池的預初始化的一個線程(見 "Java Tip 78: Recycle Broken Objects in Resource Pools,http://www.javaworld.com/javaworld/javatips/jw-Javatip78.Html";)
3. 阻塞:在同一時間只處理一個請求,其他請求必須等待
由於我們從客戶端發起一次發現,自然地,阻塞策略在這裡是可行的。這是因為我們的客戶端會以一定時間間隔持續發送發現消息,直到服務被定位或者請求失敗達到了預定的次數。
檢查協議頭
收到一個數據包後,我們接著檢查所包含的消息是否我們想要的。要完成這項工作,我們用startsWith()方法將byte []轉換成String。雖然我們將協議頭RMI-DISCOVERY硬編碼到了下面的例子中,不過在實際的源碼中它是作為一個常量來存取的。
String msg=new String(packet.getData()).trim();
boolean validPacket=msg.startsWith("RMI-DISCOVERY");
解析消息
假設我們得到一個有效的數據包,我們可以從中解析出消息來。由於消息是用定界符劃分的,我們可以用StringTokenizer來分開它:
private String [] parseMsg(String msg,String delim){
//符合格式的請求
//
StringTokenizer tok=new StringTokenizer(msg,delim);
tok.nextToken(); //protocol header
String [] strArray=new String[3];
strArray[0]=tok.nextToken();//回復端口
strArray[1]=tok.nextToken();//接口名
strArray[2]=tok.nextToken();//服務名
return strArray;
}
我們將消息數據包轉換成了參數以後,就可以對照服務器的名字檢查接口名和唯一服務器名。
匹配接口和服務器名
要用參數來匹配服務器的唯一名字,你只需簡單地比較兩個String對象。如果你下載了全部源碼(見文後Resources),你可以看到RMILookup類有兩個參數:一個指明了它的唯一名字,另一個是Remote對象。
你可以對存儲服務器實現的所有接口的整個數組比較接口名字:
//在啟動時完成
Class c=_service.getClass();
_serviceInterface=c.getInterfaces();
//進行匹配的部分代碼
//interfaceName是請求的一部分
boolean match=false;
for(int i=0;!match && i<_serviceInterface.length;i++){
match=_serviceInterface[i].getName().equals(interfaceName);
}
返回到發現者的單播連接
如果唯一服務器名和接口類都匹配,我們就嘗試連回客戶端,並把服務器的stub串行化:
//repAddress已從進入的DatagramPacket中獲得
//repPort已從消息數據包中解析出來
//_service 是RMI服務器的Remote ref(stub)
Socket sock=new Socket(repAddress,repPort);
ObjectOutputStream oos=new ObjectOutputStream(sock.getOutputStream());
oos.writeObject(new MarshalledObject(_service));
oos.flush();
oos.close();
值得注意的一點的是上面的例子中使用MarshalledObject。如果我們簡單地串行化Remote對象到流,在客戶端會發生一個ClassNotFoundException,除非客戶端已經訪問了服務器的stub(在大多數情況下這是糟糕的)。客戶端會得到ClassNotFoundException是因為,不同於通過RMI傳遞對象,codebase會被附加到流中,在這兒我們是透過一個socket使用串行化,不會包含codebase。
MarshalledObject在Java 2中加入,提供了一個方便的途徑來傳遞串行化的對象及其codebase。在內部,MarshalledObject將對象串行化到字節數組,這意味著當MarshalledObject被解串行化時,內部的對象不會解串行化。這對Jini服務,如查找服務,是極其有用的,因為它們不再被迫去下載注冊的代理所代表的類。
要訪問內部的對象,你要在客戶端調用MarshalledObject的get()方法。
實現客戶端的類
前面我們說明了RMI客戶端怎樣通過指定接口類名字和服務器的唯一名字來發現RMI服務器,如下所示:
Class clazz=Translator.class;
String id="Spanish";
Translator service
=(Translator)RMIDiscovery.lookup(clazz,id);
在考慮怎樣實現我們的RMIDiscovery類以前,讓我們先扼要重述一下它的職責:
1. 監聽來自服務器的RMILookup的單播響應
2. 向多播地址發送UDP包
3. 從流中讀取遠程對象
4. 停止發送多播數據包
5. 停止在單播socket上監聽
6. 使用服務器
建立單播TCP/IP監聽器
要建立一個單播TCP/IP socket,我們必須選擇一個監聽的端口。不過,我們不能簡單地將一個固定的端口號定義成常量,因為其他進程可能在使用這個端口。我們因此需要指定一個使用的端口號的范圍:
private ServerSocket startListener(int port,int range){
ServerSocket listener=null;
for(int i=port;listener==null && i<(port+range+1);i++){try{
listener =new ServerSocket(i);
}catch(IOException ex){
//端口(可能)已經被使用
//處理違例
}}
return listener;
}
上面的startListener()方法嘗試在指定范圍內的一個端口上創建ServerSocket。此方法的調用者可以檢查返回值是否為null(null意味著ServerSocket不能被創建)並獲得使用的端口。另一個選擇是在ServerSocket不能被創建時拋出一個違例:
ServerSocket listener=startListener(START_PORT,RANGE);
if(listener!=null){
int port=listener.getLocalPort();
//format message to include port number格式化消息以包含端口號
//start the multicast message dispatcher 啟動多播消息分發器
Socket sock=listener.accept();
//read remote stub from stream 從流中讀取remote stub
}
當我們成功地建立了單播監聽器,我們就可以格式化消息數據包並啟動多播消息分發器。
建立多播UDP分發器
如同多播監聽器,我們必須使用一個已知的多播地址/端口聯合。我們可以通過System屬性或者通過一個常量來獲取這項數據:
int port=6789;
String multicastAddress="228.5.6.7";
MulticastSocket socket=new MulticastSocket(port);
InetAddress address=InetAddress.getByName(multicastAddress);
socket.joinGroup(address);
//outMsg是用定界符劃分的請求
byte [] buf=outMsg.getBytes();
//循環n次或一直到單播監聽器收到響應為止
DatagramPacket packet=new DatagramPacket(buf,
buf.length,address,multicastPort);
socket.send(packet);
//結束循環
socket.leaveGroup(address);
socket.close();
一步一步察看上面的代碼,你可以看到當我們配置好MulticastSocket之後,outMsg字符串被轉換成一個字節數組以便從socket發送出去。注釋說明了然後我們將發送消息預先指定的次數或者直到單播監聽器收到響應為止。為了使例子簡明,我們從中省略了與單播監聽器的線程間的協調工作;你可以下載整個源碼(見文後Resources)來看看這是怎樣完成的。
讀取服務器的stub
前面我們已經看到了怎樣建立一個單播ServerSocket。現在我們要看看讀取服務器的stub的代碼。方法ServerSocket.accept()是阻塞的,所以它不會返回一個Socket對象,除非進入的連接已經完成:
Socket sock=listener.accept();
ObjectInputStream ois=new ObjectInputStream(sock.getInputStream());
MarshalledObject mo=(MarshalledObject)ois.readObject();
sock.close();
//server是一個成員域
server=(Remote)mo.get();
當我們獲得了服務器的一個引用,我們接著可以喚醒調用RMIDiscovery.lookup()而被阻塞的線程,它將給客戶端返回一個Remote對象。
采用Jini
在這篇文章中,我們向你展示了怎樣為普通的RMI客戶端和服務器應用一項類似Jini的發現概念的技術。雖然我們建議在新的項目中使用Jini,你還是能夠用類似發現的機制來增強現有的RMI系統從而獲得好處。
前面說明的RMI發現機制有一些Jini能夠克服的局限。例如,多播UDP有受限制的范圍,通常是一個子網內。這意味著使用我們的多播機制的客戶端不能發現在多播范圍以外運行的RMI服務器。然而,Jini有聯合查找服務的概念可以“加入”不同的子網以使跨越WAN(廣域網)的發現過程對客戶端透明。
我們鼓勵讀者下載全部源碼(見文後Resources)來進行試驗。用一個RMI服務器為許多在多播范圍以外運行的服務器的遠程引用做委托或代理,並在其中使用RMILookup工具類,會是一個有趣的試驗。
根本來說,Jini是一個更好更優雅的解決方案,所以我們強烈建議還沒有體驗過Jini的讀者盡快體驗一下。
最後,要指出的是,一般來說多播UDP在沒有連接到集線器的獨立的機器上不能工作。使用loopback適配器是可選的方案;不過,我們在基於Windows的機器上使用這種方法時遇到了錯誤。
注:作者 Philip Bishop and Nigel Warren 翻譯 FooSleeper
原文來自JavaWorld,見http://www.javaworld.com/Javaworld/jw-11-2001/jw-1121-jinirmi_p.Html