《Python编程入门》12.3 socket、HTTP 客户端与 requests/httpx

从 TCP/IP 分层讲起,用 socket 手写能跑通的 echo server/client,理解 bind/listen/accept/connect/send/recv、粘包与分帧、settimeout;再用原始 socket 看清 HTTP 请求行长什么样,对比 urllib、requests 会话复用与 httpx 同步/异步双接口。

本节目标:理解 TCP socket 的生命周期与字节流特性,能读懂 HTTP 报文的原始形态,并在 urllib、requests、httpx 之间做出正确选择。
适用版本:Python 3.12+(实测 3.14.6)

12.3 socket、HTTP 客户端与 requests/httpx

前面几节我们把「怎么把活分给多个执行单元」讲完了。本节转向 I/O 的另一半——网络:数据怎么从远端来到你的程序里。我们会从最底层的 socket 一路看到上层的 HTTP 客户端库,看清它们各自替你做了什么。

12.3.1 TCP/IP 分层速览

网络通信是分层的,从下到上:

层代表协议关心什么
链路层以太网、Wi-Fi同一局域网内的帧
网络层IP主机到主机的寻址与路由
传输层TCP / UDP进程到进程的可靠/不可靠字节流
应用层HTTP / DNS / SMTP业务语义

写网络程序时,socket 是传输层的接口,AF_INET 指 IPv4、SOCK_STREAM 指 TCP。HTTP 则是架在 TCP 之上的应用层协议——它本质上就是「在一条 TCP 连接里按固定格式收发文本」。理解了这一点,后面所有客户端库都不神秘了。

12.3.2 最小 echo server / client

服务端绑定端口、等待连接、收到什么回什么:

import socket

HOST, PORT = "127.0.0.1", 65432

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind((HOST, PORT))             # 绑定地址
    srv.listen(1)                      # 开始监听,排队 1 个连接
    print(f"[server] 监听 {HOST}:{PORT}")
    conn, addr = srv.accept()          # 阻塞,直到有客户端连入
    with conn:
        print(f"[server] 已连接: {addr}")
        while True:
            data = conn.recv(1024)     # 最多收 1024 字节
            if not data:               # 对端关闭时 recv 返回空
                break
            print(f"[server] 收到 {data!r},回显")
            conn.sendall(data)
    print("[server] 连接关闭,退出")

客户端连接后发三条消息:

import socket

HOST, PORT = "127.0.0.1", 65432

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect((HOST, PORT))
    print(f"[client] 已连接 {HOST}:{PORT}")
    for msg in [b"hello", b"world", b"bye"]:
        s.sendall(msg)
        reply = s.recv(1024)
        print(f"[client] 发送 {msg!r} -> 收到 {reply!r}")
print("[client] 退出")

先跑 server,再跑 client,两边输出(真实运行):

[server] 监听 127.0.0.1:65432
[server] 已连接: ('127.0.0.1', 56837)
[server] 收到 b'hello',回显
[server] 收到 b'world',回显
[server] 收到 b'bye',回显
[server] 连接关闭,退出

[client] 已连接 127.0.0.1:65432
[client] 发送 b'hello' -> 收到 b'hello'
[client] 发送 b'world' -> 收到 b'world'
[client] 发送 b'bye' -> 收到 b'bye'
[client] 退出

12.3.3 socket 的生命周期

动作服务端客户端
建 socketsocket(AF_INET, SOCK_STREAM)同左
绑定地址bind((host, port))不需要
监听listen(n)不需要
建立连接accept() 阻塞返回 (conn, addr)connect((host, port))
收发conn.sendall() / conn.recv()s.sendall() / s.recv()
关闭conn.close()(或 with)s.close()(或 with)

要点:

  • recv(n) 最多读 n 字节,不保证读满;recv 返回空字节串表示对端已关闭。
  • sendall() 保证把全部数据发完(内部循环),比 send() 省心。
  • 用 with socket... 保证连接一定被关闭,避免句柄泄漏。
  • SO_REUSEADDR 让端口在关闭后能被立刻重用,避免「Address already in use」。

