深入浅出指南:Netty开发【NIO网络编程
关于我:我是山茶君nlefer,一个专注于技术的菜鸟。你懂的越多,就懂得不懂的越多。
1.NIO网络编程
在java基础中IO网络编程是使用Socket 和ServerSocket 套接字来完成C/S开发,在NIO中我们使用的是SocketChannel和ServerSocketChannel来进行网络编程的开发
SocketChannel和ServerSocketChannel分别是客户端创建和服务端创建套接字,均支持阻塞与非阻塞模式。
1.1.阻塞模式:
阻塞的表现其实就是线程暂停了,暂停期间不会占用 cpu,但线程相当于闲置单线程下,阻塞方法之间相互影响,几乎不能正常工作,需要多线程支持。
简要说明:
- ServerSocketChannel.accept 会在没有连接建立时让线程暂停
- SocketChannel.read 会在没有数据可读时让线程暂停
优缺点:
- 开发上手简单,适合于低并发场景
- 性能较低,不适用于高并发场景,资源占用较大
1.2.非阻塞模式:
非阻塞的表现其实就是线程还会继续执行,在进行写数据的时候,线程知识等待数据写入channel即可,不需要等channel通过网络将数据发送出去
简要说明:
- 在 ServerSocketChannel.accept 在没有连接建立时,会返回 null,继续运行
- SocketChannel.read 在没有数据可读时,会返回 0,但线程不必阻塞,可以去执行其它 SocketChannel 的 read 或是去执行 ServerSocketChannel.accept
优缺点:
- 性能较好,适用于高并发场景
- 开发有一定的难度
1.3.代码
1.3.0.ScoketChannel和ServerSocketChannel基本使用步骤
1.3.0.1.基本操作步骤
服务端:
1.缓冲区创建---->2.服务器创建---->3.绑定对应端口---->4.获取链接集合---->5.等到客户端链接---->6.创建循环监控客户端操作----> 7.列表中存储客户端连接channel---->8.读取列表中的cahnnel 客户端链接,按照逻辑执行各个客户端的指令---->9.接收客户端发送的数据---->10.数据写入到缓冲区---->11.数据切换模式,切换为写模式---->12.展示结果---->13.写模式切换
public static void main(String[] args) { try { // 1.缓冲区创建 ByteBuffer byteBuffer = ByteBuffer.allocate(16); // 2.服务器创建 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); // 3.绑定对应端口 serverSocketChannel.bind(new InetSocketAddress(8099)); // 4.获取链接集合 List listChannel = new ArrayList(); // 6.创建循环监控客户端操作, while(true){ // 5.等到客户端链接 System.out.println("等待客户端的链接啊.............."); SocketChannel socket = serverSocketChannel.accept(); System.out.println("客户端链接情况........{}"+socket); // 7.列表中存储客户端连接channel listChannel.add(socket); // 8.读取列表中的cahnnel 客户端链接,按照逻辑执行各个客户端的指令 for (SocketChannel socketChannel:listChannel) { // 9.接收客户端发送的数据 System.out.println("客户端数据读取前的数据.......:"+socketChannel); // 10.数据写入到缓冲区 socketChannel.read(byteBuffer); // 11.数据切换模式,切换为写模式 byteBuffer.flip(); // 12.展示结果 debugAll(byteBuffer); // 13.写模式切换 byteBuffer.clear(); System.out.println("服务端处理完成客户端的数据"); } } } catch (IOException e) { e.printStackTrace(); } }
客户端
1.客户端创建---->2.绑定接口---->3.发送消息
public static void main(String[] args) { try { SocketChannel socketChannel = SocketChannel.open(); socketChannel.connect(new InetSocketAddress(8099)); System.out.println("waitting"); } catch (IOException e) { e.printStackTrace(); } } 注意客户端需要开启debug模式,否则会出现立马就结束的情况 消息发送使用方法socketChannel.write(Charset.defaultCharset().encode("hello"));
tips1:开启多个客户端操作(2021以上版本idea)
1.3.1.核心方法
方法 | 方法说明 | 备注 |
---|---|---|
open() | 创建服务器或客户端方法 | 1 |
bind() | 绑定端口,通常与new InetSocketAddress()共同使用 | 1 |
accept() | 监听连接事件 | 1 |
configureBlocking() | 阻塞模式设置开关 | 2 |
read() | 通过read进行数据读取,既读取内核缓冲区数据数据到本地Buffer中 | 2 |
write() | 通过read进行数据读取,既写入数据到channel中,既发送数据 | 2 |
在0中的服务端处理中,serverSocketChannel.accept();是阻塞的,当第一个客户端只建立连接,但是没有发送消息时,会阻塞第二个客户端的连接操作,及排队等待第一个连接并发送消息完成。
多客户端开启
阻塞模式下只有一个客户端连接
客户端发送消息后第二个客户端开始处理,建立了连接
为了解决同一服务对应多个客户端造成阻塞现象,故设置模式时将其设置为非阻塞模式。既增加configureBlocking()方法,设置为false,且增加判断,判断scoket连接是否是已经建立连接,如果没有则是null,线程会继续处理数据读取;针对数据读取,需要判断是否能继续读到,读不到就处理其他的事件,因此需要判断数据读取,无数据时会返回-1,增加判断即可int readlength = socketChannel.read(byteBuffer); 15-16条为新增内容
public static void main(String[] args) { try { // 1.缓冲区创建 ByteBuffer byteBuffer = ByteBuffer.allocate(16); // 2.服务器创建 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); // 3.绑定对应端口 serverSocketChannel.bind(new InetSocketAddress(8099)); // 4.获取链接集合 List listChannel = new ArrayList(); // 6.创建循环监控客户端操作, while(true){ // 5.等到客户端链接 System.out.println("等待客户端的链接啊.............."); SocketChannel socket = serverSocketChannel.accept(); System.out.println("客户端链接情况........{}"+socket); if (socket != null){ // 14.设置阻塞的模式 socket.configureBlocking(false); // 7.列表中存储客户端连接channel listChannel.add(socket); } // // 8.读取列表中的cahnnel 客户端链接,按照逻辑执行各个客户端的指令 for (SocketChannel socketChannel:listChannel) { // 9.接收客户端发送的数据 System.out.println("客户端数据读取前的数据.......:"+socketChannel); // 10.数据写入到缓冲区 // socketChannel.read(byteBuffer); // 15.改判断内容 int readlength = socketChannel.read(byteBuffer); // 16.新增加判断的逻辑 if (readlength > 0){ // 11.数据切换模式,切换为写模式 byteBuffer.flip(); // 12.展示结果 debugAll(byteBuffer); // 13.写模式切换 byteBuffer.clear(); System.out.println("服务端处理完成客户端的数据"); } // } } } catch (IOException e) { e.printStackTrace(); } }
开启多个客户端
服务端均已连接,无阻塞情况出现
1.3.2.常见事件及问题处理
整个NIO网络编程,就是构建在非阻塞IO和Selector多路复用器之上的。Selector是Java NIO中能够检测1到多个NIO通道并能够知晓通道是否为注入读写事件做好准备的组件,通过它,一个单独的线程就可以管理多个Channel,从而管理多个网络连接。
事件 | 事件内容 |
---|---|
accept | 有请求连接时触发 |
connect | 客户端,建立连接后触发 |
read | 可读事件 |
write | 可写时间 |
绑定的事件类型
事件 | 事件内容 |
---|---|
OP_ACCEPT | 连接事件 |
OP_CONNECT | 连接事件 |
OP_READ | 读事件 |
OP_WRITE | 写事件 |
1.3.2.1.NIO编程实现步骤
第一步:创建Selector
第二步:创建ServerSocketChannel,并绑定监听端口
第三步:将Channel设置为非阻塞模式
第四步:将Channel注册到Selector上,监听连接事件
第五步:循环调用Selector的select方法,检测就绪情况
第六步:调用selectedKeys方法获取就绪channel集合
第七步:判断就绪事件种类,调用业务处理方法
第八步:根据业务需要决定是否再次注册监听事件,重复执行第三步操作
1.3.2.2.代码实现
单一事件处理
问题1:当事件已经发生时,需要进行处理或者是cancel方法调用处理,否则服务器就会认为该事件没有完成,就会一直执行。简而言之就是事件发生后不能“置之不理”
多事件的处理
public static void main(String[] args) { try { // 2.服务器创建 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); // 3.绑定对应端口 serverSocketChannel.bind(new InetSocketAddress(8099)); // 4.设定服务端非阻塞模式 serverSocketChannel.configureBlocking(false); // 5. 多路复用器初始化,可以管理多个cahnnel Selector selector = Selector.open(); // 6.对应的channel发生什么事件之后进行存储的对应事件的key值【建立selector之间的联系,且记录对应事件】 SelectionKey selectionKey = serverSocketChannel.register(selector,0,null); // 6.1 selectionKey关注于链接事件 selectionKey.interestOps(SelectionKey.OP_ACCEPT); while(true){ // 7。select()方法,没有事件发生,线程是阻塞的,有事件发生,线程才会恢复运行 // select在事件未处理是,不会阻塞,事件发生以后,要么处理,要么取消 selector.select(); // 8.遍历对应的cahnnel的key值,使用迭代器进行遍历 Iterator iterator = selector.selectedKeys().iterator(); while(iterator.hasNext()){ // 9.获取每个的取值,既取到事件类型 // // 19.新增删除selectedKeys中解绑的channel // iterator.remove(); SelectionKey key = iterator.next(); // 10.区分时间类型进行判断 // 11. 判断是不是连接的类型 if (key.isAcceptable()){ // 12. 创建服务daunt的连接 ServerSocketChannel channel = (ServerSocketChannel) key.channel(); // 13. 发生了可连接事件,那么就创建连接 SocketChannel sc = channel.accept(); // 13.设置模式为非阻塞模式,既 sc.configureBlocking(false); SelectionKey scKey = sc.register(selector,0,null); // 14.关注事件类型读数据,开始进行数据读取操作 scKey.interestOps(SelectionKey.OP_READ); }else if (key.isReadable()){ // 15.事件时可读事件,数据进行读取 SocketChannel socketChannel = (SocketChannel) key.channel(); // 16.缓冲区创建 ByteBuffer byteBuffer = ByteBuffer.allocate(16); // 17.数据存储到缓冲区 socketChannel.read(byteBuffer); // 18 。数据模式切换为读模式 byteBuffer.flip(); debugAll(byteBuffer); } } } } catch (IOException e) { e.printStackTrace(); } }
问题2:当发送socketChannel.write(Charset.defaultCharset().encode("hi"))消息时,出现空指针异常,这个原因主要是因为事件已经处理过了,但是在SelectionKey中的channel事件没有对应的移除,就会导致重复读取,但是对应的channle已经不做该事件处理了,解绑了,所以就会出现空指针异常的情况。(上述代码中26-33行就)
要想解决空指针异常的情况,则需要进行对应事件处理完成后将对应key值移除操作。既增加第19步骤操作
// 19.新增删除selectedKeys中解绑的channel iterator.remove();
selector处理客户端断开连接【强制断开与正常断开】
强制断开连接时会出现io异常提醒,且继续阻塞select运行
java.io.IOException: 远程主机强迫关闭了一个现有的连接。 at java.base/sun.nio.ch.SocketDispatcher.read0(Native Method) at java.base/sun.nio.ch.SocketDispatcher.read(SocketDispatcher.java:43) at java.base/sun.nio.ch.IOUtil.readIntoNativeBuffer(IOUtil.java:276) at java.base/sun.nio.ch.IOUtil.read(IOUtil.java:245) at java.base/sun.nio.ch.IOUtil.read(IOUtil.java:223) at java.base/sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:358) at nio.serve.network.ServerIO.main(ServerIO.java:68)
因此需要在服务端的代码上增加try catch异常处理,且异常处理中将对应的key的进行移除与取消,从集合中删除,既增加内容为key.cancel();
正常断开,不会继续走到catch中,那么key也不会真正的被移除,所以始终都会存在一个读取的事件,会造成一个循环,因此需要判断read事件的返回逻辑,返回-1即为正常的断开
解决方案,服务代码修改第17条数据,接收返回值,并新增判断逻辑,判断是否返回为-1,客户端正常断开read 返回值是-1
selector处理消息边界
消息边界:缓冲区的大小范围与实际合理逻辑的数据长度范围不匹配导致的输出内容的不连贯,或者说对于一些中文字符的拆分传递导致的不合理拼凑问题,就会导致出现解析出现异常
示例内容
中文字符“世界占”6个字符,缓冲区大小只有4个,因此需要读取两遍才能把对应数据读取完成
对应的解决方案
1:固定传输空间大小,传输指定数据空间【实际使用较少,浪费空间,浪费网络带宽数据等】
2:使用固定分隔符号,进行数据切割轮询读取数据,类似使用于粘包和半包的数据读取【对比效率比较低,一个一个字节进行对比】
3:在数据传送时,发送包含数据传送内容的大小值,以供创建对应大小空间,类似于HTTP协议接口传输【常用的方式,netty中常用演示使用】HTTP2.0----LTV、HTTP1.1----TLV格式
selector处理可写事件
以往在进行数据写入的时候,当数据没有写完就会一直不断的占用着,不符合非阻塞的原则,也就是说在缓冲区已经满了,没有办法继续写入的时候,就还是会等缓冲区空,我就等着写进去。为了避免这中阻塞情况的出现,且符合非阻塞的原则,因此再这里实现Selector处理可写事件
public static void main(String[] args) { try { // 1.创建服务端并且设置为非阻塞模式 ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false); // 2.io多路复用器创建并绑定,绑定接口并注册,且开始监听事件 Selector selector = Selector.open(); ssc.register(selector,0, SelectionKey.OP_ACCEPT); ssc.bind(new InetSocketAddress(8097)); // 3.开始进行数据业务操作,遍历对应的cahnnel的key值,使用迭代器进行遍历 while(true){ selector.select(); Iterator iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()){ SelectionKey selectionKey = iterator.next(); iterator.remove(); // if (selectionKey.isAcceptable()){ // 4.事件处理操作,可以简化操作步骤 ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectionKey.channel(); SocketChannel socketChannel = serverSocketChannel.accept(); socketChannel.configureBlocking(false); StringBuilder stringBuilder = new StringBuilder(); for (int i = 0;i< 200000;i++){ stringBuilder.append("a"); } ByteBuffer byteBuffer = Charset.defaultCharset().encode(stringBuilder.toString()); // 返回值代表写入的内容 // while(byteBuffer.hasRemaining()){ // int w = socketChannel.write(byteBuffer); // System.out.println(w); // } // 如果缓冲区还有空余的话,说明能继续写入数据,那么就关注可写事件 if (byteBuffer.hasRemaining()){ // selectionKey.interestOps(SelectionKey.OP_WRITE); // 存再问题,可能件原有关注事件覆盖 selectionKey.interestOps(selectionKey.interestOps()+SelectionKey.OP_WRITE);// 解决上述的问题 // 把没有洗完的数据挂到keyu上 selectionKey.attach(byteBuffer); } // 发现事件为继续可以写的事件,那么就继续写 }else if (selectionKey.isWritable()){ ByteBuffer buffer = (ByteBuffer) selectionKey.attachment(); SocketChannel socketChannelOne = (SocketChannel) selectionKey.channel(); int w = socketChannelOne.write(buffer); System.out.println(w); // 数据清理操作,减少数据占用内存 if (!buffer.hasRemaining()){ // 去掉数据的挂在 selectionKey.attach(null); // 去掉写事件的监控 selectionKey.interestOps(selectionKey.interestOps() - SelectionKey.OP_WRITE) } } } } } catch (IOException e) { e.printStackTrace(); } }
1.4.小结
因为网络编程本身的复杂性,以及JDK API开发的使用难度较高,所以在开源社区中,涌现出很多对JDK NIO进行封装、增加后的网络编程框架,例如:Netty、Mina等,后续我们将进入Netty框架的学习与总结