简介:一份面向广东工业大学计算机网络课程设计的完整项目资料,实现基于P2P架构的局域网即时通信系统,涵盖图形界面、用户注册、在线对等方扫描与发现,以及基于TCP连接的消息和文件传输等核心功能,适合计网课设学生及需要参考P2P通信实现的学习者。压缩包共158个文件、约20.77MB,包含14个Java源码、17个编译后class文件及XML配置等;源码中的ChatWindow聊天窗口、ProgressBar进度条、sendFilebt_adapter文件发送适配器、SendIPtoLAN对等方发现等类模块,清晰呈现了从网段扫描、端口3333通信到消息渲染的完整实现路径。已有424人学习下载。对完成课设任务或深入理解P2P局域网通信机制而言,这套资料提供了可直接对照的代码结构与界面设计思路,能帮助读者少走弯路、快速搭建同类系统。
1. 广工计网课设这道P2P局域网即时通信题:不是写个QQ,而是把“没有服务器”的假设写进每一行代码
广工计算机网络课程设计里,基于P2P的局域网即时通信系统是点名率极高的题目。多数同学第一反应是“无非做一个聊天软件,把服务器换成广播”,真写起来才发现:没有中心节点之后,节点怎么被发现、在线状态谁来维护、消息发给谁、对端崩溃了怎么收敛,全都要自己负责,复杂度比C/S高一个量级。这道题能同时覆盖应用层报文设计、UDP广播与TCP长连接两套协议栈、多线程并发三个大头,是计网课设里性价比最高的选题之一。适合手里已经写过socket、想用一份课设把教材上P2P概念落到代码的人。
2. 架构选型:丢掉C/S惯性,先定身份、消息信封和端口三件事
2.1 P2P和C/S在局域网里的真实区别:责任没有外包给服务器
C/S架构下,“对方是谁、在哪、是否在线”由服务器回答,客户端只需把消息交给服务器,剩下都是服务器的事。P2P把这份责任平分给每一个节点,“基于P2P的局域网即时通信系统”的真正难点不是界面,也不是消息盒子,而是分布式环境下的状态一致性:每个节点都只有自己视角里的在线表,没有任何节点拥有全局真相。
还要区分两种P2P:纯P2P和有目录节点的混合P2P。混合P2P会有一个Rendezvous节点,但它不中转消息,只存“当前在线节点名单”;纯P2P则连这个名单都靠节点自己散布。课设题目只要不明确要求目录节点,我建议做纯P2P——答辩时一句话就能说清楚“我的系统里没有一台机器中转任何消息”,这也最贴合《计算机网络自顶向下》教材里P2P的“节点既是客户端又是服务器”的描述。
这个观点直接支撑后面的端口设计:既然每个节点都是服务器,每个节点就必须在同一网段内有一个固定的可寻址地址。在局域网里这个地址就是IP加端口。
2.2 先立好的四件套:节点ID、显示名、UDP广播端口、TCP监听端口
动手写代码以前,必须先定协议身份。很多课设翻车都翻在“用IP+用户名当身份”:同一个Wi-Fi里IP会变(DHCP租约到期重新分配)、用户名会重名,对端一多在线表就乱。
| 参数 | 取值建议 | 理由 |
|---|---|---|
| NODE_ID | uuid4().hex[:8] | 全局唯一,不随IP变化 |
| DISPLAY_NAME | 用户名@主机名 | 展示用,不参与路由 |
| BEACON_PORT | 8899(UDP) | 广播、心跳、bye都走这里 |
| TCP_LISTEN_PORT | 8900(固定) | 每节点都监听同一个端口,简化连接竞争 |
| HEARTBEAT_INTERVAL | 5 秒 | 在线状态刷新粒度 |
| TIMEOUT_THRESHOLD | 15 秒 | 心跳间隔的3倍,容忍丢包不误删 |
固定TCP监听端口这条值得展开。如果监听端口是随机生成的,广播包里就必须携带tcp_port字段,对端收到后要解析、再发起连接;一旦端口被占用,连接逻辑还会出岔子。固定8900意味着所有节点的服务端口一致,连接建立可以做成“一个规则:字典序小的节点主动连对方”,不必双方都尝试连接。这也顺带解决了同时发起连接的四元组竞争。
2.3 线程模型:发现、会话、UI三块互不拧巴
把系统拆成三个模块。发现模块负责UDP:一个beacon线程周期广播Hello/心跳,一个receiver线程收包;会话模块负责TCP:一个listener线程接受连接,每个对端一条session线程读写消息;UI模块只做展示和输入,不碰任何socket。三个模块之间通过两个queue.Queue传递事件,避免跨线程直接访问控件。
我见过不少课设为了省事,把UDP接收和TCP接收塞进同一个线程里轮询,或者让GUI线程直接阻塞在socket.recv上——这在单机demo里没问题,一旦两台机器一测,界面整个冻结。线程模型这件事应该在架构阶段就定下来,而不是功能写完后“哪里不对补哪里”。
3. 节点发现与在线维护:UDP广播加心跳,没有服务器也能把在线表做稳
3.1 上线广播:连续发三次Hello,等对端回应
第一个要写的是发现协议。新节点启动后,向局域网广播一个Hello报文。UDP是尽力而为的,广播在二层交换机和Wi-Fi下都可能丢包;连续发三次,每次间隔2秒,能把丢包概率压到可接受范围。对应代码如下:
import json import socket import time import uuid BEACON_PORT = 8899 # UDP广播/心跳统一端口 TOTAL_HELLO = 3 # 上线广播次数 HELLO_GAP = 2 # 两次广播间隔,秒 class P2PNode: def __init__(self, name: str, broadcast_ip: str = "255.255.255.255"): self.node_id = uuid.uuid4().hex[:8] self.name = name self.broadcast_ip = broadcast_ip def send_hello(self): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) hello = { "type": "hello", "node_id": self.node_id, "name": self.name, "tcp_port": 8900, } payload = json.dumps(hello, ensure_ascii=False).encode("utf-8") for _ in range(TOTAL_HELLO): # 广播到受限广播地址,同一网段内所有监听8899的节点都能收到 sock.sendto(payload, (self.broadcast_ip, BEACON_PORT)) time.sleep(HELLO_GAP) sock.close()逻辑说明:为什么用sendto而不是connect+send?因为UDP广播天然是给网段内所有节点,不需要建立连接;setsockopt的SO_BROADCAST必须在Windows和Linux上都显式打开,否则sendto会报PermissionError。部分机器上还需要把socket绑定到具体网卡(绑定0.0.0.0即可),如果遇到“自己发自己收不到”,第一反应是查无线网卡是否允许广播。
收到Hello的节点怎么做?把节点ID写入在线表,给对端回一个“hello_ack”表示“我认识你了”。注意这里不能依赖TCP连接去回,因为TCP可能还没建立,ack仍然走UDP,和解决“TCP连接由谁发起”的问题是两回事。
3.2 心跳状态机:从ONLINE到OFFLINE要经历三个状态
Hello只在上线瞬间有效。进程崩溃、拔网线、休眠唤醒都不会发bye,所以必须用心跳兜底。常见做法是每个节点每5秒广播一次heartbeat,接收端收到任何来自该节点的报文(包括聊天消息)都刷新last_seen时间戳;另开一个扫描线程,每2秒检查在线表,超过15秒没刷新的节点标记为SUSPECT,再过一轮还是超时,才从在线表移除。
这样设计的好处是避免瞬时抖动:Wi-Fi下丢包是常态,如果5秒一次收不到就立刻删除,在线列表会疯狂闪烁。超时阈值设成心跳间隔的3倍,允许丢1到2个心跳包而不误删。扫描代码核心如下:
import threading import time def prune_offline(self) -> None: """定期清理超时节点:先标记再删除,避免抖动误删""" now = time.time() stale = [] with self.lock: for nid, info in self.peers.items(): if info["state"] == "ONLINE" and now - info["last_seen"] > 15: info["state"] = "SUSPECT" elif info["state"] == "SUSPECT" and now - info["last_seen"] > 20: stale.append(nid) for nid in stale: self.peers.pop(nid, None) if stale: # 通知UI线程刷新在线列表,不在工作线程里直接操作控件 self.ui_queue.put(("peer_left", stale))逻辑说明:state字段是两个档位的状态机,ONLINE到SUSPECT再到删除,间隔分别是15秒和20秒。为什么SUSPECT还要多留5秒?因为收到一次迟到的心跳就能让节点恢复ONLINE(在更新last_seen时把state重置回ONLINE),给网络抖动留余量。这里有一把self.lock保护在线表,不持锁做UI通知,避免锁内阻塞。
3.3 在线表的数据结构与并发访问红线
在线表不推荐用list,推荐用字典,key是node_id,value里放name、ip、tcp_port、last_seen、state五个字段。字典的优势是“按ID精确查找”是O(1),收到对端消息时能立刻反查发送者信息并更新其last_seen。写代码时对在线表的所有读写都包在同一把锁里,不指望字典自己线程安全:
def snapshot_peers(self) -> list: """返回在线列表快照,供UI和消息路由使用""" with self.lock: return [ { "node_id": nid, "name": info["name"], "ip": info["ip"], "tcp_port": info["tcp_port"], "last_seen": info["last_seen"], "state": info["state"], } for nid, info in self.peers.items() ]快照比直接暴露内部字典好使,UI线程不管在线表怎么变,拿到一份拷贝就能渲染;消息路由要遍历在线节点时也用快照,遍历过程不会被心跳写操作打断。这条设计看起来简单,却是多数P2P课设翻车的重灾区:不加速接锁导致遍历时KeyError,加锁后又在持锁时调用GUI,导致死锁甚至卡死。
4. 消息收发与文件传输:TCP长连接、粘包拆包和分片传输
4.1 谁连谁由节点ID决定,避免双连接
两个节点互相发现后,需要在TCP层建立会话。一个常见错误是双方都主动connect对方,于是产生AB和BA两条连接,消息在各连一条的混乱里丢失。解决办法是把“主动方”的决定权交给静态比较规则:节点ID字典序小的那台负责发起连接,另一台被动接受。
def ensure_session(self, peer: dict): """比较node_id决定连接方向,避免双方同时connect产生双连接""" peer_id = peer["node_id"] if peer_id in self.sessions: return if self.node_id < peer_id: # 本节点是主动方,发起TCP连接 sock = socket.create_connection((peer["ip"], peer["tcp_port"]), timeout=5) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) self.sessions[peer_id] = sock self._start_session_reader(sock, peer_id) # 字典序大的一方什么都不做,等待对方连进来逻辑说明:TCP_NODELAY关闭了Nagle算法,局域网场景下延迟本来就不大,但聊天消息是一问一答式小包,关闭Nagle可以让每条消息立刻发出,不用等内核把多个小包攒在一起。节点ID是uuid生成的无序字符串,任意一方都有机会当主动方,不会出现某台机器永远被动等待的问题。接受连接的一方在accept后也要做一次“重复连接丢弃”校验,如果该peer的session已存在,直接close新socket。
4.2 用4字节长度前缀解决粘包和半包
TCP是字节流,不是报文流。recv(4096)一次可能收到半条消息,也可能一次收到三条消息拼在一起。所以发送方必须给每一条消息加帧头:4字节大端整数表示后续JSON的长度,接收方按“先收满4字节头部,再按长度收满消息体”的顺序循环。这是最经典、最容易答辩讲清楚的粘包方案。
import struct import json def send_frame(sock, obj: dict): body = json.dumps(obj, ensure_ascii=False).encode("utf-8") header = struct.pack(">I", len(body)) sock.sendall(header + body) def recv_frame(sock): """按帧接收:先收4字节长度前缀,再收完整消息体""" header = b"" while len(header) < 4: chunk = sock.recv(4 - len(header)) if not chunk: return None header += chunk body_len = struct.unpack(">I", header)[0] if body_len > 16 * 1024 * 1024: # 防单条消息撑爆内存 raise ValueError(f"frame too large: {body_len}") body = b"" while len(body) < body_len: chunk = sock.recv(body_len - len(body)) if not chunk: return None body += chunk return json.loads(body.decode("utf-8"))这里有个细节,body_len上限要设。如果不设,对端恶意或异常发一个超大长度前缀,接收端会一直阻塞在recv里试图读满根本不可能到达的字节数。16MB对文本聊天和文件分片都足够,文件本身不走这个通道传输元数据,这里只传控制信息。
4.3 文件分片:把大文件切成64KB,避免长连接被独占
文件直接塞进消息体是最常见的简化方案,但一个大文件会封住整条TCP连接,后续聊天消息在它后面排队,体验极差。更顺手的做法是把文件切成64KB分片,每片作为一个独立帧发送,帧里带msg_id、分片序号和文件元数据;接收端按msg_id累积分片,写临时文件,全部收齐后做CRC32校验。
CHUNK_SIZE = 64 * 1024 def send_file_chunks(sock, file_path: str, msg_id: str): import base64, os, zlib file_size = os.path.getsize(file_path) crc32_val = 0 with open(file_path, "rb") as f: seq = 0 while True: data = f.read(CHUNK_SIZE) if not data: break crc32_val = zlib.crc32(data, crc32_val) frame = { "type": "file_chunk", "msg_id": msg_id, "seq": seq, "file_name": os.path.basename(file_path), "file_size": file_size, "crc32": format(crc32_val, "08x"), "blob": base64.b64encode(data).decode("ascii"), } send_frame(sock, frame) seq += 1逻辑说明:分片base64编码后实际体积膨胀约33%,64KB一片base64后约87KB,在局域网里单帧发送没有压力;分片的好处是聊天消息能在分片间隙穿插发送,接收端也能逐片做简单校验。分片窗口大小建议控制在100片以内,避免接收端一次性申请太多内存缓冲。接收端按seq写盘,缺片时等超时后由发送端补发,这块逻辑课设里可以不做得太深,但至少要记录received_packets字典,避免因为乱序导致文件损坏。
5. 局域网P2P即时通信常见问题排查:从教室到宿舍都能复现的5个典型坑
5.1 广播发出去了,对端就是收不到
现象:A节点启动后hello代码没有任何异常,B节点却收不到任何包;反过来B广播A却收得到。
原因:Windows防火墙默认拦截Python解释器的UDP入站包;无线AP开启了“客户端隔离”,同一Wi-Fi下两台设备二层不通;代码里socket没有绑定到具体网卡,多网卡机器把广播发到了错误的出口。
解决:先ping通对方,确认二层链路;Windows里打开“允许应用通过防火墙”,把python.exe加入允许列表;socket创建后bind(("0.0.0.0", 0));如果AP有客户端隔离,插网线或到路由器后台关闭隔离,这是宿舍无线路由最常见的坑。
5.2 双连接竞争:消息发得出去一半,回不来一半
现象:A、B互相发现后,双方都能发消息,但聊天记录来回不一致,偶尔消息重复。
原因:两个节点都认为自己小(或都认为自己大),先后connect对方,建立了不同端口组合的两条连接;会话reader线程各读各的,消息自然乱序。
解决:节点ID字典序小的主动连、大的等listen,并在代码里做重复连接清理:accept到新socket时,若session字典里已有同peer_id的活跃socket,直接close新socket。这条规则要写在同一份代码的注释里,避免后来维护时又改出双连接。
5.3 幽灵在线:人已经退出了,在线列表还挂着名字
现象:A进程被任务管理器杀掉,B上A的影子还留在在线列表;点它发消息要等超时才报“发送失败”。
原因:A异常退出没有发bye,B的在线表只能靠心跳超时清理;心跳间隔和超时阈值设得不匹配,清理动作迟迟不触发。
解决:统一心跳间隔5秒、超时阈值15秒,扫描线程每2秒跑一次prune_offline;正常退出必须发bye(UDP包,不依赖TCP连接),bye到达后立即从在线表移除;异常退出就交给心跳超时兜底。评分时老师大概率会故意杀掉一个进程看在线表多久更新,这里要能说出“最多20秒后消失”。
5.4 中文消息到了对端变成乱码
现象:Windows上A发“你好”,B显示乱码或问号;英文消息一切正常。
原因:Windows控制台和文件默认编码是GBK,socket接收方decode用的又是系统编码,两边不一致。同一份代码在Linux跑正常,回到Windows就乱码,最容易误判成消息协议问题。
解决:协议层统一UTF-8,JSON序列化时ensure_ascii=False,发送encode("utf-8")、接收decode("utf-8")必须在所有出入口写显式编码,不依赖系统默认。控制台打印调试信息时先sys.stdout.reconfigure(encoding="utf-8");GUI控件本身接受UTF-8字符串,不会出问题。
5.5 测试3分钟没问题,跑5分钟后界面卡死
现象:功能都正常,窗口收一会儿消息就变成白屏/无响应,强制关闭后能看到进程仍在后台跑。
原因:socket接收线程直接调用了Tkinter的insert、configure等控件方法,Tkinter不是线程安全;多个线程同时操作同一控件时,事件循环被破坏。
解决:所有从socket线程产生的UI事件统一put进queue.Queue,主线程用root.after(100, poll_queue)每100毫秒取出并处理,不在任何工作线程触碰控件。这条规则也适用于Qt,只是Qt的跨线程信号槽可以替代queue,但queue方案最不容易出错。
6. 把课设做出区分度:群聊扩展、离线消息与一条命令的冒烟验证
6.1 群聊并不复杂:把单聊路由改为会话内广播
单聊消息帧里带to字段,路由时查在线表发送给指定节点。群聊最省事的实现是:群消息帧里不带具体to,带一个group_id;每个节点收到群消息,先判断自己是否在该群的成员列表,如果在就把消息转给会话中所有其它在线成员。这就是P2P模式下的“洪泛路由”,局域网节点数在几十台规模内完全适用。
6.2 离线消息:上线拉取比实时推送更适合P2P
P2P没有中心存储,A给离线的B发的消息,A先写进本地outbox;B上线完成发现、建连后再发一条fetch_messages请求,A把outbox里发给B的消息按序补发。这个“启动时拉取”模式把离线消息的压力从“永远在线”变成了“双方同时在线的短暂窗口”,也更符合P2P的分布式存储直觉。
6.3 冒烟验证:单机跑起两个节点自动发现
给所有socket相关地址做成构造参数注入后,测试就不需要真的两台机器。下面是单机冒烟脚本,在本地回环上启动两个节点,发送Hello并断言在线表更新:
# smoke_test.py import sys, time sys.path.insert(0, "./src") from node import P2PNode a = P2PNode("node_a", beacon_port=8899, broadcast_ip="127.0.0.1") b = P2PNode("node_b", beacon_port=8899, broadcast_ip="127.0.0.1") a.start() b.start() time.sleep(0.5) a.send_hello() # 手动触发一次上线广播 time.sleep(1.5) # 给receiver留出处理时间 peers = b.snapshot_peers() assert any(p["node_id"] == a.node_id for p in peers), "B 没有发现 A" print("P2P discovery OK")回环测试时广播地址已经不是255.255.255.255,而是127.0.0.1,这正说明发现模块把广播目标做成构造参数是值得的;在真实局域网里传入网段广播地址即可。配合抓包工具看UDP 8899端口到底发没发出去,比盯着代码猜快得多。
这份课设我一路做下来的最大教训是“在线表这把锁要最先设计,而不是最后补”。我第一版图省事让三个线程直接读同一个字典,结果联调时一半时间浪费在排查last_seen为什么突然变旧上;改成“锁保护 + 快照 + queue通知”后,后面的功能全都顺了。局域网P2P通讯本来就是并发问题集中营,把数据边界划清楚,远比多写几十行功能代码有价值。希望帮到你。
本文还有配套的精品资源,点击获取