文章目录
- 题目描述
- 线程结构图
- 基本思路
- 服务端
- Server线程
- ServerRead线程
- Broadcast线程
- 我发现的问题
- 客户端
- Client 线程
- Send 线程
- Read 线程
- 其他类
- 获取时间戳方法
- 奇怪的运行测试
题目描述
基于多线程实现多人聊天室
服务端有上线提示功能与广播(发送给所有客户端的功能)
客户端有接收服务器数据与发送信息给服务器的功能
为实现聊天室,服务器有把从一个客户端接收到的数据分发给所有客户端的功能本文参考了 这篇文章,对其中出现的部分问题加以修正。
线程结构图
基本思路
每一个代码片都是一个单独的类。
服务端
Server线程
用于打开端口,连接客户端。
基本逻辑分析:
- 进行初始化打开ServerSocket之后,打开第一个线程Server(写在自身的run方法里了)
其实可以放在Server类的main方法里,但是既然图都画了,我就懒得改了( - 此时进入while循环,阻塞至第一个客户端socket连接。
- 一旦连接上一个socket,把该socket加入到另外一个线程Broadcast的List中去(用于后续的迭代发送信息)
- 加入后,执行对于该客户端socket的ServerRead线程。
- 进行下一次循环,继续阻塞。
public class Server implements Runnable {
static ArrayList<Socket> socketList = new ArrayList<>();
static ServerSocket serverSocket = null;
static Socket socket = null;
// 初始化服务socket,打开端口
static {
try {
serverSocket = new ServerSocket(4001);
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
new Thread(new Server()).start(); // 启动端口(本类的run线程)
new Thread(new Broadcast()).start(); // 启动服务端的广播线程
}
// 启动端口,等待连接的线程
@Override
public void run() {
System.out.println("打开4001端口,等待连接:");
int clientCount = 0;
while (true) {
try {
socket = serverSocket.accept();
System.out.println("第" + (++clientCount) + "个客户连接了");
} catch (IOException e) {
e.printStackTrace();
}
Broadcast.add(socket); // 将每个客户端的socket添加到Broadcast和ServerRead的socketList中
new Thread(new ServerRead(socket)).start(); // 启动服务端读线程
}
}
}
ServerRead线程
用于接收客户端的数据,打印到控制台并分发给所有客户端。
逻辑分析:
- 新建一个socketList保存所有的socket
- 每次从Server中调用new ServerRead(socket)构造方法时,会先把自身的private socket赋值,然后在这个静态的socketList中保存这个socket。由于静态,所以这个集合对于此类的所有实例线程共享。
- 进入while循环,创建输入流。
- 阻塞至读取不知道从哪个客户端来的数据msg,显示到自己的控制台中。
- 对socketList进行迭代,把msg写入每个客户端的socket中。(注意:因为本程序中读取都采用readLine方法,而且readLine会把换行符给去掉,所以写入的时候要手动加一个换行符,以便接受端能够顺利读取。)
- 进入下一次while循环。
public class ServerRead implements Runnable{
// 从socket接收数据,读取到屏幕上并分发到所有客户端的线程(服务端可用)
private Socket socket = null;
static List<Socket> socketList = new ArrayList<>();
public ServerRead(Socket socket){
this.socket = socket;
socketList.add(socket);
}
@Override
public void run() {
while (true) {
// 读取
try {
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
while (true) {
String msg = in.readLine(); // 读取到
System.out.println(msg); // 发送到控制台
for (Socket socket : socketList) {
BufferedWriter out = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
out.write(msg+"\n"); // 写到每个socket里
out.flush();
}
}
} catch (Exception e) {
e.printStackTrace();
break;
}
}
}
}
Broadcast线程
用于在服务端输入信息时,将消息分发给每个客户端。
我发现的问题
- 这里说一个我参考的代码中的问题:
- 原文的代码逻辑是:在Server线程的while循环里直接创建一个新的Broadcast(原文中叫print)线程,也就是连接几个客户端,就会创建几个Broadcast线程。而每个线程都需要一个nextLine的输入方法。
- 一旦连入2个客户端,就会创建两个线程,两个线程都会进入while循环,然后都阻塞在nextLine()方法上。 这时候,就需要输入两行信息,才能同时解除两个Broadcast线程,然后一口气往2个客户端中写入两行信息。
- 同理,如果接入3个客户端,就会需要输入三行信息,n个客户端,就要输入n行信息……
因此对保存socket和分发信息进行分离处理。
而上面的ServerRead线程由于不会阻塞在从控制台输入的步骤,也就不需要分离。- 但实际上我也有个问题。如果多个线程都阻塞在nextLine(),这时候输入一行信息,为什么不是其中一个线程抢占到这一行信息然后直接执行迭代分发的操作呢?有可能是我理解有误。
逻辑分析:
- 使用add方法手动添加socket到集合中。在Server线程中每次连接到一个客户端就会add一个socket。
- 打开线程后,进入while循环
- 从控制台获取输入
- 组装输入、时间戳、发送者名字、换行符。成为完整的要发送的数据。
- 迭代集合,写入每个socket里。
- 进入下一次循环,等待控制台输入。
public class Broadcast implements Runnable {
// 服务端的广播线程,向所有客户端socket发送信息
static List<Socket> socketList = new ArrayList<>();
Scanner sc = new Scanner(System.in);
public static void add(Socket socket) {
socketList.add(socket);
}
// 重写run代码块
@Override
public void run() {
// 遍历socketList
try {
while (true) {
String m = sc.nextLine(); //获取输入
// 生成msg
StringBuffer msg = new StringBuffer(GetDate.time()).append("服务器:").append(m).append("\n");
for (Socket socket : socketList) {
BufferedWriter out = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
out.write(new String(msg)); // 写到每个socket里
out.flush();
}
System.out.println("发送成功");
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
客户端
Client 线程
逻辑分析:
- 初始化客户名称,生成10000以内的随机数即可。
- 连接服务端
- 启动对于服务端的Read读取线程
- 启动对于服务端的Send发送线程
public class Client {
static Socket socket = null;
static String name;
// 初始化名字
static {
int x = (int)(Math.random()*10000);
name = "客户"+x;
}
public static void main(String[] args) {
System.out.println("----客户端----");
try{
socket = new Socket(InetAddress.getLocalHost(),4001);
System.out.println("连接成功,你是"+name);
} catch (IOException e){
e.printStackTrace();
}
// 启动客户端接收线程
new Thread(new Read(socket)).start();
// 启动客户端发送线程
new Thread(new Send(socket,name)).start();
}
}
Send 线程
逻辑分析:
- 构造方法里传入服务器的socket与自定义客户端名称。
- 开启线程后,进入while循环
- 输入一行信息
- 组装发送的信息,发送给服务器socket
- 进入下一次循环
public class Send implements Runnable {
// 客户端发送信息给服务端socket的线程
static Socket socket = null;
static Scanner sc = new Scanner(System.in);
static String name;
public Send(Socket socket,String name){
Send.socket = socket;
Send.name = name;
}
@Override
public void run() {
while (true) {
try {
String msg = sc.nextLine();
BufferedWriter out = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
out.write(GetDate.time() + name + "说:" + msg + "\n");
out.flush();
} catch (Exception e) {
e.printStackTrace();
break;
}
}
}
}
Read 线程
逻辑分析:
- 指定服务器socket,构造实例
- 进入while循环
- 读取从服务器发来的数据,打印到控制台。
- 进入下一次循环
public class Read implements Runnable{
// 将从socket接收到的数据读取到屏幕上的线程(所有端可用)
static Socket socket = null;
public Read(Socket socket){
Read.socket = socket;
}
@Override
public void run() {
while (true) {
try {
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
while (true) {
System.out.println(in.readLine());
}
} catch (Exception e) {
e.printStackTrace();
break;
}
}
}
}
其他类
获取时间戳方法
因为用得不多,使用静态方法。在Send和ServerRead线程中组装发送数据时会用到。
public class GetDate {
public static String time(){
Date date = new Date();
DateFormat sdf = new SimpleDateFormat("(MM/dd HH:mm:ss)");
return sdf.format(date);
}
}
奇怪的运行测试