☰
局域网聊天程序课设:Socket编程与多客户端并发实战
2026/10/6 20:24:26 网站建设 项目流程

简介:这是一份面向高校计算机、软件工程等专业学生的计算机网络课程设计参考资料,主题为基于P2P技术的局域网聊天程序,适合正在完成课设、需要参考完整设计思路与实现方案的学习者。压缩包内共1个doc文档,约161KB,为课程设计说明书(论文)格式,涵盖需求分析、总体设计、详细设计、系统实现编码及运行结果、总结与参考文献等章节,并配有目录与摘要。文档围绕用户注册登录、聊天、文件传输、好友管理等模块展开,涉及客户端、服务器端与数据库三层架构,以及表示层、应用层、业务逻辑层、数据访问层的软件层次模型,同时介绍了TCP、UDP协议原理与Socket编程方法。目前已有559人学习下载,可作为课设选题、文档撰写与功能模块划分的参考,帮助读者快速理清P2P局域网聊天程序的设计脉络与实现要点。

1. 局域网聊天程序:从 Socket 到多端互通的课设落地路线

宿舍里几台电脑想互相发消息,不想装任何第三方软件,也不想连外网——这就是「局域网聊天程序」这个课设标题最朴素的出发点。它本质上是一个基于 TCP/UDP 的 C/S 或 P2P 通信小系统,核心考点集中在 Socket 编程、多线程/IO 多路复用、应用层协议设计这三块。很多人做课设时卡在「能连上但消息发不全」「多客户端一上就崩」「中文乱码」这些具体问题上,而不是卡在概念上。这篇笔记按「先跑通最小闭环 → 再补多客户端 → 再处理协议和边界」的顺序展开,适合正在做计算机网络课设、想拿一个能演示、能答辩、代码自己能讲清楚的同学。下面所有代码以 Python 为例,换 C/Java 思路一致,只是 API 名字不同。

2. 先跑通最小闭环:TCP 服务端与客户端的一对一通信

2.1 为什么课设首选 TCP 而不是 UDP

局域网聊天程序在课设场景下,绝大多数老师默认你选 TCP。原因很实际:TCP 自带连接管理、可靠传输、字节流顺序保证,你不需要自己写重传、去重、排序。UDP 虽然代码更短,但一旦要保证「消息不丢、不乱序」,你就得在应用层补一套序列号和 ACK 机制,工作量反而更大,答辩时也容易被追问「你怎么保证可靠性」。

常见做法是:文字聊天走 TCP,如果课设要求做语音或视频片段传输,再单独用 UDP 并说明理由。这个分工在答辩时是一个加分点,因为它体现你理解两种协议的适用边界。

TCP 服务端的生命周期是固定的四步:socket()创建套接字 →bind()绑定地址端口 →listen()进入监听 →accept()取出连接。客户端是socket()→connect()。这个顺序不能乱,bind之前必须先创建套接字,listen之前必须先bind。

2.2 最小可运行的服务端代码

import socket HOST = '0.0.0.0' # 监听本机所有网卡,局域网内其他机器才能连进来 PORT = 9000 # 端口选 1024 以上,避免权限问题 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 重启时端口不被 TIME_WAIT 占住 server.bind((HOST, PORT)) server.listen(5) # backlog 设为 5,够课设用 print(f'服务端已启动,监听 {PORT}') conn, addr = server.accept() # 阻塞等待一个客户端 print(f'客户端 {addr} 已连接') while True: data = conn.recv(1024) # 每次最多收 1024 字节 if not data: # 对端关闭连接时 recv 返回空 break msg = data.decode('utf-8') print(f'收到:{msg}') conn.sendall(f'服务端已收到:{msg}'.encode('utf-8')) conn.close() server.close()

逻辑说明:SO_REUSEADDR是课设调试阶段最容易被忽略的一行。没有它,你每次重启服务端都可能报Address already in use,因为上一次的连接还处于 TIME_WAIT 状态。recv(1024)的 1024 是单次读取上限,不是消息长度,TCP 是字节流,一条消息可能分多次到达,这一点后面第 4 章会专门处理。sendall和send的区别是前者保证全部发完,课设里统一用sendall更省心。

2.3 对应的客户端代码与连接验证

