Netty 学习(六)实现自定义协议通信

2023-05-16

目录

  • 前言
  • 一、通信协议设计
    • 通用协议
    • 自定义协议
    • 网络协议需要具备的要素
      • 1. 魔数
      • 2. 协议版本号
      • 3. 序列化算法
      • 4. 报文类型
      • 5. 长度域字段
      • 6. 请求数据
      • 7. 状态
      • 8. 校验字段
      • 9. 保留字段
  • 二、Netty 实现自定义通信协议
    • Netty 中编解码器分类
  • 三、抽象编码类
    • MessageToByteEncoder
    • MessageToMessageEncoder
  • 四、抽象解码类
    • ByteToMessageDecoder
    • MessageToMessageDecoder
    • 解码过程
  • 五、通信协议实战
  • 总结

前言

为了满足自己业务场景的需要, 应用层之间通信需要实现各种各样的网络协议。本文记录如何设计一个高效、可扩展、易维护的自定义通信协议,以及如何使用 Netty 实现自定义的通信协议。


一、通信协议设计

所谓的协议,就是通信双方事先商量好的接口“暗语”, 在 TCP 网络编程中,发送方和接收方的数据包格式都是二进制,发送方将对象转化成二进制流发送给接收方,接收方获得二进制数据后需要知道如何解析对象,所以协议是双方能够正常通行的基础。

通用协议

市面上已经有不小通用的协议,例如 HTTP、 HTTPS、JSON-RPC、FTP、IMAP、Protobuf等。通用协议兼容性好,易于维护,各种异构系统间可以实现无缝对接等。如果满足业务场景及性能需求的前提下,推荐采用通用协议的方案。

自定义协议

在特定的场景下,需要自定义自有协议。自定义协议有以下的优点:

  • 极致性能:通用协议考虑很多兼容性的因素,必然在性能有所损失。
  • 扩展性:自定义的协议相比通用协议更好扩展,可以更好地满足自己的业务需求。
  • 安全性:通用协议是公开的,可能存在很多漏洞。自定义协议通常是私有的,黑客需要先破解协议内容,才能攻破漏洞。

网络协议需要具备的要素

一个较为通用的协议示例:

/*
+---------------------------------------------------------------+
| 魔数 2byte | 协议版本号 1byte | 序列化算法 1byte | 报文类型 1byte  |
+---------------------------------------------------------------+
| 状态 1byte |        保留字段 4byte     |      数据长度 4byte     | 
+---------------------------------------------------------------+
|                   数据内容 (长度不定)          | 校验字段 2byte |
+---------------------------------------------------------------+
*/

1. 魔数

魔数是通信双方协商的一个暗号,通常采用固定的几个字节表示。魔数的作用是用于服务端在接收数据时先解析出前几个固定字节做正确性对比。如果和协议中的魔数不匹配,则认为是非法数据,可以直接关闭连接或采取其他措施增强系统安全性。魔数的思想在很多场景中都有体现,如 Java Class 文件开头就存储了魔数 OxCAFEBABE,在 JVM 加载 Class 文件时首先就会验证魔数对的正确性。

2. 协议版本号

为了应对业务需求的变化,可能需要对自定义协议的结构或字段进行改动。不同版本的协议对应的解析方法也是不同的。所以在生产级项目中强烈建议预留协议版本这个字段。

3. 序列化算法

序列化算法字段表示发送方将对象转换成二进制流,以及接收方将接收的二进制流转换成对象的方法,如 JSON、 Hessian、Java 自带序列化等。

4. 报文类型

报文类型用于描述业务场景中存在的不同报文类型。如 RPC 框架中有请求、响应、心跳类型。IM 通讯场景中有登陆、创建群聊、发送消息、接收消息、退出群聊等类型。

5. 长度域字段

长度域字段代表请求数据的长度,可以定义整个报文的长度,也可以是请求数据部分的长度。

6. 请求数据

请求数据通常为的业务对象信息序列化后的二进制流。是整个报文的主体。

7. 状态

状态字段用于标识请求是否正常,一般由被调用方设置。例如一次 RPC 调用失败,状态字段可被服务提供方设置为异常状态。

8. 校验字段

校验字段存放某种校验算法计算报文校验码,校验码用于验证报文的正确性。

