这是我参与11月更文挑战的第15天,活动详情查看:2021最后一次更文挑战
上一篇博客Netty编程(六)—— nio.Selector之基本使用 - 掘金 (juejin.cn)介绍了selector以及如何处理连接事件,这篇博客介绍如何处理selector的读写事件。
读事件
在Accept事件中,若有客户端与服务器端建立了连接,需要将其对应的SocketChannel设置为非阻塞,并注册到选择其中,对其添加关注read事件,当客户端发来消息后服务器channel就可以使用read()方法进行读取操作。
read()方法可以将通道传输的数据读入到buffer中,它会返回一个整型数,表示本次读取的字节数,当返回值是-1时代表本次读操作结束,读操作结束后就需要使用key.cancel()取消这个事件,否则就会一直循环下去,具体的代码如下:
else if(key.isReadable()){
try{
SocketChannel channel = ((SocketChannel) key.channel());//拿到channel
ByteBuffer buffer = ByteBuffer.allocate(100);
int size = channel.read(buffer);//如果是正常断开,size = -1
if(size == -1)
{
key.cancel();
}
else
{
buffer.flip();
ByteBufferUtil.debugAll(buffer);//缓存可视化
}
}catch (IOException e)
{
e.printStackTrace();
key.cancel();
}
}
需要注意的是两处的key.cancel(),下面解释一下两处的作用:
- 第一次是用在正常读取结束时,结束该读事件,否则selector会认为你没有处理这个读事件,不会阻塞在
selector.select()上而陷入死循环。 - 第二次是用在客户端非正常关闭情况下,代码会进入catch块,在这里结束该读事件,防止死循环。
删除事件
Netty编程(六)—— nio.Selector之基本使用 - 掘金 (juejin.cn)最后提到代码中每对事件进行一次迭代,都会执行iter.remove()删除这个事件,现在就来解释以下如果不删除为什么会出现问题。这里我放上一张包含注释的代码截图加以说明。
在这段代码里,可以看作有两个集合:
- 代码第20行建立的selector会维护一个集合,保存的是注册在该selector上的channel
- 代码29行
selector.selectedKeys()会维护一个集合,这个集合是事件集合
一开始selector集合只有一个ssc(新建的ServerSocketChannel),它关注accept事件,而事件集合为空:
之后有一个客户端A连接了服务端,此时事件集合中会有一个accept事件,同时连接成功后selector下的channel集合会增加一个sc(获得的SocketChannel),它关注read事件。但是需要注意的是:事件集合不会自动删除已处理完毕的事件,所以即使连接事件已经处理完毕了,还依然在事件集合中。
之后,连接上的客户端A向服务端发送消息,此时selector发现了有新事件(读),就在事件集合中加入了sc通道上的读事件:
因为有了新事件(读事件),所以不会再阻塞在第28行的selector.select()上了,下面就会遍历事件集合并处理每一个事件,==因为事件集合不会自动删除已完成的事件,所以一开始连接事件还存在==,因此会进入33行的连接事件处理,但此时并没有客户端要连接,因此会发生错误。
所以说没处理完一个事件,就需要将它从事件集合中删除,所以第32行的iter.remove()是必不可少的。
消息边界问题
实际传输的问题
传输的文本不一定每次都恰好与缓冲区的大小匹配,在实际情况下,有可能出现下面几种情况:
- 文本大于缓冲区大小:此时需要将缓冲区进行扩容
- 发生粘包现象:比如说客户端发了三次内容:"AB"、"CD"、"EF",但是服务器接收到了"ABCDEF"
- 发生半包现象:同样是上面情况,但是服务器接收到了"ABC"、"DEF"
解决这个问题一般有三种方法:
- 固定消息长度:每次发送的数据包大小一样,服务器按预定长度读取,当发送的数据较少时,需要将数据进行填充,直到长度与消息规定长度一致。但是这样浪费带宽
- 另一种思路是按分隔符拆分,缺点是效率低,需要一个一个字符地去匹配分隔符(例如遇到'\n'说明一条消息结束)
- TLV 格式,即 Type 类型、Length 长度、Value 数据
NIO的解决方法
这里使用上面说到的第二种方法,但是遇到消息过长,导致一次buffer装不下时就需要使用另一个更大的buffer,这个更大的buffer首先装入之前buffer的内容,接着装入新传来的消息:
Channel的register方法还有第三个参数:附件,可以向其中放入一个Object类型的对象,该对象会与登记的Channel以及其对应的SelectionKey绑定,可以使用可通过SelectionKey的attachment()方法获得附件,延长bytebuffer的生命周期到和key一样长。
ByteBuffer buffer = ByteBuffer.allocate(16);
// 添加通道对应的Buffer附件
socketChannel.register(selector, SelectionKey.OP_READ, buffer);
当Channel中的数据大于缓冲区时,需要对缓冲区进行扩容操作。此代码中的扩容的判定方法:Channel调用compact方法后,他的position与limit相等,说明缓冲区中的数据并未被读取(容量太小),此时创建新的缓冲区,其大小扩大为两倍。同时还要将旧缓冲区中的数据拷贝到新的缓冲区中,同时调用SelectionKey的attach方法将新的缓冲区作为新的附件放入SelectionKey中
// 如果缓冲区太小,就进行扩容
if (buffer.position() == buffer.limit()) {
ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity()*2);
buffer.flip();
// 将旧buffer中的内容放入新的buffer中
newBuffer.put(buffer);
// 将新buffer作为附件放到key中
key.attach(newBuffer);
}
下面给出完整的加入扩容机制的服务端代码,每次客户端发来的消息以'\n'为结尾,如果客户端每次发来的消息小于初始的buffer大小,那么直接输出即可,如果大于初始的buffer大小,就需要对客户端chennel绑定的buffer进行扩容:
public class SelectorServer {
private static void split(ByteBuffer buffer) {
buffer.flip();
for(int i = 0; i < buffer.limit(); i++) {
// 遍历寻找分隔符
// get(i)不会移动position
if (buffer.get(i) == '\n') {
// 缓冲区长度
int length = i+1-buffer.position();
ByteBuffer target = ByteBuffer.allocate(length);
// 将前面的内容写入target缓冲区
for(int j = 0; j < length; j++) {
// 将buffer中的数据写入target中
target.put(buffer.get());
}
// 打印结果
ByteBufferUtil.debugAll(target);
}
}
// 切换为写模式,但是缓冲区可能未读完,这里需要使用compact
buffer.compact();
}
public static void main(String[] args) throws IOException
{
Selector selector = Selector.open();//建立selector
try (ServerSocketChannel ssc = ServerSocketChannel.open()) {
ssc.configureBlocking(false);//将通道设置成非阻塞模式
SelectionKey ssckey = ssc.register(selector,0,null);//将通道注册到selector上
ssckey.interestOps(SelectionKey.OP_ACCEPT );//设置关注连接事件
ssc.bind(new InetSocketAddress(8080));
while(true)
{
int channelnum = selector.select();//阻塞,等待事件,有事件发生则停止阻塞
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();//获得发生的事件
while(iter.hasNext()){//遍历未处理的事件
SelectionKey key = iter.next();//拿到该事件的SelectionKey
iter.remove();
if (key.isAcceptable()) {//连接事件
ServerSocketChannel channel = ((ServerSocketChannel) key.channel());//获得该事件的channel
SocketChannel sc = channel.accept();//接受客户端的连接
sc.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(16);
SelectionKey sckey = sc.register(selector, 0, buffer);//将新的通道注册在selector上
sckey.interestOps(SelectionKey.OP_READ);
}
else if(key.isReadable()){
try{
SocketChannel channel = ((SocketChannel) key.channel());//拿到channel
//通过key获得附件
ByteBuffer buffer = ((ByteBuffer) key.attachment());
int size = channel.read(buffer);//如果是正常断开,size = -1
if(size == -1)
{
key.cancel();
channel.close();
ByteBufferUtil.debugAll(((ByteBuffer) key.attachment()));//缓存可视化
}
else
{
split(buffer);
if(buffer.position()==buffer.limit())//缓冲区满了,需要扩容了
{
ByteBuffer newbuffer = ByteBuffer.allocate(buffer.capacity()*2);//建立一个新的缓冲区
buffer.flip();//切换模式
newbuffer.put(buffer);//把原先buffer的内容放到新的buffer中
key.attach(newbuffer);//将这个channel的附件换成新的更大的缓冲区
}
}
}catch (IOException e)
{
e.printStackTrace();
key.cancel();
}
}
else if(key.isWritable()){
}
else if(key.isConnectable()){
}
}
}
}
}
}
写事件
如果服务端向客户端写数据的话,首先存在buffer中,然后通过SocketChannel进行传输,不过当传输的数据量非常大时就会出现问题,为了分析这种方式的问题,具体来看下面的代码:
这是服务端的代码,他接受到客户端的连接后,通过通道传输大量的数据,当然,有可能一次传输不完,所以在一个while循环中,我们输出每次传输出去的字节数
这是客户端的代码,他开辟了一个缓冲区,用来接收服务端传来的数据,同样的,他也不可能一次就接收完毕,因此在每次接收后输出一下接收的字节数:
下面是执行结果,左侧是完整的服务端的结果,右侧是部分的客户端的结果,可以发现,服务端有大量的0出现,即服务端程序在这次while循环中属于空转,探究其中原因,主要是因为服务端一次传输过大的数据(例如第一次4587485),而客户端接收能力有限,在客户端接收之前服务端是不能继续发送的,因此服务端会陷入空转现象。
为了解决这个空转问题,我们可以让多路复用器selector去控制channel,如果可以传输数据时(即上一次发送的数据客户端接收完毕了),再让channel继续发送一批,否则让他处理其他事件或者直接阻塞住。
通过之前的分析 ,我们可以设计这样的思路,先在接收到客户端请求后发送一次数据,如果没发完,就给这个通道注册写事件,让channel在写事件的代码块中继续传输余下的数据,具体步骤如下:
- 向客户端发送大量数据
- 返回值代表实际写入的字节数
- 判断是否有剩余内容
- 关注可写事件
- 把未写完的数据挂到sckey上
- 清理操作
服务端需要修改的代码有两处,如下图所示
第一处是将while换成了if,如果第一次没有传输玩buffer的内容,就需要给这个channel注册写事件,同时需要将存有余下数据的buffer给他当作附件,这样做延长了该buffer的生命周期,使channel在写代码块中还能够使用这个buffer。
第二处是在写模块中增加写buffer余下数据的代码,这里不需要while(如果用while就和第一个版本一样了),因为是写事件,所以只要selector检测这个channel能写了,就继续向客户端输出数据。值得注意的是,每次写完都需要调用buffer.hasRemaining()来检测是否写完了,写完了就要清除关联的buffer,并且取消写事件了。
下面这幅图是服务端的结果,可以看到没有输出0,说明不再有空转发生了。
服务端读写功能完整代码
给出服务端完整的代码如下:
public class SelectorServer {
private static void split(ByteBuffer buffer) {
buffer.flip();
for(int i = 0; i < buffer.limit(); i++) {
// 遍历寻找分隔符
// get(i)不会移动position
if (buffer.get(i) == '\n') {
// 缓冲区长度
int length = i+1-buffer.position();
ByteBuffer target = ByteBuffer.allocate(length);
// 将前面的内容写入target缓冲区
for(int j = 0; j < length; j++) {
// 将buffer中的数据写入target中
target.put(buffer.get());
}
// 打印结果
ByteBufferUtil.debugAll(target);
}
}
// 切换为写模式,但是缓冲区可能未读完,这里需要使用compact
buffer.compact();
}
public static void main(String[] args) throws IOException
{
Selector selector = Selector.open();//建立selector
try (ServerSocketChannel ssc = ServerSocketChannel.open()) {
ssc.configureBlocking(false);//将通道设置成非阻塞模式
SelectionKey ssckey = ssc.register(selector,0,null);//将通道注册到selector上
ssckey.interestOps(SelectionKey.OP_ACCEPT );//设置关注连接事件
ssc.bind(new InetSocketAddress(8080));
while(true)
{
int channelnum = selector.select();//阻塞,等待事件,有事件发生则停止阻塞
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();//获得发生的事件
while(iter.hasNext()){//遍历未处理的事件
SelectionKey key = iter.next();//拿到该事件的SelectionKey
iter.remove();
if (key.isAcceptable()) {//连接事件
ServerSocketChannel channel = ((ServerSocketChannel) key.channel());//获得该事件的channel
SocketChannel sc = channel.accept();//接受客户端的连接
sc.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(16);
SelectionKey sckey = sc.register(selector, 0, buffer);//将新的通道注册在selector上
sckey.interestOps(SelectionKey.OP_READ);
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 9999999; i++) {
sb.append('a');
}
ByteBuffer bb = Charset.defaultCharset().encode(sb.toString());
if(bb.hasRemaining()){
sc.register(selector,SelectionKey.OP_WRITE,bb);
}
}
else if(key.isReadable()){
try{
SocketChannel channel = ((SocketChannel) key.channel());//拿到channel
//通过key获得附件
ByteBuffer buffer = ((ByteBuffer) key.attachment());
int size = channel.read(buffer);//如果是正常断开,size = -1
if(size == -1)
{
key.cancel();
channel.close();
ByteBufferUtil.debugAll(((ByteBuffer) key.attachment()));//缓存可视化
}
else
{
split(buffer);
if(buffer.position()==buffer.limit())//缓冲区满了,需要扩容了
{
ByteBuffer newbuffer = ByteBuffer.allocate(buffer.capacity()*2);//建立一个新的缓冲区
buffer.flip();//切换模式
newbuffer.put(buffer);//把原先buffer的内容放到新的buffer中
key.attach(newbuffer);//将这个channel的附件换成新的更大的缓冲区
}
}
}catch (IOException e)
{
e.printStackTrace();
key.cancel();
}
}
else if(key.isWritable()){
SocketChannel writechannel = (SocketChannel) key.channel();
//获得buffer,这个buffer包含了剩余没有传输的数据
ByteBuffer buffer = ((ByteBuffer) key.attachment());
int writesize = writechannel.write(buffer);
System.out.println(writesize);
if(!buffer.hasRemaining())
{
//清楚关联的buffer
key.attach(null);
//取消事件
key.interestOps(0);
}
}
else if(key.isConnectable()){
}
}
}
}
}
}