Netty实战——构建高性能、可扩展的网络应用

237 阅读21分钟

前言

近期我利用 Netty 开发了一个即时通讯(IM)产品,在此分享一些对 Netty 的总结,以深化对其的理解。Netty 是一个被广泛使用的网络框架,主要用于处理 TCP 连接。让我们首先了解一下官方对 Netty 的定义。

Netty is an NIO client server framework which enables quick and easy development of network applications such as protocol servers and clients. It greatly simplifies and streamlines network programming such as TCP and UDP socket server. Netty 是一个 NIO 客户端服务器框架,可以快速轻松地开发协议服务器和客户端等网络应用程序。 它极大地简化和简化了网络编程,例如 TCP 和 UDP 套接字服务器。

在物联网开发中,服务端与设备端之间通常通过自定义 TCP 协议进行通信。(也有使用MQTT(Message Queuing Telemetry Transport))而在 Java 中,Netty 是一个常用的选择用于实现 TCP 通信。在这篇文章中,我将介绍如何利用 Netty 实现客户端和服务端之间的 TCP 通信协议。这将包括建立可靠的连接以及实现数据的双向传输。

Netty介绍

Netty应用

Netty是一个强大而灵活的网络编程框架,主要应用于以下领域:

分布式系统和RPC通信:

Netty被广泛应用于分布式系统中,特别是作为底层通信框架为RPC(远程过程调用)框架提供支持。一些知名的RPC框架,如Dubbo,使用Netty来实现高性能的远程通信。

即时通讯和聊天应用:

由于其卓越的性能和异步模型,Netty在即时通讯和聊天应用中得到了广泛的应用。它支持大规模用户同时在线,能够处理实时消息传递和即时通讯的需求。

推送系统和实时数据传输:

Netty为推送系统提供了强大的基础支持,使其能够高效地处理大量并发连接,并实现实时数据的推送。这在需要实时更新的应用中尤为重要,例如股票行情、新闻推送等领域。

游戏服务器和多人在线游戏(MMOG):

在在线游戏领域,Netty的高性能和低延迟使其成为构建游戏服务器的理想选择。它能够处理大量并发玩家的请求,并支持实时的游戏数据交互。

HTTP和Web应用开发:

Netty支持HTTP协议,因此也广泛用于构建高性能的Web服务器和应用。它的异步特性使得能够处理大量并发的HTTP请求,适用于高负载的Web应用。

物联网(IoT)应用:

Netty的轻量级和高性能特性使其适用于物联网应用,其中需要设备之间的快速、可靠的通信,例如智能家居、工业自动化等领域。

Netty被哪些开源产品使用

如果大家有关注一些开源产品,会发现很多开源产品的底层通信很多都是基于Netty的。

Dubbo:

Dubbo是阿里巴巴开源的分布式服务框架,用于提供高性能的RPC(远程过程调用)服务。Dubbo底层使用Netty作为通信框架,以实现快速而可靠的远程通信。

gRPC:

gRPC是由Google开源的高性能RPC框架,支持多种编程语言。它使用Netty作为底层通信框架,以实现在分布式系统中高效的服务通信。

Elasticsearch:

###Elasticsearch是一个分布式搜索和分析引擎,用于构建实时搜索和分析系统。它使用Netty作为底层通信库,处理节点之间的集群通信。

Kafka:

Apache Kafka是一个分布式流处理平台,用于构建实时数据流处理应用。Kafka使用Netty来实现高性能的网络通信,处理生产者和消费者之间的数据传输。

RocketMQ:

Apache RocketMQ是一个分布式消息队列系统,用于构建高性能、低延迟的消息通信。RocketMQ底层采用Netty作为通信框架,支持大规模消息的生产和消费。

Spring WebFlux:

Spring WebFlux是Spring Framework的响应式编程模块,用于构建基于异步和事件驱动的Web应用。在WebFlux中,Netty可以作为其底层的服务器实现,以支持高并发的响应式应用。

Netty实战

Netty启动类

package com.ji.jichat.chat;

import com.ji.jichat.chat.core.config.TcpServerConfig;
import com.ji.jichat.chat.netty.ServerChannelInitializer;
import com.ji.jichat.chat.netty.listener.NettyCloseFutureListener;
import com.ji.jichat.chat.netty.listener.NettyStartFutureListener;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

