【Java Nio Netty】基于TCP的简单Netty自定义协议实现(万字,全篇例子)

news2025/3/1 14:12:41

基于TCP的简单Netty自定义协议实现(万字,全篇例子)

前言

有一阵子没写博客了,最近在学习Netty写一个实时聊天软件,一个高性能异步事件驱动的网络应用框架,我们常用的SpringBoot一般基于Http协议,而Netty是没有十分明确的协议的,不过它内置了一些常用的通信协议,当然你也可以自定义协议。

一、要求

接下来的内容默认你已经有了最基本的JavaNettyNio知识,如果还没有这方面的知识的话,可以先去小破站找个视频学习学习。

二、通信协议

* 本文提到的通信协议都是指基于TCP的应用层通信协议,请勿理解错误。

1、协议基本单位

当数据在两台计算机上传输时,传输的数据以比特(Bit)为单位,就像01010100010010101...这种,但是以比特作为传输单位太过精细、太过底层,所以封装一下它,将8bit封装成一个单位,就成了字节(Byte),所以一个协议的基本单位是字节Byte。同样的,因为字节是其他大多数高级数据类型的基本组成,所以通信协议的基本单位是字节。例如一串字节流可以被解析为视频、图片、字符串等等,它是通用的。

也就是说,我们要自定义一个通信协议,就必须得自己解析字节。在SpringBoot框架中,我们在Controller中能够直接得到字符串、对象的原因是框架已经帮我们将字节解析好了,我们直接用就行,但是如果我们要自定义协议,就必须自力更生,自己定义格式并解析它。

2、协议格式

协议的格式不是固定的,协议只能是一个约定而不是强制要求。

举个例子,假如你在晚自习上睡觉,你提前和同桌约定好,老师来了他就敲两下桌子,班长来了他就敲三下桌子,那么这种约定就可以认定为是一个通信协议,但其并不是固定的,因为明晚、后晚…你可以约定其他方式,例如敲一下变成老师来了,敲两下变成班长来了,踢你一下表示老师来了,踢你两下表示班长来了。并不是固定的。

基于这种思想,我们可以定义一个简单的通信协议,版本号为V1

请求地址  客户端IP  请求正文

基于这个协议,假如我们有一个请求,它请求服务器的/test地址,客户端IP是192.168.1.2,请求正文是hello,那么这个协议看起来就像:

/test192.168.1.2hello

将它转为字节流就是(没有空格,空格只是为了方便查看加的):

47 116 101 115 116 49 57 50 46 49 54 56 46 49 46 50 104 101 108 108 111

服务器在解析时,就可以解析[0,5]个字符串为请求地址[/test],解析[6,11]个字符串为客户端IP[192.168.1.2],解析剩下的所有字符串为请求正文。

当然,为了形象一点举了一个不太恰当的简单例子,解析的不是字符串而是字节。

3、TCP的粘包半包

Ⅰ、问题描述

这个问题可能我一时半会解释不清楚,导致粘包半包的原因很多,感兴趣的可以去找找资料。

你只用知道,基于TCP时,数据并不是一次性达到的,而是分段到达的,例如我们上面举的例子,那个协议数据:/test192.168.1.2hello,服务器在接收这些数据时它就有可能:

第一次收到:/test19
第二次收到:2.168.
第三次收到:1.2hello
...

它可能不会一次收全,可能要好几次,所以我们上面定义的简单的协议就有一个问题:它没有消息边界,就是当客户端多次发送数据时,服务器无法知道哪些数据是哪次请求的。还是刚才的例子:

第一次收到:/test192.168.1.2he
第二次收到:llo/haha192.168.1.2hi

在这两次数据中,客户端分别发送了两次请求:/test192.168.1.2hello/haha192.168.1.2hi,但是因为粘包半包的问题,服务器不知道哪条是哪条了,就会导致解析出错。

Ⅱ、如何解决

解决这个问题有很多种方法,常见的方法有分隔符、标识请求长度等等。两种方法我都举个例子,你也可以自己想一个方法来解决,都是灵活的,解决方法不是固定的。

