如今,分布式系统大行其道,RPC 有着举足轻重的地位。Dubbo、Thrift、gRpc 等框架各领风骚,学习RPC是新手也是老鸟的必修课。本文带你手撸一个rpc-spring-starter,深入学习和理解rpc相关技术,包括但不限于 RPC 原理、动态代理、Javassist 字节码增强、服务注册与发现、Netty 网络通讯、传输协议、序列化、包压缩、TCP 粘包、拆包、长连接复用、心跳检测、SpringBoot 自动装载、服务分组、接口版本、客户端连接池、负载均衡、异步调用等知识, 值得收藏。
RPC定义
远程服务调用(Remote procedure call)的概念历史已久,1981年就已经被提出,最初的目的就是为了 调用远程方法像调用本地方法一样简单 ,经历了四十多年的更新与迭代,RPC 的大体思路已经趋于稳定,如今百家争鸣的 RPC 协议和框架,诸如 Dubbo (阿里)、Thrift(FaceBook)、gRpc(Google)、brpc (百度)等都在不同侧重点去解决最初的目的,有的想极致完美,有的追求极致性能,有的偏向极致简单。
RPC基本原理
让我们回到 RPC 最初的目的,要想实现 调用远程方法像调用本地方法一样简单 ,至少要解决如下问题:
- 如何获取可用的远程服务器
- 如何表示数据
- 如何传递数据
- 服务端如何确定并调用目标方法
上述四点问题,都能与现在分布式系统火热的术语一一对应,如何获取可用的远程服务器(服务注册与发现)、如何表示数据(序列化与反序列化)、如何传递数据(网络通讯)、服务端如何确定并调用目标方法(调用方法映射)。笔者将通过一个简单 RPC 项目来解决这些问题。
首先来看 RPC 的整体系统架构图:
图中服务端启动时将自己的服务节点信息注册到注册中心,客户端调用远程方法时会订阅注册中心中的可用服务节点信息,拿到可用服务节点之后远程调用方法,当注册中心中的可用服务节点发生变化时会通知客户端,避免客户端继续调用已经失效的节点。那客户端是如何调用远程方法的呢,来看一下远程调用示意图:
- 客户端模块代理所有远程方法的调用
- 将目标服务、目标方法、调用目标方法的参数等必要信息序列化
- 序列化之后的数据包进一步压缩,压缩后的数据包通过网络通信传输到目标服务节点
- 服务节点将接受到的数据包进行解压
- 解压后的数据包反序列化成目标服务、目标方法、目标方法的调用参数
- 通过服务端代理调用目标方法获取结果,结果同样需要序列化、压缩然后回传给客户端
通过以上描述,相信读者应该大体上了解了 RPC 是如何工作的,接下来看如何使用代码具体实现上述的流程。鉴于篇幅笔者会选择重要或者网络上介绍相对较少的模块来讲述。
RPC实现细节
1\. 服务注册与发现
作为一个入门项目,我们的系统选用 Zookeeper 作为注册中心, ZooKeeper 将数据保存在内存中,性能很高。在读多写少的场景中尤其适用,因为写操作会导致所有的服务器间同步状态。服务注册与发现是典型的读多写少的协调服务场景。Zookeeper 是一个典型的CP系统,在服务选举或者集群半数机器宕机时是不可用状态,相对于服务发现中主流的AP系统来说,可用性稍低,但是用于理解RPC的实现,也是绰绰有余。
ZooKeeper节点介绍
- 持久节点( PERSISENT ):一旦创建,除非主动调用删除操作,否则一直持久化存储。
- 临时节点( EPHEMERAL ):与客户端会话绑定,客户端会话失效,这个客户端所创建的所有临时节点都会被删除除。
- 节点顺序( SEQUENTIAL ):创建子节点时,如果设置SEQUENTIAL属性,则会自动在节点名后追加一个整形数字,上限是整形的最大值;同一目录下共享顺序,例如(/a0000000001,/b0000000002,/c0000000003,/test0000000004)。
ZooKeeper服务注册
在 ZooKeeper 根节点下根据服务名创建持久节点 /rpc/{serviceName}/service ,将该服务的所有服务节点使用临时节点创建在 /rpc/{serviceName}/service 目录下,代码如下(为方便展示,后续展示代码都做了删减):
public void exportService(Service serviceResource) { String name = serviceResource.getName(); String uri = GSON.toJson(serviceResource); String servicePath = "rpc/" + name + "/service"; zkClient.createPersistent(servicePath, true); String uriPath = servicePath + "/" + uri; //创建一个新的临时节点,当该节点宕机会话失效时,该临时节点会被清理 zkClient.createEphemeral(uriPath); }
注册效果如图,本地启动两个服务则 service 下有两个服务节点信息:
存储的节点信息包括服务名,服务 IP:PORT ,序列化协议,压缩协议等。
ZooKeeper服务发现
客户端启动后,不会立即从注册中心获取可用服务节点,而是在调用远程方法时获取节点信息(懒加载),并放入本地缓存 MAP 中,供后续调用,当注册中心通知目录变化时清空服务所有节点缓存,代码如下:
public List<Service> getServices(String name) { Map<String, List<Service>> SERVER_MAP = new ConcurrentHashMap<>(); String servicePath = "rpc/" + name + "/service"; List<String> children = zkClient.getChildren(servicePath); List<Service> serviceList = Optional.ofNullable(children).orElse(new ArrayList<>()).stream().map(str -> { String deCh = URLDecoder.decode(str, StandardCharsets.UTF_8.toString()); return gson.fromJson(deCh, Service.class); }).collect(Collectors.toList()); SERVER_MAP.put(name, serviceList); return serviceList; }
public class ZkChildListenerImpl implements IZkChildListener { //监听子节点的删除和新增事件 @Override public void handleChildChange(String parentPath, List<String> childList) throws Exception { //有变动就清空服务所有节点缓存 String[] arr = par