<RPC实战与核心原理>学习笔记 --- 基础篇

核心原理: RPC的通信流程

Remote Procedure Call,远程过程调用

  • 屏蔽远程调用跟本地调用的区别,感觉就是调用项目内的方法;
  • 隐藏底层网络通信的复杂性,更专注于业务逻辑。

RPC 常用于业务系统之间的数据交互,需要保证其可靠性
一般默认采用tcp协议, grpc采用的http2

RPC流程

在这里插入图片描述
AOP 技术,采用动态代理,通过字节码增强对方法进行拦截增强, 以便于增加需要的额外处理逻辑

用在rpc场景:
由服务提供者给出业务接口声明,在调用方的程序里面,RPC 框架根据调用的服务接口提前生成动态代理实现类,并通过依赖注入等技术注入到声明了该接口的相关业务逻辑里面。
该代理实现类会拦截所有的方法调用,在提供的方法处理逻辑里面完成一整套的远程调用,并把远程调用结果返回给调用方,这样调用方在调用远程方法的时候就获得了像调用本地接口一样的体验。

RPC在架构中的位置

RPC 框架能够解决系统拆分后的通信问题,并且能像调用本地一样去调用远程方法。

协议: 可扩展且向后兼容的协议

浏览器收到命令后会封装一个请求,并把请求发送到 DNS 解析出来的 IP 上,通过抓包工具可以抓到请求的数据包
在这里插入图片描述

协议的作用

只有二进制才能在网络中传输,所以 RPC 请求在发送到网络中之前,需要把方法调用的请求参数转成二进制;转成二进制后,写入本地 Socket 中,然后被网卡发送到网络设备中。

但在传输过程中,RPC 并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据包(合并的前提是同一个 TCP 连接上的数据),
对于服务提供方应用来说,会从 TCP 通道里面收到很多的二进制数据
服务提供方通过"断句"标示请求数据的结束位置。

为啥不用HTTP?

RPC 更多的是负责应用间的通信,所以性能要求相对更高

  • HTTP 协议的数据包大小相对请求数据本身要大很多,又需要加入很多无用的内容,比如换行符号、回车符等;
  • HTTP 协议属于无状态协议,客户端无法对请求和响应进行关联,每次请求都需要重新建立连接,响应完成后再关闭连接

如何设计一个私有 RPC 协议呢?

PC 每次发请求发的大小都是不固定的,所以协议必须能让接收方正确地读出不定长的内容

先固定一个长度(比如 4 个字节)用来保存整个请求数据大小
收到数据的时候,先读取固定长度的位置里面的值,值的大小就代表协议体的长度,接着再根据值的大小来读取协议体的数据,整个协议可以设计成这样:
在这里插入图片描述

  • 协议头是由一堆固定的长度参数组成
    协议长度、序列化方式,还会放一些像协议标示、消息 ID、消息类型这样的参数
  • 协议体是根据请求接口和参数构造的,长度属于可变的
    一般只放请求接口方法、请求的业务参数值和一些扩展属性

在这里插入图片描述

可拓展的协议

保证能平滑地升级改造前后的协议
协议头支持可扩展,扩展后协议头的长度就不能定长了
那要实现读取不定长的协议头里面的内容,在这之前肯定需要一个固定的地方读取长度,所以需要一个固定的写入协议头的长度。整体协议就变成了三部分内容:固定部分、协议头内容、协议体内容,前两部分还是可以统称为“协议头”,具体协议如下:

在这里插入图片描述

思考

RPC 不直接用 HTTP 协议的一个原因是无法实现请求跟响应关联,每次请求都需要重新建立连接,响应完成后再关闭连接,所以要设计私有协议。
在 RPC 里面怎么实现请求跟响应关联的呢?

官方解答:
为什么要把请求与响应关联?
这是因为在 RPC 调用过程中,调用端会向服务端发送请求消息,之后它还会收到服务端发送回来的响应消息,但这两个操作并不是同步进行的。
在高并发的情况下,调用端可能会在某一时刻向服务端连续发送很多条消息之后,才会陆续收到服务端发送回来的各个响应消息,
这时调用端需要一种手段来区分这些响应消息分别对应的是之前的哪条请求消息,所以说 RPC 在发送消息时要请求跟响应关联。

只要调用端在收到响应消息之后,从响应消息中读取到一个标识,告诉调用端,这是哪条请求消息的响应消息就可以了。
私有协议都会有消息 ID,这个消息 ID 的作用就是起到请求跟响应关联的作用。
调用端为每一个消息生成一个唯一的消息 ID,它收到服务端发送回来的响应消息如果是同一消息 ID,
那么调用端就可以认为,这条响应消息是之前那条请求消息的响应消息。

以 Dubbo 为例,消费者发送请求时,使用 AtomicLong 自增,产生一个 消息 ID。
由于 Dubbo 底层 IO 操作是异步的,Dubbo 发送请求之后,需要阻塞等待消费者返回信息
消费者会将消息 ID 保存到 Map 结构中。
为了保证请求响应可以一一对应,这就需要提供者返回的响应信息带上请求者消息 ID。
通过响应的消息 ID,通过 上面提到 Map 存储数据,就能找到对应的请求。
感参见 Dubbo 2.6 DefaultFuture 源码。

