Netty&即时通讯案例XIM

1 TCP基础

在介绍Netty高性能通信框架之前,先回顾一下TCP的基本理论。

1.1 Socket基本概念

  • Socket又称套接字,应用程序通常通过套接字向网络发出请求或者应答网络请求Socket、ServerSocket类库位于java.net包中。ServerSocket用于服务器端,Socket是建立网络连接时使用的。在连接成功时,应用程序两端都会产生一个Socket实例,操作这个实例,完成所需的会话。对于一个网络连接来说,套接字是平等的,不因为在服务器端或在客户端而产生不同级别。不管是Socket还是ServerSocket它们的工作都是通过SocketImpl类及其子类完成的。

  • 套接字之间的连接过程可以分为四个步骤:

服务器监听;客户端请求服务器;服务器确认;客户端确认,进行通信。

1.2 三次握手,四次挥手

什么是三次握手,四次挥手(对于建立连接和断开连接),为什么需要三次握手,为什么需要四次挥手(这些次数是怎么来的)?如下图,在建立连接的时候进行3次握手:
Netty&即时通讯案例XIM_第1张图片

  • 同理,我们可以推理出四次挥手的基本原理,如下表格所示:
    Netty&即时通讯案例XIM_第2张图片

  • 总结:无论三次握手还是四次挥手,都是来校验对方数据包的 “收发能力”!

2 Netty简介

2.1 介绍

  1. Netty最初由JBoss开发,现在由Netty项目社区开发和维护的一个 Java 开源框架,现为 Github上的独立项目,目前最新版本4.1.70;

  2. Netty 是一个异步的、基于事件驱动的网络应用框架,用以快速开发高性能、高可靠性的网络 IO 程序;

  3. Netty主要针对在TCP协议下,面向Clients端的高并发应用,或者Peer-to-Peer场景下的大量数据持续传输的应用;

  4. Netty本质是一个NIO框架,适用于服务器通讯相关的多种应用场景;

往通俗了讲,可以将Netty理解为:一个将Java NIO进行了大量封装,并大大降低Java NIO使用难度和上手门槛的超牛逼框架。

  1. 要理解Netty , 需要先学习 NIO , 这样我们才能阅读 Netty 的源码。

2.2 应用场景

2.2.1 互联行业

互联网行业:在分布式系统中,各个节点之间需要远程服务调用,高性能的 RPC 框架必不可少,Netty作为异步高性能的通信框架,往往作为基础通信组件被这些 RPC 框架使用。

典型的应用有:阿里分布式服务框架Dubbo的RPC框架使用Dubbo协议进行节点间通信,Dubbo 协议默认使用Netty 作为基础通信组件,用于实现各 进程节点之间的内部通信

2.2.2 游戏行业

  1. 无论是手游服务端还是大型的网络游戏,Java语言得到了越来越广泛的应用。

  2. Netty 作为高性能的基础通信组件,提供了 TCP/UDP 和 HTTP 协议栈,方便定制和开发私有协议栈,账号登录服务器。

  3. 地图服务器之间可以方便的通过 Netty进行高性能的通信。

2.2.3 大数据行业

经典的 Hadoop 的高性能通信和序列化组件 Avro 的 RPC 框架,默认采用 Netty 进行跨界点通信。

它的 Netty Service 基于 Netty 框架二次封装实现。

2.2.4 其它开源项目使用到Netty

相关项目:https://netty.io/wiki/related-projects.html

2.3 基本架构

官网只给了这么一张图片。

从这张架构图上,可以看到 Netty 的分层:

  • Core,核心层,主要定义一些基础设施,比如事件模型、通信 API、缓冲区等。

  • Transport Service,传输服务层,主要定义一些通信的底层能力,或者说是传输协议的支持,比如 TCP、UDP、HTTP 隧道、虚拟机管道等。

  • Protocol Support,协议支持层,这里的协议比较广泛,不仅仅指编解码协议,还可以是应用层协议的编解码,比如 HTTP、WebSocket、SSL、Protobuf、文本协议、二进制协议、压缩协议、大文件传输等,基本上主流的协议都支持。

3 急速入门

我们通过一个简单的Netty示例来演示服务器端和客户端之间的通信:

3.1 POM依赖

<dependency>
  <groupId>io.nettygroupId>
  <artifactId>netty-allartifactId>
  <version>4.1.70.Finalversion>
dependency>

3.2 Netty Server

Netty Server端需要编写Server和ServerHandler两个核心类:

  • NettyServer
package com.maya.netty.demo;import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;/**
 * NettyServer
 * description
 *
 * @author Tony
 * @version 1.0
 * @date 2021年12月01日
 */
