【Java Nio Netty】基于TCP的简单Netty自定义协议实现(万字,全篇例子)
基于TCP的简单Netty自定义协议实现(万字,全篇例子)
前言
有一阵子没写博客了,最近在学习
Netty
写一个实时聊天软件,一个高性能异步事件驱动的网络应用框架
,我们常用的SpringBoot
一般基于Http
协议,而Netty
是没有十分明确的协议的,不过它内置了一些常用的通信协议,当然你也可以自定义协议。
一、要求
接下来的内容默认你已经有了最基本的
Java
、Netty
、Nio
知识,如果还没有这方面的知识的话,可以先去小破站找个视频学习学习。
二、通信协议
*
本文提到的通信协议都是指基于TCP的应用层通信协议
,请勿理解错误。
1、协议基本单位
当数据在两台计算机上传输时,传输的数据以
比特(Bit)
为单位,就像01010100010010101...
这种,但是以比特作为传输单位太过精细、太过底层,所以封装一下它,将8
个bit
封装成一个单位,就成了字节(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
中,i32
占4
字节,它对应的Java
数字类型是int
,i64
占8
字节,对应的Java
类型是long
,以此类推。
JavaScript
的number
类型是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
就是注册等等。
我使用的是英文字符串的方式,也就是一个字符一个字节,但是路径长度不是不变的,它会变化。例如
/test
是5
个字节,但是/hi
是3
个字节,不能像刚才一样用固定的长度来标识,那么就需要一个固定的路径长度字段,用来表示后续路径的长度。
于是协议的第四部分就是:
长度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.Nio
中ByteBuffer
的进一步封装升级。
画个简陋的图,自定义处理器处理数据的整个流程看起来像这样:
我们刚刚自定义的处理器初始化器就是这部分:
它的作用就是往处理器链中添加一个个的自定义处理器,在
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
时写的,比较简单,基本能使用,感兴趣的可以参考一下,也欢迎贡献。
写在最后
最后叠个甲吧:
以上内容是我个人理解,不保证全部正确,如有遗漏、错误等后续我会回来更新这篇博客,欢迎评论区指正。
相关文章:

【Java Nio Netty】基于TCP的简单Netty自定义协议实现(万字,全篇例子)
基于TCP的简单Netty自定义协议实现(万字,全篇例子) 前言 有一阵子没写博客了,最近在学习Netty写一个实时聊天软件,一个高性能异步事件驱动的网络应用框架,我们常用的SpringBoot一般基于Http协议࿰…...

【JavaWeb后端学习笔记】Redis常用命令以及Java客户端操作Redis
redis 1、redis安装与启动服务2、redis数据类型3、redis常用命令3.1 字符串String3.2 哈希Hash3.3 列表List3.4 集合Set(无序)3.5 有序集合zset3.6 通用命令 4、使用Java操作Redis4.1 环境准备4.2 Java操作字符串String4.3 Java操作哈希Hash4.4 Java操作…...

pdb调试器详解
文章目录 1. 启动 pdb 调试器1.1 在代码中插入断点1.2 使用命令行直接调试脚本 2. 常用调试命令2.1 基本命令2.2 高级命令2.3 断点操作 3. 调试过程示例4. 调试技巧4.1 条件断点4.2 自动启用调试4.2.1 运行程序时指定 -m pdb4.2.2在代码中启用 pdb.post_mortem4.2.3 使用 sys.e…...

项目15:简易扫雷--- 《跟着小王学Python·新手》
项目15:简易扫雷 — 《跟着小王学Python新手》 《跟着小王学Python》 是一套精心设计的Python学习教程,适合各个层次的学习者。本教程从基础语法入手,逐步深入到高级应用,以实例驱动的方式,帮助学习者逐步掌握Python的…...

Flink CDC实时同步mysql数据
官方参考资料: https://nightlies.apache.org/flink/flink-cdc-docs-master/zh/docs/connectors/flink-sources/mysql-cdc/ Apache Flink 的 Change Data Capture (CDC) 是一种用于捕获数据库变化(如插入、更新和删除操作)的技术。Flink CDC…...
题解 - 自然数无序拆分
题目描述 美羊羊给喜羊羊和沸羊羊出了一道难题,说谁能先做出来,我就奖励给他我自己做的一样礼物。沸羊羊这下可乐了,于是马上答应立刻做出来,喜羊羊见状,当然也不甘示弱,向沸羊羊发起了挑战。 可是这道题目…...
dfs_bool_void 两种写法感悟
dfs 的两种写法 在看之前实现图的遍历 dfs 和拓扑排序 dfs 实现的代码的时候的感悟 图的遍历 dfs 和拓扑排序 dfs 的区别 0 → 1 ↓ ↓ 2 → 3图的邻接表表示: adjList[0] {1, 2}; adjList[1] {3}; adjList[2] {3}; adjList[3] {};正常的 DFS 遍历&#x…...
MySQL 主从复制与 Binlog 深度解析
目录 1. Binlog的工作原理与配置2. 主从复制的设置与故障排除3. 数据一致性与同步延迟的处理 小结 MySQL的binlog(二进制日志)和主从复制是实现数据备份、容灾、负载均衡以及数据同步的重要机制。在高可用性架构和分布式数据库设计中,binlog同…...

大连理工大学《2024年845自动控制原理真题》 (完整版)
本文内容,全部选自自动化考研联盟的:《大连理工大学845自控考研资料》的真题篇。后续会持续更新更多学校,更多年份的真题,记得关注哦 目录 2024年真题 Part1:2024年完整版真题 2024年真题...

Java性能调优 - 多线程性能调优
锁优化 Synchronized 在JDK1.6中引入了分级锁机制来优化Synchronized。当一个线程获取锁时 首先对象锁将成为一个偏向锁,这样做是为了优化同一线程重复获取锁,导致的用户态与内核态的切换问题;其次如果有多个线程竞争锁资源,锁…...
行为树详解(4)——节点参数配置化
【分析】 行为树是否足够灵活强大依赖于足够丰富的各类条件节点和动作节点,在实现这些节点时,不可避免的,节点本身需要有一些参数供配置。 这些参数可以分为静态的固定值的参数以及动态读取设置的参数。 静态参数直接设置为Public即可&…...
计算机网络中的三大交换技术详解与实现
目录 计算机网络中的三大交换技术详解与实现1. 计算机网络中的交换技术概述1.1 交换技术的意义1.2 三大交换技术简介 2. 电路交换技术2.1 理论介绍2.2 Python实现及代码详解2.3 案例分析 3. 分组交换技术3.1 理论介绍3.2 Python实现及代码详解3.3 案例分析 4. 报文交换技术4.1 …...
《杨辉三角》
题目描述 给出 n(1≤n≤20)n(1≤n≤20),输出杨辉三角的前 nn 行。 如果你不知道什么是杨辉三角,可以观察样例找找规律。 输入格式 无 输出格式 无 输入输出样例 输入 #1复制 6 输出 #1复制 1 1 1 1 2 1 1 3 3 1 1 4 6 4 1 1 5 10 10 5 1 C语言…...

ARM学习(35)单元测试框架以及MinGW GCC覆盖率报告
单元测试框架以及MinGW GCC覆盖率报告 1、单元测试与覆盖率简介 随着代码越写越多,越来越需要注意自测的重要性,基本可以提前解决90%的问题,所以就来介绍一下单元测试,单元测试是否测试充分,需要进行评价,覆盖率就是单元测试是否充分的评估工具。 例如跑过单元测试后,…...

边缘计算+人工智能:让设备更聪明的秘密
引言:日常生活中的“智能”设备 你是否发现,身边的设备正变得越来越“聪明”? 早上醒来时,智能音箱已经根据你的日程播放舒缓音乐;走进厨房,智能冰箱提醒你今天的食材库存;而在城市道路上&…...
neo4j知识图谱AOPC的安装方法
AOPC下载链接:aopc全版本github下载 APOC,全称为Awesome Procedures On Cypher,是Neo4j图数据库的一个非常强大和流行的扩展库。它极大地丰富了Cypher查询语言的功能,提供了超过450个过程(procedures)和函数…...

图像分割数据集植物图像叶片健康状态分割数据集labelme格式180张3类别
数据集格式:labelme格式(不包含mask文件,仅仅包含jpg图片和对应的json文件) 图片数量(jpg文件个数):180 标注数量(json文件个数):180 标注类别数:3 标注类别名称:["Healthy","nitrogen deficiency"…...

Python学习(二)—— 基础语法(上)
目录 一,表达式和常量和变量 1.1 表达式 1.2 变量 1.3 动态类型特性 1.4 输入 二,运算符 2.1 算术运算符 2.2 关系运算符 2.3 逻辑运算符 2.4 赋值运算符 2.5 练习 三,语句 3.1 条件语句 3.2 while循环 3.3 for循环 四&#…...

Cesium-(Primitive)-(CircleOutlineGeometry)
CircleOutlineGeometry 效果: CircleOutlineGeometry 是 CesiumJS 中的一个类,它用来描述在椭球体上圆的轮廓。以下是 CircleOutlineGeometry 的构造函数属性,以表格形式展示: 属性名类型默认值描述centerCartesian3圆心点在固定坐标系中的坐标。radiusnumber圆的半径,…...
计算机网络技术基础:2.计算机网络的组成
计算机网络从逻辑上可以分为两个子网:资源子网和通信子网。 一、资源子网 资源子网主要负责全网的数据处理业务,为全网用户提供各种网络资源与网络服务。资源子网由主机、终端、各种软件资源与信息资源等组成。 1)主机 主机是资源子网的主要…...
生成xcframework
打包 XCFramework 的方法 XCFramework 是苹果推出的一种多平台二进制分发格式,可以包含多个架构和平台的代码。打包 XCFramework 通常用于分发库或框架。 使用 Xcode 命令行工具打包 通过 xcodebuild 命令可以打包 XCFramework。确保项目已经配置好需要支持的平台…...

(十)学生端搭建
本次旨在将之前的已完成的部分功能进行拼装到学生端,同时完善学生端的构建。本次工作主要包括: 1.学生端整体界面布局 2.模拟考场与部分个人画像流程的串联 3.整体学生端逻辑 一、学生端 在主界面可以选择自己的用户角色 选择学生则进入学生登录界面…...

聊聊 Pulsar:Producer 源码解析
一、前言 Apache Pulsar 是一个企业级的开源分布式消息传递平台,以其高性能、可扩展性和存储计算分离架构在消息队列和流处理领域独树一帜。在 Pulsar 的核心架构中,Producer(生产者) 是连接客户端应用与消息队列的第一步。生产者…...

Python实现prophet 理论及参数优化
文章目录 Prophet理论及模型参数介绍Python代码完整实现prophet 添加外部数据进行模型优化 之前初步学习prophet的时候,写过一篇简单实现,后期随着对该模型的深入研究,本次记录涉及到prophet 的公式以及参数调优,从公式可以更直观…...
【论文笔记】若干矿井粉尘检测算法概述
总的来说,传统机器学习、传统机器学习与深度学习的结合、LSTM等算法所需要的数据集来源于矿井传感器测量的粉尘浓度,通过建立回归模型来预测未来矿井的粉尘浓度。传统机器学习算法性能易受数据中极端值的影响。YOLO等计算机视觉算法所需要的数据集来源于…...
CMake控制VS2022项目文件分组
我们可以通过 CMake 控制源文件的组织结构,使它们在 VS 解决方案资源管理器中以“组”(Filter)的形式进行分类展示。 🎯 目标 通过 CMake 脚本将 .cpp、.h 等源文件分组显示在 Visual Studio 2022 的解决方案资源管理器中。 ✅ 支持的方法汇总(共4种) 方法描述是否推荐…...
【Go语言基础【12】】指针:声明、取地址、解引用
文章目录 零、概述:指针 vs. 引用(类比其他语言)一、指针基础概念二、指针声明与初始化三、指针操作符1. &:取地址(拿到内存地址)2. *:解引用(拿到值) 四、空指针&am…...

使用LangGraph和LangSmith构建多智能体人工智能系统
现在,通过组合几个较小的子智能体来创建一个强大的人工智能智能体正成为一种趋势。但这也带来了一些挑战,比如减少幻觉、管理对话流程、在测试期间留意智能体的工作方式、允许人工介入以及评估其性能。你需要进行大量的反复试验。 在这篇博客〔原作者&a…...
MinIO Docker 部署:仅开放一个端口
MinIO Docker 部署:仅开放一个端口 在实际的服务器部署中,出于安全和管理的考虑,我们可能只能开放一个端口。MinIO 是一个高性能的对象存储服务,支持 Docker 部署,但默认情况下它需要两个端口:一个是 API 端口(用于存储和访问数据),另一个是控制台端口(用于管理界面…...
Kubernetes 网络模型深度解析:Pod IP 与 Service 的负载均衡机制,Service到底是什么?
Pod IP 的本质与特性 Pod IP 的定位 纯端点地址:Pod IP 是分配给 Pod 网络命名空间的真实 IP 地址(如 10.244.1.2)无特殊名称:在 Kubernetes 中,它通常被称为 “Pod IP” 或 “容器 IP”生命周期:与 Pod …...