nio、netty

  • 0 Netty 简介
  • 1 BootStrap
  • 1.1 启动器 BootStrap 初步介绍
  • 1.2 BootStrap 执行流程
  • 2 Netty 入门
  • 2.1 服务端
  • 2.1.1 NettyServer 以及相关类
  • 2.2 客户端
  • 2.2.1 NettyClient 以及相关类
  • 2.3 通信协议
  • 2.3.1 codec 通信消息体
  • 2.4 消息分发
  • 2.5 NettyServerConfig 和 NettyClientConfig
  • 3 Netty 解决粘包和拆包问题
  • 3.1 粘包和拆包
  • 3.2 常见解决方案
  • 3.3 Netty 提供的粘包拆包解决方案
  • 4 认证逻辑等
  • 5. 单聊逻辑
  • 6. 群聊逻辑


最近学习了一下 netty,然后不知如何下手,就找了相关的资料查看。大体的学习路径如下所示:
先要了解学习下 nio,因为 netty 是在 nio 的基础上进行完善的可以看美团的这篇。nio 有 Channel(通道)、Buffer(缓冲区)、Mapped(内存映射文件)和 Selector(选择区)Socket这些点要清楚。之后就要学习 Netty 的相关组件了,Reactor 模型、BootStrap、EventLoopGroup、ChannelPipeline、Buffer、Codec 等。
乍一看有很多很多的东西,其实也的确不少,最起码也得先把 nio 过一遍,了解清楚了 nio 和 bio 的区别,以及同步异步、阻塞与分阻塞的区别。简单实现一下客户端和服务端的一个简单通话。接下来就可以入手学习 Netty 了。Netty 知识点过完后也要做一个入门的 IM 程序。在这个过程中,你就会发现,其实 NIO 和 Netty 的实现原理是一样的,只是 Netty 实现了自己的类,这些类和 NIO 有很多不一样的地方,但是代码实现的逻辑以及步骤几乎一致。

0 Netty 简介

Netty 是一个 Java 开源框架,提供了异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务器和客户端程序。也就是说,Netty 是一个基于 NIO 的客户、服务器端编程框架,使用 Netty 可以确保你快速和简单的开发出一个网络应用。
Netty 最重要的特点就是它的设计理念。Netty 的设计是为了让你从第一天起就能在 API 和实现方面获得最舒适的体验。

1 BootStrap

在学习 BootStrap 之前,先要熟悉几个重要概念:

  1. 父子 Channel:在 Netty 中, Channel 是一个 Socket 连接的抽象,它为用户提供了关于底层 Socket 状态(是否连接还是断开)以及对 Socket 的读写等操作。每当 Netty 建立了一个连接后,都会有一个对应的 Channel 实例。并且有父子 channel 的概念:服务器监听的 channel 也叫 parent channel,对应于每一个 socket 连接的 channel 也叫 child channel。
  2. EventLoop 线程与线程组:先了解一下 EventLoop:简单说,就是在程序中设置两个线程:一个负责程序本身的运行,称为"主线程";另一个负责主线程与其他进程(主要是各种I/O操作)的通信,被称为"Event Loop线程"(可以译为"消息线程")详情。在 Netty 中,每一个 Channel 绑定了一个 Thread 。一个 Thread ,封装到一个 EventLoop,多个 EventLoop,组成一个线程组 EventLoopGroup。(EventGroup 大小是 CPU 数量 * 2)
  3. 通道与 Reactor 线程组:这里主要涉及的是服务器端。服务器端,一般有设置两个线程组,监听连接的 parent channel 工作在一个独立的线程组,这个叫 boss 线程组。连接成功后,负责客户端连接读写的 child channel 是另一个线程组,这里名为 worker 线程组。
  4. Channel 通道的类型:除了 TCP 协议外,Netty 还支持很多其他的协议。主要是 NIOSocketChannel 和 NioServerSocketChannel。

1.1 启动器 BootStrap 初步介绍

BootStrap 是 Netty 提供的一个便利的工厂类,可以通过它来完成客户端或服务器端的 Netty 初始化。

且客户端和服务器端的启动类配置大致相同

java nio Netty 区别 nio和netty_后端

1.2 BootStrap 执行流程

首先创建一个引导器 ServerBootstrap 实例,直接 new 就行。

启动一个 BootStrap ,大致需要 8 步:设置 EventLoopGroup 线程组(reactor 线程组) --》设置 Channel 类型 --》设置监听端口 --》设置通道参数 --》装配 Handler 流水线 --》启动进行端口绑定 --》等待通道关闭 --》优雅关闭 EventLoopGroup。
除了没有关闭代码,和上面的代码一致。

2 Netty 入门

理解了上面的概念就可以入门做一个 Netty 项目了,分为三个小项目:一个服务端,一个客户端和一个基础封装(提供 Netty 的基础封装,提供消息的编解码和分发的功能)。
此外还包括一些 Netty 的常用功能:

  • 心跳机制:实现服务端对客户端的存活检测。
  • 断线连接:实现客户端对服务端的重新连接。

2.1 服务端

服务端的整体代码架构如下:

java nio Netty 区别 nio和netty_nio_02

2.1.1 NettyServer 以及相关类

Server 包下的 NettyServer 类代码如下,这是 服务端的核心代码:

java nio Netty 区别 nio和netty_nio_03

java nio Netty 区别 nio和netty_nio_04


java nio Netty 区别 nio和netty_java_05