序列化:对象在网络中传输

网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是不能直接在网络中传输的,所以需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程一般叫做“序列化”。

序列化就是将对象转换成二进制数据的过程,而反序列就是反过来将二进制转换为对象的过程

在这里插入图片描述

RPC通信流程图

在这里插入图片描述

JDK原生序列化

序列化具体的实现是由 ObjectOutputStream 完成的,而反序列化的具体实现是由 ObjectInputStream 完成的

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class Student implements Serializable {
  // 学号
  private int no;
  // 姓名
  private String name;

  public static void main(String[] args) throws IOException, ClassNotFoundException {
    String home = System.getProperty("user.home");
    String basePath = home + "/Desktop";
    FileOutputStream fos = new FileOutputStream(basePath + "student.dat");
    Student student = new Student();
    student.setNo(100);
    student.setName("TEST_STUDENT");
    ObjectOutputStream oos = new ObjectOutputStream(fos);
    oos.writeObject(student);
    oos.flush();
    oos.close();

    FileInputStream fis = new FileInputStream(basePath + "student.dat");
    ObjectInputStream ois = new ObjectInputStream(fis);
    Student deStudent = (Student) ois.readObject();
    ois.close();

    System.out.println(deStudent);
  }

  public int getNo() {
    return no;
  }

  public void setNo(int no) {
    this.no = no;
  }

  public String getName() {
    return name;
  }

  public void setName(String name) {
    this.name = name;
  }

  @Override
  public String toString() {
    return "Student{" + "no=" + no + ", name='" + name + '\'' + '}';
  }
}

在这里插入图片描述
序列化过程就是在读取对象数据的时候,不断加入一些特殊分隔符,这些特殊分隔符用于在反序列化过程中截断用。

  • 头部数据用来声明序列化协议、序列化版本,用于高低版本向后兼容
  • 对象数据主要包括类名、签名、属性名、属性类型及属性值,当然还有开头结尾等数据,除了属性值属于真正的对象值,其他都是为了反序列化用的元数据
  • 存在对象引用、继承的情况下,就是递归遍历“写对象”逻辑

实际上任何一种序列化框架,核心思想就是设计一种序列化协议,将对象的类型、属性类型、属性值一一按照固定的格式写到二进制字节流中来完成序列化,再按照固定的格式一一读出对象的类型、属性类型、属性值,通过这些信息重新创建出一个新的对象,来完成反序列化。

JSON

json序列化的两个问题:

  • JSON 进行序列化的额外空间开销比较大,对于大数据量服务这意味着需要巨大的内存和磁盘开销;
  • JSON 没有类型,但像 Java 这种强类型语言,需要通过反射统一解决,所以性能不会太好。

所以如果 RPC 框架选用 JSON 序列化,服务提供者与服务调用者之间传输的数据量要相对较小,否则将严重影响性能。

Hessian

Hessian 是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架。
Hessian 协议要比 JDK、JSON 更加紧凑,性能上要比 JDK、JSON 序列化高效很多,而且生成的字节数也更小。

使用示例


Student student = new Student();
student.setNo(101);
student.setName("HESSIAN");

//把student对象转化为byte数组
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Hessian2Output output = new Hessian2Output(bos);
output.writeObject(student);
output.flushBuffer();
byte[] data = bos.toByteArray();
bos.close();

//把刚才序列化出来的byte数组转化为student对象
ByteArrayInputStream bis = new ByteArrayInputStream(data);
Hessian2Input input = new Hessian2Input(bis);
Student deStudent = (Student) input.readObject();
input.close();

System.out.println(deStudent);

相对于 JDK、JSON,由于 Hessian 更加高效,生成的字节数更小,有非常好的兼容性和稳定性,所以 Hessian 更加适合作为 RPC 框架远程通信的序列化协议。

但 Hessian 本身也有问题,官方版本对 Java 里面一些常见对象的类型不支持,比如:

  • Linked 系列,LinkedHashMap、LinkedHashSet 等,但是可以通过扩展 CollectionDeserializer 类修复;
  • Locale 类,可以通过扩展 ContextSerializerFactory 类修复;
  • Byte/Short 反序列化的时候变成 Integer。

Protobuf

Protobuf 是 Google 公司内部的混合语言数据标准,是一种轻便、高效的结构化数据存储格式,可以用于结构化数据序列化,支持 Java、Python、C++、Go 等语言。

Protobuf 使用的时候需要定义 IDL(Interface description language),然后使用不同语言的 IDL 编译器,生成序列化工具类,它的优点是:

  • 序列化后体积相比 JSON、Hessian 小很多;
  • IDL 能清晰地描述语义,所以足以帮助并保证应用程序之间的类型不会丢失,无需类似 XML 解析器;
  • 序列化反序列化速度很快,不需要通过反射获取类型;
  • 消息格式升级和兼容性不错,可以做到向后兼容。
