RPC理解

本文是博主看到的一篇比較好博文,語言通俗易懂,遠離那些不接地氣的官方話語。

html

如何向老婆解釋RPC

一個陽光明媚的早晨,老婆又在翻看我訂閱的技術雜誌。java

「老公,什麼是RPC呀,爲何大家程序員那麼多黑話!」,老婆仍是一如既往的好奇。
「RPC,就是Remote Procedure Call的簡稱呀,翻譯成中文就是遠程過程調用嘛」,我一邊看着書,一邊漫不經心的回答着。
「啥?你在說啥?誰不知道翻譯成中文是什麼意思?你個廢柴,快給我滾去洗碗!」
「我去。。。」,我如夢初醒,我對面坐着的可不是一個程序員,爲了避免去洗碗,我瞬間調動起所有腦細胞,星辰大海在我腦中匯聚,靈感涌現......git

"是這樣,遠程過程調用,天然是相對於本地過程調用來講的嘛。"
「嗯哼,那先給老孃講講,本地過程調用是啥子?」
「本地過程調用,就比如你如今在家裏,你要想洗碗,那你直接把碗放進洗碗機,打開洗碗機開關就能夠洗了。這就叫本地過程調用。」程序員

「哎呦,我可不幹,那啥是遠程過程調用?」
「遠程嘛,那就是你如今不在家,跟姐妹們浪去了,忽然發現碗還沒洗,打了個電話過來,叫我去洗碗,這就是遠程過程調用啦」,多麼通俗易懂的解釋,我真是天才!github

「哦!我明白了」,說着,老婆開始收拾包包。
「你這是幹啥去哦」
「我?我要出門浪去呀,待會記得接收個人遠程調用哦,哦不,我們要專業點,應該說,待會記得接收個人RPC哦!」
......apache

如何科學的解釋RPC

提及RPC,就不能不提到分佈式,這個促使RPC誕生的領域。編程

假設你有一個計算器接口,Calculator,以及它的實現類CalculatorImpl,那麼在系統仍是單體應用時,你要調用Calculator的add方法來執行一個加運算,直接new一個CalculatorImpl,而後調用add方法就好了,這其實就是很是普通的本地函數調用,由於在同一個地址空間,或者說在同一塊內存,因此經過方法棧和參數棧就能夠實現。緩存

img

如今,基於高性能和高可靠等因素的考慮,你決定將系統改造爲分佈式應用,將不少能夠共享的功能都單獨拎出來,好比上面說到的計算器,你單獨把它放到一個服務裏頭,讓別的服務去調用它。restful

img

這下問題來了,服務A裏頭並無CalculatorImpl這個類,那它要怎樣調用服務B的CalculatorImpl的add方法呢?網絡

有同窗會說,能夠模仿B/S架構的調用方式呀,在B服務暴露一個Restful接口,而後A服務經過調用這個Restful接口來間接調用CalculatorImpl的add方法。

很好,這已經很接近RPC了,不過若是是這樣,那每次調用時,是否是都須要寫一串發起http請求的代碼呢?好比httpClient.sendRequest...之類的,能不能像本地調用同樣,去發起遠程調用,讓使用者感知不到遠程調用的過程呢,像這樣:

@Reference
private Calculator calculator;

...

calculator.add(1,2);

...

這時候,有同窗就會說,用代理模式呀!並且最好是結合Spring IoC一塊兒使用,經過Spring注入calculator對象,注入時,若是掃描到對象加了@Reference註解,那麼就給它生成一個代理對象,將這個代理對象放進容器中。而這個代理對象的內部,就是經過httpClient來實現RPC遠程過程調用的。

可能上面這段描述比較抽象,不過這就是不少RPC框架要解決的問題和解決的思路,好比阿里的Dubbo。

總結一下,RPC要解決的兩個問題:

  1. 解決分佈式系統中,服務之間的調用問題。
  2. 遠程調用時,要可以像本地調用同樣方便,讓調用者感知不到遠程調用的邏輯。

如何實現一個RPC

實際狀況下,RPC不多用到http協議來進行數據傳輸,畢竟我只是想傳輸一下數據而已,何須動用到一個文本傳輸的應用層協議呢,我爲何不直接使用二進制傳輸?好比直接用Java的Socket協議進行傳輸?

無論你用何種協議進行數據傳輸,一個完整的RPC過程,均可以用下面這張圖來描述

img

以左邊的Client端爲例,Application就是rpc的調用方,Client Stub就是咱們上面說到的代理對象,也就是那個看起來像是Calculator的實現類,其實內部是經過rpc方式來進行遠程調用的代理對象,至於Client Run-time Library,則是實現遠程調用的工具包,好比jdk的Socket,最後經過底層網絡實現實現數據的傳輸。