Reactor 学习于这里 1、先熟悉一下 Reactor 是什么:reactor (反应器)设计模式是一个事件驱动(event handing)模式,用于处理由一个或多个输入事件(one or more inputs)同时传递(delivered concurrently)给服务处理程序(a service handler)的服务请求(handling service)。然后,服务处理程序对输入的请求进行解多路分解复用(demmultiplexes),并将它们同步分配给相关的请求处理程序。

通过以上的概念可以了解到 Reactor 的处理方式:同步地等待多个事件到达,并将事件多路分解以及分配相应的事件服务进行处理,这个分派采用 Server 集中分配处理,分解的事件以及对应的事件服务从分派服务中分离出去。

2、为何要用 Reactor:常见的网络服务中,如果每一个客户端都维持一个与登录服务器的连接,那么服务器将维持多个和不同客户端的连接以满足客户端的不同请求(connect、read、write)等,特别是对于长连接的服务,有多少个 c 端,就需要在 S 端维护同等的 I/O 连接,这对服务器来说是一个很大的开销。使用 NIO 解决了读写阻塞的问题、线程等待时间长和线程连接有限制的问题。而 Reactor 设计模式具有以下几点特征:更少的资源利用,通常不需要一个客户端一个线程;更少的开销,更少的上下文切换以及 locking;能够跟踪服务器状态;以及能够管理 handler 对 event 的绑定。

3、Netty 中服务端采用的是多 Reactor 多线程的模型,一般是两个,一个用来处理网络 I/O 连接建立,另一个用来处理建立起来的 Socket做数据交互和业务处理操作。而客户端是单 Reactor 多线程的模型。

Netty 采用的是多 Reactor 多线程的模型,服务端可以接收更多的客户端的数据读写。原因是:

  1. 创建专门用于接收客户端连接的 bossLoopGroup 线程组,避免因为已经连接的客户端的数据读写频繁,影响新的客户端的连接(就是这个线程组只管连接,而不操心读写,读写都交给 worker 线程组)。
  2. 创建专门用于接收客户端读写的 workerLoopGroup 线程组,多个线程进行客户端的数据读写,可以支持更多客户端。

start()方法中,我们创建了 ServerBootStrap 类,有了之前的基础在看这个就不陌生了。初始化属性与之前无异。

  • option(ChannelOption.SO_BACKLOG, 1024)方法,设置服务端接受客户端的连接队列的大小。因为我们知道,TCP 建立连接要经过三次握手,在一次握手完成后,Netty 会将这个连接添加到服务端的队列当中,以保持其活性。
  • childOption(ChannelOption.SO_KEEPALIVE, true)方法,TCP Keepalive 机制,实现 TCP 层级的心跳保活功能。
  • childOption(ChannelOption.TCP_NODELAY, true)方法,允许较小的数据包发送,降低延迟。
  • childHandler(ChannelHandler childHandler)方法,设置客户端连接上来的 Channel 的处理器为 NettyServerHandlerInitializer。
  • 其他的地方注释已经很清晰了。

接下来详细看下其中几个的内部原理:以下内容学习于这里这里

  1. TCP Keepalive 的起源:TCP 协议中有长连接和短连接之分。短连接环境下,数据交互完毕后,主动释放连接(一般都是一次数据交互,且基本是 client 端先 close 连接,这样每次建立连接和断开连接的资源会很浪费);而长连接在双方建立连接后,不会在一次数据传输后就断开,而是会一直保持连接。有些连接会在数据交互完毕后,主动释放连接,而有些不会,这些不会的有可能会出现掉电、死机、异常重启等问题,当这些问题发生后,这些 TCP 连接并未来得及正常释放,那么,连接的另一方并不知道对端的情况,它会一直维持着这个连接,长时间维持着半连接也会造成资源浪费,为了解决这个问题,在传输层可以利用 TCP 的保活报文来实现,这就有了 TCP Keepalive 机制。
  2. TCP Keepalive 存在的作用:探测l连接的对端是否存活:利用保活探测功能,可以探知这种对端的意外情况(断电、死机、崩溃或重启等),从而保证在意外发生时,可以释放半打开的 TCP 连接;防止中间设备因超时删除连接相关的连接表:中间设备如防火墙等,会为经过它的数据报文建立相关的连接信息表,会设置连接的过期时间,超过时间就将此连接从表中删除,在删除后,中间设备将丢弃该报文,从而导致应用出现异常,通过保活报文来维持中间设备中该连接的信息来解决。
  3. TCP 保活报文交互过程:
  4. TCP Keepalive 常见的使用模式:默认情况下使用 Keepalive 的周期为 2 小时,若时间到期,会每隔 75s 发送保活探测包,共发送 9 次(这些都是默认值)。关闭 TCP 的Keepalive ,完全使用业务层面心跳保活机制掌管心跳,更加灵活和可控(TCP 属于传输层,已经实现了保活机制,在应用层进行参数设置可以更灵活,Java 代码只能设置开启,Linux 内核可以设置其他参数)。
  5. Socket 编程中,TCP_NODELAY 选项是用来控制是否开启 Nagle 算法,该算法是为了提高较慢的广域网传输效率,减少小分组的报文个数,因为在广域网上,大量的 TCP 小分组极有可能造成网络的拥塞。Nagle 是针对每一个 TCP 连接的,它要求一个 TCP 连接上最多只能有一个未被确认的小分组,在该分组的ack 到达之前不能发送其他小分组。TCP 会搜集这些小分组,然后在之前小分组的确认到达后才将搜集的小分组合并发送出去。在一些对时延要求较高的交互式操作环境中时,必须要关闭 Nagle 算法,因为所有的小分组必须尽快发送出去。
  6. childHandler(nettyServerHandlerInitializer):nettyServerHandlerInitializer 的实现在这里

