RPC:概述

RPC

提出问题

  • 考虑将“无所不在”的对象作为所有问题的解决之道,其思想是所有相互协作的对象彼此都知道对方在哪里
    • 当在一台计算机上的某个对象需要调 用在另一台计算机上的某个对象时就会发送一个包含这个请求的详细信息的网络消息。
    • 远程对象响应请求,并返回客户端

概述

客户端服务器角色

我们需要的机制并不是HTTP的机制,而是客户端程序员以常规的方式进行方法调用而无需操心将数据发送到网络上或解析响应之类的问题。

即在客户端为远程对象安装一个代理,对于客户端来说就像是要访问的远程对象一样,客户调用该代理时只需要进行常规的方法调用。代理负责使用网络协议与服务器联系。

什么是RPC服务

RPC协议是一种通过网络从远程计算机程序上请求服务来得到计算服务或数据服务,且不需要了解底层网络技术的协议和框架。RPC框架负责屏蔽底层的传输方式(TCP或者UDP)、序列化方式(XML/Json/二进制)和通信细节。框架使用者只需要了解谁在什么位置提供了什么样的远程服务接口即可,开发者不需要关心底层通信细节和调用过程。

  • 简单,RPC的语义十分清晰和简单,便于建立分布式系统
  • 高效,能够高效地实现远程的过程调用
  • 通用,RPC导出的服务可以供多个使用者用于不同的目的。

核心技术点

RPC框架实现的几个核心技术点总结如下:

  1. 远程服务提供者需要以某种形式提供服务调用相关的信息,包括但不限于服务接口定义、数据结构,或者中间态的服务定义文件,例如 Thrift 的 IDL 文件,WS-RPC 的 WSDL 文件定义,甚至也可以是服务端的接口说明文档;服务调用者需要通过一定的途径获取远程服务调用相关信息,例如服务端接口定义 Jar 包导入,获取服务端 IDL 文件等。
  2. 远程代理对象:服务调用者调用的服务实际是远程服务的本地代理,对于 Java 语言,它的实现就是 JDK 的动态代理,通过动态代理的拦截机制,将本地调用封装成远程服务调用。
  3. 通信:RPC 框架与具体的协议无关,例如 Spring 的远程调用支持 HTTP Invoke、RMI Invoke,MessagePack 使用的是私有的二进制压缩协议。
  4. 序列化:远程通信,需要将对象转换成二进制码流进行网络传输,不同的序列化框架,支持的数据类型、数据包大小、异常类型以及性能等都不同。不同的 RPC 框架应用场景不同,因此技术选择也会存在很大差异。一些做的比较好的 RPC 框架,可以支持多种序列化方式,有的甚至支持用户自定义序列化框架(Hadoop Avro)。

RPC协议实现

  • RPC协议以传输协议(HTTP,又或者TCP/UDP)为基础,为两个不同的应用程序传递数据。
  • RPC采用客户端/服务端模式,请求程序就是一个客户端,服务提供程序就是一个服务端
    • 1563772474306

RPC服务的原理

Socket套接字

网络上的两个程序通过一个双向通信连接实现数据的交换,这个连接的一端被称为Socket,用于描述IP地址和端口,是一个通信连接的句柄,可以用来实现不同计算机间的通信,是网络编程接口的具体实现。

本地调用过程

考虑C语言的调用

1
2
3
4
//fd:一个整形数,表示一个文件
//buf:一个字符数组,用于存储读入的数据
//nbytes:另一个整形数,记录实际读入的字节数,即数组长度
count = read(fd , buf , nbytes);
  • 在主程序中进行调用该函数,则调用之前堆栈的状态如图2(a)所示。
  • 为了进行调用,调用方首先把参数反序压入堆栈,即为最后一个参数先压入,如图2(b)所示。
  • 在read操作运行完毕后,它将返回值放在某个寄存器中,移出返回地址,并将控制权交回给调用方。
  • 调用方随后将参数从堆栈中移出,使堆栈还原到最初的状态。

