文章目录
一、从两个人通信到多人通信
上一篇已经实现了两个程序之间通过 Socket 发送和接收消息,下来继续往多人通信扩展。
如果现在有 A、B、C 三个客户端都需要聊天,最直接的想法是让它们两两建立连接。但这样客户端一多,连接数量也会跟着增加,程序会越来越难处理。
所以这次改成了比较常见的结构:所有客户端都连接到同一个 Server,由 Server 统一负责接收和转发消息。大致结构如下:
text
Client A
|
Client B--Server--Client C
A 发出的消息先到 Server,Server 收到以后再转发给 B、C。这样客户端只需要和服务器保持一条连接,不需要自己管理其他客户端。
二、服务端的基本结构
写一个 Server 类,把服务端需要的功能分别封装成方法。
首先是初始化:
java
ServerSocket ss = null;
public void init() {
try {
ss = new ServerSocket(54321);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
服务端监听 54321 端口,然后单独写了一个 listen() 方法:
java
public Socket listen() {
try {
return ss.accept();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
每有一个客户端连接,accept() 就会返回一个对应的 Socket。
因为现在不再只有一个客户端,所以服务端还需要把这些 Socket 保存起来:
java
ArrayList<Socket> SocketList = new ArrayList<>();
客户端连接成功之后:
java
Socket s = server.listen();
server.SocketList.add(s);
这样服务端就能够知道当前有哪些客户端在线,后面转发消息时也可以直接遍历这个列表。
三、阻塞问题
一开始处理多客户端消息时,思路是把所有 Socket 放进列表,然后循环读取:
java
for (...) {
InputStream is = socket.getInputStream();
receive(is);
}
看起来只要不断循环,就能够把每个客户端的消息都读取出来。
实际测试以后才发现不是这样,read() 本身是阻塞的。如果当前循环先读客户端 1,而客户端 1 没有发消息,程序就会一直停在这里。即使客户端 2、客户端 3 已经发了消息,也暂时读取不到。
text
客户端1
↓
客户端2
↓
客户端3
↓
客户端4
基本要按照连接顺序一个个发送,前面的人不发,后面的人就会被卡住。
所以问题并不在有没有写 while(true),而在于所有客户端共用同一个读取流程。只要前面的 read() 阻塞,后面的 Socket 就没有机会继续执行。
四、一个客户端对应一个线程
解决方法就是把每个客户端的消息监听拆开。每有一个客户端连接进来,就给这个 Socket 创建一个独立线程:
java
public void proClintMsg(Socket socket) {
new Thread(() -> {
while (true) {
try {
InputStream is =
socket.getInputStream();
String msg =
receive(is);
replyMsg(socket, msg);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}).start();
}
连接成功以后直接调用:
java
server.proClintMsg(s);
这样 A、B、C 三个客户端就分别有自己的监听线程。
text
Socket A → Thread A
Socket B → Thread B
Socket C → Thread C
A 的 read() 即使正在等待,也不会影响 B 和 C。
重新测试以后,就不再要求客户端严格按照连接顺序发送消息了,任何一个客户端都可以随时向服务器发消息。
五、消息的发送和接收
服务端的发送方法:
java
public void send(String message, OutputStream os) {
try {
byte[] msgs = message.getBytes();
int len = msgs.length;
os.write(len);
os.write(msgs);
os.flush();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
这里先将字符串转换成字节数组:
java
byte[] msgs = message.getBytes();
然后先发送:
java
os.write(len);
再发送真正的消息:
java
os.write(msgs);
接收端按照相同的格式读取:
java
public String receive(InputStream is) {
try {
int len = is.read();
byte[] msgs = new byte[len];
is.read(msgs);
String msg = new String(msgs);
System.out.println("收到:" + msg);
return msg;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
也就是:
text
先读取消息长度
↓
根据长度创建 byte[]
↓
读取消息正文
↓
byte[] 转成 String
发送和接收两边必须使用相同的数据格式,否则消息就无法正常解析。
六、服务端实现消息转发
服务端能够同时收到多个客户端的消息以后,下一步就是把收到的消息转发出去。
这里写了一个 replyMsg() 方法:
java
public void replyMsg(Socket socket, String msg) {
for (int i = 0;
i < SocketList.size();
i++) {
Socket s =
SocketList.get(i);
if (s != socket) {
try {
send(
msg,
s.getOutputStream()
);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
}
其中传进来的 socket 就是当前发送消息的客户端。
服务端遍历所有已经连接的 Socket:
java
SocketList
如果发现:
java
s != socket
说明这个 Socket 不是消息发送者,就把消息转发给它。比如 A 发送:
text
hello
整个过程就是:
text
A
↓
Server
↓
B
↓
C
A 自己不会再收到一遍自己发送的消息。
七、客户端同时负责发送和接收
服务端虽然已经能够转发消息,但一开始客户端只有发送功能,所以 Server 把消息发回来以后,客户端并没有代码去读取。
因此客户端也需要增加接收消息的方法:
java
public String readMsg() {
try {
InputStream is =
socket.getInputStream();
int len = is.read();
byte[] msgs =
new byte[len];
is.read(msgs);
return new String(msgs);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
但是发送消息本身已经有一个:
java
while (true)
如果接收消息再直接写一个死循环,两个循环不能顺序执行,所以这里也使用线程处理。
java
new Thread(() -> {
while (true) {
String msg =
client.readMsg();
System.out.print("接收消息:");
System.out.println(msg);
System.out.println("请输入:");
}
}).start();
主线程仍然负责键盘输入和发送:
java
Scanner sc = new Scanner(System.in);
while (true) {
System.out.println("请输入:");
String message =
sc.nextLine();
client.send(message);
}
于是客户端现在实际有两部分同时运行:
text
主线程
↓
输入消息
↓
发送
子线程
↓
等待服务端消息
↓
接收并显示
服务端主要负责三件事情:
text
1. 监听客户端连接
2. 为每个客户端创建线程并接收消息
3. 将收到的消息转发给其他客户端
客户端则主要负责:
text
1. 连接服务器
2. 输入并发送消息
3. 单独启动线程接收其他客户端消息
整体结构大概是:
text
Server
|
---------------------
| | |
Client1 Client2 Client3
| | |
Thread1 Thread2 Thread3
服务端通过 SocketList 保存客户端连接,每个 Socket 有自己的消息处理线程,客户端自身又将发送和接收拆开。