几段代码,带你理解RPC

作者: 码哥说 | 来源:发表于2020-05-25 11:13 被阅读0次

    RPC

    RPC 远程过程调用可以说是分布式系统的基础,本文将通过 Java 演示一次普通的 rpc 调用到底发生了什么。

    咸鱼君曾经在网上看到有人提问,为什么 RPC 要叫作远程过程调用,而不叫作 RMC 远程方法调用。但咸鱼君为 RPC 的叫法才是合理的,远程调用的是某个过程,不一定是一个具体的方法。(你只要看过后面第一个版本的代码就能懂了)

    image

    这整个过程可以用一句话概括:机器 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 远程调用的原始简易版本,可以看到联网的代码写死在了客户端中,网络部分代码和getProductById()耦合在了一起,如果想把别的方法改为远程调用还要重新写一遍联网的代码,非常麻烦。

    image

    在实际的使用中,我们会编写各种各样的远程调用,打个比方,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的实现类,得想办法让它返回任意类型的服务实现类。

    image

    思路和远程调用方法相似,在远程调用方法时,我们把方法的名称,参数类型,参数传递给服务端;现在要动态创建服务类,我们可以把服务接口的名字传给服务端。服务端拿到远程接口的名字后,就可以从服务注册表中找到对应服务实现类。

    至于服务实现类如何注册到服务注册表,这里提供一个思路:可以考虑使用 Spring 的注解注入。这和我们平时写 spring 代码是相似的,在创建完服务实现类后我们会加上注解 @Service,这样我们就可以在收到远程调用后,遍历使用了 @Service的Bean,找到对应的实现类。

    总结

    如果想要搭建一个简易的RPC框架,有4个点:

    1. 代理问题
    2. 服务实例化问题
    3. 序列化问题
    4. 通信问题

    本篇文章的侧重点是前两个,代理问题和服务实例化问题,我们回顾一下:

    1. 首先通过代理模式解决了每个远程方法联网的问题
    2. 通过传递方法名,方法参数类型,参数,以及后面提及的直接传接口名字的形式解决服务实例化问题,这背后的原理是反射,传这些参数也是给反射用的。

    关于另外两个点的延伸

    在网络上传输数据避免不了序列化问题。Java 自带的序列化方式效率比较低,如果要造一个 RPC 框架的话,我们可以考虑引入其他第三方的序列化框架。

    通信问题也很重要,现有的 RPC 框架使用的通信协议也不相同,各有各的优缺点。但这部分手动实现可能比较困难,可以根据语言自身封装好的 API 进行选择。


    参考:马士兵 rpc 的演化过程公开课 https://www.bilibili.com/video/BV1Ug4y1875i?p=2

    最后

    如果大家喜欢我们的文章,欢迎大家转发,点击在看让更多的人看到。也欢迎大家热爱技术和学习的朋友加入的我们的知识星球当中,我们共同成长,进步。

    本文参考公众号 "java极客技术"

    请关注我的订阅号

    订阅号.png

    相关文章

      网友评论

        本文标题:几段代码,带你理解RPC

        本文链接:https://www.haomeiwen.com/subject/hovjohtx.html