RPC原理--動態代理
1 如何呼叫他人的遠端服務?
由於各服務部署在不同機器,服務間的呼叫免不了網路通訊過程,服務消費方每呼叫一個服務都要寫一坨網路通訊相關的程式碼,不僅複雜而且極易出錯。
如果有一種方式能讓我們像呼叫本地服務一樣呼叫遠端服務,而讓呼叫者對網路通訊這些細節透明,那麼將大大提高生產力,比如服務消費方在執行helloWorldService.sayHello("test")時,實質上呼叫的是遠端的服務。這種方式其實就是RPC(Remote Procedure Call Protocol),在各大網際網路公司中被廣泛使用,如阿里巴巴的hsf、dubbo(開源)、Facebook的thrift(開源)、Google grpc(開源)、Twitter的finagle(開源)等。
要讓網路通訊細節對使用者透明,我們需要對通訊細節進行封裝,我們先看下一個RPC呼叫的流程涉及到哪些通訊細節:
1)服務消費方(client)呼叫以本地呼叫方式呼叫服務;
2)client stub接收到呼叫後負責將方法、引數等組裝成能夠進行網路傳輸的訊息體;
3)client stub找到服務地址,並將訊息傳送到服務端;
4)server stub收到訊息後進行解碼;
5)server stub根據解碼結果呼叫本地的服務;
6)本地服務執行並將結果返回給server stub;
7)server stub將返回結果打包成訊息併發送至消費方;
8)client stub接收到訊息,並進行解碼;
9)服務消費方得到最終結果。
RPC的目標就是要2~8這些步驟都封裝起來,讓使用者對這些細節透明。
1.1 怎麼做到透明化遠端服務呼叫?
怎麼封裝通訊細節才能讓使用者像以本地呼叫方式呼叫遠端服務呢?對java來說就是使用代理!java代理有兩種方式:1) jdk 動態代理;2)位元組碼生成。儘管位元組碼生成方式實現的代理更為強大和高效,但程式碼維護不易,大部分公司實現RPC框架時還是選擇動態代理方式。
下面簡單介紹下動態代理怎麼實現我們的需求。我們需要實現RPCProxyClient代理類,代理類的invoke方法中封裝了與遠端服務通訊的細節,消費方首先從RPCProxyClient獲得服務提供方的介面,當執行helloWorldService.sayHello("test")方法時就會呼叫invoke方法。
public class RPCProxyClient implements java.lang.reflect.InvocationHandler{
private Object obj;
public RPCProxyClient(Object obj){
this.obj=obj;
}
/**
* 得到被代理物件;
*/
public static Object getProxy(Object obj){
return java.lang.reflect.Proxy.newProxyInstance(obj.getClass().getClassLoader(),
obj.getClass().getInterfaces(), new RPCProxyClient(obj));
}
/**
* 呼叫此方法執行
*/
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
//結果引數;
Object result = new Object();
// ...執行通訊相關邏輯
// ...
return result;
}
}
1 public class Test {
2 public static void main(String[] args) {
3 HelloWorldService helloWorldService = (HelloWorldService)RPCProxyClient.getProxy(HelloWorldService.class);
4 helloWorldService.sayHello("test");
5 }
6 }
1.2 怎麼對訊息進行編碼和解碼?
1.2.1 確定訊息資料結構
上節講了invoke裡需要封裝通訊細節,而通訊的第一步就是要確定客戶端和服務端相互通訊的訊息結構。客戶端的請求訊息結構一般需要包括以下內容:
1)介面名稱
在我們的例子裡介面名是“HelloWorldService”,如果不傳,服務端就不知道呼叫哪個介面了;
2)方法名
一個介面內可能有很多方法,如果不傳方法名服務端也就不知道呼叫哪個方法;
3)引數型別&引數值
引數型別有很多,比如有bool、int、long、double、string、map、list,甚至如struct(class);
以及相應的引數值;
4)超時時間
5)requestID,標識唯一請求id,在下面一節會詳細描述requestID的用處。
同理服務端返回的訊息結構一般包括以下內容。
1)返回值
2)狀態code
3)requestID
1.2.2 序列化
一旦確定了訊息的資料結構後,下一步就是要考慮序列化與反序列化了。
什麼是序列化?序列化就是將資料結構或物件轉換成二進位制串的過程,也就是編碼的過程。
什麼是反序列化?將在序列化過程中所生成的二進位制串轉換成資料結構或者物件的過程。
為什麼需要序列化?轉換為二進位制串後才好進行網路傳輸嘛!
為什麼需要反序列化?將二進位制轉換為物件才好進行後續處理!
現如今序列化的方案越來越多,每種序列化方案都有優點和缺點,它們在設計之初有自己獨特的應用場景,那到底選擇哪種呢?從RPC的角度上看,主要看三點:1)通用性,比如是否能支援Map等複雜的資料結構;2)效能,包括時間複雜度和空間複雜度,由於RPC框架將會被公司幾乎所有服務使用,如果序列化上能節約一點時間,對整個公司的收益都將非常可觀,同理如果序列化上能節約一點記憶體,網路頻寬也能省下不少;3)可擴充套件性,對網際網路公司而言,業務變化飛快,如果序列化協議具有良好的可擴充套件性,支援自動增加新的業務欄位,而不影響老的服務,這將大大提供系統的靈活度。
目前網際網路公司廣泛使用Protobuf、Thrift、Avro等成熟的序列化解決方案來搭建RPC框架,這些都是久經考驗的解決方案。
1.3 通訊
訊息資料結構被序列化為二進位制串後,下一步就要進行網路通訊了。目前有兩種常用IO通訊模型:1)BIO;2)NIO。一般RPC框架需要支援這兩種IO模型,原理可參考:一個故事講清楚NIO。
如何實現RPC的IO通訊框架呢?1)使用java nio方式自研,這種方式較為複雜,而且很有可能出現隱藏bug,但也見過一些網際網路公司使用這種方式;2)基於mina,mina在早幾年比較火熱,不過這些年版本更新緩慢;3)基於netty,現在很多RPC框架都直接基於netty這一IO通訊框架,省力又省心,比如阿里巴巴的HSF、dubbo,Twitter的finagle等。
1.4 訊息裡為什麼要有requestID?
如果使用netty的話,一般會用channel.writeAndFlush()方法來發送訊息二進位制串,這個方法呼叫後對於整個遠端呼叫(從發出請求到接收到結果)來說是一個非同步的,即對於當前執行緒來說,將請求傳送出來後,執行緒就可以往後執行了,至於服務端的結果,是服務端處理完成後,再以訊息的形式傳送給客戶端的。於是這裡出現以下兩個問題:
1)怎麼讓當前執行緒“暫停”,等結果回來後,再向後執行?
2)如果有多個執行緒同時進行遠端方法呼叫,這時建立在client server之間的socket連線上會有很多雙方傳送的訊息傳遞,前後順序也可能是隨機的,server處理完結果後,將結果訊息傳送給client,client收到很多訊息,怎麼知道哪個訊息結果是原先哪個執行緒呼叫的?
如下圖所示,執行緒A和執行緒B同時向client socket傳送請求requestA和requestB,socket先後將requestB和requestA傳送至server,而server可能將responseA先返回,儘管requestA請求到達時間更晚。我們需要一種機制保證responseA丟給ThreadA,responseB丟給ThreadB。
怎麼解決呢?
1)client執行緒每次通過socket呼叫一次遠端介面前,生成一個唯一的ID,即requestID(requestID必需保證在一個Socket連線裡面是唯一的),一般常常使用AtomicLong從0開始累計數字生成唯一ID;
2)將處理結果的回撥物件callback,存放到全域性ConcurrentHashMap裡面put(requestID, callback);
3)當執行緒呼叫channel.writeAndFlush()傳送訊息後,緊接著執行callback的get()方法試圖獲取遠端返回的結果。在get()內部,則使用synchronized獲取回撥物件callback的鎖,再先檢測是否已經獲取到結果,如果沒有,然後呼叫callback的wait()方法,釋放callback上的鎖,讓當前執行緒處於等待狀態。
4)服務端接收到請求並處理後,將response結果(此結果中包含了前面的requestID)傳送給客戶端,客戶端socket連線上專門監聽訊息的執行緒收到訊息,分析結果,取到requestID,再從前面的ConcurrentHashMap裡面get(requestID),從而找到callback物件,再用synchronized獲取callback上的鎖,將方法呼叫結果設定到callback物件裡,再呼叫callback.notifyAll()喚醒前面處於等待狀態的執行緒。
public Object get() {
synchronized (this) { // 旋鎖
while (!isDone) { // 是否有結果了
wait(); //沒結果是釋放鎖,讓當前執行緒處於等待狀態
}
}
}
private void setDone(Response res) {
this.res = res;
isDone = true;
synchronized (this) { //獲取鎖,因為前面wait()已經釋放了callback的鎖了
notifyAll(); // 喚醒處於等待的執行緒
}
}
2 如何釋出自己的服務?
如何讓別人使用我們的服務呢?有同學說很簡單嘛,告訴使用者服務的IP以及埠就可以了啊。確實是這樣,這裡問題的關鍵在於是自動告知還是人肉告知。
人肉告知的方式:如果你發現你的服務一臺機器不夠,要再新增一臺,這個時候就要告訴呼叫者我現在有兩個ip了,你們要輪詢呼叫來實現負載均衡;呼叫者咬咬牙改了,結果某天一臺機器掛了,呼叫者發現服務有一半不可用,他又只能手動修改程式碼來刪除掛掉那臺機器的ip。現實生產環境當然不會使用人肉方式。
有沒有一種方法能實現自動告知,即機器的增添、剔除對呼叫方透明,呼叫者不再需要寫死服務提供方地址?當然可以,現如今zookeeper被廣泛用於實現服務自動註冊與發現功能!
簡單來講,zookeeper可以充當一個服務登錄檔
(Service Registry),讓多個服務提供者
形成一個叢集,讓服務消費者
通過服務登錄檔獲取具體的服務訪問地址(ip+埠)去訪問具體的服務提供者。如下圖所示:
具體來說,zookeeper就是個分散式檔案系統,每當一個服務提供者部署後都要將自己的服務註冊到zookeeper的某一路徑上: /{service}/{version}/{ip:port}, 比如我們的HelloWorldService部署到兩臺機器,那麼zookeeper上就會建立兩條目錄:分別為/HelloWorldService/1.0.0/100.19.20.01:16888 /HelloWorldService/1.0.0/100.19.20.02:16888。
zookeeper提供了“心跳檢測”功能,它會定時向各個服務提供者傳送一個請求(實際上建立的是一個 Socket 長連線),如果長期沒有響應,服務中心就認為該服務提供者已經“掛了”,並將其剔除,比如100.19.20.02這臺機器如果宕機了,那麼zookeeper上的路徑就會只剩/HelloWorldService/1.0.0/100.19.20.01:16888。
服務消費者會去監聽相應路徑(/HelloWorldService/1.0.0),一旦路徑上的資料有任務變化(增加或減少),zookeeper都會通知服務消費方服務提供者地址列表已經發生改變,從而進行更新。
更為重要的是zookeeper與生俱來的容錯容災能力(比如leader選舉),可以確保服務登錄檔的高可用性。
3 小結
RPC幾乎是每一個從學校進入網際網路公司的同學都要首先學習的框架,之前面試過一個在大型網際網路公司工作過兩年的同學,對RPC還是停留在使用層面,這是不應該的,希望大家不僅要會用而且要知道內部的原理。本文也僅是對RPC的一個比較粗糙的描述,希望對大家有所幫助,錯誤之處也請指出修正。