课程《一站式学习Java网络编程 全面理解BIO/NIO/AIO》的学习笔记(二):
BIO编程模型 & 基于BIO的多人聊天室实现
源码地址:https://github.com/NoxWang/web-program
【Java网络编程】基于BIO/NIO/AIO的多人聊天室(一):java IO与内核IO
【Java网络编程】基于BIO/NIO/AIO的多人聊天室(三):NIO概述与实践
【Java网络编程】基于BIO/NIO/AIO的多人聊天室(四):NIO聊天室
【Java网络编程】基于BIO/NIO/AIO的多人聊天室(五):AIO聊天室
【Java网络编程】基于BIO/NIO/AIO的多人聊天室(六):思维导图
一、BIO编程模型
BIO:阻塞式IO
- 线程 Acceptor 负责接收客户端发起的连接请求
- 每当有新客户端连接,服务器都为其创建一个新线程Handler,负责处理与该客户端相关的输入输出操作
- 创建新线程的原因:如果直接在线程 Acceptor 中处理客户端的输入输出操作,则服务器无法再接收其他客户端的连接请求
- 创建新线程的原因:如果直接在线程 Acceptor 中处理客户端的输入输出操作,则服务器无法再接收其他客户端的连接请求
二、基于BIO的多人聊天室设计
2.1 需求分析
- 基于BIO模型
- 支持多人同时在线
- 每个用户的发言都被转发给其他在线用户
2.2 聊天室设计
2.2.1 服务器端
- 需要一个 Acceptor 线程,使用 ServerSocket 的 accept() 函数阻塞式地接受客户端连接
- 为每一个连接进来的客户端新建一个线程,该线程需要实现两个功能:1)接收该客户端发来的信息;2)将该信息转发给其他客户端
- 转发功能要求客户端能够存储当前在线的所有客户端列表
2.2.2 客户端
- 可以和服务端建立连接
- 客户端需要完成两个功能:1)接收用户在控制台的输入;2)读取其他客户端发送来的消息
- 由于等待用户输入的过程是阻塞的,所以客户端也需要两条线程,分别完成上述两个功能
2.2.3 BIO的体现
- ServerSocket.accept():该方法为阻塞式调用,在有新客户端连接进来之前一直阻塞
- InputStream.read() & OutputStream.read():这两个方法均为阻塞式调用,在用户输入消息前,read()方法会一直阻塞,因此需要为每个客户端都创建一个线程
三、基于BIO的多人聊天室实现
代码结构如下:
- server
- ChatServer.java:服务端的主线程,负责启动服务端、接收客户端请求、存储当前在线客户端、为客户端新建处理线程等
- ChatHandler.java:处理和客户端之间的输入输出工作
- client
- ChatClient.java:客户端的主线程,负责连接服务端,读取服务端转发来的其他客户端信息
- UserInputHandler.java:等待和发送用户输入
3.1 服务端
3.1.1 ChatServer.java
服务端的主线程,负责启动服务端、接收客户端请求、存储当前在线客户端、为客户端新建处理线程等
package server;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.HashMap;
import java.util.Map;
public class ChatServer {
/** 服务器监听端口 */
private int SERVER_PORT = 8888;
/** 客户端退出标志(客户端发送\quit表示退出聊天室 */
private final String QUIT = "\\quit";
/** 服务端 Socket */
private ServerSocket serverSocket;
/**
* 存储已连接的客户端
* key:客户端的端口号
* value:向该端口发信息所使用的 Writer
*/
private Map<Integer, Writer> connectedClients;
public ChatServer() {
connectedClients = new HashMap<>();
}
/**
* 添加新在线客户端
* @param socket 新增客户端的socket
* @throws IOException
*/
public synchronized void addClient(Socket socket) throws IOException {
if (socket != null) {
int port = socket.getPort();
BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(socket.getOutputStream())
);
connectedClients.put(port, writer);
System.out.println("客户端[" + port + "]已连接到服务器");
}
}
/**
* 移除已下线客户端
* @param socket 已下线的客户端socket
* @throws IOException
*/
public synchronized void removeClient(Socket socket) throws IOException {
if (socket != null) {
int port = socket.getPort();
if (connectedClients.containsKey(port)) {
connectedClients.get(port).close();
}
connectedClients.remove(port);
System.out.println("客户端[" + port + "]已断开连接");
}
}
/**
* 转发信息给其他所有在线客户端
* @param socket 发送信息的客户端
* @param fwdMsg 该客户端发送的信息
* @throws IOException
*/
public synchronized void forwardMessage(Socket socket, String fwdMsg) throws IOException {
for (Integer id : connectedClients.keySet()) {
if (!id.equals(socket.getPort())) {
Writer writer = connectedClients.get(id);
writer.write(fwdMsg);
writer.flush();
}
}
}
/**
* 服务端主要逻辑
*/
public void start() {
try {
// 为服务端绑定端口
serverSocket = new ServerSocket(SERVER_PORT);
System.out.println("服务器启动,监听端口:" + SERVER_PORT + "...");
while (true) {
// accept()方法是阻塞式的
Socket socket = serverSocket.accept();
// 有客户端连接后,为它创建一个ChatHandler线程
new Thread(new ChatHandler(this, socket)).start();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
close();
}
}
/**
* 判断客户端是否准备退出
* @param msg 客户端发送的消息
* @return true:准备退出
*/
public boolean readyToQuit(String msg) {
return QUIT.equals(msg);
}
/**
* 关闭服务器
*/
public synchronized void close() {
if (serverSocket != null) {
try {
serverSocket.close();
System.out.println("服务器关闭");
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
ChatServer chatServer = new ChatServer();
chatServer.start();
}
}
可使用线程池对服务器端进行进一步的改进,限制可以进入聊天室的客户端个数,避免为用户开启的线程过多。改进后的部分代码如下:
...
public class ChatServer {
...
/** 线程池 */
private ExecutorService executorService;
public ChatServer(int threadNum) {
// 创建线程池
executorService = Executors.newFixedThreadPool(threadNum);
connectedClient = new HashMap<>();
}
...
/**
* 服务端主要逻辑
*/
public void start() {
try {
// 绑定监听端口
serverSocket = new ServerSocket(SERVER_PORT);
System.out.println("服务器启动,监听端口" + SERVER_PORT + "...");
while (true) {
// 等待客户端连接
Socket socket = serverSocket.accept();
// 向线程池提交任务
executorService.execute(new ChatHandler(this, socket));
}
} catch (IOException e) {
e.printStackTrace();
} finally {
close();
}
}
public static void main(String[] args) {
// 创建服务器类时传入允许的最大在线用户数
ChatServer chatServer = new ChatServer(3);
chatServer.start();
}
}
3.1.2 ChatHandler.java
每当有新客户端连接进服务器时,服务器均会为其创建一个ChatHandler线程,处理服务器和客户端之间的输入输出工作
package server;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
public class ChatHandler implements Runnable{
/** 服务器类 */
private ChatServer server;
/** 当前客户端 Socket */
private Socket socket;
public ChatHandler(ChatServer server, Socket socket) {
this.server = server;
this.socket = socket;
}
@Override
public void run() {
try {
// 存储新上线用户
server.addClient(socket);
// 读取用户发送的消息
BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream())
);
String msg = null;
while ((msg = reader.readLine()) != null) {
// 检查用户是否退出
if (server.readyToQuit(msg)) {
break;
}
String fwdMsg = "客户端[" + socket.getPort() + "]:" + msg + "\n";
System.out.print(fwdMsg);
// 转发消息至其他在线用户
server.forwardMessage(socket, fwdMsg);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
server.removeClient(socket);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
3.2 客户端
3.2.1 ChatClient.java
客户端的主线程,负责连接服务端,读取服务端转发来的其他客户端信息
package client;
import java.io.*;
import java.net.Socket;
public class ChatClient {
/** 服务器 IP */
private final String SERVER_HOST = "127.0.0.1";
/** 服务器监听端口号 */
private final int SERVER_PORT = 8888;
/** 客户端退出命令 */
private final String QUIT = "\\quit";
/** 客户端 Socket */
private Socket socket;
/** 从服务端读取信息的 Reader */
private BufferedReader reader;
/** 向服务端发送消息的 Writer */
private BufferedWriter writer;
/**
* 发送消息给服务器
* @param msg
* @throws IOException
*/
public void send(String msg) throws IOException {
// 确定输出流没有被关闭
if (!socket.isOutputShutdown()) {
writer.write(msg + "\n");
writer.flush();
}
}
/**
* 检查用户是否准备退出
*/
public boolean readyToQuit(String msg) {
return QUIT.equals(msg);
}
/**
* 关闭服务器
*/
public void close() {
if (writer != null) {
try {
writer.close();
System.out.println("关闭客户端");
} catch (IOException e) {
e.printStackTrace();
}
}
}
public void start() {
try {
// 创建socket
socket = new Socket(SERVER_HOST, SERVER_PORT);
// 创建IO流
reader = new BufferedReader(
new InputStreamReader(socket.getInputStream())
);
writer = new BufferedWriter(
new OutputStreamWriter(socket.getOutputStream())
);
// 处理用户输入
new Thread(new UserInputHandler(this)).start();
// 读取服务器转发的信息
String msg = null;
while ((msg = reader.readLine()) != null) {
System.out.println(msg);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
close();
}
}
public static void main(String[] args) {
ChatClient client = new ChatClient();
client.start();
}
}
3.2.2 UserInputHandler.java
需要为每一个客户端进程都创建一个UserInputHandler线程,负责等待和发送用户输入
package client;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class UserInputHandler implements Runnable{
/** 客户端类 */
private ChatClient chatClient;
public UserInputHandler(ChatClient chatClient) {
this.chatClient = chatClient;
}
@Override
public void run() {
try {
BufferedReader consoleReader = new BufferedReader(
new InputStreamReader(System.in)
);
while (true) {
// 等待用户输入消息
String input = consoleReader.readLine();
// 向服务器发送消息
chatClient.send(input);
// 检查用户是否准备退出
if (chatClient.readyToQuit(input)) {
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
3.3 运行结果
-
启动服务器和三个服务端进程(左上角为服务器,注意各个客户端是不同的端口):
-
三个客户端可各自发送消息,并接受由服务器转发的其他客户端的消息
-
某客户端发送“\quit”后退出聊天室(右下角)