这个类继承了ChannelInitializer 类,这个类用于 Channel 创建时的自定义初始化处理。

在每一个客户端与服务端建立完成连接时,服务端就会创建一个 Channel 与之对应。

其他添加的管道处理器看注释。

addLast(nettyServerHandler)中的服务端处理器的实现也是很重要的,具体实现如下:

java nio Netty 区别 nio和netty_netty_06

java nio Netty 区别 nio和netty_java nio Netty 区别_07

创建 NettyServerHandler 类继承了 ChannelInboundHandlerAdapter 类,这个类主要是用来实现客户端 Channel 建立连接、断开连接、异常时处理等。
需要注意一下@ChannelHandler.sharable这个注解,这是一个内部类注解,是一个标记注解。

而其中的 ChannelHandlerManager 类,具体实现如下:

java nio Netty 区别 nio和netty_java nio Netty 区别_08


java nio Netty 区别 nio和netty_后端_09


java nio Netty 区别 nio和netty_后端_10

至此,服务端全部的代码基本上就完成了。你会发现这些只是包结构中 server 包下的几个类,但是已经很多了,熟悉这些全部和内部的知识点是我们目前需要做的。下面说完客户端和 common 再说剩余模块。

2.2 客户端

还是先来看下项目架构:

java nio Netty 区别 nio和netty_java_11


整体结构和 server 类似

2.2.1 NettyClient 以及相关类
/**
 * 因为 NettyClient 是客户端,所以无需像 NettyServer 一样使用 NettyChannelManager 维护 Channel 的集合
 */
@Component //Component 注解,将 NettyClient 的创建交给 SPring 管理
public class NettyClient {

    /**
     * 重连频率,单位:秒
     */
    private static final Integer RECONNECT_SECONDS = 20;

    private Logger logger = LoggerFactory.getLogger(getClass());
		
		// 1.服务端只需提供端口,而客户端需要地址和端口
    @Value("${netty.server.host}")
    private String serverHost;
    @Value("${netty.server.port}")
    private Integer serverPort;

		// 初始化都一样
    @Autowired
    private NettyClientHandlerInitializer nettyClientHandlerInitializer;

    /**
     * 线程组,用于客户端对服务端的链接、数据读写
     * 2.客户端只有一个线程组,服务端是两个
     */
    private EventLoopGroup eventGroup = new NioEventLoopGroup();
    /**
     * Netty Client Channel
     */
    private volatile Channel channel;

    /**
     * 启动 Netty Client,有 PostConstruct 注解,会在类创建时执行
     */
    @PostConstruct
    public void start() throws InterruptedException {
        // 3. 创建 Bootstrap 对象,用于 Netty Client 启动(BootStrap 是Netty 提供的客户端的启动类,方便我们初始化 Channel,与之对应的是服务端的 ServerBootStrap)
        Bootstrap bootstrap = new Bootstrap();
        // 设置 Bootstrap 的各种属性。
        bootstrap.group(eventGroup) // 设置一个 EventLoopGroup 对象,实现对客户端的管理
                .channel(NioSocketChannel.class)  // 4. 指定 Channel 为客户端 NioSocketChannel,与之对应的是 NioServerSocketChannel
                .remoteAddress(serverHost, serverPort) // 指定链接服务器的地址
                .option(ChannelOption.SO_KEEPALIVE, true) // TCP Keepalive 机制,实现 TCP 层级的心跳保活功能
                .option(ChannelOption.TCP_NODELAY, true) // 允许较小的数据包的发送,降低延迟
                .handler(nettyClientHandlerInitializer); // 设置自己的 Channel 的处理器为 NettyClientHandlerInitializer 类
        // 5. 链接服务器,并异步等待成功,即启动客户端。同时,添加回调监听器 ChannelFutureListener 在连接服务器失败的时候进行定时重连
        bootstrap.connect().addListener(new ChannelFutureListener() {

            @Override
            public void operationComplete(ChannelFuture future) throws Exception {
                // 连接失败
                if (!future.isSuccess()) {
                    logger.error("[start][Netty Client 连接服务器({}:{}) 失败]", serverHost, serverPort);
                    reconnect();
                    return;
                }
                // 连接成功
                channel = future.channel();
                logger.info("[start][Netty Client 连接服务器({}:{}) 成功]", serverHost, serverPort);
            }

        });
    }
		// 6.客户端多了重连机制
    public void reconnect() {
        eventGroup.schedule(new Runnable() {
            @Override
            public void run() {
                logger.info("[reconnect][开始重连]");
                try {
                    start();
                } catch (InterruptedException e) {
                    logger.error("[reconnect][重连失败]", e);
                }
            }
        }, RECONNECT_SECONDS, TimeUnit.SECONDS);
        logger.info("[reconnect][{} 秒后将发起重连]", RECONNECT_SECONDS);
    }

    /**
     * 关闭 Netty Server
     */
    @PreDestroy
    public void shutdown() {
        // 关闭 Netty Client
        if (channel != null) {
            channel.close();
        }
        // 优雅关闭一个 EventLoopGroup 对象
        eventGroup.shutdownGracefully();
    }