分隔符的方法也很简单:我们在每次请求结束时,都添加一个特殊符号,用于标识这个请求结束了,服务器在解析时,遇到这个特殊符号,就知道这个请求结束了,后面的数据是新请求的了。例如我们以$为分隔符,服务器:

第一次收到:/test192.168.1.2
第二次收到:hello$/haha192.168.1.2hi

服务器在解析到$符时,就知道/test请求已经结束了,后面的数据是属于/haha请求的了。但是这么做的话,有一个缺点,就是之后传输的正文数据中不能含有$符,不然解析依旧出错,你也可以定义复杂一点的符号,例如几个符号拼接也行:@$&...。不过我要说的是,其实你还可以用标识请求长度的方式解决。

标识请求长度就是客户端在传输请求之前,先计算好整个请求有多少个字符(为了不复杂先说成字符吧,其实是字节),再传输数据,服务器在接收到数据后,会去读取这个字段,查看整个请求有多少个字符,然后再根据这个数字读取多少个字符。那这就需要一个字段用来专门存储长度了。

基于这个需求,我们上面定义的协议就得小小的升级一下,变成V2

请求长度  请求地址  客户端IP  请求正文

以后,服务器会先读取开头的长度,再根据长度读取后面的数据,例如我们还是刚才的/test请求,那么它将会变成:

21/test192.168.1.2hello

因为 /test192.168.1.2hello 总共是21个字符,所以一开始就变为了21,服务器一读取到开头的数字21,就往下读取21个字符,读完后,就默认这个请求已经结束了,再往下的就是其他请求了。

当然,你也可以将长度字段包含在内,那就是:

23/test192.168.1.2hello

这个长度可以出现在整个请求体的任何地方(除了正文),只要你在服务器/客户端解析的时候对应解析就行了。

暂时就介绍这个两个简单的方法,其他的方法你可以自己想,想出来了可以自己实现,原则是能解决问题就是好办法。

三、创建协议

1、改正上面的说法

在上面的各个例子中,我为了例子不复杂说的是解析字符,其实解析的是字节(Byte)

字符是字符,字节是字节,它们不一样, 是一个字符,你好 是一个字符串,而 -28(十进制) 它是一个字节,-28-67-96 它们三个字节组成了一个字符

***UTF8编码下,常见的中文字符一般由3个字节组成,不常见的一般是4个字节组成。

***UTF8编码下,英文字符一般由1个字节组成。

*** 数字的情况稍微复杂:
1、8位的数字一般占用1字节,范围从 -128 到 127
2、16位的数字一般占2字节,范围从 -32,768 到 32,767
3、32位数字一般占4字节,范围从 -2,147,483,648 到 2,147,483,647
4、64位数字一般占8字节,范围从 -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807
5、128位数字一般占16字节,范围很大,不写了。
例如在Rust中,i324字节,它对应的Java数字类型是inti648字节,对应的Java类型是long,以此类推。
JavaScriptnumber类型是64位的,占8字节,所以js要想表达64位以下的就有点麻烦了。

2、SP协议

解释了上面的错误后,可以开始正式自定义协议了,给这个协议取个名字,就叫SP协议吧,Simple Protocol,译为简单的协议。

Ⅰ、报文长度

首先,粘包半包的问题用长度字段解决,4个字节表示的32位数字就够用了,它的范围是-2,147,483,648 到 2,147,483,647,负的20亿到正的20亿,用来表示数据的话(不算负数):2147483648 / (1024 * 1024) = 2048 MB,也就是说32位数字所表示的数字范围(正数)用来表示数据大小的话,可以表示2GB的数据,一个请求根本不可能达到这么大,所以32位的数字够用。因为2147483648个字节就是2GB

那么协议开头就是:

长度4字节
Ⅱ、魔数

在协议中添加一个魔数,用来标识这个报文是属于SP协议的,服务器在网络中读取字节流时,如果在长度字节后没有找到这个魔数,就证明该字节流不是SP协议的,就可以停止读取接下来的数据了,可以做关闭连接、丢弃数据等操作,就好像,你去坐火车去北京,火车进站时你看第二节车厢上有没有写目的地北京,如果写了,那么就是你要坐的火车,如果没写,那就证明不是你要坐的火车,你可以等下一趟。其实就是为整个协议打一个标记。

