跳至主要內容

RPC 核心基础

holic-x...大约 49 分钟架构RPC

RPC 核心基础

学习核心

  • RPC的作用核心:

    • 屏蔽远程调用跟本地调用的区别:像调用本地方法一样调用远程方法,可以更加方便构建分布式系统
    • 屏蔽底层网络通信复杂性:隐藏底层网络通信的复杂性,让开发者更专注于业务逻辑
  • RPC 底层原理:

    • RPC 三要素:服务提供方、服务调用方、注册中心
    • 通信流程:传输协议、序列化机制、协议格式、AOP
    • RPC 在架构中的位置:分布式系统的"脉络" ,解决系统拆分后的网络通信问题,有效解决传统系统解耦、开发效率低下的问题
  • 协议的设计:由浅入深

    • 基础版:消息长度、消息体
    • 进阶版:消息头(协议版本、消息长度、序列化方式、消息ID、消息类型等)、消息体
    • 扩展版:消息头(头长度、协议版本、消息长度、序列化方式、消息ID、消息类型等)、不固定的扩展头字段(也可以理解为消息头的一部分)、消息体
  • 网络通信:

    • 常见的网络IO模型:NIO、AIO、多路复用
    • 零拷贝
  • 动态代理:

    • 面向接口编程,屏蔽RPC处理流程
  • RPC实战(gRPC源码剖析)

学习资料

PRC 的 通信流程

1.什么是RPC?

​ RPC的全称是Remote Procedure Call,即远程过程调用。RPC的作用体现在两方面:

  • 屏蔽远程调用跟本地调用的区别:像调用本地方法一样调用远程方法,可以更加方便构建分布式系统
  • 屏蔽底层网络通信复杂性:隐藏底层网络通信的复杂性,让开发者更专注于业务逻辑

2.RPC 通信流程

(1)简易版本的RPC框架

传输协议

​ RPC 框架可以帮助应用透明地完成远程调用(发起请求的一方称为服务调用方,被调用的一方为服务提供方),常用于业务系统之间的数据交互,这个远程调用的过程需要通过网络来传输数据,因此需要保证其可靠性,所以RPC框架一般默认基于TCP来传输

序列化机制:数据在网络中传输是二进制数据

​ 网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象,因此需要通过序列化手段把它转成可传输的二进制

协议数据格式

​ 调用方持续地把请求参数序列化成二进制后,经过TCP传输给了服务提供方。服务提供方从TCP通道里面收到二进制数据,那如何知道一个请求的数据到哪里结束,是一个什么类型的请求呢?

​ 因此通过约定数据格式,限定数据的包装类型和长度,让服务提供方可以根据约定的数据格式正确识别和解析数据,将数据反序列化为请求对象并进行处理(根据反序列化的请求对象找到对应的服务实现类进行方法调用),随后将结果进行序列化回写到对应的TCP通道中;服务调用方获取到应答的数据包后,将数据流反序列化为响应对象,完成了一次PRC调用

AOP

​ 基于上述分析,如果要完成一次上述的RPC调用,需要手动构造请求、调用序列化方法、进行网络调用、响应处理,对于研发人员来说有一些底层的重复工作量是非常不友好的。因此需要进一步思考可以通过什么方式简化API,屏蔽RPC的细节,让使用者只关注业务接口的逻辑开发,像是调用本地方法一样调用远程

​ Spring 中的AOP技术采用动态代理的方式,通过字节码增强对方法进行拦截增强,以实现对方法的横向扩展,实际上这个方案也可以应用到RPC场景中来解决刚刚面临的问题

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

image-20241121172028997

3.RPC 在架构中的位置

​ RPC是解决应用间通信的一种方式,而无论是在一个大型的分布式应用系统还是中小型系统中,应用架构最终都会从“单体”演进成“微服务化”,整个应用系统会被拆分为多个不同功能的应用,并将它们部署在不同的服务器中,而应用之间会通过RPC进行通信,可以说RPC对应的是整个分布式应用系统,就像是“经络”一样的存在

如果没有RPC,现实中的开发过程是怎样的一个体验呢?

代码体系臃肿:所有的功能代码都会被堆砌在一个大项目中(单体应用),当要进行功能迭代的时候,有时候可能仅仅只是修改某一行代码想要验证,就会发现写代码1分钟编译运行1小时这种无力感

团队协同开发效率低下:在一些大型的项目中,团队协作开发的时候,如果一些队友将接口定义或者交互改了之后,其影响的覆盖面是很大的,甚至不可估计影响范围,不管是开发(核心接口一改可能整个系统都崩掉)还是测试(QA无法评估测试范围)、版本上线(进度受到拖延),整个流程都会受到影响,保险起见只能把所有的功能进行回归测试,这样会导致上线新功能的整体周期都特别长

