文章
合集Python 标准库第 8 / 8 篇

Python 标准库 Day 6:网络与测试

1. 问题引入

继续博客项目。这些场景需要今天的工具:

  1. 抓取 RSS 源、调第三方 API(天气、统计服务)——怎么发 HTTP 请求?
  2. 想给同事临时分享 output/ 目录里的报告——能不能一行命令起个文件服务器?
  3. 需要直接和 SMTP / Redis / 自定义协议通信——比 HTTP 更底层
  4. 写好的函数怎么验证它真的对?怎么防止以后改代码改坏了?

今天讲五个模块,前四个是网络,最后一个是测试。彼此独立,不强行串联。

2. 主题讲解

2.1 urllib —— 标准库 HTTP 客户端

模块结构

urllib 是个"包",包含几个子模块:

urllib.request    # 发 HTTP 请求
urllib.parse      # URL 解析、编码
urllib.error      # 异常类

基本 GET 请求

from urllib.request import urlopen

with urlopen("https://api.github.com") as response:
    data = response.read()              # bytes
    text = data.decode("utf-8")         # str
    status = response.status             # 200
    headers = response.headers           # 类 dict

urlopen 返回的是个类文件对象——支持 read()、readline() 这些操作。这种"网络资源像文件一样读"的设计是 Unix 哲学的体现。

POST 请求 + 自定义 Header

from urllib.request import urlopen, Request
import json

data = json.dumps({"name": "Alice"}).encode("utf-8")    # 必须是 bytes

req = Request(
    "https://api.example.com/users",
    data=data,
    headers={"Content-Type": "application/json"},
    method="POST",
)

with urlopen(req) as response:
    result = json.loads(response.read())

URL 编码

from urllib.parse import urlencode, quote


params = {"q": "python 教程", "page": 2}
query = urlencode(params)        # 'q=python+%E6%95%99%E7%A8%8B&page=2'
url = f"https://example.com/search?{query}"


quote("hello world")             # 'hello%20world'

urlencode 和 quote 帮你处理中文、空格、特殊字符。永远不要自己拼 URL 不转义——会出 bug。

异常处理

from urllib.request import urlopen
from urllib.error import HTTPError, URLError

try:
    with urlopen("https://example.com/404", timeout=10) as r:
        data = r.read()
except HTTPError as e:
    # 服务器返回了 4xx/5xx
    print(f"HTTP {e.code}: {e.reason}")
except URLError as e:
    # 连接失败、DNS 错误、超时等
    print(f"网络错误: {e.reason}")

timeout 一定要加,否则可能永久阻塞。

urllib vs requests

requests 是第三方库,API 远比 urllib 友好:


req = Request(url, data=json.dumps(d).encode(), headers={"Content-Type": "application/json"})
with urlopen(req) as r:
    result = json.loads(r.read())


result = requests.post(url, json=d).json()

为什么还要会 urllib:

  • 标准库自带,写小工具不想加依赖时用
  • Docker 镜像精简、嵌入式 Python 等无 pip 环境
  • 读老代码会遇到

新代码生产环境推荐 requests 或 httpx。今天学 urllib 是为了理解原理,不是推荐你日常用。

2.2 http.client —— 更底层的 HTTP 客户端

urllib.request 内部就是用 http.client 实现的。直接用 http.client 的场景:

  • 需要复用同一个 TCP 连接发多个请求(性能优化)
  • 需要精细控制请求过程
  • 实现 HTTP 库(你自己造轮子时)
from http.client import HTTPSConnection

conn = HTTPSConnection("api.github.com", timeout=10)
conn.request("GET", "/")
response = conn.getresponse()
print(response.status)            # 200
print(response.read().decode())
conn.close()

实际工作中 95% 用 **urllib** 或 **requests** 就够——http.client 知道存在即可。

2.3 http.server —— 一行起本地服务器

命令行用法(最常用)

cd /path/to/files
python -m http.server 8000

打开浏览器访问 http://localhost:8000——当前目录文件全列出来。

典型场景:

  • 临时给同事分享文件
  • 测试前端能不能加载本地资源
  • 调试 webhook(先用本地 server 看请求长啥样)
  • 演示静态网站

