【文章作者/来源】一个没有追求的技术人/https://sourl.cn/sJ4Brp
缘 起
最近在公司分享了手撸RPC,因此做一个总结。
概 念 篇
RPC 是什么?
RPC 称远程过程调用(Remote Procedure Call),用于解决分布式系统中服务之间的调用问题。通俗地讲,就是开发者能够像调用本地方法一样调用远程的服务。所以,RPC的作用主要体现在这两个方面:
屏蔽远程调用跟本地调用的区别,让我们感觉就是调用项目内的方法; 隐藏底层网络通信的复杂性,让我们更专注于业务逻辑。
RPC 框架基本架构

服务端在启动后,会将它提供的服务列表发布到注册中心,客户端向注册中心订阅服务地址;
客户端会通过本地代理模块 Proxy 调用服务端,Proxy 模块收到负责将方法、参数等数据转化成网络字节流; 客户端从服务列表中选取其中一个的服务地址,并将数据通过网络发送给服务端; 服务端接收到数据后进行解码,得到请求信息; 服务端根据解码后的请求信息调用对应的服务,然后将调用结果返回给客户端。
RPC 框架通信流程以及涉及到的角色

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

服务消费方(client)以本地调用方式调用服务 client stub 接收到调用后负责将方法、参数等封装成能够进行网络传输的消息体 client stub 将消息进行编码并发送到服务端 server stub 收到消息后进行解码 server stub 根据解码结果调用本地的服务 本地服务执行并将结果返回给 server stub server stub 将返回导入结果进行编码并发送至消费方 client stub 接收到消息并进行解码 服务消费方(client)得到结果
RPC 消息协议
实 战 篇
用户使用我们的RPC框架时如何尽量少的配置
如何将服务注册到ZK(这里注册中心选择ZK)上并且让用户无感知
如何调用透明(尽量用户无感知)的调用服务提供者
启用多个服务提供者如何做到动态负载均衡
框架如何做到能让用户自定义扩展组件(比如扩展自定义负载均衡策略)
如何定义消息协议,以及编解码
...等等
技术选型
注册中心 目前成熟的注册中心有Zookeeper,Nacos,Consul,Eureka,这里使用ZK作为注册中心,没有提供切换以及用户自定义注册中心的功能。 IO通信框架 本实现采用 Netty 作为底层通信框架,因为Netty 是一个高性能事件驱动型的非阻塞的IO(NIO)框架,没有提供别的实现,也不支持用户自定义通信框架。 消息协议 本实现使用自定义消息协议,后面会具体说明。
项目总体结构

整体依赖情况

项目实现介绍
为什么要设计成 两个 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上,会根据这个两个元数据注册。 
对于消费服务 消费服务需要使用自定义的 @RpcAutowired 注解标识,是一个复合注解,基于 @Autowired。 
发布服务原理:

消费服务原理


注册中心

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

负载均衡策略

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

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

自定义消息协议、编解码
自定义消息协议 
编解码
编解码实现在 rpc-core 模块,在包 com.rrtv.rpc.core.codec下。 自定义编码器通过继承 netty 的 MessageToByteEncoder<MessageProtocol<T>>类实现消息编码。 
自定义解码器通过继承 netty 的 ByteToMessageDecoder类实现消息解码。

魔数:魔数是通信双方协商的一个暗号,通常采用固定的几个字节表示。魔数的作用是防止任何人随便向服务器的端口上发送数据。
解码时需要注意TCP粘包、拆包问题
什么是TCP粘包、拆包

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

序列化和反序列化
空间上

时间上

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

客户端 RPC 调用方式
Sync 同步调用 客户端线程发起 RPC 调用后,当前线程会一直阻塞,直至服务端返回结果或者处理超时异常。 
Future 异步调用
客户端发起调用后不会再阻塞等待,而是拿到 RPC 框架返回的 Future 对象,调用结果会被服务端缓存,客户端自行决定后续何时获取返回结果。当客户端主动获取结果时,该过程是阻塞等待的

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

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

整体架构和流程

服务提供者启动 服务消费者启动 调用过程
以上流程具体可以结合代码分析,代码后面会给出。
环境搭建
操作系统:Windows 集成开发工具:IntelliJ IDEA 项目技术栈:SpringBoot 2.5.2 + JDK 1.8 + Netty 4.1.42.Final 项目依赖管理工具:Maven 4.0.0 注册中心:Zookeeeper 3.7.0
项目测试
启动 Zookeeper 服务器:bin/zkServer.cmd 启动 provider 模块 ProviderApplication 启动 consumer 模块 ConsumerApplication 测试:浏览器输入 http://localhost:9090/hello/world?name=hello,成功返回 您好:hello, rpc 调用成功