当前位置: 首页 > 科技观察

如何搭建一个比较完善的RPC框架?

时间:2023-03-21 19:11:56 科技观察

概念RPC是什么?RPC全称为远程过程调用(RemoteProcedureCall),用于解决分布式系统中服务之间的调用问题。通俗地说,开发者可以像调用本地方法一样调用远程服务。所以RPC的作用主要体现在这两个方面:屏蔽了远程调用和本地调用的区别,让我们感觉像在项目中调用方法;隐藏底层网络通信的复杂性,让我们更专注于业务逻辑。RPC框架的基本架构下面用一张图来说明RPC框架的基本架构。RPC框架包括三个最重要的组件,即客户端、服务器和注册中心。在一个RPC调用过程中,这三个组件的交互方式如下:服务器启动后,将其提供的服务列表发布到注册中心,客户端从注册中心订阅服务地址;客户端会使用本地的代理模块Proxy调用服务器,Proxy模块接收数据负责将方法、参数等转换成网络字节流;客户端从服务列表中选择一个服务地址,通过网络向服务器发送数据;服务器接收到数据,进行解码,得到请求信息;服务器根据解码后的请求信息调用相应的服务,然后将调用结果返回给客户端。RPC框架通信过程及涉及的角色从上图可以看出RPC框架一般有这些组件:服务治理(注册发现)、负载均衡、容错、序列化/反序列化、编解码、网络传输、线程池、动态代理等角色,当然有些RPC框架还有连接池、日志、安全等角色。具体调用流程服务消费者(client)在本地调用服务。客户端存根负责将方法、参数等封装成一个消息体,在收到调用后可以通过网络进行传输。客户端存根对消息进行编码并将其发送到服务器。服务器存根接收到消息后,服务器存根根据解码结果调用本地服务。本地服务执行并将结果返回给服务器存根。服务器存根对返回的导入结果进行编码,并将其发送给消费者。客户端存根接收消息并对其进行解码。服务消费者(客户端)获取结果RPC消息协??议RPC调用需要将参数编组为消息发送,接收方需要将消息作为参数解组,进程处理结果也需要编组和解组。消息由哪些部分组成以及消息的表示构成了消息协议。RPC调用过程中使用的消息协议称为RPC消息协议。从上面的概念,我们知道了一个RPC框架大概有哪些组件,所以在设计RPC框架的时候,我们也需要考虑这些组件。从RPC的定义我们可以知道,RPC框架需要屏蔽底层细节,让用户感觉调用远程服务就像调用本地方法一样简单,所以需要考虑这些问题:如何配置尽可能少可能当用户使用我们的RPC框架时如何注册服务去ZK(这里注册中心选择ZK)并且让用户无意识如何调用透明(尽可能用户察觉不到)调用服务提供者启用多个服务提供者如何实现动态负载均衡框架如何让用户自定义扩展组件(如扩展自定义负载均衡策略)如何定义消息协议,以及编解码器……等等这些问题都将在这个RPC框架的设计中得到解决。技术选型注册中心目前比较成熟的注册中心有Zookeeper、Nacos、Consul、Eureka。这里使用ZK作为注册中心,没有切换和自定义注册中心的功能。IO通信框架的实现使用Netty作为底层通信框架,因为Netty是一个高性能的事件驱动的非阻塞IO(NIO)框架,不提供其他实现,不支持用户自定义通信框架消息协议。自定义消息协议,项目整体结构后面会详细说明。从这个结构可以知道rpc命名的模块是rpc框架的模块,也是本项目RPC框架的内容,consumer就是服务消费者,provider就是服务提供者.,provider-api是暴露的服务API。整体依赖项目实现介绍为了保证用户使用我们的RPC框架尽可能少的配置,所以rpc框架设计成一个starter,用户只需要依赖这个starter,基本就可以了。为什么要设计成两个启动器(client-starter/server-starter)?这是为了更好的体现client和server的概念。消费者依赖客户端,服务提供者依赖服务器,尽量减少依赖。为什么要把它设计成启动器?基于springboot自动装配机制,会加载starter中的spring.factories文件,在文件中配置如下代码。到这里,我们的starter配置类就会生效,在配置类中配置一些需要的bean。org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.rrtv.rpc.client.config.RpcClientAutoConfiguration发布服务和消费服务对于发布服务提供者需要在暴露的服务上添加注解@RpcService,这个自定义注解是基于@service是复合注解,具有@service注解的作用。在@RpcService注解中,指定服务接口和服务版本,将服务发布到ZK,根据这两个元数据注册和发布服务原理:服务提供者启动后,根据springboot自动组装机制,服务端-starter配置类生效,在bean后处理器(RpcServerProvider)中获取@RpcService注解的bean,将注解的元数据注册到ZK。对于消费服务,消费服务需要使用自定义的@RpcAutowired注解标识,它是基于@Autowired的复合注解。消费者服务原理要让客户端在不知不觉中调用服务提供者,就需要使用动态代理。如上图,HelloWordService没有实现类,所以需要为其分配一个代理类,并在代理类中发起请求调用。基于springboot自动组装,服务消费者启动,bean后处理器RpcClientProcessor开始工作。主要是遍历所有的bean,判断每个bean中的属性是否被@RpcAutowired注解修改,如果是,则动态将该属性赋值给代理类,再次调用时会调用代理类的invoke方法。代理类的invoke方法通过服务发现获取服务端的元数据,封装请求,通过netty发起调用。注册中心本项目的注册中心使用的是ZK,因为服务消费者和服务提供者都使用注册中心。所以把ZK放在rpc-core模块中。rpc-core模块如上图所示,核心功能都在这个模块中。服务在注册包下注册。服务注册接口使用ZK实现。负载均衡策略负载均衡定义在rpc-core中,目前支持轮询(FullRoundBalance)和随机(RandomBalance),默认使用随机策略。由rpc-client-spring-boot-starter指定。通过ZK服务发现时,会发现多个实例,然后通过负载均衡策略获取其中一个实例。可以在consumer中配置rpc.client.balance=fullRoundBalance来代替,也可以通过实现接口LoadBalance来自定义负载均衡策略,并设置创建的类可以添加到IOC容器中。由于我们配置了@ConditionalOnMissingBean,所以用户定义的bean将首先被加载。自定义消息协议,codec所谓协议,就是通信的双方事先协商好规则,服务器知道如何解析发送的数据。自定义消息协议Magicnumber:magicnumber是通信双方协商的密码,通常用固定的字节数表示。幻数的作用是防止任何人随意向服务器端口发送数据。例如,幻数0xCAFEBABE存放在javaClass文件的开头。当加载Class文件时,首先会验证magicnumber的正确性。协议版本号:随着业务需求的变化,协议可能需要修改结构或字段。相应的分析方法也不同。Serializationalgorithm:serializationalgorithm字段表示数据发送方应该使用哪种方法将请求的对象转换成二进制,如何将二进制转换成对象,如JSON、Hessian、Java自带的序列化等。包类型:在不同的业务场景中,可能会有不同类型的数据包。RPC帧中有请求、响应、心跳等类型的消息。状态:状态字段用于标识请求是否正常(SUCCESS、FAIL)。MessageID:请求的唯一ID,通过它关联response,也可以通过requestID进行链接跟踪。数据长度:表示数据的长度,用于判断是否是一个完整的数据包。数据内容:请求体内容的编解码在rpc-core模块中实现,在com.rrtv.rpc.core.codec包下。自定义编码器通过继承netty的MessageToByteEncoder>类实现消息编码。自定义解码器通过继承netty的ByteToMessageDecoder类实现消息解码。解码的时候需要注意TCP粘包和解包的问题。什么是TCP粘包和解包?TCP传输协议是面向流的,没有数据包边界,也就是说消息是没有边界的。客户端向服务端发送数据时,可能会将一条完整的消息拆分成多条小消息发送,也可能将多条消息组合成一条大消息发送。于是就有了拆包贴。在网络通信过程中,每次可以发送的数据包大小受到多种因素的限制,如MTU传输单元大小、滑动窗口等。因此,如果一次发送的网络包数据大小超过传输单元的大小,那么我们的数据可能会被拆分成多个数据包发送出去。如果每次请求的网络包数据很小,比如一共请求10000次,TCP不会单独发送10000次请求。TCP采用的Nagle(批量发送,主要用于解决频繁发送小数据包造成的网络拥塞问题)算法对此进行了优化。所以网络传输会出现这样的:tcp_package.png服务端只是读取了两个完整的数据包A和B,并没有解包/粘包问题;端需要解析出A和B;服务器收到完整的A和部分B数据包B-1,服务器需要解析出完整的A,等待读取完整的B数据包;服务器收到A的A部分数据包A-1,此时需要等待接收完整的A数据包;数据包A比较大,服务器需要多次接收数据包A。如何解决TCP粘包和拆包问题解决问题的根本方法:找出报文的边界:报文的长度是固定的。每个数据包需要一个固定的长度。当接收方累计读取到一条定长消息后,就认为得到了一条完整的消息。当发送方的数据小于固定长度时,就需要补空。消息定长方式使用起来非常简单,但是它的缺点也非常明显。定长值不可能设置好。长度太大会浪费字节,长度太小又会影响消息传输。因此,一般情况下,不会采用报文定长法。Specificdelimiter在每条发送的消息末尾添加一个特定的分隔符,接收方可以根据这个特殊的分隔符拆分消息。分隔符的选择不能和邮件正文中的字符相同,以免发生冲突。否则可能会发生错误的消息拆分。推荐的方法是对消息进行编码,比如base64编码,然后可以选择64编码字符以外的字符作为具体的分隔符。消息长度+消息内容消息长度+消息内容是项目开发中最常用的协议。接收方根据消息长度读取消息内容。本项目采用“报文长度+报文内容”的方式解决TCP粘包和解包问题。所以解码的时候需要判断数据是否足够长,可以读取。如果还不够,说明数据还没有准备好。继续读取和解码数据。这种方法可以一个一个的获取完整的数据包。序列化与反序列化序列化与反序列化在rpc-core模块com.rrtv.rpc.core.serialization包下提供HessianSerialization和JsonSerialization序列化。默认情况下使用HessianSerialization进行序列化。用户无法自定义。序列化性能:空间上的serialization_space.png,时间上的serialization_time.png,网络传输,用nettynetty代码固定,值得注意的是handlers的顺序不能错,以服务端为例,encoding是出站操作(可以放在inbound站后),解码和接收响应都是入站操作,解码应该在前面。image.png客户端RPC调用方式一个成熟的RPC框架一般会提供四种调用方式,分别是同步Sync、异步Future、回调Callback和单向Oneway。Sync调用客户端线程发起RPC调用后,当前线程会一直阻塞,直到服务端返回结果或处理超时异常。sync.pngFuture异步调用客户端发起调用后,不会阻塞等待,而是获取RPC框架返回的Future对象。调用结果会被服务器缓存起来,由客户端决定何时获取返回结果。当客户端主动获取结果时,流程是在客户端发起调用时阻塞等待future.png回调回调调用,将Callback对象传递给RPC框架,不等待结果直接返回同步返回。拿到服务端的响应结果或者超时异常后,执行用户注册的回调Callbackcallback.pngOnewayOneway调用客户端发起请求直接返回,忽略返回结果oneway.png这里使用第一种方法:client同步调用,其他没有实现。逻辑在RpcFuture中,使用CountDownLatch实现阻塞等待(超时等待)整体架构和流程分为三部分:服务提供者启动流程,服务消费者启动,调用流程服务提供者启动服务提供者会依赖rpc-server-spring-boot-starterProviderApplication启动,根据springboot自动组装机制,RpcServerAutoConfiguration自动配置生效RpcServerProvider是一个bean后处理器,会发布服务,注册服务元数据到ZKRpcServerProvider.run方法会启动一个netty服务Consumer启动服务Consumerconsumer会依赖rpc-client-spring-boot-starterConsumerApplication启动,根据springboot自动组装机制,RpcClientAutoConfiguration自动配置生效,将服务发现、负载均衡、代理等bean添加到IOC容器后处理器RpcClientProcessor将扫描thebean,将@RpcAutowired修改的属性动态赋值给代理对象,调用流程服务消费者发起请求http://localhost:9090/hello/world?name=hello服务消费者调用helloWordService.sayHello()方法,它会被代理执行ClientStubInvocationHandler.invoke()方法。服务消费者通过ZK服务发现获取服务元数据。如果找不到404错误,服务消费者定义协议并封装请求头和请求体。服务消费者通过自定义编码器RpcEncoder编码发送消息,服务消费者通过服务发现获取服务提供者的IP和端口,通过Netty网络传输层发起调用。服务消费者通过RpcFuture输入返回结果(超时),等待服务提供者接收消费者请求。服务提供者发送消息通过自定义解码器RpcDecoder对服务提供者解码后的数据进行解码后发送给RpcRequestHandler处理,通过反射调用执行服务端本地方法,获取结果服务提供者执行结果由编码器RpcEncoder编码(由于请求和响应协议相同,所以编码器和解码器可以一套使用)服务消费者通过自定义解码器RpcDecoder解码消息服务消费者使用RpcResponseHandler来将消息写入请求和响应池,设置RpcFuture的响应结果。服务消费者获取结果。以上过程可以结合代码进行分析,代码后面会给出环境。搭建操作系统:Windows集成开发工具:IntelliJIDEA项目技术栈:SpringBoot2.5.2+JDK1.8+Netty4.1.42.Final项目依赖管理工具:Maven4.0.0注册中心:Zookeeper3.7.0项目测试启动Zookeeperserver:bin/zkServer.cmd启动provider模块ProviderApplication启动consumer模块ConsumerApplication测试:浏览器输入http://localhost:9090/hello/world?name=hello,成功返回hello:hello,rpc成功调用项目代码地址https//gitee.com/listen_w/rpc.git