這個過程當中最重要的就是序列化反序列化了,由於數據傳輸的數據包必須是二進制的,你直接丟一個Java對象過去,人家可不認識,你必須把Java對象序列化爲二進制格式,傳給Server端,Server端接收到以後,再反序列化爲Java對象。

下一次我也將經過代碼,給你們演示一下,如何實現一個簡單的RPC。

RPC vs Restful

其實這二者並非一個維度的概念,總得來講RPC涉及的維度更廣。

若是硬要比較,那麼能夠從RPC風格的url和Restful風格的url上進行比較。

好比你提供一個查詢訂單的接口,用RPC風格,你可能會這樣寫:

/queryOrder?orderId=123

用Restful風格呢?

Get  
/order?orderId=123

RPC是面向過程,Restful是面向資源,而且使用了Http動詞。從這個維度上看,Restful風格的url在表述的精簡性、可讀性上都要更好。

RPC vs RMI

嚴格來講這二者也不是一個維度的。

RMI是Java提供的一種訪問遠程對象的協議,是已經實現好了的,能夠直接用了。

而RPC呢?人家只是一種編程模型,並無規定你具體要怎樣實現,你甚至均可以在你的RPC框架裏面使用RMI來實現數據的傳輸,好比Dubbo:Dubbo - rmi協議

RPC沒那麼簡單

要實現一個RPC不算難,難的是實現一個高性能高可靠的RPC框架。

好比,既然是分佈式了,那麼一個服務可能有多個實例,你在調用時,要如何獲取這些實例的地址呢?

這時候就須要一個服務註冊中心,好比在Dubbo裏頭,就可使用Zookeeper做爲註冊中心,在調用時,從Zookeeper獲取服務的實例列表,再從中選擇一個進行調用。

那麼選哪一個調用好呢?這時候就須要負載均衡了,因而你又得考慮如何實現複雜均衡,好比Dubbo就提供了好幾種負載均衡策略。

這還沒完,總不能每次調用時都去註冊中心查詢實例列表吧,這樣效率多低呀,因而又有了緩存,有了緩存,就要考慮緩存的更新問題,blablabla......

你覺得就這樣結束了,沒呢,還有這些:

  • 客戶端總不能每次調用完都乾等着服務端返回數據吧,因而就要支持異步調用;
  • 服務端的接口修改了,老的接口還有人在用,怎麼辦?總不能讓他們都改了吧?這就須要版本控制了;
  • 服務端總不能每次接到請求都立刻啓動一個線程去處理吧?因而就須要線程池;
  • 服務端關閉時,還沒處理完的請求怎麼辦?是直接結束呢,仍是等所有請求處理完再關閉呢?
  • ......

如此種種,都是一個優秀的RPC框架須要考慮的問題。

固然,接下來咱們仍是先實現一個簡單的RPC,再在上面一步步優化!

如何實現一個簡單的RPC

RPC的實現原理

正如上一講所說,RPC主要是爲了解決的兩個問題:

  • 解決分佈式系統中,服務之間的調用問題。
  • 遠程調用時,要可以像本地調用同樣方便,讓調用者感知不到遠程調用的邏輯。

仍是以計算器Calculator爲例,若是實現類CalculatorImpl是放在本地的,那麼直接調用便可:

img

如今系統變成分佈式了,CalculatorImpl和調用方不在同一個地址空間,那麼就必需要進行遠程過程調用:

img

那麼如何實現遠程過程調用,也就是RPC呢,一個完整的RPC流程,能夠用下面這張圖來描述:

img