一般为阻塞send,因为如同本地方法调用一般,需要先执行完需要执行的函数,才执行之后的

RPC调用过程

在本地调用过程中,read例程由链接器从库中提取出来,然后插入到目标程序当中。即通过编译的方式实现这一过程。而在RPC调用中并不可行,因为目标函数是存在于远端程序上,

因此RPC调用是建立在语言级别的,必须使用socket通信完成,为使得RPC调用如同本地调用一般透明,则出现了几个新的概念

  • 客户存根:存根就像代理一样,当read实际上是一个远程过程时(位于文件服务器所在机器上运行的过程),库中就放入read的另一个版本(代理代码),即客户存根
    • 在具体调用过程中,调用者调用存根如同调用本地代码一样方便
  • 服务器存根:客户存根的等价物
  • 其具体的原理为:
    • 对于每个独立的远程过程都有一个存根。当客户机调用远程过程时,RPC系统调用合适的存根,并传递远程过程的参数
    • 该存根位于服务器的端口,并编组参数(涉及将参数打包成可通过网络传输的形式),接着存根使用消息传递向服务器发送一个消息。
    • 服务器的一个类似存根接收这一消息,并调用服务器上的过程。如有必要,返回值可通过同样的技术返回客户机

1563773031397

  • 客户端以正常的方式调用客户存根。
    • 从客户端的角度来看,这个调用仿佛就是调用一个本地方法,而它的真正执行是发生在远端服务器上的。客户存根的方法将参数打包并封装成一个或多个网络消息体并发送到服务端。
    • 将参数封装到网络消息中的过程称为编码,它将所有的数据化为字节数组格式
  • 客户存根生成一个消息,然后调用本地操作系统;
    • 调用OS的socket套接字接口来向远程服务发送我们编码的网络消息
  • 客户端操作系统将消息发送给远程操作系统;
    • OS使用某种协议,如TCP传输到远程服务端
  • 远程操作系统将消息交给服务器存根;
    • 远程OS套接字收到消息,并将消息传递到指定端口
  • 服务器存根调将参数提取出来,而后调用服务器;
    • 服务器存根对参数进行解码,通常会将参数从标准的网络格式转换为特定的语言格式
  • 服务器执行要求的操作,操作完成后将结果返回给服务器存根;
    • 服务器存根调用服务端的方法,并且将从客户端接收的参数传递给该方法,它运行具体的功能并返回,这部分代码的执行对于客户端来说就是远程过程调用
  • 服务器存根将结果打包成一个消息,而后调用本地操作系统;
    • 将最后的结果进行编码并序列化,打包成网络参数等,
  • 服务器操作系统将含有结果的消息发送给客户端操作系统;
  • 客户端操作系统将消息交给客户存根;
  • 客户存根将结果从消息中提取出来,返回给调用它的客户存根。

RPC实现

Java RMI

对象被抽离出类,类里面包含操作,对这些类和方法的注册形成了第二代RPC

JavaRMI使程序员可以创建分布式应用程序,能够从其他JVM中调用远程对象的方法。

只要客户端应用程序具有对远程对象的引用,就可以进行远程调用,该引用是通过查找RMI提供的命名服务(RMI注册表)中的远程对象得到的,调用该引用就可以像调用本地方法一样调用远程方法,然后接收方法执行之后传递回来的返回值。

RMI架构

1563773983561

RMI中分布式对象模型的特点

  • 服务对象的引用可以作为参数传递或作为结果返回
  • 调用远程服务对象的方法就像调用本地对象的方法一样
  • 内置的Java的instanceof可以测试远程对象实现的远程接口,就像本地使用方法一样
  • 调用远程对象的方法实际上是与远程服务接口进行交互,而不是实现类
  • 远程方法调用过程中,参数和返回值是通过传递值类实现的,而不是传递引用
  • 远程对象的引用是通过引用传递的,一切真实的操作发生在远程的服务端
  • 客户端必须处理因为远程调用而导致的额外的异常。