/**
 *
 * // IDl 文件格式
 * synax = "proto3";
 * option java_package = "com.test";
 * option java_outer_classname = "StudentProtobuf";
 *
 * message StudentMsg {
 * //序号
 * int32 no = 1;
 * //姓名
 * string name = 2;
 * }
 * 
 */
 
StudentProtobuf.StudentMsg.Builder builder = StudentProtobuf.StudentMsg.newBuilder();
builder.setNo(103);
builder.setName("protobuf");

//把student对象转化为byte数组
StudentProtobuf.StudentMsg msg = builder.build();
byte[] data = msg.toByteArray();

//把刚才序列化出来的byte数组转化为student对象
StudentProtobuf.StudentMsg deStudent = StudentProtobuf.StudentMsg.parseFrom(data);

System.out.println(deStudent);

Protobuf 非常高效,但是对于具有反射和动态能力的语言来说,这样用起来很费劲,这一点就不如 Hessian,
比如用 Java 的话,这个预编译过程不是必须的,可以考虑使用 Protostuff。

Protostuff 不需要依赖 IDL 文件,可以直接对 Java 领域对象进行反 / 序列化操作,在效率上跟 Protobuf 差不多,生成的二进制格式和 Protobuf 是完全相同的,可以说是一个 Java 版本的 Protobuf 序列化框架。一些不支持的情况:

  • 不支持 null;(旧版本)
  • ProtoStuff 不支持单纯的 Map、List 集合对象,需要包在对象里面。

RPC 框架中如何选择序列化?

其他的序列化协议: Message pack、kryo

需要考虑
性能 / 效率 / 空间开销 / 序列化协议的通用性和兼容性 / 序列化协议的安全性

在序列化的选择上,与序列化协议的效率、性能、序列化协议后的体积相比,其通用性和兼容性的优先级会更高,因为他是会直接关系到服务调用的稳定性和可用率的,对于服务的性能来说,服务的可靠性显然更加重要。更加看重这种序列化协议在版本升级后的兼容性是否很好,是否支持更多的对象类型,是否是跨平台、跨语言的,是否有很多人已经用过并且踩过了很多的坑,其次才会去考虑性能、效率和空间开

在这里插入图片描述

首选的还是 Hessian 与 Protobuf,因为他们在性能、时间开销、空间开销、通用性、兼容性和安全性上,都满足了要求。

  • 其中 Hessian 在使用上更加方便,在对象的兼容性上更好;
  • Protobuf 则更加高效,通用性上更有优势。

RPC 框架在使用时要注意哪些问题

  1. 对象构造得过于复杂, 对象嵌套对象
    对象要尽量简单,没有太多的依赖关系,属性不要太多,尽量高内聚
  2. 对象过于庞大
    入参对象与返回值对象体积不要太大,更不要传太大的集合
  3. 使用序列化框架不支持的类作为入参类
    尽量使用简单的、常用的、开发语言原生的对象,尤其是集合类
    比如 Hessian 框架,天然是不支持 LinkHashMap、LinkedHashSet 等,而且大多数情况下最好不要使用第三方集合类, 如 Guava 中的集合类,很多开源的序列化框架都是优先支持编程语言原生的对象
  4. 对象有复杂的继承关系
    对象不要有复杂的继承关系,最好不要有父子类的情况

网络通信:RPC框架更倾向于哪种网络IO模型?

RPC 是解决进程间通信的一种方式。

服务调用者通过网络 IO 发送一条请求消息,服务提供者接收并解析,处理完相关的业务逻辑之后,再发送一条响应消息给服务调用者,服务调用者接收并解析响应消息,处理完相关的响应逻辑,一次 RPC 调用便结束了。

常见的网络 IO 模型

1. 同步阻塞 IO(BIO)(blocking IO)

在 Linux 中,默认情况下所有的 socket 都是 blocking 的

应用进程发起 IO 系统调用后,应用进程被阻塞,转到内核空间处理。之后,内核开始等待数据,等待到数据之后,再将内核中的数据拷贝到用户内存中,整个 IO 处理完毕后返回进程。最后应用的进程解除阻塞状态,运行业务逻辑

系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。而在这两个阶段中,应用进程中 IO 操作的线程会一直都处于阻塞状态,如果是基于 Java 多线程开发,那么每一个 IO 操作都要占用线程,直至 IO 操作结束

2. 同步非阻塞 IO(NIO)
3. IO 多路复用(IO multiplexing)

多路复用 IO 是在高并发场景中使用最为广泛的一种 IO 模型,如 Java 的 NIO、Redis、Nginx 的底层实现就是此类 IO 模型的应用,经典的 Reactor 模式也是基于此类 IO 模型。

IO 多路复用
多路就是指多个通道,也就是多个网络连接的 IO,而复用就是指多个通道复用在一个复用器上。
多个网络连接的 IO 可以注册到一个复用器(select)上,当用户进程调用了 select,那么整个进程会被阻塞。
同时,内核会“监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。
这个时候用户进程再调用 read 操作,将数据从内核中拷贝到用户进程。