其中左邊的Client,對應的就是前面的Service A,而右邊的Server,對應的則是Service B。
下面一步一步詳細解釋一下。

  1. Service A的應用層代碼中,調用了Calculator的一個實現類的add方法,但願執行一個加法運算;
  2. 這個Calculator實現類,內部並非直接實現計算器的加減乘除邏輯,而是經過遠程調用Service B的RPC接口,來獲取運算結果,所以稱之爲Stub
  3. Stub怎麼和Service B創建遠程通信呢?這時候就要用到遠程通信工具了,也就是圖中的Run-time Library,這個工具將幫你實現遠程通信的功能,好比Java的Socket,就是這樣一個庫,固然,你也能夠用基於Http協議的HttpClient,或者其餘通信工具類,均可以,RPC並無規定說你要用何種協議進行通信
  4. Stub經過調用通信工具提供的方法,和Service B創建起了通信,而後將請求數據發給Service B。須要注意的是,因爲底層的網絡通信是基於二進制格式的,所以這裏Stub傳給通信工具類的數據也必須是二進制,好比calculator.add(1,2),你必須把參數值1和2放到一個Request對象裏頭(這個Request對象固然不僅這些信息,還包括要調用哪一個服務的哪一個RPC接口等其餘信息),而後序列化爲二進制,再傳給通信工具類,這一點也將在下面的代碼實現中體現;
  5. 二進制的數據傳到Service B這一邊了,Service B固然也有本身的通信工具,經過這個通信工具接收二進制的請求;
  6. 既然數據是二進制的,那麼天然要進行反序列化了,將二進制的數據反序列化爲請求對象,而後將這個請求對象交給Service B的Stub處理;
  7. 和以前的Service A的Stub同樣,這裏的Stub也一樣是個「假玩意」,它所負責的,只是去解析請求對象,知道調用方要調的是哪一個RPC接口,傳進來的參數又是什麼,而後再把這些參數傳給對應的RPC接口,也就是Calculator的實際實現類去執行。很明顯,若是是Java,那這裏確定用到了反射
  8. RPC接口執行完畢,返回執行結果,如今輪到Service B要把數據發給Service A了,怎麼發?同樣的道理,同樣的流程,只是如今Service B變成了Client,Service A變成了Server而已:Service B反序列化執行結果->傳輸給Service A->Service A反序列化執行結果 -> 將結果返回給Application,完畢。

理論的講完了,是時候把理論變成實踐了。

把理論變成實踐

本文的示例代碼,可到Github下載。

首先是Client端的應用層怎麼發起RPC,ComsumerApp:

public class ComsumerApp {
    public static void main(String[] args) {
        Calculator calculator = new CalculatorRemoteImpl();
        int result = calculator.add(1, 2);
    }
}

經過一個CalculatorRemoteImpl,咱們把RPC的邏輯封裝進去了,客戶端調用時感知不到遠程調用的麻煩。下面再來看看CalculatorRemoteImpl,代碼有些多,可是其實就是把上面的二、三、4幾個步驟用代碼實現了而已,CalculatorRemoteImpl:

public class CalculatorRemoteImpl implements Calculator {
    public int add(int a, int b) {
        List<String> addressList = lookupProviders("Calculator.add");
        String address = chooseTarget(addressList);
        try {
            Socket socket = new Socket(address, PORT);

            // 將請求序列化
            CalculateRpcRequest calculateRpcRequest = generateRequest(a, b);
            ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream());

            // 將請求發給服務提供方
            objectOutputStream.writeObject(calculateRpcRequest);

            // 將響應體反序列化
            ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream());
            Object response = objectInputStream.readObject();

            if (response instanceof Integer) {
                return (Integer) response;
            } else {
                throw new InternalError();
            }

        } catch (Exception e) {
            log.error("fail", e);
            throw new InternalError();
        }
    }
}

add方法的前面兩行,lookupProviders和chooseTarget,可能你們會以爲不明覺厲。

分佈式應用下,一個服務可能有多個實例,好比Service B,可能有ip地址爲198.168.1.11和198.168.1.13兩個實例,lookupProviders,其實就是在尋找要調用的服務的實例列表。在分佈式應用下,一般會有一個服務註冊中心,來提供查詢實例列表的功能。

查到實例列表以後要調用哪個實例呢,只時候就須要chooseTarget了,其實內部就是一個負載均衡策略。

因爲咱們這裏只是想實現一個簡單的RPC,因此暫時不考慮服務註冊中心和負載均衡,所以代碼裏寫死了返回ip地址爲127.0.0.1。

代碼繼續往下走,咱們這裏用到了Socket來進行遠程通信,同時利用ObjectOutputStream的writeObject和ObjectInputStream的readObject,來實現序列化和反序列化。

最後再來看看Server端的實現,和Client端很是相似,ProviderApp:

public class ProviderApp {
    private Calculator calculator = new CalculatorImpl();

    public static void main(String[] args) throws IOException {
        new ProviderApp().run();
    }

    private void run() throws IOException {
        ServerSocket listener = new ServerSocket(9090);
        try {
            while (true) {
                Socket socket = listener.accept();
                try {
                    // 將請求反序列化
                    ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream());
                    Object object = objectInputStream.readObject();

                    log.info("request is {}", object);

                    // 調用服務
                    int result = 0;
                    if (object instanceof CalculateRpcRequest) {
                        CalculateRpcRequest calculateRpcRequest = (CalculateRpcRequest) object;
                        if ("add".equals(calculateRpcRequest.getMethod())) {
                            result = calculator.add(calculateRpcRequest.getA(), calculateRpcRequest.getB());
                        } else {
                            throw new UnsupportedOperationException();
                        }
                    }

                    // 返回結果
                    ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream());
                    objectOutputStream.writeObject(new Integer(result));
                } catch (Exception e) {
                    log.error("fail", e);
                } finally {
                    socket.close();
                }
            }
        } finally {
            listener.close();
        }
    }

}

