阅读 890

如何手撸一个较为完整的RPC框架

缘起

最近在公司分享了手撸RPC,因此做一个总结。

概念篇

RPC 是什么?

RPC 称远程过程调用(Remote Procedure Call),用于解决分布式系统中服务之间的调用问题。 通俗地讲,就是开发者能够像调用本地方法一样调用远程的服务。 所以,RPC的作用主要体现在这两个方面:

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

RPC 框架基本架构

下面我们通过一幅图来说说 RPC 框架的基本架构 image.png RPC 框架包含三个最重要的组件,分别是客户端、服务端和注册中心。在一次 RPC 调用流程中,这三个组件是这样交互的:

  • 服务端在启动后,会将它提供的服务列表发布到注册中心,客户端向注册中心订阅服务地址;

  • 客户端会通过本地代理模块 Proxy 调用服务端,Proxy 模块收到负责将方法、参数等数据转化成网络字节流;

  • 客户端从服务列表中选取其中一个的服务地址,并将数据通过网络发送给服务端;

  • 服务端接收到数据后进行解码,得到请求信息;

  • 服务端根据解码后的请求信息调用对应的服务,然后将调用结果返回给客户端。

RPC 框架通信流程以及涉及到的角色

image.png 从上面这张图中,可以看见 RPC 框架一般有这些组件:服务治理(注册发现)、负载均衡、容错、序列化/反序列化、编解码、网络传输、线程池、动态代理等角色,当然有的RPC框架还会有连接池、日志、安全等角色。

具体调用过程

image.png

  1. 服务消费方(client)以本地调用方式调用服务

  2. client stub 接收到调用后负责将方法、参数等封装成能够进行网络传输的消息体

  3. client stub 将消息进行编码并发送到服务端

  4. server stub 收到消息后进行解码

  5. server stub 根据解码结果调用本地的服务

  6. 本地服务执行并将结果返回给 server stub

  7. server stub 将返回导入结果进行编码并发送至消费方

  8. client stub 接收到消息并进行解码

  9. 服务消费方(client)得到结果

RPC 消息协议

RPC调用过程中需要将参数编组为消息进行发送,接收方需要解组消息为参数,过程处理结果同样需要经编组、解组。消息由哪些部分构成及消息的表示形式就构成了消息协议。
RPC调用过程中采用的消息协议称为RPC消息协议。

实战篇

从上面的概念我们知道一个RPC框架大概有哪些部分组成,所以在设计一个RPC框架也需要从这些组成部分考虑。 从RPC的定义中可以知道,RPC框架需要屏蔽底层细节,让用户感觉调用远程服务像调用本地方法一样简单,所以需要考虑这些问题:

  • 用户使用我们的RPC框架时如何尽量少的配置
  • 如何将服务注册到ZK(这里注册中心选择ZK)上并且让用户无感知
  • 如何调用透明(尽量用户无感知)的调用服务提供者
  • 启用多个服务提供者如何做到动态负载均衡
  • 框架如何做到能让用户自定义扩展组件(比如扩展自定义负载均衡策略)
  • 如何定义消息协议,以及编解码
  • ...等等

上面这些问题在设计这个RPC框架中都会给予解决。

技术选型

  • 注册中心 目前成熟的注册中心有Zookeeper,Nacos,Consul,Eureka,这里使用ZK作为注册中心,没有提供切换以及用户自定义注册中心的功能。

  • IO通信框架 本实现采用 Netty 作为底层通信框架,因为Netty 是一个高性能事件驱动型的非阻塞的IO(NIO)框架,没有提供别的实现,也不支持用户自定义通信框架

  • 消息协议 本实现使用自定义消息协议,后面会具体说明

项目总体结构

image.png 从这个结构中可以知道,以rpc命名开头的是rpc框架的模块,也是本项目RPC框架的内容,而consumer是服务消费者,provider是服务提供者,provider-api是暴露的服务API。

整体依赖情况

image.png

项目实现介绍