当用户进程发起了 select 调用,进程会被阻塞,当发现该 select 负责的 socket 有准备好的数据时才返回,之后才发起一次 read,

  • 坏处
    整个流程要比阻塞 IO 要复杂,似乎也更浪费性能。
  • 好处
    用户可以在一个线程内同时处理多个 socket 的 IO 请求
    用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。
    而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

好比几个人一起去餐厅吃饭,专门留了一个人在餐厅排号等位,其他人就去逛街了,等排号的朋友通知可以吃饭就直接去享用了。

4. 异步非阻塞 IO(AIO)

为什么说阻塞 IO 和 IO 多路复用最为常用?

在网络 IO 的应用需要考虑

  • 系统内核的支持
    大多数系统内核都会支持阻塞 IO、非阻塞 IO 和 IO 多路复用,但像信号驱动 IO、异步 IO,只有高版本的 Linux 系统内核才会支持
  • 编程语言的支持
    无论 C++ 还是 Java,在高性能的网络编程框架的编写上,大多数都是基于 Reactor 模式,其中最为典型的便是 Java 的 Netty 框架,而 Reactor 模式是基于 IO 多路复用的。当然,在非高并发场景下,同步阻塞 IO 是最为常见的

这两种 IO 模型,已经可以满足绝大多数网络 IO 的应用场景。

RPC 框架在网络通信上倾向选择哪种网络 IO 模型?

  • IO 多路复用更适合高并发的场景,可以用较少的进程(线程)处理较多的 socket 的 IO 请求,但使用难度比较高。当然高级的编程语言支持得还是比较好的,比如 Java 语言有很多的开源框架对 Java 原生 API 做了封装,如 Netty 框架,使用非常简便;而 GO 语言,语言本身对 IO 多路复用的封装就已经很简洁了。

  • 而阻塞 IO 与 IO 多路复用相比,阻塞 IO 每处理一个 socket 的 IO 请求都会阻塞进程(线程),但使用难度较低。在并发量较低、业务逻辑只需要同步进行 IO 操作的场景下,阻塞 IO 已经满足了需求,并且不需要发起 select 调用,开销上还要比 IO 多路复用低。

RPC 调用在大多数的情况下,是一个高并发调用的场景,考虑到系统内核的支持、编程语言的支持以及 IO 模型本身的特点,在 RPC 框架的实现中,在网络通信的处理上,会选择 IO 多路复用的方式。

开发语言的网络通信框架的选型上,最优的选择是基于 Reactor 模式实现的框架,如 Java 语言,首选的框架便是 Netty 框架(Java 还有很多其他 NIO 框架,但目前 Netty 应用得最为广泛),并且在 Linux 环境下,也要开启 epoll 来提升系统性能(Windows 环境下是无法开启 epoll 的,因为系统内核不支持)

零拷贝(Zero-copy)

讲阻塞 IO 的时候讲过,系统内核处理 IO 操作分为两个阶段——等待数据和拷贝数据。

  • 等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中;
  • 而拷贝数据,就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中

具体流程如下:
在这里插入图片描述

应用进程的每一次写操作,都会把数据写到用户空间的缓冲区中,再由 CPU 将数据拷贝到系统内核的缓冲区中,之后再由 DMA 将这份数据拷贝到网卡中,最后由网卡发送出去
一次写操作数据要拷贝两次才能通过网卡发送出去,而用户进程的读操作则是将整个流程反过来,数据同样会拷贝两次才能让应用程序读取到数据

应用进程的一次完整的读写操作,都需要在用户空间与内核空间中来回拷贝,并且每一次拷贝,都需要 CPU 进行一次上下文切换(由用户进程切换到系统内核,或由系统内核切换到用户进程),这样很浪费 CPU 和性能

零拷贝(Zero-copy) — 可以减少进程间的数据拷贝,提高数据传输的效率
所谓的零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,都可以通过一种方式,让应用进程向用户空间写入或者读取数据,就如同直接向内核空间写入或者读取数据一样,再通过 DMA 将内核中的数据拷贝到网卡,或将网卡中的数据 copy 到内核。

用户空间与内核空间都将数据写到一个地方,就不需要拷贝 --> 虚拟内存

在这里插入图片描述
零拷贝有两种解决方式

  • mmap+write 方式
    通过虚拟内存来解决
  • sendfile 方式

Netty 中的零拷贝

最优选择:
基于 Reactor 模式实现的框架,如 Java 语言,首选的便是 Netty 框架

  • 操作系统层面上的零拷贝
    主要目标是避免用户空间与内核空间之间的数据拷贝操作,可以提升 CPU 的利用率。
  • Netty 的零拷贝
    站在了用户空间上,也就是 JVM 上,它的零拷贝主要是偏向于数据操作的优化上

那么 Netty 这么做的意义

RPC 框架如何去设计协议
在传输过程中,RPC 并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据包,所以消息都需要有边界
那么一端的机器收到消息之后,就需要对数据包进行处理,根据边界对数据包进行分割和合并,最终获得一条完整的消息。

