
简介这是面向计算机、互联网相关专业学生的《计算机网络》课程设计报告书以聊天室为综合实践项目重点展示基于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 ConcurrentHashMapString, 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 在线列表同步的触发机制在线列表的同步依赖两类服务端广播用户上线时广播 type0用户下线时广播 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 服务端按目标转发与客户端接收线程服务端收到 type1 的消息后遍历 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 - { ListString 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 在线列表刷新与重复上线处理在线列表的刷新逻辑集中在客户端收到 type0 或 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); // 下线时移除 } }); }这里有一个时序细节。登录成功的客户端会先发送 type0 给服务端服务端再广播给其他客户端。也就是说用户 B 在列表里看到用户 A存在一个以网络延迟为上限的时间差。如果 B 在这个窗口期内就给 A 发消息服务端实际上已经知道 A 的连接转发没有问题只是 B 的列表里暂时看不到 A 的名字。这个现象在本地跑几乎感知不到但要做好这个心理预期不要把列表刷新当作严格实时。5. 文件传输的 P2P 直连方案与排障5.1 文件传输为什么不能直接走服务端中转文件传输是聊天室里看起来最直观但坑最多的功能。最朴素的想法是把文件内容当普通聊天消息发给服务端再由服务端转发给目标但仔细想就会发现这种方案有明显问题大文件会占用服务端的大量带宽和内存同一时刻如果有多个用户在传文件服务端会率先成为瓶颈聊天消息也会因为共享 Socket 而产生排队延迟。这份报告采用的方案是让服务端只做协调发送端和接收端通过聊天信道交换临时端口之后双方建立一条直连 Socket文件内容完全不经过服务端。整个文件传输流程分成五个阶段。发送端双击在线列表中的用户弹出文件选择对话框选中文件后构造 type2 的 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接收端的临时端口没有送达发送端检查 type3 端口协商消息是否走的是聊天连接确认端口号以字符串传递时无误文件传完大小不对循环终止条件只判断了 read() -1改成以 fileSize 为准判断接收端读完指定字节数后结束关闭聊天窗口后端口仍被占用临时 ServerSocket 没有在 finally 中关闭在 finally 中依次关闭 DataInputStream、FileOutputStream、ServerSocket一个快速的验证技巧在本地同时启动两个客户端用file.length()对比传输前后的文件大小同时关注服务端日志中是否出现 type2 和 type3 两条记录。如果两条都有但传输失败问题几乎都出在接收端的临时服务器这一侧优先检查接收端端口是否被防火墙拦截以及接收端返回的 IP 是否可被发送端访问。本文还有配套的精品资源点击获取