魔数用几个字节都行,为了不重复,建议使用4字节的32位数字,那么协议的第二部分应该是:

长度4字节 魔数4字节
Ⅲ、客户端身份

在多个客户端连接时,服务器需要为每个客户端颁发一个标识,用来区分不同的客户端的请求,用几个字节都行,为了不重复,建议使用32字节的uuid作为客户端唯一标识。

那么协议第三部分是:

长度4字节 魔数4字节 客户端标识32位
Ⅳ、请求路径

请求路径这块比较灵活,你可以使用1字节的8位数字表示,也就是-128 到 127个数字。例如,你可以规定1就是登录,2就是注册等等。

我使用的是英文字符串的方式,也就是一个字符一个字节,但是路径长度不是不变的,它会变化。例如 /test5个字节,但是 /hi3个字节,不能像刚才一样用固定的长度来标识,那么就需要一个固定的路径长度字段,用来表示后续路径的长度。

于是协议的第四部分就是:

长度4字节 魔数4字节 客户端标识32位 路径长度4字节 路径N字节
Ⅴ、请求正文

到这步后这个简单的协议就基本完成了,后续的正文长度是不定的,但是我们有开头的长度字段表示整个报文的长度,所以这个协议第五部分就是:

长度4字节 魔数4字节 客户端标识32位 路径长度4字节 路径N字节 正文N字节

3、完整协议

协议定义到这后基本完成了,但是这只是一个简单的例子,实际应用中肯定要复杂许多。

基于该协议,模拟一个请求,它请求/test路径,使用Java字节码文件同款的魔数0xCAFEBABE,请求正文是hello,那么这个协议组装完成应该是这样的:

| 54 | 0xCAFEBABE | 32位的UUID | 5 | /test | hello |

解释一下,首先魔数占了4字节,UUID占了32字节,路径长度占了4字节,路径占了5字节,正文占了5字节,报文长度字段不计算在内,所以总长度是:4 + 32 + 4 + 5 + 5 = 54字节,这就是开头54的由来。

路径 /test 前的 5 就是表示 /test 所占的 5 字节。

至此,协议定义完成,任何只要遵守了这个协议的请求都能够被Netty服务器识别。

四、服务器代码实现

协议定义好了,该写服务器代码实现这个协议了。

1、Netty服务器启动流程

首先得先来复习一下Netty的启动流程,我们才知道如何实现这个协议。

快速启动一个Netty服务器代码:

public static void main(String[] args) {
   NioEventLoopGroup boss = new NioEventLoopGroup(1);// 处理连接
   NioEventLoopGroup worker = new NioEventLoopGroup();// 处理业务
   try {
       ChannelFuture channelFuture = new ServerBootstrap()
               .group(boss, worker) // 设置线程组
               .channel(NioServerSocketChannel.class) // 使用NIO通信模式
               .childHandler(new ChannelInitializer<SocketChannel>() {
                   @Override
                   protected void initChannel(SocketChannel socketChannel) throws Exception {
                       // 在这里添加自定义的处理器
                   }
               })
               .bind(8080).sync();// 绑定端口并启动服务器
       System.out.println("Netty Server is starting...");
       channelFuture.channel().closeFuture().sync();// 监听关闭
   } catch (InterruptedException e) {
       throw new RuntimeException(e);
   }finally {
       // 优雅的关闭线程组
       boss.shutdownGracefully();
       worker.shutdownGracefully();
   }
}

要想自定义一个协议,我们的重点在 initChannel() 方法上,它可以为Netty添加处理器,在TCP收到的数据传过来的时候,处理原始的字节流数据

2、添加自定义处理器

Ⅰ、解释ChannelInitializer的作用

为了启动看起来清爽,我们可以将childHandler()所需的参数抽取出来:

public class CustomHandler extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {
    
    }
}

childHandler()中传递.childHandler(new CustomHandler())

原始的字节流数据在达到Netty的时候,Netty内部会在我们自定义的处理器之前先做一些处理,比如说将字节流数据封装成ByteBuf对象等等,就像SprinigBoot我们添加自定义拦截器一样,在我们添加的拦截器之前,SpringBoot就已经添加了许多内部的拦截器先一步处理过数据了。