收到消息后,对数据包的分割和合并,是在用户空间完成的,不是在内核空间完成的

因为对数据包的处理工作都是由应用程序来处理的,这里可能会存在数据的拷贝操作,当然不是在用户空间与内核空间之间的拷贝,是用户空间内部内存中的拷贝处理操作。Netty 的零拷贝就是为了解决这个问题,在用户空间对数据操作进行优化

那么 Netty 是怎么对数据操作进行优化的呢?

  • Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf,避免了各个 ByteBuf 之间的拷贝。
  • ByteBuf 支持 slice 操作,因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf,避免了内存的拷贝。
  • 通过 wrap 操作,可以将 byte[] 数组、ByteBuf、ByteBuffer 等包装成一个 Netty ByteBuf 对象, 进而避免拷贝操作。

Netty 框架中很多内部的 ChannelHandler 实现类,都是通过 CompositeByteBuf、slice、wrap 操作来处理 TCP 传输中的拆包与粘包问题的。

那么 Netty 有没有解决用户空间内核空间之间的数据拷贝问题的方法呢?

Netty 的 ByteBuffer 可以采用 Direct Buffers,使用堆外直接内存进行 Socket 的读写操作,最终的效果与刚才讲解的虚拟内存所实现的效果是一样的。

Netty 还提供 FileRegion 中包装 NIO 的 FileChannel.transferTo() 方法实现了零拷贝,这与 Linux 中的 sendfile 方式在原理上也是一样的。

总结

RPC 框架在网络通信的处理上,更倾向选择 IO 多路复用的方式

零拷贝带来的好处就是避免没必要的 CPU 拷贝,让 CPU 解脱出来去做其他的事,同时也减少了 CPU 在用户空间与内核空间之间的上下文切换,从而提升了网络通信效率与应用程序的整体性能

Netty 的零拷贝与操作系统的零拷贝是有些区别的,Netty 的零拷贝偏向于用户空间中对数据操作的优化,这对处理 TCP 传输中的拆包粘包问题有着重要的意义,对应用程序处理请求数据与返回数据也有重要的意义

合理使用 ByteBuf 子类,做到完全零拷贝,提升 RPC 框架的整体性能。

思考

IO多路复用分为select,poll和epoll,文中描述的应该是select的过程,nigix,redis等使用的是epoll

目前很多的主流的需要通信的中间件都差不多都实现了零拷贝,如Kfaka,RocketMQ等。
kafka的零拷贝是通过java.nio.channels.FileChannel中的transferTo方法来实现的,transferTo方法底层是基于操作系统的sendfile这个system call来实现的

动态代理:面向接口编程,屏蔽RPC处理流程

不需要改动原有代码的前提下,还能实现非业务逻辑跟业务逻辑的解耦

核心: 采用动态代理技术,通过对字节码进行增强,在方法调用的时候进行拦截,以便于在方法调用前后,增加需要的额外处理逻辑。

远程调用

RPC 会自动给接口生成一个代理类,当在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类
这样在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样就可以在生成的代理类里面,加入远程调用逻辑。

在这里插入图片描述

帮用户屏蔽远程调用的细节,实现像调用本地一样地调用远程的体验

实现原理

/** 要代理的接口 */
interface Hello {
  String say();
}

/** 真实调用对象 */
class RealHello {
  public String invoke() {
    return "i'm proxy";
  }
}

/** JDK代理类生成 */
class JDKProxy implements InvocationHandler {
  private Object target;

  JDKProxy(Object target) {
    this.target = target;
  }

  @Override
  public Object invoke(Object proxy, Method method, Object[] paramValues) {
    return ((RealHello) target).invoke();
  }
}

/** 测试例子 */
public class TestProxy {

  public static void main(String[] args) {
    // 构建代理器
    JDKProxy proxy = new JDKProxy(new RealHello());
    ClassLoader classLoader = ClassLoaderUtils.getCurrentClassLoader();
    // 把生成的代理类保存到文件
    System.setProperty("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
    // 生成代理类
    Hello test = (Hello) Proxy.newProxyInstance(classLoader, new Class[] {Hello.class}, proxy);
    // 方法调用
    System.out.println(test.say());
  }
}Hello 接口生成一个动态代理类,并调用接口 say() 方法,真实返回的值是来自 RealHello 里面的 invoke() 方法返回值

Proxy.newProxyInstance

代理类生成流程
在这里插入图片描述

在生成字节码的那个地方,也就是 ProxyGenerator.generateProxyClass() 方法里面
通过代码可以看到,里面是用参数 saveGeneratedFiles 来控制是否把生成的字节码保存到本地磁盘。
同时为了更直观地了解代理的本质,需要把参数 saveGeneratedFiles 设置成 true,
但这个参数的值是由 key 为“sun.misc.ProxyGenerator.saveGeneratedFiles”的 Property 来控制的,
动态生成的类会保存在工程根目录下的 com/sun/proxy 目录里面。

反编译 $Proxy0.class 文件:

可以看到 $Proxy0 类里面有一个跟 Hello 一样签名的 say() 方法,
其中 this.h 绑定的是刚才传入的 JDKProxy 对象,
所以当调用 Hello.say() 的时候,其实它是被转发到了 JDKProxy.invoke()

package com.sun.proxy;

import com.proxy.Hello;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;

public final class $Proxy0 extends Proxy implements Hello {
  private static Method m3;
  