存根与参数编码

客户端的存根构造了一个信息块:

  • 被使用的远程对象的标识符
  • 被调用的方法的描述
  • 编组后的参数

存根将此消息发送给服务器,在服务器的另一端,接收器对象执行以下动作:

  • 定位要调用的远程对象
  • 调用所需要的方法,并传递客户端提供的参数
  • 捕获返回值或该调用产生的异常
  • 将返回值编组,打包送回给客户端存根

实现

主要包括接口和对象类、远程接口实现类、生成存根代码、注册接口和查找对象、分布式的垃圾回收器

接口和对象类

首先定义一个要被传输的对象类,它需要实现Serializable,以能够序列化

1
2
3
4
5
public class Person implements Serializable {
private int id;
private String name;
private int age;
}

远程对象的接口需要扩展Remote接口,并且当远程方法调用失败后会抛出异常

1
2
3
public interface PersonService extends Remote {
public Person getPersonInf(int n) throws RemoteException;
}

远程接口实现类

可以使用java.rmi.server.RemoteServer和它的子类来实现远程调用功能,

1
2
3
4
5
6
7
public class PersonServiceImpl extends UnicastRemoteObject implements PersonService {
@Override
public Person getPersonInf(int n) throws RemoteException {
Person person = new Person();
return person;
}
}

生成存根代码

存根可以由rmic编译器生成,Java1.5后,支持运行时动态生成存根类

注册接口和查找对象

创建Server代码,绑定特定的端口并注册远程接口的实现类,远程对象接口也可以通过java.rmi.Naming使用基于URL的方法进行注册。

RMI的URL以rmi:开头,后接服务器与一个可选的端口号,服务器告诉注册表在给定位置将这个名字关联到该对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Server {
public static void main(String[] args) {
try {
PersonService personService = new PersonServiceImpl();
LocateRegistry.createRegistry(6600);
Context namingContext = new InitialContext();
namingContext.rebind("rmi://127.0.0.1:8800/person-service", personService);
System.out.println("service started");
} catch (RemoteException e) {
e.printStackTrace();
} catch (NamingException e) {
e.printStackTrace();
}
}
}

创建Client,获取远程接口对应的远程实现类,并通过远程接口操作,以Nmaing.lookup绑定远程对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Client {
public static void main(String[] args) {
try {
PersonService personService = (PersonService) Naming.lookup("rmi://127.0.0.1:8800/person-service");
Person person = personService.getPersonInf(5);
System.out.println(person.toString());
} catch (RemoteException e) {
e.printStackTrace();
} catch (NotBoundException e) {
e.printStackTrace();
} catch (MalformedURLException e) {
e.printStackTrace();
}
}
}

分布式的垃圾回收器

启动服务端,再运行客户端即可以看到远程调用结果,然而如何判断服务器上的引用对象何时被垃圾回收。

RMI创建了一个分布式环境,运行在一个JVM上的进程可以访问运行在不同JVM进程上驻留的对象,即一个服务器上的进程需要知道一个对象在什么时候不会被客户引用,并且可以删除。

使用RMI的JVM中支持两种操作:标记脏数据和清理。

  • 当对象仍在使用时,本地JVM会定期向服务器的JVM发送一个标记脏数据的调用。标记脏数据基于服务器给定的时间间隔定期重新发送心跳信息
  • 当客户端没有更多的本地引用远程对象时,会发送一个清理调用给服务器,因此服务器只需要计算客户端发送的清理调用或者说心跳超时,即可删除对象。

RMI架构

1563777082595

  • 顶层是存根/骨架层,通过对将要传输的数据编码成流的行驶传递到远程引用层。
  • 远程引用层定义了RMI连接的调用语义并给予实现,RMI在进行远程调用时,用到JRMP协议,这一层提供了专门用于引用远程服务的RemoteRef对象
  • 传输层在JVM间建立基于流的网络连接,并负责设置和管理这些连接。

远程方法中的参数与返回值