    /**
     * 向服务端发送消息
     *
     * @param invocation 消息体
     */
    public void send(Invocation invocation) {
        if (channel == null) {
            logger.error("[send][连接不存在]");
            return;
        }
        if (!channel.isActive()) {
            logger.error("[send][连接({})未激活]", channel.id());
            return;
        }
        // 发送消息
        channel.writeAndFlush(invocation);
    }

}

看完代码你会发现 NettyClient 和 NettyServer 对等,且基本是一致的。

这里只有几个点不一致:就是代码中标注的 6 个点,这几个点也不难理解。

而其中的自动注入的 bean 是 NettyClientHandlerInitializer,具体代码如下:

import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.handler.timeout.IdleStateHandler;
import io.netty.handler.timeout.ReadTimeoutHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class NettyClientHandlerInitializer extends ChannelInitializer<Channel> {

    /**
     * 心跳超时时间
     */
    private static final Integer READ_TIMEOUT_SECONDS = 60;

    @Autowired
    private MessageDispatcher messageDispatcher;

    @Autowired
    private NettyClientHandler nettyClientHandler;

    /**
     * 初始化channel,进行自定义的初始化
     * @param ch
     */
    @Override
    protected void initChannel(Channel ch) {
        ch.pipeline()
                // 空闲检测
                .addLast(new IdleStateHandler(READ_TIMEOUT_SECONDS, 0, 0)) //额外增加的处理
                .addLast(new ReadTimeoutHandler(3 * READ_TIMEOUT_SECONDS))
                // 编码器
                .addLast(new InvocationEncoder())
                // 解码器
                .addLast(new InvocationDecoder())
                // 消息分发器
                .addLast(messageDispatcher)
                // 客户端处理器
                .addLast(nettyClientHandler)
        ;
    }

}

代码与服务端的几乎一致。

下面我们看看 NettyClientHandler 的代码:

import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.timeout.IdleStateEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
@ChannelHandler.Sharable // 标记这个 ChannelHandler 可以被多个 Channel 使用
public class NettyClientHandler extends ChannelInboundHandlerAdapter {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired
    private NettyClient nettyClient;

    /**
     * 实现在和服务端断开连接时,调用 NettyClient 的重连方法,实现定时重连
     * @param ctx
     * @throws Exception
     */
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        // 发起重连
        nettyClient.reconnect();
        // 继续触发事件
        super.channelInactive(ctx);
    }

    /**
     * 发生异常时,断开连接
     * @param ctx
     * @param cause
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        logger.error("[exceptionCaught][连接({}) 发生异常]", ctx.channel().id(), cause);
        // 断开连接
        ctx.channel().close();
    }

    /**
     * 在客户端空闲时,向服务端发送一次心跳,即心跳机制
     * @param ctx
     * @param event
     * @throws Exception
     */
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object event) throws Exception {
        // 空闲时,向服务端发起一次心跳
        if (event instanceof IdleStateEvent) {
            logger.info("[userEventTriggered][发起一次心跳]");
            HeartbeatRequest heartbeatRequest = new HeartbeatRequest();
            ctx.writeAndFlush(new Invocation(HeartbeatRequest.TYPE, heartbeatRequest))
                    .addListener(ChannelFutureListener.CLOSE_ON_FAILURE);
        } else {
            super.userEventTriggered(ctx, event);
        }
    }

}

代码的注释已经加好了。

至此,我们已经构建了 Netty 的服务端和客户端,因为 Netty 提供的 api 非常方便,所以我们不用像处理 NIO 时处理大量的底层细节。
但是这两个部分只是连接了,要想让他们通信,还需要有通信功能。

2.3 通信协议

通信就是进行数据的读写。在日常的项目开发中,前后端之间采用 HTTP 作为通信协议,使用文本内容进行交互,数据格式一般是json但是在 TCP 的世界里,我们需要自己基于二进制构建客户端和服务端的通信协议
这里的二进制的意思就是我们要将 Java 代码转换为二进制数据传递给另一方。这就需要转换工具了。Netty 使用的是 Google 的 Protobuf,性能高效且序列化出来的二进制数据较小。

下面我们举例来了解一下:假设客户端要发送一个登录请求给服务端,对应的数据是用户名和密码,显然,我们无法将一个 Java 对象直接丢到 TCP Socket 中,而是需要将其转换为 byte 字节数组,才能写入到 TCP Socket 中。即,需要将消息进行序列化。同时服务端对应的即是反序列化。(反之亦然)

我们新建一个项目,这个项目下只有需要用到的通信工具包。

java nio Netty 区别 nio和netty_java_12

2.3.1 codec 通信消息体

先看 codec 包下的类:

@Data
public class Invocation {
    /**
     * 类型:用于匹配对应的消息处理器。如果类比 HTTP 协议,type 属性相当于请求地址。
     */
    private String type;

    /**
     * 消息 json 格式
     */
    private String message;

    public Invocation(String type, String message) {
        this.type = type;
        this.message = message;
    }
		//Message 是自定义的消息接口
    public Invocation(String type, Message message) {
        this.type = type;
        this.message = JSON.toJSONString(message);
    }
    public Invocation() {
    }
}



/**
 * 消息接口
 * @Date 2022/1/7 17:40
 * @Created by gt136
 */
public interface Message {
}

而编码器,我们根据常用的三种方案中的:消息分为消息头和消息体,在头部保存有当前整个消息的长度,只有读取到足够长度才停止读取的方案。
下面是编码和解码器:

