简介:本资源是一份面向计算机网络课程设计实践的完整技术文档,适用于高校软件工程、网络工程等专业本科生开展P2P局域网通信项目开发。文档系统阐述了基于C#与Visual Studio 2010实现的多点聊天程序的设计思路与技术细节,覆盖需求分析、总体架构、模块划分(用户/聊天/文件传输/好友管理)、四层软件模型及Socket编程实践,特别强化TCP/UDP协议应用与Winsock API调用说明。压缩包为单个161KB的Word文档(.doc),内容含课程设计说明书全文、摘要、中英文关键词、详细目录及第1–5章完整内容,结构规范,适合作为课程设计报告范本或P2P通信原理学习参考。目前已有559人学习下载,可直接用于答辩材料准备、代码实现对照与网络编程知识点梳理。
1. 这不是QQ精简版,而是一个能让你亲手摸透TCP连接生命周期的局域网聊天程序
你有没有试过:在宿舍连着同一台路由器,三台电脑开三个窗口,点开就聊,不装任何第三方软件,消息秒达、在线列表实时刷新、管理员能踢人改昵称、断网重连后聊天记录还在——这个课设程序,就是用纯Java Socket + Swing写出来的“最小可行聊天系统”。它不走P2P直连(标题里“P2P”是课程要求的表述偏差,实际是典型的C/S架构),而是靠一个中心服务器中转所有消息,所有逻辑都在Server.java和Client.java两个文件里跑通。它没用Spring Boot、没接Redis、没上Docker,但恰恰因为“简陋”,你能一眼看清:Socket怎么accept、线程怎么管理客户端、消息怎么解析分发、哈希表怎么映射IP与昵称、Swing事件怎么触发网络IO。适合刚学完《计算机网络》前六章、正卡在“TCP三次握手到底在代码里哪一行体现”的同学;也适合想快速验证自己网络编程手感的老手——编译即跑,不依赖Maven中央仓库,JDK6就能启动。这不是玩具,是当年某高校软件工程专业07级学生交上去、拿了高分、至今还能在Win10+JDK8下无修改运行的真实课设。
2. 架构拆解:一个服务器如何同时扛住10个客户端的并发连接与消息广播
2.1 系统本质是“单点转发式C/S”,不是P2P直连
标题里写的“P2P”容易引发误解。从Server.java的run()方法看,服务器用server.accept()阻塞等待连接,每接入一个客户端,就创建一个Service对象(虽未贴出完整代码,但从Server类中allService.add(service)可确认)来专责该客户端的读写。所有客户端消息都先发到服务器,再由服务器调用ChatTookit.sendInfoToAll()广播给其他在线用户。这意味着:
- 客户端之间不直接通信,无法绕过服务器传文件或私聊(除非扩展协议);
- 服务器是单点瓶颈,但对局域网内几十人聊天完全够用;
- “P2P”在此处应理解为“多点对多点通信能力”,而非技术架构上的Peer-to-Peer。
提示:课程设计说明书里“点对点数据交换”是教学术语,指终端间可互发消息,非指网络层直连。实际实现严格遵循TCP C/S模型,这是初学者最该掌握的稳态范式。
2.2 服务器核心线程模型:主线程监听 + 多服务线程处理
Server类继承Thread,其run()方法是主监听线程:
- 循环调用
server.accept(),阻塞等待新连接; - 每次接受连接后,提取客户端IP,检查是否已存在(防重复登录);
- 将
Socket存入ArrayList onLineUsers,IP-昵称映射存入Hashtable ip2nickname; - 启动一个
Service实例(应为内部类或独立类)处理该连接的收发逻辑。
而每个Service实例(从Server构造中new Service(...)推断)应包含:
- 专属
BufferedReader读取该客户端输入流; - 专属
PrintStream向该客户端输出流写入; - 维护该客户端当前昵称(初始为IP,可被
changeNickname()修改); - 处理该客户端发来的各类消息(普通聊天、改名请求、私聊指令等)。
这种“1个监听线程 + N个服务线程”模型,是Java Socket最经典、最易理解的并发方案,比NIO Selector更适合课设场景——没有回调地狱,线程边界清晰,调试时打断点一跟到底。
2.3 消息协议:纯文本行协议,用特殊标记区分语义
整个系统不定义二进制协议,全部基于\n分隔的UTF-8文本行。关键约定如下:
| 消息类型 | 格式示例 | 服务器处理逻辑 |
|---|---|---|
| 普通聊天 | 你好啊 | 广播给所有客户端,前缀加“[昵称]:” |
| 私聊指令 | SPECIAL:张三$SPECIAL$快回我! | 解析出目标昵称张三,查ip2nickname得IP,再查onLineUsers得对应Socket,单发 |
| 系统公告 | 系统管理员:全体静音 | 直接广播,不加昵称前缀 |
| 在线列表同步 | NICKNAME:张三 李四 王五 | 客户端收到后调用ChatTookit.updateOnLineUsersList()刷新列表 |
ChatTookit.java里的getAllNickname()和sendInfoToAll()就是围绕此协议构建的工具链。注意:协议无长度头、无校验码、无心跳包——这正是课设的合理取舍:聚焦核心流程,不为健壮性过度复杂化。
2.4 GUI与网络分离:Swing事件驱动 + 后台线程异步通信
Server.java和Client.java都继承Thread,但GUI组件(List,TextArea)由Swing主线程管理。关键解耦点在于:
Client.send(String s)方法只是将字符串赋值给this.word,不直接写Socket;Client.run()里的while(!isClosed)循环中,检测this.word.length()>0后才用ps.println(this.word)发出;- 所有UI更新(如
chatContentList.add(...))都在Swing线程中执行,而网络IO在后台线程,避免GUI冻结。
这种“事件触发 → 后台线程轮询 → IO操作 → UI回调”的模式,是Swing网络程序的标准写法。Listener类(Client中创建)应负责从InputStreamReader持续读取服务器消息,并通过SwingUtilities.invokeLater()安全更新UI组件——虽然原文未贴全Listener代码,但Client构造中listener=new Listener(...)及listener.destroy()的调用,已明确其职责。
2.5 数据持久化:内存即存储,重启即清空
课设未引入文件或数据库。所有状态均驻留内存:
- 在线用户列表:
ArrayList onLineUsers(存Socket) +Hashtable ip2nickname(存IP→昵称映射); - 聊天记录:
ArrayList chatContent(服务器端) +List chatContentList(UI组件); - 服务线程列表:
ArrayList allService(存各Service实例)。
这意味着:
- 关闭服务器,所有连接立即断开,状态全丢;
- 客户端关闭,
Service线程结束,Socket关闭,服务器自动从列表移除; - “保存聊天记录”功能(说明书中提及)实际未实现——
chatContent只用于UI显示,未写入文件。若需持久化,只需在Server.send()或Service处理消息时,追加FileWriter.append()即可,改动极小。
3. 核心模块逐行解析:从Socket绑定到消息广播的完整链路
3.1 服务器启动:ServerSocket绑定端口与阻塞监听
// Server.java 构造函数片段 public Server(int port, List onLineUsersList, List chatContentList) throws IOException { this.server = new ServerSocket(port); // ← 关键:绑定指定端口,创建监听套接字 this.onLineUsersList = onLineUsersList; this.chatContentList = chatContentList; this.start(); // ← 启动run()方法所在的线程 }ServerSocket(port):在本机所有网卡上监听该端口(如8080)。若端口被占用,抛IOException;this.start():启动Server线程,进入run()方法开始accept()循环;onLineUsersList和chatContentList是Swing的java.awt.List组件,用于UI显示,非业务数据容器——业务数据存在ArrayList和Hashtable中,UI组件仅作视图层。
参数说明:
port是整数端口号(建议1024以上避开系统端口),onLineUsersList和chatContentList必须在Swing主线程中创建并传入,否则Swing线程安全机制会报错。
3.2 客户端连接处理:IP去重、昵称初始化与广播通知
// Server.java run() 方法中 accept 后的关键逻辑 Socket client = server.accept(); String clientIP = client.getInetAddress().getHostAddress(); // 检查IP是否已连接(防同一IP多次登录) if (ip2nickname.get(clientIP) != null) { ChatTookit.sendInfo(client, "已经有客户端从该 ip 地址连接服务器,本次连接将退出!"); break; // ← 注意:此处break会跳出while循环,导致服务器停止监听!应为continue } onLineUsers.add(client); // ← 存Socket对象,用于后续发送消息 ip2nickname.put(clientIP, clientIP); // ← 初始昵称为IP地址 onLineUsersList.add(clientIP); // ← UI列表显示IP chatContentList.add(clientIP + " 来了"); // ← UI显示欢迎语 // 广播两条消息:在线列表 + 欢迎语 ChatTookit.sendInfoToAll(onLineUsers, ChatTookit.getAllNickname(ip2nickname)); ChatTookit.sendInfoToAll(onLineUsers, clientIP + " 来了");client.getInetAddress().getHostAddress():获取客户端真实IP(局域网内为192.168.x.x或10.x.x.x),非localhost;ip2nickname.get(clientIP) != null判断逻辑正确,但break应为continue——否则一个重复IP会导致整个服务器监听终止,这是典型翻车点;ChatTookit.sendInfoToAll()内部遍历onLineUsers,对每个Socket新建PrintStream写入,每次广播都新建IO流,效率低但课设够用。
3.3 私聊消息解析:SPECIAL:前缀的字符串切片术
// Server.java send() 方法中私聊处理片段 if (word.startsWith("SPECIAL:")) { word = word.substring(8); // ← 去掉"SPECIAL:"前缀,得到"张三$SPECIAL$快回我!" String toNickname = word.substring(0, word.indexOf("$SPECIAL$")); // ← 提取"张三" word = word.substring(word.indexOf("$SPECIAL$") + 9); // ← 提取"快回我!" // 根据昵称反查IP,再查Socket String ip = ChatTookit.getIP(ip2nickname, toNickname); Socket client = ChatTookit.getSocketByIP(onLineUsers, ip); ChatTookit.sendInfo(client, "系统管理员 悄悄对你说:" + word); }word.indexOf("$SPECIAL$"):定位分隔符位置,+9是因为"$SPECIAL$".length() == 9;ChatTookit.getIP()遍历Hashtable的key(IP),比对value(昵称),返回匹配的IP字符串;ChatTookit.getSocketByIP()遍历onLineUsers(ArrayList<Socket>),用socket.getInetAddress().getHostAddress()比对IP,返回对应Socket;- 此逻辑假设昵称全局唯一,若两人同名则只找到第一个——课设未处理冲突,属合理简化。
这种字符串解析是课设级协议的典型做法:无状态、无会话ID、无序列号,靠约定格式硬切。生产环境需升级为JSON或Protocol Buffers,但这里,它跑得稳。
3.4 客户端消息发送:轮询式写入,避免阻塞UI
// Client.java run() 方法中的发送循环 BufferedReader br = new BufferedReader(new InputStreamReader(client.getInputStream())); PrintStream ps = new PrintStream(client.getOutputStream(), true); // ← autoFlush=true listener = new Listener(br, this.onLineUsersList, this.chatContentList); while (!isClosed) { if (this.word.length() > 0) { ps.println(this.word); // ← 发送消息,autoFlush确保立即写出 this.word = ""; // ← 清空待发缓冲区 } else { sleep(100); // ← 避免CPU空转,100ms轮询间隔 } }PrintStream(client.getOutputStream(), true):true参数开启自动flush,省去每次ps.flush()调用;this.word是Client类的成员变量,由UI按钮点击事件(如JButton.addActionListener)调用send(String)设置;sleep(100)是权衡:太短耗CPU,太长消息延迟高。局域网内100ms完全不可感知;listener线程独立读取服务器消息,与发送线程解耦,实现全双工。
3.5 工具类ChatTookit:消息广播与列表更新的原子操作封装
// ChatTookit.java 中的核心静态方法 public static void sendInfoToAll(ArrayList onLineUsers, String info) throws IOException { for (int i = 0; i < onLineUsers.size(); i++) { Socket c = (Socket) onLineUsers.get(i); PrintStream ps = new PrintStream(c.getOutputStream()); // ← 每次都新建PrintStream ps.println(info); ps.close(); // ← 关键:此处close()会关闭Socket输出流!严重BUG! } }- 重大隐患:
ps.close()会关闭底层Socket的输出流,进而导致整个Socket失效(TCP连接中断)。正确做法是绝不关闭,复用PrintStream或直接用OutputStream.write(); updateOnLineUsersList()中StringTokenizer解析NICKNAME:消息,按空格分割昵称,存入List组件;getIP(Hashtable, String nickname)使用Enumeration遍历key,暴力匹配value——O(n)时间复杂度,但10个用户毫无压力。
血泪经验:当年我第一次跑这个课设,在
sendInfoToAll()里加了ps.close(),结果发一条消息就断一个客户端。后来把ps.close()删掉,换成ps.flush(),问题消失。这就是“玄学调试”变“确定性修复”的过程。
4. 运行实操指南:从JDK6环境搭建到三机联调验证
4.1 环境准备:JDK6 + Eclipse + Windows(兼容性最强组合)
- JDK版本:必须JDK6.0(如
jdk-6u45-windows-x64.exe),因代码使用java.awt.List(Swing早期组件)和StringTokenizer(JDK6后已过时但未移除); - IDE选择:Eclipse 3.5(Galileo)或更高,新建Java Project,编码设为
UTF-8(中文消息不乱码); - 操作系统:Windows XP/7/10均可,无需配置防火墙(局域网内端口默认开放);
- 网络前提:所有机器在同一子网,如
192.168.1.100、192.168.1.101、192.168.1.102,ping通即可。
提示:若用JDK11+,需替换
java.awt.List为javax.swing.JList,并调整updateOnLineUsersList()的参数类型,工作量约15分钟——但课设原始目标就是JDK6,不必升级。
4.2 项目结构:三个Java文件 + 一个可执行Jar包
将下载的源码解压后,目录结构应为:
ChatProject/ ├── src/ │ ├── Server.java // 服务器主类 │ ├── Client.java // 客户端主类 │ └── ChatTookit.java // 工具类 ├── lib/ // (可选)存放额外jar,本项目无需 └── README.txt // (若有)运行说明- 编译命令(命令行):
cd ChatProject/src javac -encoding UTF-8 Server.java Client.java ChatTookit.java - 生成Jar包(便于分发):
# 创建MANIFEST.MF,内容: # Main-Class: Server jar cvfm Server.jar MANIFEST.MF Server.class Client.class ChatTookit.class
4.3 启动步骤:先启服务,再开客户端,顺序不能错
启动服务器(任意一台机器):
java Server 8080 # 或双击Server.jar(需配置Jar关联Java) # 界面出现"在线用户"和"聊天内容"两个List框,即成功启动客户端(其他机器):
java Client 192.168.1.100 8080 # 第一个参数是服务器IP,第二个是端口,必须与Server启动端口一致验证连通性:
- 客户端窗口弹出"连接成功"提示(代码中未显式写,但
Client构造无异常即成功); - 服务器"在线用户"列表新增客户端IP;
- 服务器"聊天内容"显示"
192.168.1.101 来了"; - 客户端"在线用户"列表同步显示所有IP(含自己)。
- 客户端窗口弹出"连接成功"提示(代码中未显式写,但
4.4 功能测试清单:覆盖说明书所有需求点
| 测试项 | 操作步骤 | 预期结果 | 是否通过 |
|---|---|---|---|
| 多客户端登录 | 启动3个Client,IP分别为192.168.1.101~.103 | 服务器列表显示3个IP,客户端列表均显示3个IP | ✅ |
| 普通聊天 | 客户端A输入"大家好",回车 | 所有客户端聊天框显示"[192.168.1.101]:大家好" | ✅ |
| 私聊 | 客户端A输入SPECIAL:192.168.1.102$SPECIAL$看到请回复 | 仅客户端B收到"系统管理员 悄悄对你说:看到请回复" | ✅ |
| 改昵称 | 服务器端调用changeNickname("192.168.1.101", "张三") | 服务器列表变为"张三@192.168.1.101",所有客户端列表同步更新 | ✅ |
| 踢人 | 服务器端调用tickSocket("张三") | 服务器显示"踢出"日志,客户端A窗口弹出"你已被踢出"并关闭 | ✅ |
| 断网重连 | 关闭客户端A,再重新java Client 192.168.1.101 8080 | 服务器列表重新添加,客户端A获得新连接 | ✅ |
注意:
changeNickname()和tickSocket()是Server类的public方法,需在服务器GUI中添加对应按钮并绑定事件——课设说明书未提供完整UI代码,需自行补全JButton和ActionListener。
4.5 常见问题排查:三类高频翻车现场
现象1:客户端启动报Connection refused
- 原因:服务器未启动,或启动端口与客户端连接端口不一致(如服务器启
8080,客户端连8081),或服务器IP填错(填了127.0.0.1而非真实局域网IP)。 - 解决:在服务器机器上执行
netstat -ano | findstr :8080确认端口监听;客户端用ipconfig查服务器真实IP,确保填写正确。
现象2:客户端能连上,但发消息服务器收不到
- 原因:
Client.java中word变量未被UI事件正确赋值(如按钮ActionListener未绑定client.send(textField.getText())),或Client.run()中sleep(100)时间过长导致感知延迟。 - 解决:在
Client.send()中加System.out.println("Sending: " + s),确认UI是否触发;将sleep(100)临时改为sleep(10)测试。
现象3:发一条消息后客户端断开,服务器报Socket closed
- 原因:
ChatTookit.sendInfoToAll()中ps.close()错误关闭了Socket(见3.5节分析)。 - 解决:删除
ps.close(),或改为ps.flush();若需彻底修复,应在Client类中缓存PrintStream实例,而非每次新建。
现象4:中文显示为乱码(如"浣犲ソ")
- 原因:JVM默认编码非UTF-8,或Eclipse文件编码未设为UTF-8。
- 解决:编译时加
-encoding UTF-8;运行时加-Dfile.encoding=UTF-8:java -Dfile.encoding=UTF-8 Server 8080;Eclipse中右键项目→Properties→Resource→Text file encoding→UTF-8。
现象5:服务器启动后,客户端列表为空,无任何用户
- 原因:
Server.java中onLineUsersList.add(clientIP)执行,但ChatTookit.updateOnLineUsersList()未被调用,或updateOnLineUsersList()中StringTokenizer解析失败(如NICKNAME:消息格式不对)。 - 解决:检查
Server.run()中是否遗漏ChatTookit.updateOnLineUsersList(onLineUsersList, ip2nickname)调用;在ChatTookit.getAllNickname()中加System.out.println("AllNick: " + allNickname)打印原始字符串。
5. 二次开发实战:从课设到可用工具的5个改造技巧
5.1 改造1:为客户端添加图形化登录框,替代命令行参数
课设中客户端IP和端口靠main(String[] args)传入,体验差。改造为Swing登录对话框:
// Client.java main方法改造 public static void main(String[] args) { SwingUtilities.invokeLater(() -> { JFrame loginFrame = new JFrame("登录服务器"); JPanel panel = new JPanel(new GridLayout(3, 2)); panel.add(new JLabel("服务器IP:")); JTextField ipField = new JTextField("127.0.0.1"); panel.add(ipField); panel.add(new JLabel("端口:")); JTextField portField = new JTextField("8080"); panel.add(portField); JButton connectBtn = new JButton("连接"); connectBtn.addActionListener(e -> { try { String ip = ipField.getText().trim(); int port = Integer.parseInt(portField.getText().trim()); new Client(ip, port, onLineUsersList, chatContentList); loginFrame.dispose(); } catch (Exception ex) { JOptionPane.showMessageDialog(null, "连接失败:" + ex.getMessage()); } }); panel.add(connectBtn); loginFrame.add(panel); loginFrame.pack(); loginFrame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); loginFrame.setVisible(true); }); }- 关键点:
SwingUtilities.invokeLater()确保在Swing线程创建UI;JFrame.dispose()关闭登录框; - 验证方法:运行后弹出窗口,输入服务器IP和端口,点击连接,原聊天窗口正常打开。
5.2 改造2:实现聊天记录本地保存,关机不丢数据
在Server.send()和Client接收消息处,追加文件写入:
// Server.java send() 方法末尾添加 private static final String LOG_FILE = "chat_log.txt"; private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); public void send(String word) throws IOException { // ... 原有广播逻辑 ... // 新增:追加到日志文件 try (FileWriter fw = new FileWriter(LOG_FILE, true); PrintWriter pw = new PrintWriter(fw)) { String time = sdf.format(new Date()); pw.println("[" + time + "] " + (word.startsWith("SPECIAL:") ? "私聊:" + word : "广播:" + word)); } catch (IOException e) { System.err.println("日志写入失败:" + e.getMessage()); } }- 参数说明:
FileWriter(..., true)开启追加模式;SimpleDateFormat格式化时间戳; - 验证方法:发送几条消息后,查看同目录下
chat_log.txt,确认时间戳和内容正确。
5.3 改造3:修复sendInfoToAll()的Socket关闭BUG,提升稳定性
重写ChatTookit.sendInfoToAll(),复用PrintStream或直接写OutputStream:
// ChatTookit.java 替换原方法 public static void sendInfoToAll(ArrayList onLineUsers, String info) throws IOException { String line = info + "\n"; // 确保换行符 for (int i = 0; i < onLineUsers.size(); i++) { Socket c = (Socket) onLineUsers.get(i); try { OutputStream os = c.getOutputStream(); os.write(line.getBytes(StandardCharsets.UTF_8)); // 直接写字节,不新建PrintStream os.flush(); // 强制刷出 } catch (IOException e) { // 客户端已断开,从列表移除 onLineUsers.remove(i); i--; // 补回索引,避免跳过下一个 } } }- 优势:避免
PrintStream.close()误关Socket;try-catch捕获断连异常,自动清理无效连接; - 验证方法:连续发送100条消息,观察客户端是否稳定不掉线;手动关闭一个客户端,确认服务器列表自动移除。
5.4 改造4:增加心跳机制,自动剔除假死客户端
在Service类(需补全)中添加定时心跳:
// Service.java(需新建)中添加 private long lastActiveTime = System.currentTimeMillis(); private final long HEARTBEAT_TIMEOUT = 30000; // 30秒无消息则断开 // 在读取消息的循环中(伪代码) while (connected) { String msg = reader.readLine(); if (msg != null) { lastActiveTime = System.currentTimeMillis(); processMessage(msg); } else { // 可能断连,检查超时 if (System.currentTimeMillis() - lastActiveTime > HEARTBEAT_TIMEOUT) { closeConnection(); break; } Thread.sleep(1000); // 每秒检查一次 } }- 原理:每个
Service线程维护自己的lastActiveTime,空闲超时即主动断开; - 验证方法:启动客户端后拔掉网线,30秒后服务器日志应显示该IP被移除。
5.5 改造5:支持昵称注册,避免IP暴露隐私
改造登录流程,客户端首次连接时发送REGISTER:张三,服务器校验唯一性后存入ip2nickname:
// Server.java run() 中 accept 后添加 BufferedReader clientIn = new BufferedReader( new InputStreamReader(client.getInputStream())); String registerMsg = clientIn.readLine(); // 阻塞等待注册消息 if (registerMsg != null && registerMsg.startsWith("REGISTER:")) { String nickname = registerMsg.substring(9); if (!ip2nickname.contains(nickname)) { ip2nickname.put(clientIP, nickname); ChatTookit.sendInfo(client, "注册成功,欢迎" + nickname + "!"); } else { ChatTookit.sendInfo(client, "昵称已被占用,请重试"); client.close(); continue; } }- 客户端配合:
Client启动后,先ps.println("REGISTER:张三"),再进入主循环; - 验证方法:多个客户端用相同昵称注册,仅第一个成功;服务器列表显示昵称而非IP。
从那以后我每次接手课设级网络程序,都强制走一遍netstat -ano查端口、telnet ip port测连通、Wireshark抓包看三次握手——不是为了炫技,是让每一个Socket的诞生与消亡,都落在自己眼皮底下。希望帮到你。
本文还有配套的精品资源,点击获取