​ 长此以往,这种系统架构的设计是很难支撑后续业务的快速扩张的。为了解决这个问题,首先都会想到可以采用“分而治之”的思想来进行拆分,但是拆分完的系统怎么保持跟未拆分前的调用方式一样呢?总不能因为架构升级,就把所有的代码都推倒重写一遍吧。RPC框架能够帮助我们解决系统拆分后的通信问题,并且能让我们像调用本地一样去调用远程方法。利用RPC不仅可以很方便地将应用架构从“单体”演进成“微服务化”,而且还能解决实际开发过程中的效率低下、系统耦合等问题,这样可以使得我们的系统架构整体清晰、健壮,应用可运维度增强

​ 当然RPC不仅可以用来解决通信问题,它还被用在了很多其他场景,比如:发MQ、分布式缓存、数据库等。一个应用的参考架构如下:使用了MQ来处理异步流程、Redis缓存热点数据、MySQL持久化数据,在系统中调用另外一个业务系统的接口,对这个应用来说这些都是属于RPC调用,而MQ、MySQL持久化的数据也会存在于一个分布式文件系统中,他们之间的调用也是需要用RPC来完成数据交互的

image-20241121173931537

协议:如何设计可扩展且向后兼容的协议?

什么是协议?如何理解协议?

核心:数据在网络中的传输一般是以二进制的形式,当接收方从通道中获取流数据的时候需要通过一个"分界线"的概念来判断一次请求数据的起点和终点,避免读取的语义不一致。而这个边界语义的表达就是此处说到的协议

​ 数据在网络中的传输一般是以二进制的形式,所以RPC请求在发送到网络中之前,需要把方法调用的请求参数转成二进制;转成二进制后,写入本地Socket中,然后被网卡发送到网络设备中。但在传输过程中,RPC并不会把请求参数的所有二进制数据整体一下子发送到对端机器上,中间可能会拆分成好几个数据包,也可能会合并其他请求的数据包(合并的前提是同一个TCP连接上的数据),至于怎么拆分合并,这其中的细节会涉及到系统参数配置和TCP窗口大小

​ 对于服务提供方应用来说,它会从TCP通道里面收到很多的二进制数据,那这时候怎么识别出哪些二进制是第一个请求的呢?(即如何判断一次请求的结束位置?)这就好比让你读一篇没有标点符号的文章,要怎么识别出每一句话到哪里结束呢?也就是"断句"

​ 同理在RPC传输数据的时候,为了能准确地“断句”,需要必须在应用发送请求的数据包里面加入“句号”,这样才能帮接收方应用从数据流里面分割出正确的数据。这个数据包里面的句号就是消息的边界,用于标示请求数据的结束位置。举个具体例子,调用方发送 AB、CD、EF 3 个消息,如果没有边界的话,接收端就可能收到ABCDEF或者ABC、DEF 这样的消息,这就会导致接收的语义跟发送的时候不一致了。

​ 所以呢,为了避免语义不一致的事情发生,需要在发送请求的时候设定一个边界,然后在收到请求的时候按照这个设定的边界进行数据分割。这个边界语义的表达,就是所说的协议

如何设计协议?

Q:既然HTTP协议和RPC协议都属于应用层协议,既然有现成的HTTP应用层协议,为什么RPC还要设计私有协议?

RPC主要负责应用间的通信,需要高性能支持。但HTTP协议的数据包设计相对有效的请求数据本身要大很多(对网络传输有影响),且HTTP协议是无状态协议,每次请求都需要重新建立连接、响应完成关闭连接,频繁的连接建立和销毁操作很难满足RPC的高性能支持要求

​ 相对于HTTP的用处,RPC更多的是负责应用间的通信,所以性能要求相对更高。但HTTP协议的数据包大小相对请求数据本身要大很多,又需要加入很多无用的内容,比如换行符号、回车符等;还有一个更重要的原因是,HTTP协议属于无状态协议,客户端无法对请求和响应进行关联,每次请求都需要重新建立连接,响应完成后再关闭连接。因此,对于要求高性能的RPC来说,HTTP协议基本很难满足需求,所以RPC会选择设计更紧凑的私有协议

  • 基础版本-V1:协议中需要存放什么内容,回归上面说的到"消息边界",因此可以协议长度(保存整个请求数据的大小)、**协议体(数据内容)**两个基础信息

​ 在接收到数据时可以根据读取固定长度的协议长度信息,然后根据值的大小读取协议体的数据信息

  • 进化版本-V2