import socket HOST = '192.168.1.100' # 换成服务端的局域网 IP,不要写 127.0.0.1 PORT = 9000 client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect((HOST, PORT)) while True: msg = input('请输入消息(输入 quit 退出):') if msg == 'quit': break client.sendall(msg.encode('utf-8')) reply = client.recv(1024) print(f'服务端回复:{reply.decode("utf-8")}') client.close()

参数说明:HOST必须填服务端的局域网 IP,用ipconfig(Windows)或ip addr(Linux)查。如果填127.0.0.1,只有本机能连,其他机器连不上,这是课设演示时最常见的翻车点。验证连通性可以先用ping 192.168.1.100确认网络层通,再用telnet 192.168.1.100 9000确认端口开放。Windows 默认没开 telnet 客户端,可以在「启用或关闭 Windows 功能」里勾上,或者直接用 Python 客户端测。

提示:如果客户端报ConnectionRefusedError,先确认服务端是否真的在运行,再确认防火墙有没有拦。Windows 防火墙对 Python 的入站连接默认是询问或阻止,答辩前一定要在「允许应用通过防火墙」里把 Python 勾上。

3. 多客户端并发:从单线程阻塞到多线程与 select 的选型

3.1 单线程服务端为什么一上多客户端就废

第 2 章的代码只能服务一个客户端,因为accept()返回后程序就卡在recv()的循环里,第二个客户端连上来时根本没人去accept。这是课设从「能跑」到「能用」的第一道坎。解决办法有两类:多线程和 IO 多路复用。

多线程的思路是:主线程只负责accept,每来一个客户端就开一个线程专门处理它的收发。优点是代码直观,每个连接独立,逻辑互不干扰。缺点是客户端一多线程就多,上下文切换开销大,而且共享数据(比如在线用户列表)要加锁,容易出并发 bug。

IO 多路复用的思路是:用一个select或epoll同时监听所有套接字,哪个有数据就处理哪个,单线程就能管很多连接。优点是资源占用低,没有锁的问题。缺点是代码结构绕,新手容易写错事件循环。

课设场景下,如果在线人数不超过几十个,多线程完全够用,而且更好讲。如果老师明确要求「高并发」或者你想拿高分,用select写一版更有说服力。

3.2 多线程版服务端的完整结构

import socket import threading HOST = '0.0.0.0' PORT = 9000 clients = [] # 保存在线客户端套接字 lock = threading.Lock() # 保护 clients 列表的并发修改 def broadcast(message, sender): """把消息发给除发送者外的所有在线客户端""" with lock: for c in clients: if c != sender: try: c.sendall(message) except OSError: clients.remove(c) # 发送失败说明连接已断,移除 def handle_client(conn, addr): print(f'{addr} 上线') with lock: clients.append(conn) try: while True: data = conn.recv(1024) if not data: break text = f'[{addr[0]}:{addr[1]}] {data.decode("utf-8")}' broadcast(text.encode('utf-8'), conn) finally: with lock: if conn in clients: clients.remove(conn) conn.close() print(f'{addr} 下线') server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((HOST, PORT)) server.listen(10) print('服务端已启动') while True: conn, addr = server.accept() t = threading.Thread(target=handle_client, args=(conn, addr), daemon=True) t.start()

逻辑说明:daemon=True让工作线程随主线程退出,避免程序关不掉。broadcast里对clients的遍历和修改都放在锁里,否则一个线程在遍历、另一个线程在删除,会抛RuntimeError: list changed size during iteration。try/except OSError是必要的,因为客户端可能已经断开但还没被recv发现,此时sendall会失败。

参数说明:listen(10)的 10 是等待队列长度,不是最大连接数,课设里设 5 到 10 都行。recv(1024)依然要面对粘包问题,第 4 章解决。

3.3 select 版的核心差异与适用判断

select版的关键是把所有需要监听的套接字放进一个列表,每次循环调用select.select(readable, [], []),返回可读的套接字集合,然后逐个处理。服务端套接字可读意味着有新连接,客户端套接字可读意味着有数据或连接关闭。

