一、前置技术
-
项目介绍:
- 项目为局域网沟通软件,类似内网通,核心功能包括昵称输入、聊天界面展示在线人数(实时更新)、群聊,也可扩展私聊、登录注册、聊天记录存储等功能,结尾附GitHub链接。
-
项目涉及技术:
- 包括GUI界面编程、网络通信、面向对象编程,以及字符串处理、时间获取等相关API。
-
时间获取方案之JDK8之前的Date API:
- 通过创建Date对象获取此刻时间,但其格式为美式且不直观,需用SimpleDateFormat进行格式化。
// 创建Date对象获取时间 Date date = new Date(); // 创建SimpleDateFormat对象指定格式 SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); // 格式化时间 String formattedDate = sdf.format(date);
-
时间获取方案之JDK8的LocalDateTime:
- LocalDate获取年月日,LocalTime获取时分秒,LocalDateTime获取年月日时分秒,通过now()方法获取对象,支持纳秒级精度,且为不可变对象,线程安全,格式化需用DateTimeFormatter。
// 获取LocalDateTime对象 LocalDateTime now = LocalDateTime.now(); // 创建DateTimeFormatter指定格式 DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); // 格式化时间 String formattedDateTime = now.format(dtf);
-
字符串高效操作之StringBuilder:
- String因是不可变对象,大量拼接时性能差,而StringBuilder是可变对象,基于数组容器操作,通过append()方法高效拼接,支持链式编程,最后需用toString()转为String类型。
// 创建StringBuilder对象 StringBuilder sb = new StringBuilder(); // 拼接字符串 sb.append("张三").append("李四").append("王五"); // 转为String类型 String result = sb.toString();
-
解决浮点型运算失真的BigDecimal:
- 用于解决小数运算结果失真问题,需通过字符串构造器或valueOf()方法(内部使用字符串构造器)创建对象,提供加减乘除等方法,除法时若结果除不尽需指定保留位数和舍入模式(如四舍五入)。
// 创建BigDecimal对象 BigDecimal a = new BigDecimal("0.1"); BigDecimal b = BigDecimal.valueOf(0.2); // 加法运算 BigDecimal sum = a.add(b); // 除法运算(保留2位小数,四舍五入) BigDecimal divide = a.divide(b, 2, RoundingMode.HALF_UP); // 转为double类型 double result = sum.doubleValue();
二、AI获取客户端界面:
- 项目需求分析:项目为局域网类沟通软件开发,启动界面只需输入聊天昵称,进入后显示在线人数,具备群聊功能,实时更新在线人数,先实现核心群聊功能,后续可扩展私聊等功能。
- 技术选型:涉及GUI编程技术(swing)、网络编程、面向对象设计以及Java提供的常用API。
- 项目步骤规划:第一步创建名为“it-chat”的模块;第二步获取系统所需界面(登录界面和聊天界面);第三步定义App启动类,创建并展示进入界面对象。
- 获取登录界面:通过通义千问大模型生成局域网聊天进入界面代码,包含昵称输入框、进入和取消按钮,将代码复制到IDEA中,修改类名、处理乱码(将字体改为楷体),并测试界面能否启动。
- 获取聊天界面:同样通过AI生成群聊界面代码,包含在线人数展示框、消息展示框、消息发送框和发送按钮,复制到IDEA后修改类名、调整窗口宽度、添加关闭窗口退出程序代码等,测试界面启动情况。
- 定义启动类:在SRC下新建App类,在main方法中创建聊天进入界面对象并展示,完成界面准备工作,下面将从进入界面开始开发,串起所有功能。
三、架构分析
-
系统整体架构及开发逻辑
- 客户端与服务端通过管道连接实现通信,客户端发送的信息(如登录昵称、群聊消息)需经服务端转发给其他在线客户端。
- 开发顺序:先分析系统整体架构,开发服务端,再完善客户端功能。
-
服务端核心功能
- 接收客户端的管道连接,支持多客户端同时接入。
- 接收客户端发送的登录消息(含昵称)和群聊消息。
- 存储所有在线客户端的socket管道,用于消息转发。
- 收到登录消息后,更新所有客户端的在线人数列表。
- 收到群聊消息后,将消息转发给所有在线客户端。
-
服务端开发步骤及关键代码
- 创建服务端项目,例如命名为“it-chat-server”。
- 创建服务端启动类,负责启动服务端并等待客户端连接
// 服务端启动类 public class Server {public static void main(String[] args) {try {// 注册端口,端口从常量类获取ServerSocket serverSocket = new ServerSocket(Constant.PORT);System.out.println("服务端启动成功,等待客户端连接...");while (true) {// 等待客户端连接,获取管道Socket socket = serverSocket.accept();System.out.println("一个客户端连接成功!");// 将管道交给独立线程处理new ServerReaderThread(socket).start();// 将管道暂存(后续需结合登录消息存储昵称)// 此处仅为示意,实际需在接收登录消息后完善onlineSockets.put(socket, "未知用户");}} catch (IOException e) {e.printStackTrace();}}// 定义Map集合存储在线客户端管道及对应昵称public static Map<Socket, String> onlineSockets = new HashMap<>(); }
- 定义常量类存储端口信息
// 常量类 public class Constant {public static final int PORT = 6666; // 服务端端口 }
- 创建线程类处理客户端管道通信
// 线程类处理客户端消息 public class ServerReaderThread extends Thread {private Socket socket;public ServerReaderThread(Socket socket) {this.socket = socket;}@Overridepublic void run() {// 后续将实现接收登录消息、群聊消息等逻辑System.out.println("线程开始处理客户端:" + socket);} }
四、服务端(在线人数模块)
-
内容承接:已完成服务端基础开发,包括创建项目、接收客户端Socket管道并交由独立线程处理,同时准备了
Map
集合(onlineSockets
)用于存储在线客户端的Socket
及对应昵称(Socket
为键,昵称为值)。 -
服务端接收消息的类型及处理思路
- 消息类型:登录消息、群聊消息、私聊消息、图片消息等。
- 协议设计:客户端需先发送消息类型编号(如1代表登录、2代表群聊、3代表私聊),服务端通过编号区分处理。
- 核心逻辑:服务端从
Socket
输入流读取类型编号,通过switch
分支判断并执行对应逻辑。
// 服务端接收消息类型的核心逻辑 DataInputStream dis = new DataInputStream(socket.getInputStream()); int type = dis.readInt(); // 读取消息类型编号 switch (type) {case 1: // 处理登录消息break;case 2: // 处理群聊消息break;// 其他消息类型... }
-
服务端接收登录消息的处理
- 读取昵称:当消息类型为1时,通过输入流读取客户端发送的昵称。
- 存储在线信息:将当前
Socket
和昵称存入onlineSockets
集合,标记客户端上线。
// 处理登录消息 String nickname = dis.readUTF(); // 读取昵称 Server.onlineSockets.put(socket, nickname); // 存入在线集合
-
更新全部客户端在线人数列表的方法
- 方法功能:向所有在线客户端推送最新的在线用户列表。
- 实现步骤:
- 获取所有在线用户的昵称(
onlineSockets
的values
)。 - 遍历所有在线
Socket
,通过输出流向每个客户端发送更新消息:- 先发送消息类型(1代表在线列表更新)。
- 发送用户数量,再逐个发送用户名。
- 获取所有在线用户的昵称(
// 更新在线人数列表的方法 private void updateClientOnlineList() {// 获取所有在线用户名Collection<String> allNicknames = Server.onlineSockets.values();// 遍历所有在线Socket管道for (Socket clientSocket : Server.onlineSockets.keySet()) {try {DataOutputStream dos = new DataOutputStream(clientSocket.getOutputStream());dos.writeInt(1); // 消息类型:在线列表更新dos.writeInt(allNicknames.size()); // 发送用户数量for (String nickname : allNicknames) {dos.writeUTF(nickname); // 逐个发送用户名}dos.flush(); // 刷新数据} catch (IOException e) {e.printStackTrace();}} }
-
客户端下线的处理
- 当客户端断开连接(抛出异常),服务端需将其
Socket
从onlineSockets
中移除,并触发在线列表更新。
// 客户端下线时移除在线记录 Server.onlineSockets.remove(socket); updateClientOnlineList(); // 重新更新在线列表
- 当客户端断开连接(抛出异常),服务端需将其
-
整体流程
- 服务端接收消息类型编号,判断为登录消息(1)。
- 读取昵称并存储到
onlineSockets
。 - 调用
updateClientOnlineList()
方法,向所有客户端推送更新后的在线列表。 - 客户端接收消息后,更新本地展示的在线用户列表。
五、服务端(聊天信息转发模块)
-
解决下线操作的bug
下线时需更新所有客户端的在线人数列表,需重新调用更新在线人数的方法。此时map
集合中已移除下线客户端信息,遍历剩余socket
推送更新后的列表(消息类型为1号)。// 下线时调用更新在线人数列表的方法 updateOnlineUserList();// 更新在线人数列表的方法逻辑 private void updateOnlineUserList() {// 获取当前在线用户列表(已移除下线用户)Collection<String> usernames = onlineSockets.values();// 遍历所有在线socket,推送更新后的列表for (Socket socket : onlineSockets.keySet()) {DataOutputStream dos = new DataOutputStream(socket.getOutputStream());dos.writeInt(1); // 1号消息:更新在线人数dos.writeUTF(String.join(",", usernames));dos.flush();} }
-
接收群聊消息并转发的整体逻辑
服务端线程接收2号类型的群聊消息后,需转发给所有在线socket
(包括发送者自身),确保消息在所有客户端面板展示。 -
读取客户端发送的文本消息
从数据输入流中读取客户端的文本消息:DataInputStream dis = new DataInputStream(socket.getInputStream()); String message = dis.readUTF(); // 读取客户端发送的群聊内容
-
拼装消息内容
- 获取发送者昵称:通过当前
socket
从map
集合中获取对应的用户名String senderName = Server.onlineSockets.get(socket); // onlineSockets为<Socket, String>类型的map
- 获取并格式化时间:使用
LocalDateTime
和DateTimeFormatter
处理时间LocalDateTime now = LocalDateTime.now(); DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); String timeStr = dtf.format(now); // 格式化时间为字符串
- 拼接消息:使用
StringBuilder
组合昵称、时间和消息内容,添加格式符优化展示StringBuilder sb = new StringBuilder(); sb.append(senderName) // 发送者昵称.append(" ") .append(timeStr) // 发送时间.append("\r\n") // 换行.append(message) // 消息内容.append("\r\n"); // 消息间换行 String fullMessage = sb.toString(); // 转换为字符串
- 获取发送者昵称:通过当前
-
转发消息给所有在线客户端
遍历所有在线socket
,发送拼装好的消息(消息类型为2号):private void sendMsgToAll(String fullMessage) {for (Socket socket : Server.onlineSockets.keySet()) {try {DataOutputStream dos = new DataOutputStream(socket.getOutputStream());dos.writeInt(2); // 2号消息:群聊消息dos.writeUTF(fullMessage);dos.flush(); // 刷新输出流} catch (IOException e) {e.printStackTrace();}} }
-
添加死循环处理多次消息
线程需通过死循环持续接收消息,避免只处理一次后终止:while (true) { // 死循环:持续监听客户端消息int msgType = dis.readInt(); // 读取消息类型if (msgType == 2) { // 处理2号群聊消息String message = dis.readUTF();sendMsgToAll(buildFullMessage(socket, message)); // 拼装并转发消息}// 可扩展处理其他消息类型(如3号私聊消息) }
六、客户端(登录开发)
-
开发客户端的准备与思路:服务端模块已开发完成,接下来需开发客户端,客户端初始仅有界面,需与服务端对接。开发从登录界面开始,遵循用户思维和线性思维,即按照用户操作流程推进。
-
登录界面的初始操作
- 给登录界面的“进入”按钮(后改为“登录”按钮)绑定点击事件监听器,代码使用匿名内部类或Lambda表达式简化实现:
entryButton.addActionListener(e -> {// 获取昵称String nickname = nicknameInput.getText();nicknameInput.setText(""); // 清空输入框if (nickname != null && !nickname.isEmpty()) {try {login(nickname); // 调用登录方法dispose(); // 关闭登录窗口} catch (IOException ex) {ex.printStackTrace();}} });
- 点击按钮后,从输入框获取昵称,判断非空后执行登录逻辑,并关闭登录窗口。
-
登录方法的创建与完善
- 将登录相关代码独立为
login
方法,避免代码臃肿:
private void login(String nickname) throws IOException {// 连接服务端socket = new Socket(Constant.SERVER_IP, Constant.SERVER_PORT);// 发送登录信息DataOutputStream dos = new DataOutputStream(socket.getOutputStream());dos.writeInt(1); // 消息类型为登录(1代表登录)dos.writeUTF(nickname); // 发送昵称dos.flush(); // 刷新缓冲区// 登录成功后进入聊天界面new ClientChatFrame(nickname, socket); }
- 服务端的IP和端口在常量类
Constant
中定义,方便后续修改:
public class Constant {public static final String SERVER_IP = "127.0.0.1"; // 服务器IPpublic static final int SERVER_PORT = 666; // 服务器端口,需与服务端保持一致 }
- 将登录相关代码独立为
-
发送登录消息给服务端:连接成功后,通过
DataOutputStream
向服务端发送消息类型(1代表登录)和昵称,且不能关闭流和管道,否则会中断后续通信。 -
进入聊天界面的准备:登录成功后,启动聊天界面(
ClientChatFrame
类)。需将昵称和Socket
管道传给聊天界面,因此在登录界面将Socket
定义为全局变量:private Socket socket; // 登录界面的全局Socket变量,用于保存与服务端的连接
-
聊天界面的初始化
- 聊天界面通过有参构造器接收昵称和
Socket
管道,并调用无参构造器初始化界面:
public class ClientChatFrame extends JFrame {private String nickname;private Socket socket;public ClientChatFrame(String nickname, Socket socket) {this(); // 调用无参构造器初始化界面this.nickname = nickname;this.socket = socket;setTitle(nickname + "的聊天窗口"); // 在窗口标题展示昵称}public ClientChatFrame() {// 初始化界面组件的代码initComponents();} }
- 聊天界面将昵称展示在窗口标题上,方便用户识别当前登录账号,同时保存
Socket
管道用于后续接收在线人数列表、发送和接收消息等操作。
- 聊天界面通过有参构造器接收昵称和
七、客户端(在线人数展示)
- 回顾登录界面跳转逻辑:登录成功后,将昵称和Socket管道传递给聊天界面,并销毁登录窗口,避免资源占用。关键代码逻辑如下(示意):
// 登录成功后跳转至聊天界面
ChatFrame chatFrame = new ChatFrame(nickname, socket);
chatFrame.setVisible(true);
this.dispose(); // 销毁当前登录窗口
-
明确客户端核心任务:登录后需实时读取服务端发送的两类消息:
- 在线人数更新消息(类型1)
- 群聊消息(类型2)
-
采用多线程处理消息收发:
- 独立线程(
ClientReaderThread
)负责持续接收服务端消息,避免阻塞主线程 - 主线程负责处理用户交互(如发送消息)
- 独立线程(
-
创建客户端消息读取线程类:
public class ClientReaderThread extends Thread {private Socket socket;private ChatFrame chatFrame; // 持有聊天界面对象public ClientReaderThread(Socket socket, ChatFrame chatFrame) {this.socket = socket;this.chatFrame = chatFrame;}@Overridepublic void run() {try (DataInputStream dis = new DataInputStream(socket.getInputStream())) {while (true) {int type = dis.readInt(); // 读取消息类型if (type == 1) {// 处理在线人数更新updateClientOnlineUserList(dis);} else if (type == 2) {// 处理群聊消息(下节课实现)}}} catch (IOException e) {e.printStackTrace();}}
}
- 在线人数更新方法实现:
private void updateClientOnlineUserList(DataInputStream dis) throws IOException {int count = dis.readInt(); // 读取在线用户数量String[] onlineUsers = new String[count];// 循环读取所有在线用户名for (int i = 0; i < count; i++) {onlineUsers[i] = dis.readUTF();}// 调用聊天界面方法更新UIchatFrame.updateOnlineUsers(onlineUsers);
}
- 聊天界面更新UI组件:
public class ChatFrame extends JFrame {private JList<String> onlineUserList; // 展示在线用户的列表组件// 更新在线用户列表public void updateOnlineUsers(String[] users) {DefaultListModel<String> model = new DefaultListModel<>();for (String user : users) {model.addElement(user);}onlineUserList.setModel(model);}
}
- 线程启动与数据传递:在聊天界面初始化时启动读取线程,并传递Socket和界面对象:
// 聊天界面构造方法中启动读取线程
public ChatFrame(String nickname, Socket socket) {this.nickname = nickname;this.socket = socket;// 启动消息读取线程new ClientReaderThread(socket, this).start();
}
- 功能测试验证:
- 启动服务端后,多客户端登录测试在线人数同步展示
- 关闭任一客户端,验证其他客户端在线列表实时移除该用户
八、客户端(群聊功能)
-
接收群聊消息逻辑
- 接收消息类型为2的群聊消息,通过输入流读取服务端发送的UTF格式消息(包含发送者、时间等信息)。
- 将消息展示到界面面板,核心代码如下:
// 读取群聊消息 String message = dis.readUTF(); // 将消息更新到窗口 win.setMessageToWindow(message);
- 在窗口类中实现
setMessageToWindow
方法,将消息追加到展示区域:
public void setMessageToWindow(String message) {msgArea.append(message); }
-
发送群聊消息功能
- 为发送按钮绑定点击事件,获取输入框内容并清空,通过输出流向服务端发送消息。
- 先发送消息类型2,再发送具体的群聊内容,核心代码如下:
// 为发送按钮绑定点击事件 sendButton.addActionListener(e -> {String message = inputField.getText();inputField.setText(""); // 清空输入框sendMessageToServer(message); });// 发送消息到服务端 private void sendMessageToServer(String message) {try (DataOutputStream dos = new DataOutputStream(socket.getOutputStream())) {dos.writeInt(2); // 发送群聊消息类型dos.writeUTF(message); // 发送消息内容dos.flush();} catch (IOException e) {e.printStackTrace();} }
-
功能测试
- 启动服务端和多个客户端,测试不同用户登录后发送消息的接收情况。
- 例如,用户“张三”发送“我好慌哦”,用户“王麻子”发送“麻子你在干啥”,验证所有客户端能否正常接收消息。
- 测试中发现换行显示存在小问题,但不影响核心功能使用。
-
多人测试与优化方向
- 修改客户端连接的IP地址(如
192.168.25.70
),连接到同一服务端进行多人测试。 - 可优化的方向包括:消息自动滚动到底部、完善换行显示、支持发送图片、美化界面等。
- 修改客户端连接的IP地址(如
GitHub:https://github.com/Andy123211/chat-system/tree/master