概念篇
RPC是什么
两个不同的服务 A、B 部署在两台不同的机器上,服务 A 如果想要调用服务 B 中的某个方法的话就可以通过 RPC 来做。
RPC 的出现就是为了让你调用远程方法像调用本地方法一样简单。
RPC原理
- 客户端(服务消费端) :调用远程方法的一端。
- 客户端 Stub(桩) : 这其实就是一代理类。代理类主要做的事情很简单,就是把你调用方法、类、方法参数等信息传递到服务端。
- 网络传输 : 网络传输就是你要把你调用的方法的信息比如说参数啊这些东西传输到服务端,然后服务端执行完之后再把返回结果通过网络传输给你传输回来。网络传输的实现方式有很多种比如最近基本的 Socket或者性能以及封装更加优秀的 Netty(推荐)。
- 服务端 Stub(桩) :这个桩就不是代理类了。我觉得理解为桩实际不太好,大家注意一下就好。这里的服务端 Stub 实际指的就是接收到客户端执行方法的请求后,去执行对应的方法然后返回结果给客户端的类。
- 服务端(服务提供端) :提供远程方法的一端。
- 服务消费端(client)以本地调用的方式调用远程服务;
- 客户端 Stub(client stub) 接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体(序列化):
RpcRequest; - 客户端 Stub(client stub) 找到远程服务的地址,并将消息发送到服务提供端;
- 服务端 Stub(桩)收到消息将消息反序列化为Java对象:
RpcRequest; - 服务端 Stub(桩)根据
RpcRequest中的类、方法、方法参数等信息调用本地的方法; - 服务端 Stub(桩)得到方法执行结果并将组装成能够进行网络传输的消息体:
RpcResponse(序列化)发送至消费方; - 客户端 Stub(client stub)接收到消息并将消息反序列化为Java对象:
RpcResponse,这样也就得到了最终结果。over!
有了HTTP,为什么还要RPC
HTTP和RPC都是协议,作用是用来规范消息头
HTTP更倾向于不同公司的软件进行通信,而RPC更倾向于同个公司的软件进行通信
HTTP和RPC的区别
服务发现
首先要向某个服务器发起请求,你得先建立连接,而建立连接的前提是,你得知道 IP 地址和端口 。这个找到服务对应的 IP 端口的过程,其实就是 服务发现。
在 HTTP 中,你知道服务的域名,就可以通过 DNS 服务 去解析得到它背后的 IP 地址,默认 80 端口。
而 RPC 的话,就有些区别,一般会有专门的中间服务去保存服务名和 IP 信息,比如 Consul、Etcd、Nacos、ZooKeeper,甚至是 Redis。想要访问某个服务,就去这些中间服务去获得 IP 和端口信息。由于 DNS 也是服务发现的一种,所以也有基于 DNS 去做服务发现的组件,比如 CoreDNS
底层连接形式
以主流的 HTTP1.1 协议为例,其默认在建立底层 TCP 连接之后会一直保持这个连接(keep alive),之后的请求和响应都会复用这条连接。
而 RPC 协议,也跟 HTTP 类似,也是通过建立 TCP 长链接进行数据交互,但不同的地方在于,RPC 协议一般还会再建个 连接池,在请求量大的时候,建立多条连接放在池内,要发数据的时候就从池里取一条连接出来,用完放回去,下次再复用,可以说非常环保。
传输的内容
基于 TCP 传输的消息,说到底,无非都是 消息头 Header 和消息体 Body。
HTTP采用JSON进行传输,内容很冗余,性能偏低
而 RPC,因为它定制化程度更高,可以采用体积更小的 Protobuf 或其他序列化协议去保存结构体数据,同时也不需要像 HTTP 那样考虑各种浏览器行为,比如 302 重定向跳转啥的。**因此性能也会更好一些,这也是在公司内部微服务中抛弃 HTTP,选择使用 RPC 的最主要原因
优化篇
使用CompletableFuture优化接受服务提供端返回结果
面试篇
RPC框架的架构
- 注册中心
- 网络传输
- 序列化和反序列化
- 动态代理
- 负载均衡
- 传输协议
NIO模型中的select,poll,epoll怎么理解
是不同机制的IO多路复用模型
select
当网卡没有接收到新的数据报文请求时,用户态执行select函数会处于堵塞状态中,此时内核态会对fd集合进行循环遍历,对每个连接上的fd都执行read操作,判断是否有新的数据报文抵达。
如果此时有新的数据报文抵达网卡缓冲区,则会将数据信息拷贝到内核态指定的内存块区域中,并且返回给调用select函数的用户态程序
一次select函数调用,会发生一次系统内核调用,和内核态内部的n多次就绪文件符的read函数调用
poll
poll也是和select相似,通过一次系统调用,然后在内核态中对连接的文件描述符集合进行遍历判断是否有就绪状态的连接接收到了网卡数据。但是select函数中只能监听1024个文件描述符(之所以是1024,我推断是为了尽量避免过多的集合在用户态和内核态之间的拷贝情况发生),而poll函数则是去除掉了这块的限制。
epoll
底层使用红黑树结构
epoll其实是对select的优化,select存在下列问题
- 任一fd发生变化,则整个fd集合都要发送给用户态,然后用户态又需要遍历来发现哪些fd是变化的
- select函数在内核态中依然是通过遍历的方式来判断究竟哪个fd已经处于就绪状态。
epoll进行了如下优化
- 用户态无需将整份fd数据在用户态和内核态之间进行拷贝,只会拷贝发生变化的fd数据。
- 内核态中不再是通过循环遍历的方式来判断哪些fd处于就绪状态,而是通过异步事件通知的方式告知。
- 内核态会将有数据抵达的fd返回到用户态,此时用户态可以减少不必要的遍历操作。
除了项目上写的序列化框架还了解其他序列化框架吗?
- JDK自带序列化机制,不过性能差,存在安全问题
- Kryo 是一个高性能的序列化/反序列化工具,由于其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积。
- Protobuf,hessian
解释一下什么是粘包,什么是拆包
粘包与拆包是网络编程中的常见问题。粘包指的是在传输过程中,多个数据包黏在了一起,导致接收端无法正确识别每个数据包的边界;拆包则相反,指的是将一个数据包分成多个数据包进行传输。这些问题通常会导致数据解析错误或数据丢失。
有没有了解过netty里自己解决粘包拆包的
Netty提供了多种解决粘包和拆包问题的方法,如LineBasedFrameDecoder、LengthFieldPrepender和LengthFieldBasedFrameDecoder等实用类。
我在项目中是使用LengthFieldBasedFrameDecoder进行解决的
创建一个类继承LengthFieldBasedFrameDecoder,并且根据你的自定义传输协议进行长度上的划分,拿到自定义传输协议中的消息长度然后就可以进行判断
HTTP如何解决粘包问题的? (Header字段? )
HTTP协议中使用Content-Length字段指示正文长度,从而确保正确解析HTTP消息。此外,HTTP/1.1还支持分块传输编码(chunked transfer encoding),可以将消息分成多个块进行传输,每个块都有一个长度字段指示长度。
这个RPC框架是基于看过别的框架的东西吗?
看过Dubbo
zookeeper作为注册中心是怎么存储的,存储了什么数据在zookeeper中?
Zookeeper作为分布式协调服务,主要用于管理配置信息、命名服务、分布式锁和群组服务等。在Zookeeper中,每个znode节点都可以存储数据,存储的数据可以是任何二进制数据。
服务端将服务注册进去,客户端请求服务,这个过程在项目中大概是什么样子的,描述一下?
服务端使用Zookeeper将服务注册为临时节点,并将服务地址信息存储在节点中。客户端通过Zookeeper获取可用服务列表,并向其中一个服务节点发送请求。
为什么要把每个服务端注册成临时节点?
将服务注册为临时节点可以使Zookeeper在服务下线时自动删除该节点,从而避免了集群中出现失效服务。
客户端发起调用的时候每次都去获取节点信息吗?
通常情况下,客户端会缓存服务地址列表,以避免每次调用都需要从Zookeeper中获取节点信息。
如果每次都获取的话性能就会比较低,有没有什么优化方案? (用watcher)
可以使用Watcher机制,当服务列表发生变化时,Zookeeper会自动通知客户端,从而避免了客户端每次都需要从Zookeeper中获取节点信息。
使用watcher的时候需要注意什么? watcher回调完后还要做什么事情吗?
使用Watcher时需要注意避免出现Watcher泛滥和重复注册Watcher的问题。当Watcher被调用后,客户端需要重新从Zookeeper中获取节点信息。
有自己搭了zookeeper集群吗?
没有
zookeeper集群里会有哪些角色?
在Zookeeper集群中,每个节点都可以扮演不同的角色,如Leader、Follower和Observer等。其中Leader负责处理写请求和协调集群,Follower则负责处理读请求和投票选举,Observer不参与投票选举,只接收数据。
为什么要设计observer呢? 它既不参与leader竟选,也不参与数据过半写成功?
Observer通常用于在高负载环境下缓解Leader和Follower节点的压力,提高集群的读取性能。同时,Observer也可以用于在多个数据中心之间复制数据。
rpc中应用线程池调用者发送调用后,请求跟响应是怎么异步关联的呢? (回调)
当调用者发送请求后,会异步等待响应。一般情况下,会使用回调函数将响应与请求关联起来。
基于回调要怎么异步关联起来呢,有没有什么思路?
可以使用Future、Promise或者Callback等机制,将异步的响应与请求进行关联。在接收到响应时,将响应返回给请求方,从而完成调用。
基于一致性哈希的负载均衡算法是怎样的
import java.util.*;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* 基于一致性哈希的负载均衡算法实现
*/
public class ConsistentHashLoadBalancer {
// 节点列表
private SortedMap<Long, String> nodes = new TreeMap<>();
// 默认虚拟节点数
private static final int DEFAULT_VIRTUAL_NODES = 100;
// 虚拟节点数
private int virtualNodes;
/**
* 默认使用100个虚拟节点
*/
public ConsistentHashLoadBalancer() {
this.virtualNodes = DEFAULT_VIRTUAL_NODES;
}
/**
* 自定义虚拟节点数
* @param virtualNodes
*/
public ConsistentHashLoadBalancer(int virtualNodes) {
this.virtualNodes = virtualNodes;
}
/**
* 添加节点
* @param node
*/
public void addNode(String node) {
for (int i = 0; i < virtualNodes; i++) {
String virtualNode = node + "#" + i;
long hash = hash(virtualNode);
nodes.put(hash, node);
}
}
/**
* 删除节点
* @param node
*/
public void removeNode(String node) {
for (int i = 0; i < virtualNodes; i++) {
String virtualNode = node + "#" + i;
long hash = hash(virtualNode);
nodes.remove(hash);
}
}
/**
* 获取负载均衡结果
* @param key
* @return
*/
public String getBalanceNode(String key) {
if (nodes.isEmpty()) {
return null;
}
long hash = hash(key);
SortedMap<Long, String> tailMap = nodes.tailMap(hash);
Long nodeHash = tailMap.isEmpty() ? nodes.firstKey() : tailMap.firstKey();
return nodes.get(nodeHash);
}
/**
* 使用MD5计算哈希值
* @param key
* @return
*/
private long hash(String key) {
try {
MessageDigest md5 = MessageDigest.getInstance("MD5");
byte[] bytes = md5.digest(key.getBytes());
return ((long) (bytes[0] & 0xFF) << 56)
| ((long) (bytes[1] & 0xFF) << 48)
| ((long) (bytes[2] & 0xFF) << 40)
| ((long) (bytes[3] & 0xFF) << 32)
| ((long) (bytes[4] & 0xFF) << 24)
| ((long) (bytes[5] & 0xFF) << 16)
| ((long) (bytes[6] & 0xFF) << 8)
| ((long) (bytes[7] & 0xFF));
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
return 0;
}
}
Netty篇
如何解决粘包拆包
Netty提供了多种解决粘包和拆包问题的方法,如LineBasedFrameDecoder、LengthFieldPrepender和LengthFieldBasedFrameDecoder等实用类。
我在项目中是使用LengthFieldBasedFrameDecoder进行解决的
创建一个类继承LengthFieldBasedFrameDecoder,并且根据你的自定义传输协议进行长度上的划分,拿到自定义传输协议中的消息长度然后就可以进行判断
//基于长度域的拆分器
//用于解决粘包半包问题
//解决思路:根据长度进行数据包的拆分
public class Spliter extends LengthFieldBasedFrameDecoder {
private static final int LENGTH_FIELD_OFFSET = 7;
private static final int LENGTH_FIELD_LENGTH = 4;
public Spliter() {
//第二个参数是长度域相对整个数据包的偏移量是多少,这里显然是 4+1+1+1=7
//第三个参数是长度域的长度为多少,这里为4
super(Integer.MAX_VALUE, LENGTH_FIELD_OFFSET, LENGTH_FIELD_LENGTH);
}
//判断前四位是否为魔数
@Override
protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
if (in.getInt(in.readerIndex()) != PacketCodeC.MAGIC_NUMBER) {
ctx.channel().close();
return null;
}
return super.decode(ctx, in);
}
}
如何进行断连重连
连接不上服务器需要不断重试
public class NettyClient {
private EventLoopGroup group;
private Bootstrap bootstrap;
private ChannelFuture future;
public void start() {
// 创建EventLoopGroup实例
group = new NioEventLoopGroup();
// 创建Bootstrap实例
bootstrap = new Bootstrap();
bootstrap.group(group)
.channel(NioSocketChannel.class)
.option(ChannelOption.SO_KEEPALIVE, true)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new MyHandler()); // 添加自定义的ChannelHandler
}
});
// 建立连接
connect();
}
private void connect() {
future = bootstrap.connect("127.0.0.1", 8080);
future.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
if (future.isSuccess()) {
System.out.println("连接成功!");
} else {
System.out.println("连接失败,准备重新连接...");
future.channel().eventLoop().schedule(new Runnable() {
@Override
public void run() {
connect();
}
}, 3, TimeUnit.SECONDS); // 3秒后重新连接
}
}
});
}
public void stop() {
if (future != null) {
future.channel().closeFuture();
}
if (group != null) {
group.shutdownGracefully();
}
}
}
**在这个示例中,通过addListener方法添加了一个ChannelFutureListener,在连接断开后会执行schedule方法中的重连操作。这个示例会在连接失败后,等待3秒后重新尝试连接服务器。如果连接成功,则打印“连接成功!”;如果连接失败,则打印“连接失败,准备重新连接…”,并进行重连操作。 **
如何进行心跳保持
/**
* 心跳检测客户端处理器,继承自ChannelInboundHandlerAdapter
*/
public class HeartbeatClientHandler extends ChannelInboundHandlerAdapter {
private static final ByteBuf HEARTBEAT_SEQUENCE = Unpooled.unreleasableBuffer(Unpooled.copiedBuffer("HEARTBEAT", CharsetUtil.UTF_8)); // 心跳数据
private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); // 定时任务执行器
private ScheduledFuture<?> heartBeat; // 心跳任务
/**
* 当通道连接成功后,启动心跳任务
* @param ctx 通道处理器上下文
* @throws Exception 异常
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
heartBeat = executor.scheduleAtFixedRate(
new HeartbeatTask(ctx),
0,
5,
TimeUnit.SECONDS);
super.channelActive(ctx);
}
/**
* 当通道断开连接后,进行重连操作
* @param ctx 通道处理器上下文
* @throws Exception 异常
*/
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
System.err.println("连接断开,正在重连...");
ctx.channel().eventLoop().schedule(() -> {
ChannelFuture future = NettyClient.connect();
future.addListener((ChannelFutureListener) future1 -> {
if (future1.isSuccess()) {
System.out.println("重连成功!");
} else {
System.err.println("重连失败!");
future1.channel().pipeline().fireChannelInactive();
}
});
}, 5, TimeUnit.SECONDS);
super.channelInactive(ctx);
}
/**
* 心跳任务,向服务端发送心跳数据
*/
private static class HeartbeatTask implements Runnable {
private final ChannelHandlerContext ctx;
public HeartbeatTask(final ChannelHandlerContext ctx) {
this.ctx = ctx;
}
@Override
public void run() {
ctx.writeAndFlush(HEARTBEAT_SEQUENCE.duplicate());
}
}
/**
* 当接收到服务端响应时,读取数据并处理粘包拆包问题
* @param ctx 通道处理器上下文
* @param msg 接收到的服务端信息
* @throws Exception 异常
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf message = (ByteBuf) msg;
// 处理粘包拆包问题
while (message.readableBytes() > 0) {
int length = message.readableBytes();
byte[] bytes = new byte[length];
message.readBytes(bytes);
String body = new String(bytes, CharsetUtil.UTF_8);
System.out.println("收到服务端消息:" + body);
}
message.release(); // 释放ByteBuf
}
/**
* 异常处理,打印异常并取消心跳任务
* @param ctx 通道处理器上下文
* @param cause 异常原因
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
if (heartBeat != null) {
heartBeat.cancel(true); // 取消心跳任务
heartBeat = null;
}
ctx.fireExceptionCaught(cause);
}
}
如何实现请求鉴权
思路:请求抵达服务端调用具体方法之前,先对其调用凭证进行判断操作,如果凭证不一致则抛出异常。
/**
* 用于客户端的鉴权处理器,发送认证信息并处理鉴权结果。
*/
public class AuthClientHandler extends ChannelInboundHandlerAdapter {
private final String userName;
private final String password;
public AuthClientHandler(String userName, String password) {
this.userName = userName;
this.password = password;
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
// 发送认证信息
ctx.writeAndFlush(new AuthRequest(userName, password));
super.channelActive(ctx);
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (msg instanceof AuthResponse) {
AuthResponse response = (AuthResponse) msg;
if (response.isSuccess()) {
// 鉴权成功
System.out.println("鉴权成功!");
ctx.pipeline().remove(this); // 移除鉴权处理器
} else {
// 鉴权失败
System.err.println("鉴权失败!");
ctx.close(); // 关闭连接
}
} else {
// 非鉴权响应,继续传递给下一个处理器
ctx.fireChannelRead(msg);
}
}
}
如何实现分组管理
分组的服务管理在团队协作过程中使用的频率会比较高,例如当A、B两个工程师在共同开发一个叫做UserService服务时候,A的UserService开发还未完成,处于自测阶段,B的UserService已经开发完成,进入了测试阶段,而此时只有一个注册中心供团队使用,那么此时可能会出现:测试同学进行功能测试的时候,调用到了A写的UserService,从而影响测试结果的准确性。
而如果我们将服务按照组别进行管理,A开发的UserService的group设置为dev,B开发的UserService的group设置为test,而远程调用的时候严格遵守group参数进行匹配调用,这样就能确保测试同学在调用服务的时候,不会将请求路由到A同学所写的还未完善的UserService上边了。
/**
* 用于客户端的分组处理器,发送分组信息并处理分组消息。
*/
public class GroupClientHandler extends SimpleChannelInboundHandler<GroupMessage> {
private final String groupId;
private final String userId;
private final String ip;
private final int port;
private Channel channel;
public GroupClientHandler(String groupId, String userId, String ip, int port) {
this.groupId = groupId;
this.userId = userId;
this.ip = ip;
this.port = port;
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
channel = ctx.channel();
// 发送分组信息
ctx.writeAndFlush(new GroupRequest(groupId, userId));
super.channelActive(ctx);
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
System.err.println("连接断开,正在重连...");
connect(); // 重新连接
super.channelInactive(ctx);
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, GroupMessage msg) throws Exception {
// 处理分组消息
System.out.println("收到分组消息:" + msg.getBody());
}
public void send(String message) {
// 发送分组消息
channel.writeAndFlush(new GroupMessage(groupId, userId, message));
}
private void connect() {
EventLoopGroup group = new NioEventLoopGroup();
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline()
.addLast(new GroupClientHandler(groupId, userId, ip, port));
}
});
try {
bootstrap.connect(ip, port).sync();
System.out.println("连接成功!");
} catch (Exception e) {
System.err.println("连接失败!");
group.shutdownGracefully();
e.printStackTrace();
}
}
}
如何实现IP直连
按照指定ip访问的方式请求server端是我们在测试阶段会比较常见的方式,例如服务部署之后,发现2个名字相同的服务,面对相同的请求参数,在两个服务节点中返回的结果却不一样,此时就可以通过指定请求ip来进行debug诊断。
String ip = "127.0.0.1"; // 目标IP地址
int port = 8080; // 目标端口号
EventLoopGroup group = new NioEventLoopGroup();
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new MyHandler()); // 添加自定义的ChannelHandler
}
});
try {
ChannelFuture future = bootstrap.connect(ip, port).sync();
// 连接成功后,可以通过future.channel()获取Channel实例,进行数据交互
future.channel().writeAndFlush("Hello, Netty!");
future.channel().closeFuture().sync(); // 关闭连接
} catch (Exception e) {
e.printStackTrace();
} finally {
group.shutdownGracefully(); // 释放资源
}
其中的MyHandle
public class MyHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.writeAndFlush("Hello, Netty!");
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
System.out.println("收到消息:" + msg);
}
}
如何实现灰度发布
灰度发布(Gray Release),也称为金丝雀发布(Canary Release),是一种软件发布的技术方案。它的主要目的是在不影响生产环境的情况下,逐步地将新版本的软件推广到用户中。在灰度发布中,新版本软件只在一小部分用户中进行测试,而其他用户则使用旧版本的软件,以便在测试结果可靠之后,才能将新版本软件全面地推广到所有用户中。这种发布方式可以帮助软件开发团队逐步消除新版本中的漏洞和缺陷,使软件更加稳定和可靠。
/**
* 版本号匹配客户端处理器,继承自ChannelInboundHandlerAdapter
*/
public class VersionClientHandler extends ChannelInboundHandlerAdapter {
private final String version; // 版本号
/**
* 构造方法,传入版本号参数
* @param version 版本号
*/
public VersionClientHandler(String version) {
this.version = version;
}
/**
* 当通道连接成功后,发送版本信息
* @param ctx 通道处理器上下文
* @throws Exception 异常
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
// 发送版本信息
ctx.writeAndFlush(new VersionRequest(version));
super.channelActive(ctx);
}
/**
* 当接收到服务端响应时,处理响应信息
* @param ctx 通道处理器上下文
* @param msg 接收到的服务端信息
* @throws Exception 异常
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (msg instanceof VersionResponse) {
VersionResponse response = (VersionResponse) msg;
if (response.isSuccess()) {
// 版本匹配成功
System.out.println("版本匹配成功!");
ctx.pipeline().remove(this); // 移除处理器
} else {
// 版本匹配失败
System.err.println("版本匹配失败!");
ctx.close(); // 关闭通道
}
} else {
ctx.fireChannelRead(msg); // 传递消息给下一个处理器
}
}
}
Zookeeper篇
架构
也是先定义一个rpc的根节点,接着是不同的服务名称(例如:com.sise.data.UserService)作为二级节点,在二级节点下划分了provider和consumer节点。provider下存放的数据以ip+端口的格式存储,consumer下边存放具体的服务调用服务名与地址。
定义RegistryService接口,实现服务的注册,下线,订阅,取消订阅
- void register(URL url);
注册接口,当某个服务要启动的时候,需要再将接口注册到注册中心,之后服务调用方才可以获取到新服务的数据了。
- void unRegister(URL url);
服务下线接口,当某个服务提供者要下线了,则需要主动将注册过的服务信息从zk的指定节点上摘除,此时就需要调用unRegister接口。
- void subscribe(URL url);
订阅某个服务,通常是客户端在启动阶段需要调用的接口。客户端在启动过程中需要调用该函数,从注册中心中提取现有的服务提供者地址,从而实现服务订阅功能。
- void doUnSubscribe(URL url);
取消订阅服务,当服务调用方不打算再继续订阅某些服务的时候,就需要调用该函数去取消服务的订阅功能,将注册中心的订阅记录进行移除操作。
URL的配置类架构
IRPC的主要配置都封装在了里面
package org.idea.irpc.framework.core.registy;
import org.idea.irpc.framework.core.registy.zookeeper.ProviderNodeInfo;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* @Author linhao
* @Date created in 3:48 下午 2021/12/11
*/
public class URL {
/**
* 服务应用名称
*/
private String applicationName;
/**
* 注册到节点到服务名称,例如:com.sise.test.UserService
*/
private String serviceName;
/**
* 这里面可以自定义不限进行扩展
* 分组
* 权重
* 服务提供者的地址
* 服务提供者的端口
*/
private Map<String, String> parameters = new HashMap<>();
public void addParameter(String key, String value) {
this.parameters.putIfAbsent(key, value);
}
public String getApplicationName() {
return applicationName;
}
public void setApplicationName(String applicationName) {
this.applicationName = applicationName;
}
public String getServiceName() {
return serviceName;
}
public void setServiceName(String serviceName) {
this.serviceName = serviceName;
}
public Map<String, String> getParameters() {
return parameters;
}
public void setParameters(Map<String, String> parameters) {
this.parameters = parameters;
}
/**
* 将URL转换为写入zk的provider节点下的一段字符串
*
* @param url
* @return
*/
public static String buildProviderUrlStr(URL url) {
String host = url.getParameters().get("host");
String port = url.getParameters().get("port");
return new String((url.getApplicationName() + ";" + url.getServiceName() + ";" + host + ":" + port + ";" + System.currentTimeMillis()).getBytes(), StandardCharsets.UTF_8);
}
/**
* 将URL转换为写入zk的consumer节点下的一段字符串
*
* @param url
* @return
*/
public static String buildConsumerUrlStr(URL url) {
String host = url.getParameters().get("host");
return new String((url.getApplicationName() + ";" + url.getServiceName() + ";" + host + ";" + System.currentTimeMillis()).getBytes(), StandardCharsets.UTF_8);
}
/**
* 将某个节点下的信息转换为一个Provider节点对象
*
* @param providerNodeStr
* @return
*/
public static ProviderNodeInfo buildURLFromUrlStr(String providerNodeStr) {
String[] items = providerNodeStr.split("/");
ProviderNodeInfo providerNodeInfo = new ProviderNodeInfo();
providerNodeInfo.setServiceName(items[2]);
providerNodeInfo.setAddress(items[4]);
return providerNodeInfo;
}
}
如何根据内部节点的变化来实现服务提供者权重属性的动态更新
- 在 Zookeeper 中为服务提供者创建一个 znode,并为其设置一个初始权重属性。
- 客户端通过订阅服务提供者的权重节点,监听其变化。
- 当服务提供者的权重属性发生变化时,会触发权重节点的变更事件,客户端会收到通知。
- 客户端接收到通知后,更新本地缓存的服务提供者列表,包括其权重信息,同时更新本地的负载均衡策略。
- 客户端根据新的权重信息重新选择服务提供者进行调用。
1.定义一个 ServiceProvider 类,用于存储服务提供者的地址和权重属性
public class ServiceProvider {
private String address; // 服务提供者地址
private int weight; // 服务提供者权重属性
// 构造方法
public ServiceProvider(String address, int weight) {
this.address = address;
this.weight = weight;
}
// getter 和 setter 方法
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
public int getWeight() {
return weight;
}
public void setWeight(int weight) {
this.weight = weight;
}
}
2.定义一个 ServiceRegistry 类,用于与 Zookeeper 进行通信,注册服务提供者和监听服务提供者节点
public class ServiceRegistry {
private ZooKeeper zk; // Zookeeper 客户端
private String zkPrefix; // Zookeeper 服务根目录
// 构造方法
public ServiceRegistry(String zkHosts, String zkPrefix) throws IOException {
this.zk = new ZooKeeper(zkHosts, 5000, null);
this.zkPrefix = zkPrefix;
}
// 注册服务提供者
public void registerServiceProvider(String serviceName, ServiceProvider provider) throws KeeperException, InterruptedException {
// 创建服务提供者节点
String providerPath = zkPrefix + "/" + serviceName + "/" + provider.getAddress();
Stat stat = zk.exists(providerPath, false);
byte[] data = String.valueOf(provider.getWeight()).getBytes();
if (stat == null) {
zk.create(providerPath, data, ZooKeeper.Ids.OPEN_ACL_UNSAFE, zk.createMode().ephemeral());
} else {
zk.setData(providerPath, data, -1);
}
}
// 监听服务提供者节点
public void watchServiceProviders(String serviceName, Watcher watcher) throws KeeperException, InterruptedException {
String providersPath = zkPrefix + "/" + serviceName;
List<String> providerNodes = zk.getChildren(providersPath, watcher);
List<ServiceProvider> providers = new ArrayList<>();
for (String node : providerNodes) {
String nodePath = providersPath + "/" + node;
byte[] data = zk.getData(nodePath, false, null);
String address = node;
int weight = Integer.parseInt(new String(data));
ServiceProvider provider = new ServiceProvider(address, weight);
providers.add(provider);
}
Collections.sort(providers, new Comparator<ServiceProvider>() {
@Override
public int compare(ServiceProvider o1, ServiceProvider o2) {
return o2.getWeight() - o1.getWeight();
}
});
watcher.process(new WatchedEvent(Watcher.Event.EventType.NodeChildrenChanged, null, null));
}
}
3.定义一个 ServiceConsumer 类,用于选择提供者并更新服务提供者列表:(负载均衡使用在这里)
public class ServiceConsumer {
private ZooKeeper zk; // Zookeeper 客户端
private String zkPrefix; // Zookeeper 服务根目录
private Map<String, List<ServiceProvider>> serviceProviders = new HashMap<>(); // 服务提供者列表
// 构造方法
public ServiceConsumer(String zkHosts, String zkPrefix) throws IOException {
this.zk = new ZooKeeper(zkHosts, 5000, null);
this.zkPrefix = zkPrefix;
}
// 选择服务提供者
public String chooseServiceProvider(String serviceName) throws KeeperException, InterruptedException {
if (!serviceProviders.containsKey(serviceName)) {
updateServiceProviders(serviceName);
}
List<ServiceProvider> providers = serviceProviders.get(serviceName);
if (providers == null || providers.isEmpty()) {
return null;
}
int totalWeight = providers.stream().mapToInt(p -> p.getWeight()).sum();
int randomWeight = new Random().nextInt(totalWeight);
int currentWeight = 0;
for (ServiceProvider provider : providers) {
currentWeight += provider.getWeight();
if (currentWeight >= randomWeight) {
return provider.getAddress();
}
}
return null;
}
// 更新服务提供者列表
public void updateServiceProviders(String serviceName) throws KeeperException, InterruptedException {
String providersPath = zkPrefix + "/" + serviceName;
List<String> providerNodes = zk.getChildren(providersPath, getWatcher(serviceName));
List<ServiceProvider> providers = new ArrayList<>();
for (String node : providerNodes) {
String nodePath = providersPath + "/" + node;
byte[] data = zk.getData(nodePath, false, null);
String address = node;
int weight = Integer.parseInt(new String(data));
ServiceProvider provider = new ServiceProvider(address, weight);
providers.add(provider);
}
Collections.sort(providers, new Comparator<ServiceProvider>() {
@Override
public int compare(ServiceProvider o1, ServiceProvider o2) {
return o2.getWeight() - o1.getWeight();
}
});
serviceProviders.put(serviceName, providers);
}
// 获取服务提供者节点的 Watcher
private Watcher getWatcher(String serviceName) {
return new Watcher() {
@Override
public void process(WatchedEvent event) {
if (event.getType() == Watcher.Event.EventType.NodeChildrenChanged) {
try {
watchServiceProviders(serviceName, this);
} catch (KeeperException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
}
}
4.测试
public class Main {
public static void main(String[] args) throws IOException, KeeperException, InterruptedException {
String zkHosts = "localhost:2181"; // Zookeeper 集群地址
String zkPrefix = "services"; // Zookeeper 服务根目录
String serviceName = "my_service"; // 服务名称
ServiceProvider provider1 = new ServiceProvider("192.168.1.100", 5); // 服务提供者1
ServiceProvider provider2 = new ServiceProvider("192.168.1.101", 3); // 服务提供者2
ServiceRegistry registry = new ServiceRegistry(zkHosts, zkPrefix); // Zookeeper 服务注册中心
registry.registerServiceProvider(serviceName, provider1);
registry.registerServiceProvider(serviceName, provider2);
ServiceConsumer consumer = new ServiceConsumer(zkHosts, zkPrefix);
String address;
for (int i = 0; i < 5; i++) {
address = consumer.chooseServiceProvider(serviceName);
System.out.println("Call service '" + serviceName + "' with provider '" + address + "'");
Thread.sleep(1000);
}
}
}
CompletableFuture篇
CompletableFuture如何实现调用端与服务端完全异步
在 RPC 中,当客户端发起调用请求后,服务端会异步地处理请求,并在处理完成后返回 CompletableFuture,这样客户端可以继续进行其他操作,等到需要获取结果时再通过 CompletableFuture 的 API 获取结果,这样就实现了完全异步的调用过程,提高了系统的性能
协议篇
自定义客户端服务端通信协议是怎样的
传输协议 在《如何自己实现一个 RPC 框架》这一节,我们就提到了传输协议的作用。 简单来说:通过设计协议,我们定义需要传输哪些类型的数据, 并且还会规定每一种类型的数据应该占多少字节。这样我们在接收到二级制数据之后,就可以正确的解析出我们需要的数据。这有一点像密文传输的感觉。 以下便是我们设计的传输协议(编解码器这里会用到!!!):
Java 1 2 3 4 5 6 7 8 9 10 11 * 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 * +-----+-----+-----+-----+--------+----+----+----+------+-----------+-------+----- --+-----+-----+-------+ * | magic code |version | full length | messageType| codec|compress| RequestId | * +-----------------------+--------+---------------------+-----------+-----------+-----------+------------+ * | | * | body | * | | * | ... ... | * +-------------------------------------------------------------------------------------------------------+ *
4B magic code(魔法数) 1B version(版本) 4B full length(消息长度) 1B messageType(消息类型) * 1B compress(压缩类型) 1B codec(序列化类型) 4B requestId(请求的Id)
● 魔法数 : 通常是 4 个字节。这个魔数主要是为了筛选来到服务端的数据包,有了这个魔数之后,服务端首先取出前面四个字节进行比对,能够在第一时间识别出这个数据包并非是遵循自定义协议的,也就是无效数据包,为了安全考虑可以直接关闭连接以节省资源。 ● 序列化器类型 :标识序列化的方式,比如是使用 Java 自带的序列化,还是 json,kyro 等序列化方式。 ● 消息长度 : 运行时计算出来。