【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.mzph.cn/web/63377.shtml

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

相关文章

【2025最新计算机毕业设计】基于SSM校园歌手赛事管理系统【提供源码+答辩PPT+文档+项目部署】

作者简介&#xff1a;✌CSDN新星计划导师、Java领域优质创作者、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java技术领域和学生毕业项目实战,高校老师/讲师/同行前辈交流。✌ 主要内容&#xff1a;&#x1f31f;Java项目、Python项目、前端项目、PHP、ASP.NET、人工智能…

Visual Studio 使用 GitHub Copilot 协助调试

&#x1f380;&#x1f380;&#x1f380;【AI辅助编程系列】&#x1f380;&#x1f380;&#x1f380; Visual Studio 使用 GitHub Copilot 与 IntelliCode 辅助编码Visual Studio 安装和管理 GitHub CopilotVisual Studio 使用 GitHub Copilot 扩展Visual Studio 使用 GitHu…

了解ARM的千兆以太网——RK3588

1. 简介 本文并不重点讲解调试内容&#xff0c;重点了解以太网在ARM设计中的框架以及在设备树以及驱动的一个整体框架。了解作为一个驱动开发人员当拿到一款未开发过的ARM板卡应该怎么去把网卡配置使用起来。 2. 基础知识介绍 在嵌入式ARM中实现以太网的解决方案通常有以下两种…

Springboot家政服务管理系统

摘 要 科技进步的飞速发展引起人们日常生活的巨大变化&#xff0c;电子信息技术的飞速发展使得电子信息技术的各个领域的应用水平得到普及和应用。信息时代的到来已成为不可阻挡的时尚潮流&#xff0c;人类发展的历史正进入一个新时代。在现实运用中&#xff0c;应用软件的工作…

DC-9笔记

靶机信息 官网:DC: 9 ~ VulnHub 只有一个flag,官网上没给其他提示 信息收集 nmap 192.168.66.2-254nmap 192.168.66.146 -A -p-开放了80端口,22端口是filtered的,被过滤? NMAP 六种端口状态解读_nmap filtered-CSDN博客 那来看看http服务吧 http(80) 页脚是空白的,插件也…

STM32-笔记3-驱动蜂鸣器

1、复制03项目&#xff0c;重命名为04项目 打开04项目的Drivers/BSP/led文件夹&#xff0c;把led文件夹更改为beep文件夹&#xff0c;改文件夹内部的.c和.h文件更改为beep.c和beep.h文件&#xff0c;如下图所示。 2、打开工程文件 出现弹窗&#xff0c;显示找不到xx文件&#…

PHP开发日志 ━━ 基础知识:四种不同的变量返回方式该如何调用

最近在给框架升级&#xff0c;其中涉及到古早的缓存系统升级&#xff0c;现在准备区分类型为混合、变量和普通文件&#xff0c;那么变量用什么形式存储到缓存才能给后续开发带来便利和通用性呢&#xff1f;于是就涉及到了本文的php基础知识。 好吧&#xff0c;又是一个无用的知…

概率论得学习和整理30: 用EXCEL 描述泊松分布 poisson distribution

目录 1 泊松分布的基本内容 1.1 泊松分布的关键点 1.1.1 属于离散分布 1.1.2 泊松分布的特点&#xff1a;每个子区间内概率相等 &#xff0c; λ就是平均概率 1.2 核心参数 1.3 pmf公式 1.4 期望和方差 2 例1&#xff1a;用EXCEL计算泊松分布的概率 3 比较λ不同值时…

测试工程师八股文05|功能测试、业务测试

一、基础概念 1、软件测试分类 1️⃣按照软件产生的阶段划分 单元测试&#xff1a;针对程序源代码进行测试【开发自测】集成测试&#xff1a;针对模块之间功能交互进行测试系统测试&#xff1a;对整个系统&#xff08;功能、非功能&#xff09;进行全面测试验收测试&#xff…

图(dfs与bfs)算法2

进度&#xff1a;15/100 原题1&#xff1a; 给你一棵二叉树的根节点 root &#xff0c;翻转这棵二叉树&#xff0c;并返回其根节点。 &#xff08;力扣的图&#xff09; 原题2&#xff1a; 给定二叉树的根节点 root &#xff0c;返回所有左叶子之和。 原题3&#xff1a; 给…

