← Backend / Python

01_类型模型与工程组织

类型提示、数据类、可变对象、模块边界、资源管理与后端项目组织。

Python 类型模型与工程组织

学习目标:用类型提示、数据类和清晰的模块边界,让已掌握的 Python 语法适合维护后端项目。

1. 类型提示负责什么

核心概念:类型提示(type hints)是给阅读者、IDE 和静态检查器看的契约;Python 运行时通常不会自动按注解检查参数。

from dataclasses import dataclass, field

@dataclass(frozen=True)
class User:
    id: int
    name: str
    tags: list[str] = field(default_factory=list)

def display_name(user: User) -> str:
    return user.name.strip() or f"user-{user.id}"

frozen=True 防止对字段重新赋值,但不会深度冻结 tags 列表。若需要真正不可变的数据,可使用 tuple[str, ...] 并在边界复制输入。

易错点:不要写 `tags: list[str] = []` 作为可变默认值;在函数参数中也不要用可变对象作默认值。

2. 值、引用与复制

  • list、dict、set 可变;str、int、tuple 通常按不可变值使用。
  • 赋值只绑定名字,b = a 不复制对象;浅复制仅复制外层容器,嵌套对象仍共享。
  • 跨请求共享可变全局状态会产生并发和测试污染;请求状态应由依赖注入或显式参数传递。

3. 包与项目边界

app/
  api/          # HTTP 输入、输出和状态码
  service/      # 业务规则
  repository/   # 数据访问
  models/       # 数据模型与持久化模型
tests/
pyproject.toml

依赖方向应尽量从 API 层指向业务层,再指向数据访问接口;业务逻辑不要直接依赖 Web 请求对象。这样可以脱离 HTTP 测试规则。

使用项目虚拟环境和 pyproject.toml 管理依赖;把运行依赖与测试、格式化工具分开记录。密钥从环境变量或密钥管理服务读取,不提交到仓库。

4. 资源与异常

from pathlib import Path

def read_config(path: Path) -> str:
    with path.open(encoding="utf-8") as stream:
        return stream.read()

with 会在正常返回或异常时关闭文件。数据库连接、锁和临时文件也应遵循同样的获取与释放原则。只在能增加业务语义时捕获异常,不要用裸 except: 吞掉故障。

自测

  1. frozen=True 能否阻止 user.tags.append("x")?不能,它不深度冻结列表。
  2. 为什么服务层不宜直接读取 HTTP 请求对象?因为业务规则会被 Web 框架绑住,难以独立测试和复用。

5. 把类型标注用在边界上

类型标注最有价值的地方是函数输入、返回值和跨模块的数据结构。list[str] 表示列表元素预期为字符串,str | None 表示可能没有值;调用前仍要检查来自 JSON、环境变量和数据库的真实数据。静态检查只能分析声明与代码的关系,不能替代运行时校验。

from typing import Mapping

def parse_page(query: Mapping[str, str]) -> int:
    raw = query.get("page", "1")
    try:
        page = int(raw)
    except ValueError as exc:
        raise ValueError("page 必须是整数") from exc
    if page < 1:
        raise ValueError("page 必须大于 0")
    return page

这里先把不可信字符串变成整数,再检查业务范围。Mapping 表示只需要“按键读取”能力,不要求调用方一定传 dict。异常用 from exc 保留原因;HTTP 层再把它映射为 400 或 422,不应让业务函数自行拼装 HTTP 响应。

6. 可变性、默认值与对象所有权

def add_tag(tag: str, tags: list[str] | None = None) -> list[str]:
    result = [] if tags is None else tags.copy()
    result.append(tag)
    return result

这个函数每次创建结果,不意外修改调用者传入的列表。若数据嵌套,copy() 只复制第一层,内层对象仍共享;需要深拷贝时先评估成本,通常更好的办法是明确哪一层拥有修改权。@dataclass(frozen=True) 同理,只限制属性重新绑定;要表达不可变集合,优先用 tuple、frozenset 或不可变值对象。

7. 从脚本整理成可测试的包

  1. 把“读取输入—计算结果—写出结果”拆为三个函数。计算函数只接收普通值并返回普通值,最容易测试。
  2. 将文件、数据库和网络操作放在边界模块;创建连接的代码集中在应用入口或依赖提供者。
  3. 在 pyproject.toml 声明项目和工具配置,使用独立虚拟环境;记录可复现的安装、测试和启动命令。
  4. 在测试里传入临时路径或替身仓储,不依赖当前工作目录、真实用户文件和生产数据库。
from pathlib import Path

def count_nonblank(path: Path) -> int:
    text = path.read_text(encoding="utf-8")
    return sum(not char.isspace() for char in text)

上例显式传入 Path,调用者可以把测试临时文件传进去。读取很大的文件时,应改为逐块读取;“一次读完”适合大小可控的配置或笔记文件。

8. 动手练习与参考

  • 为 parse_page 写出缺省值、0、负数、非整数和正常值五个测试;说清每个分支的期望。
  • 把 User.tags 改成 tuple[str, ...],观察 frozen=True 与不可变字段的区别。