UDP+TCP+多线程+Swing:局域网P2P即时通信课设全解析 简介广东工业大学计算机网络课程设计项目实现基于P2P的局域网即时通信系统面向计网课设、Java网络编程与P2P应用开发者。系统采用P2P架构程序既是服务器又是客户端通过3333端口扫描局域网在线对等方完成用户注册、在线列表获取、消息发送与文件传输界面包含对等方列表、消息显示区和传输控制按钮代码涵盖TCP连接、多线程处理及Swing界面设计等关键知识点。包内共158个文件Java源码为程序实现class为编译产物XML与Eclipse工程文件支撑项目结构与配置另附js、properties等辅助资源RAR压缩包约20.77MB导入开发环境即可运行调试与二次学习。已有426人浏览学习适合需要完成类似课程设计、快速理解P2P局域网通信原理的读者可作为课程设计完整参考及答辩梳理素材。1. 局域网 P2P 即时通信课设一次把 UDP、TCP、多线程和 Swing 全串起来的实战某高校计网课设里要求实现一个纯 P2P 架构的局域网即时通信系统服务端口固定用 3333程序既要当服务器又要当客户支持用户注册、在线对等方自动发现、点对点消息和文件传输。这项目卡住大多数人的不是某个单独知识点而是怎么把 UDP 广播扫描、TCP 长连接、多线程文件收发和 Swing 界面组合在一起让它们不互相踩踏。这套资源里已经打包好了完整可运行的 Java 工程包含聊天窗口、好友列表、文件发送、进度条、UDP 接收线程、TCP 服务线程和文件保存线程等全套类文件拿到的同学可以直接导入 IDE 跑起来。适合正被计网课设折磨的在校生也适合想快速搞懂 P2P 通信模型怎么落地的人。2. 协议设计为什么发现对等方用 UDP传文件却必须换 TCP2.1 P2P 通信模型的选型理由课设里程序既是服务器又是客户这句话是理解整个架构的钥匙。传统 C/S 模型里服务器是中心节点客户端之间不直接通信而这里每个节点都要运行一个 TCP Server 线程监听 3333 端口同时也要主动去扫描和连接别人。这意味着每个程序实例的网络层要拆成两个独立的部分负责发现对等方的 UDP 模块以及负责实际传输消息和文件的 TCP 模块。选择 UDP 做对等方发现是因为它天然支持广播。一个节点启动后往局域网广播地址发一条数据报同一网段里所有监听 3333 端口的主机都能收到这种一对多的能力 TCP 做不到——TCP 必须先建立连接而你还不知道要和谁连。但 UDP 不保证可靠传输所以发现消息丢了怎么办课设要求里写的接收方接收到消息后把对等方加入用户列表并发应答消息本质上是靠应用层的握手来补可靠性的短板。文件传输切到 TCP是因为文件数据不能丢。UDP 丢一个包只是发现消息没送达重新广播一次就行文件丢一个包整个文件就损坏了。TCP 的顺序保证、重传机制、流量控制在这里是刚需。所以整个系统的通信策略是UDP 负责找到人TCP 负责传东西。2.2 应用层消息格式定义课设要求双方交换的消息格式自己定义至少包含用户名和 IP 地址。我见过不少同学直接发字符串不加分隔符导致解析时各种玄学报错。这里给一个稳定可用的格式方案// 消息格式TYPE|SENDER_NAME|SENDER_IP|LENGTH|CONTENT // TYPE1上线通知2上线应答3聊天消息4文件请求5文件传输完成 public class MessageProtocol { public static final String SEPARATOR |; public static String buildMessage(int type, String senderName, String senderIp, String content) { StringBuilder sb new StringBuilder(); sb.append(type).append(SEPARATOR) .append(senderName).append(SEPARATOR) .append(senderIp).append(SEPARATOR) .append(content.getBytes().length).append(SEPARATOR) .append(content); return sb.toString(); } public static String[] parseMessage(String raw) { // 注意content里可能包含分隔符所以要先解析前4段剩下的都是content int firstSep raw.indexOf(SEPARATOR); int secondSep raw.indexOf(SEPARATOR, firstSep 1); int thirdSep raw.indexOf(SEPARATOR, secondSep 1); int fourthSep raw.indexOf(SEPARATOR, thirdSep 1); String type raw.substring(0, firstSep); String senderName raw.substring(firstSep 1, secondSep); String senderIp raw.substring(secondSep 1, thirdSep); int contentLength Integer.parseInt(raw.substring(thirdSep 1, fourthSep)); String content raw.substring(fourthSep 1); return new String[]{type, senderName, senderIp, String.valueOf(contentLength), content}; } }关键点在于解析时要处理 content 里可能出现的分隔符。我用的是先从左边解析出前四个字段剩下的所有内容都归为 content而不是简单地用 split(|)。这个方法缺陷也明显如果用户名或 IP 里出现分隔符协议就坏了。实际课设里用户名一般不会有人输竖线这个风险可以接受。参数说明LENGTH 字段存的是 content 的字节长度不是字符长度因为文件内容可能是二进制转成 String 后中文和英文字节数不一样用字节长度在接收端验证完整性更可靠。2.3 网段扫描与在线对等方列表维护扫描网段要有边界意识。扫描网段中在线的对等方不能真的把整个 B 类地址都扫完会等到天荒地老。常见做法是只扫本机 IP 所在的 /24 网段也就是 192.168.x.1 到 192.168.x.254。# Windows 下查看本机IP和子网掩码 ipconfig # Linux / macOS 下查看 ifconfig | grep inet 拿到本机 IP 后把最后一位从 1 到 254 遍历对每个地址构造一个对等方探测消息通过 UDP 发送。这个循环可以放在一个线程里避免界面卡死。一个实际的优化思路是先判断本机 IP 是 192.168.x.y 还是 10.x.y.z按前三位作为广播前缀。比如本机是 192.168.1.100就向 192.168.1.255 发广播同时遍历 192.168.1.1 到 192.168.1.254 发单播双管齐下防止有些路由器隔离了广播域导致广播包到不了所有主机。在线列表的维护核心是给每个对等方加时间戳。UDP 是无连接的A 收到了 B 的应答不代表 B 五分钟之后还在线。所以收到任何消息时更新对应对等方的时间戳另起一个定时任务每 30 秒扫描一次列表把超过 90 秒没有活跃记录的对等方移除出用户列表。这个机制不是课设要求里明确写的但如果不做你关掉一台机器后另一台机器的列表里永远躺着那个僵尸节点点它发消息就扑空。3. 代码骨架搭建从 UI 到网络层一条线串起来3.1 Swing 界面的职责切分资源包里 ChatWindow.class、friendlist_doubleclick.class、sendFilebt_adapter.class 这些类对应的界面结构可以做这样的切分。ChatWindow 是主窗口里面嵌套三个面板左侧对等方列表JList右侧上方消息显示区JTextArea只读右侧下方消息输入区JTextField JButton。文件传输的进度条放在窗口底部或独立的进度面板里。// 主窗口布局代码片段 public class ChatWindow extends JFrame { private JListString friendList; private DefaultListModelString listModel; private JTextArea messageArea; private JProgressBar transferProgress; public ChatWindow() { setTitle(P2P 局域网通信系统); setSize(800, 600); setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); // 左侧对等方列表 listModel new DefaultListModel(); friendList new JList(listModel); friendList.addMouseListener(new FriendListDoubleClick(this)); // 右侧消息区域 messageArea new JTextArea(); messageArea.setEditable(false); // 底部输入区 JTextField inputField new JTextField(); JButton sendButton new JButton(发送); sendButton.addActionListener(e - handleSend(inputField.getText())); // 使用 BorderLayout 组合布局 getContentPane().add(new JScrollPane(friendList), BorderLayout.WEST); getContentPane().add(new JScrollPane(messageArea), BorderLayout.CENTER); // ... } }布局上用 BorderLayout 是最省事的方案。左窄右宽的比例可以通过设置 friendList 的 preferredSize 或给 WEST 面板包一层固定宽度的容器来控制。一个容易被忽略的细节是 JList 默认不支持双击事件必须通过 MouseListener 手动判断 clickCount 2FriendListDoubleClick 这个类就是干这个的。3.2 UDP 发现模块发送线程与接收线程资源里的 SendIPtoLAN.class 对应发送广播和探测包的逻辑ReceiveIP$GetPacket.class 对应接收数据报的线程。这两个线程的生命周期是整个程序运行期间不是点一下发一次就结束的。// UDP 发送端向局域网广播自己的上线信息 public class SendIPtoLAN { private static final int PORT 3333; private static final String BROADCAST_ADDR 255.255.255.255; public static void broadcastOnlineInfo(String userName, String groupName) throws IOException { DatagramSocket socket new DatagramSocket(); socket.setBroadcast(true); // 必须开启广播权限否则抛异常 String message MessageProtocol.buildMessage( 1, userName, getLocalIp(), groupName); byte[] data message.getBytes(); // 先发全局广播 DatagramPacket packet new DatagramPacket(data, data.length, InetAddress.getByName(BROADCAST_ADDR), PORT); socket.send(packet); // 再对 /24 网段逐个发单播探测 String localIp getLocalIp(); String subnetPrefix localIp.substring(0, localIp.lastIndexOf(.) 1); for (int i 1; i 254; i) { String targetIp subnetPrefix i; if (targetIp.equals(localIp)) continue; try { DatagramPacket unicast new DatagramPacket(data, data.length, InetAddress.getByName(targetIp), PORT); socket.send(unicast); } catch (Exception ignored) { // 单播失败说明该IP无主机忽略继续 } } socket.close(); } }参数说明PORT 固定 3333 是本课设的硬性要求不要改成别的端口老师测试时会按 3333 来连。setBroadcast(true) 必须调用否则发往 255.255.255.255 的包会被系统拦截并抛 SocketException。getLocalIp() 建议用 NetworkInterface 遍历接口拿到非 127.0.0.1 的那个 IPv4 地址而不是直接用 InetAddress.getLocalHost()——在多网卡机器上后者经常返回错误的地址。接收线程要处理的核心问题是同一个端口上既可能收到别人的上线通知也可能收到别人对你说我在线的应答。这两种消息要区分对待所以消息类型字段在这里起到了路由的作用。// UDP 接收线程持续监听 3333 端口 public class GetPacket extends Thread { private DatagramSocket socket; private ChatWindow window; public void run() { try { socket new DatagramSocket(3333); byte[] buffer new byte[4096]; while (true) { DatagramPacket packet new DatagramPacket(buffer, buffer.length); socket.receive(packet); // 阻塞等待数据报 String raw new String(packet.getData(), 0, packet.getLength()); String[] parts MessageProtocol.parseMessage(raw); int type Integer.parseInt(parts[0]); String senderName parts[1]; String senderIp parts[2]; // 收到别人的上线通知就要应答收到别人的应答就加入列表 if (type 1) { // 立即回一条上线应答 String response MessageProtocol.buildMessage( 2, window.getUserName(), getLocalIp(), ); DatagramPacket respPacket new DatagramPacket( response.getBytes(), response.getBytes().length, InetAddress.getByName(senderIp), 3333); socket.send(respPacket); window.addFriend(senderName, senderIp); } else if (type 2) { window.addFriend(senderName, senderIp); } // 刷新时间戳防止被清理 window.updateFriendTimestamp(senderIp); } } catch (SocketException e) { // 端口被占用时在此处落日志 e.printStackTrace(); } } }这个线程是死循环阻塞在 receive() 上的所以必须作为独立线程启动不能混进 UI 线程。一个常见的坑是如果程序崩溃重启旧的进程还没有完全退出新进程的 DatagramSocket 绑 3333 会报 Address already in use。这时候在 Windows 上用 netstat -ano | findstr 3333 找到占用进程taskkill /PID 来杀掉。3.3 TCP 文件传输二进制流的分块接收与进度回显文件传输是另一个独立模块涉及三个类发送端把文件分块读出并写入 Socket 输出流接收端 testSever$ServerThread$FileSave 收到之后要在本地磁盘落文件而 ProgressBar.class 负责把进度显示到界面上。这里的难点是文件长度和进度百分比需要先知道总量所以自定义协议里必须有文件大小字段。// 发送端发送文件给指定对等方 public void sendFile(String targetIp, File file) { long fileSize file.length(); String fileName file.getName(); // 先发一条文件请求消息包含文件名和大小 String header MessageProtocol.buildMessage(4, userName, getLocalIp(), fileName : fileSize); try { Socket socket new Socket(targetIp, 3333); OutputStream out socket.getOutputStream(); out.write(header.getBytes()); out.flush(); // 发送文件内容分块读每块 4KB FileInputStream fis new FileInputStream(file); byte[] buffer new byte[4096]; int bytesRead; long totalSent 0; while ((bytesRead fis.read(buffer)) ! -1) { out.write(buffer, 0, bytesRead); totalSent bytesRead; int percent (int) (totalSent * 100 / fileSize); window.updateProgressBar(percent); } fis.close(); socket.close(); window.updateProgressBar(100); } catch (IOException e) { window.appendMessage(文件发送失败 e.getMessage()); } }这里有个精妙的设计UDP 用的端口 3333TCP Server 监听端口也用 3333。TCP 和 UDP 是不同协议同一台机器的同一个端口号可以分别绑定在两个协议上互不冲突。这是计网课的一个经典考点很多同学在这里犯迷糊以为端口被占用了实际上完全能共存。接收端的问题是必须先读文件头拿到文件名和长度再决定缓冲区和写入策略。如果把文件头当成文件数据的一部分写进文件里文件就会损坏。// 接收端ServerThread 里的文件保存逻辑 public void handleFileSave(Socket socket) throws IOException { InputStream in socket.getInputStream(); BufferedReader reader new BufferedReader(new InputStreamReader(in)); // 第一行是文件头TYPE|NAME|IP|LEN|filename:size String headerLine reader.readLine(); String[] parts MessageProtocol.parseMessage(headerLine); String[] fileInfo parts[4].split(:); String fileName fileInfo[0]; long fileSize Long.parseLong(fileInfo[1]); // 根据文件大小决定保存路径避免覆盖同名文件 String savePath received_ fileName; FileOutputStream fos new FileOutputStream(savePath); byte[] buffer new byte[4096]; long totalRead 0; int bytesRead; while (totalRead fileSize (bytesRead in.read(buffer)) ! -1) { fos.write(buffer, 0, bytesRead); totalRead bytesRead; int percent (int) (totalRead * 100 / fileSize); window.updateProgressBar(percent); } fos.close(); window.appendMessage(文件接收完成 savePath); }注意 header 用 BufferedReader.readLine() 来读可以但文件内容不能再用 BufferedReader 读必须让同一个 InputStream 继续按字节读。如果 readLine() 已经把多余的字节读进缓冲后面的文件数据就会缺失一小段——这是 BufferedReader 的经典陷阱。解决方法是读文件头时不经过 BufferedReader直接自定义读取直到遇到换行符或者用 DataOutputStream/DataInputStream 的 writeUTF/readUTF 来传输文件头这样更干净。4. 避坑记录从编译报错到运行期翻车的完整排障清单4.1 端口占用Windows 下进程没杀干净现象启动程序时 DatagramSocket 或 ServerSocket 抛出 java.net.BindException: Address already in use。原因上一次运行的程序没有正常退出或者 IDE 的热部署启动了两个实例后一个抢不到 3333 端口。解决命令行执行 netstat -ano | findstr 3333找到 LISTENING 状态的 PID然后 taskkill /F /PID 对应进程号。如果是在 IDE 里检查是否启用了多实例启动把Allow parallel run关掉。这个坑在反复改代码调试时出现频率极高。4.2 界面卡死把网络操作放进了 UI 线程现象点击发送按钮后整个窗口无响应过一会儿弹出来甚至直接白屏。原因Swing 的事件分发线程EDT里做了耗时的 Socket 操作比如发大文件时循环写流几百毫秒EDT 被阻塞后所有重绘和点击事件都排队。解决所有 Socket 连接、文件读写、UDP 收发必须放在独立线程。用 SwingWorker 或直接 new Thread 包一层。我一般会封装一个 NetworkTask 工具类把所有网络调用放进去统一管理线程。判断标准很简单凡是会阻塞超过 50ms 的操作一律不进 EDT。4.3 文件传输损坏分不清文件头和文件体现象收到的文件打不开图片显示不全压缩包提示校验失败。文件大小看起来和源文件差不多但字节内容错位。原因文件头包含文件名和大小和文件内容用了同一个 Stream 但读取方式不一致。比如用 BufferedReader 读了头部它内部缓冲区把一部分文件字节也读走了。解决使用 DataInputStream/DataOutputStream文件头用 writeUTF 写文件体按裸字节写。writeUTF 自带长度前缀读取时 readUTF 精确读出头不会吃进多余字节。这是血泪经验我最早用 BufferedReader 版本调了三天最后发现是缓冲区预读的锅。4.4 对等方列表不更新UDP 广播被路由器隔离现象A 机能扫到自己B 机能扫到自己但 A 和 B 互相看不见。如果 A、B 在同一台机器上开两个实例倒是能发现。原因有些家用路由器和虚拟机的 NAT 网络模式下广播包不会转发到所有网段主机。发送端用 255.255.255.255 全局广播在某些网络环境下只能到达本机自己。解决把发送逻辑改成广播 逐 IP 单播双通道。广播失败时靠单播兜底。同时确认两台的子网掩码一致如果一个是 255.255.255.0 一个是 255.255.255.128认为对方不在同一网段。虚拟机环境里检查网络模式是否同为桥接或同为 NAT混用会导致隔离。4.5 Java 版本导致的内置类兼容问题现象资源包里的 .class 文件导入 IDE 后反编译可以重新编译却报错比如 ProgressBar 的 setStringPainted 方法不存在或 JProgressBar 构造器参数不匹配。原因原作者用的是某 Java 8 版本编写而本地环境是 Java 17 或更高版本部分 Swing API 的签名发生了变化或者高版本移除了某些内部 API。解决优先找资源包里的 .java 源码用源码重新编译而不是直接引 .class 文件。如果是纯 .class 包检查 Project Structure 里 SDK 版本设为 Java 8。某高校课设的测试环境通常也是 Java 8别用 Java 17 来跑减少版本差异带来的麻烦。头像资源路径问题也值得注意资源里有图片文件的话确认 src 目录下 resources 文件夹被标记为 Sources 或 Resources。5. 验证课设成果的模拟局域环境与两个加分技巧5.1 没有两台电脑也能测的虚拟环境很多同学寝室里只有一台笔记本没法模拟真实局域网。我一般会推荐用 VMware 或 VirtualBox 开两个虚拟机镜像网络模式都选桥接模式然后给不同虚拟机设置静态 IP确保在同网段。这样两个完全隔离的系统实例可以真实跑一遍互相发现 → 收发消息 → 传文件全流程。如果不想上虚拟机还有一个更轻量但绝对不能用来应付验收的方案在同一台机器上开两个程序实例把本机 IP 当作对等方互相发现。但注意这个方法有其局限——它不能验证网段扫描的逻辑因为两个实例共用同一个 IP。验收时的模拟拓扑建议这样配节点角色IP 地址说明宿主机对等方 A192.168.1.100运行聊天窗口主程序虚拟机1对等方 B192.168.1.101桥接模式运行第二个实例虚拟机2对等方 C192.168.1.102桥接模式验证多节点互发先启动 B 和 C再启动 A观察 A 的列表是否出现 B 和 C然后从 A 给 B 发一条消息再传一个至少 10MB 的文件观察 B 端的进度条是否平滑走到 100%C 端是否完全不受影响。这个验证能覆盖课设要求里的全部功能点。5.2 文件传输进度条的线程安全更新技巧SwingWorker 是处理后台任务和进度更新的最佳工具。它可以在 doInBackground 里做网络传输通过 publish/process 回调把进度安全地传回 EDT 更新 JProgressBar完全避免我前面说的界面卡死隐患。// 使用 SwingWorker 包装文件发送任务 SwingWorkerVoid, Integer worker new SwingWorker() { Override protected Void doInBackground() throws Exception { sendFileWithProgress(targetIp, file, this); return null; } Override protected void process(ListInteger chunks) { // 这个方法运行在 EDT 上可以安全更新 UI int latest chunks.get(chunks.size() - 1); progressBar.setValue(latest); progressBar.setString(latest %); } Override protected void done() { progressBar.setString(传输完成); } }; worker.execute();这里的一个关键参数理解progressBar.setStringPainted(true) 要在初始化时设置否则 setString 不会显示百分比文字。SwingWorker 的 publish 方法可以合并多个中间值process 里拿到的 chunks 是累积的一批数据不需要逐个更新进度条取最后一个值就好。文件传输完成后 done() 方法里做收尾工作比如弹提示框、重置进度条状态。5.3 数据包抓取验证协议正确性如果程序显示一切正常但你心里没底可以用 Wireshark 抓包来验证自定义协议的真实运行情况。在 Wireshark 里设置过滤器 udp.port 3333启动程序后观察有没有预期的广播包和应答包再设置 tcp.port 3333观察文件传输时 TCP 三次握手和数据段的情况。我见过有同学代码里写死了错误的格式却侥幸跑通——例如发送端用 GBK 编码接收端用 UTF-8 解码纯英文消息没问题中文消息全乱码。抓包能一眼看出报文里的字节序列和编码方式这比在代码里打日志更直观。这套课设资源里附带的全套源码和编译好的类文件意味着拿到的同学不需要从零开始敲代码重点是理解每一层为什么这么设计。我自己的经验是即便有完整源码也把 UDP 接收线程、TCP Server 线程、文件传输这三段核心代码手动重敲一遍再交付这样答辩时被追问到实现细节才不会慌。希望帮到你。本文还有配套的精品资源点击获取