网络编程培训入门:用 Python 编写一个简单的 TCP 聊天程序

网络编程培训入门:用 Python 编写一个简单的 TCP 聊天程序

网络编程培训入门:用 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 或成熟的网络框架,会更容易判断它们解决的具体问题。

© 版权声明

相关文章