也就是说,我们自定义处理器接收到的数据,其实是经过ByteBuf封装过的字节流缓冲对象,ByteBuf对象其实就是对Java.NioByteBuffer的进一步封装升级。

画个简陋的图,自定义处理器处理数据的整个流程看起来像这样:

在这里插入图片描述

我们刚刚自定义的处理器初始化器就是这部分:

在这里插入图片描述

它的作用就是往处理器链中添加一个个的自定义处理器,在ChannelInitializer中添加处理器也很简单,继承ChannelInitializer并实现它的initChannel方法,再通过initChannel的形参SocketChannel获取到ChannelPipeline就可以添加了,代码像这样:

@Override
protected void initChannel(SocketChannel channel) throws Exception {
    ChannelPipeline pipeline = channel.pipeline();
    pipeline.addLast(处理器对象);// 添加一个个的处理器
    pipeline.addLast(处理器对象);// 添加一个个的处理器
    ...
}
Ⅱ、出站(Outbound)和入站(Inbound)

我没打错字,是出站入站,不是出栈入栈,说白了其实就是数据进入Netty和数据从Netty发出,进入Netty的行为叫入站Netty往外发送数据的行为叫出站

所以处理器可以分为三种:入站处理器出站处理器入站出站处理器入站处理器专门处理进入Netty的数据,出站处理器专门处理从Netty发送的数据,而入站出站处理器则两者都可以。

这些处理器看起来像这样:

在这里插入图片描述

*** 注意,出站处理器的顺序是与入站相反的,出站是从尾巴上为第1个处理器,头为最后一个处理器,处理数据时会按照顺序一个一个进行。

有一个比喻可以很好理解它们之间的关系:
处理器链pipeline就像两条相反的流水线,pipeline.addLast();方法就像在流水线上安排一个工人,调用一次就安排一个工人,只不过一些工人专门处理过来的货物,一些工人专门处理过去的货物。

好了,接下来我们开始代码实现处理器了。

Ⅲ、处理器实现
①、处理长度

报文长度字段是我们自定义协议SP协议的第一个字段,所以第一个处理器我们先处理长度。

首先,这个处理器肯定是入站处理器,因为是客户端发送来的数据,我们要解析。而入站处理器怎么写呢?

其实Netty为我们提供了入站出站处理器的多个模板,我们需要继承并写上自己的实现就行了。

最简单的入站处理器是SimpleChannelInboundHandler,源代码我就不讲了,不然又要讲半天。我们新建一个类继承它,这个类就叫CustomLengthHandler吧:

public class CustomLengthHandler extends SimpleChannelInboundHandler<ByteBuf> {
    @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf) throws Exception {
        
    }
}

为什么SimpleChannelInboundHandler的泛型是ByteBuf?其实这里不一定是固定的(不是第一个处理器的情况),你想是什么都可以,取决于上一个处理器传递给当前处理器什么东西,还记得我们上面的那个流程图吗?:

在这里插入图片描述

一个一个的处理器处理完数据后,可以继续往下传递数据,传递的数据就是自定义的。例如我从上一个处理器得到ByteBuf对象,我将其解析完后,封装成一个对象MyObject,那么我可以往下传递这个MyObject对象,下一个处理器就不用再处理一遍ByteBuf原始数据了,下一个处理器直接处理MyBoject封装好数据的对象就行了。类比一下,就好像上一个处理器给我当前处理器传递一个JSON字符串,我当前处理器处理JSON字符串,将其序列化为对象,并往下传递这个对象,那么下一个处理器就不用再处理原始的JSON字符串了,就这么个意思。

所以SimpleChannelInboundHandler的泛型就是上一个处理器,传递给当前处理器的数据的类型,刚才解释过了,它并不是固定的,上面的CustomLengthHandler也可以这么写:

public class CustomLengthHandler extends SimpleChannelInboundHandler<String> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String str) throws Exception {
        // 上一个处理器给我传递了一个字符串
    }
}

也可以:

public class CustomLengthHandler extends SimpleChannelInboundHandler<Integer> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Integer itg) throws Exception {
    	// 上一个处理器给我传递了一个数字
    }
}

并不是固定的。

