这是我参与8月更文挑战的第21天,活动详情查看: 8月更文挑战
本文使用源码地址:simple-rpc
通信本质是I/O,但是如果我们从I/O开始说起,那内容就太多了,光I/O模型就可以写好多篇。所以有兴趣的可以自己再去翻看一下。我们只简单回顾一下四个概念:
- 阻塞:调用方发起调用请求,在没有返回结果之前,调用方线程被挂起,处于一直等待的状态。
- 同步:发出一个请求时,在没有得到结果之前,该调用就不返回。
- 非阻塞:与阻塞相反,调用方发起调用请求,当前线程不会被挂起,而会立刻返回,后续可以通过轮询等手段获取调用结果。
- 异步:异步与同步想对。当一个异步过程调用发出后,调用者不会立即获得结果,后续可以通过回调等方式获取调用结果。
我故意将阻塞和同步、非阻塞和异步放在一起,看起来它们很相似的概念,其实是关注点不同,同步和异步关注的是消息的通信机制,而阻塞和非阻塞关注的是等待消息时程序的状态。
构建分布式服务框架底层通信基础
在此之前先了解下在simple-rpc中承担底层通信的主角:netty。推荐大家两本书《netty 权威指南》和一本掘进的小册《Netty 入门与实战:仿写微信 IM 即时通讯系统》,对于阅读下面的代码来说已经绰绰有余了。
大致了解下Netty的基本概念:
-
Netty 抽象出两组线程池
BossGroup
和WorkerGroup
,BossGroup
专门负责接收客户端的连接,WorkerGroup
专门负责网络的读写。 -
BossGroup
和WorkerGroup
类型都是NioEventLoopGroup
。 -
NioEventLoopGroup
相当于一个事件循环线程组, 这个组中含有多个事件循环线程 , 每一个事件循环线程是NioEventLoop
。 -
每个
NioEventLoop
都有一个selector
, 用于监听注册在其上的SocketChannel
的网络通讯。 -
每个
Boss NioEventLoop
线程内部循环执行的步骤有 3 步:- 处理
accept
事件 , 与client
建立连接 , 生成NioSocketChannel
。 - 将
NioSocketChannel
注册到某个workerNIOEventLoop
上的selector
。 - 处理任务队列的任务 , 即runAllTasks。
- 处理
-
每个worker
NIOEventLoop
线程循环执行的步骤:
- 轮询注册到自己
selector
上的所有NioSocketChannel
的read, write事件。 - 处理 I/O 事件, 即read , write 事件, 在对应
NioSocketChannel
处理业务。 - runAllTasks处理任务队列TaskQueue的任务 ,一些耗时的业务处理一般可以放入TaskQueue中慢慢处理,这样不影响数据在 pipeline 中的流动处理。
- 每个worker
NIOEventLoop
处理NioSocketChannel
业务时,会使用pipeline
(管道),管道中维护了很多handler
处理器用来处理channel
中的数据。
上面的图和概念有个大致印象,我们接着来看下如何使用Netty进行服务端和客户端的开发。
Netty开发入门
本次使用的Netty版本是:
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.29.Final</version>
</dependency>
服务端
public class NettyServer {
public static void main(String[] args) {
EventLoopGroup boss = new NioEventLoopGroup();
EventLoopGroup worker = new NioEventLoopGroup();
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(boss, worker)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childHandler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
ch.pipeline().addLast(new FirstServerHandler());
}
});
bootstrap.bind(8080);
}
private static class FirstServerHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf byteBuf = (ByteBuf) msg;
System.out.println(new Date() + "服务端读取数据:" + byteBuf.toString(Charset.forName("UTF-8")));
// 服务端回复数据到客户端
ByteBuf out = getByteBuf(ctx);
ctx.channel().writeAndFlush(out);
}
private ByteBuf getByteBuf(ChannelHandlerContext ctx) {
// 获取二进制抽象
ByteBuf buffer = ctx.alloc().buffer();
// 准备数据
byte[] bytes = "你好,我是服务端".getBytes(Charset.forName("UTF-8"));
// 填充数据到ByteBuf
buffer.writeBytes(bytes);
return buffer;
}
}
}
-
创建两个
EventLoopGroup
实例:bossGroup
和workerGroup
。bossGroup
用于接受新连接线程,主要负责创建新连接,workGroup
负责读取数据的线程,主要用于读取数据以及业务逻辑处理。 -
创建服务端辅助启动类
ServerBootstrap
,这个类将引导我们进行服务端的启动工作。 -
通过
group(boss, worker)
方法给辅助启动类ServerBootstrap
设置线程组。 -
通过
channel(NioServerSocketChannel.class)
指定IO模型为NIO,对应于JDK NIO类ServerSocketChannel
。 -
通过
option(ChannelOption.SO_BACKLOG, 1024)
方法设置TCP参数,连接请求的最大队列长度为1024。 -
通过
childHandler(ChannelHandler childHandler)
方法设置I/O时间,用来处理消息的编解码已经业务逻辑(具体都在实现类FirstServerHandler
中,就不再赘述了)。 -
通过
bind(8080)
方法绑定服务端口8080。
到此为止我们一个最简单的Netty服务端代码也就完成了。
客户端
客户端代码与服务端代码相似,具体如下:
public class NettyClient {
public static void main(String[] args) {
Bootstrap b = new Bootstrap();
EventLoopGroup group = new NioEventLoopGroup();
b.group(group)
.channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true)
.handler(new ChannelInitializer<Channel>() {
@Override
protected void initChannel(Channel ch) throws Exception {
ch.pipeline().addLast(new FirstClientHandler());
}
});
b.connect("127.0.0.1", 8080);
}
private static class FirstClientHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
System.out.println(new Date() + " 客户端数据");
ByteBuf byteBuf = getByteBuf(ctx);
ctx.channel().writeAndFlush(byteBuf);
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf byteBuf = (ByteBuf) msg;
System.out.println(new Date() + "客户端接收数据->" + byteBuf.toString(Charset.forName("UTF-8")));
}
private ByteBuf getByteBuf(ChannelHandlerContext ctx) {
// 获取二进制抽象
ByteBuf buffer = ctx.alloc().buffer();
// 准备数据
byte[] bytes = "你好,我是客户端".getBytes(Charset.forName("UTF-8"));
// 填充数据到ByteBuf
buffer.writeBytes(bytes);
return buffer;
}
}
}
- 与服务端一样,我们还是要创建一个
NioEventLoopGroup
用于管理用于客户端处理I/O读写的NIO线程,然后也是创建一个辅助启动类并将线程组添加到启动类,但是这里的启动类是Bootstrap
而不再是ServerBootstrap
。 - 指定I/O模型为
NioSocketChannel
,对应JDK NIO中SocketChannel类。 - 设置业务处理handler。
- 通过
connect
方法发起异步连接。
这样分别启动客户端和服务端代码就能建立通信了。
粘包半包问题
我们对上面Client业务处理类FirstClientHandler
稍作改动,如下所示:
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
System.out.println(new Date() + " 客户端数据");
for (int i = 0; i < 1000; i++) {
ByteBuf byteBuf = getByteBuf(ctx);
ctx.channel().writeAndFlush(byteBuf);
}
}
与客户端建连成功后循环1000次发送数据,可以看到如下现象:
这就是TCP传输过程中出现的粘包/半包问题导致的现象。
为什么会出现粘包/半包的现象呢?
TCP传输数据按照字节包数据流的形式进行传输,就像流水一样连在一起,TCP底层无法获知业务数据的具体含义,无法按照业务含义进行分包,只会按照TCP缓冲区的实际情况进行包的划分,业务数据被分拆为多个数据包,这些数据包到达目的地有以下三种情况:
-
按照业务数据本身的边界组个到达目的地。
-
多个业务数据组合成一个数据包到达目的地,这种即为粘包问题。
- 到达目的地的数据包中只包含部分业务数据,这种即为半包问题。
Netty传输数据我们一般采用的是TCP/IP协议,也会出现上述的粘包与半包问题。下面将介绍如何解决这个问题。
如何解决粘包/半包问题呢?
其实本质就是区分业务数据的边界,长度、特殊字符等,只要能够区分,那么我们就能够判断需要是否是一个完整的数据包。
Netty提供了如下几种自带的拆包器:
- DelimiterBasedFrameDecoder:利用特殊分隔符作为消息的结束标志。
- LineBasedFrameDecoder:组合一换行符作为消息的结束标志。
- FixedLengthFrameDecoder:按照固定长度获取消息。
- LengthFieldBasedFrameDecoder:基于长度域拆包器。
当然除此之外,你还可以通过继承ByteToMessageDecoder
类的方式自定义拆包器。在自定义拆包器中你可以根据自己定义的协议来进行消息的拆包,如果非本协议协议还可以进行拒绝。
小结
介绍了Netty的使用和其粘包半包问题,下面我们将利用它来实现分布式服务的底层信息。