  private static Method m1;
  
  private static Method m0;
  
  private static Method m2;
  
  public $Proxy0(InvocationHandler paramInvocationHandler) {
    super(paramInvocationHandler);
  }
  
  public final String say() {
    try {
      return (String)this.h.invoke(this, m3, null);
    } catch (Error|RuntimeException error) {
      throw null;
    } catch (Throwable throwable) {
      throw new UndeclaredThrowableException(throwable);
    } 
  }
  
  public final boolean equals(Object paramObject) {
    try {
      return ((Boolean)this.h.invoke(this, m1, new Object[] { paramObject })).booleanValue();
    } catch (Error|RuntimeException error) {
      throw null;
    } catch (Throwable throwable) {
      throw new UndeclaredThrowableException(throwable);
    } 
  }
  
  public final int hashCode() {
    try {
      return ((Integer)this.h.invoke(this, m0, null)).intValue();
    } catch (Error|RuntimeException error) {
      throw null;
    } catch (Throwable throwable) {
      throw new UndeclaredThrowableException(throwable);
    } 
  }
  
  public final String toString() {
    try {
      return (String)this.h.invoke(this, m2, null);
    } catch (Error|RuntimeException error) {
      throw null;
    } catch (Throwable throwable) {
      throw new UndeclaredThrowableException(throwable);
    } 
  }
  
  static {
    try {
      m3 = Class.forName("com.proxy.Hello").getMethod("say", new Class[0]);
      m1 = Class.forName("java.lang.Object").getMethod("equals", 
      	new Class[] { Class.forName("java.lang.Object") });
      m0 = Class.forName("java.lang.Object").getMethod("hashCode", new Class[0]);
      m2 = Class.forName("java.lang.Object").getMethod("toString", new Class[0]);
      return;
    } catch (NoSuchMethodException noSuchMethodException) {
      throw new NoSuchMethodError(noSuchMethodException.getMessage());
    } catch (ClassNotFoundException classNotFoundException) {
      throw new NoClassDefFoundError(classNotFoundException.getMessage());
    } 
  }
}

实现方法

Java 领域完成代理功能的:

  • JDK 默认的 InvocationHandler
    单纯从代理功能上来看,JDK 默认的代理功能是有一定的局限性的,它要求被代理的类只能是接口。原因是因为生成的代理类会继承 Proxy 类,但 Java 是不支持多重继承的。
    这个限制在 RPC 应用场景里面还是挺要紧的,因为对于服务调用方来说,在使用 RPC 的时候本来就是面向接口来编程的。使用 JDK 默认的代理功能,最大的问题就是性能问题。它生成后的代理类是使用反射来完成方法调用的,而这种方式相对直接用编码调用来说,性能会降低,但 JDK8 及以上版本对反射调用的性能有很大的提升。
  • Javassist
    相对 JDK 自带的代理功能,Javassist 的定位是能够操纵底层字节码,所以使用起来并不简单,要生成动态代理类恐怕是有点复杂了。但好的方面是,通过 Javassist 生成字节码,不需要通过反射完成方法调用,所以性能肯定是更胜一筹的。在使用中,要注意一个问题,通过 Javassist 生成一个代理类后,此 CtClass 对象会被冻结起来,不允许再修改;否则,再次生成时会报错
  • Byte Buddy
    Byte Buddy 则属于后起之秀,在很多优秀的项目中,像 Spring、Jackson 都用到了 Byte Buddy 来完成底层代理。相比 Javassist,Byte Buddy 提供了更容易操作的 API,编写的代码可读性更高。更重要的是,生成的代理类执行速度比 Javassist 更快

区别

  • 通过什么方式生成的代理类
  • 在生成的代理类里面是怎么完成的方法调用

动态代理框架选型

