目录
思路:
首先我们要创建的聊天室是满足一些基本的要求:
1.客户端:
能够显示聊天的界面,并且供用户选择注册和登陆功能,登陆成功后,能接收到其他用户发出的消息,并且自己也能发送消息,发送和接收是能够同时进行的,也就是说只要有别的用户发消息,自己能够立马收到,类似于群聊一样。
2.服务器端;
服务端要能够支持多个用户同时连接,并且要做到登录验证,信息反馈,消息转发等功能。
实现细节:
总的接收和发送,为了提高效率,我都是使用的缓冲流,BufferedReader和BufferedWriter。
并且,用一个文本文件 log.txt 来存储用户名和密码。
对于数据读取,我才用的是readLine()方法,这样一次读取一整行,并且为了防止BufferedReader的死等发生,使用了结束标记"END\n",这里加\n是为了让readLine读取到,每当读取到END这个字符串的时候,就退出读取数据的循环,表示读完了,作为一个结束标记。
因为是需要多次读取,所以发送端不能使用shutdownOutput直接关闭输出流,否则就收不到下一条消息了
接收处代码:
发送代码:
这里的t是用来存储message的信息,比如我要发送的是"你好",发送端会发送两行数据:
"你好\n"和"END\n"(结束标记),这里的t会存储"你好",然后重新赋值给message(此时=“END”),然后退出读取循环。
聊天室中的所有接发消息的代码逻辑都和以上差不多
客户端:
为了实现接收消息和发送消息的互不影响的效果,我在登陆成功后的分支,新开了一个线程专门用来接收服务器转发来的其他用户的消息
代码如下:
我开启了一个新的线程专门用于接收消息并且反馈到控制台上,因为如果单纯放在一个线程中,接收和发送会发生阻塞,会出现: 你发出一条消息后,才能接收到一条消息的情况
服务端:
这里我在一开始使用了线程池ExecutorService,通过工具类Executors.newCachedThreadPool()方法获取,这是一个没有设置具体线程数上限的线程池。用来接收各个客户端的Socket
代码如下:
注意:
这里的获取套接字Socket一定要放在while循环中,因为一个accept对应一个客户端,没接收到一个客户端就要新建一个Socket。并且用线程池实现多线程处理不同客户端的数据,让他们能同时各聊各的。
此外,还为了实现消息转发,我采用了集合存储来自各个客户端的Socket,并且为了线程安全,使用了CopyOnWriteArrayList<Socket>,后面只要把消息,用集合遍历发送到各个客户端就行了,集合创建在main函数内,方法外,这样可以让多个线程共享同一个集合,同步增加和删除等。
这里的代码是只有当用户名和密码都对的情况下,也就是登陆成功,才会将Socket加入到集合中,因为只有登录成功的Socket才是有效的Socket。
实际效果:
具体代码:
客户端:
public class client {
/*
支持多个用户同时聊天,服务器进行转发消息,相当于群聊一样
有注册和登陆选项
*/
public static void main(String[] args) throws IOException {
//功能号
String function = "";
//登录界面
System.out.println("与服务器接连成功");
System.out.println("============欢迎来到聊天室==========");
while (!function.equals("3")) {
//这里把套接字放到循环内来创建,否则第二次循环就还是用的第一次已经关闭的的套接字
//创建Socket套接字对象
Socket socket = new Socket("127.0.0.1", 10086);
System.out.println("请选择功能按钮");
System.out.println("1.注册");
System.out.println("2.登陆");
System.out.println("3.退出");
//输入功能号
Scanner sc = new Scanner(System.in);
function = sc.nextLine();
//打开连接通道,发送账号密码
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
//获取输入流
BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
String message = "";
String t = "";
//注册分支
if (function.equals("1")) {
StringJoiner sj = new StringJoiner("=", "", "");
//告诉服务器这是注册分支
sj.add("1");
System.out.println("请输入你要注册的用户名");
sj.add(sc.nextLine());
System.out.println("请输入你要注册的密码");
sj.add(sc.nextLine());
writer.write(sj.toString());
writer.newLine();
//结束标记
writer.write("END\n");
//刷新缓冲区,以发出数据
writer.flush();
//获取服务器的反馈信息
while ((message = br.readLine()) != null) {
if (message.equals("END")) {
System.out.println(t);
break;
}
t = message;
}
}
//登陆分支
else if (function.equals("2")) {
StringJoiner sj = new StringJoiner("=", "", "");
//告诉服务器这是登陆分支
sj.add("2");
System.out.println("请输入你的用户名");
sj.add(sc.nextLine());
System.out.println("请输入你的密码");
sj.add(sc.nextLine());
//打开连接通道,发送账号密码
writer.write(sj.toString());
writer.newLine();
//结束标记
writer.write("END\n");
//刷新缓冲区,以发出数据
writer.flush();
//等待服务器的登陆确认
while ((message = br.readLine()) != null) {
if (message.equals("END")) {
//获取到上一条信息,因为当前信息是结束标志
message = t;
break;
}
t = message;
}
System.out.println(message);
if (message.equals("登陆成功")) {
System.out.println("登陆成功,请开始聊天");
String temp = "";
System.out.println("请输入你要说的话:");
while (!temp.equals("886")) {
//接下来就是用循环,实现发送群消息
temp = sc.nextLine();
//发送数据
/*
重新获取输出流
一旦你调用了shutdownOutput()方法关闭了输出流,那么之后尝试使用该输出流进行输出操作将会引发IOException。
关闭输出流后,如果你需要再次发送数据,你需要重新获取一个新的输出流对象。关闭输出流是为了告知对方已经发送完毕,不再发送数据,
因此需要重新获取输出流来发送新的数据。
不过这里我没有采用shutdownOutput 还是用之前创建的缓冲输出流向服务器发送我们说的话
*/
writer.write(temp);
writer.newLine();
//结束标志
writer.write("END\n");
//刷新缓冲区,以发出数据
writer.flush();
/*
如果你希望客户端能够同时接收和发送消息,通常需要使用多线程。这是因为客户端在发送消息时需要等待服务器的响应,
如果在同一个线程中进行这两个操作,就会导致阻塞,无法同时进行发送和接收。通过将接收消息的逻辑放在一个单独的线程中,
可以实现并发地进行发送和接收操作。这样,客户端就能够实时地接收到其他客户端发送的消息,而不会因为发送操作的阻塞而导致无法接收。
因此,多线程可以提供更好的用户体验,使客户端能够同时进行发送和接收操作,增加聊天室的实时性和交互性。
*/
// 启动消息接收线程
Thread receiveThread = new Thread(() -> {
try {
String m = "";
while (!m.equals("886")) {
// 接收服务器的消息
String receivedMessage = br.readLine();
if (receivedMessage.equals("END")) {
continue;
}
System.out.println(receivedMessage);
}
} catch (IOException e) {
e.printStackTrace();
}
});
receiveThread.start();
}
} else if (message.equals("密码错误")) {
System.out.println("密码错误");
} else if (message.equals("用户名不存在")) {
System.out.println("用户名不存在");
}
}
//释放资源
socket.close();
}
}
服务器端:
(这里注意把 输入输出流 (也就是BufferedReader和BufferedWriter)的文件路径改为自己本地已有的txt文件路径)
package com.itheima.x_网络编程.TCP.大作业_聊天室实现;
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.StringJoiner;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class server {
/*
支持多个用户同时聊天,服务器进行转发消息,相当于群聊一样
有注册和登陆选项
*/
public static void main(String[] args) throws IOException {
//创建ServerSocket套接字对象
ServerSocket socket = new ServerSocket(10086);
//利用线程池
ExecutorService service = Executors.newCachedThreadPool();
//用集合存储Socket套接字,对象用于转发,这里采用线程安全的集合类型
/*
CopyOnWriteArrayList是Java中的一个线程安全的列表实现,它是ArrayList的线程安全版本。
CopyOnWriteArrayList的特点是在进行写操作时,它会创建一个底层数组的副本,并在副本上进行修改,而原始数组保持不变。这意味着写操作不会影响到正在进行的读操作,从而实现了并发安全性。
由于CopyOnWriteArrayList的写操作会创建副本,因此写操作的开销较大,适用于读操作频繁、写操作较少的场景。它适合在多线程环境下进行遍历和读取操作,但不适合频繁的增加、删除和修改操作。
CopyOnWriteArrayList实现了List接口,因此它具备了List的常用方法,如添加元素、获取元素、删除元素等。它可以作为线程安全的列表容器,用于在多线程环境下进行元素的读取和遍历操作。
需要注意的是,由于CopyOnWriteArrayList的写操作会创建副本,因此它会占用更多的内存空间。在数据量较大的情况下,应谨慎使用,以避免内存占用过高的问题。
*/
CopyOnWriteArrayList<Socket> as = new CopyOnWriteArrayList<>();
while (true) {
//打开通道获取输入流
Socket accept = socket.accept();
service.submit(() -> {
try {
chat(as, accept);
} catch (IOException e) {
e.printStackTrace();
}
});
}
}
public static void chat(CopyOnWriteArrayList<Socket> as, Socket accept) throws IOException {
try (
//括号内的变量创建,在结束后会自动释放资源
BufferedReader reader = new BufferedReader(new InputStreamReader(accept.getInputStream()));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(accept.getOutputStream()))
) {
//访问log.txt
BufferedReader fr = new BufferedReader(new FileReader("这里填你存放用户密码的txt文件路径"));
//创建需要的变量
String t = "";
String message = "";
int flag = 0;
while ((message = reader.readLine()) != null) {
if (message.equals("END")) {
message = t;
break;
}
t = message;
}
//注册分支
if(message.split("=")[0].equals("1")) {
//要保证用户名是唯一的
String temp;
while((temp=fr.readLine())!=null)
{
if(temp.split("=")[0].equals(message.split("=")[1]))
{
flag++;
//如果用户名重复了,就返回告诉客户端用户名已存在
writer.write("用户名已存在,注册失败");
writer.newLine();
writer.write("END\n");
writer.flush();
break;
}
}
if(flag==0)
{
//注意这里要用append模式,否则会覆盖文件
BufferedWriter bw=new BufferedWriter(new FileWriter("这里填你存放用户密码的txt文件路径",true));
StringJoiner sj=new StringJoiner("=");
sj.add(message.split("=")[1]);
sj.add(message.split("=")[2]);
//写入文本
//注意这里要换行,因为我们后面都是readLine整行整行读取的
bw.newLine();
bw.write(sj.toString());
bw.flush();
writer.write("用户创建完成,注册成功");
writer.newLine();
writer.write("END\n");
writer.flush();
}
}
//登录分支
else if(message.split("=")[0].equals("2")) {
//从文件中比对信息,验证密码
String temp;
while ((temp = fr.readLine()) != null) {
//首先比对用户名
if (temp.split("=")[0].equals(message.split("=")[1])) {
flag++;
//进而判断密码
if (temp.split("=")[1].equals(message.split("=")[2])) {
//将该套接字加入集合
as.add(accept);
//传递登陆成功返回给客户端
writer.write("登陆成功");
writer.newLine();
//结束标记
writer.write("END\n");
writer.flush();
/*
后面接一个聊天方法就行
*/
//获取用户名
String username = temp.split("=")[0];
while (true) {
//接收信息和转发
while ((message = reader.readLine()) != null) {
if (message.equals("END")) {
message = t;
break;
}
t = message;
}
for (int i = 0; i < as.size(); i++) {
//这里不能用之前的输出流了,要对每一个socket新建,还要注意,用as.get(i)获取输出流
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(as.get(i).getOutputStream()));
bw.write(username + "发来的消息: " + message);
bw.newLine();
//结束标记
bw.write("END\n");
bw.flush();
}
}
} else {
writer.write("密码错误");
writer.flush();
break;//直接退出,不需要进入下一层循环
}
}
}
//用户名不匹配的话直接反馈给客户端,用户名错误
if (flag == 0) {
writer.write("用户名不存在");
writer.flush();
}
}
} finally {
accept.close();
}
}
}