​ 上述的版本中只实现了最基础的断句,但实际上还需要确认数据是基于哪种方式进行序列化和反序列化,因此要将序列化相关的参数信息约定好,同理也是用固定长度存储这些信息。而这些固定长度存放的参数被统一约定为"协议头",因此整个协议被划分为"协议头"(存储一些固定长度的参数,例如此处的协议长度、序列化方式)和"协议体"

​ 此外,在协议头里面,除了会放协议长度序列化方式,还会放一些像协议标示消息ID消息类型这样的参数,而协议体一般只放请求接口方法、请求的业务参数值和一些扩展属性

​ 基于此,一个完整的RPC协议雏形大概出现了:协议头(固定长度参数:协议长度序列化方式协议标示消息ID消息类型)+ 协议体(可变参数:请求参数信息和扩展属性)

  • 扩展版本-V3:上述计划版本中的协议设计是一个定长协议头,即往后无法往协议头上加入新参数,那么后续如果加入新参数就会导致线上兼容问题

如何理解此处的线上兼容问题? =》假设旧版本设计了一个88bit的协议头,其中协议长度32bit,旧的服务版本稳定运行。如果因功能迭代需要将协议头扩展为90bit的协议头(协议头里加了2bit),那么一些还没有升级的应用版本收到请求后还是会按照88bit来处理协议头,此时继续取32bit长度的消息体时就会将消息头多出来的2bit误当作消息体读取,就会导致读取的协议体数据时错误的

那么是否可以将要扩展的参数放在不定长的协议体中? =》协议体虽然不定长的,支持存储新加的参数,但是协议体中的内容都是需要经过序列化的,那么每当要获取到这个定义的参数就需要将这个协议体进行反序列化,但在某些场景下这样做的代价有点高。例如【判断请求是否超时的功能】:服务提供方收到一个过期请求(服务提供方收到的这个请求的时间大于调用方发送的时间和配置的超时时间)既然已经过期就没有必要接着处理,直接返回一个超时的响应提示。要实现这个功能,就要在协议里面传递这个配置的超时时间,那如果之前协议里面没有加超时时间参数的话,而是把这个超时时间加到协议体里面,这种做法显然会加重CPU的消耗

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

image-20241121213720861

​ 综上,要设计一个协议可能并不难,但是要设计一个可升级的协议则需要从很多方面如考虑,需要考虑扩展新特性的时候可以做到向下兼容,尽可能减少资源损耗。因此协议的设计不仅要支持协议头的扩展,还要支持协议体的扩展,通过这个设计的协议模板可以避掉一些坑

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

在不同的场景下合理地选择序列化方式,对提升RPC框架整体的稳定性和性能是至关重要的

序列化与反序列化

​ 对象在网络中的传输是通过二进制流的形式:对象 =》二进制流(序列化)、二进制流 =》对象(反序列化),

​ 服务调用方在发送请求先需要将请求数据转换为可通过网络传输二进制流,这个转换算法是可逆的,这个过程称为序列化;随后服务的提供方就可以正确地从二进制数据中分割出不同的请求,并根据请求类型和序列化类型将二进制的消息体逆向还原成请求对象,这个过程称为反序列化

image-20241121215641565

1.常见的序列化方式

🚀原生JDK序列化

​ JDK 原生的序列化方式是通过ObjectInputStreamObjectOutputStream实现的,其序列化过程分析如下:

image-20241121215934431

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

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

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

JDK 序列化案例

/**
 * Person 对象定义
 */
public class Person implements Serializable {

    private String id;

    private String name;

    public Person(){}

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

    @Override
    public String toString() {
        return "Person{" +
                "id='" + id + '\'' +
                ", name='" + name + '\'' +
                '}';
    }
}
/**
 * 原生JDK序列化方式
 */
public class JDKSerializable{

    // 序列化
    public static void serialize()throws Exception{
        // 1.定义对象
        Person person = new Person("001","小红");

        // 2.借助ObjectOutputStream输出为二进制流
        FileOutputStream fos = new FileOutputStream(new File("D:\\Desktop\\test\\rpc-person.dat"));
        ObjectOutputStream oos = new ObjectOutputStream(fos);
        oos.writeObject(person);
        oos.flush();

        // 关闭文件流
        oos.close();
        fos.close();
    }

    // 反序列化
    public static void unserialize()throws Exception{

        // 1.读取二进制流文件
        FileInputStream fis = new FileInputStream(new File("D:\\Desktop\\test\\rpc-person.dat"));
        // 2.反序列化
        ObjectInputStream ois = new ObjectInputStream(fis);
        Person person = (Person) ois.readObject();
        System.out.println(person);

    }

    public static void main(String[] args) throws Exception {
        JDKSerializable.serialize();
        JDKSerializable.unserialize();

    }

}

🚀JSON

