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, ...] 并在边界复制输入。
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: 吞掉故障。
自测
frozen=True能否阻止user.tags.append("x")?不能,它不深度冻结列表。- 为什么服务层不宜直接读取 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. 从脚本整理成可测试的包
- 把“读取输入—计算结果—写出结果”拆为三个函数。计算函数只接收普通值并返回普通值,最容易测试。
- 将文件、数据库和网络操作放在边界模块;创建连接的代码集中在应用入口或依赖提供者。
- 在
pyproject.toml声明项目和工具配置,使用独立虚拟环境;记录可复现的安装、测试和启动命令。 - 在测试里传入临时路径或替身仓储,不依赖当前工作目录、真实用户文件和生产数据库。
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与不可变字段的区别。