问题陈述
使用Java语言编写Server及Client代码如下:
/**
* Selector学习
*/
@Slf4j
public class Server {
public static void main(String[] args) throws IOException {
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(8080));
// 创建Selector
Selector selector = Selector.open();
ssc.configureBlocking(false);
// 注册事件到selector上
SelectionKey sscKey = ssc.register(selector, 0, null);
// 设置只关注accept事件
sscKey.interestOps(SelectionKey.OP_ACCEPT);
log.debug("sscKey : {}", sscKey);
while(true){
// select方法,没有事件发生时,线程阻塞,有事件,线程才会恢复运行
// select 在事件未处理时,它不会阻塞,事件发生后要么处理,要么取消,不能置之不理
int count = selector.select();
log.info("select count : {}", count);
// 处理事件selectedKeys 内部包含了所有发生的事件
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while(iterator.hasNext()){
SelectionKey key = iterator.next();
log.debug("key : {}", key);
iterator.remove();
// 区分事件类型
if(key.isAcceptable()){
// 是连接事件
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
SocketChannel sc = channel.accept();
sc.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(16);
// 将一个byteBuffer作为附件关联到selectionKey上
SelectionKey scKey = sc.register(selector, 0, buffer);
// Key只关注可读事件
scKey.interestOps(SelectionKey.OP_READ);
log.debug("sc : {}", sc);
log.debug("scKey : {}", scKey);
}else if(key.isReadable()){
// 是可读事件
try {
SocketChannel chanel = (SocketChannel) key.channel(); // 拿到触发事件的channel
ByteBuffer buffer = ByteBuffer.allocate(4);
// 获取selectionKey上关联的附件
// ByteBuffer buffer = (ByteBuffer) key.attachment();
int read = chanel.read(buffer);
if(read == -1){
// 正常断开,read方法的返回值是-1,此时需要将key取消
// 正常断开需要走四次挥手,服务端依旧需要读取客户端发送的断开连接的报文,此时依旧会触发一次读事件.
key.cancel();
}else{
buffer.flip();
ByteBufferUtil.debugRead(buffer);
System.out.println(Charset.defaultCharset().decode(buffer));
// split(buffer);
// if(buffer.position() == buffer.limit()){
// // 此时说明关联的byteBuffer容量不够需要扩容
// ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity() * 2);
// buffer.flip();
// newBuffer.put(buffer);
// key.attach(newBuffer);
// }
}
} catch (IOException e) {
e.printStackTrace();
// 客户端断开了,因此需要将Key取消(从selector 的 keys集合中真正删除key)
key.cancel();
}
}
}
}
}
}
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
public class Client {
public static void main(String[] args) throws IOException {
SocketChannel sc = SocketChannel.open();
sc.connect(new InetSocketAddress(InetAddress.getLocalHost(), 8080));
sc.write(Charset.defaultCharset().encode("中国\n"));
System.out.println("waiting ...");
}
}
上述代码中Server端通过Selector监听Readable可读事件,当Client端向服务端发起连接并发送数据后,服务端接收客户端发送的数据并读取Chanel中的数据并写入buffer。结果如下:
客户端发送数据:sc.write(Charset.defaultCharset().encode("中国\n")); 明显发送数据不符合预期,主要原因为发送中文字符"中国"共占有6个字节,buffer缓冲区大小为4个字节,第一次读取到buffer中的内容为4字节,输出后只有一个"中"随后乱码,第二次读取原因相似。
消息边界的处理
上述问题产生的原因归结于消息边界的处理不正确。使用ByteBuffer接收数据时主要有以下几种情况:(拆包与粘包)
上述问题可以概括为Server端的拆包与粘包。对于网络通信中消息接收的处理主要有以下三种方案:
- 固定消息长度,数据包大小一样,服务器按预定长度读取,缺点是浪费带宽。
- 按分隔符拆分,缺点是效率低。
- TLV 格式,即 Type 类型、Length 长度、Value 数据,类型和长度已知的情况下,就可以方便获取消息大小,分配合适的 buffer,缺点是 buffer 需要提前分配,如果内容过大,则影响 server 吞吐量。【TCP通信主要参考此种方式】。
采用按分隔符拆分处理消息边界,此时需要构建动态的buffer,能够实现扩容操作,当读取数据时buffer已经填充满,但此时仍未达到分隔符,此时需要将byteBuffer扩容并将上次读取的内容填充至新的byteBuffer中。
使用以下命令将byteBuffer和具体的SelectionKey关联起来,随后当key触发读事件时,获取相关联的byteBuffer,并读取chanel中的数据至buffer,若buffer中未包含分隔符,此时需要将关联的byteBuffer取出、扩容、填充已读取数据、将新的byteBuffer关联到SelectionKey上。
测试效果:首次关联的byteBuffer大小为16,客户端传递的消息为"1234567890abcdef3333\n"若byteBuffer接收消息时不会自动扩容处理,则最终接收到的消息为3333,扩容处理后可以得到原始数据,结果如下:
ByteBuffer 大小分配
-
每个 channel 都需要记录可能被切分的消息,因为 ByteBuffer 不能被多个 channel 共同使用,因此需要为每个 channel 维护一个独立的 ByteBuffer
-
ByteBuffer 不能太大,比如一个 ByteBuffer 1Mb 的话,要支持百万连接就要 1Tb 内存,因此需要设计大小可变的 ByteBuffer
-
一种思路是首先分配一个较小的 buffer,例如 4k,如果发现数据不够,再分配 8k 的 buffer,将 4k buffer 内容拷贝至 8k buffer,优点是消息连续容易处理,缺点是数据拷贝耗费性能,参考实现 Java Resizable Array
-
另一种思路是用多个数组组成 buffer,一个数组不够,把多出来的内容写入新的数组,与前面的区别是消息存储不连续解析复杂,优点是避免了拷贝引起的性能损耗
-
ByteBuffer处理write事件
使用write写数据一次不能够写完,此时需要循环写入,如果直接循环直至将数据完全写入,此时相当于BIO阻塞,线程无法处理其他任务。
上述阻塞模式下线程整体工作效率并不高,应当只有接收方缓冲区有空闲,发送方才进行数据的发送。提高线程的工作效率。
-
非阻塞模式下,无法保证把 buffer 中所有数据都写入 channel,因此需要追踪 write 方法的返回值(代表实际写入字节数)
-
用 selector 监听所有 channel 的可写事件,每个 channel 都需要一个 key 来跟踪 buffer,但这样又会导致占用内存过多,就有两阶段策略
-
当消息处理器第一次写入消息时,才将 channel 注册到 selector 上。
-
selector 检查 channel 上的可写事件,如果所有的数据写完了,就取消 channel 的注册
-
如果不取消,会每次可写均会触发 write 事件。
-
整体代码如下:
package com.example.code.c4.serverwrite;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.Charset;
import java.util.Iterator;
/**
* 测试服务端向客户端写数据
*/
public class Server {
public static void main(String[] args) throws IOException {
// 创建serverSocketChannel
ServerSocketChannel ssc = ServerSocketChannel.open();
// 设置非阻塞模式
ssc.configureBlocking(false);
// 绑定端口
ssc.bind(new InetSocketAddress(8080));
Selector selector = Selector.open();
ssc.register(selector, SelectionKey.OP_ACCEPT);
while(true){
selector.select();
// 获取触发的事件
Iterator<SelectionKey> itor = selector.selectedKeys().iterator();
while(itor.hasNext()){
SelectionKey key = itor.next();
itor.remove();
if (key.isAcceptable()) {
SocketChannel sc = ssc.accept();
sc.configureBlocking(false);
SelectionKey scKey = sc.register(selector, SelectionKey.OP_READ);
// 向客户端发送数据
StringBuilder stringBuilder = new StringBuilder();
for (int i = 0; i < 30000000; i++) {
stringBuilder.append("a");
}
ByteBuffer buffer = Charset.defaultCharset().encode(stringBuilder.toString());
// while (buffer.hasRemaining()) {
// int write = sc.write(buffer);
// System.out.println(write);
// }
int write = sc.write(buffer);
System.out.println(write);
if(buffer.hasRemaining()){
scKey.interestOps(scKey.interestOps() | SelectionKey.OP_WRITE);
scKey.attach(buffer);
}
}else if(key.isWritable()){
ByteBuffer bufffer = (ByteBuffer) key.attachment();
SocketChannel sc = (SocketChannel) key.channel();
int write = sc.write(bufffer);
System.out.println(write);
if(!bufffer.hasRemaining()){
key.interestOps(key.interestOps() ^ SelectionKey.OP_WRITE);
key.attach(null);
}
}
}
}
}
}
package com.example.code.c4.serverwrite;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
public class Client {
public static void main(String[] args) throws IOException {
SocketChannel sc = SocketChannel.open();
sc.connect(new InetSocketAddress("localhost", 8080));
// 接收数据
int count = 0;
ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);
while (true){
count += sc.read(buffer);
System.out.println(count);
buffer.clear();
}
}
}