代码方式(自定义路由)

from http.server import BaseHTTPRequestHandler, HTTPServer

class MyHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/":
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write("<h1>Hello!</h1>".encode("utf-8"))
        elif self.path == "/api/time":
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.end_headers()
            self.wfile.write(b'{"time": "now"}')
        else:
            self.send_error(404)

server = HTTPServer(("localhost", 8000), MyHandler)
server.serve_forever()

**http.server**** 只用于开发、演示、内部工具——绝不能上生产**。它单线程、没安全特性、性能极差。生产用 nginx / uvicorn / gunicorn。

2.4 socket —— TCP/UDP 底层网络编程

socket 是网络编程的最底层——HTTP、SMTP、Redis 协议、WebSocket 全部建立在它之上。99% 的应用代码用不到它,但知道它长什么样,看别人的网络代码会轻松很多。

TCP 客户端示例

import socket


s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)


s.connect(("example.com", 80))


s.sendall(b"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")


data = b""
while True:
    chunk = s.recv(4096)
    if not chunk:
        break
    data += chunk


s.close()

print(data.decode("utf-8")[:200])

这就是手撸了一个最简陋的 HTTP 客户端——拼字符串发出去,接收返回。urlopen 内部本质就是这套,外加协议解析。

TCP 服务器示例

import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("localhost", 9999))
s.listen()

print("等待连接...")
conn, addr = s.accept()                  # 阻塞等待
print(f"客户端 {addr} 连进来了")

data = conn.recv(1024)
print(f"收到: {data.decode()}")

conn.sendall(b"Hello back!")
conn.close()

什么时候真的需要 socket

  • 实现自定义协议(IoT、游戏、私有 RPC)
  • 直接和 Redis / Memcached 等对话(虽然有现成库)
  • 写底层网络工具

绝大多数应用层场景用 urllib / requests / 框架级 API 就够了。今天只要知道 socket 是网络的底层基石就行。

2.5 unittest —— 单元测试

为什么写测试

  • 验证代码"现在是对的"
  • 以后改代码时,测试能告诉你有没有改坏——这才是测试真正的价值
  • 重构时心里有底
  • 文档作用——好的测试展示了函数怎么用

基本用法


import unittest
from my_module import add, divide

class TestMath(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(2, 3), 5)
        self.assertEqual(add(-1, 1), 0)
    
    def test_divide(self):
        self.assertEqual(divide(10, 2), 5)
        self.assertAlmostEqual(divide(1, 3), 0.333, places=2)
    
    def test_divide_by_zero(self):
        # 测试"应该抛异常"
        with self.assertRaises(ZeroDivisionError):
            divide(10, 0)


if __name__ == "__main__":
    unittest.main()

跑测试:

python -m unittest test_my_module.py
python -m unittest discover    # 自动发现当前目录所有 test_*.py

常用断言

方法含义
assertEqual(a, b)a == b
assertNotEqual(a, b)a != b
assertTrue(x)
/ assertFalse(x)
布尔判断
assertIsNone(x)
/ assertIsNotNone(x)
None 判断
assertIn(a, b)a in b
assertAlmostEqual(a, b, places=7)浮点近似相等
assertRaises(Exc)在 with 块里期望抛异常
assertGreater(a, b)a > b

setUp / tearDown —— 测试前后的钩子

class TestDatabase(unittest.TestCase):
    def setUp(self):
        """每个 test_ 方法运行前调用。"""
        self.conn = create_test_db()
    
    def tearDown(self):
        """每个 test_ 方法运行后调用。"""
        self.conn.close()
    
    def test_insert(self):
        self.conn.insert(...)
        ...
    
    def test_query(self):
        self.conn.query(...)
        ...

每个测试都拿到干净的 self.conn,互不干扰。

一个完整测试示例


def humanize(seconds):
    if seconds < 60:
        return f"{seconds}秒前"
    elif seconds < 3600:
        return f"{seconds // 60}分钟前"
    else:
        return f"{seconds // 3600}小时前"


import unittest
from my_module import humanize