/**
 * {@link Invocation} 编码器
 * <p>MessageToByteEncoder 是 Netty 定义的编码 ChannelHandler 的抽象类,用来将泛型 T 转换为字节数组。会最终将 
 * ByteBuf out 写入到 TCP Socket 中。</p>
 * 
 */
public class InvocationEncoder extends MessageToByteEncoder<Invocation> {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    protected void encode(ChannelHandlerContext ctx, Invocation invocation, ByteBuf out) {
        // 将 Invocation 转换成 byte[] 数组
        byte[] content = JSON.toJSONBytes(invocation);
        // 将字节数组的长度写入TCP Socket中,后续的解码器可以根据 length ,解决粘包和拆包的问题。
        out.writeInt(content.length);
        // 将字节数组写入 TCP Socket 内容
        out.writeBytes(content);
        logger.info("[encode][连接({}) 编码了一条消息({})]", ctx.channel().id(), invocation.toString());
    }
}

/*=====================================*/

/**
 * {@link Invocation} 解码器
 * ByteToMessageDecoder 是Netty 定义的解码 ChannelHandler 的抽象类,在 TCP Socket 读取到新数据时,触发解码。
 * <p>最终将读取出的 Invocation 添加到 list 中交给后续的 ChannelHandler 进行处理。会由 MessageDispatcher 
 * 分发到对应的 MessageHandler 中进行业务逻辑处理</p>
 */
public class InvocationDecoder extends ByteToMessageDecoder {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
        // 标记当前读取位置
        in.markReaderIndex();
        // 判断是否能够读取 length 长度
        if (in.readableBytes() <= 4) {
            return;
        }
        // 读取长度
        int length = in.readInt();
        if (length < 0) {
            throw new CorruptedFrameException("negative length: " + length);
        }
        // 如果 message 不够可读,则退回到原读取位置
        if (in.readableBytes() < length) {
            in.resetReaderIndex();
            return;
        }
        // 读取内容
        byte[] content = new byte[length];
        in.readBytes(content);
        // 解析成 Invocation
        Invocation invocation = JSON.parseObject(content, Invocation.class);
        out.add(invocation);
        logger.info("[decode][连接({}) 解析到一条消息({})]", ctx.channel().id(), invocation.toString());
    }
}

2.4 消息分发

在 SpringMVC 中,DispatcherServlet 会根据请求地址、方法等,将请求分发到匹配的 Controller 的 Method 方法上。
在 Netty 中,我们创建了 MessageDispatcher 类,实现和 DispatcherServlet 类似的功能:将 Invocation 分发到其对应的 MessageHandler 中,进行业务逻辑的执行。

/**
 * 消息分发器:在 Spring MVC 中,DispatcherServlet 会根据请求地址、方法等,将请求分发到匹配的 Controller 的 Method 上。
 * <p>在 Netty 中,我们创建了 MessageDispatcher 来实现和 MVC 中类似的功能。而继承的 SimpleChannelInboundHandler<I> 是 Netty 定义的消息处理器 ChannelHandler 的抽象类,处理的消息类型是 <I> </p>
 */
@ChannelHandler.Sharable
public class MessageDispatcher extends SimpleChannelInboundHandler<Invocation> {

    @Autowired
    private MessageHandlerContainer messageHandlerContainer;

    private final ExecutorService executor =  Executors.newFixedThreadPool(200);

    /**
     * 处理消息,进行分发
     * @param ctx
     * @param invocation
     */
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Invocation invocation) {
        // 获得 type 对应的 MessageHandler 处理器
        MessageHandler messageHandler = messageHandlerContainer.getMessageHandler(invocation.getType());
        // 获得  MessageHandler 处理器 的消息类
        Class<? extends Message> messageClass = MessageHandlerContainer.getMessageClass(messageHandler);
        // 解析消息
        Message message = JSON.parseObject(invocation.getMessage(), messageClass);
        // 执行逻辑:正常情况下多个 Channel 适配一个 线程,多个线程适配一个 EventGroup。所以当 Channel 在执行逻辑的过程中,
        // 如果发生阻塞就会影响执行流程。所以,在执行逻辑的时候开启多线程,可以避免这种情况
        executor.submit(new Runnable() {

            @Override
            public void run() {
                // noinspection unchecked
                messageHandler.execute(ctx.channel(), message);
            }

        });
    }

}


/**=================== MessageHandler ==========================*/

/**
 * MessageHandler 的实现类是 client 和 server 包下的 messagehandler 类。分别是认证模块、聊天模块和心跳模块
 * @param <T>
 */
public interface MessageHandler<T extends Message> {

    /**
     * 执行处理消息
     *
     * @param channel 通道
     * @param message 消息
     */
    void execute(Channel channel, T message);

    /**
     * @return 消息类型,即每个 Message 实现类上的 TYPE 静态字段
     */
    String getType();

}


/**=================== MessageHandlerContainer 类,作为 MessageHandler 的容器 ==========================*/


/**
 * MessageHandler 的容器,通过实现 InitializingBean 接口,在类完成 bean 的注册时初始化
 */
public class MessageHandlerContainer implements InitializingBean {

    private Logger logger = LoggerFactory.getLogger(getClass());

    /**
     * 消息类型与 MessageHandler 的映射
     */
    private final Map<String, MessageHandler> handlers = new HashMap<>();

    @Autowired
    private ApplicationContext applicationContext;