/**
 * Socket服务器
 *
 * @author jisl
 */
@Slf4j
@Component
public class TCPServer implements CommandLineRunner {

    private ServerBootstrap serverBootstrap;
    private EventLoopGroup bossGroup;
    private EventLoopGroup workerGroup;

    @Autowired
    private TcpServerConfig tcpServerConfig;

    @Autowired
    @Qualifier("serverChannelInitializer")
    private ServerChannelInitializer serverChannelInitializer;

    @Autowired
    private NettyStartFutureListener nettyStartFutureListener;

    @Autowired
    private NettyCloseFutureListener nettyCloseFutureListener;

    @Override
    public void run(String... args) {
        start();
    }

    public void start() {
        // 默认以cpu核心数*2为工作线程池大小
        final int core = Runtime.getRuntime().availableProcessors();
        tcpServerConfig.setWorkerCount(core * 2);
        log.info("Netty 起飞 [{}]", tcpServerConfig);

        // 创建两个线程组,用于接收客户端的连接和处理 I/O 操作
        // 用于接收连接
        bossGroup = new NioEventLoopGroup(tcpServerConfig.getBossCount());
        // 用于处理连接后的 I/O 操作
        workerGroup = new NioEventLoopGroup(tcpServerConfig.getWorkerCount());

        // 创建 ServerBootstrap 实例
        serverBootstrap = new ServerBootstrap()
                .group(bossGroup, workerGroup)
                .channel(NioServerSocketChannel.class)
                .handler(new LoggingHandler(LogLevel.INFO)) // 添加日志处理器,用于打印日志信息
                .childHandler(serverChannelInitializer); // 设置子处理器,用于处理连接后的事件

        // 绑定端口并启动服务
        ChannelFuture channelFuture = serverBootstrap.bind(tcpServerConfig.getTcpPort()).addListener(nettyStartFutureListener);

        // 等待服务端关闭
        channelFuture.channel().closeFuture().addListener(nettyCloseFutureListener);
    }

    public void shutdownGracefully() {
        // 优雅地关闭线程组
        bossGroup.shutdownGracefully();
        workerGroup.shutdownGracefully();
    }
}

EventLoopGroup 是定义线程数,一般bossGroup是负责连接,而workGroup是处理Channel的I/O可以理解实际工作的。

ChannelInitializer 是定义一个责任链,这个类的实现是用户自定义的handler实现,像编码解码,粘包和粘包,具体的业务处理

ChannelInitializer 加入自定义的Handler实现

    protected void initChannel(SocketChannel channel) {

        channel.pipeline()
                // MDC日志上下文
                .addLast(MdcLoggingHandler.NAME, mdcLoggingHandler)
                //自定义长度解码器
                .addLast(LengthFieldDecoderHandler.NAME, new LengthFieldDecoderHandler())
                // 编解码
                .addLast(PacketCodecHandler.NAME, packetCodecHandler)
                //                设定IdleStateHandler心跳检测每15秒进行一次读检测,如果15秒内ChannelRead()方法未被调用则触发一次userEventTrigger()方法
                .addLast(new IdleStateHandler(15, 0, 0))
                // 心跳处理器
                .addLast(heartbeatHandler)
//                登录处理器
                .addLast(loginHandler)
                // 业务处理器
                .addLast(BizServerHandler.NAME, bizServerHandler);
        // 连接异常处理

    }

从上面可以看到定义了多个Handler,上面的代码执行顺序就是按上面的顺序。下面详细介绍这几个Handler功能。

LengthFieldDecoderHandler——处理"粘包"和"拆包"问题

"粘包"和"拆包"问题

"粘包"和"拆包"是在网络通信中常见的问题,特别是在基于流式传输的协议中。这两个问题通常涉及到数据的传输和接收方的处理方式,导致接收方无法正确解析接收到的数据。

粘包问题:粘包是指发送方在传输数据时,将多个小的数据包粘合在一起发送,而接收方却无法准确地划分和解析这些数据包。这可能导致接收方在处理时出现混乱,无法正确识别每个数据包的边界。

拆包问题:拆包是指发送方发送的数据被接收方拆分成了不同的部分,导致接收方无法正确还原原始的数据包。这可能使得接收方无法正确解析和处理数据。

1. 粘包问题的示例: 假设发送端要发送两个消息,分别是 "Hello" 和 "World"。

