你應該知道的RPC原理
寫的比較好的 RPC 初級教程君纫,感謝作者~~~~~
在學校期間大家都寫過不少程序,比如寫個hello world服務類,然后本地調用下屠尊,如下所示。這些程序的特點是服務消費方和服務提供方是本地調用關系耕拷。
而一旦踏入公司尤其是大型互聯(lián)網公司就會發(fā)現(xiàn)讼昆,公司的系統(tǒng)都由成千上萬大大小小的服務組成,各服務部署在不同的機器上骚烧,由不同的團隊負責浸赫。這時就會遇到兩個問題:1)要搭建一個新服務闰围,免不了需要依賴他人的服務,而現(xiàn)在他人的服務都在遠端既峡,怎么調用羡榴?2)其它團隊要使用我們的新服務,我們的服務該怎么發(fā)布以便他人調用运敢?下文將對這兩個問題展開探討炕矮。
1 public interface HelloWorldService {
2 String sayHello(String msg);
3 }
1 public class HelloWorldServiceImpl implements HelloWorldService {
2 @Override
3 public String sayHello(String msg) {
4 String result = "hello world " + msg;
5 System.out.println(result);
6 return result;
7 }
8 }
1 public class Test {
2 public static void main(String[] args) {
3 HelloWorldService helloWorldService = new HelloWorldServiceImpl();
4 helloWorldService.sayHello("test");
5 }
6 }
1 如何調用他人的遠程服務?
由于各服務部署在不同機器者冤,服務間的調用免不了網絡通信過程肤视,服務消費方每調用一個服務都要寫一坨網絡通信相關的代碼,不僅復雜而且極易出錯涉枫。
如果有一種方式能讓我們像調用本地服務一樣調用遠程服務邢滑,而讓調用者對網絡通信這些細節(jié)透明,那么將大大提高生產力愿汰,比如服務消費方在執(zhí)行helloWorldService.sayHello("test")時困后,實質上調用的是遠端的服務。這種方式其實就是RPC(Remote Procedure Call Protocol)衬廷,在各大互聯(lián)網公司中被廣泛使用摇予,如阿里巴巴的hsf、dubbo(開源)吗跋、Facebook的thrift(開源)侧戴、Google grpc(開源)、Twitter的finagle(開源)等跌宛。
要讓網絡通信細節(jié)對使用者透明酗宋,我們需要對通信細節(jié)進行封裝,我們先看下一個RPC調用的流程涉及到哪些通信細節(jié):
1)服務消費方(client)調用以本地調用方式調用服務疆拘;
2)client stub接收到調用后負責將方法蜕猫、參數(shù)等組裝成能夠進行網絡傳輸?shù)南Ⅲw;
3)client stub找到服務地址哎迄,并將消息發(fā)送到服務端回右;
4)server stub收到消息后進行解碼;
5)server stub根據(jù)解碼結果調用本地的服務漱挚;
6)本地服務執(zhí)行并將結果返回給server stub翔烁;
7)server stub將返回結果打包成消息并發(fā)送至消費方;
8)client stub接收到消息棱烂,并進行解碼租漂;
9)服務消費方得到最終結果。
RPC的目標就是要2~8這些步驟都封裝起來,讓用戶對這些細節(jié)透明哩治。
1.1 怎么做到透明化遠程服務調用秃踩?
怎么封裝通信細節(jié)才能讓用戶像以本地調用方式調用遠程服務呢?對java來說就是使用代理业筏!java代理有兩種方式:1) jdk 動態(tài)代理憔杨;2)字節(jié)碼生成。盡管字節(jié)碼生成方式實現(xiàn)的代理更為強大和高效蒜胖,但代碼維護不易消别,大部分公司實現(xiàn)RPC框架時還是選擇動態(tài)代理方式。
下面簡單介紹下動態(tài)代理怎么實現(xiàn)我們的需求台谢。我們需要實現(xiàn)RPCProxyClient代理類寻狂,代理類的invoke方法中封裝了與遠端服務通信的細節(jié),消費方首先從RPCProxyClient獲得服務提供方的接口朋沮,當執(zhí)行helloWorldService.sayHello("test")方法時就會調用invoke方法蛇券。
1 public class RPCProxyClient implements java.lang.reflect.InvocationHandler{
2 private Object obj;
3
4 public RPCProxyClient(Object obj){
5 this.obj=obj;
6 }
7
8 /**
9 * 得到被代理對象;
10 */
11 public static Object getProxy(Object obj){
12 return java.lang.reflect.Proxy.newProxyInstance(obj.getClass().getClassLoader(),
13 obj.getClass().getInterfaces(), new RPCProxyClient(obj));
14 }
15
16 /**
17 * 調用此方法執(zhí)行
18 */
19 public Object invoke(Object proxy, Method method, Object[] args)
20 throws Throwable {
21 //結果參數(shù);
22 Object result = new Object();
23 // ...執(zhí)行通信相關邏輯
24 // ...
25 return result;
26 }
27 }
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 確定消息數(shù)據(jù)結構
上節(jié)講了invoke里需要封裝通信細節(jié)樊拓,而通信的第一步就是要確定客戶端和服務端相互通信的消息結構纠亚。客戶端的請求消息結構一般需要包括以下內容:
1)接口名稱
在我們的例子里接口名是“HelloWorldService”筋夏,如果不傳蒂胞,服務端就不知道調用哪個接口了;
2)方法名
一個接口內可能有很多方法条篷,如果不傳方法名服務端也就不知道調用哪個方法骗随;
3)參數(shù)類型&參數(shù)值
參數(shù)類型有很多,比如有bool拥娄、int蚊锹、long、double稚瘾、string、map姚炕、list摊欠,甚至如struct(class);
以及相應的參數(shù)值柱宦;
4)超時時間
5)requestID些椒,標識唯一請求id,在下面一節(jié)會詳細描述requestID的用處掸刊。
同理服務端返回的消息結構一般包括以下內容免糕。
1)返回值
2)狀態(tài)code
3)requestID
1.2.2 序列化
一旦確定了消息的數(shù)據(jù)結構后,下一步就是要考慮序列化與反序列化了。
什么是序列化石窑?序列化就是將數(shù)據(jù)結構或對象轉換成二進制串的過程牌芋,也就是編碼的過程。
什么是反序列化松逊?將在序列化過程中所生成的二進制串轉換成數(shù)據(jù)結構或者對象的過程躺屁。
為什么需要序列化?轉換為二進制串后才好進行網絡傳輸嘛经宏!
為什么需要反序列化犀暑?將二進制轉換為對象才好進行后續(xù)處理!
現(xiàn)如今序列化的方案越來越多烁兰,每種序列化方案都有優(yōu)點和缺點耐亏,它們在設計之初有自己獨特的應用場景,那到底選擇哪種呢沪斟?從RPC的角度上看广辰,主要看三點:1)通用性,比如是否能支持Map等復雜的數(shù)據(jù)結構币喧;2)性能轨域,包括時間復雜度和空間復雜度,由于RPC框架將會被公司幾乎所有服務使用杀餐,如果序列化上能節(jié)約一點時間干发,對整個公司的收益都將非常可觀史翘,同理如果序列化上能節(jié)約一點內存枉长,網絡帶寬也能省下不少;3)可擴展性琼讽,對互聯(lián)網公司而言必峰,業(yè)務變化飛快,如果序列化協(xié)議具有良好的可擴展性钻蹬,支持自動增加新的業(yè)務字段吼蚁,而不影響老的服務,這將大大提供系統(tǒng)的靈活度问欠。
目前互聯(lián)網公司廣泛使用Protobuf肝匆、Thrift、Avro等成熟的序列化解決方案來搭建RPC框架顺献,這些都是久經考驗的解決方案旗国。
1.3 通信
消息數(shù)據(jù)結構被序列化為二進制串后,下一步就要進行網絡通信了注整。目前有兩種常用IO通信模型:1)BIO能曾;2)NIO度硝。一般RPC框架需要支持這兩種IO模型,原理可參考:一個故事講清楚NIO寿冕。
如何實現(xiàn)RPC的IO通信框架呢蕊程?1)使用java nio方式自研,這種方式較為復雜蚂斤,而且很有可能出現(xiàn)隱藏bug存捺,但也見過一些互聯(lián)網公司使用這種方式;2)基于mina曙蒸,mina在早幾年比較火熱捌治,不過這些年版本更新緩慢;3)基于netty纽窟,現(xiàn)在很多RPC框架都直接基于netty這一IO通信框架肖油,省力又省心,比如阿里巴巴的HSF臂港、dubbo森枪,Twitter的finagle等。
1.4 消息里為什么要有requestID审孽?
如果使用netty的話县袱,一般會用channel.writeAndFlush()方法來發(fā)送消息二進制串,這個方法調用后對于整個遠程調用(從發(fā)出請求到接收到結果)來說是一個異步的佑力,即對于當前線程來說式散,將請求發(fā)送出來后,線程就可以往后執(zhí)行了打颤,至于服務端的結果暴拄,是服務端處理完成后,再以消息的形式發(fā)送給客戶端的编饺。于是這里出現(xiàn)以下兩個問題:
1)怎么讓當前線程“暫凸耘瘢”,等結果回來后透且,再向后執(zhí)行撕蔼?
2)如果有多個線程同時進行遠程方法調用,這時建立在client server之間的socket連接上會有很多雙方發(fā)送的消息傳遞秽誊,前后順序也可能是隨機的罕邀,server處理完結果后,將結果消息發(fā)送給client养距,client收到很多消息,怎么知道哪個消息結果是原先哪個線程調用的日熬?
如下圖所示棍厌,線程A和線程B同時向client socket發(fā)送請求requestA和requestB,socket先后將requestB和requestA發(fā)送至server,而server可能將responseA先返回耘纱,盡管requestA請求到達時間更晚敬肚。我們需要一種機制保證responseA丟給ThreadA,responseB丟給ThreadB束析。
怎么解決呢艳馒?
1)client線程每次通過socket調用一次遠程接口前,生成一個唯一的ID员寇,即requestID(requestID必需保證在一個Socket連接里面是唯一的)弄慰,一般常常使用AtomicLong從0開始累計數(shù)字生成唯一ID;
2)將處理結果的回調對象callback蝶锋,存放到全局ConcurrentHashMap里面put(requestID, callback)陆爽;
3)當線程調用channel.writeAndFlush()發(fā)送消息后,緊接著執(zhí)行callback的get()方法試圖獲取遠程返回的結果扳缕。在get()內部慌闭,則使用synchronized獲取回調對象callback的鎖,再先檢測是否已經獲取到結果躯舔,如果沒有驴剔,然后調用callback的wait()方法,釋放callback上的鎖粥庄,讓當前線程處于等待狀態(tài)丧失。
4)服務端接收到請求并處理后,將response結果(此結果中包含了前面的requestID)發(fā)送給客戶端飒赃,客戶端socket連接上專門監(jiān)聽消息的線程收到消息利花,分析結果,取到requestID载佳,再從前面的ConcurrentHashMap里面get(requestID)炒事,從而找到callback對象,再用synchronized獲取callback上的鎖蔫慧,將方法調用結果設置到callback對象里挠乳,再調用callback.notifyAll()喚醒前面處于等待狀態(tài)的線程。
[](javascript:void(0);)
1 public Object get() {
2 synchronized (this) { // 旋鎖
3 while (!isDone) { // 是否有結果了
4 wait(); //沒結果是釋放鎖姑躲,讓當前線程處于等待狀態(tài)
5 }
6 }
7 }
[](javascript:void(0);)
[](javascript:void(0);)
1 private void setDone(Response res) {
2 this.res = res;
3 isDone = true;
4 synchronized (this) { //獲取鎖睡扬,因為前面wait()已經釋放了callback的鎖了
5 notifyAll(); // 喚醒處于等待的線程
6 }
7 }
[](javascript:void(0);)
2 如何發(fā)布自己的服務?
如何讓別人使用我們的服務呢黍析?有同學說很簡單嘛卖怜,告訴使用者服務的IP以及端口就可以了啊。確實是這樣阐枣,這里問題的關鍵在于是自動告知還是人肉告知马靠。
人肉告知的方式:如果你發(fā)現(xiàn)你的服務一臺機器不夠奄抽,要再添加一臺,這個時候就要告訴調用者我現(xiàn)在有兩個ip了甩鳄,你們要輪詢調用來實現(xiàn)負載均衡逞度;調用者咬咬牙改了,結果某天一臺機器掛了妙啃,調用者發(fā)現(xiàn)服務有一半不可用档泽,他又只能手動修改代碼來刪除掛掉那臺機器的ip。現(xiàn)實生產環(huán)境當然不會使用人肉方式揖赴。
有沒有一種方法能實現(xiàn)自動告知馆匿,即機器的增添、剔除對調用方透明储笑,調用者不再需要寫死服務提供方地址甜熔?當然可以,現(xiàn)如今zookeeper被廣泛用于實現(xiàn)服務自動注冊與發(fā)現(xiàn)功能突倍!
簡單來講腔稀,zookeeper可以充當一個服務注冊表
(Service Registry),讓多個服務提供者
形成一個集群羽历,讓服務消費者
通過服務注冊表獲取具體的服務訪問地址(ip+端口)去訪問具體的服務提供者焊虏。如下圖所示:
具體來說,zookeeper就是個分布式文件系統(tǒng)秕磷,每當一個服務提供者部署后都要將自己的服務注冊到zookeeper的某一路徑上: /{service}/{version}/{ip:port}, 比如我們的HelloWorldService部署到兩臺機器诵闭,那么zookeeper上就會創(chuàng)建兩條目錄:分別為/HelloWorldService/1.0.0/100.19.20.01:16888 /HelloWorldService/1.0.0/100.19.20.02:16888。
zookeeper提供了“心跳檢測”功能澎嚣,它會定時向各個服務提供者發(fā)送一個請求(實際上建立的是一個 Socket 長連接)疏尿,如果長期沒有響應,服務中心就認為該服務提供者已經“掛了”易桃,并將其剔除褥琐,比如100.19.20.02這臺機器如果宕機了,那么zookeeper上的路徑就會只剩/HelloWorldService/1.0.0/100.19.20.01:16888晤郑。
服務消費者會去監(jiān)聽相應路徑(/HelloWorldService/1.0.0)敌呈,一旦路徑上的數(shù)據(jù)有任務變化(增加或減少),zookeeper都會通知服務消費方服務提供者地址列表已經發(fā)生改變造寝,從而進行更新磕洪。
更為重要的是zookeeper與生俱來的容錯容災能力(比如leader選舉),可以確保服務注冊表的高可用性诫龙。
3 小結
RPC幾乎是每一個從學校進入互聯(lián)網公司的同學都要首先學習的框架析显,之前面試過一個在大型互聯(lián)網公司工作過兩年的同學,對RPC還是停留在使用層面签赃,這是不應該的叫榕,希望大家不僅要會用而且要知道內部的原理浑侥。本文也僅是對RPC的一個比較粗糙的描述,希望對大家有所幫助晰绎,錯誤之處也請指出修正。