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

基于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>() {@Overrideprotected 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> {@Overrideprotected 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> {@Overrideprotected 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> {@Overrideprotected void channelRead0(ChannelHandlerContext ctx, String str) throws Exception {// 上一个处理器给我传递了一个字符串}
}

也可以:

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

并不是固定的。

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

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

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

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

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

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

做一下简单的魔数校验:

public class CustomMagicNumberHandler extends SimpleChannelInboundHandler<ByteBuf> {@Overrideprotected 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> {@Overrideprotected void initChannel(SocketChannel channel) throws Exception {ChannelPipeline pipeline = channel.pipeline();// 长度处理器,它也是入站处理器1pipeline.addLast(new LengthFieldBasedFrameDecoder((1024 * 1024) * 50, // 限制最大报文长度为50MB0, 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.rhkb.cn/news/490166.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

小程序播放设备没有声音

使用在使用小程序播放设备时没有声音请按照以下步骤排查 1、确认设备是否开启麦克风 设备的本地配置页面可以查看麦克风的开启状态&#xff0c;也可以通过其他方式播放检查是否有声音&#xff0c;比如萤石app或者ezuikit&#xff0c;若其他端播放有声音说明设备的麦克风已开启 …

【考前预习】3.计算机网络—数据链路层

往期推荐 【考前预习】2.计算机网络—物理层-CSDN博客 【考前预习】1.计算机网络概述-CSDN博客 浅谈云原生--微服务、CICD、Serverless、服务网格_云原生cicd-CSDN博客 子网掩码、网络地址、广播地址、子网划分及计算_子网广播地址-CSDN博客 浅学React和JSX-CSDN博客 目录 1.数…

bean创建源码

去字节面试&#xff0c;直接让人出门左拐&#xff1a;Bean 生命周期都不知道&#xff01; spring启动创建bean流程 下面就接上了 bean生命周期 doGetBean Object sharedInstance this.getSingleton(beanName); sharedInstance this.getSingleton(beanName, new ObjectF…

iOS swift开发系列 -- tabbar问题总结

1.单视图如何改为tabbar&#xff0c;以便显示2个标签页 右上角➕&#xff0c;输入tabbar 找到控件&#xff0c;然后选中&#xff0c;把entrypoint移动到tabbar控件 2.改成tabbar&#xff0c;生成两个item&#xff0c;配置各自视图后&#xff0c;启动发现报错 Thread 1: “-[p…

【常考前端面试题总结】---2025

React fiber架构 1.为什么会出现 React fiber 架构? React 15 Stack Reconciler 是通过递归更新子组件 。由于递归执行&#xff0c;所以更新一旦开始&#xff0c;中途就无法中断。当层级很深时&#xff0c;递归更新时间超过了 16ms&#xff0c;用户交互就会卡顿。对于特别庞…

直流开关电源技术及应用

文章目录 1. 开关电源概论1.1 开关电源稳压原理1.1.1 开关电源稳压原理核心组成部分及其作用工作过程稳压原理 1. 开关电源概论 1.1 开关电源稳压原理 为了提高效率&#xff0c;必须使功率调整器件处于开关工作状态。 作为开关而言&#xff0c;导通时压降很小&#xff0c;几乎…

汽车嵌入式软件构建高效技术团队的全面思考

在汽车嵌入式软件开发领域&#xff0c;构建一支高效的通用技术团队至关重要。这类团队负责为各种项目提供可复用、标准化的技术基石&#xff0c;从而提高开发效率、降低成本并确保产品质量。构建这样的团队需要从技术能力、角色分工、标准化与复用、流程管理与质量保证、工具和…

SpringBoot左脚进门之Maven管理家

一、概念 Maven 是一个项目管理和整合工具。通过对 目录结构和构建生命周期 的标准化&#xff0c; 使开发团队用极少的时间就能够自动完成工程的基础构建配置。 Maven 简化了工程的构建过程&#xff0c;并对其标准化&#xff0c;提高了重用性。 Maven 本地仓库 (Local Reposi…

活动报名:Voice Agent 开发者分享会丨RTE Meetup

引入 voice agent 的口语学习应用 Speak 估值已达 10 亿美元 Voice Agent 开发者分享会 一同探索语音驱动的下一代人机交互界面&#xff0c;一场 voice agent builder 的小规模深度交流会。 RTE Meetup 迎来第六期&#xff01;12 月 15 日&#xff08;周日&#xff09;上午&…

gentoo安装Xfce桌面

一、安装Xfce 1.选择一个配置文件 参见另一篇“https://blog.csdn.net/my1114/article/details/143919066”&#xff0c;配置文件选择24. 2.安装Xfce (1)root #emerge --ask xfce-base/xfce4-meta 第一次启动登录后时可能还需starx来启动X11 (2)安装slim&#xff08;X11登录管理…

舌头分割数据集labelme格式2557张1类别

数据集格式&#xff1a;labelme格式(不包含mask文件&#xff0c;仅仅包含jpg图片和对应的json文件) 图片数量(jpg文件个数)&#xff1a;2557 标注数量(json文件个数)&#xff1a;2557 标注类别数&#xff1a;1 标注类别名称:["tongue"] 每个类别标注的框数&#xff1…

【提升工作能力:五大关键要素】

提升工作能力&#xff1a;五大关键要素 在职场中&#xff0c;工作能力强的员工往往能够更高效地完成任务&#xff0c;赢得同事的尊重和领导的信任。那么&#xff0c;如何提升自己的工作能力呢&#xff1f;根据上述内容&#xff0c;我们可以将提升工作能力的关键要素归纳为以下…

【Linux服务器nginx前端部署详解】ubantu22.04,前端Vue项目dist打包

本文主要讲一下在Linux系统环境下&#xff08;以ubantu22.04为例&#xff09;&#xff0c;如何用nginx部署前端Vue项目打包的dist静态资源。有些具体的命令就不展开讲了&#xff0c;可以自行查看其他博主的文章&#xff0c;我主要讲整体的步骤和思路。 一、ubantu系统安装ngin…

(后序遍历 简单)leetcode 101翻转二叉树

将根结点的左右结点看作 两个树的根结点&#xff0c;后序遍历&#xff08;从叶子结点从下往上遍历&#xff09; 两个树边遍历边比较。 左节点就左右根的后序遍历 右根结点就右左根的后序遍历来写 后序遍历&#xff08;从叶子结点从下往上遍历&#xff09; /*** Definition …

【Google Cloud】VPC Service Controls 的试运行模式

本文介绍了 VPC 服务控制的试运行模式。 什么是 VPC Service Controls VPC Service Controls 是 Google Cloud&#xff08;以前称为 GCP&#xff09;的一项安全功能。它通过设置一个被称为 边界 的逻辑围栏&#xff0c;防止从内部到外部和从外部到内部的双向意外访问&#xf…

NAT网络地址转化技术

1.什么是NAT NAT技术是一种将自己内网的多个私有IP地址转换为一个公网IP进行访问互联网的一项技术&#xff0c;这个技术主要是用来解决IPv4地址不够的问题。 2.NAT技术的具体例子 如果我们用手机使用流量浏览一个网站&#xff0c;那么第一步手机会对这个域名进行DNS解析&#…

图形几何之美系列:铺装算法效果赏析

你知道怎么设置地砖排布吗&#xff1f;或者墙砖排布 有一块布料&#xff0c;怎么填充好看的纹理&#xff1f; 可以用铺装算法&#xff0c;使得设计和艺术变得更有创造力 轮廓铺装&#xff1a;地板铺装、墙砖铺砖、吊顶铺装、图案填充。 图案变得更具艺术感~电视机&#xff1f…

Tomcat项目本地部署

前言&#xff1a; 除了在idea中将项目启动之外&#xff0c;也可以将项目部署在本地tomcat或者云服务器上&#xff0c;本片文章主要介绍了怎样将项目部署在本地tomcat 下面介绍如何使用Tomcat部署本地项目&#xff1a; 1、本篇文章使用的项目案例为一个聚合项目&#xff0c;ha…

Django结合websocket实现分组的多人聊天

其他地方和上一篇大致相同&#xff0c;上一篇地址点击进入, 改动点1&#xff1a;在setting.py中最后再添加如下配置&#xff1a; # 多人聊天 CHANNEL_LAYERS {"default":{"BACKEND": "channels.layers.InMemoryChannelLayer"} }因此完整的se…

网络工程师常用软件之配置对比软件

老王说网络&#xff1a;网络资源共享汇总 https://docs.qq.com/sheet/DWXZiSGxiaVhxYU1F ☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝ 我们经常在项目或者运维中对设备的config进行变更&am…