​ JSON是典型的Key-Value方式,没有数据类型,是一种文本型序列化框架。它在应用上还是很广泛的,无论是前台Web用Ajax调用、用磁盘存储文本类型的数据,还是基于HTTP协议的RPC框架通信,都会选择JSON格式。但用JSON进行序列化有这样两个问题,需要格外注意:

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

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

🚀Hessian

​ Hessian是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架。Hessian协议要比JDK、JSON更加紧凑,性能上要比JDK、JSON序列化高效很多,而且生成的字节数也更小。相对于JDK、JSON,由于Hessian更加高效,生成的字节数更小,有非常好的兼容性和稳定性,所以Hessian更加适合作为RPC框架远程通信的序列化协议。但Hessian本身也有问题,官方版本对Java里面一些常见对象的类型不支持,比如:

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

以上这些情况,在实践时需要格外注意

Hessian 案例实践: todoopen in new window

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);

🚀Protobuf

​ Protobuf 是 Google 公司内部的混合语言数据标准,是一种轻便、高效的结构化数据存储格式,可以用于结构化数据序列化,支持Java、Python、C++、Go等语言。Protobuf使用的时候需要定义IDL(Interface description language),然后使用不同语言的IDL编译器,生成序列化工具类,它的优点是:

  • 序列化后体积相比 JSON、Hessian小很多;
  • IDL能清晰地描述语义,所以足以帮助并保证应用程序之间的类型不会丢失,无需类似 XML 解析器;
  • 序列化反序列化速度很快,不需要通过反射获取类型;
  • 消息格式升级和兼容性不错,可以做到向后兼容;

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

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

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

Protobuf 使用实践

