Java講解RPC的基本實現

抑菌發表於2020-04-24

RPC遠端過程呼叫可以說是分散式系統的基礎,本文將通過Java演示一次普通的rpc呼叫到底發生了什麼。

我曾經在網上看到有人提問,為什麼RPC要叫作遠端過程呼叫,而不叫作RMC遠端方法呼叫。個人認為RPC的叫法才是合理的,遠端呼叫的是某個過程,不一定是一個具體的方法,你只要看過第一個版本的程式碼就能懂了。

這整個過程可以用一句話概括:機器A通過網路與機器B建立連線,A傳送一些引數給B,B執行某個過程,並把結果返回給A。

先說一個前置背景,我們有一個商品類

public class Product implements Serializable {

    private Integer id;
    private String name;

    public Product(Integer id, String name) {
        this.id = id;
        this.name = name;
    }

    //toString()
    
    //get set 方法
}

有一個商品服務介面

public interface IProductService {

    Product getProductById(Integer id);
}

服務端有商品服務介面的實現類

public class ProductServiceImpl implements IProductService {
    @Override
    public Product getProductById(Integer id) {
        //實際上這裡應該去查詢資料庫獲得資料,下面簡化了
        return new Product(id, "手機");
    }
}

下面我們通過客戶端傳送一個商品id到服務端,服務端獲得id後通過通過商品服務類獲取商品資訊,返回給客戶端

public class Client {

    public static void main(String[] args) throws Exception {
        //建立Socket
        Socket socket = new Socket("127.0.0.1", 8888);
        //獲取輸出流
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        DataOutputStream dos = new DataOutputStream(baos);
        //把商品Id通過網路傳到服務端
        dos.writeInt(123);

        socket.getOutputStream().write(baos.toByteArray());
        socket.getOutputStream().flush();

        //讀取服務端返回的商品資訊
        DataInputStream dis = new DataInputStream(socket.getInputStream());
        Integer id = dis.readInt();     //商品id
        String name = dis.readUTF();    //商品名稱
        Product product = new Product(id, name);//通過服務端返回的商品資訊生成商品

        System.out.println(product);
        
        //關閉流資源為了方便閱讀,沒有做try-catch處理
        dos.close();
        baos.close();
        socket.close();
    }
}

public class Server {
    private static boolean running = true;

    public static void main(String[] args) throws Exception {
        //建立服務端Socket
        ServerSocket ss = new ServerSocket(8888);
        //不斷監聽,處理客戶端請求
        while (running) {
            Socket socket = ss.accept();
            process(socket);
            socket.close();
        }
        ss.close();
    }

    private static void process(Socket socket) throws Exception {
        InputStream is = socket.getInputStream();
        OutputStream os = socket.getOutputStream();
        DataInputStream dis = new DataInputStream(is);
        DataOutputStream dos = new DataOutputStream(os);

        //讀取客戶端發過來的id
        Integer id = dis.readInt();
        //呼叫服務類生成商品
        IProductService service = new ProductServiceImpl();
        Product product = service.getProductById(id);
        //把商品的資訊寫回給客戶端
        dos.writeInt(id);
        dos.writeUTF(product.getName());
        dos.flush();

        dos.close();
        dis.close();
        os.close();
        is.close();
    }
}

上面的是RPC遠端呼叫1.0版本,可以看到聯網的程式碼寫死在了客戶端中,網路的程式碼和getProductById()耦合在了一起,實際的rpc框架是絕對不可能這麼做的。

在實際的使用中,我們會編寫各種各樣的遠端呼叫,打個比方,IProductService介面以後可能會擴充套件成這樣:

public interface IProductService {

    Product getProductById(Integer id);
    
    Product getProductByName(String name);
    
    Product getMostExpensiveProduct();
}

我們總不可能為每個方法都編寫一段網路連線的程式碼吧,我們得想到一種辦法為所有的方法都嵌入一段共用的網路連線程式碼。

那具體應該怎樣嵌入呢?這裡我們可以用到代理模式。