要做到用户使用我们的RPC框架时尽量少的配置,所以把rpc框架设计成一个starter,用户只要依赖这个starter,基本那就可以了。

为什么要设计成两个 starter (client-starter/server-starter) ?

这个是为了更好的体现出客户端和服务端的概念,消费者依赖客户端,服务提供者依赖服务端,还有就是最小化依赖。

为什么要设计成 starter ?

基于spring boot自动装配机制,会加载starter中的 spring.factories 文件,在文件中配置以下代码,这里我们starter的配置类就生效了,在配置类里面配置一些需要的bean。

org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.rrtv.rpc.client.config.RpcClientAutoConfiguration
复制代码

发布服务和消费服务

  • 对于发布服务
    服务提供者需要在暴露的服务上增加注解 @RpcService,这个自定义注解是基于 @service 的,是一个复合注解,具备@service注解的功能,在@RpcService注解中指明服务接口和服务版本,发布服务到ZK上,会根据这个两个元数据注册 image.png

    • 发布服务原理:
      服务提供者启动之后,根据spring boot自动装配机制,server-starter的配置类就生效了,在一个 bean 的后置处理器(RpcServerProvider)中获取被注解 @RpcService 修饰的bean,将注解的元数据注册到ZK上。
      image.png
  • 对于消费服务
    消费服务需要使用自定义的 @RpcAutowired 注解标识,是一个复合注解,基于 @Autowired。 image.png

    • 消费服务原理
      要让客户端无感知的调用服务提供者,就需要使用动态代理,如上面所示, HelloWordService 没有实现类,需要给它赋值代理类,在代理类中发起请求调用。基于spring boot自动装配,服务消费者启动,bean 后置处理器 RpcClientProcessor 开始工作,它主要是遍历所有的bean,判断每个bean中的属性是否有被 @RpcAutowired 注解修饰,有的话把该属性动态赋值代理类,这个再调用时会调用代理类的 invoke 方法。
      image.png

      代理类 invoke 方法通过服务发现获取服务端元数据,封装请求,通过netty发起调用。 image.png

注册中心

本项目注册中心使用ZK,由于注册中心被服务消费者和服务提供者都使用。所以把ZK放在rpc-core模块。 image.png rpc-core 这个模块如上图所示,核心功能都在这个模块。服务注册在 register 包下。

服务注册接口,具体实现使用ZK实现。 image.png

负载均衡策略

负载均衡定义在rpc-core中,目前支持轮询(FullRoundBalance)和随机(RandomBalance),默认使用随机策略。由rpc-client-spring-boot-starter指定。 image.png

通过ZK服务发现时会找到多个实例,然后通过负载均衡策略获取其中一个实例 image.png

可以在消费者中配置 rpc.client.balance=fullRoundBalance 替换,也可以自定义负载均衡策略,通过实现接口 LoadBalance,并将创建的类加入IOC容器即可。由于我们配置 @ConditionalOnMissingBean,所以会优先加载用户自定义的 bean。
image.png

自定义消息协议、编解码

所谓协议,就是通信双方事先商量好规则,服务端知道发送过来的数据将如何解析。

  • 自定义消息协议 image.png

    • 魔数:魔数是通信双方协商的一个暗号,通常采用固定的几个字节表示。魔数的作用是防止任何人随便向服务器的端口上发送数据。 例如 java Class 文件开头就存储了魔数 0xCAFEBABE,在加载 Class 文件时首先会验证魔数的正确性

    • 协议版本号:随着业务需求的变化,协议可能需要对结构或字段进行改动,不同版本的协议对应的解析方法也是不同的。

    • 序列化算法:序列化算法字段表示数据发送方应该采用何种方法将请求的对象转化为二进制,以及如何再将二进制转化为对象,如 JSON、Hessian、Java 自带序列化等。

    • 报文类型: 在不同的业务场景中,报文可能存在不同的类型。RPC 框架中有请求、响应、心跳等类型的报文。

    • 状态: 状态字段用于标识请求是否正常(SUCCESS、FAIL)。

    • 消息ID: 请求唯一ID,通过这个请求ID将响应关联起来,也可以通过请求ID做链路追踪。

    • 数据长度: 标明数据的长度,用于判断是否是一个完整的数据包

    • 数据内容: 请求体内容

  • 编解码
    编解码实现在 rpc-core 模块,在包 com.rrtv.rpc.core.codec下。

    自定义编码器通过继承 netty 的 MessageToByteEncoder<MessageProtocol<T>>类实现消息编码。 image.png

    自定义解码器通过继承 netty 的 ByteToMessageDecoder类实现消息解码。

    image.png image.png