UE UMG 多级弹出菜单踩坑

多级弹出菜单 https://www.bilibili.com/video/BV1ub411J7nA 运行时添加 widget 的方法 create widget 然后 add child 到某个组件&#xff0c;比如 canvas 运行时修改 widget 位置的方法 set widget slot position 用起来没效果 怀疑是因为我没有传入 slot 但是暂时不知…

sunset: midnight

https://www.vulnhub.com/entry/sunset-midnight,517/ 主机发现端口扫描 探测存活主机&#xff0c;8是靶机 nmap -sP 192.168.56.0/24 Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-12-05 16:49 CST Nmap scan report for 192.168.56.1 …

伊克罗德与九科信息共同发布RPA+AI智能机器人解决方案

12月12日&#xff0c;伊克罗德信息在上海举办“创见AI&#xff0c;迈进智能化未来——科技赋能零售电商”活动&#xff0c;与九科信息、亚马逊云科技共同探讨与分享&#xff0c;融合生成式AI技术和智能自动化&#xff08;RPA,Robotic Process Automation&#xff09;在电商零售…

从 CephFS 到 JuiceFS:同程旅行亿级文件存储平台构建之路

随着公司业务的快速发展&#xff0c;同程旅行的非结构化的数据突破 10 亿&#xff0c;在 2022 年&#xff0c;同程首先完成了对象存储服务的建设。当时&#xff0c;分布式文件系统方面&#xff0c;同程使用的是 CephFS&#xff0c;随着数据量的持续增长&#xff0c;CephFS 的高…

固定资产分类,提升资产盘活效益

固定资产是企业长期使用的重要资源&#xff0c;涵盖范围广、种类多&#xff0c;不同的资产需要针对性管理。通过科学的分类与高效的盘活策略&#xff0c;不仅可以优化资源配置&#xff0c;还能提升企业资产的利用效率和经济效益。以下将详细解析固定资产的分类方式和盘活效益的…

【字符串匹配算法——BF算法】

&#x1f308;个人主页: Aileen_0v0 &#x1f525;热门专栏: 华为鸿蒙系统学习|计算机网络|数据结构与算法 ​&#x1f4ab;个人格言:“没有罗马,那就自己创造罗马~” 文章目录 BF算法介绍及过程演示代码实现过程下节预告KMP算法利用next数组存储子串中j回退的位置&#xff08;…

Linux 文件系统目录结构及其简要介绍

Hello! 亲爱的小伙伴们&#xff0c;大家好呀&#xff08;Smile~&#xff09;&#xff01;我是 H u a z z i Huazzi Huazzi&#xff0c;欢迎观看本篇博客&#xff0c;接下来让我们一起来学习一下Linux 文件系统目录结构吧&#xff01;祝你有所收获&#xff01; 本篇博客的目录&a…

小米准备入局Nas?Nas究竟是啥?能干啥?

一开头就来了个三连问&#xff1a;小米准备入局Nas&#xff1f;Nas究竟是啥&#xff1f;Nas能干啥&#xff1f; 好像这段时间Nas这个词频频出现&#xff0c;但很多小伙伴都不知道这个是什么设备。首先咱们来解决一下名词Nas是什么意思。 什么是Nas&#xff1f; 为了尽可能解释…

基于Socket实现客户端和服务端的Tcp通信(C#)

0.前言 使用C#和Unity实现复刻Liar’s bar中的功能 软件开发大作业 本系列文章用于记录与分享开发过程中使用到的知识点&#xff0c;以及常见错误 本文主要描述有关网络编程的内容 目录 0.前言1.使用Socket搭建Server1.1Server端的Socket连接1.2 Server端接收Client的信息1.3…

游戏AI实现-寻路算法(A*)

A*&#xff08;A-star&#xff09;是一种图遍历和寻路算法&#xff0c;由于其完整性、最优性和最佳效率&#xff0c;它被用于计算机科学的许多领域。给定一个加权图、一个源节点和一个目标节点&#xff0c;该算法将找到从源到目标的最短路径&#xff08;相对于给定的权重&#…