/**
 *
 * // 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);

2.RPC 框架如何选择序列化方式?

​ 上述简单地介绍了几种最常见的序列化协议,其实远不止这几种,还有 Message pack、kryo等。那么面对这么多的序列化协议,在RPC框架中该如何选择呢?

性能和效率:序列化与反序列化过程是RPC调用的一个必须过程,那么序列化与反序列化的性能和效率势必将直接关系到RPC框架整体的性能和效率。

空间开销:也就是序列化之后的二进制数据的体积大小。序列化后的字节数据体积越小,网络传输的数据量就越小,传输数据的速度也就越快,由于RPC是远程调用,那么网络传输的速度将直接关系到请求响应的耗时

通用性和兼容性:在RPC实践中可能会遇到一些问题,比如某个类型为集合类的入参服务调用者不能解析了,服务提供方将入参类加一个属性之后服务调用方不能正常调用,升级了RPC版本后发起调用时报序列化异常了… 因此在序列化的选择上,与序列化协议的效率、性能、序列化协议后的体积相比,其通用性和兼容性的优先级会更高,因为它是会直接关系到服务调用的稳定性和可用率的,对于服务的性能来说,服务的可靠性显然更加重要。实际也更加看重这种序列化协议在版本升级后的兼容性是否很好,是否支持更多的对象类型,是否是跨平台、跨语言的,是否有很多人已经用过并且踩过了很多的坑,其次才会去考虑性能、效率和空间开销

​ 除了序列化协议的通用性和兼容性,序列化协议的安全性也是非常重要的一个参考因素,甚至应该放在第一位去考虑。以JDK原生序列化为例,它就存在漏洞。如果序列化存在安全漏洞,那么线上的服务就很可能被入侵

image-20241121220620215

​ 综合上面几个参考因素,首选的还是Hessian与Protobuf,因为他们在性能、时间开销、空间开销、通用性、兼容性和安全性上,都满足要求。其中Hessian在使用上更加方便,在对象的兼容性上更好;Protobuf则更加高效,通用性上更有优势

3.RPC 框架在使用时要注意什么问题?

​ 了解了在RPC框架中如何选择序列化,那么在使用过程中需要注意哪些序列化上的问题呢?

​ 在RPC的实践中,一般遇到的最多的问题就是序列化问题了,除了早期RPC框架本身出现的问题以外,大多数问题都是使用方使用不正确导致的,下述介绍高频出现的人为问题:

​ **对象构造得过于复杂:**属性很多,并且存在多层的嵌套,比如A对象关联B对象,B对象又聚合C对象,C对象又关联聚合很多其他对象,对象依赖关系过于复杂。序列化框架在序列化与反序列化对象时,对象越复杂就越浪费性能,消耗CPU,这会严重影响RPC框架整体的性能;另外,对象越复杂,在序列化与反序列化的过程中,出现问题的概率就越高。

​ **对象过于庞大:**我经常遇到业务过来咨询,为啥他们的RPC请求经常超时,排查后发现他们的入参对象非常得大,比如为一个大List或者大Map,序列化之后字节长度达到了上兆字节。这种情况同样会严重地浪费了性能、CPU,并且序列化一个如此大的对象是很耗费时间的,这肯定会直接影响到请求的耗时。

​ **使用序列化框架不支持的类作为入参类:**比如Hessian框架,他天然是不支持LinkedHashMap、LinkedHashSet等,而且大多数情况下最好不要使用第三方集合类,如Guava中的集合类。很多开源的序列化框架都是优先支持编程语言原生的对象。因此如果入参是集合类,应尽量选用原生的、最为常用的集合类,如HashMap、ArrayList。

​ **对象有复杂的继承关系:**大多数序列化框架在序列化对象时都会将对象的属性一一进行序列化,当有继承关系时,会不停地寻找父类,遍历属性。就像问题1一样,对象关系越复杂,就越浪费性能,同时又很容易出现序列化上的问题。

​ 在RPC框架的使用过程中,要尽量构建简单的对象作为入参和返回值对象,避免上述问题

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

学习核心

  • 常见网络IO模型基础概念和场景应用,引申出RPC框架在网络通信处理上的选择(IO多路复用)
  • 零拷贝(操作系统层、Netty)
    • 操作系统层的零拷贝的目的在于减少不必要的内存拷贝(用户空间和内核空间的内存拷贝)、避免频繁的上下文切换,以提升数据传输效率和应用整体性能
    • Netty中的零拷贝则是侧重于用户空间的数据操作优化,其对处理TCP传输中的拆包粘包问题以及应用程序处理请求/响应数据有着重要一一
    • 在RPC框架的开发和使用中,要深入了解网络通信相关的原理知识,尽量做到零拷贝(如使用Netty框架);合理使用ByteBuf子类,做到完全零拷贝,提升RPC框架的整体性能

1.常见的网络IO模型

​ 常见的网络IO模型分为四种:

  • 同步IO:同步阻塞IO(BIO)、同步非阻塞IO(NIO)、IO多路复用
  • 异步IO:异步非阻塞IO(AIO)

同步阻塞IO

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

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

现实场景案例:

水龙头接水:人拿杯子接水,整个过程中需要等待水被接满,期间人不能做其他事情

餐厅等位吃饭:去餐厅吃饭,人到达餐厅,向服务员点餐,之后要一直在餐厅等待后厨将菜做好,然后服务员会将菜端出来,人才能享用。

IO多路复用

IO多路复用:多路复用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请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

水龙头接水:接水时宿管阿姨负责监控,当有水来了就通知学生接水

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

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

​ 实际在网络IO的应用上,需要的是系统内核的支持以及编程语言的支持。

​ 在系统内核的支持上,现在大多数系统内核都会支持阻塞IO、非阻塞IO和IO多路复用,但像信号驱动IO、异步IO,只有高版本的Linux系统内核才会支持。

​ 在编程语言上,无论C++还是Java,在高性能的网络编程框架的编写上,大多数都是基于Reactor模式,其中最为典型的便是Java的Netty框架,而Reactor模式是基于IO多路复用的。当然,在非高并发场景下,同步阻塞IO是最为常见的。

​ 综合来讲,在这四种常用的IO模型中,应用最多的、系统内核与编程语言支持最为完善的,便是阻塞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的,因为系统内核不支持)

2.零拷贝

什么是零拷贝?

​ 系统内核处理IO操作分为两个阶段——等待数据和拷贝数据。等待数据,就是系统内核在等待网卡接收到数据后,把数据写到内核中;而拷贝数据,就是系统内核在获取到数据后,将数据拷贝到用户进程的空间中。以下是具体流程:

image-20241203202115147

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

​ 应用进程的一次完整的读写操作,都需要在用户空间与内核空间中来回拷贝,并且每一次拷贝,都需要CPU进行一次上下文切换(由用户进程切换到系统内核,或由系统内核切换到用户进程),一定程度上浪费了CPU和性能。因此引入了零拷贝(Zero-copy)技术以减少进程间的数据拷贝、避免频繁的上下文切换,提高数据传输的效率和应用程序的整体性能。

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

​ 那怎么做到零拷贝?可以试想一下如果用户空间与内核空间都将数据写到一个地方,是不是就不需要拷贝了?由此联想到虚拟内存

image-20241203202508311

​ 而零拷贝有两种解决方式:

  • mmap+write方式:核心原理就是通过虚拟内存解决
  • sendfile方式

Netty 中的零拷贝

​ 基于上述基础概念分析,RPC框架在网络通信框架的选型上,最优的选择是基于Reactor模式实现的框架,如Java语言(首选的便是Netty框架)。那么Netty框架是否也有零拷贝机制呢?Netty框架中的零拷贝和前面cue到的操作系统层面上的零拷贝又有什么不同呢?

​ 于操作系统层面而言,零拷贝的主要目标是避免用户空间和内核空间之间的数据拷贝操作,以提升CPU的利用率

​ 而Netty中的零拷贝则不同,它完全站在了用户空间上(也就是JVM上),Netty的零拷贝主要是偏向于数据操作的优化上,那么为什么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处理流程

学习核心

​ 动态代理是RPC框架实现中的一种具体的技术框架,它用于实现面向接口编程,帮助用户屏蔽RPC调用细节,最终呈现给用户一个像调用本地一样去调用远程的编程体验。涉及到技术选型的问题可以从下述角度切入:

① 代理类是在运行中生成的,那么代理框架生成代理类的速度、生成代理类的字节码大小等等,都会影响到其性能——生成的字节码越小,运行所占资源就越小

② 生成的代理类,是用于接口方法请求拦截的,所以每次调用接口方法的时候,都会执行生成的代理类,此时对生成的代理类的执行效率有高效性需求

③ 基于使用角度出发,需要考虑API设计是否好理解、社区活跃度、依赖复杂度等

1.远程调用

​ RPC 的使用:一般的做法是先通过Maven或者其他工具将相关接口定义依赖到项目中,在编写业务逻辑的时候如果要调用服务提供方提供的接口,则只需要通过依赖注入的方式把接口注入到项目中,然后在代码中调用接口方法。实际上接口中并不会包含真实的业务逻辑,真实的业务逻辑在服务提供放的应用中,但是通过调用接口方法确实拿到了想要的结果,进而实现像调用本地方法一样调用远程服务的效果,而实现这一神奇的魔术所应用的核心技术就是动态代理

​ RPC 会自动给接口生成一个代理类,当在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类。这样在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样就可以在生成的代理类里面,加入远程调用逻辑。通过这种设计,帮助用户屏蔽远程调用的细节,实现像调用本地一样地调用远程的体验

image-20241205171057218

JDK 动态代理 案例分析

​ 在Java开发中,JDK动态代理是一种非常有用的技术,它允许开发者在不修改目标类代码的情况下,为目标类添加额外的功能

/**
 * 代理接口
 */