public class NettyServer {public static void main(String[] args) throws Exception {//1. 创建BossGroup 和 WorkerGroup
        //说明
        //1.1 创建两个线程组 bossGroup 和 workerGroup
        //1.2 bossGroup 只是处理连接请求 , 真正的和客户端业务处理,会交给 workerGroup完成
        //1.3 两个都是无限循环
        //1.4 bossGroup 和 workerGroup 含有的子线程(NioEventLoop)的个数
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workGroup = new NioEventLoopGroup();try {
            //2. 通过辅助类去构造server/client
            ServerBootstrap b = new ServerBootstrap();//3. 进行Nio Server的基础配置//3.1 绑定两个线程组
            b.group(bossGroup, workGroup)
                    //3.2 因为是server端,所以需要配置NioServerSocketChannel
                    .channel(NioServerSocketChannel.class)
                    //3.3 设置链接超时时间
                    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
                    //3.4 设置TCP backlog参数 = sync队列 + accept队列
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    //3.5 设置配置项 通信不延迟
                    .childOption(ChannelOption.TCP_NODELAY, true)
                    //3.6 设置配置项 接收与发送缓存区大小
                    .childOption(ChannelOption.SO_RCVBUF, 1024 * 32)
                    .childOption(ChannelOption.SO_SNDBUF, 1024 * 32)
                    //3.7 进行初始化 ChannelInitializer , 用于构建双向链表 "pipeline" 添加业务handler处理
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                            //3.8 这里仅仅只是添加一个业务处理器:ServerHandler(后面我们要针对他进行编码)
                            ch.pipeline().addLast(new NettyServerHandler());
                        }
                    });//4. 服务器端绑定端口并启动服务;使用channel级别的监听close端口 阻塞的方式
            //绑定一个端口并且同步, 生成了一个 ChannelFuture 对象
            //启动服务器(并绑定端口)
            ChannelFuture cf = b.bind(6668).sync();
			//5. 对关闭通道进行监听
            cf.channel().closeFuture().sync();
        } finally {
            //6. 释放资源
            bossGroup.shutdownGracefully();
            workGroup.shutdownGracefully();
        }
    }
}
  • NettyServerHandler
package com.maya.netty.demo;import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.util.CharsetUtil;/**
 * NettyServerHandler
 * description
 *
 * @author Tony
 * @version 1.0
 * @date 2021年12月01日
 */
public class NettyServerHandler extends ChannelInboundHandlerAdapter {/**
     *  channelActive
     *  通道激活方法
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        System.err.println("server channel active..");
    }/**
     *  channelRead
     *  读写数据核心方法(读取数据,这里我们可以读取客户端发送的消息)
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {//1. 读取客户端的数据(缓存中去取并打印到控制台)
        ByteBuf buf = (ByteBuf) msg;
        byte[] request = new byte[buf.readableBytes()];
        buf.readBytes(request);
        String requestBody = new String(request, "utf-8");
        System.err.println("Server: " + requestBody);//2. 返回响应数据
        String responseBody = "返回响应数据," + requestBody;
        ctx.writeAndFlush(Unpooled.copiedBuffer(responseBody.getBytes()));
    }/**
     * 数据读取完毕
     */
    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
        //writeAndFlush 是 write + flush
        //将数据写入到缓存,并刷新
        //一般讲,我们对这个发送的数据进行编码
        ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~服务端读取数据完毕", CharsetUtil.UTF_8));
    }/**
     *  exceptionCaught
     *  捕获异常方法(处理异常, 一般是需要关闭通道)
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        ctx.fireExceptionCaught(cause);
        ctx.close();
    }
}

3.3 Netty Client

Netty Client端需要编写Client和ClientHandler两个核心类:

  • NettyClient
package com.maya.netty.demo;import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;/**
 * NettyClient
 * description
 *
 * @author Tony
 * @version 1.0
 * @date 2021年12月01日
 */
public class NettyClient {public static void main(String[] args) throws Exception {//1. 客户端只需要一个线程组,用于我们的实际处理(网络通信的读写)
        EventLoopGroup workGroup = new NioEventLoopGroup();try {
            //2. 通过辅助类去构造client,然后进行配置响应的配置参数
            //创建客户端启动对象
            //注意客户端使用的不是 ServerBootstrap 而是 Bootstrap
            Bootstrap b = new Bootstrap();
            b.group(workGroup)
                    .channel(NioSocketChannel.class)
                    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
                    .option(ChannelOption.SO_RCVBUF, 1024 * 32)
                    .option(ChannelOption.SO_SNDBUF, 1024 * 32)
                    //3. 初始化ChannelInitializer
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                            //3.1  添加客户端业务处理类
                            ch.pipeline().addLast(new NettyClientHandler());
                        }
                    });
            //4. 服务器端绑定端口并启动服务; 使用channel级别的监听close端口 阻塞的方式
            ChannelFuture cf = b.connect("127.0.0.1", 6668).syncUninterruptibly();//5. 发送一条数据到服务器端
            cf.channel().writeAndFlush(Unpooled.copiedBuffer("hello netty!".getBytes()));//6. 休眠一秒钟后再发送一条数据到服务端
            Thread.sleep(1000);
            cf.channel().writeAndFlush(Unpooled.copiedBuffer("hello netty again!".getBytes()));//7. 对关闭通道进行监听
            cf.channel().closeFuture().sync();
        } finally {
            //8. 释放资源
            workGroup.shutdownGracefully();
        }
    }
}
  • NettyClientHandler
package com.maya.netty.demo;import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.util.CharsetUtil;/**
 * NettyClientHandler
 * description
 *
 * @author Tony
 * @version 1.0
 * @date 2021年12月01日
 */