解码时需要注意TCP粘包、拆包问题

什么是TCP粘包、拆包

TCP 传输协议是面向流的,没有数据包界限,也就是说消息无边界。客户端向服务端发送数据时,可能将一个完整的报文拆分成多个小报文进行发送,也可能将多个报文合并成一个大的报文进行发送。 因此就有了拆包和粘包。

在网络通信的过程中,每次可以发送的数据包大小是受多种因素限制的,如 MTU 传输单元大小、滑动窗口等。
所以如果一次传输的网络包数据大小超过传输单元大小,那么我们的数据可能会拆分为多个数据包发送出去。 如果每次请求的网络包数据都很小,比如一共请求了 10000 次,TCP 并不会分别发送 10000 次。 TCP采用的 Nagle(批量发送,主要用于解决频繁发送小数据包而带来的网络拥塞问题) 算法对此作出了优化。

所以,网络传输会出现这样:
tcp_package.png

  1. 服务端恰巧读到了两个完整的数据包 A 和 B,没有出现拆包/粘包问题;
  2. 服务端接收到 A 和 B 粘在一起的数据包,服务端需要解析出 A 和 B;
  3. 服务端收到完整的 A 和 B 的一部分数据包 B-1,服务端需要解析出完整的 A,并等待读取完整的 B 数据包;
  4. 服务端接收到 A 的一部分数据包 A-1,此时需要等待接收到完整的 A 数据包;
  5. 数据包 A 较大,服务端需要多次才可以接收完数据包 A。

如何解决TCP粘包、拆包问题

解决问题的根本手段:找出消息的边界:

  • 消息长度固定
    每个数据报文都需要一个固定的长度。当接收方累计读取到固定长度的报文后,就认为已经获得一个完整的消息。当发送方的数据小于固定长度时,则需要空位补齐。
    消息定长法使用非常简单,但是缺点也非常明显,无法很好设定固定长度的值,如果长度太大会造成字节浪费,长度太小又会影响消息传输,所以在一般情况下消息定长法不会被采用。
  • 特定分隔符
    在每次发送报文的尾部加上特定分隔符,接收方就可以根据特殊分隔符进行消息拆分。 分隔符的选择一定要避免和消息体中字符相同,以免冲突。否则可能出现错误的消息拆分。比较推荐的做法是将消息进行编码,例如 base64 编码,然后可以选择 64 个编码字符之外的字符作为特定分隔符
  • 消息长度 + 消息内容
    消息长度 + 消息内容是项目开发中最常用的一种协议,接收方根据消息长度来读取消息内容。

本项目就是利用 “消息长度 + 消息内容” 方式解决TCP粘包、拆包问题的。所以在解码时要判断数据是否够长度读取,没有不够说明数据没有准备好,继续读取数据并解码,这里这种方式可以获取一个个完整的数据包。 image.png

序列化和反序列化

序列化和反序列化在 rpc-core 模块 com.rrtv.rpc.core.serialization 包下,提供了 HessianSerializationJsonSerialization 序列化。
默认使用 HessianSerialization 序列化。用户不可以自定义。

序列化性能:

  • 空间上

serialization_space.png

  • 时间上

serialization_time.png

网络传输,使用netty

netty 代码固定的,值得注意的是 handler 的顺序不能弄错,以服务端为例,编码是出站操作(可以放在入站后面),解码和收到响应都是入站操作,解码要在前面。 image.png

