易筋SpringBoot 2.2 | 第廿九篇:SpringBoot之RPC入门到精通

写作时间:2019-11-28
Spring Boot: 2.2 ,JDK: 1.8, IDE: IntelliJ IDEA

1.1 什么是 RPC

RPC 是远程过程调用(Remote Procedure Call)的缩写形式。RPC 这个概念术语在上世纪 80 年代由 Bruce Jay Nelson 提出。Birrell 和 Nelson 在 1984 发表于 ACM Transactions on Computer Systems 的论文《Implementing remote procedure calls》对 RPC 做了经典的诠释。这里我们追溯下当初开发 RPC 的原动机是什么?在 Nelson 的论文 “Implementing Remote Procedure Calls” 中他提到了几点:

  • 简单:RPC 概念的语义十分清晰和简单,这样建立分布式计算就更容易。
  • 高效:过程调用看起来十分简单而且高效。
  • 通用:在单机计算中过程往往是不同算法部分间最重要的通信机制。

RPC 是指计算机 A 上的进程,调用另外一台计算机 B 上的进程,其中 A 上的调用进程被挂起,而 B 上的被调用进程开始执行,当值返回给 A 时,A 进程继续执行。调用方可以通过使用参数将信息传送给被调用方,而后可以通过传回的结果得到信息。而这一过程,对于开发人员来说是透明的。

图1 描述了数据报在一个简单的RPC传递的过程
图1 描述了数据报在一个简单的RPC传递的过程

注:上述论文,可以在线阅读 https://www.cs.princeton.edu/courses/archive/fall03/cs518/papers/rpc.pdf

远程过程调用采用客户机/服务器(C/S)模式。请求程序就是一个客户机,而服务提供程序就是一台服务器。和常规或本地过程调用一样,远程过程调用是同步操作,在远程过程结果返回之前,需要暂时中止请求程序。使用相同地址空间的低权进程或低权线程允许同时运行多个远程过程调用。

1.2 RPC 结构

Nelson 的论文中指出实现 RPC 的程序包括 5 个部分:

  1. User
  2. User-stub 存根进程
  3. RPCRuntime
  4. Server-stub 存根进程
  5. Server

RPC结构

在这里插入图片描述

这里 user 就是 client 端,当 user 想发起一个远程调用时,它实际是通过本地调用 user-stub。user-stub 负责将调用的接口、方法和参数通过约定的协议规范进行编码并通过本地的 RPCRuntime 实例传输到远端的实例。远端 RPCRuntime 实例收到请求后交给 server-stub 进行解码后发起本地端调用,调用结果再返回给 user 端。

以上是粗粒度的 RPC 实现概念结构,接下来我们进一步细化它应该由哪些组件构成,如下图所示。

RPC 结构拆解

在这里插入图片描述
RPC 服务方通过 RpcServer 去导出(export)远程接口方法,而客户方通过 RpcClient 去引入(import)远程接口方法。客户方像调用本地方法一样去调用远程接口方法,RPC 框架提供接口的代理实现,实际的调用将委托给代理RpcProxy 。代理封装调用信息并将调用转交给RpcInvoker 去实际执行。在客户端的RpcInvoker 通过连接器RpcConnector 去维持与服务端的通道RpcChannel,并使用RpcProtocol 执行协议编码(encode)并将编码后的请求消息通过通道发送给服务方。

RPC 服务端接收器 RpcAcceptor 接收客户端的调用请求,同样使用RpcProtocol 执行协议解码(decode)。解码后的调用信息传递给RpcProcessor 去控制处理调用过程,最后再委托调用给RpcInvoker 去实际执行并返回调用结果。如下是各个部分的详细职责:

1. RpcServer  
   负责导出(export)远程接口  

2. RpcClient  
   负责导入(import)远程接口的代理实现  

3. RpcProxy  
   远程接口的代理实现  

4. RpcInvoker  
   客户方实现:负责编码调用信息和发送调用请求到服务方并等待调用结果返回  
   服务方实现:负责调用服务端接口的具体实现并返回调用结果  

5. RpcProtocol  
   负责协议编/解码  

6. RpcConnector  
   负责维持客户方和服务方的连接通道和发送数据到服务方  

7. RpcAcceptor  
   负责接收客户方请求并返回请求结果  

