应用层的网络协议,本质是就是两大块,协议体的定义和网络编程的模型,网络编程模型现在主流就是NIO和AIO但是大多数还是以Netty的优化过的NIO为主,所以实际上很多协议的出现比如rpc,http2它们为了快,大多都是选择如何降低传输的桢的大小,比如http2的分片思想,因为桢小,传输的才更快。但http3.0做了个离谱的操作,用UDP代替TCP来以求更快,稳定性问题通过中间再引入一个中间件也就是所谓的QUIC,思想就是客户端到中间件用UDP,中间件到服务端用UDP或者TCP。反正架构问题,解决不了就中间引入一个中间件,大多都能解决。
高性能NIO通信框架:自定义WebSocket协议设计与实现
在高并发、低延迟的网络通信框架中,应用层协议的设计和实现对系统的整体性能至关重要。本文将介绍一个基于自定义协议的WebSocket替代方案,讨论协议体的设计、消息字段定义、分片机制及并发支持等方面,并结合代码实例展示如何实现一个高效的协议解析和分片重组工具。
一、协议体设计
在我们的自定义WebSocket协议中,协议体的设计需要考虑到高效解析和分片重组。协议体的设计包括多个关键字段,确保消息在传输过程中能够被正确解析和处理。具体字段设计如下:
-
魔法值(Magic)
用于标识协议的唯一性。每个消息包都会包含一个魔法值,接收方通过该魔法值判断消息是否符合当前协议。 -
帧类型(Frame Type)
标识消息帧的类型,如普通消息、心跳(Ping/Pong)等。这个字段帮助接收方识别消息的内容和处理方式。 -
消息体长度(Length)
表示消息体的大小,不包括协议头。这个字段对于解析和缓冲区管理非常重要,接收方可以根据该字段确定需要读取的字节数。 -
消息负载(Payload)
实际的消息数据,通常是经过序列化的字节数组。这个字段包含了消息的核心内容。 -
序列号(Sequence Number)
用于消息分片的排序和拼接。在多片消息的情况下,每个分片都有一个唯一的序列号,接收方依照序列号将分片拼装成完整的消息。 -
是否为最后一个分片(Is Last Fragment)
用于标识当前分片是否为最后一个分片,接收方根据这个标志判断是否完成了所有分片的接收。 -
消息ID(Message ID)
唯一标识一个消息。这个字段用于消息去重和区分不同的消息。 -
客户端ID(Client ID)
用于标识发送消息的客户端。这个字段在多客户端环境中非常重要,可以确保消息不被错误地发送到其他客户端。 -
消息ID长度(Message ID Length)
标识消息ID字段的长度,用于在反序列化时正确读取消息ID。 -
客户端ID长度(Client ID Length)
标识客户端ID字段的长度,用于在反序列化时正确读取客户端ID。
二、工具类实现
为了实现对自定义协议的解析、分片和重组,我们需要一个工具类来进行相关操作。下面是 BinaryFrameParser
类的实现,包含了协议解析、分片解析和拼装多个分片为一个完整消息的方法。
1. 单帧解析:parse()
该方法负责解析一个完整的协议帧,首先检查是否有足够的数据来解析协议头(魔法值、帧类型、消息体长度),然后根据消息体的长度继续解析负载、序列号、分片标识、消息ID和客户端ID等字段。最终将这些字段封装成 CustomFrame
对象。
public static CustomFrame parse(ByteBuffer buffer) {
// 检查是否有足够的数据来解析 magic、frameType、length
if (buffer.remaining() < 2 + 1 + 4 + 2 + 2) {
return null; // 不足够数据,无法解析
}
buffer.mark(); // 标记当前位置
// 解析协议头部:magic + frameType + length
short magic = buffer.getShort();
byte frameType = buffer.get();
int length = buffer.getInt();
// 解析消息内容,计算总数据大小
if (buffer.remaining() < length + 4 + 1 + 2 + 2) {
buffer.reset();
return null; // 不足够数据,无法解析完整消息
}
byte[] payload = new byte[length];
buffer.get(payload);
int sequenceNumber = buffer.getInt();
boolean isLastFragment = buffer.get() == 1;
// 解析 messageId 和 clientId 的长度字段
short messageIdLength = buffer.getShort();
byte[] messageIdBytes = new byte[messageIdLength];
buffer.get(messageIdBytes);
String messageId = new String(messageIdBytes, StandardCharsets.UTF_8);
short clientIdLength = buffer.getShort();
byte[] clientIdBytes = new byte[clientIdLength];
buffer.get(clientIdBytes);
String clientId = new String(clientIdBytes, StandardCharsets.UTF_8);
return new CustomFrame(magic, frameType, length, payload, sequenceNumber, isLastFragment, messageId, clientId, messageIdLength, clientIdLength);
}
2. 分片解析:parseFragments()
该方法用于解析多个分片消息。通过调用 parse()
方法逐个解析每个分片,直到所有分片都被解析完成。
public static List<CustomFrame> parseFragments(ByteBuffer buffer) {
List<CustomFrame> frames = new ArrayList<>();
while (buffer.remaining() > 0) {
CustomFrame frame = parse(buffer);
if (frame != null) {
frames.add(frame);
}
}
return frames;
}
3. 分片重组:reassembleFragments()
该方法将所有分片根据序列号进行排序,确保正确拼接。然后,将所有分片的负载数据按顺序写入 ByteArrayOutputStream
,最终返回完整的消息。
public static byte[] reassembleFragments(List<CustomFrame> frames) {
// 按序列号排序分片,确保正确拼接
frames.sort(Comparator.comparingInt(CustomFrame::getSequenceNumber));
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
for (CustomFrame frame : frames) {
outputStream.write(frame.getPayload(), 0, frame.getPayload().length);
}
return outputStream.toByteArray();
}
三、如何使用协议和工具类
-
客户端发送消息:
客户端根据协议体的设计构建消息,使用CustomFrame
对象进行序列化,并通过NIO发送到服务器端。 -
服务器端接收消息:
服务器端接收到消息后,使用BinaryFrameParser
进行解析。如果消息被分片,调用parseFragments()
方法解析所有分片,最后使用reassembleFragments()
方法将分片拼装成完整消息。