Server端主要是經過ServerSocket的accept方法,來接收Client端的請求,接着就是反序列化請求->執行->序列化執行結果,最後將二進制格式的執行結果返回給Client。

就這樣咱們實現了一個簡陋而又詳細的RPC。
說它簡陋,是由於這個實現確實比較挫,在下一小節會說它爲何挫。
說它詳細,是由於它一步一步的演示了一個RPC的執行流程,方便你們瞭解RPC的內部機制。

爲何說這個RPC實現很挫

這個RPC實現只是爲了給你們演示一下RPC的原理,要是想放到生產環境去用,那是絕對不行的。

一、缺少通用性
我經過給Calculator接口寫了一個CalculatorRemoteImpl,來實現計算器的遠程調用,下一次要是有別的接口須要遠程調用,是否是又得再寫對應的遠程調用實現類?這確定是很不方便的。

那該如何解決呢?先來看看使用Dubbo時是如何實現RPC調用的:

@Reference
private Calculator calculator;

...

calculator.add(1,2);

...

Dubbo經過和Spring的集成,在Spring容器初始化的時候,若是掃描到對象加了@Reference註解,那麼就給這個對象生成一個代理對象,這個代理對象會負責遠程通信,而後將代理對象放進容器中。因此代碼運行期用到的calculator就是那個代理對象了。

咱們能夠先不和Spring集成,也就是先不採用依賴注入,可是咱們要作到像Dubbo同樣,無需本身手動寫代理對象,怎麼作呢?那天然是要求全部的遠程調用都遵循一套模板,把遠程調用的信息放到一個RpcRequest對象裏面,發給Server端,Server端解析以後就知道你要調用的是哪一個RPC接口、以及入參是什麼類型、入參的值又是什麼,就像Dubbo的RpcInvocation:

public class RpcInvocation implements Invocation, Serializable {

    private static final long serialVersionUID = -4355285085441097045L;

    private String methodName;

    private Class<?>[] parameterTypes;

    private Object[] arguments;

    private Map<String, String> attachments;

    private transient Invoker<?> invoker;

二、集成Spring
在實現了代理對象通用化以後,下一步就能夠考慮集成Spring的IOC功能了,經過Spring來建立代理對象,這一點就須要對Spring的bean初始化有必定掌握了。

三、長鏈接or短鏈接
總不能每次要調用RPC接口時都去開啓一個Socket創建鏈接吧?是否是能夠保持若干個長鏈接,而後每次有rpc請求時,把請求放到任務隊列中,而後由線程池去消費執行?只是一個思路,後續能夠參考一下Dubbo是如何實現的。

四、 服務端線程池
咱們如今的Server端,是單線程的,每次都要等一個請求處理完,才能去accept另外一個socket的鏈接,這樣性能確定不好,是否是能夠經過一個線程池,來實現同時處理多個RPC請求?一樣只是一個思路。

五、服務註冊中心
正如以前提到的,要調用服務,首先你須要一個服務註冊中心,告訴你對方服務都有哪些實例。Dubbo的服務註冊中心是能夠配置的,官方推薦使用Zookeeper。若是使用Zookeeper的話,要怎樣往上面註冊實例,又要怎樣獲取實例,這些都是要實現的。

六、負載均衡
如何從多個實例裏挑選一個出來,進行調用,這就要用到負載均衡了。負載均衡的策略確定不僅一種,要怎樣把策略作成可配置的?又要如何實現這些策略?一樣能夠參考Dubbo,Dubbo - 負載均衡

七、結果緩存
每次調用查詢接口時都要真的去Server端查詢嗎?是否是要考慮一下支持緩存?

八、多版本控制
服務端接口修改了,舊的接口怎麼辦?

九、異步調用
客戶端調用完接口以後,不想等待服務端返回,想去幹點別的事,能夠支持不?

十、優雅停機
服務端要停機了,還沒處理完的請求,怎麼辦?

......

諸如此類的優化點還有不少,這也是爲何實現一個高性能高可用的RPC框架那麼難的緣由。

固然,咱們如今已經有不少很不錯的RPC框架能夠參考了,咱們徹底能夠借鑑一下前人的智慧。

後面若是有(dian)機(zan)會(duo)的話,也將和你們分享一下如何一步一步優化現有的這塊RPC代碼,把它作成一個小型RPC框架!

參考

本文摘自:https://www.jianshu.com/p/2accc2840a1b

相關文章
相關標籤/搜索