☰
P2P局域网即时通信系统实战:UDP发现与TCP传输
2026/10/19 11:10:46 网站建设 项目流程

简介:广东工业大学计算机网络课程设计实现方案:基于点对点模式的局域网即时通信系统,适合正在完成计网课设或学习网络编程的学生。系统采用“既是服务器又是客户端”架构,利用固定端口扫描网段在线对等方,通过自定义协议消息完成注册、对等方发现与应答,支持选择用户建立连接,实现消息和文件传输;界面含对等方列表、消息区、输入框及文件传输控件。压缩包共158个文件,以源码文件、工程配置和界面类为主,另有扩展配置文件等,总大小约20.77MB,目录完整,可导入IDE直接查看调试。目前已有426人学习下载,可学习多线程Socket编程、连接管理、界面布局及协议设计,也可作为课设报告撰写和答辩参考。无论是从零复现还是扩展功能,都很有帮助。

1. 只开一个端口,干四件事:P2P局域网即时通信系统的真实形态

把服务端和客户端塞进同一个程序、同一个端口3333,听起来像个黑匣子——但这正是某高校计网课设里最常见的一道题。这套基于P2P的局域网即时通信系统,启动后自己监听3333端口等别人连,同时向整个网段广播“我上线了”,把回应消息的人加进对等方列表;双击列表里的名字就能开聊天窗,发文本、传文件都走新建的TCP连接。界面是Swing画的,对等方列表、消息显示区、输入框、文件传输进度条一个不少,整体是“程序既是服务器又是客户”的典型写法。适合正在做计网课设的人、想搞懂UDP与TCP在真实项目里怎么分工的人,以及打算拿它二次开发做局域网协作工具的人。

2. 技术选型与类结构:从class文件名反推一个Swing + Socket项目

2.1 为什么是Swing + Socket而不是WebSocket

拿到资源包先别急着解压,看一眼里面的.class文件名,项目骨架基本就出来了:ChatWindow、ProgressBar、SendIPtoLAN、ReceiveIP、testSever、friendlist_doubleclick、sendFilebt_adapter,外加一个看起来不相关的assumedExternalFilesCache——那是IDE缓存目录混进来了,直接忽略。真正核心的可编译实体就是这七个。

这套课设的约束条件很明确:图形用户界面、局域网内运行、程序既是服务器又是客户。在这个前提下,Java Swing加java.net原生Socket就是最稳的组合。Swing是JDK自带的,不引入任何第三方依赖;Socket和DatagramSocket也是标准库,javac编完就能跑。为什么不选WebSocket?WebSocket要建立在HTTP握手之上,通信双方得有明确的“服务端”,和P2P“人人平等”的模型天然冲突,课设评审也不看重这个。为什么不选Netty?在一个局域网课设里引入异步事件循环框架,复杂度直接翻倍,跑起来还需要额外jar包,得不偿失。

一句话总结技术栈:Java 8 + Swing + Socket + DatagramSocket,全部靠JDK内置类搞定。这种选型哲学也是学生课设应该有的样子——把精力放在协议设计和并发处理上,而不是花在环境搭建上。

2.2 目录结构与核心类职责

从class文件反推,这套程序的类职责大概如下表:

类名职责备注
SendIPtoLAN启动时向局域网广播自己的注册信息用UDP广播实现“扫描网段在线对等方”
ReceiveIP监听UDP端口,接收广播和应答内部类GetPacket负责循环收包
testSever主入口,启动3333端口TCP服务类名Sever是拼写遗留,不影响运行
testSever$ServerThread处理每个TCP连接每accept一个连接就new一个线程
testSever$ServerThread$FileSave接收文件流并写入磁盘文件传输的落盘逻辑
ChatWindow聊天主窗口消息列表、输入框、文件按钮都在这里
friendlist_doubleclick对等方列表双击监听器双击触发私聊窗口打开
sendFilebt_adapter发送文件按钮事件适配器按钮叫sendFilebt,应该是sendFileButton缩写