import socket import select HOST = '0.0.0.0' PORT = 9000 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((HOST, PORT)) server.listen(10) inputs = [server] # 所有被监听的套接字 while True: readable, _, _ = select.select(inputs, [], []) for s in readable: if s is server: conn, addr = s.accept() inputs.append(conn) # 新连接加入监听 print(f'{addr} 上线') else: data = s.recv(1024) if data: for c in inputs: if c is not server and c is not s: c.sendall(data) else: inputs.remove(s) # 连接关闭,移出监听 s.close()

逻辑说明:select的第一个参数是「等待可读」的套接字列表,第二个是「等待可写」,第三个是「等待异常」,课设里通常只关心可读。inputs列表在循环中被修改,但select每次调用都会重新读取它,所以不会像多线程那样有迭代中修改的问题。这个版本单线程就能支撑几十个客户端,代码量比多线程还少,缺点是所有逻辑挤在一个循环里,扩展功能时不如多线程清晰。

选型建议:如果课设要求做私聊、群聊、文件传输、在线列表等多个功能,多线程版更容易按功能拆函数;如果只要求群聊且强调并发性能,select版更合适。两者都写一遍对比,答辩时能讲出取舍,是加分项。

4. 消息边界与协议设计:粘包、半包和自定义报文格式

4.1 粘包和半包到底是怎么发生的

TCP 是字节流协议,它不保留你send的次数边界。你连续send两条消息,接收端可能一次recv全收到(粘包),也可能一条消息分两次recv才收全(半包)。这不是 bug,是 TCP 的设计。课设里如果只发短消息、发得慢,可能一直不触发,但一旦做文件传输或快速连发,必然翻车。

解决思路只有一条:在应用层定义消息边界。常见做法有三种。一是固定长度,每条消息定长,不够补空格,简单但浪费带宽。二是分隔符,比如用\n结尾,接收端按行拆,适合文本聊天。三是长度前缀,先发 4 字节表示消息体长度,再发消息体,最通用,文件传输也适用。

课设推荐长度前缀,因为老师一问「你怎么处理粘包」你能直接答上来,而且代码不复杂。

4.2 长度前缀协议的收发实现

import struct def send_msg(sock, text): """先发 4 字节长度,再发消息体""" body = text.encode('utf-8') length = struct.pack('!I', len(body)) # !I 表示网络字节序的无符号 4 字节整数 sock.sendall(length + body) def recv_msg(sock): """先收 4 字节长度,再按长度收消息体""" raw_len = recv_exact(sock, 4) if not raw_len: return None length = struct.unpack('!I', raw_len)[0] body = recv_exact(sock, length) if not body: return None return body.decode('utf-8') def recv_exact(sock, n): """确保收满 n 字节,处理半包""" buf = b'' while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: return None buf += chunk return buf

逻辑说明:struct.pack('!I', len(body))里的!表示网络字节序(大端),I表示 4 字节无符号整数。不同机器字节序可能不同,统一用网络字节序是标准做法。recv_exact是解决半包的关键,它循环接收直到凑够指定字节数,recv(n - len(buf))保证不会多收。recv_msg先收 4 字节长度,再按长度收消息体,这样无论 TCP 怎么拆分合并,应用层拿到的都是完整的一条消息。

参数说明:长度用 4 字节意味着单条消息最大约 4GB,课设完全够用。如果做文件传输,可以把文件也按这个格式分块发送,每块带上类型标识。utf-8编码保证中文不乱码,服务端和客户端必须统一,一边用gbk一边用utf-8是乱码的常见原因。

4.3 带类型字段的报文格式扩展

纯文本聊天用长度前缀就够了,但如果要区分「普通消息」「私聊」「文件块」「在线列表请求」,就需要在消息体里再加一个类型字段。常见做法是消息体前 1 字节表示类型,后面跟内容。

字段长度说明
总长度4 字节网络字节序,表示后续所有字节数
类型1 字节0x01 群聊,0x02 私聊,0x03 文件,0x04 控制
目标变长私聊时填目标用户名,群聊留空
内容变长实际消息或文件块

这个格式在答辩时可以直接画在白板上讲,比纯文字描述清楚得多。实现时把send_msg改成先拼类型和目标,再算总长度即可。

注意:不要用\n做分隔符同时又在消息内容里允许\n,否则接收端按行拆会把一条消息拆成多条。如果坚持用分隔符,必须对内容里的分隔符做转义,这比长度前缀麻烦。