| Length (4 bytes) | Data           |
|-------------------|----------------|
| 5                 | "Hello"        |
| 5                 | "World"        |

接收端在收到这两个消息时,可能会将它们视为一个连续的消息,形成粘包。

2. 拆包问题的示例: 假设发送端要发送一个消息 "How are you?",但由于网络限制,每次只能发送 5 个字节的数据。

| Length (4 bytes) | Data           |
|-------------------|----------------|
| 14                | "How are you?" |

接收端在收到这个消息时,可能会分两次接收到数据:

第一次接收到 5 个字节的数据:"How a" 第二次接收到 9 个字节的数据:"re you?"

由于拆包,接收端无法正确地组装消息。

"粘包"和"拆包"问题 解决方案

定长消息:

思路: 确保每个消息的长度是固定的,不论消息内容是多少。 实现: 在传输的数据中,每个消息都有固定的长度,接收方按照这个长度来拆分消息。 优点: 简单直观,易于实现。 缺点: 对于消息长度不固定的情况,可能会浪费空间。 Netty类: FixedLengthFrameDecoder

分隔符:

思路: 在消息之间插入特殊的分隔符,接收方根据分隔符来区分消息。 实现: 例如使用换行符、逗号等作为分隔符。 优点: 灵活,适用于不同长度的消息。 缺点: 可能会受到消息内容中包含分隔符的影响。 Netty类: DelimiterBasedFrameDecoder

消息长度字段:

思路: 在消息头部加入一个描述消息长度的字段,接收方首先读取这个长度字段,然后根据长度读取对应的消息内容。 实现: 使用固定长度的字段,通常是4个字节,来表示消息的长度。 优点: 精确地知道每个消息的长度,适用于不同长度的消息。 缺点: 需要额外的字节用于表示消息长度,复杂一些。 Netty类: LengthFieldBasedFrameDecoder

目前绝大多数的协议是使用消息长度字段方案,在JiChat 方案也是使用消息长度字段方案。平常我们使用的Http请求为啥都没处理过"粘包"和"拆包"问题,那是因为HTTP(Hypertext Transfer Protocol)是一种应用层协议,而TCP(Transmission Control Protocol)是一种传输层协议。HTTP已经帮我们处理好了"粘包"和"拆包"问题,我们自己创建的TCP连接那就需要我们自己处理这些问题了。那么Http协议是如何处理的呢,大家可以思考下。接下来我们看看什么是协议。

package com.ji.jichat.chat.netty.handler;

import io.netty.handler.codec.LengthFieldBasedFrameDecoder;

import java.nio.ByteOrder;

/**
  * LengthFieldBasedFrameDecoder:自定义长度解码器,通过在消息头中定义消息长度字段来标志消息体的长度,然后根据消息的总长度来读取消息
  * @author jisl on 2023/8/15 13:47
  **/
public class LengthFieldDecoderHandler extends LengthFieldBasedFrameDecoder {

    public static final String NAME = "LengthFieldDecoderHandler";

    private static final int MAX_FRAME_LENGTH = Integer.MAX_VALUE; // 最大数据帧长度
    private static final int LENGTH_FIELD_OFFSET = 4; // 长度字段在数据帧中的偏移量
    private static final int LENGTH_FIELD_LENGTH = 4; // 长度字段的字节长度
    private static final int LENGTH_ADJUSTMENT = 4; // 长度字段的值加上的调整值,因为字节长度不包括包尾,四字节那么要加上这4个字节
    private static final int INITIAL_BYTES_TO_STRIP = 0; // 解码后跳过的字节数
    private static final  boolean FAIL_FAST = true; // 这里是是否快速失败


    public LengthFieldDecoderHandler() {
        super(ByteOrder.BIG_ENDIAN,MAX_FRAME_LENGTH, LENGTH_FIELD_OFFSET, LENGTH_FIELD_LENGTH,LENGTH_ADJUSTMENT,INITIAL_BYTES_TO_STRIP, FAIL_FAST);
    }


}

PacketCodecHandler——编解码

上面看到我们定义的LengthFieldDecoderHandler一些参数

    private static final int LENGTH_FIELD_OFFSET = 4; // 长度字段在数据帧中的偏移量
    private static final int LENGTH_FIELD_LENGTH = 4; // 长度字段的字节长度
    private static final int LENGTH_ADJUSTMENT = 4; // 长度字段的值加上的调整值,因为字节长度不包括包尾,四字节那么要加上这4个字节