在Java中許多優秀的框架都用到了代理模式做程式碼嵌入,比如說Mybatis。它把JDBC連線部分的程式碼通過代理模式嵌入到sql語句的周圍,讓我們專注於寫sql。

首先,服務端的程式碼要進行修改:

public class Server {

    private static boolean running = true;

    public static void main(String[] args) throws Exception {
        //......
    }

    private static void process(Socket socket) throws Exception {
        //獲取輸入流,輸出流
        InputStream is = socket.getInputStream();
        OutputStream os = socket.getOutputStream();
        ObjectInputStream ois = new ObjectInputStream(is);
        ObjectOutputStream oos = new ObjectOutputStream(os);
        //獲取本次遠端呼叫的方法名
        String methodName = ois.readUTF();
        //獲取本次遠端呼叫方法的引數型別
        Class[] parameterTypes = (Class[]) ois.readObject();
        //獲取具體的引數物件
        Object[] args = (Object[]) ois.readObject();
        
        //建立商品服務類例項
        IProductService service = new ProductServiceImpl();
        //根據遠端獲取的方法名和引數,呼叫相應的方法
        Method method = service.getClass().getMethod(methodName, parameterTypes);
        Product product = (Product) method.invoke(service, args);
        //把結果寫回給客戶端
        oos.writeObject(product);

        oos.close();
        ois.close();
        socket.close();
    }
}

然後在客戶端,我們建立一個新的代理類,對外提供一個getStub獲取代理類的方法。使用JDK的動態代理需要三個引數,一個是類載入器,一個是介面的class類,最後一個是InvocationHandler例項。

JDK動態代理背後的邏輯是這樣的:JVM會根據介面的class類動態建立一個代理類物件,這個代理物件實現了傳入的介面,也就是說它擁有了介面中所有方法的實現。方法具體的實現可以由使用者指定,也就是呼叫InvocationHandlerinvoke方法。

invoke方法中有三個引數,分別是proxy代理類,method呼叫的方法,args呼叫方法的引數。我們可以在invoke方法中對具體的實現方法進行增強,在本案例中就是進行網路呼叫。

public class Stub {

    public static IProductService getStub() {

        InvocationHandler h = new InvocationHandler() {
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                //和服務端建立Socket連線
                Socket socket = new Socket("127.0.0.1", 8888);
                ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
                //拿到遠端呼叫的方法名
                String methodName = method.getName();
                //拿到遠端呼叫方法的引數型別
                Class[] parametersTypes = method.getParameterTypes();
                //把方法名傳遞給服務端
                oos.writeUTF(methodName);
                //把方法引數型別傳遞給服務端
                oos.writeObject(parametersTypes);
                //把方法引數傳遞給服務端
                oos.writeObject(args);
                oos.flush();
                //獲取遠端呼叫的返回結果
                ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
                Product product = (Product) ois.readObject();

                ois.close();
                oos.close();
                socket.close();
                return product;
            }
        };
        Object o = Proxy.newProxyInstance(IProductService.class.getClassLoader(), new Class[]{IProductService.class}, h);
        return (IProductService) o;
    }
}

這個新版本比第一個版本又美好了一些,但是其實還可以繼續優化。現在我們的代理只能夠返回IProductService的實現類,得想辦法讓它返回任意型別的服務實現類。

思路和遠端呼叫方法相似,在遠端呼叫方法時,我們把方法的名稱,引數型別,引數傳遞給服務端;現在要動態建立服務類,我們可以把服務介面的名字傳給服務端。服務端拿到遠端介面的名字後,就可以從服務登錄檔中找到對應服務實現類。

至於服務實現類如何註冊到服務登錄檔,這裡提供一個思路:可以考慮使用Spring的註解注入。這和我們平時寫spring程式碼是相似的,在建立完服務實現類後我們會加上註解@Service,這樣我們就可以在收到遠端呼叫後,遍歷使用了@Service的Bean,找到對應的實現類。


參考:馬士兵rpc的演化過程公開課
https://www.bilibili.com/video/BV1Ug4y1875i?p=2

相關文章