9. 保留字段

保留字段是可选项,为了应对协议升级的可能性,可以预留若干字节的保留字段,以备不时之需。


二、Netty 实现自定义通信协议

Netty 作为一个非常优秀的网络通信框架,提供了非常丰富的编解码抽象基类来实现自定义协议。

Netty 中编解码器分类

  • 编码解码分类:
类型 编解码基类 说明
常用编码器类型 MessageToByteEncoder 对象编码成字节流
MessageToMessageEncoder 一种消息类型编码成另外一种消息类型
常用解码器类型 ByteToMessageDecoder/ReplayingDecoder 将字节流解码为消息对象
MessageToMessageDecoder 将一种消息类型解码为另外一种消息类型
  • 分层解码分类:
    一次解码:一次解码用于解决 TCP 拆包/粘包问题,按协议解析得到的字节数据。常用一次编解码器:MessageToByteEncoder / ByteToMessageDecoder。
    二次解码:对一次解析后的字节数据做对象模型的转换,这时候需要二次解码器,同理编码器的过程是反过来的。常用二次编解码器:MessageToMessageEncoder / MessageToMessageDecoder。

三、抽象编码类

在这里插入图片描述通过抽象编码类的继承图可以看出,编码类是 ChanneOutboundHandler 的抽象类实现,具体操作的是 Outbound 出站数据。

MessageToByteEncoder

MessageToByteEncoder 用于将对象编码成字节流,只需要实现其 encode 方法即可完成自定义编码。

MessageToByteEncoder 的核心源码片段,如下所示。

	@Override
	public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
	
	    ByteBuf buf = null;
	
	    try {
	        if (acceptOutboundMessage(msg)) { // 1. 消息类型是否匹配
	            @SuppressWarnings("unchecked")
	            I cast = (I) msg;
	
	            buf = allocateBuffer(ctx, cast, preferDirect); // 2. 分配 ByteBuf 资源
	
	            try {
	                encode(ctx, cast, buf); // 3. 执行 encode 方法完成数据编码
	            } finally {
	                ReferenceCountUtil.release(cast);
	            }
	
	            if (buf.isReadable()) {
	                ctx.write(buf, promise); // 4. 向后传递写事件
	            } else {
	                buf.release();
	                ctx.write(Unpooled.EMPTY_BUFFER, promise);
	            }
	            buf = null;
	        } else {
	            ctx.write(msg, promise);
	        }
	    } catch (EncoderException e) {
	        throw e;
	    } catch (Throwable e) {
	        throw new EncoderException(e);
	    } finally {
	        if (buf != null) {
	            buf.release();
	        }
	    }
	}

MessageToByteEncoder 重写了 ChanneOutboundHandler 的 write() 方法,其主要逻辑分为以下几个步骤:

  • acceptOutboundMessage 判断是否有匹配的消息类型,如果匹配需要执行编码流程,如果不匹配直接继续传递给下一个 ChannelOutboundHandler;

  • 分配 ByteBuf 资源,默认使用堆外内存;

  • 调用子类实现的 encode 方法完成数据编码,一旦消息被成功编码,会通过调用 ReferenceCountUtil.release(cast) 自动释放;

  • 如果 ByteBuf 可读,说明已经成功编码得到数据,然后写入 ChannelHandlerContext 交到下一个节点;如果 ByteBuf 不可读,则释放 ByteBuf 资源,向下传递空的 ByteBuf 对象。

编码器实现非常简单,不需要关注拆包/粘包问题。如下例子,展示了如何将字符串类型的数据写入到 ByteBuf 实例,ByteBuf 实例将传递给 ChannelPipeline 链表中的下一个 ChannelOutboundHandler。

    public class StringToByteEncoder extends MessageToByteEncoder<String> {
            @Override
            protected void encode(ChannelHandlerContext channelHandlerContext, String data, ByteBuf byteBuf) throws Exception {
                byteBuf.writeBytes(data.getBytes());
            }
    }

MessageToMessageEncoder