public class NettyClientHandler extends ChannelInboundHandlerAdapter {
    /**
     *  channelActive
     *  客户端通道激活(当通道就绪就会触发该方法)
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("client " + ctx);
        ctx.writeAndFlush(Unpooled.copiedBuffer("hello, server~", CharsetUtil.UTF_8));
    }/**
     *  channelRead
     *  真正的数据最终会走到这个方法进行处理(当通道有读取事件时,会触发,整个Netty是基于事件驱动的)
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        ByteBuf buf = (ByteBuf) msg;
        System.out.println("服务器回复的消息:" + buf.toString(CharsetUtil.UTF_8));
        System.out.println("服务器的地址: "+ ctx.channel().remoteAddress());
    }/**
     *  exceptionCaught
     *  异常捕获方法
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        ctx.fireExceptionCaught(cause);
        ctx.close();
    }
}

4 核心概念和基本源码查看

4.1 经典的三种 I/O 模式

  1. I/O 模型简单的理解:就是用什么样的通道进行数据的发送和接收,很大程度上决定了程序通信的性能。

  2. Java共支持三种经典的网络编程模型/IO模式:BIO、NIO、AIO。

4.1.1 Java BIO

同步并阻塞(传统阻塞型),服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成 不必要的线程开销。

概述:

Java BIO 就是传统的java io编程,其相关的类和接口在 java.io ;BIO(blocking I/O) : 同步阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以通过线程池机制改善(实现多个客户连接服务器);
BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,程序简单易理解

问题分析:

每个请求都需要创建独立的线程,与对应的客户端进行数据Read,业务处理,数据 Write;当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大;连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 Read 操作上,造成线程资源浪费。

package com.maya.netty.bio;import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;/**
 * BIOServer
 * description
 *
 * @author Tony
 * @version 1.0
 * @date 2021年12月01日
 */
public class BIOServer {public static void main(String[] args) throws IOException {
        // 启动服务端,绑定8001端口
        ServerSocket serverSocket = new ServerSocket(8001);System.out.println("server start");while (true) {
            // 开始接受客户端连接,监听连接,阻塞等待连接....
            Socket socket = serverSocket.accept();System.out.println("one client conn: " + socket);
            // 启动线程处理连接数据
            new Thread(()->{
                try {
                    // 读取数据
                    BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                    String msg;
                    while ((msg = reader.readLine()) != null) {
                        System.out.println("receive msg: " + msg);
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

首先,我们使用 ServerSocket serverSocket = new ServerSocket(8001); 启动了一个服务端,这时候服务端可以接收连接了吗?当然不能,就像路边摊虽然摆好了,但还没开始营业一样。

其次,声明了一个死循环 while (true),为什么要声明为死循环呢?因为服务端跟客户端是一对多的关系,可能会有多个客户端连接到服务端,对于每一个连接过来的客户端,服务端都要去 “接待”,就像路边摊的老板,对于每一个顾客他都要亲自接待一样,如果没有死循环,那么老板自始至终只能接待一个顾客。

再次,我们使用 Socket socket = serverSocket.accept(); 接受客户端的连接,并把这个连接(Socket)保存下来,用于后续读取数据。

接着,我们启动了一个线程来处理这个连接,为什么要启动线程呢?如果不启动线程,那么我们只能把处理连接的数据放在主线程中,这时候主线程只能处理当前连接的这一个客户端,而不能同时处理多个客户端。

4.1.2 Java NIO

Java NIO : 同步非阻塞,服务器实现模式为一个线程处理多个请求(连接),即客户端发送的连接请求都会注册到多路复用器上(Selector选择器),多路复用器轮询到连接有I/O请求就进行处理。

概述:

Java NIO 全称 java non-blocking IO,是指 JDK 提供的新API。从 JDK1.4 开始,Java 提供了一系列改进的输入/输出的新特性,被统称为 NIO(即 New IO),是同步非阻塞的;NIO 相关类都被放在 java.nio 包及子包下,并且对原 java.io包中的很多类进行改写;NIO 有三大核心部分Channel(通道)Buffer(缓冲区)Selector(选择器)

NIO是面向缓冲区,或者面向块编程的。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞式的高伸缩性网络。Java NIO的非阻塞模式,使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情;
通俗理解:NIO是可以做到用一个线程来处理多个操作的。假设有10000个请求过来, 根据实际情况,可以分配50或者100个线程来处理。不像之前的阻塞IO那样,非得分配10000个;HTTP2.0使用了多路复用的技术,做到同一个连接并发处理多个请求,而且并发请求的数量比HTTP1.1大了好几个数量级。

Selector Channel Buffer 的关系图

  1. 每个channel 都会对应一个Buffer;

  2. Selector 对应一个线程, 一个线程对应多个Channel(可以理解为一个连接);

  3. 该图反应了有三个channel 注册到该selector;

  4. 程序切换到哪个channel 是有事件决定的, Event 就是一个重要的概念;

  5. Selector 会根据不同的事件,在各个通道上切换;

  6. Buffer 就是一个内存块 , 底层是有一个数组;

  7. 数据的读取写入是通过Buffer, 这个和BIO , BIO 中要么是输入流,或者是输出流, 不能双向,但是NIO的Buffer 是可以读也可以写(需要 flip 方法切换);

  8. channel 是双向的, 可以返回底层操作系统的情况, 比如Linux,底层的操作系统通道就是双向的。

package com.maya.netty.nio;import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;/**
 * NIOServer
 * description
 *
 * @author Tony
 * @version 1.0
 * @date 2021年12月01日
 */
public class NIOServer {public static void main(String[] args) throws IOException {
        // 创建一个Selector (充当 IO 多路复用中的选择器,类似于饭店中的美女服务员)
        Selector selector = Selector.open();
        // 创建ServerSocketChannel
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        // 绑定8080端口
        serverSocketChannel.bind(new InetSocketAddress(8002));
        // 设置为非阻塞模式
        serverSocketChannel.configureBlocking(false);
        // 将Channel注册到selector上,并注册Accept事件
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);System.out.println("server start");while (true) {
            // 阻塞在select上(第一阶段阻塞)
            selector.select();// 如果使用的是select(timeout)或selectNow()需要判断返回值是否大于0// 有就绪的Channel
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            // 遍历selectKeys
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey selectionKey = iterator.next();
                // 如果是accept事件
                if (selectionKey.isAcceptable()) {
                    // 强制转换为ServerSocketChannel
                    ServerSocketChannel ssc = (ServerSocketChannel) selectionKey.channel();
                    SocketChannel socketChannel = ssc.accept();
                    System.out.println("accept new conn: " + socketChannel.getRemoteAddress());
                    socketChannel.configureBlocking(false);
                    // 将SocketChannel注册到Selector上,并注册读事件
                    socketChannel.register(selector, SelectionKey.OP_READ);
                } else if (selectionKey.isReadable()) {
                    // 如果是读取事件
                    // 强制转换为SocketChannel
                    SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
                    // 创建Buffer用于读取数据
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    // 将数据读入到buffer中(第二阶段阻塞)
                    int length = socketChannel.read(buffer);
                    if (length > 0) {
                        buffer.flip();
                        byte[] bytes = new byte[buffer.remaining()];
                        // 将数据读入到byte数组中
                        buffer.get(bytes);// 换行符会跟着消息一起传过来
                        String content = new String(bytes, "UTF-8").replace("\r\n", "");
                        System.out.println("receive msg: " + content);
                    }
                }
                iterator.remove();
            }
        }
    }
}

首先,创建了一个 Selector,充当 IO 多路复用中的选择器,类似于饭店中的美女服务员。

其次,我们启动了一个 ServerSocketChannel,并设置其为非阻塞模式,与 BIO 中的 ServerSocket 类似,是服务端进程。

再次,把 ServerSocketChannel 注册到 Selector 上,相当于是告诉服务员等会记得帮我去后厨看看我的菜好了没。

然后,又来一个死循环,这个死循环跟 BIO 中的死循环不一样,这里的死循环是让 Selector 不要停,一次又一次地轮询下去,因为你的菜好了,还会有更多的人让这个服务员去询问他们的菜好了没。

接着,Selector 轮询完一次之后会拿到一系列 Key,这些 Key 叫作 SelectionKey,每个 SelectionKey 里面都绑定了一个数据准备好了的 Channel,通过这个 Channel 我们就可以去读取数据了。

最后,遍历这些 SelectionKey,取出其中的 Channel,再根据不同的事件类型用 Channel 去读取数据并打印出来,就像服务员拿到了准备好了菜的顾客号码,通知他们去聚餐一样。

原生NIO存在的问题

  1. NIO 的类库和 API 繁杂,使用麻烦:需要熟练掌握 Selector、ServerSocketChannel、 SocketChannel、ByteBuffer 等。
  2. 需要具备其他的额外技能:要熟悉 Java 多线程编程,因为 NIO 编程涉及到 Reactor模式,你必须对多线程和网络编程非常熟悉,才能编写出高质量的 NIO 程序。
  3. 开发工作量和难度都非常大:例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常流的处理等等。
  4. JDK NIO 的 Bug:例如臭名昭著的 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU 100%,一直未根除此问题。

4.1.3 Java AIO(NIO.2)

Java AIO(NIO.2) : 异步非阻塞,AIO 引入异步通道的概念,采用了Proactor模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用。

JDK 7 引入了 Asynchronous I/O,即 AIO。在进行 I/O 编程中,常用到两种模式:Reactor和 Proactor。Java 的 NIO 就是 Reactor,当有事件触发时,服务器端得到通知,进行相应的处理;AIO 即 NIO2.0,叫做异步不阻塞的 IO。
AIO 引入异步通道的概念,采用了Proactor 模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用;
目前 AIO 还没有广泛应用,Netty 也是基于NIO, 而不是AIO, 因此在此就不详解AIO;目前作为广大服务器使用的系统 linux 对 AIO 的支持还不完善,导致我们还不能放心地使用 AIO 这项技术,不过,我相信有一天 AIO 会成为那颗闪亮的星的,现阶段,还是以学好 NIO 为主。

4.2 Reactor反应器模式

4.2.1 概述

关于 Reactor 模式的定义,让我们看看维基百科怎么说:

The reactor design pattern is an event handling pattern for handling service requests delivered concurrently to a service handler by one or more inputs. The service handler then demultiplexes the incoming requests and dispatches them synchronously to the associated request handlers.

从这段定义中,我们能得出以下几个信息:

  1. Reactor 模式是一种事件处理模式

  2. 用于处理服务请求,把它们并发地传递给一个服务处理器(service handler);

  3. 有一个或多个输入源(inputs);

  4. 服务处理器将这些请求以多路复用的方式分离(demultiplexes ),并把它们同步地分发到相关的请求处理器(request handlers);

总结一下,Reactor 模式包含一个或多个输入源,一个 service handler,多个 request handler,service handler 是输入源和 request handler 之间的桥梁,用于分发输入的请求,如下图所示:

为了方便描述,把 input 叫作事件,service handler 叫作事件分离器,request handler 叫作事件处理器。

举个形象的前端例子,类似下面的代码:

txt.onblur(function() {
    if(!content) {
        alert("请您输入用户名");
    }
});
btn.onclick(function() {
    alert("登录成功");
});

登录页面,用户名输入框失去焦点时如果为空则提示 “请您输入用户名”,点击按钮的时候弹出对话框显示 “登录成功”,类似于下面这张图:

这就是典型的事件驱动模型,事件即网页上的各种事件,比如按钮点击事件、失去焦点事件、鼠标右击事件等等,事件处理器即我们编写的回调函数,即上面代码中括号中的 function,事件分离器即 Javascript 内部根据不同的事件分发到不同的回调的处理器。

4.2.2 网络请求的处理过程

一般地,网络请求都要经过以下几个处理过程:

  • Read request,读取请求

  • Decode request,解码请求

  • Process service,处理业务

  • Encode reply,编码响应

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3MhLsKdb-1641354270351)(https://tcs.teambition.net/storage/312c71a29020e27375b0d4b90a152417e118?Signature=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJBcHBJRCI6IjU5Mzc3MGZmODM5NjMyMDAyZTAzNThmMSIsIl9hcHBJZCI6IjU5Mzc3MGZmODM5NjMyMDAyZTAzNThmMSIsIl9vcmdhbml6YXRpb25JZCI6IiIsImV4cCI6MTY0MTUzOTE5NywiaWF0IjoxNjQwOTM0Mzk3LCJyZXNvdXJjZSI6Ii9zdG9yYWdlLzMxMmM3MWEyOTAyMGUyNzM3NWIwZDRiOTBhMTUyNDE3ZTExOCJ9.sSJUb9LupWYklyBc7njo4Yft3lMVx5IzJZEfBoaW5Q0&download=image-20211212194709491.png "style=“zoom:80%;”)]

传统的服务设计

基于以上处理过程,传统的服务设计:

每次来一个客户端都启动一个新的线程来处理,每一个 handler 都在它自己的线程中。很像前面描述BIO 的模型,它们是相通的。

使用这种服务设计自然有它的优点:
1)编码简单;
2)每一个 handler 都在自己的线程中,不存在线程切换的问题,不需要考虑线程安全的问题;
但是,随着服务请求量越来越大,启动的线程数量会越来越多,最后,会导致服务端的线程无限增多,然而,其实大部分的线程可能都处于 IO 阻塞状态,并没有使用到 CPU,无法充分利用 CPU。

那么,如何改进?采用基于事件驱动的设计,当有事件触发时,才调用相应的处理器来处理事件。

4.2.3 单Reactor单线程模式

Reactor模型的朴素原型Java的NIO模式的Selector网络通讯,其实就是一个简单的Reactor模型。可以说是Reactor模型的朴素原型。

实际上的Reactor模式,是基于Java NIO的,在他的基础上,抽象出来两个组件——Reactor和Handler两个组件:
• Reactor:负责响应IO事件,当检测到一个新的事件,将其发送给相应的Handler去处理;新的事件包含连接建立就绪、读就绪、写就绪等。
• Handler:将自身(handler)与事件绑定,负责事件的处理,完成channel的读入,完成处理业务逻辑后,负责将结果写出channel。

单Reactor单线程模式,就像一个饭店只有老板一个人一样,既要负责接待客人,又要当厨师,又要当服务员,一个人干所有的事,效率势必非常低下;这里的Reactor类似上面说的ServiceHandler,使用一个线程就可以处理大量的事件。

在服务端,对于网络请求有三种不同的事件:Accept 事件(建立连接)、Read 事件、Write 事件,对应于上图中的 acceptor、read、send。

Connect 事件属于客户端事件。

为什么 acceptor(Accept 事件处理器)是双向箭头,而 read 和 send 是单向箭头呢?因为服务端启动的时候是先注册 Accept 事件到 Reactor 上,当收到客户端连接时,也就是 Accept 事件时,才会注册 Read 和 Write 事件,所以 acceptor 是双向的,Reactor 不仅要向 acceptor 分发 Accept 事件,acceptor 也要向 Reactor 注册 Read 和 Write 事件。

一个 Reactor(Reactor 对象通过 Select 监控客户端请求件,收到事件后通过 Dispatch 进行分发) 就相当于一个事件分离器,而单线程模式下,所有客户端的所有事件都在一个线程中完成,这就出现了一个新的问题,如果哪个请求有阻塞,直接影响了所有请求的处理速度,所以,自然而然就进化出了 Reactor 的多线程模式。

优点:模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成
缺点:性能问题,只有一个线程,无法完全发挥多核 CPU 的性能。Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈
缺点:可靠性问题,线程意外终止,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障
使用场景:客户端的数量有限,业务处理非常快速,比如 Redis在业务处理的时间复杂度 O(1) 的情况

4.2.4 单Reactor多线程模式

单Reactor 多线程模式,还是把 IO 事件放在 Reactor 线程中来处理,同时,把业务处理逻辑放到单独的线程池中来处理,这个线程池我们称为工作线程池(Worker Thread Pool)或者业务线程池。

此时,如果业务处理逻辑中有 IO 阻塞,则不会影响其它请求的处理,能很大程度提高系统的并发量。

Reactor 多线程模式,就像饭店中老板只负责主要事务,比如,接待客人、接收客人的下单请求等,具体的事务交给服务员去处理。

但是,这种模式还不够完美,一个客户端连接过程需要三次握手,是一个比较耗时的操作,将 Accept 事件和 Read 事件与 Write 事件放在一个 Reactor 中来处理,明显降低了 Read 和 Write 事件的响应速度。而且,一个 Reactor 只有一个线程,也无法利用多核 CPU 的性能提升。因此,又自然而然的出现了 Reactor 主从模式。

4.2.5 Reactor主从模式

Reactor 主从模式把** Accept 事件**的处理单独拿出来放到主 Reactor 中来处理,把 Read 和 Write 事件放到子 Reactor 中来处理,而且,像这样的子 Reactor 我们可以启动多个,充分利用多核 CPU 的资源。

Reactor 主从模式,就像饭店中的老板只负责客人接待这一件事,其它事务全部交给服务员来处理,而且服务员也可以按区域划分,比如 1 号服务员负责 1 到 5 号包厢,2 号服务员负责 6 到 10 号包厢,极大地提高了效率。

在 Reactor 主从模式中,依然把业务逻辑的处理放到业务线程池中来处理。

4.2.6 变异的 Reactor 模式

基于主从模式可以有很多种变异的模式,比如使用子 Reactor 线程池来处理业务逻辑。

正常情况下,在 Netty 中,我们也是这么使用的,当然,依据不同的业务场景也可以有不同的变异。

如果说,正常的 Reactor 主从模式下,一批服务员负责不同包厢的下单请求(多个子 Reactor),另外一批服务员负责包厢的其它事务,比如上菜、端茶、倒水(业务线程池)。那么,变异的 Reactor 主从模式下,就是一个服务员负责几个包厢的所有事务,不管下单请求,还是上菜、端茶、倒水,另一个服务员再负责另几个包厢的所有事务,海底捞貌似就是这种变异的 Reactor 模式。

4.2.7 Netty 中使用 Reactor 的不同模式

4.2.7.1 Reactor 单线程模式的使用

Reactor 单线程模式,只有一个 Reactor,也就是一个线程处理所有事务,所以,在 Netty 中,只需要声明一个 EventLoopGroup 就可以了。

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup); 

4.2.7.2 Reactor 多线程模式的使用

Reactor 多线程模式,实际上还是只有一个 Reactor,但是这个 Reactor 只负责处理 IO 事件,而不负责处理业务逻辑,所以,在 Netty 中,需要将业务逻辑的处理,也就是 Handler,放到另外的线程池中。

EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 一个
ReactorServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup); // Handler使用线程池处理

4.2.7.3 Reactor 主从模式的使用

Reactor 主从模式,有一个主 Reactor 和多个子 Reactor,但是,业务逻辑的处理还是在线程池中,所以,在 Netty 中,需要声明两个不同的 EventLoopGroup,Handler 依然使用线程池处理。

EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 一个主
ReactorEventLoopGroup workerGroup = new NioEventLoopGroup(); // 多个子
ReactorServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup); // Handler使用线程池处理

4.2.7.4 Reactor 变异主从模式的使用

Reactor 变异主从模式,业务线程池和子 Reactor 池合并为一,所以,在 Netty 中,Handler 放在子 Reactor 池中处理即可,默认情况,Netty 也是使用的这种模式

EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 一个主
ReactorEventLoopGroup workerGroup = new NioEventLoopGroup(); // 多个子
ReactorServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup); 