5. 避坑与排查:课设演示前必须过的五道关

5.1 客户端连不上服务端

现象:客户端报ConnectionRefusedError或一直卡在connect。原因通常是三个:服务端没启动、IP 填错、防火墙拦截。解决顺序是先ping服务端 IP 确认网络通,再用telnet IP 端口确认端口开放,最后检查服务端是否真的在listen状态。Windows 防火墙对 Python 入站默认拦截,需要在防火墙设置里放行,或者临时关闭防火墙测试(答辩时不要关,提前配好规则)。

5.2 中文消息乱码

现象:收到的是b'\xe4\xbd\xa0\xe5\xa5\xbd'这种字节串或者问号。原因:发送端和接收端编码不一致,或者接收端直接print(data)没有decode。解决:统一用utf-8,发送前encode('utf-8'),接收后decode('utf-8')。如果 Windows 控制台显示乱码但数据本身没错,是控制台编码问题,可以chcp 65001切到 UTF-8 代码页。

5.3 消息发出去但对方收不到

现象:服务端打印了收到消息,但广播后其他客户端没显示。原因:广播时遍历的clients列表里没有目标客户端,或者目标客户端套接字已失效但没被移除。解决:在broadcast里对每个发送加try/except,失败就移除;同时确认新客户端accept后确实加入了clients列表。多线程版尤其要注意加锁,否则列表状态可能不一致。

5.4 服务端重启报端口被占用

现象:OSError: [Errno 98] Address already in use。原因:上一次的服务端连接处于 TIME_WAIT 状态,端口还没释放。解决:在bind之前加setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)。如果已经加了还报,说明有另一个进程占着这个端口,用netstat -ano | findstr 9000(Windows)或lsof -i:9000(Linux)找到进程号杀掉。

5.5 客户端直接关窗口导致服务端崩溃

现象:客户端点右上角关闭,服务端抛异常或卡死。原因:客户端异常断开时,服务端recv返回空,如果没处理就会继续用失效的套接字发送。解决:recv返回空时break出循环,在finally里关闭套接字并从clients移除。多线程版还要确保移除时加锁,避免和其他线程的广播冲突。

6. 进阶技巧:用心跳机制和在线列表把课设做成可演示的作品

课设答辩时,老师最常问的两个问题是「你怎么知道对方还在线」和「你怎么保证消息一定送到」。第二个问题 TCP 已经帮你答了,第一个问题需要你自己做心跳。心跳的思路很简单:客户端每隔固定时间(比如 30 秒)给服务端发一个特殊类型的报文,服务端收到后更新该客户端的最后活跃时间;服务端再起一个定时线程,扫描所有客户端,超过一定时间(比如 90 秒)没收到心跳的就判定离线,移除并广播下线通知。

import time import threading last_active = {} # {conn: 最后活跃时间戳} def heartbeat_monitor(): while True: time.sleep(30) now = time.time() with lock: for conn in list(clients): if now - last_active.get(conn, 0) > 90: clients.remove(conn) last_active.pop(conn, None) conn.close() broadcast(f'有用户超时下线'.encode('utf-8'), None) # 在 handle_client 的 recv 循环里,每收到一条消息就更新: # last_active[conn] = time.time()

参数说明:心跳间隔 30 秒、超时 90 秒是常见组合,允许丢两次心跳。课设演示时可以把间隔调短到 5 秒和 15 秒,方便现场展示「拔网线后对方变离线」的效果。注意broadcast的第二个参数传None表示发给所有人,包括触发者自己,这和之前的排除发送者逻辑要区分开。

在线列表的实现是在心跳基础上加一个类型为0x04的控制报文,客户端连上后主动请求一次,服务端把clients里的地址和用户名拼成列表返回。用户名可以在连接后第一条消息里带上,服务端用一个字典维护conn到用户名的映射。

我自己的习惯是:课设代码写完先不急着加功能,而是把「一个客户端连上、发消息、另一个客户端收到、关掉一个、另一个收到下线通知」这条链路手动走十遍,每次都在不同机器上走。翻车最多的地方从来不是协议设计,而是防火墙、IP 填错、编码不统一这三个看起来最不起眼的问题。把这三样在答辩前固定成检查清单,比多写两百行功能代码有用。希望帮到你。

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

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

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

立即咨询