用Python实现Socket网络编程:TCP客户端与服务端示例

站长资讯9分钟前发布 jdkjadmin
0 00
用Python实现Socket网络编程:TCP客户端与服务端示例

用Python实现Socket网络编程:TCP客户端与服务端示例

Python 的 socket 模块提供了操作网络套接字的基础接口。用它可以建立 TCP 或 UDP 连接,读写字节,并据此实现简单的客户端、服务端和自定义应用层协议。本文以 TCP 为例,带你从最小的请求—响应程序开始,逐步认识消息分帧、并发处理、超时和安全边界。

示例面向 Python 3,使用标准库,不依赖第三方包。读者可以在本机运行客户端和服务端,观察连接建立、发送数据和关闭连接的过程。需要先说明:这些示例用于学习和可信环境中的小型工具,不是经过完整安全审计的生产服务。

一、TCP Socket 适合什么场景

TCP 是面向连接的字节流协议。通信双方建立连接后,可以双向发送字节;TCP 负责可靠、有序地传送数据,并处理丢包重传等传输层工作。适合需要可靠传输、并且希望自行定义应用层消息格式的场景,例如内部工具、设备通信、简单的长连接服务或学习网络基础。

TCP 并不保证一次发送对应一次接收。客户端调用一次 sendall(),服务端可能分几次 recv() 才读到数据;反过来,多次发送的数据也可能被一次 recv() 读出。应用程序必须设计消息边界,不能把 TCP 当成“按消息收发”的协议。

如果需求是公开的 Web API、文件传输、身份认证或跨团队服务通信,优先评估 HTTP、HTTPS、SSH 等成熟协议和现有服务框架。直接用 Socket 并不会自动获得加密、身份验证、访问控制或完善的错误处理。

二、TCP 通信的基本步骤

典型服务端先创建套接字、绑定地址和端口、开始监听,再接受客户端连接。典型客户端创建套接字并连接服务端。连接建立后,两端都可以收发数据,最后关闭连接。

  • socket.socket() 创建套接字。
  • bind() 将服务端套接字绑定到本机地址和端口。
  • listen() 让服务端开始等待连接。
  • accept() 接受一个连接,返回新的连接套接字和对端地址。
  • connect() 让客户端连接服务端。
  • sendall() 尽可能发送给定的全部字节;连接异常时会抛出异常。
  • recv() 最多读取指定数量的字节;返回空字节 b"" 通常表示对端已关闭连接。

以下示例使用换行符作为消息结束标记,即每条消息必须以字节 \n 结尾。这个约定构成了最简单的应用层协议:一行是一条请求或响应。示例限制单条消息最大为 4096 字节,避免程序无限累积输入。该限制是示例协议的选择,不是 TCP 或 Python 的通用上限。

三、实现一个可运行的 TCP 服务端

服务端使用标准库 socketserver 管理监听和连接分发。每个客户端连接由一个处理线程服务,适合本机演示和低并发的小型场景。收到一行文本后,服务端返回一行确认消息;客户端断开或发送不完整、过长的数据时,处理函数结束并关闭该连接。

import socketserver

HOST = "127.0.0.1"
PORT = 50007
MAX_LINE_BYTES = 4096


class TCPHandler(socketserver.BaseRequestHandler):
    def handle(self):
        peer = self.client_address
        print(f"客户端已连接:{peer}")

        pending = bytearray()

        while True:
            chunk = self.request.recv(1024)

            # recv 返回空字节,表示对端已关闭连接
            if not chunk:
                if pending:
                    print(f"{peer} 断开时留下了未完成的消息")
                break

            pending.extend(chunk)

            # 一次 recv 可能包含多条消息,逐条取出
            while b"\n" in pending:
                line, _, remainder = pending.partition(b"\n")
                pending = bytearray(remainder)

                # 限制单条消息长度,不包括结尾换行符
                if len(line) > MAX_LINE_BYTES:
                    print(f"{peer} 发送的消息过长")
                    return

                # 允许以 CRLF 结尾
                if line.endswith(b"\r"):
                    line = line[:-1]

                try:
                    message = line.decode("utf-8")
                except UnicodeDecodeError:
                    print(f"{peer} 发送了无效的 UTF-8 数据")
                    return

                print(f"收到 {peer} 的消息:{message}")

                response = f"服务器收到:{message}\n".encode("utf-8")
                self.request.sendall(response)

            # 尚未出现换行符时,也要防止缓冲区无限增长
            if len(pending) > MAX_LINE_BYTES:
                print(f"{peer} 发送了过长的未完成消息")
                return


class ThreadedTCPServer(socketserver.ThreadingTCPServer):
    # 允许服务停止后较快重新绑定同一地址和端口
    allow_reuse_address = True

    # 服务关闭时不必等待所有处理线程结束
    daemon_threads = True


if __name__ == "__main__":
    with ThreadedTCPServer((HOST, PORT), TCPHandler) as server:
        print(f"服务端监听于 {HOST}:{PORT}")
        try:
            server.serve_forever()
        except KeyboardInterrupt:
            print("\n正在停止服务端")

将代码保存为 server.py 后执行:

python server.py