class TestHumanize(unittest.TestCase):
    def test_seconds(self):
        self.assertEqual(humanize(30), "30秒前")
    
    def test_minutes(self):
        self.assertEqual(humanize(120), "2分钟前")
    
    def test_hours(self):
        self.assertEqual(humanize(7200), "2小时前")
    
    def test_boundary(self):
        # 边界值测试
        self.assertEqual(humanize(59), "59秒前")
        self.assertEqual(humanize(60), "1分钟前")
        self.assertEqual(humanize(3599), "59分钟前")
        self.assertEqual(humanize(3600), "1小时前")
    
    def test_zero(self):
        self.assertEqual(humanize(0), "0秒前")

if __name__ == "__main__":
    unittest.main()

unittest vs pytest

pytest 是第三方,但事实上是 Python 测试圈的标准:


class TestMath(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(2, 3), 5)


def test_add():
    assert add(2, 3) == 5

pytest 更简洁,自动发现测试,错误提示更友好,有强大的 fixture 系统。新项目推荐 pytest,但 unittest 是标准库自带、无依赖,写小工具时仍有用。

测试覆盖率

写完测试要看覆盖率——多少代码被测到了:

pip install coverage
coverage run -m unittest discover
coverage report

工业界一般要求 60-80%+ 覆盖率。

3. Maybe Useful 旁注

旁注 1:HTTPS 证书问题:用 urllib 访问自签名证书的服务会报 SSL 错误。临时关闭验证(仅开发用):

import ssl
ctx = ssl._create_unverified_context()
urlopen(url, context=ctx)

生产环境绝对不能这样——会被中间人攻击。

旁注 2:**python -m http.server**** 的妙用**:远程服务器调试时,python -m http.server 8000 起来后用 curl 或浏览器访问能立刻确认 "端口能不能通"。这比 telnet 直观。

旁注 3:socket 编程的字节序问题:跨平台传二进制数据时要处理 big-endian / little-endian。struct 模块用来打包/解包二进制。今天用不到,但写自定义协议会撞见。

旁注 4:测试金字塔:单元测试(多)> 集成测试(中)> 端到端测试(少)。今天讲的是单元测试——快、独立、不依赖外部。集成测试测多个模块协作,端到端测试测整个用户流程。

旁注 5:mock 的概念:测试中需要"假装"某个依赖——比如测一个调 API 的函数,你不想真的发请求。unittest.mock 模块解决这个,但今天先不展开,看到代码里 @patch(...) 知道是 mock 就行。

旁注 6:面试高频题:

  • "GET 和 POST 区别?" → 必答
  • "怎么测试一个函数?" → 必答
  • "什么是 mock?什么时候用?" → 加分
  • "TCP 和 UDP 区别?" → 加分(socket 相关)
  • "为什么要写测试?" → 必答

旁注 7:**http.server**** 的安全坑**:默认会列出当前目录的所有文件——不要在含敏感数据的目录跑。.git/``.env 都会被暴露。

4. 代码实践

四个独立的小例子,对应今天每个主题。不串成一个综合项目。

4.1 用 urllib 抓 GitHub API

import json
from urllib.request import urlopen, Request
from urllib.error import HTTPError, URLError

def get_repo_info(owner: str, repo: str) -> dict | None:
    url = f"https://api.github.com/repos/{owner}/{repo}"
    req = Request(url, headers={"User-Agent": "Python-urllib"})
    
    try:
        with urlopen(req, timeout=10) as response:
            return json.loads(response.read())
    except HTTPError as e:
        print(f"HTTP {e.code}: {e.reason}")
    except URLError as e:
        print(f"网络错误: {e.reason}")
    return None


if __name__ == "__main__":
    info = get_repo_info("python", "cpython")
    if info:
        print(f"{info['stargazers_count']}")
        print(f"{info['description']}")

4.2 起一个简单文件服务器



from http.server import BaseHTTPRequestHandler, HTTPServer

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(b'{"status": "ok"}')

if __name__ == "__main__":
    HTTPServer(("localhost", 8000), Handler).serve_forever()

4.3 socket 简易 echo 客户端

import socket

