目录
什么是消息边界?
分析
解决方案
方案一:固定消息长度,数据包大小一样,服务器按预定长度读取
方案二:按分隔符动态调整缓冲数组的长度,截取合适区间
方案三:TLV格式将消息划分为类型、长度和值三个部分,以便于处理消息边界和解析
TLV概念
以前有伙伴写过这样的代码,思考注释中两个问题,以 bio 为例,其实 nio 道理是一样的
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket ss=new ServerSocket(9000);
while (true) {
Socket s = ss.accept();
InputStream in = s.getInputStream();
// 这里这么写,有没有问题
byte[] arr = new byte[4];
while(true) {
int read = in.read(arr);
// 这里这么写,有没有问题
if(read == -1) {
break;
}
System.out.println(new String(arr, 0, read));
}
}
}
}
客户端
public class Client { public static void main(String[] args) throws IOException { Socket max = new Socket("localhost", 9000); OutputStream out = max.getOutputStream(); out.write("hello".getBytes()); out.write("world".getBytes()); out.write("你好".getBytes()); max.close(); } }
输出
hell owor ld� �好
其实消息边界和我们文件编程的 “ 半包粘包 ”问题有点类似,都是由于缓冲区的大小范围与实际合理逻辑的数据长度范围不匹配导致的输出内容的不连贯,或者说对于一些中文字符的拆分传递导致的不合理拼凑问题
(例如以上案例,由于我们采用的是UTF-8的编码格式,所以说每个中文字符都占3个字节,”你好“两个字符就占用6个字节,但是由于我的缓冲区大小只有4字节,所以我一次就读取了"你好"字符的2/3,也就是对"好"字进行了一个拆分,这样子输出的结果当然就是乱码)
当使用NIO Selector来处理消息边界时,可以使用固定消息长度的方式。这意味着每个数据包的大小都是固定的,服务器会按照预定的长度读取数据包。而对于客户端来说就需要想办法来处理字段过长或者字段偏短的问题,为此,当字段偏短的时候需要想办法填充无用字段到定长,偏长的时候需要想办法对字段进行语义拆分。以此来避免消息边界问题
在代码上,可以创建一个定长的ByteBuffer来保存接收到的数据,然后使用Selector监听网络事件。当有新的数据可读取时,可以通过SocketChannel将数据读取到ByteBuffer中。
接下来,你可以检查ByteBuffer中是否已经接收到了足够长度的数据来构成一个完整的消息。如果是的话,你可以提取出完整的消息进行处理,然后从ByteBuffer中移除已处理的数据,以便继续接收后续的消息。
public class FixedLengthMessageServer {
private static final int MESSAGE_LENGTH = 10; // 消息长度
public void start(int port) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress(port));
Selector selector = Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
ByteBuffer buffer = ByteBuffer.allocate(MESSAGE_LENGTH);
while (true) {
selector.select();
Set selectedKeys = selector.selectedKeys();
Iterator iterator = selectedKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
SocketChannel clientChannel = serverChannel.accept();
clientChannel.configureBlocking(false);
clientChannel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel clientChannel = (SocketChannel) key.channel();
int bytesRead = clientChannel.read(buffer);
if (bytesRead == -1) {
// 连接关闭
clientChannel.close();
continue;
}
if (buffer.position() == MESSAGE_LENGTH) {
// 提取完成的消息
buffer.flip();
byte[] messageBytes = new byte[MESSAGE_LENGTH];
buffer.get(messageBytes);
String message = new String(messageBytes);
System.out.println("Received message: " + message);
buffer.clear();
}
}
}
}
}
public static void main(String[] args) throws IOException {
FixedLengthMessageServer server = new FixedLengthMessageServer();
server.start(8080);
}
}
优点:
- 简单直观:使用固定消息长度处理方法相对简单,理解和实现起来比较容易。
- 易于解析:由于消息长度固定,解析消息时不需要复杂的逻辑,可以简单地将接收到的数据拆分成固定长度的消息。
- 效率高:固定消息长度可以很好地与底层的网络传输机制配合,减少了读取数据和处理数据的开销。
缺点:
- 固定长度限制:这种方法假设所有的消息都具有相同的固定长度,因此无法处理长度不固定的消息,对于变长消息,可能需要额外的处理逻辑。
- 浪费空间:由于每个消息的长度都是固定的,因此如果某个消息长度小于固定长度,会浪费一部分的空间。
- 不灵活:当消息结构发生变化或需要传输的数据大小不一致时,固定消息长度的方法可能无法适应,并且需要进行修改。
具体实现过程如下:
- 创建一个ByteBuffer来保存接收到的数据。
- 使用Selector监听网络事件。
- 当有新的数据可读取时,通过SocketChannel将数据读取到ByteBuffer中。
- 检查ByteBuffer中是否包含消息分隔符(如换行符)作为消息的边界标识。
- 如果找到了边界标识,就可以截取边界之前的数据作为一个完整的消息进行处理,然后从ByteBuffer中移除已处理的数据,以便继续接收后续的消息。
- 如果没有找到边界标识,说明当前接收到的数据不完整,可以等待下次接收再进行处理。
private static void split(ByteBuffer source) {
source.flip();
for (int i = 0; i < source.limit(); i++) {
// 找到一条完整消息
if (source.get(i) == '\n') {
int length = i + 1 - source.position();
// 把这条完整消息存入新的 ByteBuffer
ByteBuffer target = ByteBuffer.allocate(length);
// 从 source 读,向 target 写
for (int j = 0; j < length; j++) {
target.put(source.get());
}
debugAll(target);
}
}
source.compact(); // 0123456789abcdef position 16 limit 16
}
public static void main(String[] args) throws IOException {
// 1. 创建 selector, 管理多个 channel
Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
// 2. 建立 selector 和 channel 的联系(注册)
// SelectionKey 就是将来事件发生后,通过它可以知道事件和哪个channel的事件
SelectionKey sscKey = ssc.register(selector, 0, null);
// key 只关注 accept 事件
sscKey.interestOps(SelectionKey.OP_ACCEPT);
log.debug("sscKey:{}", sscKey);
ssc.bind(new InetSocketAddress(8080));
while (true) {
// 3. select 方法, 没有事件发生,线程阻塞,有事件,线程才会恢复运行
// select 在事件未处理时,它不会阻塞, 事件发生后要么处理,要么取消,不能置之不理
selector.select();
// 4. 处理事件, selectedKeys 内部包含了所有发生的事件
Iterator iter = selector.selectedKeys().iterator(); // accept, read
while (iter.hasNext()) {
SelectionKey key = iter.next();
// 处理key 时,要从 selectedKeys 集合中删除,否则下次处理就会有问题
iter.remove();
log.debug("key: {}", key);
// 5. 区分事件类型
if (key.isAcceptable()) { // 如果是 accept
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
SocketChannel sc = channel.accept();
sc.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(16); // attachment
// 将一个 byteBuffer 作为附件关联到 selectionKey 上
SelectionKey scKey = sc.register(selector, 0, buffer);
scKey.interestOps(SelectionKey.OP_READ);
log.debug("{}", sc);
log.debug("scKey:{}", scKey);
} else if (key.isReadable()) { // 如果是 read
try {
SocketChannel channel = (SocketChannel) key.channel(); // 拿到触发事件的channel
// 获取 selectionKey 上关联的附件
ByteBuffer buffer = (ByteBuffer) key.attachment();
int read = channel.read(buffer); // 如果是正常断开,read 的方法的返回值是 -1
if(read == -1) {
key.cancel();
} else {
split(buffer);
// 需要扩容
if (buffer.position() == buffer.limit()) {
ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity() * 2);
buffer.flip();
newBuffer.put(buffer); // 0123456789abcdef3333\n
key.attach(newBuffer);
}
}
} catch (IOException e) {
e.printStackTrace();
key.cancel(); // 因为客户端断开了,因此需要将 key 取消(从 selector 的 keys 集合中真正删除 key)
}
}
}
}
}
上述代码在读取数据时,会使用指定的分隔符将接收到的数据拆分成消息数组。然后遍历消息数组,处理每个完整的消息,并将最后一个不完整的消息保留在缓冲区中等待下次接收。
如果上述代码看的比较吃力的可以参考一下下面类似的办法处理粘包半包问题的案例,思路差不多的
优点:
- 灵活性高:该方法可以适应各种长度不固定的消息,因为它不依赖于固定长度,而是根据消息分隔符来确定消息的边界。
- 节约空间:相比固定长度的方法,该方法可以避免浪费空间,因为它只截取合适的区间来构成完整的消息,而不需要预先分配固定大小的缓冲区。
- 容错性强:即使在接收到数据不完整的情况下,该方法也能保持消息的完整性,将不完整的消息保留在缓冲区中等待下次接收,可以处理消息拆分的情况。
缺点:
- 分隔符处理:使用分隔符作为消息边界标识需要额外的逻辑来处理,例如处理分隔符可能出现在消息内容中的情况,或者在很长的消息中出现分隔符被拆分的情况。
- 性能考虑:使用分隔符来确定消息边界需要额外的字符串处理,可能对性能产生一定的影响。
- 潜在的数据丢失:如果消息分隔符未及时到达或错过,可能导致消息丢失或拆分不正确。
TLV(Tag-Length-Value)协议规范是一种常见的数据编码格式,广泛应用于计算机和通信领域。它通常用于在数据交换中传输各种类型的信息。
Tag(标签):用于标识数据的类型或含义。每个标签都有一个唯一的数值或代码。例如,可以使用1表示姓名,2表示年龄,3表示地址等等。
Length(长度):用于表示数据的长度。它指示了Value字段中的实际数据占用的字节数。长度可以是固定的,也可以是可变的,取决于具体的协议。
Value(值):包含具体的数据。它可以是任何格式的数据,如文本、数字、二进制数据等等。Value的长度由Length字段指定。
通过使用TLV格式,发送方和接收方可以正确解析和处理数据,因为它们可以依赖Tag来识别不同类型的信息,并使用Length字段来提取Value的正确部分。
这种编码格式的好处是它的灵活性和可扩展性。通过定义新的标签,可以轻松地扩展协议,以适应不同的需求。同时,TLV格式也相对简单,易于实现和解析
方案三具体实现过程如下:
- 创建一个ByteBuffer来保存接收到的数据。
- 使用Selector监听网络事件。
- 当有新的数据可读取时,通过SocketChannel将数据读取到ByteBuffer中。
- 检查ByteBuffer中是否已经接收到了足够长度的数据以解析出完整的TLV消息。
- 如果是的话,根据TLV协议规范,解析出消息的类型、长度和值。
- 处理完整的TLV消息,然后从ByteBuffer中移除已处理的数据,以便继续接收后续的消息。
- 如果没有接收到足够长度的数据,说明消息不完整,等待下次接收再进行处理。
public class TLVMessageServer {
public void start(int port) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress(port));
Selector selector = Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
ByteBuffer buffer = ByteBuffer.allocate(1024); // 假设初始长度为1024
while (true) {
selector.select();
Set selectedKeys = selector.selectedKeys();
Iterator iterator = selectedKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
SocketChannel clientChannel = serverChannel.accept();
clientChannel.configureBlocking(false);
clientChannel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel clientChannel = (SocketChannel) key.channel();
int bytesRead = clientChannel.read(buffer);
if (bytesRead == -1) {
// 连接关闭
clientChannel.close();
continue;
}
buffer.flip();
while (buffer.remaining() >= 4) {
int type = buffer.getInt();
int length = buffer.getInt();
if (buffer.remaining() >= length) {
byte[] valueBytes = new byte[length];
buffer.get(valueBytes);
String value = new String(valueBytes);
System.out.println("Received TLV message: Type=" + type + ", Length=" + length + ", Value=" + value);
} else {
break; // 不完整的消息,等待下次接收
}
}
buffer.compact();
}
}
}
}
public static void main(String[] args) throws IOException {
TLVMessageServer server = new TLVMessageServer();
server.start(8080);
}
}
在读取数据时,会检查ByteBuffer中是否已经接收到了足够长度的数据以解析出完整的TLV消息。如果是的话,解析消息的类型、长度和值,并进行相应的处理。如果接收到的数据不足以构成完整的TLV消息,则等待下次接收再进行处理。
优点:
- 灵活性高:TLV方案可以适应各种长度不固定的消息,通过解析消息的类型和长度,能够准确地划分消息边界。
- 协议解析简单:使用TLV方案,在接收到足够长度的数据后,解析消息的类型、长度和值相对简单,遵循明确定义的规则。
- 容错性强:即使在接收到数据不完整的情况下,该方法也能够保持消息的完整性,可以等待下次接收到完整的消息再进行处理。
缺点:
- 性能考虑:TLV方案的解析过程可能相对复杂,需要根据消息长度解析出相应长度的值,可能对性能产生一定的影响。
- 内存占用较大:由于需要将完整的TLV消息保存在内存中,所以在处理大量消息时,可能会占用较多的内存空间。
- 占用带宽:相比于一些仅使用分隔符的方案,使用TLV方案可能会占用更多的带宽,因为需要传输消息的类型和长度的信息。