MessageToMessageEncoder 是将一种格式的消息转换为另一种格式的消息,它的子类同样只需要实现 encode 方法。MessageToMessageEncoder 常用的实现子类有 StringEncoder、LineEncoder、Base64Encoder 等。
StringEncoder 可以直接实现 String 类型数据的编码。源码示例如下:

    @Override
    protected void encode(ChannelHandlerContext ctx, CharSequence msg, List<Object> out) throws Exception {
        if (msg.length() == 0) {
            return;
        }
        out.add(ByteBufUtil.encodeString(ctx.alloc(), CharBuffer.wrap(msg), charset));
    }

四、抽象解码类

在这里插入图片描述解码类是 ChanneInboundHandler 的抽象类实现,操作的是 Inbound 入站数据。解码器的主要难度在于拆包和粘包问题,由于接收方可能没有接受到完整的消息,所以编码框架还要对入站数据做缓冲处理,直到获取到完整的消息。

ByteToMessageDecoder

ByteToMessageDecoder 类将字节流转换成对象,其定义的抽象 decode 方法:

    public abstract class ByteToMessageDecoder extends ChannelInboundHandlerAdapter {
        protected abstract void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception;
        protected void decodeLast(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
            if (in.isReadable()) {
                decodeRemovalReentryProtection(ctx, in, out);
            }
        }
    }

encode 方法在调用时需要传入接收的数据 ByteBuf,及用来添加编码后对象的 List。处理过程如下:

  • 由于 TCP 粘包问题,ByteBuf 中可能包含多个有效的报文,或者不够一个完整的报文,所以 Netty 会重复回调 decode 方法
  • 将解码后的对象添加到 List,直到没有更多可以读取的数据为止。
  • List 的内容会传递给 ChannelPipeline 中的下一个 ChannelInboundHandler。
    decodeLast 方法在 Channel 关闭后会被调用一次,用于处理 ByteBuf 最后剩余的字节数据。Netty 中 decodeLast 的默认实现只是简单的调用了 decode 方法,如果有特殊的需求,可以通过重写 decodeLast 方法来扩展自定义逻辑。

MessageToMessageDecoder

MessageToMessageDecoder 是将一种消息类型的编码成另外一种消息类型。MessageToMessageDecoder 不对数据报文继续缓存,其主要用作转换消息模型。

解码过程

在这里插入图片描述

  • 使用 ByteToMessageDecoder 解析 TCP 协议,解决拆包/粘包问题。解析得到有效 ByteBuf 数据
  • 使用 MessageToMessageDecoder 做数据对象的转换。

五、通信协议实战

	/*
	+---------------------------------------------------------------+
	| 魔数 2byte | 协议版本号 1byte | 序列化算法 1byte | 报文类型 1byte  |
	+---------------------------------------------------------------+
	| 状态 1byte |        保留字段 4byte     |      数据长度 4byte     | 
	+---------------------------------------------------------------+
	|                   数据内容 (长度不定)                          |
	+---------------------------------------------------------------+
	 */

对以上的自定义报文,协议头部包含了魔数、协议版本号、数据长度等固定字段。而 ByteBuf 是否完整,需要通过消息长度 dataLength 字段来判断。自定义编码器需要重写 ByteToMessageDecoder 的 encode 方法,具体代码如下所示:

	@Override
	public final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
	    // 判断 ByteBuf 可读取字节
	    if (in.readableBytes() < 14) { 
	        return;
	    }
	    in.markReaderIndex(); // 标记 ByteBuf 读指针位置
	    in.skipBytes(2); // 跳过魔数
	    in.skipBytes(1); // 跳过协议版本号
	    byte serializeType = in.readByte();
	    in.skipBytes(1); // 跳过报文类型
	    in.skipBytes(1); // 跳过状态字段
	    in.skipBytes(4); // 跳过保留字段
	    int dataLength = in.readInt();
	    if (in.readableBytes() < dataLength) {
	        in.resetReaderIndex(); // 重置 ByteBuf 读指针位置
	        return;
	    }
	    byte[] data = new byte[dataLength];
	    in.readBytes(data);
	    SerializeService serializeService = getSerializeServiceByType(serializeType);
	    Object obj = serializeService.deserialize(data);
	    if (obj != null) {
	        out.add(obj);
	    }
	}

总结

本文学习了协议设计的基本要素,以及如何使用 Netty 编解码器实现自定义协议。最后通过基于 Netty 抽象类实现自定义的编解码器,实战具体示例协议,加深对编解码器的理解。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

