
网络编程培训入门:用 Python 编写一个简单的 TCP 聊天程序
聊天程序是学习网络编程的一个实用起点:它包含建立连接、收发数据、处理多个客户端和清理连接等常见问题。本文将使用 Python 标准库中的 socket 和 threading,实现一个基于 TCP 的简易群聊程序。读者可以在本机启动服务器,再打开多个客户端相互发送消息。
这个示例适合学习和局域网内的短时演示,不是可直接用于互联网生产环境的聊天服务。代码没有身份认证、加密、持久化、限流或抗拒绝服务机制。理解这些边界和了解如何运行代码同样重要。
一、程序要解决什么问题
程序分成两部分:
- 服务器:监听一个 TCP 端口,接收客户端连接,读取客户端消息,并把消息转发给当前连接的其他客户端。
- 客户端:连接服务器,从终端读取用户输入并发送;同时在后台接收服务器转发的消息。
TCP 提供可靠、有序的字节流传输,但不提供“消息”这一概念。一次 sendall() 发送的数据,接收端可能分多次 recv() 才读完;多次发送的数据也可能在一次 recv() 中一起到达。因此,程序必须定义应用层的消息边界。
本文采用简单的换行分帧:每条消息以换行符结尾,内容使用 UTF-8 编码。程序限制每一行最多 4096 字节,并将第一行作为昵称。换行符在此协议中表示消息结束,不能作为单条消息正文的一部分;要支持正文中的换行,需要重新设计协议,例如采用长度前缀或转义规则。
二、服务器代码
将下面代码保存为 server.py。服务器使用一个线程处理一个客户端,因而多个客户端可以同时连接。对初学者来说,这种结构便于观察连接生命周期;它不是高并发服务器的最佳实现。
import argparse
import re
import socket
import threading
MAX_LINE = 4096
clients = {} # socket -> nickname
clients_lock = threading.Lock()
def iter_lines(conn):
"""从 TCP 字节流中按换行符切分消息,并限制单行长度。"""
buffer = bytearray()
while True:
chunk = conn.recv(4096)
if not chunk:
# 对端正常关闭;未以换行符结束的残留内容不作为完整消息处理。
return
buffer.extend(chunk)
while True:
newline = buffer.find(b"\n")
if newline == -1:
break
line = bytes(buffer[:newline])
del buffer[:newline + 1]
if len(line) > MAX_LINE:
raise ValueError("消息超过长度限制")
# 兼容常见的 CRLF 行结束符。
if line.endswith(b"\r"):
line = line[:-1]
yield line
if len(buffer) > MAX_LINE:
raise ValueError("消息超过长度限制")
def send_line(conn, text):
data = text.encode("utf-8")
if len(data) > MAX_LINE:
raise ValueError("待发送消息超过长度限制")
conn.sendall(data + b"\n")
def broadcast(text):
"""向当前已登记的客户端广播;发送期间用锁避免同一连接交错写入。"""
failed = []
with clients_lock:
for conn in list(clients):
try:
send_line(conn, text)
except (OSError, ValueError):
failed.append(conn)
for conn in failed:
clients.pop(conn, None)
try:
conn.close()
except OSError:
pass
def handle_client(conn, address):
nickname = None
try:
lines = iter_lines(conn)
try:
first_line = next(lines)
except StopIteration:
return
try:
nickname = first_line.decode("utf-8")
except UnicodeDecodeError:
send_line(conn, "错误:昵称必须是有效的 UTF-8")
return
# 仅允许 1 到 20 个 ASCII 字母、数字、下划线或短横线。
if not re.fullmatch(r"[A-Za-z0-9_-]{1,20}", nickname):
send_line(conn, "错误:昵称只能包含字母、数字、下划线和短横线,长度为 1 到 20")
return
with clients_lock:
clients[conn] = nickname
print(f"{address} 加入,昵称:{nickname}")
broadcast(f"系统:{nickname} 加入了聊天室")
for raw_line in lines:
try:
text = raw_line.decode("utf-8")
except UnicodeDecodeError:
send_line(conn, "错误:消息必须是有效的 UTF-8")
continue
if not text:
continue
# 限制的是 UTF-8 编码后的字节数,而不是字符数。
broadcast(f"{nickname}: {text}")
except (OSError, ValueError) as exc:
print(f"{address} 连接结束:{exc}")
finally:
with clients_lock:
was_connected = clients.pop(conn, None) is not None
try:
conn.close()
except OSError:
pass
if was_connected and nickname:
print(f"{address} 离开,昵称:{nickname}")
broadcast(f"系统:{nickname} 离开了聊天室")
def main():
parser = argparse.ArgumentParser(description="简单的 TCP 聊天服务器")
parser.add_argument("--host", default="127.0.0.1", help="监听地址,默认仅接受本机连接")
parser.add_argument("--port", type=int, default=9000, help="监听端口,默认 9000")
args = parser.parse_args()
if not 1 <= args.port <= 65535:
parser.error("端口必须在 1 到 65535 之间")
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
server.bind((args.host, args.port))
server.listen()
print(f"服务器正在 {args.host}:{args.port} 监听,按 Ctrl+C 退出")
while True:
conn, address = server.accept()
thread = threading.Thread(
target=handle_client,
args=(conn, address),
daemon=True,
)
thread.start()
except KeyboardInterrupt:
print("\n正在关闭服务器")
finally:
server.close()
if __name__ == "__main__":
main()
服务器代码的几个关键点
AF_INET表示使用 IPv4 地址,SOCK_STREAM表示使用 TCP。bind()绑定本机地址和端口,listen()开始等待连接,accept()返回新的客户端套接字。监听套接字负责接新连接,客户端套接字负责与某个客户端通信。sendall()会持续发送,直到数据全部交给操作系统或发生错误。它比假设一次send()能写完全部数据更合适。iter_lines()自行缓存尚未组成完整行的字节,避免把一次recv()错当成一条消息。昵称与正文都按 UTF-8 解码;非法编码会被拒绝或提示。clients_lock保护客户端集合,也避免多个线程同时向同一客户端写数据时发生内容交错。
三、客户端代码
将下列代码保存为 client.py。客户端同样按换行符读取消息;一个接收线程负责监听服务器,主线程负责读取终端输入。
import argparse
import socket
import threading
MAX_LINE = 4096
def iter_lines(conn):
buffer = bytearray()
while True:
chunk = conn.recv(4096)
if not chunk:
return
buffer.extend(chunk)
while True:
newline = buffer.find(b"\n")
if newline == -1:
break
line = bytes(buffer[:newline])
del buffer[:newline + 1]
if len(line) > MAX_LINE:
raise ValueError("服务器消息超过长度限制")
if line.endswith(b"\r"):
line = line[:-1]
yield line
if len(buffer) > MAX_LINE:
raise ValueError("服务器消息超过长度限制")
def receive_messages(conn):
try:
for raw_line in iter_lines(conn):
try:
print(raw_line.decode("utf-8"))
except UnicodeDecodeError:
print("收到无法按 UTF-8 解码的数据")
except (OSError, ValueError) as exc:
print(f"接收连接结束:{exc}")
finally:
try:
conn.close()
except OSError:
pass
def main():
parser = argparse.ArgumentParser(description="简单的 TCP 聊天客户端")
parser.add_argument("--host", default="127.0.0.1", help="服务器地址")
parser.add_argument("--port", type=int, default=9000, help="服务器端口")
parser.add_argument("--name", default="guest", help="昵称:1 到 20 个 ASCII 字母、数字、下划线或短横线")
args = parser.parse_args()
if not 1 <= args.port <= 65535:
parser.error("端口必须在 1 到 65535 之间")
if not 1 <= len(args.name) <= 20:
parser.error("昵称长度必须为 1 到 20")
if any(ch not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" for ch in args.name):
parser.error("昵称只能包含字母、数字、下划线和短横线")
conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
conn.connect((args.host, args.port))
conn.sendall(args.name.encode("utf-8") + b"\n")
except OSError as exc:
print(f"无法连接服务器:{exc}")
conn.close()
return
receiver = threading.Thread(target=receive_messages, args=(conn,), daemon=True)
receiver.start()
print("已连接。输入消息后按回车发送;输入 /quit 退出。")
try:
while True:
text = input()
if text == "/quit":
break
data = text.encode("utf-8")
if len(data) > MAX_LINE:
print(f"消息过长:UTF-8 编码后最多 {MAX_LINE} 字节")
continue
conn.sendall(data + b"\n")
except (EOFError, KeyboardInterrupt):
pass
except OSError as exc:
print(f"发送失败:{exc}")
finally:
try:
conn.shutdown(socket.SHUT_RDWR)
except OSError:
pass
conn.close()
if __name__ == "__main__":
main()
四、启动并验证
需要安装 Python 3,不需要额外的第三方包。在两个终端中进入代码所在目录,先启动服务器:
python server.py
默认监听地址是 127.0.0.1,这意味着只有本机程序能连接。再打开两个终端,分别启动客户端并指定昵称:
python client.py --name alice
python client.py --name bob
在任一客户端输入文字并按回车,另一端应能看到带昵称的消息。输入 /quit 或按 Ctrl+C 可以结束客户端;在服务器终端按 Ctrl+C 结束服务器。根据系统配置,运行命令也可能需要写作 python3。
如需在可信的局域网中让其他设备连接,可让服务器监听可访问的网卡地址,或绑定所有 IPv4 网卡:
python server.py --host 0.0.0.0 --port 9000
然后客户端通过服务器所在设备的局域网 IP 地址连接,例如:
python client.py --host 192.168.1.20 --port 9000 --name alice
0.0.0.0 是服务器的监听地址,不是客户端应填写的目标地址。局域网 IP 需要根据实际网络查询;防火墙也必须允许相应的入站连接。端口参数被程序限制在 1 到 65535,但端口是否空闲、是否能绑定,仍由操作系统决定;绑定失败时应检查端口占用、地址配置和权限。
五、适用场景与重要边界
适合用来做什么
- 练习 TCP 连接的建立、接收、发送与关闭流程。
- 理解字节流分帧、字符编码,以及多客户端广播的基本写法。
- 在本机或受控的可信局域网中进行课堂演示和短时实验。
不适合直接用来做什么
- 不适合公开部署。程序没有认证,任何能连接服务器的人都可以使用任意符合格式的昵称并发送消息。昵称不是身份凭证。
- 不适合传输机密内容。连接没有使用 TLS,消息在网络链路上不具备加密保护。UTF-8 编码只是文本表示方式,不是加密。
- 不适合处理大量并发或不可信流量。每个客户端创建一个线程;服务器向客户端逐个同步发送。某个客户端接收缓慢时,广播可能被拖慢。虽然设置了单行长度上限,但没有限制连接总数、消息频率或连接空闲时间。
- 不提供聊天记录和可靠投递。服务器只转发给当前连接的客户端;断线期间的消息不会保存,也没有消息确认、重发、排序标识或离线队列。
要把程序用于真实服务,通常还需要 TLS、可靠的身份认证和授权、连接与消息限额、超时、慢客户端隔离、日志与监控、资源回收、输入处理策略,以及明确的隐私与数据保留规则。部署位置、访问对象和数据敏感程度决定具体措施,不能仅靠“换一个端口”或隐藏 IP 地址来获得安全性。
六、常见错误与排查方法
1. 把一次 recv() 当成一条完整消息
这是 TCP 入门中最常见的错误。recv(4096) 中的 4096 是本次最多读取的字节数,不代表消息长度。一次读取可能只得到半条消息,也可能得到多条消息。本文的缓存和换行切分逻辑正是为了解决这个问题。
2. 忘记编码或解码
套接字传输的是字节,终端处理的通常是字符串。发送前要用 encode("utf-8"),接收后要用 decode("utf-8")。如果两端编码约定不一致,可能出现乱码或解码异常。长度上限针对字节而非字符,因此包含中文的消息通常会比同等字符数的 ASCII 消息占用更多字节。
3. 发送端没有追加换行符
本程序只有读到换行符才认为一条消息完整。若发送时忘记追加 b"\n",接收端会继续等待后续数据,消息看起来就像“没有发出去”。协议中的分隔符必须由收发双方保持一致。
4. 连接被拒绝或绑定失败
确认服务器已启动、客户端的地址和端口正确,并检查端口是否被其他程序占用。如果客户端与服务器不在同一台机器上,默认的 127.0.0.1 指向客户端自己,不会指向远端服务器。跨设备连接还要检查服务器监听地址、路由和防火墙。
5. 中文昵称不符合本示例规则
服务器把昵称限制为 1 到 20 个 ASCII 字母、数字、下划线或短横线。客户端也进行相应检查。这是示例协议主动采用的规则,并非 TCP 或 Python 的限制。若要支持中文昵称,需要重新定义长度和字符校验规则,并考虑 Unicode 规范化、显示相似字符及昵称冲突等问题。
6. 误以为断开连接会自动通知所有人
客户端断开时,服务器的接收循环会遇到连接关闭,随后清理客户端并广播离开通知。如果进程被强制终止、网络中断或设备失联,检测时间和行为会受操作系统与 TCP 状态影响。实际服务通常要设计心跳、超时和断线重连策略。
七、可以怎样继续改进
在理解基本实现后,可以循序渐进地添加功能:为消息增加类型和版本号;用长度前缀协议支持多行内容;限制每个 IP 的连接数和发送频率;为慢客户端单独设置发送队列;记录结构化日志;通过 TLS 保护链路;并加入可靠的身份认证。每一项改动都需要同步更新协议、客户端和服务器,并针对断线、超长输入、无效编码和并发访问编写测试。
这个小程序的价值不在于充当完整的聊天平台,而在于把 TCP 编程中容易忽视的要点暴露出来:TCP 是字节流,协议需要自行定义消息边界,连接有建立和释放的生命周期,并发访问需要协调,而安全能力必须明确设计。理解这些基础后,再学习事件循环、异步 I/O 或成熟的网络框架,会更容易判断它们解决的具体问题。