这里绑定到 127.0.0.1,意味着服务只接受本机回环接口上的连接,适合本地实验。若改为 0.0.0.0,服务通常会监听本机所有 IPv4 网络接口,其他设备可能因此可以尝试连接。是否能从外部访问还取决于防火墙、网络路由和云平台安全组等设置;不要仅为了“让它能连上”就把监听地址改为所有接口。

服务端使用 ThreadingTCPServer,让不同连接可以由不同线程处理。它适合说明并发连接的基本模型,但线程并不意味着可以无限扩展:大量慢连接会占用线程和文件描述符,也可能造成内存或 CPU 压力。面向高并发或不可信网络的服务需要进一步设计连接数上限、读写超时、请求速率限制和资源回收策略。

四、实现 TCP 客户端

客户端连接服务端,发送一条 UTF-8 编码的消息,并读取以换行符结束的响应。这里设置连接超时和接收超时,避免在服务端不可达或不返回数据时无限等待。超时只限制相应的阻塞操作,并不能替代完整的请求总时限或重试策略。

import socket

HOST = "127.0.0.1"
PORT = 50007
MAX_RESPONSE_BYTES = 4096


def receive_one_line(sock):
    data = bytearray()

    while True:
        chunk = sock.recv(1024)

        if not chunk:
            raise ConnectionError("服务端在返回完整响应前关闭了连接")

        data.extend(chunk)

        newline_index = data.find(b"\n")
        if newline_index >= 0:
            line = data[:newline_index]
            if len(line) > MAX_RESPONSE_BYTES:
                raise ValueError("服务端响应超过长度限制")
            return bytes(line)

        if len(data) > MAX_RESPONSE_BYTES:
            raise ValueError("服务端响应超过长度限制")


def main():
    message = "你好,TCP 服务端"
    payload = (message + "\n").encode("utf-8")

    if len(payload) > 4096:
        raise ValueError("请求超过协议规定的长度")

    try:
        with socket.create_connection(
            (HOST, PORT),
            timeout=5,
        ) as sock:
            # 设置后续阻塞读写的超时
            sock.settimeout(5)

            sock.sendall(payload)
            response = receive_one_line(sock)
            print(response.decode("utf-8"))

    except socket.timeout:
        print("网络操作超时")
    except ConnectionRefusedError:
        print("连接被拒绝:请检查服务端是否已启动及端口是否正确")
    except OSError as exc:
        print(f"网络错误:{exc}")


if __name__ == "__main__":
    main()

将代码保存为 client.py。保持服务端运行,在另一个终端执行:

python client.py

正常情况下会看到类似输出:

服务器收到:你好,TCP 服务端

客户端调用 create_connection() 创建连接,并在连接成功后使用 with 块管理套接字。离开代码块时,连接会被关闭。服务端处理函数返回后,连接套接字也会由服务框架关闭。明确管理套接字生命周期,可以减少连接未关闭导致的资源泄漏。

五、消息分帧:TCP 编程的关键边界

上面的服务端在字节流中寻找换行符,并保留尚未收完整的部分;一次 recv() 读到多条消息时,也会逐条处理。这样做是因为发送方和接收方的读写次数没有一一对应关系。

换行分帧简单易读,但要考虑消息内容中能否出现换行符。如果协议允许任意二进制内容,或者消息本身可能包含换行,就不应直接用换行分隔。常见替代方案包括:

  • 固定长度:每条消息长度预先确定,适合格式简单且长度固定的数据。
  • 长度前缀:先发送固定宽度的长度字段,再发送指定字节数的消息。接收方必须先完整读取长度字段,校验长度范围,再完整读取消息体。
  • 明确的结束标记:通过转义规则处理正文中出现的标记,协议设计和解析都会更复杂。

无论采用哪种方式,都要定义最大消息长度。长度前缀也不是安全机制本身:恶意对端可以声明极大的长度,导致程序等待、分配过多内存或耗尽资源。接收方必须先验证长度,并为读取设置合理的时间和资源限制。

示例的客户端每个连接只发送一条请求,并读取一条响应。服务端虽然支持同一连接上连续发送多条换行消息,但当前客户端没有保存多条响应的缓冲区,不适合直接改成复杂的多请求长连接客户端。如果要复用连接,应在客户端实现与服务端一致的分帧解析,并明确响应如何对应请求,例如顺序请求、请求编号或其他应用层约定。

六、超时、异常与连接关闭

Socket 的读写可能阻塞。没有超时的客户端,在服务端无响应、网络故障或连接状态异常时,可能一直等待。可以通过 socket.settimeout(seconds) 为阻塞操作设置超时;超时发生时通常抛出 socket.timeout。设置为 None 表示不设置超时,设置为 0 则会进入非阻塞模式,后者需要按非阻塞 I/O 的规则处理,不是“超时立即返回”的通用替代方案。

示例捕获了常见的连接拒绝、超时和操作系统错误。实际程序还要结合业务处理异常:是否重试、重试几次、是否记录请求状态,都不能只依据一次 Socket 异常机械决定。尤其是客户端发送请求后发生超时,客户端未必知道服务端是否已经处理了请求。直接重试可能导致重复执行。涉及扣款、创建订单等操作时,需要由应用协议提供幂等键或其他去重机制。

