一、 NIO 聊天室需求
1 . NIO 聊天室需求 :
① 服务器 客户端 通信 : 服务器 与 客户端 实现 双向通信 ; 服务器可以写出数据到客户端 , 也能读取客户端的数据 ; 客户端可以写出数据到服务器端 , 也可以读取服务器端的数据 ;
② 多人聊天 : 一个服务器 与 多个客户端 进行数据交互 , 同时还要实现将某一个客户端的数据转发给其它客户端 ;
③ 用户状态监测 : 服务器可以检测用户的 上线 , 离线 状态 ;
2 . 数据传输细节 :
① 上线监听 : 当有客户端连接时 , 服务器检测到用户上线 , 服务器将该用户上线状态通知给其它客户端 ;
② 下线监听 : 如果有客户端离线 , 服务器检测到连接断开 , 服务器将该用户离线的状态通知给聊天室的其它客户端 ;
③ 聊天信息转发 : 客户端发送消息时 , 服务器端接收到该数据 , 并转发给聊天室的其它用户客户端 ;
二、 NIO 聊天室 服务器端 代码分析
服务器端的连接管理流程 : 创建 服务器套接字通道 ( ServerSocketChannel ) , 将该通道注册给 选择器 ( Selector ) , 选择器开启监听 , 监听到客户端连接 , 就创建一个 套接字通道 ( SocketChannel ) , 注册给选择器 ;
服务器端的消息转发流程 : 服务器端收到客户端发送的消息 , 将该消息转发给除该客户端外的其它客户端 , 从选择器中可以获取到所有的 通道 , 注意 屏蔽 服务器套接字通道 和 发送本消息的客户端对应的通道 ;
服务器连接监听 : 当客户端与服务器连接成功 , 即触发注册给 选择器 ( Selector ) 的 服务器套接字通道 ( ServerSocketChannel ) 的 SelectionKey.OP_ACCEPT 事件 , 表示有客户端连接服务器成功 , 用户上线 ;
服务器断开连接监听 : 当服务器端与客户端读写数据出现异常时 , 说明该客户端离线 , 在异常处理代码中可以判定某个客户端离线 ;
1 . 服务器套接字通道 : 调用 open 静态方法创建服务器套接字通道 , 并绑定 8888 端口 , 设置非阻塞网络通信模式 ;
// 创建并配置 服务器套接字通道 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.socket().bind(new InetSocketAddress(PORT)); serverSocketChannel.configureBlocking(false);
2 . 服务器端选择器 : 调用 open 静态方法获取 选择器 , 注册之前创建的 服务器套接字通道 ;
// 获取选择器, 并注册 服务器套接字通道 ServerSocketChannel selector = Selector.open(); serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
3 . 监听事件 : 阻塞监听, 如果有事件触发, 返回触发的事件个数 ; 被触发的 SelectionKey 事件都存放到了 Set<SelectionKey> selectedKeys 集合中 ;
// 阻塞监听, 如果有事件触发, 返回触发的事件个数 // 被触发的 SelectionKey 事件都存放到了 Set<SelectionKey> selectedKeys 集合中 // 下面开始遍历上述 selectedKeys 集合 try { int eventTriggerCount = selector.select(); } catch (IOException e) { e.printStackTrace(); }
4 . 处理客户端连接事件 : 接受客户端连接 , 获取 网络套接字通道 ( SocketChannel ) , 并注册给 选择器 ( Selector ) , 监听 SelectionKey.OP_READ 数据读取事件 ;
// 客户端连接服务器, 服务器端需要执行 accept 操作 if (key.isAcceptable()) { //创建通道 : 为该客户端创建一个对应的 SocketChannel 通道 //不等待 : 当前已经知道有客户端连接服务器, 因此不需要阻塞等待 //非阻塞方法 : ServerSocketChannel 的 accept() 是非阻塞的方法 SocketChannel socketChannel = null; try { socketChannel = serverSocketChannel.accept(); //如果 ServerSocketChannel 是非阻塞的, 这里的 SocketChannel 也要设置成非阻塞的 //否则会报 java.nio.channels.IllegalBlockingModeException 异常 socketChannel.configureBlocking(false); //注册通道 : 将 SocketChannel 通道注册给 选择器 ( Selector ) //关注事件 : 关注事件时读取事件, 服务器端从该通道读取数据 //关联缓冲区 : socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024)); System.out.println(String.format("用户 %s 进入聊天室", socketChannel.getRemoteAddress())); } catch (IOException e) { e.printStackTrace(); } }
5 . 处理客户端消息转发事件 :
① 读取客户端上传的数据 : 通过 SelectionKey 获取 通道 和 缓冲区 , 使用 套接字通道 ( SocketChannel ) 读取 缓冲区 ( ByteBuffer ) 中的数据 , 然后记录显示该数据 ;
// 获取 通道 ( Channel ) : 通过 SelectionKey 获取 SocketChannel socketChannel = (SocketChannel) key.channel(); // 获取 缓冲区 ( Buffer ) : 获取到 通道 ( Channel ) 关联的 缓冲区 ( Buffer ) ByteBuffer byteBuffer = (ByteBuffer) key.attachment(); String remoteAddress = null; String message = null; try { // 读取客户端传输的数据 int readCount = socketChannel.read(byteBuffer); byte[] messageBytes = new byte[readCount]; byteBuffer.flip(); byteBuffer.get(messageBytes); // 处理读取的消息 message = new String(messageBytes); //重置以便下次使用 byteBuffer.flip(); remoteAddress = socketChannel.getRemoteAddress().toString(); System.out.println(String.format("%s : %s", remoteAddress, message)); } catch (IOException e) { //e.printStackTrace(); // 如果此处出现异常, 说明该客户端离线了, 服务器提示, 取消选择器上的注册信息, 关闭通道 try { System.out.println( String.format("%s 用户离线 !", socketChannel.getRemoteAddress()) ); key.cancel(); socketChannel.close(); //继续下一次循环 continue; } catch (IOException ex) { ex.printStackTrace(); } }
② 转发给其它客户端 : 从 选择器 ( Selector ) 的 keys 集合 中获取所有注册的通道 , 然后除 ServerSocketChannel 和 发送本信息的 客户端对应的 SocketChannel 通道 之外 , 其它所有的通道都转发一份聊天信息 ;
// 向其它客户端转发消息, 发送消息的客户端自己就不用再发送该消息了 // 遍历所有注册到 选择器 Selector 的 SocketChannel Set<SelectionKey> selectionKeys = selector.keys(); for (SelectionKey selectionKey : selectionKeys) { // 获取客户端对应的 套接字通道 // 这里不能强转成 SocketChannel, 因为这里可能存在 ServerSocketChannel Channel channel = selectionKey.channel(); // 将自己排除在外, 注意这里是地址对比, 就是这两个类不能是同一个地址的类 // 这个类的类型必须是 SocketChannel, 排除之前注册的 ServerSocketChannel 干扰 if (socketChannel != channel && channel instanceof SocketChannel) { // 将通道转为 SocketChannel, 之后将字符串发送到客户端 SocketChannel clientSocketChannel = (SocketChannel) channel; // 写出字符串到其它客户端 try { clientSocketChannel.write(ByteBuffer.wrap( ( remoteAddress + " : " + message ).getBytes())); } catch (IOException e) { //e.printStackTrace(); // 如果此处出现异常, 说明该客户端离线了, 服务器提示, 取消选择器上的注册信息, 关闭通道 try { System.out.println( String.format("%s 用户离线 !", clientSocketChannel.getRemoteAddress()) ); selectionKey.cancel(); clientSocketChannel.close(); } catch (IOException ex) { ex.printStackTrace(); } } } }