8. RpcProcessor  
   负责在服务方控制调用过程,包括管理调用线程池、超时时间等  

9. RpcChannel  
   数据传输通道 

1.3 RPC 工作原理

RPC的设计由Client,Client stub,Network ,Server stub,Server构成。 其中Client就是用来调用服务的,Cient stub是用来把调用的方法和参数序列化的(因为要在网络中传输,必须要把对象转变成字节),Network用来传输这些信息到Server stub, Server stub用来把这些信息反序列化的,Server就是服务的提供者,最终调用的就是Server提供的方法。
在这里插入图片描述

RPC工作原理

  1. Client像调用本地服务似的调用远程服务;
  2. Client stub接收到调用后,将方法、参数序列化
  3. 客户端通过sockets将消息发送到服务端
  4. Server stub 收到消息后进行解码(将消息对象反序列化)
  5. Server stub 根据解码结果调用本地的服务
  6. 本地服务执行(对于服务端来说是本地执行)并将结果返回给Server stub
  7. Server stub将返回结果打包成消息(将结果消息对象序列化)
  8. 服务端通过sockets将消息发送到客户端
  9. Client stub接收到结果消息,并进行解码(将结果消息发序列化)
  10. 客户端得到最终结果。

RPC 调用分以下两种:

  1. 同步调用:客户方等待调用执行完成并返回结果。
  2. 异步调用:客户方调用后不用等待执行结果返回,但依然可以通过回调通知等方式获取返回结果。若客户方不关心调用返回结果,则变成单向异步调用,单向调用不用返回结果。

异步和同步的区分在于是否等待服务端执行完成并返回结果。

1.4 RPC 能干什么?

RPC 的主要功能目标是让构建分布式计算(应用)更容易,在提供强大的远程调用能力时不损失本地调用的语义简洁性。为实现该目标,RPC 框架需提供一种透明调用机制,让使用者不必显式的区分本地调用和远程调用,在之前给出的一种实现结构,基于 stub 的结构来实现。下面我们将具体细化 stub 结构的实现。

  1. 可以做到分布式,现代化的微服务
  2. 部署灵活
  3. 解耦服务
  4. 扩展性强

RPC的目的是让你在本地调用远程的方法,而对你来说这个调用是透明的,你并不知道这个调用的方法是部署哪里。通过RPC能解耦服务,这才是使用RPC的真正目的。

1.5 传递引用参数

传递引用参数相对来说比较困难。单纯传递参数的引用(也包含指针)是完全没有意义的,因为引用地址传递给远程计算机,其指向的内存位置可能跟远程系统上完全不同。如果你想支持传递引用参数,你必须发送参数的副本,将它们放置在远程系统内存中,向他们传递一个指向服务器函数的指针,然后将对象发送回客户端,复制它的引用。如果远程过程调用必须支持引用复杂的结构,比如树和链表,他们需要将结构复制到一个无指针的表示里面(比如,一个扁平的树),并传输到在远程端来重建数据结构。

1.6 如何表示数据

在本地系统上不存在数据不相容的问题,因为数据格式总是相同的。而在分布式系统中,不同远程机器上可能有不同的字节顺序,不同大小的整数,以及不同的浮点表示。对于 RPC,如果想与异构系统通信,我们就需要想出一个“标准”来对所有数据类型进行编码,并可以作为参数传递。例如,ONC RPC 使用 XDR (eXternal Data Representation) 格式 。这些数据表示格式可以使用隐式或显式类型。隐式类型,是指只传递值,而不传递变量的名称或类型。常见的例子是 ONC RPC 的 XDR 和 DCE RPC 的 NDR。显式类型,指需要传递每个字段的类型以及值。常见的例子是 ISO 标准 ASN.1 (Abstract Syntax Notation)、JSON (JavaScript Object Notation)、Google Protocol Buffers、以及各种基于 XML 的数据表示格式。

1.7 如何选用传输协议

有些实现只允许使用一个协议(例如 TCP )。大多数 RPC 实现支持几个,并允许用户选择。

1.8 出错时,会发生什么

相比于本地过程调用,远程过程调用出错的机会将会更多。由于本地过程调用没有过程调用失败的概念,项目使用远程过程调用必须准备测试远程过程调用的失败或捕获异常。

1.9 远程调用的语义是什么