看了这几种模式的使用,你可能会有个疑问:为什么只能有一个主 Reactor 呢?启动多个主 Reactor 可不可以呢?

答案是,可以,但没必要,因为底层的 Accept 事件的处理依然要排队处理,具体可以查看源码 sun.nio.ch.ServerSocketChannelImpl#accept():

public SocketChannel accept() throws IOException {        
	Object var1 = this.lock;        
	synchronized(this.lock) {            
			// 省略具体代码        
		}
	}

可以看到,accept () 方法中使用了一个 synchronized 锁来控制同时只能处理一个客户端的连接请求,使用一个线程来处理,相应地,还能减少线程的切换,提高一定的性能,有兴趣的同学,可以去查查 synchronized 的偏向锁、轻量级锁、重量级锁相关的内容。

4.2.8 Reactor 模式的优点和缺点

Reactor 并不是一剂万能药,所以我们有必要了解它的的优点和缺点,综合对比,我们才能决定要不要使用它。

首先,我们来看看它的优点,也是 Reactor 的主要卖点:

  1. 能够解耦模块,将 IO 操作与业务逻辑解耦;

  2. 能够提高并发量,充分利用 CPU 资源;

  3. 可扩展性好,简单地增加子 Reactor 的数量就能很好地扩展;

  4. 可复用性好,Reactor 框架本身不与具体的业务逻辑挂钩,复用性好,等等。