好了,不说废话了,开始代码实现:

因为我们是第一个入站处理器,上面我们也提到过,Netty内部会将数据封装成ByteBuf,所以我们从上一个处理器接收到的数据其实是一个ByteBuf对象,所以第一个处理器的泛型必需为ByteBuf

public class CustomLengthHandler extends SimpleChannelInboundHandler<ByteBuf> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) throws Exception {
    
    }
}

ByteBuf是一个字节缓冲区,我们可以从它读取到字节数据,例如:

protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) throws Exception {
    byte b = buf.readByte();// 读取1个字节
    int i = buf.readInt();// 读取4个字节,因为我们之前说了Java的int是4字节组成的
    buf.readShort();// 依次类推,读取2字节
    buf.readLong();
    String str = buf.readBytes(5).toString(StandardCharsets.UTF_8);// 读取5个字节并转为字符串,注意编码为UTF8
}

还记得吗,在我们的SP协议中,我们定义前四个字节是报文长度,所以一开始我们先读取4字节:

protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) throws Exception {
    int msgLength = buf.readInt();// 报文长度
}

在得到这个报文长度字段后,我们需要对ByteBuf的长度做一下判断,如果它的长度小于报文长度,那就说明数据还未全部到达,那我们先不做处理,等完全到达后再做处理,代码像这样:

protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) throws Exception {
    int msgLength = buf.readInt();
    if (buf.readableBytes() < msgLength){ // 缓冲区中的数据不足 msgLength 个,暂不处理
        return;
    }
    // 读取 msgLength 个字节,也就是整个报文长度的字节,它得到的就是整个报文的完整字节缓冲区
    ByteBuf bufNew = buf.readBytes(msgLength);// 读取 msgLength 个字节,不包含 msgLength 占用的4字节
    // 为了效率也可以写为:
    // ByteBuf bufNew = buf.readSlice(msgLength);
    ctx.fireChannelRead(bufNew);// 传递给下一个处理器
}

为什么要这样写?还记得一开始我提到的TCP粘包半包吗?因为数据并不是一次完整到达的,所以我们必需处理数据部分达到的情况。ByteBuf就像一个蓄水池,从管道中一开始流进来一些水,但是这些水没有达到蓄水池该有的蓄水量,所以不管它,等它满足了蓄水量,我们再处理。

buf.readBytes(msgLength);就是一次性从蓄水池(ByteBuf)中获取msgLength量的水(字节),并将它放到一个新的水池(ByteBuf bufNew)中,这个新的水池,包含了完整的水量(报文所有字节),接着往下传递这个新的水池ctx.fireChannelRead(bufNew);

定义完处理器后,还需要将它添加进处理器链中,还记得我们上面一开始定义的public class CustomHandler extends ChannelInitializer<SocketChannel>吗?在其中添加:

public class CustomHandler extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel channel) throws Exception {
        ChannelPipeline pipeline = channel.pipeline();
        pipeline.addLast(new CustomLengthHandler());// 我们自定义的第一个长度处理器,它也是入站处理器1
    }
}

到此为止,这个超级简单的报文长度处理器就写完了,当然,这个处理器有很多的问题,它只作为演示,实际使用会有很多Bug,因为实际使用中要处理的情况有点复杂,好在Netty给我们提供了一个开箱即用的报文长度处理器,这也是为什么我写得这么简单的原因,因为只需了解简单的原理而不需要深入探索,Netty有现成的。

这个处理器就是 LengthFieldBasedFrameDecoder,它的构造函数常用且重要的有5个参数,类型都是int,我们一个一个来看:
1、第一个参数maxFrameLength,是整个报文最大长度,说白了就是限制报文大小的,你的报文不可能无限大。
2、第二个参数lengthFieldOffset,是你的长度字段是从第几个字节开始的,我们的SP协议定义了一开始就是长度字段,所以这个参数我们可以填0。
3、第三个参数lengthFieldLength,是你的长度字段占几个字节,我们定义的SP协议指明了长度字段占4个字节,所以填4就行。
4、第四个参数lengthAdjustment,有点绕,是指没有计算进长度,但是在报文中存在的数据的长度。例如你有数据:5ab,因为长度字段5占用4个字节,b占用1个字节,但是没有把a占用的1个字节算进来,所以这个例子中,lengthAdjustment就得填1,如果是6ab,那么lengthAdjustment就得填0,因为你将a占用的1字节算进来了。
5、第五个参数initialBytesToStrip,是指最终得到的数据要跳过几个字节,在我们的SP协议中,如果接下来的数据你不想要长度字段,那就可以跳过长度字段的4字节,initialBytesToStrip就可以填4,那么得到的数据中就不包含长度了。