这个参数是怎么来的,这个就是从我们定的协议来。协议通俗讲就是和我们定义接口差不多,接口约定前端要怎么传参数,而协议就是定义连接的客户端要连接服务端要传什么格式。 下面我们看看国内某相机品牌的TCP协议报文格式。

类型字节长度说明备注
UINT324包头 0x77aa77aa
UINT324数据报文长度包长度之后到包尾之前的所有字段的长度
UINT324协议版本版本2,其中的XML数据采用XML方式描述;版本3,其中的XML数据采用JSON方式描述;
UINT324命令码数据报文的含义
..................数据内容
UINT324包尾 0x77ab77ab

|0x77aa77aa| “版本号+命令码+数据内容”的长度| 版本号 |命令码| 数据内容| 0x77ab77ab|

再看看JiChat这边定义的协议报文格式。

0xAAAABBBB| “版本号+code+数据内容”的长度| 版本号 |code| 数据内容| 0xEEEEFFFF

以上每一段,除了数据内容长度是不确定,其他长度都是固定。每次传输长度都是一样。

LENGTH_FIELD_OFFSET =0xAAAABBBB
LENGTH_FIELD_LENGTH=“版本号+code+数据内容”的长度
LENGTH_ADJUSTMENT=0xEEEEFFFF

通过定义以上参数,Netty就知道当前传输的包是否完整了。包完整了,那么就进行编解码了。

package com.ji.jichat.chat.netty.handler;

import com.ji.jichat.chat.netty.protocol.ProtocolCodec;
import com.ji.jichat.chat.api.dto.Message;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToMessageCodec;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.List;

/**
 *
 */
@Slf4j
@Component
@ChannelHandler.Sharable
public class PacketCodecHandler extends MessageToMessageCodec<ByteBuf, Object> {

    public static final String NAME = "PacketCodecHandler";


    private PacketCodecHandler() {

    }

    @Override
    protected void encode(ChannelHandlerContext ctx, Object msg, List<Object> out) {
        if (msg instanceof Message) {
            ByteBuf byteBuf = ctx.channel().alloc().ioBuffer();
            Message message = (Message) msg;
            ProtocolCodec.encode(byteBuf, message);
            out.add(byteBuf);
        } else {
            log.error("msg 必须为Message类型");
            throw new IllegalArgumentException("msg 必须为Message类型");
        }

    }

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf byteBuf, List<Object> out) {
        final Message message = ProtocolCodec.decode(byteBuf);
        out.add(message);
    }


}

我们看到PacketCodecHandler继承自MessageToMessageCodec。MessageToMessageCodec 是Netty中提供的一种编解码器,用于将一种消息类型转换为另一种消息类型。这个编解码器同时继承了ChannelInboundHandlerAdapter和ChannelOutboundHandlerAdapter,因此可以同时处理入站(inbound)和出站(outbound)的消息。这样每次消息进来会自动将二进制转成对象,而消息出去自动将消息转成二进制,不用我们每次处理。

我们看下根据我们定义的协议,具体的实现方法

package com.ji.jichat.chat.netty.protocol;

import com.alibaba.fastjson.JSON;
import com.ji.jichat.chat.api.enums.CommandCodeEnum;
import com.ji.jichat.chat.utils.ByteUtil;
import com.ji.jichat.common.exception.ServiceException;
import com.ji.jichat.chat.api.dto.Message;
import io.netty.buffer.ByteBuf;
import lombok.extern.slf4j.Slf4j;

import java.nio.charset.StandardCharsets;
import java.util.Objects;


/**
 * 协议编解码
 *
 * @author jisl on 2023/8/15 14:08
 **/
@Slf4j
public class ProtocolCodec {


    public static final int PACKAGE_HEAD = 0xAAAABBBB;

    public static final int PACKAGE_TAIL = 0xEEEEFFFF;

    // 0xAAAABBBB| “版本号+code+数据内容”的长度| 版本号 |code| 数据内容| 0xEEEEFFFF
    public static final int PROTOCOL_VERSION = 1001;