然而 ,同样地,它也有一些缺点

  1. 相比于传统的简单模式,Reactor 增加了一定复杂度,增加了学习成本、试错成本和调试成本;

  2. 需要编程语言支持事件分离器,比如 Java 中的 Selector,如果自己实现不现实;

  3. 多个客户端共用同一个 Reactor,如果有文件传输这种耗时的 IO 操作, 不适合使用 Reactor 模式;

Reactor 的几种模式以及它们在 Netty 中的使用,总结下来,我们在 Netty 中,一般使用变异的主从模式就够了,除非有比较耗时的 IO 阻塞,我们才需要使用主从模式那种更复杂的情形。Netty 本身默认使用的也是这种改进版的主从模式

4.3 Netty的核心组件

4.3.1 Bootstrap与ServerBootstrap

Bootstrap 与 ServerBootstrap 是 Netty 程序的引导类,主要用于配置各种参数,并启动整个 Netty 服务。它们俩都继承自 AbstractBootstrap 抽象类,不同的是,Bootstrap 用于客户端引导,而 ServerBootstrap 用于服务端引导

相对于 Bootstrap,ServerBootstrap 多了一个维度,用于处理 Accept 事件,所以它的很多方法都会多一份 childXxx(),比如,childHandler()childOption() 等。

常见的方法有