基于我们的SP协议,最终得到的处理器应该是:

public class CustomHandler extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel channel) throws Exception {
        ChannelPipeline pipeline = channel.pipeline();
        // 长度处理器,它也是入站处理器1
        pipeline.addLast(new LengthFieldBasedFrameDecoder((1024 * 1024) * 50, // 限制最大报文长度为50MB
                0, 4, 0, 0));// 长度是从0开始的,长度字段4字节,偏移量为0,不跳过字节
    }
}
②、魔数校验

长度处理完了,现在TCP粘包半包所带来的问题我们解决了,接下来就是校验魔数,新增一个入站处理器:

public class CustomMagicNumberHandler extends SimpleChannelInboundHandler<ByteBuf> {
    @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf) throws Exception {
        
    }
}

LengthFieldBasedFrameDecoder中传递过来的数据依旧是ByteBuf,所以泛型我们依旧写成ByteBuf,到达这里的数据,其实还是原始的报文数据,只不过经过前面的处理它一定是完整的。

做一下简单的魔数校验:

public class CustomMagicNumberHandler extends SimpleChannelInboundHandler<ByteBuf> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) throws Exception {
        buf.readInt();// 跳过开头的4字节长度字段
        int magicNumber = buf.readInt();
        if (magicNumber != 0xCAFEBABE){
            ctx.close();// 魔数不正确,直接关闭连接
        }
        ctx.fireChannelRead(buf);
    }
}

将处理器添加进处理器链:

public class CustomHandler extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel channel) throws Exception {
        ChannelPipeline pipeline = channel.pipeline();
        // 长度处理器,它也是入站处理器1
        pipeline.addLast(new LengthFieldBasedFrameDecoder((1024 * 1024) * 50, // 限制最大报文长度为50MB
                0, 4,
                0, 0));// 长度是从0开始的,长度字段4字节,偏移量为0,不跳过字节
                
        pipeline.addLast(new CustomMagicNumberHandler());// 魔数处理器,入站处理器2
    }
}
③、为客户端生成唯一值UUID或校验客户端的UUID是否存在

这里我就不写了,其实就是简单的颁发身份证明和校验身份证明而已,生成一个唯一值,然后存储到服务器上,这里判断UUID是否存在在报文中,如果不存在为其生成一个UUID并存储,如果存在,从服务器存储的UUID中找看能不能找得到。
后面的代码可以根据协议定义的规则解析。

④、其他规则实现

Ⅳ、需要注意的点
①、ByteBuf的读取

ByteBuf在读取的时候是不可回退的,就像迭代器,迭代到下一个就不能再回去读上一个了,要想回去重新读,必需得重置读取:

buf.resetReaderIndex();

然后又从最开头开始读取。ByteBuf中数据的基本单位是字节,readInt()readLong()等方法实际上读取的都是字节,只不过封装了一下,将多个字节转为对应Java类型了。

②、字符编码

注意,解析协议时,客户端与服务器都要使用相同的字符编码,否则解析字节会对不上,因为有些字符编码使用的字节数可能不太一样。

③、业务逻辑处理

协议解析完后,将数据传递到业务逻辑时,可以使用Netty服务器启动时的:

NioEventLoopGroup worker = new NioEventLoopGroup();

worker来处理业务逻辑,worker的本质其实是一个线程池。

其他的注意事项我想起来了后续会加,有什么问题可以评论区留言,看到会回复。

五、简单封装的框架

根据以上代码的思路,我封装了一个简单的开源框架,主要处理SP协议的加强版,它包含了长度处理魔数客户端标识路径处理数据加密等操作(暂未做数据验证)。

