Java聊天室课程设计:基于TCP长连接与对象流的多人在线通信与文件传输实现
2026/9/19 4:20:20 网站建设 项目流程

简介:这是面向计算机、互联网相关专业学生的《计算机网络》课程设计报告书,以聊天室为综合实践项目,重点展示基于Java Socket与ServerSocket搭建TCP通信、实现注册登录、多人在线聊天及文件传输的完整设计流程。报告包含题目意义与需求分析、总体模块划分、系统详细设计、流程图以及带注释的程序源代码,适合作为计算机网络课程设计、Java网络编程作业或期末报告的参考范例。资源为1个docx文档,共283KB,打开即可阅读与编辑,便于按需调整格式或补充内容。已有197人浏览学习。借助这份报告,读者可以快速理解聊天室从需求到编码落地的主要思路,掌握Socket通信、对象流传输、多线程处理在线用户等关键环节,也能参考其排版结构撰写自己的课程设计文档。

1. 聊天室要解决的核心问题:TCP长连接下的在线状态维护

聊天室课程设计是计算机网络课的经典实战题,表面上看只是把消息从一端转发到另一端,真正动手后门槛在三处:一是每个用户的 Socket 连接要长时间保持,服务端得同时支撑多路连接;二是传输数据不能是裸字符串,要把消息类型、发送者、目标用户、时间戳打包成可序列化对象;三是用户上下线时在线列表要实时同步,否则消息选不到人。这份课程设计报告书 docx 版本完整实现了注册登录、多人在线和文件传输三个模块,JDK 1.8+ 环境可跑,核心链路是 ServerSocket 长连接加 ObjectOutputStream 对象流。下面把服务端线程模型、Message 协议对象、客户端 Swing 界面、文件传输四块逐一拆开,代码可直接复现,参数边界和踩坑点一并说明。

2. 服务端 ServerSocket 的监听与多线程连接管理

2.1 长连接模型与线程选型

聊天室和 HTTP 请求最大的区别在于连接的生命周期。HTTP 是无状态短连接,每次请求都新建连接,服务器无法主动向客户端推送消息;聊天室恰恰需要服务器把消息主动推给指定用户,所以每个客户端和服务器之间必须维持一条长时间不关闭的 TCP 连接。因此服务端不能采用“收请求、处理、关连接”的模式,而是要在 accept 一个连接后,为该连接创建一个持续运行的读取循环,循环内部阻塞等待下一个 Message 对象到达。

这里的线程模型有两种常见方案。第一种是一连接一线程,来一个客户端就 new Thread 启动一个线程,逻辑直观,适合课程设计里的十人左右场景;第二种是用线程池配合阻塞队列,比如 Executors.newCachedThreadPool(),让空闲线程得到复用,连接频繁建立和断开时更省资源。两者在小型局域网场景下没有明显差异,但线程池在答辩时更好展开。真正要注意的是线程退出时机:如果线程只靠 readObject() 阻塞,服务端关闭时必须主动关闭对应的 Socket,才能让阻塞读抛出 IOException 从而结束线程,否则线程会一直挂着。

下面用一个简单的表格对比连接模型的选择。

线程模型适用场景缺点
一连接一线程用户量小于 100线程创建销毁开销随连接数线性增长
线程池连接频繁,中等并发需要额外处理阻塞读的退出
NIO/Netty上万用户长连接代码复杂度高,不适合课程设计

2.2 服务端入口与连接登记代码

服务端的主流程很短:一个 ServerSocket 监听端口,一个无限循环 accept 新连接。下面的代码是可运行骨架,重点在 ConcurrentHashMap 的选用。