sendall() 会持续发送,直到给定数据全部交给本地网络栈,或者发生错误。它不等于“对端应用已经收到并处理”。若需要确认业务处理结果,必须由应用层响应明确确认。

对端关闭连接时,recv() 返回 b""。接收方应将它视为连接结束,而不是继续循环读取。服务端示例在连接结束时丢弃没有换行符的残留数据,因为它不符合本例“一条消息以换行符结束”的协议。真实系统应按协议决定这属于正常结束还是错误,并记录必要信息。

七、常见错误及排查方法

1. 把一次 recv 当成完整消息

这是最常见的问题。即使客户端一次发送完整请求,服务端也可能只读到部分字节;如果直接解码或解析,就可能得到截断数据。应持续读取,直到识别到分隔符、长度字段指示的字节数,或协议规定的结束条件。

2. 忘记编码和解码

网络套接字收发的是字节,不是 Python 字符串。发送文本前要用 encode("utf-8") 转为字节,收到后按双方约定的编码用 decode("utf-8") 解码。编码不一致可能导致乱码或解码异常;对于二进制协议,不要擅自按文本解码。

3. 端口或监听地址不匹配

客户端连接的地址和端口必须与服务端实际监听设置一致。连接被拒绝时,先确认服务端是否启动、端口是否正确、是否有其他程序占用该端口。若客户端和服务端不在同一台机器上,客户端的 127.0.0.1 指向客户端自身,而不是远端服务端;应使用服务端可达的地址,并检查路由、防火墙和安全组。

4. 误以为 bind 失败只能通过换端口解决

地址已被其他程序占用时,绑定会失败。服务端退出后短时间内重新绑定也可能受到操作系统连接状态和套接字配置影响。示例启用了 allow_reuse_address,用于改善服务重启时重新绑定的体验,但具体行为受平台和套接字状态影响,不能据此判断端口永远可用,也不应通过盲目改端口掩盖冲突。

5. 把连接超时和读写超时混为一谈

建立连接失败、连接建立后读取无响应,是不同阶段的问题。示例为连接设置了超时,并在连接后再次设置套接字超时。生产程序还应设计整个操作的截止时间,避免多次读写各自耗时接近超时上限,导致一个请求累计等待过久。

6. 忽视并发服务的资源限制

每个连接创建一个线程的实现容易理解,但连接量增加时,线程、内存、文件描述符都会消耗资源。还需要处理慢速发送者长期占用连接、过量连接导致资源枯竭等情况。应根据负载选择线程池、异步 I/O 或成熟的服务器框架,并实施连接上限、超时和监控。

八、安全边界与部署注意事项

普通 TCP Socket 不提供传输加密,也不验证对端身份。只要网络路径允许,传输内容可能被观察或篡改;仅仅把监听地址限制在本机,不能替代远程部署时的身份认证和加密。跨不可信网络通信时,应使用经过验证的 TLS 方案或已有的安全协议,并正确配置证书验证。不要自行设计加密算法,也不要把明文口令直接放进自定义协议。

服务端还需要把所有客户端输入视为不可信数据。示例限制单行大小,并处理了无效 UTF-8,但这只是基础输入检查。若实际服务根据消息内容读写文件、执行命令、访问数据库或触发其他操作,还必须校验字段类型、范围和权限,避免注入、路径越界、越权和资源滥用。

ThreadingTCPServer 的线程模型没有在示例中实现严格的连接配额、身份认证和细粒度访问控制。监听在公网地址上运行之前,应先确认服务的暴露范围、主机防火墙规则、日志和监控方式,并评估拒绝服务风险。不要将开发示例直接当作生产级服务部署。

端口号也有使用边界。端口字段是 16 位无符号值,数值范围为 0 到 65535;实际客户端连接通常使用服务端明确开放的非零端口。端口 0 常用于让操作系统分配本地临时端口,不适合作为客户端要连接的固定服务端口。某些系统对低端口的绑定还有限制,具体权限取决于操作系统和运行环境。

九、如何扩展这份示例

在理解基本收发和分帧后,可以按需求逐步扩展,而不是一开始就把所有功能塞进 Socket 处理循环:

  • 用明确的数据结构定义请求和响应字段,并规定版本号、错误码和兼容策略。
  • 实现统一的分帧函数,将“收够一条完整消息”的逻辑从业务处理代码中拆出来。
  • 为请求设置长度限制、超时、连接数上限和可观测的日志。
  • 需要远程身份验证和加密时,选用成熟的 TLS 或应用协议实现。
  • 并发量提升后,评估线程池、selectors、asyncio 或经过维护的网络框架。

Socket 让开发者直接面对网络字节流,因此既灵活,也要求明确处理消息边界、连接状态、异常和资源限制。本文的换行协议与线程服务端足以帮助理解 TCP 客户端和服务端的基本结构;真正用于业务时,应把应用层协议、输入校验、超时策略和安全措施作为整体设计,而不是只在现有代码上增加几次 send() 和 recv()。

© 版权声明

相关文章