调用一个普通的过程语义很简单:当我们调用时,过程被执行。远程过程完全一次性调用成功是非常难以实现。执行远程过程可以有如下结果:

  1. 如果服务器崩溃或进程在运行服务器代码之前就死了,那么远程过程会被执行0次;

  2. 如果一切工作正常,远程过程会被执行1次;

  3. 如果服务器返回服务器存根后在发送响应前就奔溃了,远程过程会被执行1次或者多次。客户端接收不到返回的响应,可以决定再试一次,因此出现多次执行函数。如果没有再试一次,函数执行一次;

  4. 如果客户机超时和重新传输,那么远程过程会被执行多次。也有可能是原始请求延迟了。两者都可能会执行或不执行。

    RPC 系统通常会提供至少一次或最多一次的语义,或者在两者之间选择。如果需要了解应用程序的性质和远程过程的功能是否安全,可以通过多次调用同一个函数来验证。如果一个函数可以运行任何次数而不影响结果,这是幂等(idempotent)函数的,如每天的时间、数学函数、读取静态数据等。否则,它是一个非幂等(nonidempotent)函数,如添加或修改一个文件)。

1.10 远程调用的性能怎么样

毫无疑问,一个远程过程调用将会比常规的本地过程调用慢得多,因为产生了额外的步骤以及网络传输本身存在延迟。然而,这并不应该阻止我们使用远程过程调用。

1.11 远程调用安全吗?

使用 RPC,我们必须关注各种安全问题:

  1. 客户端发送消息到远程过程,那个过程是可信的吗?
  2. 客户端发送消息到远程计算机,那个远程机器是可信的吗?
  3. 服务器如何验证接收的消息是来自合法的客户端吗?服务器如何识别客户端?
  4. 消息在网络中传播如何防止时被其他进程嗅探?
  5. 可以由其他进程消息被拦截和修改时遍历网络从客户端到服务器或服务器端?
  6. 协议能防止重播攻击吗?
  7. 如何防止消息在网络传播中被意外损坏或截断?

1.12 远程过程调用的优点

远程过程调用有诸多的优点:

  1. 你不必担心传输地址问题。服务器可以绑定到任何可用的端口,然后用 RPC 名称服务来注册端口。客户端将通过该名称服务来找到对应的端口号所需要的程序。而这一切对于程序员来说是透明的。
  2. 系统可以独立于传输提供者。自动生成服务器存根使其可以在系统上的任何一个传输提供者上可用,包括 TCP 和 UDP,而这些,客户端可以动态选择的。当代码发送以后,接收消息是自动生成的,而不需要额外的编程代码。
  3. 应用程序在客户端只需要知道一个传输地址——名称服务,负责告诉应用程序去哪里连接服务器函数集。
  4. 使用函数调用模型来代替 socket 的发送/接收(读/写)接口。用户不需要处理参数的解析。

1.13 RPC API

任何 RPC 实现都需要提供一组支持库。这些包括:

  1. 名称服务操作: 注册和查找绑定信息(端口、机器)。允许一个应用程序使用动态端口(操作系统分配的);
  2. 绑定操作:使用适当的协议建立客户机/服务器通信(建立通信端点);
  3. 终端操作:注册端点信息(协议、端口号、机器名)到名称服务并监听过程调用请求。这些函数通常被自动生成的主程序——服务器存根(骨架)所调用;
  4. 安全操作:系统应该提供机制保证客户端和服务器之间能够相互验证,两者之间提供一个安全的通信通道;
  5. 国际化操作(可能):这是很少的一部分 RPC 包可能包括了转换时间格式、货币格式和特定于语言的在字符串表的字符串的功能;
  6. 封送处理/数据转换操作:函数将数据序列化为一个普通的的字节数组,通过网络进行传递,并能够重建;
  7. 存根内存管理和垃圾收集:存根可能需要分配内存来存储参数,特别是模拟引用传递语义。RPC 包需要分配和清理任何这样的分配。他们也可能需要为创建网络缓冲区而分配内存。RPC 包支持对象,RPC 系统需要一种跟踪远程客户端是否仍有引用对象或一个对象是否可以删除。
  8. 程序标识操作:允许应用程序访问(或处理) RPC 接口集的标识符,这样的服务器提供的接口集可以被用来交流和使用。
  9. 对象和函数的标识操作: 允许将远程函数或远程对象的引用传递给其他进程。并不是所有的 RPC 系统都支持。