    public static void encode(ByteBuf byteBuf, Message message) {
        byte[] packageHead = ByteUtil.intToBytes(PACKAGE_HEAD);
        byte[] protocolVersion = ByteUtil.intToBytes(PROTOCOL_VERSION);
        byte[] code = ByteUtil.intToBytes(message.getCode());
        byte[] packageTail = ByteUtil.intToBytes(PACKAGE_TAIL);
        final byte[] content = JSON.toJSONString(message).getBytes(StandardCharsets.UTF_8);
        final byte[] pkLenBytes = ByteUtil.intToBytes(protocolVersion.length + code.length + content.length);
//        // 写入固定标识
        byteBuf.writeBytes(packageHead);
//        // 写入字节数组长度
        byteBuf.writeBytes(pkLenBytes);
        byteBuf.writeBytes(protocolVersion);
        byteBuf.writeBytes(code);
        byteBuf.writeBytes(content);
        byteBuf.writeBytes(packageTail);
    }


    public static Message decode(ByteBuf byteBuf) {
        // 获取版本号
        int protocolVersion = byteBuf.getInt(8);
        if (Objects.equals(protocolVersion, PROTOCOL_VERSION)) {
            int contentLen = byteBuf.getInt(4);
            final int code = byteBuf.getInt(12);
            byte[] content = new byte[contentLen - 8];
            byteBuf.getBytes(16, content); //从位置12开始读取contentLen个字节的数据
            return JSON.parseObject(new String(content, StandardCharsets.UTF_8), CommandCodeEnum.getClazz(code));
        } else {
            throw new ServiceException("当前版本号暂不支持");
        }
    }


}

0xAAAABBBB| “版本号+code+数据内容”的长度| 版本号 |code| 数据内容| 0xEEEEFFFF

这边我们定义的协议每段是4个字节长度,因为Java int是4字节。而且目前主流编程语言用的也都是int 4字节,但是有的协议也会用2字节。还有的协议会用小端法,目前主要是大端法。

大端法"和"小端法"是两种表示多字节数据在内存中存储顺序的方式。它们是关于多字节数据的字节顺序的两种不同约定。 大端法(Big Endian):在大端法中,最高有效字节(Most Significant Byte,MSB)的地址是数据的起始地址,而最低有效字节(Least Significant Byte,LSB)的地址是数据的结束地址。 数据在内存中的存储顺序是从高地址到低地址。例如,对于十六进制值0x12345678,在大端法中存储的顺序为:12 34 56 78。 小端法(Little Endian):在小端法中,最低有效字节的地址是数据的起始地址,而最高有效字节的地址是数据的结束地址。数据在内存中的存储顺序是从低地址到高地址。例如,对于十六进制值0x12345678,在小端法中存储的顺序为:78 56 34 12。 Android和IOS Intel兼容机 Windows Linux 都是用小端法,IBM和Oracle(Sun)用大端法。一些网络协议规定使用大端法,例如IP协议。网络字节序通常是大端法。(toB产品用大端法,toC产品用小端法)

版本号:是为了可扩展性,将来如果要更换协议。那么只需要升级版本号,其他代码都不用改。基本上开放接口和协议都会定义版本。 code:命令码,建立通信。为了处理不同的方法,那么就会定义code,可以理解成接口。不同的接口就意味着,有不同的入参。这边为了能让服务端知道,当前入参格式。那么知道code,就知道转换成对应的类。这也是一种设计模式,实际设计模式就是为了一个目的,让代码简洁优雅可扩展。

IdleStateHandler——处理心跳

一般常规处理心跳,就是客户端定时上传心跳。然后服务端起个定时器检查,客户端在规定时间内是否有数据心跳。这个也可以,但是有时候客户端和服务端一直通信,确实没必要再检查心跳。那有没更优雅的方式,那么就是IdleStateHandler

IdleStateHandler 是Netty中的一个处理器,用于处理连接空闲状态的情况。它可以检测连接在一定时间内是否没有进行读操作、写操作或读写操作,从而触发相应的事件,方便用户在这些情况下执行一些操作。 IdleStateHandler可以指定检测读空闲(READER_IDLE)检测写空闲(WRITER_IDLE)检测读写空闲(ALL_IDLE),一般是服务端用READER_IDLE,客户端用WRITER_IDLE。也就是客户端一段时间没有向服务端发消息,那么触发userEventTriggered()方法;服务端一段时间没有收到客户端消息,那么触发userEventTriggered()

          //                设定IdleStateHandler心跳检测每15秒进行一次读检测,如果15秒内ChannelRead()方法未被调用则触发一次userEventTrigger()方法
                .addLast(new IdleStateHandler(15, 0, 0))
                