•public ServerBootstrap group(EventLoopGroup parentGroup, EventLoopGroup childGroup),该方法 用于服务器端,用来设置两个 EventLoop;
• public B group(EventLoopGroup group) ,该方法用于客户端,用来设置一个 EventLoop ;
• public B channel(Class channelClass),该方法用来设置一个服务器端的通道实现;
• public B option(ChannelOption option, T value),用来给 ServerChannel 添加配置;
• public ServerBootstrap childOption(ChannelOption childOption, T value),用来给接收到的通道添加配置;
• public ServerBootstrap childHandler(ChannelHandler childHandler),该方法用来设置业务处理类(自定义的 handler) ;
• public ChannelFuture bind(int inetPort) ,该方法用于服务器端,用来设置占用的端口号;
• public ChannelFuture connect(String inetHost, int inetPort) ,该方法用于客户端,用来连接服务器端。

4.3.2 EventLoopGroup和其实现类NioEventLoopGroup

EventLoopGroup 可以理解为一个线程池,对于服务端程序,我们一般会绑定两个线程池,一个用于处理 Accept 事件,一个用于处理读写事件。

EventLoopGroup 是一组 EventLoop 的抽象,Netty 为了更好的利用多核 CPU 资源,一般会有多个 EventLoop 同时工作,每个 EventLoop 维护着一个 Selector 实例。