Netty 学习(六)实现自定义协议通信 的相关文章

  • 数据库中字符串匹配函数like、rlike、instr、regexp_extract

    instr 简介 MySQL hive中函数 xff0c instr str substr position occurrence 其中str代表从哪个字符串中搜索 xff0c substr代表搜索哪个子字符串 xff0c 返回值为子字符串
  • 【C语言】实现linux下的基于C语言的一个简单的TCP客/服 端的通信

    对于基础的好的朋友可以直接取代码 xff0c 如果想要看详细解析的朋友可以详看下方的解析 TCP服务端代码 xff1a include lt stdio h gt include lt string h gt include lt stdl
  • 实验二 OpenGL的简单动画

    ZZU的学弟学妹们不要抄作业哦 一 实验目的 1 掌握OpenGL的闲置函数 2 掌握OpenGL的时间函数 3 掌握OpenGL的简单动画功能 4 了解OpengGL裁剪窗口 视区 显示窗口的概念和它们之间的关系 5 进一步掌握OpenG
  • 使用脚本(命令行)编译KEIL工程

    参考KEIL官网 http www keil com support man docs uv4 uv4 commandline htm Keil Build bat脚本 64 echo off set UV 61 D Keil v5 UV4
  • (二)五次多项式轨迹规划

    一 三次多项式轨迹规划的缺陷 上一篇文章说道 xff0c 三次多项式轨迹规划只能够保证速度和位移连续 xff0c 并不能保证加速度连续 加速度不连续将会对使电机抖动 甚至冲击 二 轨迹规划中的五次多项式 我们对加速度数值进行指定 xff0c
  • (三)抛物线过渡的线性函数规划

    前面说到 xff0c 无论是三次还是五次多项式进行规划存在以下缺点 xff1a 位移往返没有匀速段 这一节中 xff0c 我们的研究对象是初速度和末速度都为0关节运动 一 无过渡线性函数 假设时刻 t t t 和角度
  • GCC的学习(二)头文件及其库制作

    当前目录 43 相对路径 61 绝对路径大写i xff0c I头文件包含路径大写l xff0c L库文件路径小写l xff0c l库名字 xff08 去头去尾没有lib xff0c 也没有so ldd 查看动态库连接库存在性及其路径nm 查
  • VSCODE(八)launch 调试与运行

    前面讲了如何配置任务文件tasks json xff0c 调试功能在程序程序经常会用上 xff0c 那么VSCODE是完成调试功能的呢 xff1f 答 xff1a vscode文件夹内的launch json文件配置 xff0c 一些调试器
  • C++11线程库 (七) 线程的停止

    在这篇文章里 xff0c 我们将会讨论如何在C 43 43 11下停止和终止一个线程 为什么C 43 43 11不直接提供一个停止线程的方法 xff1f 这是因为线程在停止之前可能有一些资源需要被释放 关闭 xff0c 比如说 xff1a
  • QCustomplot(五)QCPAxisRect进行子绘图

    子绘图是比较复杂的 xff0c 你几乎需要掌握QCustomPlot所有类及其含义 那么需要掌握的类有 xff1a QCPAxis 描述轴行为 xff0c 轴需要依赖QCPAxisRectQCPAxisRect 描述矩形区域 xff0c 区
  • 关于C/C++的换行

    可以分为两种情况 xff1a 代码换行长字符串换行 一 C C 43 43 代码换行直接用enter键换行 xff0c 同时注意关键字不要切割 xff1a span class token keyword bool span a span
  • 31线性变换及其矩阵

    一 线性变换的概念 线性变换是数学中一种特殊的映射 xff0c 它具有将向量空间中的元素转换到另一向量空间元素的功能 xff0c 同时也保持了变换前后向量的线性不变的性质 线性变换的数学表达式如下 xff1a T c
  • 30奇异值分解

    在讲SVD之前 xff0c 我们先来看看计算是如何存储一个灰度图的 灰度图会被分割成一块块的小灰度像素 xff0c 然后进行存储 xff0c 数值的大小表示灰度的程度 一个灰度像素用8位进行存储 xff0c 也就是256种状态 xff0c
  • (二)PID控制的Anti-windup

    比例环节 xff1a 快速接近目标积分环节 xff1a 防止稳态误差微分环节 xff1a 减少振荡 被控对象可以分为两个部分 xff0c 分别是执行器 xff0c 用于产生力或者能量从而改变系统 xff0c 和处理 xff0c 比如说温控的
  • Socket的学习(一)什么是Socket?

    本文参考的是 Socket通信原理 https www cnblogs com wangcq p 3520400 html 一 TCP IP UDP是什么 xff1f TCP IP xff08 Transmission Control Pr
  • Socket的学习(二)AF_UNIX实现本地通信

    参考文章 xff1a 1 Unix Domain Socket IPC通信机制 2 How fast are Unix domain sockets 3 read 函数参数理解 一 Unix域的Socket通信及其优点 基于socket的框
  • 工程师笔记|在CMD窗口运行C语言

    cmd窗口大家可能都比较熟悉 xff0c 那如何在cmd窗口编译运行C语言程序呢 xff1f 其实很简单的 xff0c 只需要下载一个C C 43 43 编译器 xff0c 添加一下环境变量 xff0c 之后就能在cmd窗口中编译运行C语言
  • 推挽输出与开漏输出

    推挽输出 要理解推挽输出 xff0c 首先要理解好三极管 xff08 晶体管 xff09 的原理 下面这种三极管有三个端口 xff0c 分别是基极 xff08 Base xff09 集电极 xff08 Collector xff09 和发射
  • 固件、驱动、软件的区别

    不管我们使用什么操作系统 xff0c 无论是 Windows macOS xff0c 还是 Linux xff0c 里面都安装了许多软件 驱动程序和固件 但是 xff0c 这三者概念有什么区别 xff1f 转载微信公众号 xff1a 良许L
  • 51单片机与STM32的区别(为何51单片机IO引脚的驱动能力弱)

    1 51单片机的特性 51单片机之所以成为经典 xff0c 成为易上手的单片机主要有以下特点 xff1a 从内部的硬件到软件有一套完整的按位操作系统 xff0c 称作位处理器 xff0c 处理对象不是字或字节而是位 不但能对片内某些特殊功能