服务端一段时间没有收到客户端消息,就触发检查心跳。

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
        if (evt instanceof IdleStateEvent) {
            IdleStateEvent idleStateEvent = (IdleStateEvent) evt;
            if (idleStateEvent.state() == IdleState.READER_IDLE) {
                //长时间没有收到客户端消息,开始 处理心跳
                final Channel channel = ctx.channel();
                final Long lastReadTime = NettyAttrUtil.getReaderTime(channel);
                final long now = System.currentTimeMillis();
                if (TimeUnit.MILLISECONDS.toMinutes(now - lastReadTime) > 1) {
                    log.info("长时间没有收到【{}】心跳,关闭客户端连接", ChannelRepository.getUserKey(channel));
//                    超过一分钟,那么就关闭连接
                    userChatServerCache.remove(channel);
                    channel.close();
                }
            }
        }
    }

LoginHandler ——总不能谁都能连接

这个和我们登录接口,一样就是验证当前这个客户端是否可以登录。一般流程是客户端通过http登录接口,拿到token后。用拿到到的token连接服务端TCP。

package com.ji.jichat.chat.netty.handler;

import com.ji.jichat.chat.api.dto.LoginMessage;
import com.ji.jichat.chat.api.enums.CommandCodeEnum;
import com.ji.jichat.chat.kit.UserChatServerCache;
import com.ji.jichat.chat.netty.ChannelRepository;
import com.ji.jichat.common.constants.CacheConstant;
import com.ji.jichat.security.admin.utils.JwtUtil;
import com.ji.jichat.user.api.vo.LoginUser;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Objects;

/**
 *
 */
@Slf4j
@ChannelHandler.Sharable
@Component
public class LoginHandler extends SimpleChannelInboundHandler<LoginMessage> {


    private static final int MAX_NO_LOGIN_PROTOCOL_COUNT = 3;

    private int count = 0;

    @Resource
    private RedisTemplate<String, Object> redisTemplate;


    @Resource
    private UserChatServerCache userChatServerCache;


    @Override
    public void channelRead0(ChannelHandlerContext ctx, LoginMessage msg) throws Exception {
        final String key = msg.getUserKey();
        final Channel channel = ChannelRepository.get(key);
        if (channel == null && !msg.isMatch(CommandCodeEnum.LOGIN.getCode())) {
            //之前没有登录,且当前编码不是登录那么需要返回异常
            log.debug("第[{}]次收到连接[{},{}]未登录,丢弃请求msg=[{}]", ++count, ctx.channel().remoteAddress(), key, msg);
            if (count >= MAX_NO_LOGIN_PROTOCOL_COUNT) {
                log.error("连接[{}]超过最大未登录发送连接次数[{}],关闭连接", ctx.channel().remoteAddress(), MAX_NO_LOGIN_PROTOCOL_COUNT);
                ctx.channel().close();
            }
            return;
        } else if (!msg.isMatch(CommandCodeEnum.LOGIN.getCode())) {
            log.debug("收到连接[{}] 请求msg=[{}],remove LoginHandler", msg.getUserKey(), msg);
            ctx.pipeline().remove(this);
            super.channelRead(ctx, msg);
            return;
        }
        log.info("收到连接[{}] 登录请求msg=[{}]", ctx.channel().remoteAddress(), msg);
        final LoginUser loginUser = getLoginUser(msg);
        if (Objects.isNull(loginUser)) {
            log.warn("连接[{}]登录请求token为空,关闭连接", ctx.channel().remoteAddress());
            ctx.channel().close();
            return;
        }
        //缓存用户连接的服务信息
        userChatServerCache.put(loginUser, ctx.channel());
//        理器在处理完特定的任务后,不再需要继续处理后续的事件。通过调用 remove(this) 可以将该处理器从链中移除,防止后续的事件传递给它。这在某些场景下有助于提高性能或确保在适当的时候清理资源
        ctx.pipeline().remove(this);
        log.info("[{}]建立连接登录成功,初始化session", key);
    }

