简介:广东工业大学计算机网络课程设计项目,实现基于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 // TYPE:1=上线通知,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 JList<String> friendList; private DefaultListModel<String> 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 == 2,FriendListDoubleClick 这个类就是干这个的。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 用的端口 3333,TCP 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 包装文件发送任务 SwingWorker<Void, Integer> worker = new SwingWorker<>() { @Override protected Void doInBackground() throws Exception { sendFileWithProgress(targetIp, file, this); return null; } @Override protected void process(List<Integer> 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 线程、文件传输这三段核心代码手动重敲一遍再交付,这样答辩时被追问到实现细节才不会慌。希望帮到你。本文还有配套的精品资源,点击获取
简介:广东工业大学计算机网络课程设计项目,实现基于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 // TYPE:1=上线通知,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 JList<String> friendList; private DefaultListModel<String> 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 == 2,FriendListDoubleClick 这个类就是干这个的。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 用的端口 3333,TCP 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 包装文件发送任务 SwingWorker<Void, Integer> worker = new SwingWorker<>() { @Override protected Void doInBackground() throws Exception { sendFileWithProgress(targetIp, file, this); return null; } @Override protected void process(List<Integer> 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 线程、文件传输这三段核心代码手动重敲一遍再交付,这样答辩时被追问到实现细节才不会慌。希望帮到你。本文还有配套的精品资源,点击获取