EventLoopGroup 提供 next 接口,可以从组里面按照一定规则获取其中一个EventLoop来处理任务。在 Netty 服务器端编程中,我们一般都需要提供两个,EventLoopGroup,例如:BossEventLoopGroup 和 WorkerEventLoopGroup。

通常一个服务端口即一个 ServerSocketChannel对应一个Selector 和一个EventLoop线程。BossEventLoop 负责接收客户端的连接并将 SocketChannel交给WorkerEventLoopGroup 来进行 IO 处理,如下图所示:

• BossEventLoopGroup 通常是一个单线程的 EventLoop,EventLoop 维护着一 个注册了ServerSocketChannel的Selector 实例BossEventLoop 不断轮询Selector 将连接事件分离出来
• 通常是 OP_ACCEPT 事件,然后将接收到的 SocketChannel 交给WorkerEventLoopGroup
• WorkerEventLoopGroup 会由 next 选择其中一个 EventLoop来将这个SocketChannel 注册到其维护的 Selector 并对其后续的 IO 事件进行处理。

常见的方法有
• public NioEventLoopGroup(),构造方法
• public Future shutdownGracefully(),断开连接,关闭线程

4.3.3 EventLoop

EventLoop 可以理解为是 EventLoopGroup 中的工作线程,类似于 ThreadPoolExecutor 中的 Worker,但是,实际上,它并不是一个线程,它里面包含了一个线程,控制着这个线程的生命周期。

4.3.4 Channel

  • Netty 网络通信的组件,能够用于执行网络 I/O 操作。

  • 通过Channel 可获得当前网络连接的通道的状态

  • 通过Channel 可获得 网络连接的配置参数 (例如接收缓冲区大小)

  • Channel 提供异步的网络 I/O 操作(如建立连接,读写,绑定端口),异步调用意味着任何 I/O 调用都将立即返回,并且不保证在调用结束时所请求的 I/O 操作已完成。

  • 调用立即返回一个 ChannelFuture 实例,通过注册监听器到 ChannelFuture 上,可以I/O 操作成功、失败或取消时回调通知调用方

  • 支持关联 I/O 操作与对应的处理程序

  • 不同协议、不同的阻塞类型的连接都有不同的 Channel 类型与之对应,常用的Channel 类型:

• NioSocketChannel,异步的客户端 TCP Socket 连接。
• NioServerSocketChannel,异步的服务器端 TCP Socket 连接。
• NioDatagramChannel,异步的 UDP 连接。
• NioSctpChannel,异步的客户端 Sctp 连接。
• NioSctpServerChannel,异步的 Sctp 服务器端连接,这些通道涵盖了 UDP 和 TCP 网络 IO 以及文件 IO。

4.3.5 Selector

  • Netty 基于 Selector 对象实现 I/O 多路复用,通过 Selector 一个线程可以监听多个连接的 Channel 事件。

  • 当向一个 Selector 中注册 Channel 后,Selector 内部的机制就可以自动不断地查询(Select) 这些注册的 Channel 是否有已就绪的 I/O 事件(例如可读,可写,网络连接完成等),这样程序就可以很简单地使用一个线程高效地管理多个 Channel。

4.3.6 ChannelHandler

ChannelHandler 是核心业务处理接口,用于处理或拦截 IO 事件,并将其转发到 ChannelPipeline 中的下一个ChannelHandler,运用的是责任链设计模式。

ChannelHandler 分为入站和出站两种:ChannelInboundHandler 和 ChannelOutboundHandler,不过一般不建议直接实现这两个接口,而是它们的抽象类:

  • SimpleChannelInboundHandler:处理入站事件,不建议直接使用ChannelInboundHandlerAdapter

  • ChannelOutboundHandlerAdapter:处理出站事件

  • ChannelDuplexHandler:双向的

其中,SimpleChannelInboundHandler 相比于 ChannelInboundHandlerAdapter 优势更明显,它可以帮我们做资源的自动释放等操作。

4.3.7 ChannelHandlerContext

ChannelHandlerContext 保存着 Channel 的上下文,同时关联着一个 ChannelHandler,通过 ChannelHandlerContext,ChannelHandler 方能与 ChannelPipeline 或者其它 ChannelHandler 进行交互,ChannelHandlerContext 是它们之间的纽带。

4.3.8 Future、ChannelFuture

Netty 中所有的 IO 操作都是异步的,不能立刻得知消息是否被正确处理。但是可以过一会等它执行完成或者直接注册一个监听,具体的实现就是通过 Future 和 ChannelFutures,他们可以注册一个监听,当操作执行成功或失败时监听会自动触发注册的监听事件。

通过 ChannelFuture,可以查看 IO 操作是否已完成、是否成功、是否已取消等等。

常见的方法有
• Channel channel(),返回当前正在进行 IO 操作的通道
• ChannelFuture sync(),等待异步操作执行完毕

4.3.9 ChannelPipeline

ChannelPipeline 是 ChannelHandler 的集合,它负责处理和拦截入站和出站的事件和操作,每个 Channel 都有一个 ChannelPipeline 与之对应,会自动创建。

更确切地说,ChannelPipeline 中存储的是 ChannelHandlerContext 链,通过这个链把 ChannelHandler 连接起来,让我们仔细研究一下几者之间的关系:

  • 一个 Channel 对应一个 ChannelPipeline

  • 一个 ChannelPipeline 包含一条双向的 ChannelHandlerContext 链

  • 一个 ChannelHandlerContext 中包含一个 ChannelHandler

  • 一个 Channel 会绑定到一个 EventLoop 上

  • 一个 NioEventLoop 维护了一个 Selector(使用的是 Java 原生的 Selector)

  • 一个 NioEventLoop 相当于一个线程

通过以上分析,可以得出,ChannelPipeline、ChannelHandlerContext 都是线程安全的,因为同一个 Channel 的事件都会在一个线程中处理完毕(假设用户不自己启动线程)。但是,ChannelHandler 却不一定,ChannelHandler 类似于 Spring MVC 中的 Service 层,专门处理业务逻辑的地方,一个 ChannelHandler 实例可以供多个 Channel 使用,所以,不建议把有状态的变量放在 ChannelHandler 中,而是放在消息本身或者 ChannelHandlerContext 中。

4.3.10 ChannelOption

ChannelOption 严格来说不算是一种组件,它保存了很多我们拿来即用的参数,使用这些参数能够让我们以类型安全地方式来配置 Channel,比如,我们前面使用过的ChannelOption.SO_BACKLOG,Netty 还提供了很多这种类似的参数,使得我们能够以更精细地方式控制程序正确、正常、高性能地运行。

5 多端通讯实战-XIM

5.1 背景

XIM 一款IM(即时通讯)系统;预想实现以下基本功能:

  • IM 即时通讯系统。

  • 适用于 APP 的消息推送中间件。

5.2 服务端

5.3 移动端

实现私聊群聊登陆注册添加好友等功能;通过Demo演示AndroidiOS设备之间进行通信。

5.3.1 iOS端

5.3.2 Android端

5.4 Web端

暂无

6 总结

Netty的体系比较庞大,涉及的东西也很多,今天更多的是认识Netty,也希望对大家学习Netty有一定的帮助。

你可能感兴趣的:(java,中台,网络,java,网络协议,netty)