所以,判断一种通信方式是否是 RPC,就看它是否提供上述的 API。

2.0 实战裸写RPC

展示了一个简单 RPC 进行远程计算的例子。其中,远程过程 add(i,j) 有两个参数 i 和 j, 其结果是返回 i 和 j 的算术和。
在这里插入图片描述
通过 RPC 进行远程计算的步骤有:

  1. 将参数放入消息中,并在消息中添加要调用的过程的名称或者编码。
  2. 消息到达服务器后,服务器存根堆该消息进行分析,以判明需要调用哪个过程,随后执行相应的调用。
  3. 服务器运行完毕后,服务器存根将服务器得到的结果打包成消息送回客户存根,客户存根将结果从消息中提取出来,把结果值返回给客户端。

当然,这里只是做了简单的演示,在实际分布式系统中,还需要考虑其他情况,因为不同的机器对于数字、字符和其他类型的数据项的表示方式常有差异。比如整数型,就有 Big Endian 和 Little Endian 之分。

2.1 Client工程建立

参照教程【SpringBoot 2.1 | 第一篇:构建第一个SpringBoot工程】新建一个Spring Boot项目,名字叫RPCClient, 在目录src/main/java/resources 下找到配置文件application.properties,重命名为application.yml

Spring Boot 版本选择2.2.1,依赖勾选Developer Tools > Lombok.
在这里插入图片描述

2.2 Client新建公用接口和类

计算接口

package com.zgpeace.rpc.common;

public interface Calculator {
  int add(int a, int b);
}

RPC请求类,包括请求参数,和请求方法。

package com.zgpeace.rpc.common;

import lombok.Data;

import java.io.Serializable;

@Data
public class CalculateRPCRequest implements Serializable {

  private String method;
  private int a;
  private int b;

  @Override
  public String toString() {
    return "CalculateRPCRequest{" +
            "method='" + method + '\'' +
            ", a=" + a +
            ", b=" + b +
            "}";
  }

}

2.3 Client Service

客户端请求service

package com.zgpeace.rpc.client.service;

import com.zgpeace.rpc.common.Calculator;
import com.zgpeace.rpc.common.CalculateRPCRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.Socket;
import java.util.ArrayList;
import java.util.List;

public class CalculatorRemoteImpl implements Calculator {
  public static final int PORT = 9090;
  private static Logger logger = LoggerFactory.getLogger(CalculatorRemoteImpl.class);

  @Override
  public int add(int a, int b) {
    List<String> addressList = lookupProvider("Calculator.add");
    String address = chooseTarget(addressList);
    try {
      Socket socket = new Socket(address, PORT);

      // 将请求序列化
      CalculateRPCRequest calculateRPCRequest = generateRqeust(a, b);
      ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream());

      // 将请求发给服务提供方
      objectOutputStream.writeObject(calculateRPCRequest);

      // 将响应体反序列化
      ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream());
      Object response = objectInputStream.readObject();

      logger.info("response is {}", response);
      if (response instanceof Integer) {
        return (Integer)response;
      } else {
        throw new InternalError();
      }

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

  private CalculateRPCRequest generateRqeust(int a, int b) {
    CalculateRPCRequest calculateRPCRequest = new CalculateRPCRequest();
    calculateRPCRequest.setA(a);
    calculateRPCRequest.setB(b);
    calculateRPCRequest.setMethod("add");
    return calculateRPCRequest;
  }

  private String chooseTarget(List<String> providers) {
    if (null == providers || providers.size() == 0) {
      throw new IllegalArgumentException();
    }
    return providers.get(0);
  }

  private List<String> lookupProvider(String name) {
    List<String> strings = new ArrayList<>();
    strings.add("127.0.0.1");
    return strings;
  }
}

解析:

  1. socket建立长连接
  2. 把RPC请求对象序列化,包括参数和方法名。
  3. 通过socket发送请求
  4. 将响应体反序列化,把结果打印
  5. 关闭长连接

2.4 运行客户端

因为没有远程socket开启,所以是建立连接失败。