  • 因为代理类是在运行中生成的,那么代理框架生成代理类的速度、生成代理类的字节码大小等等,都会影响到其性能——生成的字节码越小,运行所占资源就越小。
  • 还有就是生成的代理类,是用于接口方法请求拦截的,所以每次调用接口方法的时候,都会执行生成的代理类,这时生成的代理类的执行效率就需要很高效。
  • 最后一个是从的使用角度出发的,肯定希望选择一个使用起来很方便的代理类框架,比如可以考虑:API 设计是否好理解、社区活跃度、还有就是依赖复杂度等等。

思考

如果没有动态代理完成方法调用拦截,用户该怎么完成 RPC 调用

官方:
参考下 gRPC 框架。
gRPC 框架中就没有使用动态代理,它是通过代码生成的方式生成 Service 存根,
这个 Service 存根起到的作用和 RPC 框架中的动态代理是一样的。
gRPC 框架用代码生成的 Service 存根来代替动态代理主要是为了实现多语言的客户端,
因为有些语言是不支持动态代理的,比如 C++、go 等,但缺点也是显而易见的。
如果使用过 gRPC 这种代码生成 Service 存根的方式与动态代理相比还是很麻烦的,并不如动态代理的方式使用起来方便、透明。

服务消费者将类信息序列化
—>
按照协议拼接报文
---->
调用网络程序发送报文
---->
收到提供者返回信息
---->
根据协议解析出返回信息
----->
再反序列化成返回结果

RPC实战:剖析gRPC源码,动手实现一个完整的RPC

gRPC 是由 Google 开发并且开源的一款高性能、跨语言的 RPC 框架,当前支持 C、Java 和 Go 等语言,当前 Java 版本最新 Release 版为 1.27.0。
gRPC 有很多特点,比如跨语言,通信协议是基于标准的 HTTP/2 设计的,序列化支持 PB(Protocol Buffer)和 JSON,整个调用示例如下图所示:

gRPC调用示例图在这里插入图片描述

通过 Protocol Buffer 定义’接口’

syntax = "proto3";

option java_multiple_files = true;
option java_package = "io.grpc.hello";
option java_outer_classname = "HelloProto";
option objc_class_prefix = "HLW";

package hello;

service HelloService{
rpc Say(HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
string name = 1;
}

message HelloReply {
string message = 1;
}

有了这段代码, 可以利用 Protocol Buffer 的编译器 protoc,再配合 gRPC Java 插件(protoc-gen-grpc-java),通过命令行 protoc3 加上 plugin 和 proto 目录地址参数,为客户端和服务器端生成消息对象gRPC 通信所需要的基础代码

如果项目是 Maven 工程的话,还可以直接选择使用 Maven 插件来生成同样的代码

发送原理

调用端代码

生成完基础代码以后,可以基于生成的代码写下调用端代码,具体如下:

package io.grpc.hello;

import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.StatusRuntimeException;

import java.util.concurrent.TimeUnit;

public class HelloWorldClient {

    private final ManagedChannel channel;
    private final HelloServiceGrpc.HelloServiceBlockingStub blockingStub;
    /**
    * 构建Channel连接
    **/
    public HelloWorldClient(String host, int port) {
        this(ManagedChannelBuilder.forAddress(host, port)
                .usePlaintext()
                .build());
    }

    /**
    * 构建Stub用于发请求
    **/
    HelloWorldClient(ManagedChannel channel) {
        this.channel = channel;
        blockingStub = HelloServiceGrpc.newBlockingStub(channel);
    }
    
    /**
    * 调用完手动关闭
    **/
    public void shutdown() throws InterruptedException {
        channel.shutdown().awaitTermination(5, TimeUnit.SECONDS);
    }

    /**
    * 发送rpc请求
    **/
    public void say(String name) {
        // 构建入参对象
        HelloRequest request = HelloRequest.newBuilder().setName(name).build();
        HelloReply response;
        try {
            // 发送请求 -- 发起一个rpc调用
            response = blockingStub.say(request);
        } catch (StatusRuntimeException e) {
            return;
        }
        System.out.println(response);
    }

    public static void main(String[] args) throws Exception {
            HelloWorldClient client = new HelloWorldClient("127.0.0.1", 50051);
            try {
                client.say("world");
            } finally {
                client.shutdown();
            }
    }
}
调用端代码步骤
  1. 首先用 host 和 port 生成 channel 连接;
  2. 然后用前面生成的 HelloService gRPC 创建 Stub 类;
  3. 最后可以用生成的这个 Stub 调用 say 方法发起真正的 RPC 调用,后续其它的 RPC 通信细节就对使用者透明了
整体流程图

进入到ClientCalls.blockingUnaryCall 方法看逻辑细节
在这里插入图片描述

序列化在 gRPC 里面的应用

流程图第三步, 在 writePayload 之前,ClientCallImpl 里面方法 method.streamRequest(message)
把对象转成一个 InputStream,有了 InputStream 就很容易获得入参对象的二进制数据。但是为啥不直接返回二进制数组?

MethodDescriptor 对象是用来存储一些 RPC 调用过程中的元数据, 要调用 RPC 服务的接口名、方法名、服务调用的方式以及请求和响应的序列化和反序列化实现类

  • 当调用 method.streamRequest(message) 的时候,实际是调用 requestMarshaller.stream(requestMessage) 方法
  • requestMarshaller 方法是在绑定请求的时候用来序列化方式对象, 里面会绑定一个 Parser,这个 Parser 才真正地把对象转成了 InputStream 对象
二进制流经过网络传输后,如何正确地还原请求前语义

在 gRPC 文档中可以看到,gRPC 的通信协议是基于标准的 HTTP/2 设计的,而 HTTP/2 相对于常用的 HTTP/1.X 来说,它最大的特点就是多路复用、双向流,好比生活中的单行道和双行道,HTTP/1.X 就是单行道,HTTP/2 就是双行道。

那既然在请求收到后需要进行请求“断句”,那肯定就需要在发送的时候把断句的符号加上,在 gRPC 里面是怎么加的?
因为 gRPC 是基于 HTTP/2 协议,而 HTTP/2 传输基本单位是 Frame,Frame 格式是以固定 9 字节长度的 header,后面加上不定长的 payload 组成,协议格式如下图所示:
那在 gRPC 里面就变成怎么构造一个 HTTP/2 的 Frame 了。

在这里插入图片描述

现在回看上面那个流程图的第 4 步,在 write 到 Netty 里面之前,看到在 MessageFramer.writePayload 方法里面会间接调用 writeKnownLengthUncompressed 方法,该方法要做的两件事情就是构造 Frame Header 和 Frame Body,然后再把构造的 Frame 发送到 NettyClientHandler,最后将 Frame 写入到 HTTP/2 Stream 中,完成请求消息的发送。

接收原理

服务提供方代码
static class HelloServiceImpl extends HelloServiceGrpc.HelloServiceImplBase {

  @Override
  public void say(HelloRequest req, StreamObserver<HelloReply> responseObserver) {
    HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build();
    responseObserver.onNext(reply);
    responseObserver.onCompleted();
  }
}

HelloServiceImpl 类是按照 gRPC 使用方式实现了 HelloService 接口逻辑,但是对于调用者来说并不能把它调用过来,因为没有把这个接口对外暴露,在 gRPC 里面是采用 Build 模式对底层服务进行绑定,具体代码如下:

package io.grpc.hello;

import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.stub.StreamObserver;

import java.io.IOException;

public class HelloWorldServer {

  private Server server;

  /**
  * 对外暴露服务
  **/
  private void start() throws IOException {
    int port = 50051;
    server = ServerBuilder.forPort(port)
        .addService(new HelloServiceImpl())
        .build()
        .start();
    Runtime.getRuntime().addShutdownHook(new Thread() {
      @Override
      public void run() {
        HelloWorldServer.this.stop();
      }
    });
  }

  /**
  * 关闭端口
  **/
  private void stop() {
    if (server != null) {
      server.shutdown();
    }
  }

  /**
  * 优雅关闭
  **/
  private void blockUntilShutdown() throws InterruptedException {
    if (server != null) {
      server.awaitTermination();
    }
  }


  public static void main(String[] args) throws IOException, InterruptedException {
    final HelloWorldServer server = new HelloWorldServer();
    server.start();
    server.blockUntilShutdown();
  }
  
}

服务对外暴露的目的是让过来的请求在被还原成信息后,能找到对应接口的实现。
在这之前,需要先保证能正常接收请求,通俗地讲就是要先开启一个 TCP 端口,让调用方可以建立连接,并把二进制数据发送到这个连接通道里面。

在这里插入图片描述

这四个步骤是用来开启一个 Netty Server,并绑定编解码逻辑的。
NettyServerHandler 里面会绑定一个 FrameListener,gRPC 会在这个 Listener 里面处理收到数据请求的 Header 和 Body,并且也会处理 Ping、RST 命令等,具体流程如下图所示:

在这里插入图片描述
在收到 Header 或者 Body 二进制数据后,NettyServerHandler 上绑定的 FrameListener 会把这些二进制数据转到 MessageDeframer 里面,从而实现 gRPC 协议消息的解析

Header 和 Body 数据是怎么分离出来的呢?

调用方发过来的是一串二进制数据,这就是前面开启 Netty Server 的时候绑定 Default HTTP/2FrameReader 的作用,它能按照 HTTP/2 协议的格式自动切分出 Header 和 Body 数据来,而对上层应用 gRPC 来说,它可以直接拿拆分后的数据来用。

总结

服务提供方通常都是以一个集群的方式对外提供服务的,所以在 gRPC 里面还可以看到负载均衡、服务发现等功能。
而且 gRPC 采用的是 HTTP/2 协议,还可以通过 Stream 方式来调用服务,以提升调用性能。

可以简单地认为 gRPC 就是采用 HTTP/2 协议,并且默认采用 PB 序列化方式的一种 RPC,它充分利用了 HTTP/2 的多路复用特性,使得可以在同一条链路上双向发送不同的 Stream 数据,以解决 HTTP/1.X 存在的性能问题

课后思考

在 gRPC 调用的时候,有一个关键步骤就是把对象转成可传输的二进制,
但是在 gRPC 里面,并没有直接转成二进制数组,而是返回一个 InputStream,这样做的好处是什么?


官方:
RPC 调用在底层传输过程中也是需要使用 Stream 的,直接返回一个 InputStream 而不是二进制数组,可以避免数据的拷贝。

InputStream封装了底层传输的字节缓冲区实现,它通常是一组通过指针连接起来的内存块的集合,这些内存块由网络的零拷贝获取的。
由于不能保证能够从内存块中获取一个byte[],不能传递一个简单的byte[]byte[][],并且可能需要一个目标byte[]来从缓冲区中获取数据。
另外byte[]的缺点是需要从缓冲区中复制一个大的、连续的数据,而实际上没有什么方法可以使它执行得更好。
当使用压缩时,也不知道消息未压缩的长度,它是动态解压缩的。
-->避免二次拷贝
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值