源代码链接是:simple-netty-core,丢在gitee上了,为什么不是GitHub?因为我的电脑不科学上网的话,始终访问不到GitHub,即使修改了host文件也访问不到,所以干脆就将源代码丢在gitee上了。

这个框架是我学习Netty时写的,比较简单,基本能使用,感兴趣的可以参考一下,也欢迎贡献。

写在最后

最后叠个甲吧:以上内容是我个人理解,不保证全部正确,如有遗漏、错误等后续我会回来更新这篇博客,欢迎评论区指正。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2259542.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Ubuntu 20.04LTS 系统离线安装5.7.44mysql数据库

Ubuntu 20.04LTS 系统离线安装5.7.44mysql数据库 环境下载 MySQL 5.7.44 包安装标题检查服务是否启动成功遇到的问题登陆&修改密码&远程访问 环境 操作系统&#xff1a;Ubuntu 20.04.4 LTS 数据库&#xff1a;MySQL 5.7.34 内核版本&#xff1a;x86_64&#xff08;amd…

后端-redis的使用

redis的服务端启动命令&#xff0c;打开redis的目录&#xff0c;输入cmd redis的客户端启动命令 设置redis密码 redis连接 指定ip地址的服务端,没设密码&#xff1a;redis-cli.exe -h localhost -p 6379 edis连接 指定ip地址的服务端,设置了密码&#xff1a;redis-cli.ex…

前端成长之路:CSS字体、文本属性和引入方式

本文主要介绍CSS的字体属性和文本属性&#xff0c;最后再介绍CSS在HTML中的引入方式。 CSS字体属性 CSS Fonts&#xff08;字体&#xff09;属性能用于定义字体系列属性&#xff0c;包括但不限于字体大小、粗细、字体样式等。 字体系列 在CSS中使用font-family属性定义文本…

基于windows环境使用nvm安装多版本nodejs

目录 前言 一、卸载node 二、nvm是什么&#xff1f; 三、nvm安装 1.官网下载 nvm 包 2. 安装nvm-setup.exe 3. 配置路径和下载镜像 4. 检查安装是否完成 四、 使用nvm安装node 五、修改npm默认镜像源为淘宝镜像 六、环境变量配置 1. 新建目录 2. 设置环境变量 七…

排序算法(2):选择排序

问题 排序 [30, 24, 5, 58, 18, 36, 12, 42, 39] 选择排序 选择排序每次从待排序序列中选出最小&#xff08;或最大&#xff09;的元素&#xff0c;将其放到序列的起始位置&#xff0c;然后&#xff0c;再从剩余未排序元素中继续寻找最小&#xff08;或最大&#xff09;元素…

009-jvm-对象相关的概念

#案例&#xff1a; 对象的创建过程 初始化默认值 成员变量显示赋值 构造代码块的初始化 构造器中的初始化 jvm

【硬件测试】基于FPGA的4FSK调制解调通信系统开发与硬件片内测试,包含信道模块,误码统计模块,可设置SNR

目录 1.算法仿真效果 2.算法涉及理论知识概要 3.Verilog核心程序 4.开发板使用说明和如何移植不同的开发板 5.完整算法代码文件获得 1.算法仿真效果 本文是之前写的文章: 《基于FPGA的4FSK调制解调系统,包含testbench,高斯信道模块,误码率统计模块,可以设置不同SNR》 的…

20 go语言(golang) - gin框架安装及使用(一)

一、简介 Gin是一个用Go语言编写的高性能Web框架&#xff0c;专注于构建快速、可靠的HTTP服务。它以其速度和简洁性而闻名&#xff0c;非常适合用于开发RESTful API。 高性能&#xff1a;Gin使用了httprouter进行路由管理&#xff0c;这是一个轻量级且非常快速的HTTP请求路由器…

检查读取数据寄存器输出的多扇出

为使第二寄存器被 RAM 原语吸收&#xff0c;来自存储器阵列的数据输出位的扇出必须为 1 。这在下图中进行了说明。 检查地址 / 读取数据寄存器上的复位信号 不应复位存储器阵列。只有 RAM 的输出可以容许复位。复位必须是同步的&#xff0c;以便将输出寄存器推断到 RAM 基元…