public interface Target {

    // 接口方法定义
    public String say();

}
/**
 * 真实调用对象
 */
public class RealTarget {

    public String invoke(){
        return "i am proxy";
    }
}
/**
 * JDK 代理类
 */
public class JDKProxy implements InvocationHandler {

    private Object target;

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

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("before method invoke");
        Object res = ((RealTarget) target).invoke();
        System.out.println("after method invoke");
        return res;
    }
}
/**
 * 代理测试
 */
public class TestProxy {

    public static void main(String[] args) {
        // 构建代理器
        JDKProxy jdkProxy = new JDKProxy(new RealTarget());
        // ClassLoader classLoader = ClassLoaderUtils.getCurrentClassLoader();
        ClassLoader classLoader = Thread.currentThread().getContextClassLoader();

        // 把生成的代理类保存到文件
        System.setProperty("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
        // 生成代理类
        Target target = (Target) Proxy.newProxyInstance(classLoader, new Class[]{Target.class}, jdkProxy);
        // 方法调用
        System.out.println(target.say());
    }

}

​ 基于JDK1.8环境测试(JDK17执行没有看到生成的代理类),执行上述TestProxymain方法,给Target接口生成一个动态代理类,调用接口的say方法,获取到的真实返回值是来自于RealTarget里面的 invoke() 方法返回值。上述方法执行,会在工程目录下的com/sun/proxy目录中生成$Proxy0.class,可以通过反编译工具打开.class文件


package com.sun.proxy;

import com.noob.rpc.base.proxy.Target;
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 Target {
    private static Method m1;
    private static Method m2;
    private static Method m3;
    private static Method m0;

    public $Proxy0(InvocationHandler var1) throws  {
        super(var1);
    }

    public final boolean equals(Object var1) throws  {
        try {
            return (Boolean)super.h.invoke(this, m1, new Object[]{var1});
        } catch (RuntimeException | Error var3) {
            throw var3;
        } catch (Throwable var4) {
            throw new UndeclaredThrowableException(var4);
        }
    }

    public final String toString() throws  {
        try {
            return (String)super.h.invoke(this, m2, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    public final String say() throws  {
        try {
            return (String)super.h.invoke(this, m3, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    public final int hashCode() throws  {
        try {
            return (Integer)super.h.invoke(this, m0, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    static {
        try {
            m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
            m2 = Class.forName("java.lang.Object").getMethod("toString");
            m3 = Class.forName("com.noob.rpc.base.proxy.Target").getMethod("say");
            m0 = Class.forName("java.lang.Object").getMethod("hashCode");
        } catch (NoSuchMethodException var2) {
            throw new NoSuchMethodError(var2.getMessage());
        } catch (ClassNotFoundException var3) {
            throw new NoClassDefFoundError(var3.getMessage());
        }
    }
}

​ 关注$Proxy0#say()方法,这个say()方法和Target签名一样,其中(String)super.h绑定的是刚刚传入的JDKProxy对象,因此当调用Target.say()方法的时候,实际上是被转发到JDKProxy.invoke()方法中,而在这个方法中真正执行RealTarget的逻辑

2.实现方法

​ 在 Java 领域,除了JDK 默认的InvocationHandler能完成代理功能,还有很多其他的第三方框架也可以,比如像 JavassistByte Buddy 这样的框架

​ 单纯从代理功能上来看,JDK 默认的代理功能是有一定的局限性的,它要求被代理的类只能是接口。原因是因为生成的代理类会继承 Proxy 类,但Java 是不支持多重继承的

​ 这个限制在RPC应用场景里面还是挺要紧的,因为对于服务调用方来说,在使用RPC的时候本来就是面向接口来编程的。使用JDK默认的代理功能,最大的问题就是性能问题。它生成后的代理类是使用反射来完成方法调用的,而这种方式相对直接用编码调用来说,性能会降低,但好在JDK8及以上版本对反射调用的性能有很大的提升,所以还是可以期待一下的

​ 相对 JDK 自带的代理功能,Javassist的定位是能够操纵底层字节码,所以使用起来并不简单,要生成动态代理类恐怕是有点复杂了。但好的方面是,通过Javassist生成字节码,不需要通过反射完成方法调用,所以性能肯定是更胜一筹的。在使用中,要注意一个问题,通过Javassist生成一个代理类后,此 CtClass 对象会被冻结起来,不允许再修改;否则,再次生成时会报错

​ Byte Buddy 则属于后起之秀,在很多优秀的项目中,像Spring、Jackson都用到了Byte Buddy来完成底层代理。相比Javassist,Byte Buddy提供了更容易操作的API,编写的代码可读性更高。更重要的是,生成的代理类执行速度比Javassist更快

​ 虽然以上这三种框架使用的方式相差很大,但核心原理却是差不多的,区别就只是通过什么方式生成的代理类以及在生成的代理类里面是怎么完成的方法调用。也正是因为这些细小的差异,才导致了不同的代理框架在性能方面的表现不同。因此,在设计RPC框架的时候,还是需要进行一些比较的,可以综合它们的优劣以及你的场景需求进行选择

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

学习核心

GRPC 源码剖析open in new window

1.剖析gRPC

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

image-20241205211336660

​ 要快速掌握一门技术的技巧,了解全新框架的工作原理,最快的方式就是Hello World,所有的基础都是基于入门案例开始进行构建。构建基于gRPC框架的Hello World示例步骤说明如下:

① 通过.proto文件定义服务

② 通过protocol buffer compiler插件生成客户端和服务端

③ 通过grpc API生成客户端和服务端代码

① 通过.proto文件定义服务

syntax = "proto3"; // 使用proto3,否则使用proto2

option java_multiple_files = true;
option java_package = "com.noob.grpc.hello"; // 生成类的包名
option java_outer_classname = "HelloProto"; // 生成的数访问类的类名(如果没有指定此值,则生成的类名为proto文件名的驼峰命名法)
option objc_class_prefix = "HLW";

package hello;

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

message HelloRequest {
	string name = 1;
}

message HelloReply {
	string message = 1;
}

​ 定义好proto文件后,可以通过官方的工具生成类protoc-3.0.0-win32.zipopen in new window 或者 maven 插件生成基础类

② 通过protocol buffer compiler插件(maven或者原生插件)生成客户端和服务端

​ 此处基于maven插件进行构建(参考grpc-基础篇),基于JDK 8版本

maven 依赖配置(核心依赖说明)

<!-- 引入 grpc 相关依赖 -->
......
<dependency>
    <groupId>io.grpc</groupId>
    <artifactId>grpc-netty-shaded</artifactId>
    <version>1.16.1</version>
</dependency>
<dependency>
    <groupId>io.grpc</groupId>
    <artifactId>grpc-protobuf</artifactId>
    <version>1.16.1</version>
</dependency>
<dependency>
    <groupId>io.grpc</groupId>
    <artifactId>grpc-stub</artifactId>
    <version>1.16.1</version>
</dependency>
......

<!-- 配置相关插件 -->
<build>
    ......
    <plugins>
        <!-- 引入grpc相关组件 -->
        <plugin>
            <groupId>org.xolstice.maven.plugins</groupId>
            <artifactId>protobuf-maven-plugin</artifactId>
            <version>0.5.1</version>
            <configuration>
                <protocArtifact>com.google.protobuf:protoc:3.5.1-1:exe:${os.detected.classifier}</protocArtifact>
                <pluginId>grpc-java</pluginId>
                <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.16.1:exe:${os.detected.classifier}</pluginArtifact>
                <!-- protoSourceRoot 指定proto文件存放位置(根据这个位置的proto文件生成)如果不指定则默认是与src/main/java同级的proto目录下的文件 -->
                <protoSourceRoot>src/main/resources/proto/</protoSourceRoot>
            </configuration>
            <executions>
                <execution>
                    <goals>
                        <goal>compile</goal>
                        <goal>compile-custom</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

proto 文件放置(此处放置在resources指定文件夹下,需在pom.xml中进行指定)

image-20241219160854669

③ 通过grpc API生成客户端和服务端代码

​ 基于grpc API(即上述通过maven插件生成的HelloServiceGrpc)生成客户端和服务端代码

(1)客户端代码分析(调用原理)
  • 客户端代码剖析:
    • ①初始化:使用hostport生成channel连接
    • ②基于上述插件生成的基础代码HelloServiceGrpc创建Stub
    • ③基于步骤②中创建的Stub类调用say方法发起真正的RPC调用(后续其他RPC通信细节对于使用者来说是透明的)
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 {
            // 发送请求
            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();
            }
    }
}

​ RPC具体的调用细节需要进入到ClientCalls.blockingUnaryCall 方法里面看(下图为核心的整体流程),在调用端代码中只需要一行代码response = blockingStub.say(request);就能发起一个RPC调用,对于使用者来说只需要关心如何创建Stub对象

image-20241219162705096

​ ==序列化:gRPC 如何把这个对象传输到服务提供方的?==回到上面流程图的第3步,在 writePayload 之前,ClientCallImpl 里面有一行代码就是 method.streamRequest(message)(用来把对象转成一个 InputStream),进而可以获得入参对象的二进制数据。method 是 MethodDescriptor 对象关联的一个实例,而 MethodDescriptor 是用来存放要调用 RPC 服务的接口名、方法名、服务调用的方式以及请求和响应的序列化和反序列化实现类。

​ 也就是说MethodDescriptor 是用来存储一些 RPC 调用过程中的元数据,而在 MethodDescriptor 里面 requestMarshaller 是在绑定请求的时候用来序列化方式对象的,所以当我们调用 method.streamRequest(message) 的时候,实际是调用 requestMarshaller.stream(requestMessage) 方法,而 requestMarshaller 里面会绑定一个 Parser,这个 Parser 才真正地把对象转成了 InputStream 对象。

​ ==协议:gRPC 里面是怎么完成请求数据“断句”的?==即二进制流经过网络传输后,怎么正确地还原请求前语义?

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

​ 因为 gRPC 是基于 HTTP/2 协议,而 HTTP/2 传输基本单位是 Frame,Frame 格式是以固定 9 字节长度的 header,后面加上不定长的 payload 组成,协议格式如下图所示。在 gRPC 里面就变成怎么构造一个 HTTP/2 的 Frame

image-20241219163711349

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

(2)服务端端代码分析(接收原理)

① 定义服务端代码(接口实现)

// 服务提供方代码
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();
    }
}

② 构建服务启动类(也可以将接口实现以staic形式嵌入,此处分开说明)

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 端口,让调用方可以建立连接,并把二进制数据发送到这个连接通道里面。核心思路分析如下:

image-20241219164756364

​ 这四个步骤是用来开启一个 Netty Server,并绑定编解码逻辑的,如果目前概念比较模糊的话可以先忽略细节。重点关注 NettyServerHandler,在这个 Handler 里面会绑定一个 FrameListener,gRPC 会在这个 Listener 里面处理收到数据请求的 Header 和 Body,并且也会处理 Ping、RST 命令等,具体流程如下图所示:

image-20241219164906869

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

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

2.gRPC思考

​ 基于上述对grpc框架的应用拆解,从原理层面简单剖析RPC的核心,将前面所了解到的RPC基础融会贯通(序列化、协议等落实到具体代码),虽然此处只分析了收发请求两个过程,但基本上只要实现这两个过程就可以完成一个点到点的RPC功能。但实际上gRPC的功能远不止于此,实际场景中服务提供方通常是以集群方式提供服务的,所以在gRPC中还会有负载均衡、服务发现等功能扩展。此外,gRPC 采用的是 HTTP/2 协议,还可以通过 Stream 方式来调用服务,以提升调用性能。

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

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3