客户端 RPC 调用方式

成熟的 RPC 框架一般会提供四种调用方式,分别为同步 Sync、异步 Future、回调 Callback和单向 Oneway。

  • Sync 同步调用。 客户端线程发起 RPC 调用后,当前线程会一直阻塞,直至服务端返回结果或者处理超时异常。 sync.png

  • Future 异步调用
    客户端发起调用后不会再阻塞等待,而是拿到 RPC 框架返回的 Future 对象,调用结果会被服务端缓存,客户端自行决定后续何时获取返回结果。当客户端主动获取结果时,该过程是阻塞等待的 future.png

  • Callback 回调调用 客户端发起调用时,将 Callback 对象传递给 RPC 框架,无须同步等待返回结果,直接返回。当获取到服务端响应结果或者超时异常后,再执行用户注册的 Callback 回调 callback.png

  • Oneway 单向调用 客户端发起请求之后直接返回,忽略返回结果
    oneway.png

这里使用的是第一种:客户端同步调用,其他的没有实现。逻辑在 RpcFuture 中,使用 CountDownLatch 实现阻塞等待(超时等待) image.png

整体架构和流程

image.png

流程分为三块:服务提供者启动流程、服务消费者启动、调用过程

  • 服务提供者启动
    1. 服务提供者 provider 会依赖 rpc-server-spring-boot-starter
    2. ProviderApplication 启动,根据springboot 自动装配机制,RpcServerAutoConfiguration 自动配置生效
    3. RpcServerProvider 是一个bean后置处理器,会发布服务,将服务元数据注册到ZK上
    4. RpcServerProvider.run 方法会开启一个 netty 服务
  • 服务消费者启动
    1. 服务消费者 consumer 会依赖 rpc-client-spring-boot-starter
    2. ConsumerApplication 启动,根据springboot 自动装配机制,RpcClientAutoConfiguration 自动配置生效
    3. 将服务发现、负载均衡、代理等bean加入IOC容器
    4. 后置处理器 RpcClientProcessor 会扫描 bean ,将被 @RpcAutowired 修饰的属性动态赋值为代理对象
  • 调用过程
    1. 服务消费者 发起请求 http://localhost:9090/hello/world?name=hello
    2. 服务消费者 调用 helloWordService.sayHello() 方法,会被代理到执行 ClientStubInvocationHandler.invoke() 方法
    3. 服务消费者 通过ZK服务发现获取服务元数据,找不到报错404
    4. 服务消费者 自定义协议,封装请求头和请求体
    5. 服务消费者 通过自定义编码器 RpcEncoder 将消息编码
    6. 服务消费者 通过 服务发现获取到服务提供者的ip和端口, 通过Netty网络传输层发起调用
    7. 服务消费者 通过 RpcFuture 进入返回结果(超时)等待
    8. 服务提供者 收到消费者请求
    9. 服务提供者 将消息通过自定义解码器 RpcDecoder 解码
    10. 服务提供者 解码之后的数据发送到 RpcRequestHandler 中进行处理,通过反射调用执行服务端本地方法并获取结果
    11. 服务提供者 将执行的结果通过 编码器 RpcEncoder 将消息编码。(由于请求和响应的协议是一样,所以编码器和解码器可以用一套)
    12. 服务消费者 将消息通过自定义解码器 RpcDecoder 解码
    13. 服务消费者 通过RpcResponseHandler将消息写入 请求和响应 池中,并设置 RpcFuture 的响应结果
    14. 服务消费者 获取到结果

以上流程具体可以结合代码分析,代码后面会给出

环境搭建

  • 操作系统:Windows
  • 集成开发工具:IntelliJ IDEA
  • 项目技术栈:SpringBoot 2.5.2 + JDK 1.8 + Netty 4.1.42.Final
  • 项目依赖管理工具:Maven 4.0.0
  • 注册中心:Zookeeeper 3.7.0

项目测试

项目代码地址

gitee.com/listen_w/rp…

文章分类
后端
文章标签