    private LoginUser getLoginUser(LoginMessage msg) {
        try {
            String token = msg.getToken();
            final String loginKey = JwtUtil.validateJwtWithGetSubject(token);
            return (LoginUser) redisTemplate.opsForValue().get(CacheConstant.LOGIN_USER + loginKey);
        } catch (Exception e) {
            log.info("解析token获取用户异常:", e);
        }
        return null;
    }


}

大家不知道会不会有个疑问,我如果登录之后每次都还要再进入一遍LoginHandler。是不是很影响效率,就像每次我请求登录接口后。每次都还要再请求一遍,Netty也想到了。这边通过

           ctx.pipeline().remove(this);

告诉Netty,下次我不需要了。就不会再进入到LoginHandler了。 ctx.pipeline().remove(this) 是在Netty中用于从ChannelPipeline 中移除当前处理器(Handler)的代码。这行代码通常在处理器的业务逻辑中调用,用于动态地从处理链中移除自身。

GitHub源码

以上就是Netty实战的介绍,涵盖了实际工作中需要用到的Netty处理。这边将源码分享到GitHub上了,点这里

总结

以上是用Netty实现TCP服务器介绍,主要是简单介绍如何使用Netty。对于Netty原理和Netty组件没有介绍。(主要是笔者也不懂哈哈) ByteBuf(字节容器):ByteBuffer 是Netty中用于处理数据的字节缓冲区。其内部是一个字节数组。ByteBuffer 提供了三种不同类型的缓冲区,分别是堆缓冲区(Heap ByteBuffer)、直接缓冲区(Direct ByteBuffer)、和复合缓冲区(Non-Heap ByteBuffer)。

Netty的零拷贝实现 Netty的接收和发送ByteBuffer采用DIRECT BUFFERS,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。除了内存零拷贝还有文件传输的零拷贝

Reactor 模型Reactor 模型是一种基于事件驱动的设计模式,主要用于处理并发 I/O 操作。该模型的核心思想是将输入(请求、连接等)和输出(响应、数据等)的处理过程分离,通过事件驱动机制来处理输入和输出操作。

Reactor模型中定义的三种角色: Reactor:负责监听和分配事件,将I/O事件分派给对应的Handler。新的事件包含连接建立就绪、读就绪、写就绪等。 Acceptor:处理客户端新连接,并分派请求到处理器链中。 Handler:将自身与事件绑定,执行非阻塞读/写任务,完成channel的读入,完成处理业务逻辑后,负责将结果写出channel。可用资源池来管理。

NIO说到底Netty是基于NIO的client-service(客户端服务器)网络框架,要理解Netty那么就要知道什么是NIO .

NIO(Non-blocking I/O)是Java中提供的一种非阻塞I/O的编程模型。它是在Java 1.4版本中引入的,为了解决传统的阻塞I/O在高并发场景下的性能问题而设计的。传统的阻塞I/O模型中,每个连接都需要一个独立的线程来处理,当连接数增加时,线程数量也随之增加,导致系统资源的浪费和性能下降。NIO通过引入通道(Channel)和缓冲区(Buffer)的概念,以及非阻塞的Selector机制,使得一个线程可以管理多个连接,实现更高效的I/O操作。

事件驱动模型 事件编程是一种基于事件驱动模型的编程范式,其中程序的执行流程主要由外部事件的发生和相应的事件处理器来控制。

select()是一种事件通知机制,常用于监听多个文件描述符上的IO事件。它可以告知进程何时可以从文件或网络套接字读取或写入数据。当文件描述符上发生可读、可写或异常事件时,select()会返回并向进程发出通知。(epoll()更高效) Linux epoll是一个事件通知库,支持高效地处理大量的并发网络连接。它使用了操作系统自身提供的IO多路复用技术,并且在实现上对其他常见的事件驱动模型(如select和poll)进行了优化。 事件处理的核心思想就是对IO事件进行异步处理,以充分利用单线程或有限的线程资源,并保证服务器响应能力和稳定性。

实际上就是要深入理解Netty,那么就要理解NIO,Reactor 模型,理解事件驱动模型;编程语言的I/O处理实际上都是基于操作系统的I/O处理,而操作系统的I/O处理,实际上是为了最大化利用计算机CPU。I/O是个很深的问题,笔者也是抛砖引玉。个人观点,要比较深入理解I/O那么对操作系统要有比较深入的认识和了解,就和并发一样。如果只是从编程语言层面,收获一般不会很多。