Java网络编程,多线程,IO流综合小项目一一ChatBoxes
作者:blue
时间:2025.3.7
1.项目介绍
项目目标:实现一个C/S架构,基于TCP协议的控制台版的聊天室,带有注册,登录功能,能实现在局域网内,多个客户端,在一个聊天室中聊天
项目需求:
客户端:拥有登录,注册,聊天功能,用户名要唯一,密码第一位必须是字母,后面是纯数字,登录成功后可以直接开始聊天
服务端:对用户,登录和注册的信息进行验证,当登录成功之后,能接收客户端发来的消息,并能向所有已经登录的用户进行转发
2.项目源码剖析
2.1客户端源码
package com.bluening.Client;
import java.io.*;
import java.net.Socket;
import java.util.Scanner;
/*
* 客户端程序
* 功能:1.登录
* 2.注册
* 3.聊天
* */
public class Client {
public static void main(String[] args) throws IOException, InterruptedException {
//创建Socket对象,与指定服务端连接
Socket socket = new Socket("127.0.0.1", 10086);
//没有连接上的话,程序会报错,所以以下代码只有当连接成功才会执行
System.out.println("与服务端连接成功");
//主界面
Scanner sc = new Scanner(System.in);//Scanner对象
//字符缓冲输入流,用于接收服务端发回来的数据,利用转换流将socket的InputStream包装
BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
//字符缓冲输出流,用于向服务端发送数据,利用转换流将socket的OutputStream包装
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
while (true) {
System.out.println("=======控制台版聊天室=======");
System.out.println("1.登录");
System.out.println("2.注册");
System.out.println("请输入你所需要的功能:");
String choice = sc.nextLine();//输入所选择的功能
if ("1".equals(choice)) {
//登录模块
while (true) {
System.out.println("请输入用户名:");
String username = sc.nextLine();
System.out.println("请输入密码:");
String password = sc.nextLine();
bw.write("username=" + username + "&" + "password=" + password + "&" + "login");//向服务器发送信息,login表示这是一个登录请求
bw.newLine();
bw.flush(); // 刷新缓冲区确保数据发送
//接收服务端发来的信息
String line = br.readLine();
if (line.equals("1")) {
System.out.println("登录成功");
break;
} else if (line.equals("2")) {
System.out.println("账户或密码错误,请重新登录");
}
}
} else if ("2".equals(choice)) {
//注册模块
System.out.println("请输入用户名:");
String username = sc.nextLine();
String password = null;
while (true) {
System.out.println("请输入密码(密码只需要以字母开头,后面为纯数字):");
password = sc.nextLine();
//可以直接在客户端检查密码是否符合条件
if (checkPassword(password)) break;
else System.out.println("密码不符合条件");
}
bw.write("username=" + username + "&" + "password=" + password + "&" + "register");//向服务器发送信息,register表示这是一个注册请求
bw.newLine();
bw.flush(); // 刷新缓冲区确保数据发送
//接收服务端发来的信息
String line = br.readLine();
if (line.equals("1")) {
System.out.println("注册成功");
} else if (line.equals("2")) {
System.out.println("用户名重复");
}
continue; //注册完应该执行continue逻辑
} else continue;
//此处表示登录成功后开始聊天
//为了使用户收发信息的操作能够同时进行,采用多线程编程来解决问题
//创建一条发送信息的线程
Thread sender = new Thread(new ClientSendMessageRunnable(bw));
sender.start();
//创建一条接收信息的线程
Thread Receiver = new Thread(new ClientReceiveMessageRunnable(br));
Receiver.start();
//此时只让线程运行就好,可以跳出主循环
break;
}
//释放资源
//socket.close();
}
private static boolean checkPassword(String password) {
for (int i = 0; i < password.length(); i++) {
char x = password.charAt(i);
if (i == 0) {
if (!((x >= 'a' && x <= 'z') || (x >= 'A' && x <= 'Z'))) return false;//不是以字母开头
} else {
if (x < '0' || x > '9') return false;//不是以纯数字作为后续
}
}
return true;
}
}
此处我对客户端源码进行大概剖析,在源代码中我的注释比较完备了,故而在此处我只挑选花费我思考时间较多的部分进行解释。
1.针对客户端其登录和注册的逻辑并不难,但这里有第一个坑点,登录与注册同样是给服务端发送username和password的信息,如何让服务端区分你是在登录还是在注册呢?
答:这里我所采用的方法是,在发送的信息字段上附带上状态信息,如果是登录那在发送的信息后面就加上"&" + "login"字段,同理如果是注册,则加上"&" + "register",这样就方便服务端识别用户当前的行为了。
2.另外,为了保证Client与Server进行实时交互,我们在每次使用BufferedWriter bw的write方法发送信息后,我们都使用了一个flush方法,这是什么意思呢?
答:因为缓冲区是内存中的一块区域,用于临时存储数据。当进行数据写入操作时,数据不会立即被写入到目标设备(如文件、网络套接字等),而是先被存储在缓冲区中。当缓冲区满了或者满足某些条件时,才会将缓冲区中的数据一次性写入到目标设备。
flush()
方法的作用是强制将缓冲区中暂存的数据立即写入到目标设备中,无论缓冲区是否已满。具体步骤如下
检查缓冲区状态一一>写入数据到目标设备一一>清空缓冲区
3.客户端的难点,在登录成功后如何实现同时可以发送信息给服务器并可以接收服务器传来的消息?
答:我采用了多线程编程的方式来解决这个问题。针对每一个Client在其登录成功后,均有两个线程,对于Sender线程,我利用了构造方法传给了他bw,对于Receiver方法,我传给了他br。因为每个客户端是独立运行的,所以每个客户端的IO流是独立的,不会出现混乱。
对于Sender和Receiver,他们分别拥有当前Socket对象的bw和br,并不相互纠缠。
另外值得注意的是IO流的生命周期和线程的生命周期都是相互独立的。
2.2客户端Sender线程Runnable源码
package com.bluening.Client;
import java.io.BufferedWriter;
import java.util.Scanner;
//为了使用户收发信息的操作能够同时进行,采用多线程编程来解决问题
public class ClientSendMessageRunnable implements Runnable{
//利用构造方法来传递,针对Socket的输出流对象
//输入对象
BufferedWriter bw;
public ClientSendMessageRunnable(BufferedWriter bw) {
this.bw = bw;
}
@Override
public void run() {
Scanner sc = new Scanner(System.in);
try {
while(true){
System.out.println("请输入你要发送的信息:");
if (sc.hasNextLine()) {
String message = sc.nextLine();
// 检查输入是否为空,如果为空则继续等待有效输入
if (!message.isEmpty()) {
bw.write(message);
bw.newLine();
bw.flush();
}
}
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
2.3客户端Receiver线程Runnable源码
package com.bluening.Client;
import java.io.BufferedReader;
import java.io.IOException;
public class ClientReceiveMessageRunnable implements Runnable{
BufferedReader br;
public ClientReceiveMessageRunnable(BufferedReader br) {
this.br = br;
}
@Override
public void run() {
try {
while(true){
String line = br.readLine();
if(line!=null) System.out.println(line);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
2.4服务端源码
package com.bluening.Server;
import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/*
*服务端程序
* */
public class Server {
public static void main(String[] args) throws IOException {
//1.读取本地文件中所有正确用户的信息
ArrayList<String> userInfo = getUserInfo("Username_Password.txt");
//2.创建ServerSocket对象,注意端口与客户端指定的端口保持一致
ServerSocket serverSocket = new ServerSocket(10086);
//3.服务端会被多个客户端连接,当用户数量过大时,单纯的循环效率低下
//我们利用多线程来进行优化,但频繁地创建,销毁线程,对系统的开销比较大,我们可以利用线程池来进行优化
//定义线程池
ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(
5,//核心线程的数量
16,//最大线程数,不能小于0,最大数量>=核心线程数量
60,//空闲线程最大存活时间
TimeUnit.SECONDS,//时间单位
new ArrayBlockingQueue<>(5),//任务队列
Executors.defaultThreadFactory(),//创建线程工厂
new ThreadPoolExecutor.AbortPolicy()//任务拒绝策略
);
//由于涉及到服务端,向多个客户端转发信息,故而可以每个监听到的socket对象,利用集合来存储
//由于用户分为登录和未登录两个状态,只有登录状态的用户才能收到服务器发送的聊天信息
//所以我们应该用一个双列集合来存储信息
//键为Socket,值为其登录状态码 1为登录,0为未登录
HashMap<Socket,Integer> AllUserSocketMap = new HashMap<>();
while(true){
//监听客户端
Socket socket = serverSocket.accept();
//将socket对象加入到集合中
//由于AllUserSocketMap对象有可能在线程中被修改,所以其为一个共享数据
//在此利用同步代码块保证线程安全
synchronized (Server.class){
AllUserSocketMap.put(socket,0);
}
//监听到一个客户端则开启一条线程
poolExecutor.submit(new ServerRunnable(socket,userInfo,AllUserSocketMap));
}
}
//1.读取本地文件中所有正确用户的信息
private static ArrayList<String> getUserInfo(String file) throws IOException {
ArrayList<String> userInfo = new ArrayList<>();
//字符缓冲输入流,可以用其中的readLine方法读取整行数据
BufferedReader bw = new BufferedReader(new FileReader(file));
//循环获取
String line;
while((line=bw.readLine())!=null){
userInfo.add(line);
}
//释放资源
bw.close();
return userInfo;
}
}
2.5服务端Runnable源码
package com.bluening.Server;
import com.sun.source.tree.WhileLoopTree;
import java.io.*;
import java.net.Socket;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
/*
* 执行线程的runnable类
* 功能:1.对登录用户进行校验
* 2.完成用户注册
* 3.用户聊天时将聊天信息转发给所有用户
* */
public class ServerRunnable implements Runnable{
//显然要完成登录与注册功能需要,该条线程对应的Socket对象,用户信息的集合
//完成聊天功能则需要所有客户端对应的socket对象集合
//我们可以通过构造方法的方式,来从Server类中获取这些内容
Socket socket;//本线程对应的socket
String User; //若用户登录成功要记录其username
static String Message; //群发的消息应该是共享的
/*
* 值得注意的是,在此处userInfo,AllUserSocketMap是会发生变化的的数据
* 每条线程都有可能对其进行增加,这说明userInfo,AllUserSocketMap是一个共享数据
* 我们应该利用同步代码块,来保证线程安全
* */
//锁对象
static final Object LOCK = new Object();
//存放每个socket对象,对应的BufferedWriter
static HashMap<Socket,BufferedWriter> AllSocketBufferedWriter = new HashMap<>();
ArrayList<String> userInfo;//所有用户信息
HashMap<Socket,Integer> AllUserSocketMap;//所有客户端对应的socket,与登录状态码
//构造方法
public ServerRunnable(Socket socket, ArrayList<String> userInfo, HashMap<Socket,Integer> AllUserSocketMaps) {
this.socket = socket;
this.userInfo = userInfo;
this.AllUserSocketMap = AllUserSocketMaps;
}
@Override
public void run() {
//服务端应该先判断当前的Client用户是否登录,如果登录已经登录,那么就只要群发信息即可
//如果没有登录,则需要判断用户当前的行为是注册还是登录
Integer status = AllUserSocketMap.get(socket);//获取状态码
//字符输入流,准备读取客户端所发送来的信息
BufferedReader br = null;
try {
br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
} catch (IOException e) {
throw new RuntimeException(e);
}
//字符输出流,准备向客户端反馈信息
BufferedWriter bw = null;
try {
bw = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
} catch (IOException e) {
throw new RuntimeException(e);
}
//维护AllSocketBufferedWriter
synchronized (LOCK) {
AllSocketBufferedWriter.put(socket,bw);
}
//用于将数据写回文件
BufferedWriter file_writer = null;
try {
//应该打开续写开关,不能清空文件
file_writer = new BufferedWriter(new FileWriter("Username_Password.txt",true));
} catch (IOException e) {
throw new RuntimeException(e);
}
while(status==0){
//表明用户未登录
try {
String line = br.readLine();
String[] arr = line.split("&");
if("login".equals(arr[2])){
//用户正在登录,应当对其账户密码进行验证
String username = arr[0].split("=")[1];
String password = arr[1].split("=")[1];
String loginInfo = username+"="+password;//直接拼接成文件中信息的形式
if(checkLogin(loginInfo)){
//登录成功
//修改当前socket状态码
synchronized (LOCK) {
//修改操作,应当保证共享数据安全
AllUserSocketMap.put(socket,1);
}
status = AllUserSocketMap.get(socket);
//向用户发送登录成功的信息
bw.write("1"); //1表示登录成功
bw.newLine();
bw.flush(); // 刷新缓冲区确保数据发送
//登录成功了,要记录当前socket对象的用户名
User = username;
}
else {
//登录失败
bw.write("2"); //2表示登录失败
bw.newLine();
bw.flush();
}
}
else if("register".equals(arr[2])){
//用户正在注册,应当对其账户密码进行验证
String username = arr[0].split("=")[1];
String password = arr[1].split("=")[1];
if(usernameContains(username)){
//用户名重复
bw.write("2"); //2表示注册失败
bw.newLine();
bw.flush();
}
else {
//注册成功,应该把数据写回文件,并且在集合中添加,这都是对共享数据的操作
synchronized (LOCK) {
//修改操作,应当保证共享数据安全
file_writer.write(username+"="+password);
// 使用系统默认的换行符
file_writer.write(System.lineSeparator());
file_writer.flush();
userInfo.add(username+"="+password);
}
bw.write("1"); //1表示注册成功
bw.newLine();
bw.flush();
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
//用户登录成功了,下面部分是群发消息的逻辑
while (true) {
try {
Message = br.readLine();
//发送逻辑
Set<Map.Entry<Socket,Integer>> AllUserSocketentrySet = AllUserSocketMap.entrySet();
for (Map.Entry<Socket, Integer> socketIntegerEntry : AllUserSocketentrySet) {
if(socketIntegerEntry.getValue()==1){
//如果已经登录
//获取到对应socket对应的流
BufferedWriter SocketBw = AllSocketBufferedWriter.get(socketIntegerEntry.getKey());
synchronized (LOCK) {
try {
SocketBw.write(User+":"+Message);
SocketBw.newLine();
SocketBw.flush();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
//给予注册模块使用,查看用户名是否已经存在
private boolean usernameContains(String username) {
for (String string : userInfo) {
String name = string.split("=")[0];
if(name.equals(username)) return true;
}
return false;
}
//给予登录模块使用,检查账户密码是否完全匹配
private boolean checkLogin(String loginInfo) {
for (String string : userInfo) {
if(string.equals(loginInfo)) return true;
}
return false;
}
}
服务端的基本逻辑是这样的
①先读取本地文件中所有正确用户信息
②当有客户端来连接时,就开启一条线程
③线程中判断当前用户是登录还是注册操作
④登录,校验用户名和密码是否正确
⑤注册,校验用户名是否唯一,校验用户名和密码格式
⑥如果登录成功,开始聊天
⑦如果注册成功,将用户信息写入到本地,开始聊天
1.首先如何判断用户是否登录了?
答:我的做法是这样的,我利用一个双列集合HashMap AllUserSocketMaps来记录每个Socket对象的状态信息,0是未登录,1是登录
2.在编写服务端代码时,我遇到最严重的问题就是共享数据的混乱,但是我通过梳理思路,一步步解决了bug,在此我想记录我梳理的过程:
答:共享数据UserInfo和AllUserSocketMap是通过构造方法传过来的。
User变量是为了记录每个线程对应的用户名,方便群发消息的时候,知道是谁发的,所以User针对每个线程应该独立
Message则是需要被群发的消息,所以每个线程都需要,而且需要同一个,所以这显然是一个共享数据,我设置其为static,并且在对其修改值做了锁操作,保证线程安全。
此外我应该格外关注ServerRunnable中的IO流,这很关键,最简单的file_writer它是面向username_password.txt的输出流,于各个线程中独立存在,它应该没有什么问题。
其次就是两个针对Socket的流,br和bw,这两个流针对每个线程也是独立的,他们都是针对于自己线程的Socket对象
3.如何做到消息群发?
答:想做到消息群发,就要获取每个线程Socket对象对应的BufferedWriter对象,我是这样设计的,我创建了一个static的(各线程之间共享,所以这是一个共享数据) HashMap,键为Socket,值为对应的BufferedWriter,在群发消息时,我通过遍历AllSocketMap,如果状态码为1,则获取其BufferedWriter对消息进行发送。值得注意的是,这样每个线程的BufferedWriter就不仅是只会在本线程中用到了,故我要对BufferedWriter操作时,应当做同步处理。
3.项目心得
项目编码耗时大约10h左右,主要在线程中的排错比较耗费时间,常有思绪打结的情况,不过我通过梳理思路,也是逐一解决了。另外我在编程时写了必要的注释,进行了分模块的编程,使我修改起来更加方便,我还使用了git来进行版本管理,方便代码混乱时做版本穿梭,回溯版本。