说实话,testSever和sendFilebt_adapter这两个命名一看就是课设现场写出来的,拼写随意、大小写不统一,但逻辑结构反而清晰:网络层、UI层、事件层分得开,读起来不费劲。如果我要给这份源码写注释,会保留这些类名不动,只在类头上标注职责,因为改名意味着动所有引用,课设阶段不值当。

2.3 双通道模型:UDP找人与TCP传话

整个系统最核心的架构决策,是网络层分了两个通道:UDP负责“发现”,TCP负责“传输”。这个分工不是拍脑袋,而是由两种协议的特性决定的。

UDP的优势是支持广播——一个DatagramPacket发到255.255.255.255,整个子网都能收到。P2P场景里,你不知道网段里有哪些机器在线,没办法逐个建立TCP连接去探测,广播是唯一高效的方式。TCP做不到广播,它必须知道对方的IP才能发起三次握手。所以“扫描在线对等方”这一步必须走UDP。

TCP的优势是可靠、有序。聊天消息不能丢,文件更不能缺字节。消息和文件走TCP连接,借助ACK机制和重传机制保证数据完整。这也是计网课设里老师最想看到的考点:两种协议的使用场景判断。

还有一个细节值得注意:3333端口被两个角色共用。TCP监听用ServerSocket绑定3333,UDP的DatagramSocket也绑定3333。这么设计的好处是协议简单——不管TCP还是UDP,对端都往3333发就行,不用记两个端口。坏处是防火墙拦截时两个通道一起挂,后面避坑章节会重点讲。

3. 核心链路拆解:从UDP广播找人到TCP传文件的完整流程

3.1 对等方发现:SendIPtoLAN广播与ReceiveIP应答

先看注册协议的消息格式。题目要求“双方交换的消息格式自己根据需要定义,至少包括用户名、IP地址”,我用的是管道符分隔的文本协议:

REGISTER|用户名|所在组|本机IP ACK|用户名|所在组|本机IP MSG_文本内容 FILE_|文件名|文件大小

用|做分隔符在Java里解析成本极低,String.split("\\|")一行搞定,比JSON省去序列化依赖。文本协议还有一个好处:抓包时肉眼可读,调bug方便。

下面是SendIPtoLAN发送广播的代码逻辑,我没有贴完整类,只贴核心方法:

public void broadcastRegister(String userName, String groupName) { DatagramSocket socket = null; try { socket = new DatagramSocket(); // 允许设置广播选项,某些系统默认关闭 socket.setBroadcast(true); // 构造注册消息:REGISTER|用户名|所在组|本机IP String msg = "REGISTER|" + userName + "|" + groupName + "|" + InetAddress.getLocalHost().getHostAddress(); byte[] buf = msg.getBytes("UTF-8"); // 广播地址255.255.255.255,端口3333 InetAddress broadcastAddr = InetAddress.getByName("255.255.255.255"); DatagramPacket packet = new DatagramPacket(buf, buf.length, broadcastAddr, 3333); socket.send(packet); socket.setSoTimeout(3000); // 循环接收应答,直到超时 while (true) { byte[] recvBuf = new byte[1024]; DatagramPacket recvPacket = new DatagramPacket(recvBuf, recvBuf.length); socket.receive(recvPacket); String response = new String(recvPacket.getData(), 0, recvPacket.getLength(), "UTF-8"); if (response.startsWith("ACK|")) { Peer peer = parsePeer(response, recvPacket.getAddress()); peerListModel.addElement(peer); } } } catch (SocketTimeoutException e) { // 3秒后没有新应答,结束扫描 System.out.println("广播扫描完成"); } catch (Exception e) { e.printStackTrace(); } finally { if (socket != null) socket.close(); } }

逻辑说明:程序启动后先发一条UDP广播,然后阻塞等待应答,setSoTimeout(3000)设置了3秒超时——3秒内收到的所有ACK包都被解析成对等方加入列表,超时后自动结束扫描。这个超时值很关键,设太短会漏掉响应慢的机器,设太长会让用户等太久。

参数说明:255.255.255.255是受限广播地址,能覆盖当前子网所有主机;DatagramSocket()无参构造会让系统分配一个随机临时端口,不影响接收应答,因为应答包会带上发送方的地址和端口;parsePeer从ACK消息里拆出用户名、所在组合IP,组装成Peer对象塞进JList的数据模型。

再来看看ReceiveIP一侧是怎么处理收到的广播。ReceiveIP$GetPacket这个内部类,名字已经说明了它的工作方式——一个专门收包的循环线程:

public class GetPacket implements Runnable { @Override public void run() { byte[] buf = new byte[1024]; DatagramPacket packet = new DatagramPacket(buf, buf.length); while (running) { socket.receive(packet); // 阻塞等待广播 String msg = new String(packet.getData(), 0, packet.getLength(), "UTF-8"); if (msg.startsWith("REGISTER|")) { // 别人广播上线:先把对方加入自己的列表 Peer peer = parseRegister(msg, packet.getAddress()); if (!isSelf(peer)) { peerListModel.addElement(peer); } // 再回一条ACK把自己的信息告诉对方 String ack = "ACK|" + myName + "|" + myGroup + "|" + myIp; byte[] ackBuf = ack.getBytes("UTF-8"); DatagramPacket ackPacket = new DatagramPacket( ackBuf, ackBuf.length, packet.getAddress(), packet.getPort()); socket.send(ackPacket); } else if (msg.startsWith("ACK|")) { // 自己广播后的应答,SendIPtoLAN那边也会收到 Peer peer = parsePeer(msg, packet.getAddress()); if (!contains(peer)) { peerListModel.addElement(peer); } } } } }

逻辑说明:这个线程常驻后台,专门等别人发REGISTER。收到别人的注册广播后做了两件事——把自己列表里加上对方,同时回一条ACK。对方收到ACK后再把本机加进它的列表。这样一来,A广播一次,A和所有在线对等方就互相知道了,符合题目里“对等方A把回应消息的其它对等方加入用户列表”的要求。

参数说明:packet.getAddress()和packet.getPort()非常重要——不能自己构造回包地址,因为广播包的源端口是发送方的临时端口,直接取packet里带的地址和端口回,才能保证ACK抵达正确的进程。另外isSelf判断要保留,否则自己广播的REGISTER会被自己的ReceiveIP收到,把自己加进列表,出现“自己和自己聊天”的诡异情况。

3.2 3333端口的TCP服务端:testSever与ServerThread

UDP负责把人凑齐,TCP负责干活。testSever这个类的职责是启动TCP服务端,监听3333端口。先看它最外层的accept循环:

public class testSever { public static void main(String[] args) throws Exception { // 监听3333端口,和UDP发现共用一个端口号 ServerSocket serverSocket = new ServerSocket(3333); System.out.println("服务端已启动,监听端口3333"); while (true) { // 阻塞等待客户端连接 Socket socket = serverSocket.accept(); System.out.println("收到连接:" + socket.getInetAddress()); // 每个连接一个线程,互不阻塞 new Thread(new ServerThread(socket)).start(); } } }

accept()是阻塞方法,有连接进来才返回一个已建立的Socket。这里必须用多线程——如果串行处理,一个客户端正在传大文件,其他客户端的消息就得排队等着,体验极差。每accept一个连接就new Thread,是最简单也最有效的并发模型。

ServerThread的run方法处理具体数据,我按协议分成文本和文件两路:

public class ServerThread implements Runnable { private Socket socket; public ServerThread(Socket socket) { this.socket = socket; } @Override public void run() { try { DataInputStream in = new DataInputStream( new BufferedInputStream(socket.getInputStream())); // 先读4字节int,拿到数据长度,解决粘包问题 int len = in.readInt(); byte[] data = new byte[len]; in.readFully(data); // 必须读满len个字节 String payload = new String(data, "UTF-8"); if (payload.startsWith("MSG_")) { // 文本消息:直接追加到聊天窗口 String content = payload.substring(4); SwingUtilities.invokeLater(() -> { chatArea.append(content + "\n"); }); } else if (payload.startsWith("FILE_")) { // 文件头:FILE_|文件名|文件大小 String[] parts = payload.split("\\|"); String fileName = parts[1]; long fileSize = Long.parseLong(parts[2]); // 交给FileSave处理落盘,避免阻塞当前线程 new Thread(new FileSave(socket, fileName, fileSize)).start(); return; // FileSave接管socket,当前线程不再处理 } in.close(); socket.close(); } catch (Exception e) { e.printStackTrace(); } } }

逻辑说明:DataInputStream是InputStream的包装,提供readInt()和readFully()这类按需读取的便捷方法。先读int长度再读内容是TCP编程的标准套路——TCP是流式协议,没有消息边界,如果不加长度前缀,接收端根本不知道一条消息在哪结束、下一条从哪开始。readFully保证读够长度,不会因为网络抖动只读到半个包。

参数说明:MSG_和FILE_是两个协议前缀,对应2.1里定义的文本协议。MSG_后面直接跟聊天内容;FILE_后面是管道符分隔的文件名和大小,这里把文件接收单独交给FileSave线程,因为文件可能很大,几秒甚至几十秒的IO不能让连接处理线程一直占着。

ServerThread$FileSave的落盘逻辑,核心就是循环读写:

public class FileSave implements Runnable { private Socket socket; private String fileName; private long fileSize; @Override public void run() { try { // 从socket读取剩余的文件字节流 InputStream in = socket.getInputStream(); FileOutputStream fos = new FileOutputStream("received_" + fileName); byte[] buffer = new byte[8192]; long totalRead = 0; int readSize; while (totalRead < fileSize && (readSize = in.read(buffer)) != -1) { fos.write(buffer, 0, readSize); totalRead += readSize; // 更新进度条,注意切回EDT线程 long finalTotal = totalRead; SwingUtilities.invokeLater(() -> { progressBar.setValue((int) (finalTotal * 100 / fileSize)); }); } fos.close(); socket.close(); } catch (Exception e) { e.printStackTrace(); } } }

逻辑说明:文件头里已经告诉接收方文件总大小,所以循环条件用totalRead < fileSize控制,读满为止。buffer用8KB的数组,兼顾内存占用和IO效率。进度条计算公式finalTotal * 100 / fileSize得到的是0到100的整数,作为JProgressBar的值。

3.3 消息与文件传输:ChatWindow、friendlist_doubleclick与sendFilebt_adapter

UI入口是ChatWindow,它的布局很简单:上方一个只读的JTextArea显示历史消息,中间是对等方列表JList,下方一个输入框加“发送”按钮和“发送文件”按钮。friendlist_doubleclick是JList的双击监听器,双击一个对等方就打开一个新的私聊窗口。

发送文本的核心代码:

Socket socket = new Socket(peer.getIp(), 3333); DataOutputStream out = new DataOutputStream( new BufferedOutputStream(socket.getOutputStream())); String msg = "MSG_" + inputField.getText(); byte[] data = msg.getBytes("UTF-8"); out.writeInt(data.length); // 先写长度 out.write(data); // 再写内容 out.flush(); socket.close();

逻辑说明:这里和ServerThread的读取是严格对称的——先writeInt写长度,再写内容,接收方才能正确还原消息边界。这组对称的读写代码是整个TCP通信的核心契约,发送方和接收方必须一致,否则解析错乱。

sendFilebt_adapter是发送文件按钮的事件适配器,我直接继承了MouseAdapter或实现了ActionListener。处理文件逻辑时有一个重要教训——不能在事件线程里做IO,否则界面卡死:

new Thread(() -> { try { File file = new File(filePath); Socket socket = new Socket(peer.getIp(), 3333); DataOutputStream out = new DataOutputStream( new BufferedOutputStream(socket.getOutputStream())); // 先发文件头:FILE_|文件名|文件大小 String header = "FILE_|" + file.getName() + "|" + file.length(); byte[] headerBytes = header.getBytes("UTF-8"); out.writeInt(headerBytes.length); out.write(headerBytes); // 再发文件内容 FileInputStream fis = new FileInputStream(file); byte[] buffer = new byte[8192]; int readSize; long totalSent = 0; while ((readSize = fis.read(buffer)) != -1) { out.write(buffer, 0, readSize); totalSent += readSize; // 进度条更新必须回到Swing事件线程 long sent = totalSent; long total = file.length(); SwingUtilities.invokeLater(() -> { progressBar.setValue((int) (sent * 100 / total)); }); } out.flush(); fis.close(); socket.close(); } catch (Exception e) { e.printStackTrace(); } }, "FileSendThread").start();

逻辑说明:文件发送的协议顺序是“文件头先走,文件体后走”。接收方ServerThread先读到文件头,解析出文件名和大小,然后FileSave接着从同一个socket流里把文件字节读出来。这里有个隐蔽的坑——FILE_的文件头发出去后,发送方紧接着写文件内容,两段数据在TCP流里是无缝连接的,接收方必须readFully先读完文件头,再进入文件体循环,顺序一错整个文件就废了。

参数说明:buffer用8192字节,即8KB。这个值经过长期实践,在局域网环境里吞吐量足够,内存占用也可控。每次write之后没有立刻flush,而是全部写完再flush,减少系统调用次数;文件较大时这个优化能明显提升发送速度。

4. 跑通与复现:从源码到两台机器互发消息的实测路径

4.1 环境准备与项目导入

这套代码跑起来的门槛很低:JDK 8及以上就够,不需要Maven,不需要Gradle,不需要任何第三方jar。我一般直接在IntelliJ IDEA里新建一个空项目,把源码文件全部拷贝到src目录,IDEA会自动识别并编译。如果你习惯命令行,下面这个流程更直接。

准备工作清单:

  • 安装JDK 8+,java -version能正常输出版本号
  • 两台上网设备,或者一台机器双开两个Java进程做回环测试
  • 确保局域网互通——两台机器能互相ping通
  • 关闭防火墙,或在防火墙里放行3333端口的TCP和UDP入站

防火墙是最容易被忽略的一步。UDP广播发出去了,对方机器的防火墙直接丢弃,表现为“我广播了,但列表永远是空的”。Windows上最简单的验证方式是:运行窗口输入wf.msc打开高级安全防火墙,在入站规则里新建一条规则,放行3333端口TCP+UDP。

4.2 编译与启动顺序

命令行编译的命令:

javac -encoding UTF-8 *.java

必须带上-encoding UTF-8,否则含中文的源码在Windows默认GBK编码下编译出来是乱码。编译成功后目录里会生成一堆.class文件,主入口是testSever:

java testSever

启动顺序有讲究。正确姿势是先启动A机器上的实例,再启动B机器上的实例。A先进入等待接收状态,B启动时发广播,A收到后回ACK,两边列表互相出现对方。如果顺序反了也没关系,A启动时B已经在线,A的广播B能收到——P2P模型里无论谁先启动,后启动的那一方都会主动广播,先启动的一方靠监听端口发现对方。

单机双开做测试时,同一个JVM里的两个进程都需要绑定3333端口。Windows和Linux同一台机器上允许不同进程绑定同一个端口吗?答案是区分场景:TCP的ServerSocket绑定3333,两个进程会冲突,第二个会报Address already in use。所以双开测试时,要么改第二个实例的端口,要么借助虚拟网卡,要么直接在两台物理机或两台虚拟机上测。我一般用VMware开两台Linux虚拟机,各配一个NAT网卡的独立IP,跑起来最真实。

4.3 功能验证清单

我把课设验收标准拆成六个步骤,照着走一遍就知道系统功能完不完整:

  1. 在A机器启动一个实例,设置用户名“A同学”、所在组“一组”
  2. 在B机器启动一个实例,设置用户名“B同学”、所在组“二组”
  3. 观察A的“对等方列表”——B应该出现在列表里,反之亦然
  4. 双击列表里的B,打开聊天窗口,输入“你好B”,点发送
  5. B的聊天窗口里能看到A发的消息,B回一条,A能看到
  6. 点击“发送文件”按钮,选一个大小约1MB的文件发给B,观察进度条走完,B的本地目录出现received_文件名

第3步很容易出问题:如果列表里看不到对方,优先检查防火墙和网段。执行ping B的IP看通不通,再看两台机器子网掩码是否一致——一个是255.255.255.0、一个是255.255.0.0,广播域就不同,UDP广播永远无法跨子网让对方收到。

4.4 网络连通性快检命令

验证3333端口TCP监听是否正常:

netstat -an | grep 3333

Windows用netstat -an | findstr 3333。输出里应看到0.0.0.0:3333 LISTENING,说明ServerSocket起来了。再执行:

telnet 目标机器IP 3333

能连通说明TCP通道正常,连不上十有八九是防火墙。

UDP通道没法用telnet测,验证方式更直接:在A机器后台开着程序,在B机器用nping或简单Java程序往3333发任意UDP包,看A的ReceiveIP那边有没有日志输出。没有日志输出就说明UDP包被拦了,先放行防火墙再跑。

5. 避坑记录:局域网P2P聊天调试中的五个高频翻车点

5.1 防火墙拦截:广播有去无回

现象:程序正常启动,用户名和组都设置好了,广播也在发,但对等方列表永远是空的,等多久都刷不出来。

原因:系统防火墙默认拦截未授权程序的入站连接。UDP广播发出去了,对方的防火墙把包丢掉,对方收不到你的REGISTER,自然不回ACK;即使对方广播过来,你的防火墙同样会拦截。

解决:在Windows防火墙入站规则里放行3333端口的TCP和UDP,或者直接给java.exe添加“允许”规则。更快的办法是把网络配置文件改成“专用网络”,Windows默认对专用网络的防火墙限制要宽松一些。Linux环境用sudo ufw allow 3333/tcp和sudo ufw allow 3333/udp放行。改完重启程序,列表立刻能刷出来。

5.2 单机双开调试:端口冲突与看不到自己

现象:想在本地开两个实例做测试,第二个实例启动直接报java.net.BindException: Address already in use;或者两个实例分别改了端口,结果互相在列表里看不到对方。

原因:3333端口被第一个实例占用,第二个实例的ServerSocket(3333)无法再绑定。改端口虽然避开了冲突,但两边监听的端口不一致,广播的REGISTER发到3333,对方在另一个端口监听,根本收不到。

解决:能用两台机器测就别单机双开,这是最省心的方法。实在只有一台机器,就起两台VMware虚拟机,网络模式选NAT,虚拟机之间天然在同一网段。另一个思路是给本机网卡加一个回环适配器(Microsoft Loopback Adapter),手动配一个额外IP,一个实例绑原IP,一个实例绑回环IP,也能模拟两台主机。

5.3 消息粘包与中文乱码:TCP流的边界问题

现象:发消息偶尔正常,偶尔两条消息黏在一起显示成一行;中文内容偶尔变成“?????”,或者后半段是乱码。

原因:TCP是流式协议,多次write写入的数据会被合并成一段连续字节,接收方无法直接判断哪一截是第一条消息、哪一截是第二条。中文乱码则是因为发送方用了默认编码(Windows下是GBK),接收方用UTF-8解码,两边编码不一致必然乱。

解决:协议里统一加长度前缀。发送方先把消息字节长度用writeInt写进去,再写内容;接收方先readInt拿到长度,再readFully读对应长度的字节,这样粘包和半包同时解决。编码统一硬性规定UTF-8,编译器加-encoding UTF-8,代码里getBytes("UTF-8")和new String(data, "UTF-8")成对出现。

5.4 大文件发送界面假死:Swing EDT线程阻塞

现象:给对等方发一个几百MB的视频,点完“发送文件”按钮,整个窗口立刻拖不动、点不了,进度条也不动,要等文件传完界面才恢复。

原因:文件发送的IO操作直接跑在Swing事件分发线程(EDT)上。EDT负责所有界面重绘和事件响应,一旦被长时间阻塞,窗口就进入“未响应”状态。进度条不动是因为文件还没读完,进度更新代码根本没机会执行。

解决:文件收发逻辑全部丢到独立线程里。发送按钮触发后new Thread(...).start()跑IO,ProgressBar的更新用SwingUtilities.invokeLater切回EDT。接收侧也同理,FileSave本身就应该是一个独立线程。改完后界面始终保持流畅,就算文件很大也能一边传一边拖动窗口。

5.5 广播地址与子网掩码不匹配:扫描不到任何对等方

现象:几台机器明明连着同一个路由器,IP都是192.168.1.x,但程序互扫不到对方。执行ipconfig发现有人子网掩码是255.255.255.0,有人是255.255.0.0。

原因:UDP广播255.255.255.255虽然叫“受限广播”,但实际能不能跨子网取决于路由器的隔离策略和主机的路由配置。子网掩码不一致时,一个主机认为对方不在同一广播域,广播包可能只发到本机网段就结束了。

解决:先用ipconfig或ifconfig确认所有参与通信的机器子网掩码一致,以及网段相同(比如都是192.168.1.0/24)。跨网段场景就不要依赖广播了,改成配置一个已知的“列表服务器”地址,或者让用户手动输入对等方的IP——这是课设阶段最务实的兜底方案。

6. 二次开发指南:把课设改造成带心跳和持久化的可用工具

这版课设的雏形能跑通验收,但离“通用工具”还差三步,每一步我都在实际改造中踩过一遍,直接给改造思路。

第一步,加心跳与离线检测。现在项目里,对等方退出后列表里还留着它的名字,点它通信直接报连接拒绝。你需要一个后台定时任务,每5秒广播一次HELLO|用户名,同时记录每个对等方的最后心跳时间。超过15秒没收到心跳就从列表里剔除。这个机制对P2P场景尤其重要,因为不存在中心服务器来维护在线状态,全靠节点互报“我还在”。

// 心跳线程:每5秒广播一次自己的存在 ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() -> { String hello = "HELLO|" + myName + "|" + myIp; // 复用SendIPtoLAN的发送代码,构造DatagramPacket发到3333端口 broadcast(hello); }, 0, 5, TimeUnit.SECONDS); // 收到HELLO时更新心跳时间 Map<String, Long> lastSeen = new ConcurrentHashMap<>(); if (msg.startsWith("HELLO|")) { lastSeen.put(peerKey(msg), System.currentTimeMillis()); }

第二步,协议升级,加消息序号与ACK。现在文本消息发出去没有回执,对方收到与否完全不知道。改造时每条MSG_消息带上自增序号,接收方处理完回一条ACK_MSG|序号。发送方如果在超时时间内没收到回执,就重发。这个机制本质是在TCP之上做应用层确认——TCP只管字节到达,不管业务消息是否完整呈现给用户。

第三步,持久化与架构演进。聊天记录目前关窗就丢,我一般会引入SQLite或直接追加写文件。每条消息追加一行日志,启动时按对话对象分组加载回聊天窗口。这不算难,但值得做。

从架构上讲,这套纯P2P模型在跨网段场景会失效——广播跨不了路由器。更成熟的演进方向是中心化信令服务器:服务器只管注册、维护在线列表、转发小消息,文件传输仍然走P2P直连。这就是从“纯P2P”演进到“混合P2P”的过程,也是很多即时通信软件的真实形态。

从那以后,我每次跑局域网通信类项目都会强制走一遍流程:先确认防火墙放行、再确认子网掩码一致、最后才打开代码查bug。这套课设的代码虽然命名随意,但协议设计和线程模型都是合格的范本,值得照着写一遍。希望帮到你。

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

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

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

立即咨询