ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python Socket实现局域网聊天与文件传输:TCP粘包处理与协议设计

Python Socket实现局域网聊天与文件传输:TCP粘包处理与协议设计 简介南京信息工程大学计算机网络课程设计套接字局域网通信软件资源包面向高校网络编程课程实践者完整实现了基于TCP/IP的局域网一对一私聊、群聊与文件传输三大功能。项目采用客户端/服务器架构服务器端负责监听连接、维护在线客户端并处理收发数据客户端则发起连接与消息交互一对一套接字连接隔离保障私密性群聊通过线程池管理并发连接并向在线成员广播文件发送经历分包、传输、重组和保存流程还需考虑网络带宽与丢包重传等问题。压缩包约5.09MB内含可运行的程序源代码与配套课程设计报告报告覆盖需求分析、系统设计、编码实现、测试调试及性能优化建议能够帮助读者掌握套接字接口、多线程编程和文件流操作等核心知识点。资源已有335人学习下载适合需要完成同类课程设计或希望深入理解局域网通信原理的本科生与开发者。1. 为什么课程设计选Socket局域网通信从一对一到群聊一次把TCP的账算清拿到“南京信息工程大学计算机网络课程设计”里的这个题目很多人第一反应是“做个聊天窗口”。真正动手才发现界面反而是最简单的一层卡人的是消息边界、多客户端并发、文件传输完整性这些计算机网络课本里反复讲、但代码里很容易翻车的东西。Socket局域网通信软件的核心不是花哨按钮而是把TCP连接模型和协议设计做扎实。这套方案适合集中在实验室里验收的课程设计场景也适合第一次写socket编程的工程新手。代码基于Python标准库socket不需要第三方框架网络包里的每一步都能在抓包工具里看到。本文给出一条从协议到代码再到报告的完整路线照着做能在一周内跑通一对一、群聊和文件传输并且能在报告里把设计理由写清楚而不是只贴代码。2. 先画协议再写代码消息帧设计、连接管理和状态机2.1 消息帧格式为什么不能用裸字符串直接传socket编程里最容易踩的第一个坑就是把TCP当成UDP来用觉得send(hello)之后对端就该一次性recv(hello)。但TCP是字节流协议没有消息边界。局域网内延迟低小消息偶尔还能一次收到一旦消息变多一次recv(1024)可能返回半条消息也可能返回两条半消息这就是粘包和半包。网络编程里没有“后悔药”唯一可靠的做法是在应用层自定义帧格式。发送端按照固定的头部结构把长度和类型组装好接收端严格按长度读取才能保证“收完一条再处理下一条”。import struct HEADER_STRUCT struct.Struct(!2sBBII) MAGIC bNJ # type: 0login 1chat 2file_meta 3file_data 4logout 5ack def build_frame(msg_type: int, sender: int, target: int, body: bytes) - bytes: header HEADER_STRUCT.pack(MAGIC, msg_type, sender, target, len(body)) return header body def parse_frame(data: bytes): if len(data) HEADER_STRUCT.size: return None magic, msg_type, sender, target, body_len HEADER_STRUCT.unpack_from(data) if magic ! MAGIC or len(data) HEADER_STRUCT.size body_len: return None body data[HEADER_STRUCT.size:HEADER_STRUCT.size body_len] return msg_type, sender, target, body!2sBBII表示网络字节序大端2字节magic1字节type1字节sender编号1字节target编号4字节body长度。magic用来做最基础的校验防止把意外连进来的非协议连接当成正常消息。sender和target用1字节最多支持255个在线用户课程设计规模完全够用如果以后要扩展超过255人可以把这两处改成H2字节头部结构同步调整。body长度用4字节无符号整数理论上最大4GB但实际不能把一个超大文件塞进单条帧。后面传输文件时会对body再做分块每块的body大小控制在几千字节避免单帧占用过多内存。2.2 连接管理与状态机一对一、群聊对TCP连接模型的不同要求一对一通信只需要两个socket连接服务端和客户端各持一端。群聊则必须引入中心服务器所有客户端先连接服务器再由服务器做消息路由。不要尝试在客户端之间做点对点直连虽然局域网内理论上可行但每台机器防火墙的入站规则会让第二台、第三台客户端很难建立新连接。中心服务器只需要开放一个端口所有入站流量都走那里管理和排错都更简单。一个TCP连接从accept到关闭应该用状态机管理。很多课设代码只维护一个socket字典不记录客户端是否发了登录包结果广播时把未登录的客户端也算进去在线列表永远不对。from enum import Enum class ClientState(Enum): INIT 0 # TCP已accept还未登录 ONLINE 1 # 已登录可收发消息 SHUTTING 2 # 正在关闭等待残留数据发完 class ClientSession: def __init__(self, conn, addr): self.conn conn self.addr addr self.state ClientState.INIT self.username None self.recv_buffer bytearray()为什么不能accept之后立刻把连接加入广播列表因为还不知道这个客户端是谁别人在在线列表里看到的是一个无名连接根本无法发起私聊。所以登录包必须是连接建立后客户端发送的第一条消息包含用户名或编号。服务端收到后把session从INIT改为ONLINE再把用户名写入映射表。状态迁移里最容易被忽略的是异常断开。TCP连接异常断开时服务端的recv会返回空字节如果不清理session在线列表里会留下一个幽灵用户其他人给他发私聊消息在服务器里积压甚至触发缓冲区写满。2.3 为什么要一个独立的recv_exact函数它是一切聊天不串包的前提socket.recv(1024)最多返回1024字节但实际可能少于这个数。如果把一次recv的结果当成完整消息去解析只要一次收到两条消息就会错位。正确做法是“先收固定长度头部再按头部里的body长度收正文”并且把“必须收满N字节”的逻辑独立出来。def recv_exact(conn, n: int) - bytes: chunks [] remain n while remain 0: data conn.recv(remain) if not data: raise ConnectionError(socket closed unexpectedly) chunks.append(data) remain - len(data) return b.join(chunks) def read_frame(conn): header recv_exact(conn, HEADER_STRUCT.size) magic, msg_type, sender, target, body_len HEADER_STRUCT.unpack(header) if magic ! MAGIC: raise ValueError(bad magic) body recv_exact(conn, body_len) return msg_type, sender, target, body注意recv()的参数是“剩余需要读取的字节数”不是固定缓冲区大小。每次循环只取本次收到的长度去扣减最终把所有分片拼起来保证不会多读下一个帧的数据。这样就不会出现“为什么socket接收到奇数字节、后面会补一个随机数”的现象——那不是系统补的是你多读了下一帧的头部把它当成了随机数据。网络框架里常见的no more data from socket错误本质也是对端关闭连接后你又调用recv用read_frame里的空字节判断就能主动识别。3. 用Python的socket库跑通一对一的完整链路客户端与服务端最小实现3.1 服务端绑定、监听、accept循环先把一对一的链路跑起来再逐步加功能。这里用线程处理每个客户端连接虽然一对一场景用不到多线程但为后面的群聊留好骨架。import socket import threading HOST 0.0.0.0 PORT 8888 server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((HOST, PORT)) server.listen(5) print(flistening on {HOST}:{PORT}) def handle(conn: socket.socket, addr): print(connected, addr) try: while True: data conn.recv(1024) if not data: break conn.sendall(back: data) except ConnectionResetError: pass finally: conn.close() print(closed, addr) while True: conn, addr server.accept() threading.Thread(targethandle, args(conn, addr), daemonTrue).start()bind的IP地址必须用0.0.0.0而不是127.0.0.1。127.0.0.1只监听本机回环网卡同一局域网内其他电脑连不进来0.0.0.0表示监听所有网卡既能本机测试也能跨机联调。SO_REUSEADDR是为了在调试时快速重启服务端否则端口可能因为TIME_WAIT状态被占用报Address already in use。listen(5)是内核accept队列的长度课程设计里10个客户端以内5已经够用。每个连接一个线程线程设置daemonTrue这样服务端主程序CtrlC退出时不会因为某个阻塞的recv线程而挂住。3.2 客户端连接、收发与优雅关闭客户端代码更简单但有一个参数值得注意connect只接受元组不接受字符串拼接。import socket SERVER_IP 192.168.1.100 PORT 8888 client socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.settimeout(5.0) client.connect((SERVER_IP, PORT)) client.sendall(bhello from client) reply client.recv(1024) print(reply) client.close()settimeout(5.0)很关键。如果服务端不存在或端口不通客户端会一直阻塞在connect上界面像死了一样。设了超时后5秒内连不上会抛socket.timeout至少能拿到一个可读的报错。跨机测试时把SERVER_IP改成服务端的局域网IP只在本机验证时写127.0.0.1就好。sendall和send的区别经常被忽略。send只负责向内核缓冲区提交数据可能只发送一部分就返回sendall会循环调用直到整个字节串都进入内核缓冲异常时才抛错。课程设计中无条件用sendall比send省心得多。3.3 先本机后跨机的联调步骤第一步在终端启动服务端看到listening on 0.0.0.0:8888。第二步新开一个终端运行客户端服务端打印connected 127.0.0.1:xxxx客户端打印back:hello from client本机链路就通了。跨机验证时先查服务端机器的局域网IP。Windows用ipconfigLinux/macOS用ifconfig或ip addr。然后在客户端机器上先ping 服务端IP能通再改代码里的SERVER_IP。如果ping不同优先检查两台机器是否在同一网段再检查防火墙是否放行Python进程和TCP 8888端口。这一步最快的验证工具是telnet 服务端IP 8888能看到端口开放说明网络通路没问题。如果是连接成功但马上断开多数是服务端线程里没做好循环或客户端没给服务端留出发送数据的时间。4. 加群聊和文件传输数据分发策略与粘包处理4.1 群聊的数据分发广播与私聊的消息路由群聊要求所有客户端都连接到服务端服务端维护两张映射表用户名到连接、连接到用户名。收到消息后根据type字段决定是广播还是私聊。广播需要遍历在线列表给除发送者之外的所有客户端转发私聊则根据target编号找到对应连接只发给一个人。conn_to_id {} id_to_conn {} lock threading.Lock() def broadcast(msg_type, sender_id, body): frame build_frame(msg_type, sender_id, 0, body) with lock: for cid, conn in list(id_to_conn.items()): if cid ! sender_id: try: conn.sendall(frame) except OSError: pass def unicast(msg_type, sender_id, target_id, body): frame build_frame(msg_type, sender_id, target_id, body) with lock: conn id_to_conn.get(target_id) if conn: conn.sendall(frame)lock必须包住遍历和send的整个过程否则两个线程同时给同一个socket发送数据可能出现字节交错接收端解析出来的帧全是乱的。list(id_to_conn.items())是为了在遍历时允许字典被其他线程修改否则会抛dictionary changed size during iteration。try OSError也不能省。客户端拔网线或直接断电时sendall会抛BrokenPipeError或ConnectionResetError这些都属于OSError。如果不捕获一个异常客户端就会打断整个广播循环其他客户端跟着掉线。4.2 发送文件先发元数据再分块传附MD5校验文件传输不能像聊天消息那样一帧发完。文件可能几十MB甚至更大必须分块读取、分块发送。这里采用“先发元数据再发文件数据”的两阶段方式。import hashlib, json, os def send_file(sock, target_id, path): md5obj hashlib.md5() with open(path, rb) as f: while chunk : f.read(4096): md5obj.update(chunk) meta { name: os.path.basename(path), size: os.path.getsize(path), md5: md5obj.hexdigest(), } sock.sendall(build_frame(2, MY_ID, target_id, json.dumps(meta).encode())) with open(path, rb) as f: while chunk : f.read(4096): sock.sendall(build_frame(3, MY_ID, target_id, chunk)) sock.sendall(build_frame(3, MY_ID, target_id, b__END__))为什么要先读一遍算MD5因为MD5必须覆盖整个文件内容边读边算最直接。先读一遍再发送虽然文件被读取两次但课程设计场景下性能完全可接受换来的是接收端能校验文件完整性。接收端按帧读取先收元数据帧再持续收文件数据帧。def recv_file(sock, save_dir.): msg_type, sender, target, body read_frame(sock) meta json.loads(body) total 0 md5obj hashlib.md5() save_path os.path.join(save_dir, meta[name]) with open(save_path, wb) as f: while True: msg_type, sender, target, body read_frame(sock) if msg_type ! 3: break if body b__END__: break f.write(body) md5obj.update(body) total len(body) if total ! meta[size]: raise ValueError(size mismatch) if md5obj.hexdigest() ! meta[md5]: raise ValueError(md5 mismatch)分块大小4096是保守值可以调整到16384甚至65536。块越大单位时间内发送的次数越少CPU开销越低但单块超过几MB时接收端的内存缓冲和重传代价都会上升。文件传输失败时先看MD5是哪个值对不上再用ls -l对比接收文件大小。如果size不对基本可以断定粘包处理有问题接收端把聊天消息或下一个文件的元数据也写进了文件。4.3 带序号的文件块要不要做ACK和滑动窗口上面这套方案已经够课程设计用但它有一个盲区如果发送端发出的某个块在网络上丢失接收端只会一直等等不到就报错不会自动重传。TCP本身有超时重传机制在局域网里丢包率很低发送端不大可能遇到块级丢失。但课程设计报告如果想拿高分建议讨论一下带序号的方案。在每块文件数据前面加4字节序号接收端每收一块回一个ACK帧发送端维护未确认块列表。这个设计很像简化版的滑动窗口。# 发送第i块 seq_body struct.pack(!I, i) chunk sock.sendall(build_frame(3, MY_ID, target_id, seq_body)) # 接收端解析后回ACK def parse_data_chunk(body): seq, data body[:4], body[4:] return struct.unpack(!I, seq)[0], data有了序号报告里可以写“当连续3个ACK超时判定当前块丢失发送端重发”。实际验收时可以通过在发送循环里sleep(0.1)人为制造慢速发送观察接收端序号是否连续这是报告里非常有说服力的测试数据。5. 局域网联调与常见问题排查为什么本机能跑换台电脑就翻车的避坑记录5.1 现象本机用127.0.0.1连正常换局域网IP就连不上这是课程设计验收现场翻车率最高的问题。原因有三个第一服务端bind了127.0.0.1只监听回环口第二Windows防火墙默认阻止Python进程入站第三两台机器不在同一网段。解决方法是先确认服务端代码里写的是0.0.0.0再用管理员权限在Windows“高级安全Windows Defender防火墙”中新建入站规则允许TCP端口8888。很多同学把防火墙整个关掉虽然能通但报告里不好写而且有安全隐患。正确做法是只放行自己端口。然后检查两台机器是否在同一局域网用ping验证。注意Wi-Fi环境下服务端连的是192.168.1.x客户端却连了手机热点192.168.137.x这种情况下ping不通自然连不上。把两台设备放到同一个路由器或交换机下即可。5.2 现象聊天内容串包两个消息拼在一起文件传完MD5不对原因非常明确客户端或服务端用recv(1024)直接解析消息没有按长度前缀读取。TCP是字节流一次recv可能返回多条消息也可能只返回一条消息的一半。解决方法是全局统一使用read_frame任何类型的消息都不直接调recv。文件传输的接收端也要用read_frame而不是自己再拼一次缓冲区。串包问题只要协议做对了一次解决。血泪经验是千万不要在recv之后手动按\n去切字符串聊天内容里只要含换行就会炸。5.3 现象为什么socket接收到的字节是奇数长度后面还跟了一串随机数这个问题在socket编程的搜索里很常见。实际不是系统补了随机数而是你的缓冲区里残留了下一个消息的头部或正文。比如消息A长度是10字节一次recv返回了20字节你按固定的\n去截取前10字节给了A后10字节没保存等下一轮recv时A后面的数据再拼上新的recv看起来就像尾部多了随机字节。解决方法是把“多读到的数据”留存在接收缓冲区里。最简单的方式就是不用裸recv而是用read_framerecv_exact每次只读固定长度不会多读因此不会留下残包。如果已经写了buffer类要记得在解析完一帧后把剩余bytes挪到buffer头部不能丢。5.4 现象多个客户端同时收发时界面卡死服务端CPU占满客户端如果在Tkinter或PyQt的界面线程里调用recv只要socket没数据整个窗口就会冻结。服务端CPU占满则多是因为某个连接进入了死循环比如solve一个待处理消息时用了while True但没有break。客户端必须把网络收发放到后台线程界面线程只通过队列读取消息。Python的queue.Queue是线程安全的适合做这个转接。import queue import threading msg_q queue.Queue() def recv_worker(sock): while True: try: frame read_frame(sock) msg_q.put(frame) except (ConnectionError, OSError): break threading.Thread(targetrecv_worker, args(sock,), daemonTrue).start()界面线程每次只做msg_q.get()或msg_q.get(timeout100)不会阻塞在网络操作上。服务端每个连接一个线程没有问题但所有sendall必须被同一个锁保护否则字节交错。需要更高并发时再考虑select或asyncio课程设计不必一步到位。5.5 现象报错“no more data from socket”以及被误认为数据库问题的错误no more data from socket常见于对端关闭后继续读取的场景。在文件传输中如果发送端没有发送__END__标记就异常退出接收端会一直卡在read_frame一旦底层连接超时或被重置就会看到这个错误。解决方式是在接收循环里捕获ConnectionError并在超时后做文件分块重传或整体重传。另一个容易混淆的是搜socket时误入数据库坑。网上大量报错“error 2002 (HY000): cant connect to local MySQL server through socket /tmp/mysql.sock”这是MySQL客户端把Unix domain socket和TCP socket搞混了。课程设计里的聊天软件如果还要连MySQL做登录注册连接参数必须使用host和port例如host127.0.0.1, port3306不要写socket/tmp/mysql.sock。这两套socket不是一个东西错一个半天查不出来。5.6 现象把Socket当成WebSocket去查Spring Boot配置和跨域问题看到“Socket”就去搜“spring boot集成web socket yml配置”“socket有跨域吗”是方向性错误。这门课程设计要的是操作系统提供的TCP socket接口由socket()函数创建数据在局域网内按字节流传输。WebSocket则是在HTTP之上建立的双向通信协议主要面向浏览器端到服务器端的实时推送有跨域、握手、浏览器兼容等一堆额外问题。如果你的课设是Python命令行或桌面窗口完全没有必要引入WebSocket。报告里如果写“采用socket编程”就老老实实展示TCP三次握手和数据帧。引入WebSocket反而会让评委质疑你对计算机网络底层概念的掌握程度。6. 让课程设计报告拿得出手抓包验证、性能测试和三层实验结论6.1 Wireshark抓包验证三次握手和数据帧报告里贴一张抓包截图能说明你做的不是玩具。打开Wireshark选客户端所在网卡过滤器写tcp.port 8888然后启动客户端连接服务端。第一条是客户端发SYN第二条是服务端回SYNACK第三条是客户端回ACK这就是TCP三次握手。再发一条聊天消息Wireshark会显示PSH、ACK标志位选中数据包可以在下方看到应用层字节。对照你自己的协议设计前2字节是MAGIC第3字节是消息类型第4、5字节是sender和target后面是body长度和内容。报告里写“抓包结果与协议定义完全一致”这就是设计闭环。6.2 用并发脚本测出群聊的吞吐与成功率在服务端和客户端跑通之后写一个并发脚本统计群聊场景下的平均响应时间。代码复用前面的build_frame、read_frame并发起5个客户端每个客户端连续发送50条群聊消息。import socket import threading import time import statistics import struct results [] def one_client(idx): sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((192.168.1.100, 8888)) sock.sendall(build_frame(0, idx, 0, buser str(idx).encode())) time.sleep(0.2) t0 time.time() for i in range(50): sock.sendall(build_frame(1, idx, 0, fmsg{i}.encode())) read_frame(sock) dt time.time() - t0 results.append(dt) sock.close() threads [threading.Thread(targetone_client, args(i,)) for i in range(5)] for t in threads: t.start() for t in threads: t.join() print(avg response time:, statistics.mean(results))注意测试时把192.168.1.100换成你自己服务端IP。这个脚本会测量从发送到收到广播回包的总耗时把客户端数量从1调到5、10得到一组“用户数—平均响应时间”数据。课程设计报告中用一张折线图展示比写一百句“性能良好”都管用。6.3 报告三层结论需求、设计、测试怎么串起来报告别按代码顺序抄要按“需求→设计→测试”三层来写。需求分析写清楚场景支持一对一会话、群聊广播、文件传输登录用户上限10人文件大小不超过200MB丢包率接近0。设计章节放协议帧结构、状态机、连接路由表和线程模型这是拿分重点每张图都要对应到代码里的一个函数。测试章节放三种结果抓包验证的连接建立与帧格式、并发脚本得到的响应时间曲线、文件传输前后的MD5对比。最后写一段话把三层串起来“协议中的长度前缀解决了TCP粘包问题因此文件传输在100轮测试中MD5全部一致线程锁保证广播在高并发下字节不交错客户端数量从1增加到10时平均响应时间从2ms上升到15ms但无丢包。”那年我做课设的时候自认为把socket用明白了结果验收现场演示文件传输最后一块数据没收到文件打开乱码。后来加上长度前缀和MD5校验才算真正踏实。希望帮到你。本文还有配套的精品资源点击获取
返回列表