    @Override
    public void afterPropertiesSet() throws Exception {
        // 通过 ApplicationContext 获得所有 MessageHandler Bean
        applicationContext.getBeansOfType(MessageHandler.class).values() // 获得所有 MessageHandler Bean
                .forEach(messageHandler -> handlers.put(messageHandler.getType(), messageHandler)); // 添加到 handlers 中
        logger.info("[afterPropertiesSet][消息处理器数量:{}]", handlers.size());
    }

    /**
     * 获得类型对应的 MessageHandler 对象,我们会在 MessageDispatcher 类中调用该方法
     *
     * @param type 类型
     * @return MessageHandler
     */
    MessageHandler getMessageHandler(String type) {
        MessageHandler handler = handlers.get(type);
        if (handler == null) {
            throw new IllegalArgumentException(String.format("类型(%s) 找不到匹配的 MessageHandler 处理器", type));
        }
        return handler;
    }

    /**
     * 获得 MessageHandler 处理的消息类
     *
     * @param handler 处理器
     * @return 消息类
     */
    static Class<? extends Message> getMessageClass(MessageHandler handler) {
        // 获得 Bean 对应的 Class 类名。因为有可能被 AOP 代理过。
        Class<?> targetClass = AopProxyUtils.ultimateTargetClass(handler);
        // 获得接口的 Type 数组
        Type[] interfaces = targetClass.getGenericInterfaces();
        Class<?> superclass = targetClass.getSuperclass();
        while ((Objects.isNull(interfaces) || 0 == interfaces.length) && Objects.nonNull(superclass)) { // 此处,是以父类的接口为准
            interfaces = superclass.getGenericInterfaces();
            superclass = targetClass.getSuperclass();
        }
        if (Objects.nonNull(interfaces)) {
            // 遍历 interfaces 数组
            for (Type type : interfaces) {
                // 要求 type 是泛型参数
                if (type instanceof ParameterizedType) {
                    ParameterizedType parameterizedType = (ParameterizedType) type;
                    // 要求是 MessageHandler 接口
                    if (Objects.equals(parameterizedType.getRawType(), MessageHandler.class)) {
                        Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
                        // 取首个元素
                        if (Objects.nonNull(actualTypeArguments) && actualTypeArguments.length > 0) {
                            return (Class<Message>) actualTypeArguments[0];
                        } else {
                            throw new IllegalStateException(String.format("类型(%s) 获得不到消息类型", handler));
                        }
                    }
                }
            }
        }
        throw new IllegalStateException(String.format("类型(%s) 获得不到消息类型", handler));
    }
}

2.5 NettyServerConfig 和 NettyClientConfig

创建 config 配置类是创建 MessageDispatcher 和 MessageHandlerContainer 的 Bean。

@Configuration
public class NettyServerConfig {

    @Bean
    public MessageDispatcher messageDispatcher() {
        return new MessageDispatcher();
    }

    @Bean
    public MessageHandlerContainer messageHandlerContainer() {
        return new MessageHandlerContainer();
    }

}

客户端代码和这个一致。

至此,我们已经基本上完成了通信功能的搭建,小结一下,从最刚开始的服务端的搭建,开启,设置断开重连、心跳机制和空闲检测机制等,也了解了粘包和拆包等相关知识,到与客户端连接设施搭建,基本上入门的代码已经完成了。后面我们会在详细整理通信相关的认证逻辑、单聊逻辑和群聊逻辑的代码。

3 Netty 解决粘包和拆包问题

这一节问题学习于这里 在 RPC 框架中,粘包和拆包问题是必须解决的一个问题,因为 RPC 框架中,各个微服务相互之间都是维系了一个 TCP 长连接,比如 dubbo 就是一个全双工的长连接。由于微服务往对方发送信息的时候,所有的请求都是使用的同一个连接,这样就会产生粘包和拆包问题。

3.1 粘包和拆包

产生粘包和拆包的问题的主要原因是操作系统在发送 TCP 数据的时候,底层会有一个缓冲区,例如 1024 个字节大小,如果一次请求发送的数据量比较小,没达到缓冲区大小,TCP 则会将多个请求合并为一个请求进行发送,这就形成了粘包问题;如果一次请求发送的数据量比较大,超过了缓冲区大小,TCP 就会将其拆分为多次发送,这就是拆包

java nio Netty 区别 nio和netty_java_13

上图中演示了三种情况:

  • A 和 B 两个包都刚好满足 TCP 缓冲区的大小,或者说其等待时间已经达到 TCP 等待时长,从而还是使用两个独立的包进行发送;
  • A 和 B 两次请求的时间间隔较短,并且数据包较小,因而合并为同一个包发送给服务器;
  • B 包比较大,因而将其拆分为两个包 B_1 和 B_2 进行发送,而这里由于拆分后的 B_2 比较小,其又与 A 包合并在一起发送。

3.2 常见解决方案

对于粘包和拆包问题,常见的解决方案有四种:

  • 客户端在发送数据包的时候,每个包都固定长度,比如 1024 个字节大小,如果客户端发送的数据长度不足 1024 个字节,则通过补充空格的方式补全到指定长度,这种方式还没有找到案例;
  • 客户端在每个包的末尾使用固定的分隔符,例如 \r\n,如果一个包被拆分了,则等待下一个包发送过来之后找到其中的 \r\n,然后对其拆分后的头部部分与前一个包的剩余部分进行合并,这样就得到了完整的包;具体的案例有 HTTP、WebSocket、Redis;
  • 将消息分为头部和消息体,在头部中保存有当前整个消息的长度,只有在读取到足够长度的消息之后才OK;这个是第一个的升级版,动态长度,
  • 通过自定义协议进行处理。