def http_get_raw(host: str, path: str = "/") -> bytes:
    """手撸 HTTP GET,纯 socket。"""
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(5)
    s.connect((host, 80))
    
    request = f"GET {path} HTTP/1.0\r\nHost: {host}\r\n\r\n".encode()
    s.sendall(request)
    
    data = b""
    while True:
        chunk = s.recv(4096)
        if not chunk:
            break
        data += chunk
    s.close()
    return data


if __name__ == "__main__":
    response = http_get_raw("example.com")
    print(response[:200].decode("utf-8", errors="ignore"))

4.4 给一个函数写测试


def humanize_size(bytes_count: int) -> str:
    """把字节数转成 'X KB' / 'X MB' 字符串。"""
    if bytes_count < 1024:
        return f"{bytes_count} B"
    elif bytes_count < 1024 ** 2:
        return f"{bytes_count / 1024:.1f} KB"
    elif bytes_count < 1024 ** 3:
        return f"{bytes_count / (1024**2):.1f} MB"
    else:
        return f"{bytes_count / (1024**3):.1f} GB"


import unittest
from my_module import humanize_size

class TestHumanizeSize(unittest.TestCase):
    def test_bytes(self):
        self.assertEqual(humanize_size(0), "0 B")
        self.assertEqual(humanize_size(512), "512 B")
        self.assertEqual(humanize_size(1023), "1023 B")
    
    def test_kilobytes(self):
        self.assertEqual(humanize_size(1024), "1.0 KB")
        self.assertEqual(humanize_size(1536), "1.5 KB")
    
    def test_megabytes(self):
        self.assertEqual(humanize_size(1024 * 1024), "1.0 MB")
        self.assertEqual(humanize_size(5 * 1024 * 1024), "5.0 MB")
    
    def test_gigabytes(self):
        self.assertEqual(humanize_size(1024 ** 3), "1.0 GB")

if __name__ == "__main__":
    unittest.main()

跑测试:

$ python -m unittest test_my_module.py
....
----------------------------------------------------------------------
Ran 4 tests in 0.001s

OK

5. 练习题

理论题

  1. urllib 和 requests 各自适合什么场景?
  2. http.server 为什么不能用于生产?
  3. socket 在网络协议栈里处于什么位置?哪些日常使用的库底层是 socket?
  4. 单元测试和集成测试的区别?
  5. setUp 和 tearDown 的作用是什么?

代码实操题

题目 A:写一个 download_json(url) 函数,用 urllib 发 GET 请求并返回解析后的 JSON。处理超时、HTTP 错误、JSON 解析失败三种异常。

提示:try 三层或者捕获多种异常类型。

题目 B:用 http.server 写一个返回当前时间的 HTTP 服务,访问 / 返回 JSON {"time": "2026-04-29T..."}。

提示:在 do_GET 里用 datetime.now(tz=timezone.utc).isoformat() 生成时间。

题目 C:给 Day 4 写过的 pv_level 函数(PV 等级映射)写至少 5 个测试用例,覆盖各种边界。

提示:测最小值、最大值、刚好在分界点上、分界点 +1 / -1。

思考题

你给项目写了 100 个测试,每次跑要 30 秒。改一行代码就要等半分钟测试通过。怎么优化测试速度?哪些测试可以并行?哪些必须串行?测试连数据库的慢测试怎么处理?

6. 当天总结

今天学了什么:

四个网络模块 + 一个测试模块:

  • urllib —— 标准库 HTTP 客户端,不想加依赖时用
  • http.client —— 更底层,95% 用不到
  • http.server —— 一行起本地服务器(开发/演示用)
  • socket —— 最底层,理解网络通信的基石
  • unittest —— 标准库测试框架

最重要的概念:

  1. urllib 知道就行,生产用 requests/httpx
  2. **python -m http.server**** 是个高频 trick**,临时分享文件、调试都能用
  3. socket 是基石,但日常用不到——理解就行
  4. 写测试比写代码更重要——尤其是改代码后能立刻知道有没有改坏
  5. 断言 + setUp/tearDown 就能覆盖 80% 测试需求

需要反复练习:

  • urllib 的 GET / POST 标准写法
  • assertEqual / assertRaises / assertAlmostEqual 这几个常用断言
  • 给自己写的函数加测试