[06] Protobuf&粘包拆包
1. ProtoBuf 说明#
1.1 编码和解码#
编写网络应用程序时,因为数据在网络中传输的都是二进制字节码数据,在发送数据时就需要编码,接收数据时就需要解码。
codec(编解码器)的组成部分有两个:decoder(解码器)和 encoder(编码器)。encoder 负责把业务数据转换成字节码数据,decoder 负责把字节码数据转换成业务数据。
Netty 自身提供了一些 codec(编解码器):
- Netty 提供的编码器
- StringEncoder,对字符串数据进行编码
- ObjectEncoder,对 Java 对象进行编码
- Netty 提供的解码器
- StringDecoder, 对字符串数据进行解码
- ObjectDecoder,对 Java 对象进行解码
Netty 本身自带的 ObjectDecoder 和 ObjectEncoder 可以用来实现 POJO 对象或各种业务对象的编码和解码,底层使用的仍是 Java 序列化技术 , 而 Java 序列化技术本身效率就不高,存在如下问题:
- 无法跨语言;
- 序列化后的体积太大,是二进制编码的 5 倍多;
- 序列化性能太低
故引出新的解决方案:GoogleのProtobuf ↓
1.2 数据结构化/序列化#
Protocol Buffers 是一种语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于数据通信协议、数据存储等。
Protocol Buffers 是一种灵活,高效,自动化机制的结构数据序列化方法-可类比 XML,但是比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更为简单。
你可以定义数据的结构,然后使用特殊生成的源代码轻松的在各种数据流中使用各种语言进行编写和读取结构数据。你甚至可以更新数据结构,而不破坏由旧数据结构编译的已部署程序。
简单来讲, ProtoBuf 是结构数据序列化方法,可简单类比于 XML,其具有以下特点:
- 语言无关、平台无关。即 ProtoBuf 支持 Java、C++、Python 等多种语言,支持多个平台
- 高效。即比 XML 更小(3 ~ 10 倍)、更快(20 ~ 100 倍)、更为简单
- 扩展性、兼容性好。你可以更新数据结构,而不影响和破坏原有的旧程序
【补充】
(1)序列化:将结构数据或对象转换成能够被存储和传输(例如网络传输)的格式,同时应当要保证这个序列化结果在之后(可能在另一个计算环境中)能够被重建回原来的结构数据或对象。
(2)类比于 XML:这里主要指在数据通信和数据存储应用场景中序列化方面的类比,但个人认为 XML 作为一种扩展标记语言和 ProtoBuf 还是有着本质区别的。
官方文档以及网上很多文章提到 ProtoBuf 可类比 XML 或 JSON。那么 ProtoBuf 是否就等同于 XML 和 JSON 呢,它们是否具有完全相同的应用场景呢?
个人认为如果要将 ProtoBuf、XML、JSON 三者放到一起去比较,应该区分两个维度。一个是数据结构化,一个是数据序列化。这里的数据结构化主要面向开发或业务层面,数据序列化面向通信或存储层面,当然数据序列化也需要“结构”和“格式”,所以这两者之间的区别主要在于面向领域和场景不同,一般要求和侧重点也会有所不同。数据结构化侧重人类可读性甚至有时会强调语义表达能力,而数据序列化侧重效率和压缩。
从这两个维度,我们可以做出下面的一些思考。
XML 作为一种扩展标记语言,JSON 作为源于 JS 的数据格式,都具有数据结构化的能力。
例如 XML 可以衍生出 HTML (虽然 HTML 早于 XML,但从概念上讲,HTML 只是预定义标签的 XML),HTML 的作用是标记和表达万维网中资源的结构,以便浏览器更好的展示万维网资源,同时也要尽可能保证其人类可读以便开发人员进行编辑,这就是面向业务或开发层面的数据结构化。
再如 XML 还可衍生出 RDF/RDFS,进一步表达语义网中资源的关系和语义,同样它强调数据结构化的能力和人类可读。
JSON 也是同理,在很多场合更多的是体现了数据结构化的能力,例如作为交互接口的数据结构的表达。在 MongoDB 中采用 JSON 作为查询语句,也是在发挥其数据结构化的能力。
当然,JSON、XML 同样也可以直接被用来数据序列化,实际上很多时候它们也是这么被使用的,例如直接采用 JSON、XML 进行网络通信传输,此时 JSON、XML 就成了一种序列化格式,它发挥了数据序列化的能力。但是经常这么被使用,不代表这么做就是合理。实际将 JSON、XML 直接作用数据序列化通常并不是最优选择,因为它们在速度、效率、空间上并不是最优。换句话说它们更适合数据结构化而非数据序列化。
扯完 XML 和 JSON,我们来看看 ProtoBuf,同样的 ProtoBuf 也具有数据结构化的能力,其实也就是上面介绍的 message 定义。我们能够在 .proto 文件中,通过 message、import、内嵌 message 等语法来实现数据结构化,但是很容易能够看出,ProtoBuf 在数据结构化方面和 XML、JSON 相差较大,人类可读性较差,不适合上面提到的 XML、JSON 的一些应用场景。
但是如果从数据序列化的角度你会发现 ProtoBuf 有着明显的优势,效率、速度、空间几乎全面占优,看完后面的 ProtoBuf 编码的文章(https://www.jianshu.com/p/b33ca81b19b5
),你更会了解 ProtoBuf 是如何极尽所能的压榨每一寸空间和性能,而其中的编码原理正是 ProtoBuf 的关键所在,message 的表达能力并不是 ProtoBuf 最关键的重点。所以可以看出 ProtoBuf 重点侧重于数据序列化 而非数据结构化。
最终对这些个人思考做一些小小的总结:
- XML、JSON、ProtoBuf 都具有数据结构化和数据序列化的能力;
- XML、JSON 更注重数据结构化,关注人类可读性和语义表达能力。ProtoBuf 更注重数据序列化,关注效率、空间、速度,人类可读性差,语义表达能力不足(为保证极致的效率,会舍弃一部分元信息);
- ProtoBuf 的应用场景更为明确,XML、JSON 的应用场景更为丰富。
- RPC 调用建议使用 TCP+Protobuf 来替换 HTTP+JSON。
1.3 Protobuf 示例#
a. 前置步骤#
(1)导入依赖
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.6.1</version>
</dependency>
(2)下载插件
(3)protoc
https://github.com/google/protobuf/releases
下载对应的 protoc,本实例使用 protoc-3.6.1-win32.zip。
使用 protobuf 编译器能自动生成代码,Protobuf 是将类的定义使用 .proto
文件进行描述。然后通过 protoc.exe 编译器根据 .proto
自动生成 .java
文件。
protoc.exe --java_out=<生成文件的存储路径> Student.proto
Protoc 语法:https://www.cnblogs.com/tohxyblog/p/8974763.html
b. 示例一#
(1)prototype
// 版本
syntax = "proto3";
// 生成的外部类名,同时也是文件名
option java_outer_classname = "StudentPOJO";
// 使用 message 管理数据
// 会在 StudentPOJO 中生成一个内部类(真正被发送的POJO)
message Student {
// 1、2 是属性序号,不是赋值操作
int32 id = 1;
string name = 2;
}
生成的 StudentPOJO.java:
(2)client
NettyClient
public class NettyClient {
public static void main(String[] args) {
// 客户端需要一个事件循环组
NioEventLoopGroup eventExecutors = new NioEventLoopGroup();
// 创建客户端启动对象
Bootstrap bootstrap = new Bootstrap();
try {
// 设置相关参数
bootstrap
.group(eventExecutors)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline()
.addLast("encoder", new ProtobufEncoder())
.addLast(new NettyClientHandler());
}
});
// 启动客户端去连接服务器端
ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 6677).sync();
// 给 ‘关闭通道’ 进行监听
channelFuture.channel().closeFuture().sync();
} catch (Exception e) {
e.printStackTrace();
} finally {
eventExecutors.shutdownGracefully();
}
}
}
NettyClientHandler
public class NettyClientHandler extends ChannelInboundHandlerAdapter {
/**
* 当通道就绪就会触发该方法
* @param ctx
* @throws Exception
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
StudentPOJO.Student student = StudentPOJO.Student.newBuilder().setId(1101).setName("Nikki").build();
ctx.writeAndFlush(student);
}
/**
* 当通道有读取事件时,会触发
* @param ctx
* @param msg
* @throws Exception
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf byteBuf = (ByteBuf) msg;
System.out.println("[Server response] " + byteBuf.toString(CharsetUtil.UTF_8));
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}
(3)server
NettyServer
public class NettyServer {
public static void main(String[] args) {
// [while (true)] bossGroup 只处理连接请求;和客户端的业务处理会交给 workerGroup
NioEventLoopGroup bossGroup = new NioEventLoopGroup(1);
NioEventLoopGroup workerGroup = new NioEventLoopGroup();
// 创建服务器端的启动对象,配置启动参数
ServerBootstrap serverBootstrap = new ServerBootstrap();
try {
// 启动
ChannelFuture channelFuture = serverBootstrap
// 设置两个线程组
.group(bossGroup, workerGroup)
// 使用 NioSocketChannel 作为服务器的通道实现
.channel(NioServerSocketChannel.class)
// 设置线程队列等待连接的个数
.option(ChannelOption.SO_BACKLOG, 128)
// 设置保持活动连接状态
.childOption(ChannelOption.SO_KEEPALIVE, true)
// 创建一个通道测试对象(匿名对象)
.childHandler(new ChannelInitializer<SocketChannel>() {
/**
* 给 workerGroup 的 EventLoop 对应的 Pipeline 设置处理器
*
* @param ch
* @throws Exception
*/
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline()
// 指定对‘指定对象类型’进行解码
.addLast(new ProtobufDecoder(StudentPOJO.Student.getDefaultInstance()))
// .addLast(new NettyServerHandler());
.addLast(new StudentHandler());
}
})
.bind(6677).sync().channel().closeFuture().sync();
} catch (Exception e) {
e.printStackTrace();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
NettyServerHandler
public class NettyServerHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
// 获取从客户端发送的StudentPOJO.Student
StudentPOJO.Student student = (StudentPOJO.Student) msg;
System.out.println(StrUtil.format("[client] {}-{}", student.getId(), student.getName()));
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
// 将数据写入缓冲(先对发送的数据进行编码)并刷新
ctx.writeAndFlush(Unpooled.copiedBuffer("Hello, Client!", CharsetUtil.UTF_8));
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
// 处理异常,一般是要关闭通道
ctx.channel().close();
}
}
StudentHandler
public class StudentHandler extends SimpleChannelInboundHandler<StudentPOJO.Student> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, StudentPOJO.Student msg) throws Exception {
// 获取从客户端发送的StudentPOJO.Student
System.out.println(StrUtil.format("[client] {}-{}", msg.getId(), msg.getName()));
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
// 将数据写入缓冲(先对发送的数据进行编码)并刷新
ctx.writeAndFlush(Unpooled.copiedBuffer("Hello, Client!", CharsetUtil.UTF_8));
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
// 处理异常,一般是要关闭通道
ctx.channel().close();
}
}
c. 示例二#
(1)prototype
syntax = "proto3";
// 快速解析
option optimize_for = SPEED;
// 指定生成到指定包下
option java_package = "org.example.netty.proto2";
// 指定生成的外部类名
option java_outer_classname = "DataInfo";
// ProtoBuf 可以使用 message 管理其他的 message
message MyMessage {
// 定义枚举类型(enum 编号从 0 开始)
enum DataType {
StudentType = 0;
WorkerType = 1;
}
// 用 data_type 来标识传的是哪个枚举类型
// 这个 1 表示 data_type 是 MyMessage 的第一个属性
DataType data_type = 1;
// 表示 Student、Worker 类型最多只能出现其中的一个(节省空间)
oneof dataBody {
Student student = 2;
Worker worker = 3;
}
}
message Student {
int32 id = 1;
string name = 2;
}
message Worker {
string name = 1;
int32 age = 2;
}
(2)client
其余代码复用。
public class NettyClientHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
// 随机的发送Student/Worker对象
int random = new Random().nextInt(3);
DataInfo.MyMessage message = null;
if (random == 0) {
message = DataInfo.MyMessage.newBuilder()
.setDataType(DataInfo.MyMessage.DataType.StudentType)
.setStudent(DataInfo.Student.newBuilder().setId(1101).setName("学生").build())
.build();
} else {
message = DataInfo.MyMessage.newBuilder()
.setDataType(DataInfo.MyMessage.DataType.WorkerType)
.setWorker(DataInfo.Worker.newBuilder().setName("社畜").setAge(24).build())
.build();
}
ctx.writeAndFlush(message);
}
// ...
}
(3)server
其余代码复用。
// .addLast("decoder", new ProtobufDecoder(DataInfo.MyMessage.getDefaultInstance()))
public class NettyServerHandler extends SimpleChannelInboundHandler<DataInfo.MyMessage> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, DataInfo.MyMessage msg) throws Exception {
if (msg.getDataType() == DataInfo.MyMessage.DataType.StudentType) {
System.out.println(StrUtil.format("[Student] {}-{}",
msg.getStudent().getId(), msg.getStudent().getName()));
} else {
System.out.println(StrUtil.format("[Worker] {}-{}",
msg.getWorker().getName(), msg.getWorker().getAge()));
}
}
// ...
}
2. TCP 粘包/拆包#
2.1 问题说明#
尽管我们在应用层面使用了 Netty,但 Netty 底层是基于 TCP 协议来处理网络数据传输。尽管我们的应用层按照 ByteBuf 为单位来发送数据,但是到了底层操作系统,仍然是按照字节流发送数据的。
我们知道 TCP 协议是面向字节流(就是没有界限的一长串二进制数据)的协议,数据像流水一样在网络中传输那何来 “包” 的概念呢?
TCP 是四层协议,其不负责数据逻辑的处理,但是数据在 TCP 层 “流” 的时候为了保证安全和节约效率会把 “流” 做一些分包处理,比如:
- 发送方约定了每次数据传输的最大包大小,超过该值的内容将会被拆分成两个包发送;
- 发送端和接收端约定每次发送数据包长度并随着网络状况动态调整接收窗口大小,这里也会出现拆包的情况;
Netty 本身是基于 TCP 协议做的处理,如果它不去对 “流” 进行处理,到底这个 “流” 从哪到哪才是完整的数据就是个迷,因为面向流的通信是无消息保护边界的。
因此,数据到了服务端,也按照字节流的方式读入,然后到了 Netty 应用层面,重新拼装成 ByteBuf。这里的 ByteBuf 与客户端按照顺序发送的 ByteBuf 可能是不对等的。因此,我们需要在客户端根据自定义协议来组装应用层的数据包,然后在服务端根据应用层的协议来组装数据包,这个过程通常在服务端被称为“拆包”,在客户端被称为“粘包”。拆包和粘包是相对的,一端粘了包,另外一端就需要将粘过的包拆开。
【举例】假设客户端分别发送了两个数据包 D1 和 D2 给服务端,由于服务端一次读取到字节数是不确定的,故可能存在以下 4 种情况:
- 服务端分两次读取到了两个独立的数据包,分别是 D1 和 D2,没有粘包和拆包;
- 服务端一次接受到了两个数据包,D1 和 D2 粘合在一起,称之为 TCP 粘包;
- 服务端分两次读取到了数据包,第一次读取到了完整的 D1 包和 D2 包的部分内容,第二次读取到了 D2 包的剩余内容,这称之为 TCP 拆包;
- 服务端分两次读取到了数据包,第一次读取到了 D1 包的部分内容 D1_1,第二次读取到了 D1 包的剩余部分内容 D1_2 和完整的 D2 包。
下面先来看在下 TCP 协议中有哪些步骤可能会让 “流” 不完整或者是出现粘滞的可能。
2.2 出现原因#
数据流在 TCP 协议下传播,因为协议本身对于流有一些规则的限制,这些规则会导致当前对端接收到的数据包不完整,归结原因有下面 3 种情况:
- Socket 缓冲区与滑动窗口
- MSS/MTU 限制
- Nagle 算法
a. Socket 缓冲区与滑动窗口#
对于 TCP 协议而言,它传输数据是基于字节流传输的。应用层在传输数据时,实际上会先将数据写入到 TCP 套接字的缓冲区,当缓冲区被写满后,数据才会被写出去。每个 TCP Socket 在内核中都有一个发送缓冲区(SO_SNDBUF)和一个接收缓冲区(SO_RCVBUF),TCP 的全双工的工作模式以及 TCP 的滑动窗口便是依赖于这两个独立的 buffer 以及此 buffer 的填充状态。
(1)SO_SNDBUF
进程发送数据的时候假设调用了一个 send 方法,将数据拷贝进入 Socket 的内核发送缓冲区中,然后 send 便会在上层返回。换句话说,send 返回时,数据不一定会发送到对端去(和 write 写文件有点类似),send 仅仅是把应用层 buffer 的数据拷贝进 Socket 的内核发送缓冲区中。
(2)SO_RCVBUF
把接收到的数据缓存到内核,应用进程一直没有调用 read 进行读取的话,此数据会一直缓存在相应 Socket 的接收缓冲区内。不管进程是否读取 Socket,对端发来的数据都会经由内核接收并且缓存到 Socket 的内核接收缓冲区之中。read 所做的工作,就是把内核缓冲区中的数据拷贝到应用层用户的 buffer 里面,仅此而已。
接收缓冲区保存收到的数据直到应用进程读走为止。对于 TCP,如果应用进程一直没有读取,buffer 满了之后发生的动作是:通知对端 TCP 协议中的窗口关闭。这个便是「滑动窗口」的实现。保证 TCP 套接字接收缓冲区不会溢出,从而保证了 TCP 是可靠传输。因为对方不允许发出超过所通告窗口大小的数据。 这就是 TCP 的流量控制,如果对方无视窗口大小而发出了超过窗口大小的数据,则接收方 TCP 将丢弃它。
(3)滑动窗口
TCP 连接在三次握手的时候,会将自己的窗口大小(window size)发送给对方,其实就是 SO_RCVBUF 指定的值。之后在发送数据的时候,发送方必须要先确认接收方的窗口没有被填充满,如果没有填满,则可以发送。
每次发送数据后,发送方将自己维护的对方的 window size 减小,表示对方的 SO_RCVBUF 可用空间变小。
当接收方开始处理 SO_RCVBUF 中的数据时,会将数据从 Socket 在内核中的接收缓冲区读出,此时接收方的 SO_RCVBUF 可用空间变大,即 window size 变大,接收方会以 ack 消息的方式将自己最新的 window size 返回给发送方,此时发送方将自己维护的接收方的 window size 设置为 ack 消息返回的 window size。
此外,发送方可以连续的给接收方发送消息,只要保证对方的 SO_RCVBUF 空间可以缓存数据即可,即 window size>0。当接收方的 SO_RCVBUF 被填充满时,此时 window size=0,发送方不能再继续发送数据,要等待接收方 ack 消息,以获得最新可用的 window size。
b. MSS/MTU 限制#
MTU(Maxitum Transmission Unit,最大传输单元)是链路层对一次可以发送的最大数据的限制。MSS(Maxitum Segment Size,最大分段大小)是 TCP 报文中 data 部分的最大长度,是传输层对一次可以发送的最大数据的限制。
数据在传输过程中,每经过一层,都会加上一些额外的信息:
- 应用层:只关心发送的数据 data,将数据写入 Socket 在内核中的缓冲区 SO_SNDBUF 即返回,操作系统会将 SO_SNDBUF 中的数据取出来进行发送;
- 传输层:会在 data 前面加上 TCP Header(20 字节);
- 网络层:会在 TCP 报文的基础上再添加一个 IP Header,也就是将自己的网络地址加入到报文中。IPv4 中 IP Header 长度是 20 字节,IPV6 中 IP Header 长度是 40 字节;
- 链路层:加上 Datalink Header 和 CRC。会将 SMAC(Source Machine,数据发送方的 MAC 地址)、DMAC(Destination Machine,数据接受方的 MAC 地址)和 Type 域加入。SMAC+DMAC+Type+CRC 总长度为 18 字节;
- 物理层:进行传输。
在回顾这个基本内容之后,再来看 MTU 和 MSS。
MTU 是以太网传输数据方面的限制,每个以太网帧最大不能超过 1518 Bytes。刨去以太网帧的帧头(DMAC+SMAC+Type 域)的 14 Bytes 和帧尾 (CRC 校验 ) 的 4 Bytes,那么剩下承载上层协议的地方也就是 data 域最大就只能有 1500 Bytes,这个值我们就把它称之为 MTU。
MSS 是在 MTU 的基础上减去网络层的 IP Header 和传输层的 TCP Header 的部分,这就是 TCP 协议一次可以发送的实际应用数据的最大大小。
MSS = MTU(1500) - IP Header(20 or 40) - TCP Header(20)
由于 IPV4 和 IPV6 的长度不同,在 IPV4 中,以太网 MSS 可以达到 1460 byte。在 IPV6 中,以太网 MSS 可以达到 1440 byte。
发送方发送数据时,当 SO_SNDBUF 中的数据量大于 MSS 时,操作系统会将数据进行拆分,使得每一部分都小于 MSS,也形成了“拆包”。然后每一部分都加上 TCP Header,构成多个完整的 TCP 报文进行发送,当然经过网络层和数据链路层的时候,还会分别加上相应的内容。
另外需要注意的是:对于本地回环地址(lookback)不需要走以太网,所以不受到以太网 MTU=1500 的限制。Linux 上输入 ifconfig 命令,可以查看不同网卡的 MTU 大小,如下:
上图显示了 2 个网卡信息:
- ens3 需要走以太网,所以 MTU 是 1500;
- lo 是本地回环,不需要走以太网,所以不受 1500 的限制。
c. Nagle 算法#
TCP/IP 协议中,无论发送多少数据,总是要在数据(data)前面加上协议头(TCP Header + IP Header),同时,对方接收到数据,也需要发送 ACK 表示确认。
即使从键盘输入的一个字符,占用一个字节,可能在传输上造成 41 字节的包,其中包括 1 字节的有用信息和 40 字节的首部数据。这种情况转变成了 4000% 的消耗,这样的情况对于重负载的网络来是无法接受的,称之为“糊涂窗口综合征”。
为了尽可能的利用网络带宽,TCP 总是希望尽可能的发送足够大的数据(一个连接会设置 MSS 参数,因此,TCP/IP 希望每次都能够以 MSS 尺寸的数据块来发送数据)。Nagle 算法就是为了尽可能发送大块数据,避免网络中充斥着许多小数据块。
Nagle 算法的基本定义是任意时刻,最多只能有一个未被确认的小段。 所谓 “小段”,指的是小于 MSS 尺寸的数据块;所谓“未被确认”,是指一个数据块发送出去后,没有收到对方发送的 ACK 确认该数据已收到。
Nagle 算法的规则:
- 如果 SO_SNDBUF 中的数据长度达到 MSS,则允许发送;
- 如果该 SO_SNDBUF 中含有 FIN,表示请求关闭连接,则先将 SO_SNDBUF 中的剩余数据发送,再关闭;
- 设置了 TCP_NODELAY=true 选项,则允许发送。TCP_NODELAY 是取消 TCP 的确认延迟机制,相当于禁用了 Nagle 算法。
- 正常情况下,当 Server 端收到数据之后,它并不会马上向 client 端发送 ACK,而是会将 ACK 的发送延迟一段时间(一般是 40ms),它希望在 t 时间内 server 端会向 client 端发送应答数据,这样 ACK 就能够和应答数据一起发送,就像是应答数据捎带着 ACK 过去。
- 当然,TCP 确认延迟 40ms 并不是一直不变的, TCP 连接的延迟确认时间一般初始化为最小值 40ms,随后根据连接的重传超时时间(RTO)、上次收到数据包与本次接收数据包的时间间隔等参数进行不断调整。另外可以通过设置 TCP_QUICKACK 选项来取消确认延迟;
- 未设置 TCP_CORK 选项时,若所有发出去的小数据包(包长度小于 MSS)均被确认,则允许发送;
- 上述条件都未满足,但发生了超时(一般为 200ms),则立即发送。
d. 小结#
基于以上问题,TCP 层肯定是会出现当次接收到的数据是不完整数据的情况。
出现“粘包”可能的原因有:
- 发送方每次写入数据 < 套接字缓冲区大小;
- 接收方读取套接字缓冲区数据不够及时。
出现“半包”的可能原因有:
- 发送方每次写入数据 > 套接字缓冲区大小;
- 发送的数据大于协议 MTU,所以必须要拆包。
解决问题肯定不能在 TCP 层来做,而是在〈应用层〉通过定义通信协议来解决“粘包”和“拆包”的问题。让发送方和接收方约定某个规则:
- 当发生粘包的时候通过某种约定来拆包;
- 如果有拆包则通过某种约定来将数据组成一个完整的包处理。
2.3 解决策略#
由于底层的 TCP 无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决。业界的主流协议的解决方案,可以归纳如下:
a. 定长协议#
指定一个报文具有固定长度。比如约定一个报文的长度是 5 字节,报文为 1234
,只有 4 字节,还差一个怎么办呢,不足部分用空格补齐,就变为 1234
。如果不补齐空格,那么就会读到下一个报文的字节来填充上一个报文直到补齐为止,这就“粘包”了。
定长协议的优点是使用简单,缺点很明显:浪费带宽。
Netty 中提供了 FixedLengthFrameDecoder ,支持把固定的长度的字节数当做一个完整的消息进行解码。
b. 特殊字符分割协议#
很好理解,在每一个你认为是一个完整的包的尾部添加指定的特殊字符,比如:\n
、\r
等。但需要注意的是:约定的特殊字符要保证唯一性,不能出现在报文的正文中,否则就将正文一分为二了。
Netty 中提供了 DelimiterBasedFrameDecoder 根据特殊字符进行解码,LineBasedFrameDecoder 默认以换行符作为分隔符。
c. 变长协议#
这种也是最通用的一种拆包器,只要你的自定义协议中包含「长度域」字段,均可以使用这个拆包器来实现〈应用层〉拆包。
变长协议的核心就是:将消息分为「消息头」和「消息体」,「消息头」中标识当前完整的「消息体长度」。
- 发送方在发送数据之前先获取数据的二进制字节大小,然后在消息体前面添加消息大小;
- 接收方在解析消息时先获取消息大小,之后必须读到该大小的字节数才认为是完整的消息。
Netty 中提供了 LengthFieldBasedFrameDecoder 来基于“长度域”拆解数据包。
/* 1. 数据包的最大长度 2. 长度域的偏移量 3. 长度域的长度 */
new LengthFieldBasedFrameDecoder(Integer.MAX_VALUE, 7, 4);
2.4 粘包处理#
【粘包演示】具体的粘包演示代码,看 #2 开头🔗的那篇博客。
演示客户端发送多条消息,使用 Netty 自定义的 ByteBuf 作为传输数据格式,看看服务端接收数据是否是按每次发送的条数来接收还是按照当前缓冲区大小来接收。其中,客户端 Handler 主要逻辑是循环 100 次给服务端发送测试消息。
处理 TCP 粘包的唯一方法就是制定应用层的数据通讯协议,通过协议来规范现有接收的数据是否满足消息数据的需要。
为了解决网络数据流的拆包粘包问题,Netty 为我们内置了如下的解码器:
解码器 | 说明 |
---|---|
ByteToMessageDecoder | 如果想实现自己的半包解码器,实现该类 |
MessageToMessageDecoder | 一般作为二次解码器,当我们在 ByteToMessageDecoder 将一个 bytes 数组转换成一个 Java 对象的时候,我们可能还需要将这个对象进行二次解码成其他对象,我们就可以继承这个类; |
LineBasedFrameDecoder | 通过在包尾添加回车换行符 \r\n 来区分整包消息; |
StringDecoder | 字符串解码器; |
DelimiterBasedFrameDecoder | 特殊字符作为分隔符来区分整包消息; |
FixedLengthFrameDecoder | 报文大小固定长度,不够空格补全; |
ProtoBufVarint32FrameDecoder | 通过 Protobuf 解码器来区分整包消息; |
ProtobufDecoder | Protobuf 解码器; |
LengthFieldBasedFrameDecoder | 指定长度来标识整包消息,通过在包头指定整包长度来约定包长。 |
以及如下的编码器:
编码器 | 说明 |
---|---|
ProtobufEncoder | Protobuf 编码器; |
MessageToByteEncoder | 将 Java 对象编码成 ByteBuf; |
MessageToMessageEncoder | 如果不想将 Java 对象编码成 ByteBuf,而是自定义类就继承这个; |
LengthFieldPrepender | 如果我们在发送消息的时候采用的是:消息长度字段+原始消息的形式,那么我们就可以使用 LengthFieldPrepender。这是因为 LengthFieldPrepender 可以将待发送消息的长度(二进制字节长度)写到 ByteBuf 的前两个字节。 |
编解码相关类结构图如下:
上面的类关系能看到所有的自定义解码器都是继承自 ByteToMessageDecoder。在 Netty 中 Decoder 主要分为两大类:
- 将字节流转换为某种协议的数据格式:ByteToMessageDecoder 和 ReplayingDecoder
- 将一种协议的数据转为另一种协议的数据格式:MessageToMessageDecoder
将字节流转为对象是一种很常见的操作,也是一个消息框架应该提供的基础功能。因为 Decoder 的作用是将输入的数据解析成特定协议,上图中可以看到所有的 Decoder 都实现了 ChannelInboundHandler 接口。在应用层将 byte 转为 message 的难度在于如何确定当前的包是一个完整的数据包,有两种方案可以实现:
- 监听当前 Socket 的线程一直等待,直到收到的 byte 可以完成的构成一个包为止。这种方式的弊端就在于要浪费一个线程去等;
- 为每个监听的 Socket 都构建一个本地缓存,当前监听线程如果遇到字节数不够的情况就先将获取到的数据存入缓存,继而处理别的请求,等到这里有数据的时候再来将新数据继续写入缓存直到数据构成一个完整的包取出。
a. ByteToMessageDecoder#
ByteToMessageDecoder 采用的是第二种方案。在 ByteToMessageDecoder 中有一个对象 ByteBuf,该对象用于存储当前 Decoder 接收到的 byte 数据。
public abstract class ByteToMessageDecoder extends ChannelInboundHandlerAdapter {
// 用来保存累计读取到的字节,我们读到的新字节会保存(缓冲)在这里
ByteBuf cumulation;
// 用来做累计的,负责将读到的新字节写入 cumulation,有两个实现 MERGE_CUMULATOR 和 COMPOSITE_CUMULATOR
private Cumulator cumulator = MERGE_CUMULATOR;
// 设置为 true 后,单个解码器只会解码出一个结果
private boolean singleDecode;
private boolean decodeWasNull;
// 是否是第一次读取数据
private boolean first;
// 多少次读取后,丢弃数据(默认16次)
private int discardAfterReads = 16;
// 已经累加了多少次数据
private int numReads;
/**
* 每次接收到数据,就会调用 channelRead 进行处理,该处理器用于处理二进制数据。
* 所以 msg 字段的类型应该是 ByteBuf。如果不是,则交给 pipeLine 的下一个处理器进行处理。
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
// 如果不是 ByteBuf 则不处理
if (msg instanceof ByteBuf) {
// out 用于存储解析二进制流得到的结果,一个二进制流可能会解析出多个消息,所以 out 是一个 list
CodecOutputList out = CodecOutputList.newInstance();
try {
ByteBuf data = (ByteBuf) msg;
// 判断 cumulation == null 并将结果赋值给 first
// 因此如果 first 为 true,则表示第一次接受到数据
first = cumulation == null;
// 如果是第 1 次接收到数据,直接将接受到的数据赋值给缓存对象 cumulation
if (first) {
cumulation = data;
} else {
// 第 2 次解码,就将 data 向 cumulation 追加,并释放 data
// 如果 cumulation 中的剩余空间,不足以存储接收到的 data,将 cumulation 扩容
cumulation = cumulator.cumulate(ctx.alloc(), cumulation, data);
}
// 得到追加后的 cumulation 后,调用 decode() 进行解码
// 解码过程中,调用 fireChannelRead 方法,主要目的是将累积区的内容 decode 到数组中
callDecode(ctx, cumulation, out);
} catch (DecoderException e) {
throw e;
} catch (Throwable t) {
throw new DecoderException(t);
} finally {
// 如果 cumulation 没有数据可读了,说明所有的二进制数据都被解析过了。此时对 cumulation
// 进行释放,以节省内存空间。反之 cumulation 还有数据可读,那么 if 中的语句不会运行,
// 因为不对 cumulation 进行释放,因此也就缓存了用户尚未解析的二进制数据。
if (cumulation != null && !cumulation.isReadable()) {
// 将次数归零
numReads = 0;
// 释放累计区
cumulation.release();
// 等待 GC
cumulation = null;
// 如果超过了 16 次,就压缩累计区,主要是将已经读过的数据丢弃,将 readIndex 归零。
} else if (++numReads >= discardAfterReads) {
// We did enough reads already try to discard some bytes so we not risk to see a OOME.
// See https://github.com/netty/netty/issues/4275
numReads = 0;
discardSomeReadBytes();
}
int size = out.size();
// 如果没有向数组插入过任何数据
decodeWasNull = !out.insertSinceRecycled();
// 循环数组,向后面的 handler 发送数据,如果数组是空,那不会调用
fireChannelRead(ctx, out, size);
// 将数组中的内容清空,将数组的数组的下标恢复至原来
out.recycle();
}
} else {
// 如果 msg 类型是不是 ByteBuf,直接调用下一个 handler 进行处理
ctx.fireChannelRead(msg);
}
}
/**
* callDecode 方法主要用于解析 cumulation 中的数据,并将解析的结果放入 List<Object> out 中。由于
* cumulation 中缓存的二进制数据,可能包含了出多条有效信息,因此在 callDecode() 中,默认会调用多次
* decode 方法。我们在覆写 decode 方法时,每次只解析一个消息,添加到 out 中,callDecode 通过多次
* 回调 decode。每次传递进来都是相同的 List<Object> out 实例,因此每一次解析出来的消息,都存储在同
* 一个 out 实例中。当 cumulation 没有数据可以继续读,或者某次调用 decode 方法后,List<Object> out
* 中元素个数没有变化,则停止回调 decode 方法。
*/
protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
try {
// 如果 cumulation 中有数据可读的话,一直循环调用 decode 方法
while (in.isReadable()) {
// 获取上一次 decode 方法调用后,out 中元素数量,如果是第 1 次调用,则为 0。
int outSize = out.size();
// 上次循环成功解码
if (outSize > 0) {
// 用后面的业务 handler 的 ChannelRead 方法读取解析的数据
fireChannelRead(ctx, out, outSize);
out.clear();
if (ctx.isRemoved()) {
break;
}
outSize = 0;
}
int oldInputLength = in.readableBytes();
// 回调 decode 方法,由开发者覆写,用于解析 in 中包含的二进制数据,并将解析结果放到 out 中。
decode(ctx, in, out);
if (ctx.isRemoved()) {
break;
}
// outSize 是上一次 decode 方法调用时 out 的大小,out.size() 是当前 out 大小。
// 如果二者相等,则说明当前 decode 方法调用没有解析出有效信息。
if (outSize == out.size()) {
// 此时,如果发现上次 decode 方法和本次 decode 方法调用候,in 中的剩余可读字节数相同,则说明本次 decode
// 方法没有读取任何数据解析(可能是遇到半包等问题,即剩余的二进制数据不足以构成一条消息),跳出 while 循环。
if (oldInputLength == in.readableBytes()) {
break;
} else {
continue;
}
}
// 处理人为失误。如果走到这段代码,则说明 outSize != out.size()。也就是本次 decode
// 实际上是解析出来了有效信息放到 out 中。若 oldInputLength == in.readableBytes()
// 说明本次 decode 方法调用并没有读取任何数据,但是 out 中元素却添加了。
// 这可能是因为开发者错误的编写了代码,例如 mock 了一个消息放到 List
if (oldInputLength == in.readableBytes()) {
throw new DecoderException(StringUtil.simpleClassName(getClass())
+ ".decode() did not read anything but decoded a message.");
}
if (isSingleDecode()) {
break;
}
}
} catch (DecoderException e) {
throw e;
} catch (Throwable cause) {
throw new DecoderException(cause);
}
}
}
这里 channelRead() 的主要逻辑是:
- 从对象池中取出一个空的数组;
- 判断成员变量是否是第 1 次使用,要注意的是,这里既然使用了成员变量,所以这个 handler 不能是
@Shareble
状态的 handler,不然你就分不清成员变量是哪个 channel 的。将 unsafe 中传递来的数据写入到这个 cumulation 累积区中; - 写到累积区后,调用子类的 decode 方法,尝试将累积区的内容解码,每成功解码一个,就调用后面节点的 channelRead 方法。若没有解码成功,什么都不做;
- 如果累积区没有未读数据了,就释放累积区;
- 如果还有未读数据,且解码超过了 16 次(默认),就对累积区进行压缩。将读取过的数据清空,也就是将 readIndex 设置为 0;
- 设置 decodeWasNull 的值,如果上一次没有插入任何数据,这个值就是 ture。该值在调用 channelReadComplete 方法的时候,会触发 read 方法(不是自动读取的话),尝试从 JDK 的通道中读取数据,并将之前的逻辑重来。主要应该是怕如果什么数据都没有插入就执行 channelReadComplete 会遗漏数据;
- 调用 fireChannelRead 方法,将数组中的元素发送到后面的 handler 中;
- 将数组清空,并还给对象池。
当数据添加到累积区之后,需要调用 decode 方法进行解码,代码见上面的 callDecode()。在 callDecode() 中最关键的代码就是将解析完的数据拿取调用 decode(ctx, in, out)。所以如果继承 ByteToMessageDecoder 类实现自己的字节流转对象的逻辑我们就要覆写该方法。
b. LengthFieldBasedFrameDecoder#
最好的方案就是:发送方告诉我当前消息总长度,接收方如果没有收到该长度大小的数据就认为是没有收完继续等待。
/**
* Creates a new instance.
*
* @param maxFrameLength 帧的最大长度
* @param lengthFieldOffset 长度字段偏移的地址
* @param lengthFieldLength 长度字段所占的字节长
* @param lengthAdjustment 解析时候跳过多少个长度
* @param initialBytesToStrip 解码出一个数据包之后,去掉开头的字节数
* @param initialBytesToStrip 为 true,当 frame 长度超过 maxFrameLength 时立即报 TooLongFrameException
* 为 false,读取完整个帧再报异
*/
public LengthFieldBasedFrameDecoder(
int maxFrameLength,
int lengthFieldOffset, int lengthFieldLength,
int lengthAdjustment, int initialBytesToStrip) {
this(
maxFrameLength,
lengthFieldOffset, lengthFieldLength, lengthAdjustment,
initialBytesToStrip, true);
}
/**
* Creates a new instance.
*
* @param maxFrameLength
* the maximum length of the frame. If the length of the frame is
* greater than this value, {@link TooLongFrameException} will be
* thrown.
* @param lengthFieldOffset
* the offset of the length field
* @param lengthFieldLength
* the length of the length field
*/
public LengthFieldBasedFrameDecoder(
int maxFrameLength, int lengthFieldOffset, int lengthFieldLength) {
this(maxFrameLength, lengthFieldOffset, lengthFieldLength, 0, 0);
}
在 LengthFieldBasedFrameDecoder 类的注解上给出了一些关于该类使用的示例:
/**
* A decoder that splits the received {@link ByteBuf}s dynamically by the
* value of the length field in the message. It is particularly useful when you
* decode a binary message which has an integer header field that represents the
* length of the message body or the whole message.
* <p>
* {@link LengthFieldBasedFrameDecoder} has many configuration parameters so
* that it can decode any message with a length field, which is often seen in
* proprietary client-server protocols. Here are some example that will give
* you the basic idea on which option does what.
*
* <h3>2 bytes length field at offset 0, do not strip header</h3>
*
* The value of the length field in this example is <tt>12 (0x0C)</tt> which
* represents the length of "HELLO, WORLD". By default, the decoder assumes
* that the length field represents the number of the bytes that follows the
* length field. Therefore, it can be decoded with the simplistic parameter
* combination.
* <pre>
* <b>lengthFieldOffset</b> = <b>0</b>
* <b>lengthFieldLength</b> = <b>2</b>
* lengthAdjustment = 0
* initialBytesToStrip = 0 (= do not strip header)
*
* BEFORE DECODE (14 bytes) AFTER DECODE (14 bytes)
* +--------+----------------+ +--------+----------------+
* | Length | Actual Content |----->| Length | Actual Content |
* | 0x000C | "HELLO, WORLD" | | 0x000C | "HELLO, WORLD" |
* +--------+----------------+ +--------+----------------+
* </pre>
*
* <h3>2 bytes length field at offset 0, strip header</h3>
*
* Because we can get the length of the content by calling
* {@link ByteBuf#readableBytes()}, you might want to strip the length
* field by specifying <tt>initialBytesToStrip</tt>. In this example, we
* specified <tt>2</tt>, that is same with the length of the length field, to
* strip the first two bytes.
* <pre>
* lengthFieldOffset = 0
* lengthFieldLength = 2
* lengthAdjustment = 0
* <b>initialBytesToStrip</b> = <b>2</b> (= the length of the Length field)
*
* BEFORE DECODE (14 bytes) AFTER DECODE (12 bytes)
* +--------+----------------+ +----------------+
* | Length | Actual Content |----->| Actual Content |
* | 0x000C | "HELLO, WORLD" | | "HELLO, WORLD" |
* +--------+----------------+ +----------------+
* </pre>
*
* <h3>2 bytes length field at offset 0, do not strip header, the length field
* represents the length of the whole message</h3>
*
* In most cases, the length field represents the length of the message body
* only, as shown in the previous examples. However, in some protocols, the
* length field represents the length of the whole message, including the
* message header. In such a case, we specify a non-zero
* <tt>lengthAdjustment</tt>. Because the length value in this example message
* is always greater than the body length by <tt>2</tt>, we specify <tt>-2</tt>
* as <tt>lengthAdjustment</tt> for compensation.
* <pre>
* lengthFieldOffset = 0
* lengthFieldLength = 2
* <b>lengthAdjustment</b> = <b>-2</b> (= the length of the Length field)
* initialBytesToStrip = 0
*
* BEFORE DECODE (14 bytes) AFTER DECODE (14 bytes)
* +--------+----------------+ +--------+----------------+
* | Length | Actual Content |----->| Length | Actual Content |
* | 0x000E | "HELLO, WORLD" | | 0x000E | "HELLO, WORLD" |
* +--------+----------------+ +--------+----------------+
* </pre>
*
* <h3>3 bytes length field at the end of 5 bytes header, do not strip header</h3>
*
* The following message is a simple variation of the first example. An extra
* header value is prepended to the message. <tt>lengthAdjustment</tt> is zero
* again because the decoder always takes the length of the prepended data into
* account during frame length calculation.
* <pre>
* <b>lengthFieldOffset</b> = <b>2</b> (= the length of Header 1)
* <b>lengthFieldLength</b> = <b>3</b>
* lengthAdjustment = 0
* initialBytesToStrip = 0
*
* BEFORE DECODE (17 bytes) AFTER DECODE (17 bytes)
* +----------+----------+----------------+ +----------+----------+----------------+
* | Header 1 | Length | Actual Content |----->| Header 1 | Length | Actual Content |
* | 0xCAFE | 0x00000C | "HELLO, WORLD" | | 0xCAFE | 0x00000C | "HELLO, WORLD" |
* +----------+----------+----------------+ +----------+----------+----------------+
* </pre>
*
* <h3>3 bytes length field at the beginning of 5 bytes header, do not strip header</h3>
*
* This is an advanced example that shows the case where there is an extra
* header between the length field and the message body. You have to specify a
* positive <tt>lengthAdjustment</tt> so that the decoder counts the extra
* header into the frame length calculation.
* <pre>
* lengthFieldOffset = 0
* lengthFieldLength = 3
* <b>lengthAdjustment</b> = <b>2</b> (= the length of Header 1)
* initialBytesToStrip = 0
*
* BEFORE DECODE (17 bytes) AFTER DECODE (17 bytes)
* +----------+----------+----------------+ +----------+----------+----------------+
* | Length | Header 1 | Actual Content |----->| Length | Header 1 | Actual Content |
* | 0x00000C | 0xCAFE | "HELLO, WORLD" | | 0x00000C | 0xCAFE | "HELLO, WORLD" |
* +----------+----------+----------------+ +----------+----------+----------------+
* </pre>
*
* <h3>2 bytes length field at offset 1 in the middle of 4 bytes header,
* strip the first header field and the length field</h3>
*
* This is a combination of all the examples above. There are the prepended
* header before the length field and the extra header after the length field.
* The prepended header affects the <tt>lengthFieldOffset</tt> and the extra
* header affects the <tt>lengthAdjustment</tt>. We also specified a non-zero
* <tt>initialBytesToStrip</tt> to strip the length field and the prepended
* header from the frame. If you don't want to strip the prepended header, you
* could specify <tt>0</tt> for <tt>initialBytesToSkip</tt>.
* <pre>
* lengthFieldOffset = 1 (= the length of HDR1)
* lengthFieldLength = 2
* <b>lengthAdjustment</b> = <b>1</b> (= the length of HDR2)
* <b>initialBytesToStrip</b> = <b>3</b> (= the length of HDR1 + LEN)
*
* BEFORE DECODE (16 bytes) AFTER DECODE (13 bytes)
* +------+--------+------+----------------+ +------+----------------+
* | HDR1 | Length | HDR2 | Actual Content |----->| HDR2 | Actual Content |
* | 0xCA | 0x000C | 0xFE | "HELLO, WORLD" | | 0xFE | "HELLO, WORLD" |
* +------+--------+------+----------------+ +------+----------------+
* </pre>
*
* <h3>2 bytes length field at offset 1 in the middle of 4 bytes header,
* strip the first header field and the length field, the length field
* represents the length of the whole message</h3>
*
* Let's give another twist to the previous example. The only difference from
* the previous example is that the length field represents the length of the
* whole message instead of the message body, just like the third example.
* We have to count the length of HDR1 and Length into <tt>lengthAdjustment</tt>.
* Please note that we don't need to take the length of HDR2 into account
* because the length field already includes the whole header length.
* <pre>
* lengthFieldOffset = 1
* lengthFieldLength = 2
* <b>lengthAdjustment</b> = <b>-3</b> (= the length of HDR1 + LEN, negative)
* <b>initialBytesToStrip</b> = <b> 3</b>
*
* BEFORE DECODE (16 bytes) AFTER DECODE (13 bytes)
* +------+--------+------+----------------+ +------+----------------+
* | HDR1 | Length | HDR2 | Actual Content |----->| HDR2 | Actual Content |
* | 0xCA | 0x0010 | 0xFE | "HELLO, WORLD" | | 0xFE | "HELLO, WORLD" |
* +------+--------+------+----------------+ +------+----------------+
* </pre>
* @see LengthFieldPrepender
*/
3. 编解码器#
我们把解决半包粘包问题的解码器叫「一次解码器」,其作用是将原始数据流(可能会出现粘包和半包的数据流)转换为用户数据(ByteBuf 中存储),但仍然是字节数据,所以我们需要「二次解码器」将字节数组转换为 Java 对象,或者将将一种格式转化为另一种格式,方便上层应用程序使用。
一次解码器继承自 ByteToMessageDecoder,二次解码器继承自 MessageToMessageDecoder,但他们的本质都是继承 ChannelInboundHandlerAdapter。
是不是也可以合并 1 次解码和 2 次解码? 可以,但不推荐。没有分层,不够清晰;耦合性高,不容易置换方案。
当 Netty 发送或者接受一个消息的时候,就将会发生一次数据转换。入站消息会被解码:从字节转换为另一种格式(比如某种类型的对象);如果是出站消息,它会被编码成字节。
Netty 提供一系列实用的编解码器,他们都实现了 ChannelInboundHandler 或 ChannelOutboundHandler 接口。在这些类中,channelRead 方法已经被重写了。以入站为例,对于每个从入站 Channel 读取的消息,这个方法会被调用。随后,它将调用由解码器所提供的 decode() 方法进行解码,并将已经解码的字节转发给 ChannelPipeline 中的下一个 ChannelInboundHandler。
3.1 示例代码#
(1)Byte2LongDecoder
public class Byte2LongDecoder extends ByteToMessageDecoder {
/**
* 这个例子,每次入站从 ByteBuf 中读取 8 字节,将其解码为一个 long,然后将它添加到下一个 List 中。
* 当没有更多元素可以被添加到该 List 中时,它的内容将会被发送给下一个 ChannelInboundHandler。
* long 在被添加到 List 中时,会被自动装箱为 Long。
* @param ctx
* @param in
* @param out
* @throws Exception
*/
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
System.out.println("[Byte2LongDecoder#decode] Server decode data...");
if (in.readableBytes() >= 8) {
out.add(in.readLong());
}
}
}
(2)Long2ByteEncoder
public class Long2ByteEncoder extends MessageToByteEncoder<Long> {
/**
* 接收的消息类型必须与待处理的消息类型必须一致,否则调用的处理逻辑是 super.encode()
*
* if (acceptOutboundMessage(msg)) {
* @SuppressWarnings("unchecked")
* I cast = (I) msg;
buf = allocateBuffer(ctx, cast, preferDirect);
* try {
* encode(ctx, cast, buf);
* } finally {
* ReferenceCountUtil.release(cast);
* }
*
* if (buf.isReadable()) {
* ctx.write(buf, promise);
* } else {
* buf.release();
* ctx.write(Unpooled.EMPTY_BUFFER, promise);
* }
* buf = null;
* } else {
* ctx.write(msg, promise);
* }
*
* @param ctx
* @param msg
* @param out
* @throws Exception
*/
@Override
protected void encode(ChannelHandlerContext ctx, Long msg, ByteBuf out) throws Exception {
System.out.println("[Long2ByteEncoder#encode] Client encode data...");
out.writeLong(msg);
}
}
(3)NettyClientHandler
public class NettyClientHandler extends ChannelInboundHandlerAdapter {
/**
* 当通道就绪就会触发该方法
*
* @param ctx
* @throws Exception
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
System.out.println("[NettyClientHandler#channelActive] Client send data...");
ctx.writeAndFlush(12345L);
// 当发送的类型不是编码器处理的类型时,则会调用 MessageToByteEncoder#encode()。
ctx.writeAndFlush(Unpooled.copiedBuffer("Hello, Server! ヾ(•ω•`)o", CharsetUtil.UTF_8));
}
}
(4)NettyServerHandler
public class NettyServerHandler extends SimpleChannelInboundHandler<Long> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, Long msg) throws Exception {
System.out.println("[NettyServerHandler#channelRead] " + msg);
}
}
(5)ClientServer
ch.pipeline()
.addLast(new Long2ByteEncoder())
.addLast(new NettyClientHandler());
(6)NettyServer
ch.pipeline()
.addLast(new Byte2LongDecoder())
.addLast(new NettyServerHandler());
(7)控制台打印
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek “源神”启动!「GitHub 热点速览」
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 我与微信审核的“相爱相杀”看个人小程序副业
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· spring官宣接入deepseek,真的太香了~