分布式服务框架-底层通信(1)

376 阅读7分钟

这是我参与8月更文挑战的第21天,活动详情查看: 8月更文挑战

本文使用源码地址:simple-rpc

通信本质是I/O,但是如果我们从I/O开始说起,那内容就太多了,光I/O模型就可以写好多篇。所以有兴趣的可以自己再去翻看一下。我们只简单回顾一下四个概念:

  • 阻塞:调用方发起调用请求,在没有返回结果之前,调用方线程被挂起,处于一直等待的状态。
  • 同步:发出一个请求时,在没有得到结果之前,该调用就不返回。
  • 非阻塞:与阻塞相反,调用方发起调用请求,当前线程不会被挂起,而会立刻返回,后续可以通过轮询等手段获取调用结果。
  • 异步:异步与同步想对。当一个异步过程调用发出后,调用者不会立即获得结果,后续可以通过回调等方式获取调用结果。

我故意将阻塞和同步、非阻塞和异步放在一起,看起来它们很相似的概念,其实是关注点不同,同步和异步关注的是消息的通信机制,而阻塞和非阻塞关注的是等待消息时程序的状态

构建分布式服务框架底层通信基础

在此之前先了解下在simple-rpc中承担底层通信的主角:netty。推荐大家两本书《netty 权威指南》和一本掘进的小册《Netty 入门与实战:仿写微信 IM 即时通讯系统》,对于阅读下面的代码来说已经绰绰有余了。

Netty Reactor 工作架构图.png

大致了解下Netty的基本概念:

  • Netty 抽象出两组线程池BossGroupWorkerGroupBossGroup专门负责接收客户端的连接, WorkerGroup专门负责网络的读写。

  • BossGroupWorkerGroup类型都是NioEventLoopGroup

  • NioEventLoopGroup 相当于一个事件循环线程组, 这个组中含有多个事件循环线程 , 每一个事件循环线程是NioEventLoop

  • 每个NioEventLoop都有一个selector , 用于监听注册在其上的SocketChannel的网络通讯。

  • 每个Boss NioEventLoop线程内部循环执行的步骤有 3 步:

    1. 处理accept事件 , 与client 建立连接 , 生成 NioSocketChannel
    2. NioSocketChannel注册到某个worker NIOEventLoop上的selector
    3. 处理任务队列的任务 , 即runAllTasks。
  • 每个worker NIOEventLoop线程循环执行的步骤:

  1. 轮询注册到自己selector上的所有NioSocketChannel 的read, write事件。
  2. 处理 I/O 事件, 即read , write 事件, 在对应NioSocketChannel 处理业务。
  3. 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;
        }
    }
}
  1. 创建两个EventLoopGroup实例:bossGroupworkerGroupbossGroup用于接受新连接线程,主要负责创建新连接,workGroup负责读取数据的线程,主要用于读取数据以及业务逻辑处理。

  2. 创建服务端辅助启动类ServerBootstrap,这个类将引导我们进行服务端的启动工作。

  3. 通过group(boss, worker)方法给辅助启动类ServerBootstrap设置线程组。

  4. 通过channel(NioServerSocketChannel.class)指定IO模型为NIO,对应于JDK NIO类ServerSocketChannel

  5. 通过option(ChannelOption.SO_BACKLOG, 1024)方法设置TCP参数,连接请求的最大队列长度为1024。

  6. 通过childHandler(ChannelHandler childHandler)方法设置I/O时间,用来处理消息的编解码已经业务逻辑(具体都在实现类FirstServerHandler中,就不再赘述了)。

  7. 通过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;
        }
    }
}
  1. 与服务端一样,我们还是要创建一个NioEventLoopGroup用于管理用于客户端处理I/O读写的NIO线程,然后也是创建一个辅助启动类并将线程组添加到启动类,但是这里的启动类是Bootstrap而不再是ServerBootstrap
  2. 指定I/O模型为NioSocketChannel,对应JDK NIO中SocketChannel类。
  3. 设置业务处理handler。
  4. 通过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次发送数据,可以看到如下现象:

半包与粘包.png

这就是TCP传输过程中出现的粘包/半包问题导致的现象。

为什么会出现粘包/半包的现象呢?

TCP传输数据按照字节包数据流的形式进行传输,就像流水一样连在一起,TCP底层无法获知业务数据的具体含义,无法按照业务含义进行分包,只会按照TCP缓冲区的实际情况进行包的划分,业务数据被分拆为多个数据包,这些数据包到达目的地有以下三种情况:

  1. 按照业务数据本身的边界组个到达目的地。

半包粘包-1.png

  1. 多个业务数据组合成一个数据包到达目的地,这种即为粘包问题。

半包粘包-2.png

  1. 到达目的地的数据包中只包含部分业务数据,这种即为半包问题。

半包粘包-3.png

Netty传输数据我们一般采用的是TCP/IP协议,也会出现上述的粘包与半包问题。下面将介绍如何解决这个问题。

如何解决粘包/半包问题呢?

其实本质就是区分业务数据的边界,长度、特殊字符等,只要能够区分,那么我们就能够判断需要是否是一个完整的数据包。

Netty提供了如下几种自带的拆包器:

  • DelimiterBasedFrameDecoder:利用特殊分隔符作为消息的结束标志。
  • LineBasedFrameDecoder:组合一换行符作为消息的结束标志。
  • FixedLengthFrameDecoder:按照固定长度获取消息。
  • LengthFieldBasedFrameDecoder:基于长度域拆包器。

当然除此之外,你还可以通过继承ByteToMessageDecoder类的方式自定义拆包器。在自定义拆包器中你可以根据自己定义的协议来进行消息的拆包,如果非本协议协议还可以进行拒绝。

小结

介绍了Netty的使用和其粘包半包问题,下面我们将利用它来实现分布式服务的底层信息。