RPC框架实践
一、什么是rpc
rpc是远程过程调用(remote procedure call)的简称。
为什么需要RPC? 两个不同服务器提供的服务不在同一个内存空间,需要网络才能传递方法调用的参数和方法调用的结果。
rpc框架可以帮助我们调用远程服务器上的某个方法,且过程如同本地方法一样简单,不需要考虑底层的细节,例如底层传输方式,序列化方式等。
1.1 RPC核心部件
客户端(服务消费端) :调用远程方法的一端。
客户端 Stub(桩,客户端存根,代理) : 这其实就是一代理类。代理类主要做的事情很简单,就是把你调用方法、类、方法参数等信息传递到服务端。
网络传输 : 网络传输就是你要把你调用的方法的信息比如说参数传输到服务端,然后服务端执行完之后再把返回结果通过网络传输给你传输回来。网络传输的实现方式有很多种,比如最基本的 Socket 或者性能以及封装更加优秀的 Netty(推荐)。
服务端处理器:服务端的逻辑处理部分。在这一部分,服务端接收到客户端的请求后,会根据请求的内容调用相应的服务逻辑,执行实际的业务操作,并将结果返回给客户端。这个处理过程可能涉及到数据库访问、文件操作、业务计算等各种服务端需要执行的任务。
服务端(服务提供端) :提供远程方法的一端。
1.2 RPC调用流程
服务消费端(client)以本地调用的方式调用远程服务;
客户端 Stub(client stub) 接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体(序列化):RpcRequest;
客户端 Stub(client stub) 找到远程服务的地址,并将消息发送到服务提供端;
服务端 Stub(桩)收到消息将消息反序列化为 Java 对象: RpcRequest;
服务端 Stub(桩)根据RpcRequest中的类、方法、方法参数等信息调用本地的方法;
服务端 Stub(桩)得到方法执行结果并将组装成能够进行网络传输的消息体:RpcResponse(序列化)发送至消费方;
客户端 Stub(client stub)接收到消息并将消息反序列化为 Java 对象:RpcResponse ,这样也就得到了最终结果。
1.3 常见RPC框架
- Apache Dubbo
- 新浪微博 Motan
- Google gRPC
- Apache Thrift
1.4 如何自己实现一个RPC框架
一个简化的框架如下图所示:
服务提供端 Server 向注册中心注册服务,服务消费者 Client 通过注册中心拿到服务相关信息,然后再通过网络请求服务提供端 Server提供服务。
一个完整的框架如下图所示:
涉及到服务端和客户端的stub,服务端的stub还需要负责负载均衡,客户端需要有动态代理,同时在进行网络传输request和response的时候还涉及到序列化和反序列化。
根据以上框架,我们需要梳理设计RPC框架的思路,或者说设计RPC框架都需要哪些东西:
1.4.1 注册中心
注册中心负责服务地址的注册与查找,相当于一个目录。服务端启动的时候将服务名称及其对应的地址(ip+port)注册到注册中心,服务消费端根据服务名称找到对应的服务地址。有了服务地址之后,服务消费端就可以通过网络请求服务端了。
这里使用zookeeper作为注册中心,Curator作为客户端,有关zookeeper的介绍可以看第六节。
1.4.2 网络传输
既然我们要调用远程的方法,就要发送网络请求来传递目标类和方法的信息以及方法的参数等数据到服务提供端。有两种方式,一种是socket通信(同步阻塞),另一种是netty框架通信(同步非阻塞)。详细的java socket通信可以看第三节的内容,或者看站内的网络编程系列(基于C++的Linux平台网络编程系列)。
1.4.3 序列化和反序列化
网络传输数据就涉及到序列化和反序列化,因为网络使用二进制数据流进行传输,java对象没有办法直接传输,为了能够让 Java 对象在网络中传输我们需要将其序列化为二进制的数据。我们最终需要的还是目标 Java 对象,因此我们还要将二进制的数据“解析”为目标 Java 对象,也就是对二进制数据再进行一次反序列化。另外,不仅网络传输的时候需要用到序列化和反序列化,将对象存储到文件、数据库等场景都需要用到序列化和反序列化。
- 在socket通信中,使用的序列化和反序列化的方法是jdk自带的方法,只需实现
java.io.Serializable接口即可。因此两个传输实体RpcRequest和RpcResponse都实现了该接口。 - 而在netty通信中,使用的是kryo,hessian,protostuff等方法。
1.4.4 动态代理
代理模式:我们给某一个对象提供一个代理对象,并由代理对象来代替真实对象做一些事情。例如,我们真实对象调用方法的时候,可以通过代理对象去做一些事情比如安全校验、日志打印等等。但是,这个过程是完全对真实对象屏蔽的。在RPC中使用动态代理,就是在调用远程方法的时候,通过代理对象来传输网络请求,从而屏蔽网络细节。
1.4.5 负载均衡
负载均衡 指的是将用户请求分摊到不同的服务器上处理,以提高系统整体的并发处理能力以及可靠性。负载均衡可以简单分为 服务端负载均衡 和 客户端负载均衡 这两种。
服务端负载均衡有硬件实现(F5、Array)和软件实现(LVS、Nginx)两种。客户端发送请求被服务端负载均衡拦截,根据负载均衡算法分发请求到具体服务器上处理请求。
在客户端负载均衡中,客户端从注册中心获得服务器的地址列表,发送请求之前,客户端会根据对应的负载均衡算法来选择具体某一台服务器处理请求。
服务端负载均衡和客户端负载均衡的区别
服务端负载均衡的流程:客户端——>负载均衡服务器——>服务器
客户端负载均衡:客户端——>服务器
所以我们可以看出服务端负载均衡是通过一台服务器达到负载均衡的,而客户端负载均衡通过自己就能达到负载均衡(它先从注册中心获取服务列表),并不需要其他服务器。
在rpc中,使用的是客户端负载均衡,实现了一致性哈希负载均衡算法以及随机负载均衡算法。
1.4.6 传输协议
在实现中设计一个私有的 RPC 协议,这个协议是客户端(服务消费方)和服务端(服务提供方)交流的基础。类似于HTTP的报文,分为报头和正文两部分,规定好需要传输的数据都有哪些,分别占多少字节。将object对象序列化之后写到报文正文部分,将报文进行二进制传输到接收方。这样在接收到二进制数据之后可以正确解析出所需要的数据,再把正文部分反序列化,得到我们需要的object对象。
1.4.7 监控中心
dubbo中有一个监控模块,负责监控服务消费方和服务提供方。
二、什么是序列化
三、Socket网络通信
四、Netty网络通信
五、静态代理+JDK/CGLIB动态代理
5.1 代理模式
- 静态代理
定义一个接口及其实现类
创建一个代理类同样实现这个接口
将目标对象注入进代理类,然后在代理类的对应方法调用目标类中的对应方法。
这样的话,我们就可以通过代理类屏蔽对目标对象的访问,并且可以在目标方法执行前后做一些自己想做的事情。
通常是在原始类的基础上附加与原始类无关的功能,用于访问控制,或者监视,统计流量。
- 动态代理
无需声明式创建Java代理类,而是在运行过程中生成“虚拟”的代理类,被ClassLoader加载。从而避免静态代理那样需要声明大量的代理类。
JDK动态代理
主要的两个核心类为:java.lang.reflect.Proxy和java.lang.reflect.InvocationHandler
- 定义一个接口及其实现类;
- 自定义 InvocationHandler 并重写invoke方法,在 invoke 方法中我们会调用原生方法(被代理类的方法)并自定义一些处理逻辑;
- 通过 Proxy.newProxyInstance(ClassLoader loader,Class<?>[] interfaces,InvocationHandler h) 方法创建代理对象;
与静态代理相比,无论有多少接口,都只需要一个代理类。
CGLIB动态代理(Code Generation Library)
然而JDK动态代理只能代理实现了接口的类,为了解决这个问题,可以使用CGLIB动态代理。
六、ZooKeeper+Curator
zookeeper
ZooKeeper 是一个开源的分布式协调服务,它的设计目标是将那些复杂且容易出错的分布式一致性服务封装起来,构成一个高效可靠的原语集,并以一系列简单易用的接口提供给用户使用。
ZooKeeper 为我们提供了高可用、高性能、稳定的分布式数据一致性解决方案,通常被用于实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。这些功能的实现主要依赖于 ZooKeeper 提供的 数据存储+事件监听功能。Curator
Curator 是Netflix公司开源的一套 ZooKeeper Java客户端框架,相比于 Zookeeper 自带的客户端 zookeeper 来说, Curator 的封装更加完善,各种 API 都可以比较方便地使用。
详细的zookeeper原理看这篇文章
接下来的几个章节是对于RPC的具体实现
github地址:
https://github.com/brucewayne9064/MyRPCframework
有两种实现,一种是基于socket通信,一种是基于netty通信
七、RPC网络传输模块
对于socket通信,服务端监听的socket地址和在zookeeper上注册的服务的地址是相同的,比如是有服务器ABC都提供服务1,他们分别注册了自己的地址,那么客户端那边会根据负载均衡算法来决定自己需要使用哪一个服务器上的服务1,所以监听地址和服务地址理所当然是一样的。
服务端的socket地址是:InetAddress.getLocalHost().getHostAddress():9998
客户端的socket地址:本地ip:系统自动分配的端口号
八、RPC注册中心模块
具体来说这里的zookeeper在我的mac环境里是装在docker容器里面的,zookeeper server启动在本地的2181端口,即127.0.0.1:2181。
- 最高层的根目录是ZK_REGISTER_ROOT_PATH = “/my-rpc”
- 在这个目录下面就是服务的名称,以String格式存储的rpcServiceName,例如这个加法计算器服务[org.example.myrpcframework.serviceAPIs.AddCalculatorService]
- 要想获得某个服务的socket通信地址,需要进入String servicePath = ZK_REGISTER_ROOT_PATH + “/“ + rpcServiceName目录,也就是/my-rpc/rpcServiceName/,一个服务可能有多个服务器可以提供服务,所以可能有多个地址在这个目录,然后需要通过负载均衡来找最合适的,目前存储的是192.168.31.74:9998这一个地址,其中ip地址是通过获得本机ip的方法获得的(这里不知道为什么是这个结果而不是localhost),端口号是人为指定的。
- 完整的servicePath是以String格式存储:String servicePath = CuratorUtil.ZK_REGISTER_ROOT_PATH + “/“ + rpcServiceName + inetSocketAddress.toString();也就是/my-rpc/rpcServiceName/(String)inetSocketAddress (inetSocketAddress是套接字地址,由主机的IP地址:端口号组成)
九、RPC其他模块
十、RPC优化
Service Provider Interface服务提供发现机制:
一致性哈希负载均衡算法:
RandomLoadBalance负载均衡算法:
线程安全的哈希表ConcurrentHashMap:
单例模式:
全局只有一个ServiceProvider,因此使用单例模式来构建。
在RPC中(SingletonFactory.java)使用的实际是单例工厂模式:
如果对象已经创建,就直接返回;如果尚未创建,则通过反射创建一个新的实例,并将其存储在OBJECT_MAP中,然后返回。这样做的好处是确保了每个类只有一个实例,并且可以在不同的线程中安全地访问这个实例。
OBJECT_MAP的结构为:
1 | private static final Map<String, Object> OBJECT_MAP = new ConcurrentHashMap<>(); |
RpcRequestHandler和ZkServiceProviderImpl
线程池:
java中的ThreadPool的使用方法:
在RPC项目中,ThreadPoolFactoryUtil.java用于创建和管理线程池。CustomThreadPoolConfig.java用于创建和管理线程池参数。存放在org.example.myrpcframework.rpcFrameworkCommon.utils.threadpool位置。该线程池用于SocketRpcServer中,
1 | public class CustomThreadPoolConfig { |
钩子函数
反射:
1 | return clazz.getDeclaredConstructor().newInstance(); |
1 | RpcRequestHandler |
AttributeMap
ChannelFuture
心跳检测
threadLocal
注解
线程池
Service Provider Interface


