1. 程式人生 > >如何實現一個簡單的RPC

如何實現一個簡單的RPC

RPC的實現原理

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

  • 解決分散式系統中,服務之間的呼叫問題。
  • 遠端呼叫時,要能夠像本地呼叫一樣方便,讓呼叫者感知不到遠端呼叫的邏輯。

還是以計算器Calculator為例,如果實現類CalculatorImpl是放在本地的,那麼直接呼叫即可:

 

現在系統變成分散式了,CalculatorImpl和呼叫方不在同一個地址空間,那麼就必須要進行遠端過程呼叫:

 

那麼如何實現遠端過程呼叫,也就是RPC呢,一個完整的RPC流程,可以用下面這張圖來描述:

 

其中左邊的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,程式碼有些多,但是其實就是把上面的2、3、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的原理,要是想放到生產環境去用,那是絕對不行的。

1、缺乏通用性
我通過給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;

2、整合Spring
在實現了代理物件通用化之後,下一步就可以考慮整合Spring的IOC功能了,通過Spring來建立代理物件,這一點就需要對Spring的bean初始化有一定掌握了。

3、長連線or短連線
總不能每次要呼叫RPC介面時都去開啟一個Socket建立連線吧?是不是可以保持若干個長連線,然後每次有rpc請求時,把請求放到任務佇列中,然後由執行緒池去消費執行?只是一個思路,後續可以參考一下Dubbo是如何實現的。

4、 服務端執行緒池
我們現在的Server端,是單執行緒的,每次都要等一個請求處理完,才能去accept另一個socket的連線,這樣效能肯定很差,是不是可以通過一個執行緒池,來實現同時處理多個RPC請求?同樣只是一個思路。

5、服務註冊中心
正如之前提到的,要呼叫服務,首先你需要一個服務註冊中心,告訴你對方服務都有哪些例項。Dubbo的服務註冊中心是可以配置的,官方推薦使用Zookeeper。如果使用Zookeeper的話,要怎樣往上面註冊例項,又要怎樣獲取例項,這些都是要實現的。

6、負載均衡
如何從多個例項裡挑選一個出來,進行呼叫,這就要用到負載均衡了。負載均衡的策略肯定不只一種,要怎樣把策略做成可配置的?又要如何實現這些策略?同樣可以參考Dubbo,Dubbo - 負載均衡

7、結果快取
每次呼叫查詢介面時都要真的去Server端查詢嗎?是不是要考慮一下支援快取?

8、多版本控制
服務端介面修改了,舊的介面怎麼辦?

9、非同步呼叫
客戶端呼叫完介面之後,不想等待服務端返回,想去幹點別的事,可以支援不?

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

......

諸如此類的優化點還有很多,這也是為什麼實現一個高效能高可用的RPC框架那麼難的原因。

當然,我們現在已經有很多很不錯的RPC框架可以參考了,我們完全可以借鑑一下前人的智慧。

後面如果有(dian)機(zan)會(duo)的話,也將和大家分享一下如何一步一步優化現有的這塊RPC程式碼,把它做成一個小型RPC框架!

 



作者:柳樹之
連結:https://www.jianshu.com/p/5b90a4e70783
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授權並註明出處。