3.3 Netty 提供的粘包拆包解决方案

  1. FixedLengthFrameDecoder:对于使用固定长度的粘包和拆包场景,可以使用 FixedLengthFrameDecoder ,该解码器会每次读取固定长度的消息,如果当前读取到的消息不足指定长度,那么就会等待下一个消息到达后进行补足。使用也比较简单,只需要在构造器中指定消息长度即可。FixedLengthFrameDecoder 只是一个解码器,Netty 只提供了一个解码器,没有编码器。这是因为对于解码是需要等待下一个包的进行补全的,代码相对复杂,而对于编码器,用户可以自行编写,因为编码时只需要将不足指定长度的部分进行补全即可。
  2. LineBasedFrameDecoder 与 DelimiterBasedFrameDecoder:对于通过分隔符进行粘包和拆包的处理,Netty 提供了两个编码的类。这里 LineBasedFrameDecoder 的作用主要是通过换行符,即 \n或者\r\n对数据进行处理;而 DelimiterBasedFrameDecoder 的作用则是通过用户指定的分隔符对数据进行粘包和拆包处理。同样的,这两个类都是解码器类,而对于数据的编码,即在每个数据包最后添加换行符或者指定分隔符的部分需要用户自行进行处理。
  3. LengthFieldBasedFrameDecoder 与 LengthFieldPrepender:这两个需要配合起来使用,其实本质上来讲,这两者一个是解码,一个是编码的关系。它们处理粘拆包的主要思想是在生成的数据包中添加一个长度字段,用于记录当前数据包的长度。LengthFieldBasedFrameDecoder 会参照指定的包长度偏移量数据对接收到的数据进行解码,从而得到目标消息体数据;而LengthFieldPrepender 则会在响应的数据前面添加指定的字节数据,这个数据中保存了当前消息体的整体字节数据长度。

粘包和拆包的处理发生在对编码和解码的时期,通过管道将编码解码规则植入。

4 认证逻辑等

从这一小节开始,就是业务逻辑的处理。

定义用户认证请求,代码如下:

/**
 * 用户认证请求
 */
@Data
public class AuthRequest implements Message {

    public static final String TYPE = "AUTH_REQUEST";

    /**
     * 认证 Token
     */
    private String accessToken;

    public AuthRequest setAccessToken(String accessToken) {
        this.accessToken = accessToken;
        return this;
    }

}

一般情况下,我们使用 HTTP 进行登录系统,然后使用登录后的身份标识(accessToken 认证令牌),将客户端和当前用户进行认证绑定。(认证这里客户端和服务端都一样)

定义用户认证响应,代码如下:

/**
 * 用户认证响应
 */
@Data
public class AuthResponse implements Message {

    public static final String TYPE = "AUTH_RESPONSE";

    /**
     * 响应状态码
     */
    private Integer code;
    /**
     * 响应提示
     */
    private String message;

    public AuthResponse setCode(Integer code) {
        this.code = code;
        return this;
    }

    public AuthResponse setMessage(String message) {
        this.message = message;
        return this;
    }
}

客户端和服务端代码也一致。

但是服务端和客户端的认证处理器不一样,代码如下:

/**
 * 服务端:服务端处理-客户端的认证请求
 */
@Component
public class AuthRequestHandler implements MessageHandler<AuthRequest> {

    @Autowired
    private NettyChannelManager nettyChannelManager;

    @Override
    public void execute(Channel channel, AuthRequest authRequest) {
        // 如果未传递 accessToken
        if (StringUtils.isEmpty(authRequest.getAccessToken())) {
            AuthResponse authResponse = new AuthResponse().setCode(1).setMessage("认证 accessToken 未传入");
            channel.writeAndFlush(new Invocation(AuthResponse.TYPE, authResponse));
            return;
        }

        // ... 此处应有一段

        // 将用户和 Channel 绑定
        // 考虑到代码简化,我们先直接使用 accessToken 作为 User
        nettyChannelManager.addUser(channel, authRequest.getAccessToken());

        // 响应认证成功
        AuthResponse authResponse = new AuthResponse().setCode(0);
        channel.writeAndFlush(new Invocation(AuthResponse.TYPE, authResponse));
    }

    @Override
    public String getType() {
        return AuthRequest.TYPE;
    }

}

/**
 * 客户端:客户端处理-服务端的认证响应
 */
 @Component
public class AuthResponseHandler implements MessageHandler<AuthResponse> {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public void execute(Channel channel, AuthResponse message) {
        logger.info("[execute][认证结果:{}]", message);
    }

    @Override
    public String getType() {
        return AuthResponse.TYPE;
    }

}

/**
 * 提供测试类
 */
 @RestController
@RequestMapping("/test")
public class TestController {

    @Autowired
    private NettyClient nettyClient;

    @PostMapping("/mock")
    public String mock(String type, String message) {
        // 创建 Invocation 对象
        Invocation invocation = new Invocation(type, message);
        // 发送消息
        nettyClient.send(invocation);
        return "success";
    }

}

5. 单聊逻辑

java nio Netty 区别 nio和netty_后端_14

具体代码如下:

/**********************不同消息实体类***********************/
/**
 * 客户端请求:发送给指定人的私聊消息 Request
 */
@Data
public class ChatSendToOneRequest implements Message {

    public static final String TYPE = "CHAT_SEND_TO_ONE_REQUEST";

    /**
     * 发送给的用户
     */
    private String toUser;
    /**
     * 消息编号
     */
    private String msgId;
    /**
     * 内容
     */
    private String content;