public class ChatServer { // 用户名 -> 客户端通道,保存输出流与 Socket private static ConcurrentHashMap<String, ClientChannel> onlineUsers = new ConcurrentHashMap<>(); public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8888); System.out.println("聊天室服务端启动,端口:8888"); ExecutorService pool = Executors.newCachedThreadPool(); while (true) { Socket socket = serverSocket.accept(); ClientChannel channel = new ClientChannel(socket); pool.execute(channel::readLoop); } } }

这段代码里有两个关键参数。第一个是端口 8888,没有特殊含义,只要不占用系统服务端口即可,客户端必须使用同一端口连接,否则会报 ConnectException;第二个是 ServerSocket 构造方法的 backlog 参数,默认 50,表示等待队列长度,同时并发连接超过这个数时,多余连接会被拒绝;第三个是 accept() 返回的 Socket,数据读写都在这条连接上完成,和 ServerSocket 本身没有关系。

ClientChannel 的 readLoop 是整个服务端的核心。它内部循环读取 Message,按消息类型分派,出现异常就清理连接。

public void readLoop() { try (ObjectInputStream in = new ObjectInputStream(socket.getInputStream())) { while (true) { Message msg = (Message) in.readObject(); switch (msg.getType()) { case 0: // 用户上线,登记到在线表 onlineUsers.put(msg.getName(), this); broadcast(msg); break; case 1: // 聊天消息,按目标转发 forwardToTargets(msg); break; case -1: // 用户主动下线 onlineUsers.remove(msg.getName()); broadcast(msg); return; } } } catch (IOException | ClassNotFoundException e) { onlineUsers.remove(getUsername()); broadcastOffline(getUsername()); } }

这里有两个细节值得说明。第一,登记用户时以 msg.getName() 作为 key,而不是 Socket 对象,这样转发消息时只需根据用户名查表,代码更直观;第二,broadcast 和 forwardToTargets 在并发场景下可能拿到用户刚关闭的连接,因此发送前要判断 isClosed(),发送时捕获异常并连带删掉该用户记录,避免僵尸连接占用内存。

2.3 在线列表同步的触发机制

在线列表的同步依赖两类服务端广播:用户上线时广播 type=0,用户下线时广播 type=-1。客户端收到后对 JList 的数据模型做 addElement 和 removeElement。一个容易被忽略的边界是“重复登录”:同一个账号在两个地方登录,服务端如果不踢掉旧连接,在线列表会出现两个相同的用户名,消息转发也会混乱。常见做法是上线登记时检查 onlineUsers 是否已有同名用户,若有则先给旧连接发送一条强制下线消息,再关闭旧连接。这个机制在课程设计中未必会触发,但面试官通常会追问,建议在代码里补上。

3. Message 协议对象设计:消息类型字段与对象序列化传输

3.1 为什么直接传对象而不是拼字符串

很多同学实现聊天室时习惯用字符串做协议,比如"login|user|123456",服务端再按分隔符拆解。这种方式在只有一种消息时没问题,但一旦消息类型多了,解析代码会膨胀成一大串 if-else,字段顺序稍有改动就会带来隐蔽的解析错误。这份报告采用的做法是定义 Message 类,通过 ObjectOutputStream 把整个对象写入 Socket 输出流,接收方用 ObjectInputStream.readObject() 直接得到反序列化后的对象,读字段、调 getter 都安全可靠。

这种方案的先决条件是通信双方必须同为 Java 程序,因为 Java 原生序列化格式是 JVM 私有的,换成其他语言客户端就无法解析。课程设计里客户端和服务端都是 Java 实现,这个前提成立,所以用对象流是最经济的选择。如果以后要扩展 Web 端,再改成 JSON 或 Protobuf 也不迟。

3.2 Message 的核心字段设计

协议设计的核心是把字段定好。下表列出了 Message 类中直接影响功能逻辑的字段及其含义。

字段类型作用
typeint消息类型,决定服务端和客户端的处理分支
nameString发送者的用户名
timerString发送时间,用于聊天记录展示
infoString聊天内容或文件请求说明
fileNameString文件名,文件传输请求时使用
sizeint文件大小,接收方用来校验完整性
clientsHashSet目标用户集合,实现单聊和群聊共用一条协议

type 字段的取值约定是整套协议的基础。0 表示用户上线登记,1 表示聊天消息,2 表示文件传输请求,-1 表示用户下线。clients 字段用 HashSet 而不是单个 String,目的是同时支持单人聊天和多人群聊:用户在 JList 里勾选几个人,就把这些名字放进集合,服务端遍历集合逐个转发,不需要为单聊单独设计一套消息。

3.3 服务端按目标转发与客户端接收线程

服务端收到 type=1 的消息后,遍历 clients 集合里的每个目标用户名,在 onlineUsers 里找到对应的 ClientChannel,把 Message 原样 writeObject 出去。

// 服务端转发聊天消息到目标用户 private void forwardToTargets(Message msg) { for (String target : msg.getClients()) { ClientChannel channel = onlineUsers.get(target); if (channel != null && channel.isActive()) { channel.send(msg); } } }

这里要注意消息对象会被多个输出流共享,但并发写同一个 Message 不会产生数据竞争,因为 writeObject 只是把对象内容序列化成字节流,不需要修改对象本身,所以不需要拷贝副本。转发失败时把该连接移除即可,不影响其他目标的发送。

客户端这边,接收线程的设计直接关系到聊天窗口是否卡顿。如果把 in.readObject() 放在界面初始化线程里,读取会一直阻塞等待服务端消息,在此期间鼠标点击、输入框事件全部得不到响应,窗口拖动都困难。正确的做法是单独维护一个接收线程,读取到消息后交给 SwingUtilities.invokeLater 更新界面。

// 客户端的接收线程骨架 class ClientReceiveThread extends Thread { private ObjectInputStream in; public void run() { while (true) { try { Message msg = (Message) in.readObject(); // 回到事件线程再更新组件 SwingUtilities.invokeLater(() -> handleMessage(msg)); } catch (IOException e) { break; // 服务端断开,退出接收线程 } } } private void handleMessage(Message msg) { if (msg.getType() == 0) { refreshOnlineList(msg); // 刷新右上角用户列表 } else if (msg.getType() == 1) { chatArea.append(format(msg)); // 把聊天内容追加上屏 } } }

handleMessage 根据 type 分派到不同逻辑,setText 和 append 都放在 invokeLater 内部,避免非 UI 线程直接操作 Swing 组件。这里的边界是:如果一个客户端在短时间内收到大量消息,invokeLater 任务会全部排队执行,可能出现消息顺序错乱,解决办法是在消息里带上时间戳,显示时按 timer 排序,课程设计场景下无需处理,知道有这样一个边界即可。

4. 客户端实现:注册登录、Swing 窗口布局与在线列表刷新

4.1 基于 user.txt 的注册与登录校验

用户数据没有用数据库,而是用 File 直接读写 user.txt。注册时逐行比对用户名是否存在,不存在就追加写入;登录时读取文件并验证用户名密码是否匹配。下面这段代码是注册方法的实现。

// 注册:校验用户名是否已存在,不存在则追加新用户 public boolean register(String username, String password) throws IOException { File file = new File("user.txt"); if (file.exists()) { BufferedReader reader = new BufferedReader(new FileReader(file)); String line; while ((line = reader.readLine()) != null) { String[] parts = line.trim().split(" "); // 按空格拆分行 if (parts.length > 0 && parts[0].equals(username)) { return false; // 用户名已存在,注册失败 } } reader.close(); } try (FileWriter writer = new FileWriter(file, true)) { writer.write(username + " " + password + "\n"); } return true; }

这段代码有几个边界情况需要注意。第一,文件里每一行是“用户名 密码”的空格分隔格式,注册时做 split(" ") 拆字段;第二,读写之间没有加锁,两个客户端同时注册同一用户名时可能出现数据交错,课程设计里用 synchronized 修饰该方法即可避免并发覆盖;第三,user.txt 的路径是相对路径,运行时的工作目录和文件所在目录必须一致,否则会抛 FileNotFoundException。比较稳妥的做法是把路径定义为常量,并通过判断 file.exists() 决定是否创建文件。

4.2 Swing 聊天窗口的组件布局与事件绑定

客户端聊天界面主要分四个区域,布局用的是绝对定位 setBounds,直接在代码里指定每个组件的坐标和尺寸。面板结构如下表所示。

界面区域组件功能
聊天记录区scrollPane + JTextArea展示历史消息,设为不可编辑
消息输入区scrollPane + JTextArea输入待发送的文本
用户列表区JList + DefaultListModel展示在线用户,支持多选
文件进度区JProgressBar + JLabel显示文件传输进度和状态

发送按钮的事件处理是客户端最核心的逻辑,要做三重校验:是否选择了聊天对象、是否发给自己、是否为空消息。完整的处理代码如下。

btnSend.addActionListener(e -> { List<String> selected = userList.getSelectedValuesList(); String text = inputArea.getText(); if (selected.isEmpty()) { JOptionPane.showMessageDialog(frame, "请选择聊天对象"); return; } if (selected.contains(name + "(我)")) { JOptionPane.showMessageDialog(frame, "不能向自己发送消息"); return; } if (text.trim().isEmpty()) { JOptionPane.showMessageDialog(frame, "不能发送空信息"); return; } // 构造消息对象并发送 Message msg = new Message(); msg.setType(1); msg.setName(name); msg.setTimer(getCurrentTime()); msg.setInfo(text); msg.setClients(new HashSet<>(selected)); sendMessage(msg); chatArea.append(getCurrentTime() + " 我对 " + selected + " 说:\n" + text + "\n"); inputArea.setText(""); inputArea.requestFocus(); });

三个校验的顺序不是随意的。必须先检查 selected 是否为空,因为如果用户没选人,getSelectedValuesList() 返回空列表,后面的 contains 判断同样会失效;先检查自己再检查空消息,可以避免把“没选人但输入了文字”的情况误判成“自己发给自己”。构造消息时,clients 直接 new HashSet<>(selected) 把 List 转成 Set,服务端遍历时不会因为目标重复而重复转发。发送成功后清空输入框并把焦点还回去,方便连续输入多条消息。

4.3 在线列表刷新与重复上线处理

在线列表的刷新逻辑,集中在客户端收到 type=0 或 type=-1 消息时的处理函数中。核心要点是复用同一个 DefaultListModel,而不是每次重新 new 一个 JList。如果反复替换列表组件,窗口布局会被打乱,事件监听也会随之失效。

// 客户端的在线列表刷新 private void refreshOnlineList(Message msg) { SwingUtilities.invokeLater(() -> { String username = msg.getName(); if (msg.getType() == 0 && !model.contains(username)) { model.addElement(username); // 上线时追加 } else if (msg.getType() == -1) { model.removeElement(username); // 下线时移除 } }); }

这里有一个时序细节。登录成功的客户端会先发送 type=0 给服务端,服务端再广播给其他客户端。也就是说,用户 B 在列表里看到用户 A,存在一个以网络延迟为上限的时间差。如果 B 在这个窗口期内就给 A 发消息,服务端实际上已经知道 A 的连接,转发没有问题,只是 B 的列表里暂时看不到 A 的名字。这个现象在本地跑几乎感知不到,但要做好这个心理预期,不要把列表刷新当作严格实时。

5. 文件传输的 P2P 直连方案与排障

5.1 文件传输为什么不能直接走服务端中转

文件传输是聊天室里看起来最直观但坑最多的功能。最朴素的想法是把文件内容当普通聊天消息发给服务端,再由服务端转发给目标,但仔细想就会发现这种方案有明显问题:大文件会占用服务端的大量带宽和内存,同一时刻如果有多个用户在传文件,服务端会率先成为瓶颈,聊天消息也会因为共享 Socket 而产生排队延迟。这份报告采用的方案是让服务端只做协调:发送端和接收端通过聊天信道交换临时端口,之后双方建立一条直连 Socket,文件内容完全不经过服务端。

整个文件传输流程分成五个阶段。发送端双击在线列表中的用户,弹出文件选择对话框;选中文件后构造 type=2 的 Message,附带文件名和文件大小,发送给服务端;服务端将该消息转发给接收端;接收端弹窗询问用户是否接收,确认后启动一个临时 ServerSocket 并绑定随机端口,将端口号通过聊天通道告知发送端;发送端连接接收端的 IP 与端口,用 DataInputStream / DataOutputStream 双向读写文件内容。控制信道和数据信道彼此独立,传输结束后数据信道自动关闭。

5.2 接收端与发送端的实现细节

接收端接文件的核心代码如下,注意临时 ServerSocket 使用端口 0 表示系统自动分配,避免手动指定造成端口冲突。

// 接收端:准备接收文件 ServerSocket fileServer = new ServerSocket(0); int filePort = fileServer.getLocalPort(); // 通过原聊天连接把端口发送给发送端 Message portMsg = new Message(); portMsg.setType(3); // 约定为端口协商消息 portMsg.setInfo(String.valueOf(filePort)); sendMessage(portMsg); Socket fileSocket = fileServer.accept(); DataInputStream dis = new DataInputStream(fileSocket.getInputStream()); String fileName = dis.readUTF(); long fileSize = dis.readLong(); FileOutputStream fos = new FileOutputStream(savePath + fileName); long received = 0; byte[] buffer = new byte[4096]; int len; while (received < fileSize && (len = dis.read(buffer)) > 0) { fos.write(buffer, 0, len); received += len; // 更新进度条:已接收字节数 / 总字节数 int percent = (int) (received * 100 / fileSize); SwingUtilities.invokeLater(() -> progressBar.setValue(percent)); } dis.close(); fos.close(); fileServer.close();

这里最关键的循环终止条件是received < fileSize && len > 0。只用read() != -1判断会有风险:发送端可能在传输过程中临时停顿,read() 会阻塞而不会立即返回,此时尚未读完整个文件,不能提前结束循环。以文件大小为准来判断读取是否结束,接收方才能确保收到完整字节。进度更新通过 invokeLater 收口到事件线程,否则直接从子线程设置 JProgressBar 的值,会触发 Swing 的绘制问题。

发送端方向的代码相对简单:拿到接收端返回的端口后,用 Socket 和 DataOutputStream 按顺序写入文件名、文件大小和文件内容。写入完成后要关闭输出流,让接收端的 read 循环能在读完文件后正常结束。

// 发送端:建立直连并推送文件 Socket socket = new Socket(receiverIp, filePort); DataOutputStream dos = new DataOutputStream(socket.getOutputStream()); dos.writeUTF(file.getName()); dos.writeLong(file.length()); FileInputStream fis = new FileInputStream(file); byte[] buffer = new byte[4096]; int read; while ((read = fis.read(buffer)) > 0) { dos.write(buffer, 0, read); } fis.close(); dos.close(); socket.close();

注意:发送端写入文件名、长度和内容时,接收端的读取顺序必须严格对应,先 readUTF 拿文件名,再 readLong 拿文件大小,最后循环读文件内容。如果顺序错,接收到的数据就会错位,文件内容解析不出来。

5.3 传输过程中的异常边界与验证方法

文件传输模块的异常边界主要集中在窗口关闭和端口释放上。报告里用了两个布尔标志位 isSendFile 和 isReceiveFile,在传输期间把聊天窗口的关闭按钮禁用掉,窗口监听器的 windowClosing 事件里判断这两个标志,如果传输还在进行,就弹窗提示“正在传输文件中,您不能离开”,防止 Socket 泄漏。

服务端日志是这个模块最有效的排查工具。在 forwardToTargets 里打印每次消息的 type、name、target,就能立刻看出文件请求是否被正确路由。下面把三个常见问题列成对照表,便于排障时快速定位。

现象可能原因处理方式
文件传输进度一直为 0接收端的临时端口没有送达发送端检查 type=3 端口协商消息是否走的是聊天连接,确认端口号以字符串传递时无误
文件传完大小不对循环终止条件只判断了 read() == -1改成以 fileSize 为准判断,接收端读完指定字节数后结束
关闭聊天窗口后端口仍被占用临时 ServerSocket 没有在 finally 中关闭在 finally 中依次关闭 DataInputStream、FileOutputStream、ServerSocket

一个快速的验证技巧:在本地同时启动两个客户端,用file.length()对比传输前后的文件大小,同时关注服务端日志中是否出现 type=2 和 type=3 两条记录。如果两条都有,但传输失败,问题几乎都出在接收端的临时服务器这一侧,优先检查接收端端口是否被防火墙拦截,以及接收端返回的 IP 是否可被发送端访问。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询