12.3.4 粘包与分帧

TCP 是字节流,没有「消息边界」。两次 send 可能被一次 recv 收全(粘包),一次 send 也可能被拆成多次 recv。用 socketpair 直观演示:

import socket

a, b = socket.socketpair()             # 一条已连好的双向管道
a.sendall(b"hello")
a.sendall(b"world")
print("两次 send 后一次 recv:", b.recv(1024))
两次 send 后一次 recv: b'helloworld'

两条消息粘成了一条。解决办法是自己定协议划边界,最常见的是换行分隔:

a, b = socket.socketpair()
a.sendall(b"hello\nworld\npartial")
buf = b""
lines = []
while len(lines) < 2:
    buf += b.recv(1024)
    while b"\n" in buf:
        line, _, buf = buf.partition(b"\n")
        lines.append(line)
print("完整消息:", lines)
print("残留:", buf)
完整消息: [b'hello', b'world']
残留: b'partial'

读完两条完整消息后,缓冲区里留下半条 partial——下次收到补全。这就是缓冲区 + 分隔符的标准写法。另一种协议是定长头 + 变长体(先发 4 字节长度,再发正文),适合二进制数据。永远不要假设一次 recv 就是一条完整消息。

12.3.5 超时

网络可能卡住,recv 默认会无限阻塞。用 settimeout 加保护:

conn.settimeout(0.3)                   # 0.3 秒内没有数据就超时
try:
    conn.recv(1024)
except TimeoutError as e:
    print("recv 超时:", type(e).__name__)
recv 超时: TimeoutError  (等待 0.30s)

超时抛的是内置 TimeoutError(socket.timeout 在 3.10+ 已并入它)。给所有网络操作设超时是生产代码的底线。

12.3.6 HTTP 请求长什么样

既然 HTTP 就是 TCP 上的文本,我们就用原始 socket 手写一个请求,看服务端到底收到了什么。先起一个本地服务(python -m http.server 8765,第 16 章会细讲),再用 socket 发请求:

import socket

HOST, PORT = "127.0.0.1", 8765
request = (
    f"GET / HTTP/1.1\r\n"
    f"Host: {HOST}:{PORT}\r\n"
    f"Connection: close\r\n"
    f"\r\n"
)
with socket.create_connection((HOST, PORT), timeout=5) as s:
    s.sendall(request.encode("ascii"))
    raw = b""
    while (chunk := s.recv(4096)):
        raw += chunk
head, _, body = raw.decode("latin-1").partition("\r\n\r\n")
print(head)
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.14.6
Date: Thu, 08 Oct 2026 23:44:55 GMT
Content-type: text/html
Content-Length: 53
Last-Modified: Thu, 08 Oct 2026 23:44:41 GMT

请求由请求行(GET / HTTP/1.1)、请求头(Host、Connection)和空行组成;响应由状态行(HTTP/1.0 200 OK)、响应头、空行、响应体组成。注意头与体之间那个空行(\r\n\r\n)——它是唯一的边界标记,Content-Length 则告诉客户端响应体有多少字节。所有 HTTP 客户端库做的事,无非是替你拼这段文本、解析那段文本、管理连接。

12.3.7 urllib.request:标准库方案

不想装任何第三方库时,urllib.request 就能发请求:

import urllib.request
import urllib.error

URL = "http://127.0.0.1:8765/"
req = urllib.request.Request(URL, headers={"User-Agent": "python-book/1.0"})
with urllib.request.urlopen(req, timeout=5) as resp:
    print("状态码:", resp.status)
    print("Content-Type:", resp.headers["Content-Type"])
    print("响应体:", resp.read().decode().strip())

try:
    urllib.request.urlopen(URL + "missing.html", timeout=5)
except urllib.error.HTTPError as e:
    print("HTTPError:", e.code, e.reason)
状态码: 200
Content-Type: text/html
响应体: <!doctype html><h1>Hello from local http.server</h1>
HTTPError: 404 File not found

注意:4xx/5xx 会抛 HTTPError 异常(它同时是一个响应对象),不像 requests 那样直接返回。urllib 不自动复用连接、不带 JSON 便捷方法、cookie 处理也原始,除非有「零依赖」硬要求,否则优先 requests / httpx。