随机推荐

  • I2C—读写EEPROM

    1 I2C协议简介 I2C通讯协议 Inter xff0d IntegratedCircuit 是由Phiilps公司开发的 xff0c 由于它引脚少 xff0c 硬件实现简单 xff0c 可扩展性强 xff0c 不需要USART CAN等
  • SPI—读写串行FLASH

    1 SPI协议简介 SPI协议是由摩托罗拉公司提出的通讯协议 SerialPeripheralInterface xff0c 即串行外围设备接口 xff0c 是一种高速全双工的通信总线 它被广泛地使用在ADC LCD等设备与MCU间 xff
  • C语言中的关键字应用技巧(volatile、const、struct/union、_ _预定义_ _、#/##、void/void*、weak)

    嵌入式C开发关键字的应用技巧 1 volatile volatile修饰表示变量是易变的 xff0c 编译器中的优化器在用到这个变量时必须每次都小心地从内存中重新读取这个变量的值 xff0c 而不是使用保存在寄存器里的备份 xff0c 有效
  • 一文弄懂GPIO不同模式之间的区别与实现原理

    GPIO全称General Purpose Input Output xff0c 即通用输入 输出 其实GPIO的本质就是芯片的一个引脚 xff0c 通常在ARM中所有的I O都是通用的 不过 xff0c 由于每个开发板上都会设计不同的外围
  • printf()是如何与UART外设驱动函数“勾搭”起来的?

    今天给大家分享的是IAR下调试信息输出机制之硬件UART外设 在嵌入式世界里 xff0c 输出打印信息是一种非常常用的辅助调试手段 xff0c 借助打印信息 xff0c 我们可以比较容易地定位和分析程序问题 在嵌入式应用设计里实现打印信息输
  • 卡塔尔世界杯:带“芯片”的智能足球亮相!背后藏着哪些技术原理?

    2022年卡塔尔世界杯正式开幕 xff01 揭幕战上 xff0c 厄瓜多尔队以2 0的比分击败东道主卡塔尔队 xff0c 取得本届世界杯的首场胜利 本场比赛后 xff0c 世界杯东道主首战不败的纪录就此作古 xff0c 这一消息也引发足球圈
  • 串口通信详解

    一 串口通讯简介 串口通信 Serial Communications 的概念非常简单 xff0c 串口按位 bit 发送和接收字节 尽管比按字节 byte 的并行通信慢 xff0c 但是串口可以在使用一根线发送数据的同时用另一根线接收数据
  • C++类详解(public、private、protected)

    二 C 43 43 类的声明 类使用class关键字声明 xff0c 声明方法如下 xff1a class 类名 xff1a public 公有成员 int num private 私有成员 int age protected 保护成员 i
  • linux下网络通信(udp通信协议详解)

    一 udp通信简介 udp是User Datagram Protocol的简称 xff0c 中文名是用户数据报协议 udp协议位于osi模型中的传输层 xff0c 它是一种面向无连接的协议 udp协议并不保证数据一定能够到达对端 xff0c
  • 广播地址、组播地址、网关和子网掩码

    一 IP地址分类 IP地址一共32位 xff0c 由两部分组成 xff0c 网络号和主机号 网络号标识当前设备处于Internet的哪一个网络 xff0c 主机号标识当前设备属于该网络中的那一台主机 IP地址一共分为5类 xff1a 地址分
  • URL格式

    一 URL基本格式 一个完整的url包含方案 用户名 密码 主机名 端口 路径 参数 查询和片段 xff0c 格式如下 xff1a lt scheme gt lt user gt lt password gt 64 lt host gt l
  • c++构造函数和析构函数

    一 构造函数和析构函数的特点 构造函数和析构函数是一种特殊的公有成员函数 xff0c 每一个类都有一个默认的构造函数和析构函数 xff1b 构造函数在类定义时由系统自动调用 xff0c 析构函数在类被销毁时由系统自动调用 xff1b 构造函
  • linux下常用压缩命令

    一 tar命令 tar命令用来打包一个目录 xff0c 它支持三种格式 xff1a tar bz2 34 和 gz 34 1 1 压缩 tar cvf 文件名 tar 文件目录 打包成 tar文件 tar jcvf 文件名 tar bz2
  • 用vscode开发autojs,输出窗口不显示任何输出结果

    我的情况是 xff1a 我vscode开发autojs 程序 xff0c 之前在一切正常的情况下 xff0c 输出窗口可以正常显示程序运行结果 xff0c 右侧红圈里可以选择我连接的手机型号 如下图 xff1a 但是现在出现问题 xff1a
  • ubuntu开机没有ens33解决方法

    最近重新安装了VMware xff0c 使用之前的ubuntu镜像 xff0c 发现只有一个lo网卡 xff0c 没有ens33 xff0c 虚拟机无法获取ip地址 xff0c samba服务器也无法正常使用 root 64 ubuntu
  • ubuntu下arm-none-eabi-gcc安装

    一 下载安装包 下载地址 xff1a https launchpad net gcc arm embedded 43 download 选择linux版本下载 xff1a gcc arm none eabi 5 4 2016q3 20160
  • 2.4G-WiFi连接路由器过程

    一 概述 WiFi的数据通信基于802 11协议进行 xff0c 无线AP在工作时会定时向空中发送beacon数据包 xff0c 基站 xff08 STA xff09 从beacon中解析出AP的名称 加密方式等信息 xff0c 从而发起连
  • STM32f103时钟树详解

    一 概述 stm32有四种时钟信号源 xff0c HSE 高速外部时钟 HSI xff08 高速内部时钟 xff09 LSE xff08 低速外部时钟 xff09 LSI xff08 低速内部时钟 xff09 HSE通常接8M晶振 xff0
  • 头文件重复包含

    一 头文件重复包含问题分析 1 问题重现 举例说明 假设在某个C 43 43 头文件 或 源文件 中 xff0c 包含了A h和B h两个头文件 xff1a span class token macro property span clas
  • Netty 学习(六)实现自定义协议通信

    目录 前言一 通信协议设计通用协议自定义协议网络协议需要具备的要素1 魔数2 协议版本号3 序列化算法4 报文类型5 长度域字段6 请求数据7 状态8 校验字段9 保留字段 二 Netty 实现自定义通信协议Netty 中编解码器分类 三