    public ChatSendToOneRequest setToUser(String toUser) {
        this.toUser = toUser;
        return this;
    }

    public ChatSendToOneRequest setMsgId(String msgId) {
        this.msgId = msgId;
        return this;
    }

    public ChatSendToOneRequest setContent(String content) {
        this.content = content;
        return this;
    }
}

/**
 * 服务端响应:聊天发送消息结果的 Response
 */
@Data
public class ChatSendResponse implements Message {

    public static final String TYPE = "CHAT_SEND_RESPONSE";

    /**
     * 消息编号
     */
    private String msgId;
    /**
     * 响应状态码
     */
    private Integer code;
    /**
     * 响应提示
     */
    private String message;

    public ChatSendResponse setMsgId(String msgId) {
        this.msgId = msgId;
        return this;
    }

    public ChatSendResponse setCode(Integer code) {
        this.code = code;
        return this;
    }

    public ChatSendResponse setMessage(String message) {
        this.message = message;
        return this;
    }
}


/**
 * 服务端:转发消息给一个用户的 Message
 */
@Data
public class ChatRedirectToUserRequest implements Message {

    public static final String TYPE = "CHAT_REDIRECT_TO_USER_REQUEST";

    /**
     * 消息编号
     */
    private String msgId;
    /**
     * 内容
     */
    private String content;

    public ChatRedirectToUserRequest setMsgId(String msgId) {
        this.msgId = msgId;
        return this;
    }

    public ChatRedirectToUserRequest setContent(String content) {
        this.content = content;
        return this;
    }
}

/**********************这些实体类对应的具体处理逻辑************************/
/**
 * 服务端处理客户端的私聊请求
 */
@Component
public class ChatSendToOneHandler implements MessageHandler<ChatSendToOneRequest> {

    @Autowired
    private NettyChannelManager nettyChannelManager;

    @Override
    public void execute(Channel channel, ChatSendToOneRequest message) {
        // 这里,假装直接成功
        ChatSendResponse sendResponse = new ChatSendResponse().setMsgId(message.getMsgId()).setCode(0);
        channel.writeAndFlush(new Invocation(ChatSendResponse.TYPE, sendResponse));

        // 创建转发的消息,发送给指定用户
        ChatRedirectToUserRequest sendToUserRequest = new ChatRedirectToUserRequest().setMsgId(message.getMsgId())
                .setContent(message.getContent());
        nettyChannelManager.send(message.getToUser(), new Invocation(ChatRedirectToUserRequest.TYPE, sendToUserRequest));
    }

    @Override
    public String getType() {
        return ChatSendToOneRequest.TYPE;
    }
}

/**
 * 客户端处理服务端的聊天响应
 */
 
@Component
public class ChatSendResponseHandler implements MessageHandler<ChatSendResponse> {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public void execute(Channel channel, ChatSendResponse message) {
        logger.info("[execute][发送结果:{}]", message);
    }

    @Override
    public String getType() {
        return ChatSendResponse.TYPE;
    }
}


/**
 * 客户端处理服务端的转发消息的请求
 */
 @Component
public class ChatRedirectToUserRequestHandler implements MessageHandler<ChatRedirectToUserRequest> {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public void execute(Channel channel, ChatRedirectToUserRequest message) {
        logger.info("[execute][收到消息:{}]", message);
    }

    @Override
    public String getType() {
        return ChatRedirectToUserRequest.TYPE;
    }
}

6. 群聊逻辑

java nio Netty 区别 nio和netty_java_15

具体代码如下:

/**********************实体类************************/
/**
 * 发送给所有人的群聊消息的 Message
 */
@Data
public class ChatSendToAllRequest implements Message {

    public static final String TYPE = "CHAT_SEND_TO_ALL_REQUEST";

    /**
     * 消息编号
     */
    private String msgId;
    /**
     * 内容
     */
    private String content;

    public ChatSendToAllRequest setContent(String content) {
        this.content = content;
        return this;
    }

    public ChatSendToAllRequest setMsgId(String msgId) {
        this.msgId = msgId;
        return this;
    }
}

/**
 * ChatSendResponse、ChatRedirectToUserRequest 与单聊代码一致
 */
/**********************这些实体类对应的具体处理逻辑************************/
/**
 * 服务端处理客户端的群聊请求
 */
@Component
public class ChatSendToAllHandler implements MessageHandler<ChatSendToAllRequest> {

    @Autowired
    private NettyChannelManager nettyChannelManager;

    @Override
    public void execute(Channel channel, ChatSendToAllRequest message) {
        // 这里,假装直接成功
        ChatSendResponse sendResponse = new ChatSendResponse().setMsgId(message.getMsgId()).setCode(0);
        channel.writeAndFlush(new Invocation(ChatSendResponse.TYPE, sendResponse));

        // 创建转发的消息,并广播发送
        ChatRedirectToUserRequest sendToUserRequest = new ChatRedirectToUserRequest().setMsgId(message.getMsgId())
                .setContent(message.getContent());
        nettyChannelManager.sendAll(new Invocation(ChatRedirectToUserRequest.TYPE, sendToUserRequest));
    }

    @Override
    public String getType() {
        return ChatSendToAllRequest.TYPE;
    }

}

/**
 * ChatSendResponseHandler、ChatRedirectToUserRequestHandler 代码与单聊逻辑一致。
 */

至此,一个 Netty 入门简单 IM 功能项目完成。