对于从一个JVM向另一个JVM传递值,我们将其区分为两种情况,传递远程对象和传递非远程对象。

  • 若客户端传递了一个对WareHouse的引用,即通过它可以调用远程的仓库对象的一个存根给另一个对象,即传递远程对象的实例
  • 传递一般的Java对象,即传递非远程对象,即序列化

传递远程对象

当一个对远程对象的引用从一个虚拟机传递到另一个虚拟机时,该远程对象的发送者和接收者都将持有一个对同一个对象的引用,这个引用并非是一个内存位置,而是由网络地址和该远程对象的唯一标识符组成的。

动态类加载

当服务器返回了一个Product接口的一个实例,客户端编译时需要Product.class的类文件,但是客户端本身是没有其实例Book类,说明客户端需要拥有在运行时加载额外类的能力。

客户端使用了与RMI注册表相同的机制,即类由服务器提供服务,

SOA与微服务

第三代RPC框架已经超越了RPC本身,主要是面向服务的实现,实现了服务的管理和治理,提供了服务监控的方法等。

SOA

面向服务的架构(SOA)是一种软件架构模式,一些应用程序组件通过网络通信协议向其他组件传递服务,这种服务间的通信可以是简单的数据传递,也可以是两个或更多个彼此需要协调的服务间相互连接。

在SOA中根据服务的功能,将服务分为服务消费者和服务生产者两个主要角色。

dubbo-architucture

Web Service和ESB

根据场景SOA具体可以分为标准的WebService和企业服务总线ESB

Web Service底层使用HTTP,类似一个远程服务提供者,可以通过SOAP协议或RESTful协议实现。Web Service是一个平台独立、低耦合、自包含的基于可编程的Web应用程序,可使用开放的XML和JSON描述、发布、发现、协调和配置这些应用程序,用于开发分布式的应用程序。

其特点是服务独立,服务间通过SOAP协议调用,服务内容通过WSDL描述,服务注册与发现通过UDDI实现

ESB是集成架构的分割,允许通过公共通信总线进行通信。

微服务

微服务与SOA一脉相承,更侧重与服务间的隔离,让服务做专门的事情,可以敏捷迭代与上线。服务是一种软件架构模式,其中的复杂应用程序由微小而独立的进程组成,使用与语言无关的API通信。

1563778296842

微服务与SOA的差异

  • 微服务中,服务可以独立于其他服务进行操作和部署,更容易经常部署新版本的服务或独立扩展服务。
  • 微服务容错方面表现良好,若在一个微服务机器上存在内存泄漏,则只有该服务器受影响,因为微服务与微服务间是隔离的
  • SOA服务可能共享数据存储,而微服务中每个服务都具有独立的数据存储
  • SOA与微服务的主要区别是规模和范围,微服务发展了异步等,但微服务概念是包含在大的SOA体系中。从实现层次角度讲,某个子系统可能是由微服务实现的,多个这样的子系统可能是采用SOA实现的。

RPC协议选型

RPC协议的具体实现方式可以不同,RPC调用的协议选择包含两部分

  • 协议栈:广义上协议栈可以分为公有协议和私有协议,例如 HTTP、SMPP、WebService 等都是公有协议;如果是某个公司或者组织内部自定义、自己使用的协议,没有被国际标准化组织接纳和认可的,往往划为私有协议,例如 Thrift 协议。
  • 序列化方式:同一种协议也可以承载多种序列化方式,以 HTTP 协议为例,它可以承载文本类序列化方式,例如:XML、JSON 等,也可以承载二进制序列化方式,例如谷歌的 Protobuf。

而不同的协议选择对RPC调用的性能、开发难度和问题定位效率都有影响,因此,选择哪种协议,对RPC框架而言至关重要。由于各个协议都有自己的优缺点,有的看重性能和时延、有的更看重跨语言和可维护性。

私有协议流行的原因