rk3588-ubuntu22.04系统网关实现路由器功能:

rk3588-ubuntu22.04系统网关实现路由器功能&#xff1a; 场景需求描述&#xff1a; 需求背景&#xff1a; 场景一&#xff1a;通过网线eth0/(路由器wlan0)访问外网&#xff1a; 如果网关 和 设备所处的环境可以通过网线联网或者路由器联网&#xff0c;那么不需要将网关配置成…

Tomcat的下载和使用,配置控制台输出中文日志

目录 1. 简介2. 下载3. 使用3.1 文件夹展示3.1.1 控制台输出乱码 3.2 访问localhost:80803.3 访问静态资源 4. 总结 1. 简介 Tomcat&#xff0c;全称为Apache Tomcat&#xff0c;是一个开源的Web应用服务器和Servlet容器&#xff0c;由Apache软件基金会的Jakarta项目开发。它实…

【银河麒麟高级服务器操作系统】有关dd及cp测试差异的现象分析详解

了解更多银河麒麟操作系统全新产品&#xff0c;请点击访问 麒麟软件产品专区&#xff1a;https://product.kylinos.cn 开发者专区&#xff1a;https://developer.kylinos.cn 文档中心&#xff1a;https://documentkylinos.cn dd现象 使用银河麒麟高级服务器操作系统执行两次…

【在Linux世界中追寻伟大的One Piece】自旋锁

目录 1 -> 概述 2 -> 原理 3 -> 优缺点及使用场景 3.1 -> 优点 3.2 -> 缺点 3.3 -> 使用场景 4 -> 纯软件自旋锁类似的原理实现 4.1 -> 结论 5 -> 样例代码 1 -> 概述 自旋锁是一种多线程同步机制&#xff0c;用于保护共享资源避免受并…

顺序表的使用,对数据的增删改查

主函数&#xff1a; 3.c #include "3.h"//头文件调用 SqlListptr sql_cerate()//创建顺序表函数 {SqlListptr ptr(SqlListptr)malloc(sizeof(SqlList));//在堆区申请连续的空间if(NULLptr){printf("创建失败\n");return NULL;//如果没有申请成功&#xff…

利用卷积神经网络进行手写数字的识别

数据集介绍 MNIST&#xff08;Modified National Institute of Standards and Technology&#xff09;数据集是一个广泛使用的手写数字识别数据集&#xff0c;常用于机器学习和计算机视觉领域中的分类任务。它包含了从0到9的手写数字样本&#xff0c;常用于训练和测试各种图像…

题解 - 取数排列

题目描述 取1到N共N个连续的数字&#xff08;1≤N≤9&#xff09;&#xff0c;组成每位数不重复的所有可能的N位数&#xff0c;按从小到大的顺序进行编号。当输入一个编号M时&#xff0c;就能打印出与该编号对应的那个N位数。例如&#xff0c;当N&#xff1d;3时&#xff0c;可…

如何在 ASP.NET Core 3.1 应用程序中使用 Log4Net

介绍 日志记录是应用程序的核心。它对于调试和故障排除以及应用程序的流畅性非常重要。 借助日志记录&#xff0c;我们可以对本地系统进行端到端的可视性&#xff0c;而对于基于云的系统&#xff0c;我们只能提供一小部分可视性。您可以将日志写入磁盘或数据库中的文件&#xf…

监控易监测对象及指标之:宝兰德中间件JMX监控指标解读

监控易作为一款全面的IT监控软件&#xff0c;能够为企业提供深入、细致的监控服务&#xff0c;确保企业IT系统的稳定运行。在本文中&#xff0c;我们将详细解读监控易针对宝兰德中间件JMX的监控指标&#xff0c;以帮助用户更好地理解和应用这些监控数据。 监测指标概览&#x…

Ubuntu 安装 Samba Server

在 Mac 上如何能够与Ubuntu 服务器共享文件夹&#xff0c;需要在 Ubuntu 上安装 Samba 文件服务器。本文将介绍如何在 Ubuntu 上安装 Samba 服务器从而达到以下目的&#xff1a; Mac 与 Ubuntu 共享文件通过用户名密码访问 安装 Samba 服务 sudo apt install samba修改配置文…