09:54:49.405 [main] ERROR com.zgpeace.rpc.client.service.CalculatorRemoteImpl - fail
java.net.ConnectException: Connection refused (Connection refused)
	at java.net.PlainSocketImpl.socketConnect(Native Method)
	at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:350)
	at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:206)
	at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:188)
	at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:392)
	at java.net.Socket.connect(Socket.java:589)
	at java.net.Socket.connect(Socket.java:538)
	at java.net.Socket.<init>(Socket.java:434)
	at java.net.Socket.<init>(Socket.java:211)
	at com.zgpeace.rpc.client.service.CalculatorRemoteImpl.add(CalculatorRemoteImpl.java:23)
	at com.zgpeace.rpc.client.controller.ClientController.main(ClientController.java:14)
Exception in thread "main" java.lang.InternalError
	at com.zgpeace.rpc.client.service.CalculatorRemoteImpl.add(CalculatorRemoteImpl.java:45)
	at com.zgpeace.rpc.client.controller.ClientController.main(ClientController.java:14)

Process finished with exit code 1


3.1 Provider工程建立

参照教程【SpringBoot 2.1 | 第一篇:构建第一个SpringBoot工程】新建一个Spring Boot项目,名字叫RPCProvider, 在目录src/main/java/resources 下找到配置文件application.properties,重命名为application.yml

Spring Boot 版本选择2.2.1,依赖勾选Developer Tools > Lombok.
在这里插入图片描述

3.2 Provider新建公用接口和类

拷贝在Client的公用接口Calculator和类CalculateRPCRequest, 按住option键(笔者是MacBook)把包common从Client工程拖过来即可。

3.3 Provider的Service

真正实现加法实现

package com.zgpeace.rpc.provider.service;

import com.zgpeace.rpc.common.Calculator;

public class CalculatorImpl implements Calculator {
  @Override
  public int add(int a, int b) {
    return a + b;
  }
}

3.4 Provider的Controller

启动ServerSocket长连接的Controller

package com.zgpeace.rpc.provider.controller;

import com.zgpeace.rpc.common.Calculator;
import com.zgpeace.rpc.provider.service.CalculatorImpl;
import com.zgpeace.rpc.common.CalculateRPCRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class ProviderController {
  private static Logger logger = LoggerFactory.getLogger(ProviderController.class);
  private Calculator calculator = new CalculatorImpl();

  public static void main(String[] args) throws IOException {
    logger.info("ProviderController running...");
    new ProviderController().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();

          logger.info("common is {}", object);
          System.out.println("common is {}" + object);

          //调用服务
          int result = 0;
          if (object instanceof CalculateRPCRequest) {
            CalculateRPCRequest calculateRPCRequest = (CalculateRPCRequest)object;
            logger.info("CalculateRPCRequest is {}", calculateRPCRequest.toString());
            if ("add".equals(calculateRPCRequest.getMethod())) {
              result = calculator.add(calculateRPCRequest.getA(), calculateRPCRequest.getB());
              logger.info("calculate success > {}", result);
            } else {
              throw new UnsupportedOperationException();
            }
          }

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

}

解析:

  1. ServerSocket启动长连接服务,监听端口9090
  2. 把接收到的二进制内容反序列化为对象
  3. 验证请求内容是否为预期的对象和方法
  4. 如果上面一条结果为正确,则实例化计算对象,把请求的参数参数传递给计算方法,计算出最终结果
  5. 序列化结果为二进制
  6. 返回给客户端

3.5 运行结果

先启动Provider Server

com.zgpeace.rpc.provider.controller.ProviderController - ProviderController running...

再启动客户端Client

com.zgpeace.rpc.client.service.CalculatorRemoteImpl - response is 3
com.zgpeace.rpc.client.controller.ClientController - result is 3

代码下载

https://github.com/zgpeace/Spring-Boot2.1/tree/master/rpc/SimpleRPC

总结

恭喜你!学完了RPC的入门教程。要想深入了解RPC推荐学习Spring Cloud 和

参考

https://zhuanlan.zhihu.com/p/36528189

https://dubbo.apache.org/zh-cn/blog/rpc-introduction.html

https://waylau.com/remote-procedure-calls/

https://www.cs.princeton.edu/courses/archive/fall03/cs518/papers/rpc.pdf

https://zhuanlan.zhihu.com/p/36427583

发布了127 篇原创文章 · 获赞 12 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/zgpeace/article/details/103287450