由某个企业自己制订,协议实现细节不愿公开,只在企业自己生产的设备之间使用的协议。私有协议具有封闭性、垄断性、排他性等特点。如果网上大量存在私有(非标准)协议,现行网络或用户一旦使用了它,后进入的厂家设备就必须跟着使用这种非标准协议,才能够互连互通,否则根本不可能进入现行网络。这样,使用非标准协议的厂家就实现了垄断市场的愿望。

RPC通信方式

在传统的 Java 应用中,通常使用以下 4 种方式进行跨节点通信。

1.通过 RMI 进行远程服务调用。

2.通过 Java 的 Socket+Java 序列化的方式进行跨节点调用。

3.利用一些开源的 RPC 框架进行远程服务调用,例如 Facebook 的 Thrift,Google 的 gRPC 等。

4.利用标准的公有协议进行跨节点服务调用,例如 HTTP+XML、Restful+JSON 或者 WebService。

跨节点的远程服务调用,除了链路层的物理连接外,还需要对请求和响应消息进行编解码。在请求和应答消息本身以外,也需要携带一些其他控制和管理类指令,例如链路建立的握手请求和响应消息、链路检测的心跳消息等。当这些功能组合到一起之后,就会形成私有协议。

私有协议的优点:灵活性高,可以按照业务的使用场景来设计和优化,在某个公司或者组织内部使用时也可以按需定制和演进,所以大部分 RPC 框架都支持私有二进制协议,例如阿里的 Dubbo、华为的 ServiceComb、Apache 的 Thrift 等。

序列化方式

当进行远程跨进程服务调用时,需要把被传输的数据结构 / 对象序列化为字节数组或者 ByteBuffer。而当远程服务读取到 ByteBuffer 对象或者字节数组时,需要将其反序列化为原始的数据结构/对象。利用序列化框架可以实现上述转换工作。

Java 序列化从JDK 1.1版本就已经提供,它不需要添加额外的类库,只需实现 java.io.Serializable并生成序列ID即可,因此,它从诞生之初就得到了广泛的应用。但是在远程服务调用(RPC)时,很少直接使用Java序列化进行消息的编解码和传输,这又是什么原因呢?下面通过分析 Java 序列化的缺点来找出答案:

  1. 无法跨语言,是 Java 序列化最致命的问题。对于跨进程的服务调用,服务提供者可能会使用 C++ 或者其他语言开发,当我们需要和异构语言进程交互时,Java 序列化就难以胜任。由于 Java 序列化技术是 Java 语言内部的私有协议,其它语言并不支持,对于用户来说它完全是黑盒。对于 Java 序列化后的字节数组,别的语言无法进行反序列化,这就严重阻碍了它的应用。事实上,目前几乎所有流行的Java RPC通信框架,都没有使用 Java 序列化作为编解码框架,原因就在于它无法跨语言,而这些RPC框架往往需要支持跨语言调用。
  2. 相比于业界的一些序列化框架,Java默认的序列化效能较低,主要体现在:序列化之后的字节数组体积较大,性能较低。在同等情况下,编码后的字节数组越大,存储的时候就越占空间,存储的硬件成本就越高,并且在网络传输时更占带宽,导致系统的吞吐量降低。Java 序列化后的码流偏大也一直被业界所诟病,导致它的应用范围受到了很大限制。

当前比较流行的序列化方式可以分为两大类:

  1. 文本类序列化方式:主要包括 JSON 和 XML,它们的优点是:支持跨语言、可读性好、配套的支持工具比较全。缺点就是:序列化之后的码流比较大、冗余内容多,性能相对比较差。
  2. 私有的二进制类序列化方式:比较流行的有 Thrift 序列化框架、MessagePack 和谷歌的 Protobuf 框架。它的优点是性能高,缺点就是可读性差,支撑的工具链不健全。

协议选型

尽管公有协议种类繁多,例如之前非常流行的 WebService、WADL 等,但目前来看,如果选择公有协议,HTTP 协议还是首选,具有 Rest 风格的 Restful + JSON 接口是当前最流行的方式。

参考