开发工程中,有一个常见的需求:服务端程序和多个客户端程序通过 TCP 协议进行通信,通信双方需通信的消息种类众多,并且客户端的数量可能有数万个。为此,双方需要约定尽可能丰富、灵活的数据帧「数据包」协议,方便后续业务功能的设计。
本文设计了一种通信协议,为压缩数据量,该协议的数据帧以二进制方式进行传输并识别,即其基本单位为字节,必要时将部分字节流手动转化为可读文本。通过设定功能位来实现丰富的通信消息类型,并且采用注册的方式,可方便扩展新的业务消息类型,可灵活地增删通信消息对象。采用 Netty 框架保证高并发场景下程序的性能。
系统整体设计框图如下:
1. 通信数据帧协议的设计
1.1 数据帧主帧的帧格式
首先给出通用的数据帧格式如下,一个数据帧主帧由:帧识别位、帧功能位、设备号、数据长度、数据体等 5 部分组成。「其实最通用的数据帧只有帧识别位,根据帧识别位确定帧类型,从而确定其余四个部分,本文中帧识别位固定,帧格式即固定了」
- 帧识别位:确定数据帧的开始,亦确定本帧的帧类型。
- 帧功能位:确定该帧所传送的消息类型,特定的帧功能位对应特定的数据体。
- 设备号:设备的识别号,服务端据此识别不同的客户端。
- 数据长度:数据体所占用的字节数。
- 数据体:根据帧功能位,所确定的需传输的具体的消息。
1.2 数据帧子帧的帧格式
数据帧除数据体以外的部分称为帧头,考虑这样一种需求,如果某帧所要传输的数据体部分内容很少,导致一个帧的大部分容量均被帧头占据,导致有效数据的占比很小,这就产生了巨大的浪费,举例如下:
- 如一个开锁帧,只需传输一个开锁信号即可,消息的接收方、消息类型均体现在了帧头中,数据部分只需要 0 个或 1 个字节即可。
- 客户端需要向服务器发送自己的当前状态信息,该状态信息可能也只需要 1 个字节左右。
由于如上实际的需求,如果增大了每一帧的有效数据的占比,整个通信链路的数据量会明显减少,IO 负担也会因此减轻,所以据此继续对帧协议进行设计。
如上图,对数据帧主帧中的「数据体」部分进行进一步拆分,数据帧主帧的数据体部分由子帧组成,子帧由:子帧功能位、数据长度、数据体等 3 部分组成。
- 子帧功能位:确定该子帧所传送的消息类型,总而言之,主帧、子帧功能位共同确定了该子帧的消息类型。
- 数据长度:数据体所占用的字节数。
- 数据体:根据子帧功能位,所确定的需传输的具体的消息。
1.3 数据帧的帧格式总览
完整的帧格式如下图所示,数据帧主帧的数据体部分完全由子帧组成,通信双方通信时,可以往一个主帧中添加多个子帧,从而可以极大提高链路的使用效率。
2 数据帧处理模块的实现
数据帧已进行了如上精心设计,将设计的数据帧通过程序实现并投入实际使用才是最终目的。
2.1 数据帧处理的基本方法
以服务端的工作为例来进行说明。服务端程序监听指定端口,客户端通过 TCP 协议向服务器发送二进制数据消息,服务端接收到二进制数据并进行处理,此处采用责任链模式,Netty 框架内建了方便的基于责任链模式的消息处理方法:
- 第一个处理器将捕获的数据截取为一个一个协议约定的数据帧并送入下层处理器,如果捕获的二进制数据未符合协议约定的格式,则可以直接丢弃。「此处未考虑半包、粘包等场景」
- 第二个处理器捕获到约定的数据帧,则着手对不同类型数据帧进行解析,解析为不同类型的 Java 消息对象,并将反序列化成功并验证成功的 Java 对象送入下层处理器。如果上述过程失败,可以认为客户端设计不合理,导致出现无效消息,直接丢弃该对象,也可以继续通知服务端或客户端该异常情况。
- 第三个处理器捕获到正确的 Java 消息对象,则可以直接送入上层 Java 模块进行处理,此处可根据不同的对象类型送入不同的上层处理模块,或者在此处进行其他的工作「比如消息日志记录工作等」。
2.2 基本 Java 消息对象的设计
Java 消息对象的设计主要由两部分组成:
- 特定数据帧对应的特定 Java 消息对象。
- 特定 Java 消息对象对应的特定的该消息对象编解码器。
以下是基本 Java 消息对象:
public abstract class BaseMsg implements Cloneable {
private final BaseMsgCodec msgCodec;
private int groupId;
private int deviceId;
private int resendTimes = 0;
protected BaseMsg(BaseMsgCodec msgCodec, int groupId, int deviceId) {
this.msgCodec = msgCodec;
this.groupId = groupId;
this.deviceId = deviceId;
}
/**
* 获取该消息对象的细节描述
*
* @return 该消息对象的细节描述
*/
public String msgDetailToString() {
return msgCodec.getDetail() +
"[majorMsgId=" + Integer.toHexString(msgCodec.getMajorMsgId()).toUpperCase() +
", subMsgId=" + Integer.toHexString(msgCodec.getSubMsgId()).toUpperCase() +
", groupId=" + groupId +
", deviceId=" + deviceId + ']';
}
/**
* 重发该消息对象的记录信息更新
*/
public void doResend() {
resendTimes++;
}
}
由上述代码可知,每个消息对象均包含该对象对应编解码器的引用,方便获取该消息对象的扩展信息,或者方便将该消息对象重新序列化为数据帧。该类包含上节数据帧主帧及子帧的所有公共信息,仅仅未包含子帧中的数据体信息,该需求由基本 Java 消息对象的子类实现。
该类由 abstract
修饰,是抽象类,无法直接实例化,具体的工作由该类的子类完成,即由具体的真正业务相关的 Java 消息对象完成。
以下为 Java 消息对象的基本编解码器:
/**
* 单个消息对象「帧」的编解码器
*/
public abstract class BaseMsgCodec implements SubFramecoder, SubFramedecoder {
private final int majorMsgId;
private final int subMsgId;
private final String detail;
protected BaseMsgCodec(int majorMsgId, int subMsgId, String detail) {
this.majorMsgId = majorMsgId;
this.subMsgId = subMsgId;
this.detail = detail;
}
public String getDetail() {
return detail;
}
public int getMajorMsgId() {
return majorMsgId;
}
public int getSubMsgId() {
return subMsgId;
}
}
由上述代码可知,特定 Java 消息对象的编解码器由数据帧的主帧、子帧功能位共同决定,这样确保了消息编解码器的规范,避免消息过多时的混乱。
Java 编解码器实现了如下两个接口,表明编解码器可将 Java 消息对象编码为数据帧,或将数据帧解码为指定的 Java 消息对象:
public interface SubFramecoder {
/**
* 将 Java 消息对象编码为数据帧
*
* @param msg 消息对象
* @param buffer TCP 数据帧的容器
* @return 生成的 TCP 数据帧的 ByteBuf
*/
ByteBuf code(BaseMsg msg, ByteBuf buffer);
}
public interface SubFramedecoder {
/**
* 将数据帧解码为指定的 Java 消息对象
*
* @param groupId 设备组 ID
* @param deviceId 设备 ID
* @param data 帧数据
* @return 特定的 Java 消息对象
*/
BaseMsg decode(int groupId, int deviceId, byte[] data);
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。