12.3.8 requests:会话与 keep-alive

requests 是事实标准。最关键的实践是用 Session 复用连接:

import requests

URL = "http://127.0.0.1:8766/"

for i in range(5):
    r = requests.get(URL, timeout=5)        # 每次调用都新建连接
    print(f"独立 #{i} status={r.status_code}")

with requests.Session() as s:               # 复用同一条连接
    for i in range(5):
        r = s.get(URL, timeout=5)
        print(f"会话 #{i} status={r.status_code}")

服务端记录到的 TCP 连接数(每次新建连接都会打印一行):

[server] 新建 TCP 连接 #1
[server] 新建 TCP 连接 #2
[server] 新建 TCP 连接 #3
[server] 新建 TCP 连接 #4
[server] 新建 TCP 连接 #5      ← 以上 5 条来自独立的 requests.get
[server] 新建 TCP 连接 #6      ← 只有 1 条来自 Session 的 5 次请求

10 次请求只用了 6 条连接:Session 把后 5 次请求复用了同一条 TCP 连接(HTTP/1.1 keep-alive)。省下的不只是几次三次握手的延迟,还有频繁建连的资源开销。在循环里请求同一站点,务必用 Session。

12.3.9 httpx:同步与异步双接口

httpx 的 API 与 requests 高度相似,但同时提供同步和异步两套接口——同一套方法名,Client 换 AsyncClient、with 换 async with:

import asyncio
import httpx

URL = "http://127.0.0.1:8766/"

with httpx.Client(trust_env=False, timeout=5.0) as client:   # 同步
    for i in range(3):
        r = client.get(URL)
        print(f"同步 #{i} status={r.status_code}")


async def main():
    async with httpx.AsyncClient(trust_env=False, timeout=5.0) as client:
        rs = await asyncio.gather(*[client.get(URL) for _ in range(3)])
        for i, r in enumerate(rs):
            print(f"异步 #{i} status={r.status_code}")


asyncio.run(main())
同步 #0 status=200
同步 #1 status=200
同步 #2 status=200
异步 #0 status=200
异步 #1 status=200
异步 #2 status=200

实测版本:requests 2.34.2、httpx 0.28.1。

一个真实的坑:httpx 默认 trust_env=True,会读取系统代理。本机配置了公司 squid 代理时,连 127.0.0.1 都会被塞进代理,返回 503。修复办法是显式关闭:httpx.Client(trust_env=False)。requests 会自动绕过 localhost,httpx 不会——调试本地服务时记得加这一句。

三者怎么选:需要异步(第 13 章)就用 httpx;纯同步、生态最成熟用 requests;完全不想装包用 urllib。三者的 timeout 都必须显式设置,默认值往往是无超时或过长。

12.3.10 延伸阅读

socket、UDP、selectors、HTTP 客户端的更多细节见专题 Python 网络编程 。

小结

  • 网络是分层的:socket 是传输层接口(AF_INET + SOCK_STREAM = TCP),HTTP 是架在 TCP 上的应用层文本协议。
  • socket 生命周期:服务端 bind → listen → accept,客户端 connect,两端用 sendall / recv 收发;recv 不保证读满,返回空串表示对端关闭。
  • TCP 是字节流,会粘包,必须用分隔符或「定长头 + 变长体」自己划边界;所有网络操作都要设超时。
  • HTTP 报文就是「请求行/状态行 + 头 + 空行 + 体」,客户端库只是替你拼装、解析与管理连接。
  • urllib 零依赖但 4xx/5xx 抛 HTTPError;requests 用 Session 复用连接(实测 10 次请求只开 6 条连接);httpx 提供同步/异步双接口,注意 trust_env 会走系统代理。

本节我们把「数据怎么来」讲清了。但它有一个共同点——等待。下一章我们进入异步:用单线程的事件循环,把成千上万个「等待」同时管起来。

阅读导航:上一节:concurrent.futures 与 multiprocessing · 下一节:async/await 与事件循环 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时