2764 字
约 9 分钟
20
S10-A05 · Python 工程化:类型、测试与依赖治理

脚本长成服务没有分界线:先加 cron,再加几个接口,然后有人开始改。等第三个人接手,最贵的已不是代码量,而是没人敢动——改一行动全局,测不出回归,装不上依赖。

工程化不是把工具装全,是三笔交易:注解买"改前知道炸哪",测试买"改后知道没炸",依赖治理买"半年后还装得上"。每笔都有成本,要判断的是值不值。


1. 先判断该不该工程化

形态 寿命预期 该投入 不该投入
一次性脚本 跑一次就扔 无 类型、测试、包结构
内部小工具 数月、单人 格式 + 基本异常处理 mypy --strict、契约测试
有外部调用方的服务 年、多人 类型、测试分层、依赖锁定、CI 无
库/被复用的包 长且不可控 全部 + 公共 API 契约 无

判据:有第二个人要读它,或者它要在半年后被修改,工程化就开始回本。单人一次性的东西上工具链,是把维护成本提前付给一个不存在的未来。


2. 动态类型的三种补救

手段 能抓到 抓不到 代价
类型注解 表达意图、辅助 IDE 运行时不校验(默认) 写注解的时间
mypy/pyright 静态检查 签名不符、None 未处理 运行期类型、外部输入 CI 时间、stub 缺失
运行时校验(pydantic/assert) 外部输入的实际结构 内部逻辑的类型错误 每次调用的开销

三者是分层不是替代:注解写给人看,静态检查守内部边界,运行时校验守外部边界。缺了运行时校验,注解会制造一种最坏的信心——"类型都对,所以数据对"。


3. 严格档位是一笔成本

# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_unreachable = true
disallow_untyped_defs = true
[[tool.mypy.overrides]]
module = ["third_party.*"]
ignore_missing_imports = true
档位 打开什么 典型代价 适合
基础 注解存在即检查 低 新项目起步
--strict disallow_untyped_defs 等 老代码一次大量报错 服务边界、核心模块
pyright strict 更严的推断与 None 流 与 mypy 结论偶有差异 团队已统一用 pyright

顺序:新模块先 strict,老模块按目录纳入。一次性全量开启,结局通常是加一堆 # type: ignore,把检查变成装饰。


4. 注解的四种正确用法

from typing import Protocol, TypedDict, Optional

class Reader(Protocol):                     # 结构化子类型:不要求继承
    def read(self) -> bytes: ...

class Payload(TypedDict):                   # 字典的字面键约束
    id: int
    name: str

def find(uid: int) -> Optional[Payload]:    # 缺失用 None,别返回空字典
    ...

def first[T](items: list[T]) -> T:          # 3.12 语法;3.11 及以下用 TypeVar
    ...

四个判据:依赖抽象用 Protocol(不逼对方继承);传输结构用 TypedDict(至少能查键名);可缺失用 Optional 并显式处理;泛型写全。别用注解表达业务约束("价格必须为正"),那是校验层的事。


5. 注解不等于校验

def transfer(amount: int) -> None: ...
transfer("1000")     # 静态检查会报,运行时照样执行 —— 除非有校验

边界上必须校验的三处:HTTP 入参、消息体、配置与环境变量。三处用同一个模型(pydantic)最省心:

位置 校验手段 不校验的后果
HTTP 入参 pydantic 模型 + 框架集成 类型混淆、注入、500
消息体 同一模型 + schema 版本 消费端批量崩溃
配置 pydantic-settings 启动即错,或静默用错默认值
数据库返回 显式转换或 ORM 模型 到处是 Any,类型形同虚设

6. 测试三层在 Python 里的形态

层 形态 依赖 速度 该测什么
单元 纯函数 + 假对象 无 毫秒 分支、边界、异常
集成 真库/真队列(容器) 有 秒 SQL、事务、序列化
契约 与外部服务的约定 有 秒 字段、状态码、兼容
# conftest.py:把"贵"的资源放到 session 作用域,把"脏"的放 function
import pytest

@pytest.fixture(scope="session")
def db():
    with start_container() as c:      # 一次启动,全测试共享
        yield c

@pytest.fixture
def clean_db(db):
    db.truncate_all()                 # 每个用例前重置
    return db

fixture 纪律:scope 由"成本"与"污染"决定——贵且可共享的放 session,会被改动的放 function。autouse=True 是常见反模式,它让用例间的隐式依赖看不见。


7. 依赖治理的四件事

[project]
dependencies = ["httpx>=0.27,<0.28"]          # 运行期:给区间,不给精确版本

[project.optional-dependencies]
dev = ["pytest", "mypy", "ruff"]              # 开发工具不进运行镜像
postgres = ["psycopg[binary]>=3.1"]

[tool.uv]
# 锁文件 uv.lock 进仓库;升级走显式命令,不靠"重装一遍"
事项 做法 失效点
锁文件 提交 uv.lock,CI 用 --frozen 不锁定 → 明天镜像不一样
私有源 [[tool.uv.index]],凭据走环境变量 凭据写进仓库
可选分组 dev/docs 分组 全塞进主依赖,镜像带编译器
传递依赖 pip-audit、uv tree 只盯直接依赖,漏洞在第三层

硬规则:运行期依赖给区间,部署用锁文件。精确版本让补丁进不来,没锁文件让"昨天能构建"变玄学。


8. 项目结构与包边界

project/
  pyproject.toml
  src/app/            # src 布局:避免误导入仓库根目录的同名目录
    __init__.py
    api/
    domain/           # 不 import api,只被 api import
    infra/
  tests/

src 布局买的是"导入即安装后的包",能立刻暴露"本地能跑、装完就崩"。包边界要写成可检查的规则:domain 不许 import infra 与 api,用 import-linter 固化,别写在文档里。


9. 配置、密钥与 12-factor

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    db_url: str
    api_key: str
    debug: bool = False
    model_config = {"env_prefix": "APP_", "env_file": ".env"}
项 该放哪 不该放哪
普通配置 环境变量、配置中心 代码常量
密钥 密钥服务、注入的环境变量 仓库、镜像、日志
环境差异 环境变量覆盖 分支里的 if env == 'prod'

BaseSettings 的价值不是读环境变量,而是启动即校验:缺字段直接失败,比跑一半才发现 None 便宜。


10. 日志与异常链

import logging, json
log = logging.getLogger("app")

try:
    handle(order)
except ValueError as e:
    log.error(json.dumps({"event": "order_failed", "order_id": order.id}), exc_info=True)
    raise
做法 收益 代价
结构化日志 可检索、可聚合 人读起来差
上下文字段 串起一次请求 需中间件注入
exc_info=True / from e 保留异常链 日志变大
只 log.error(str(e)) 无 丢栈、丢类型

纪律:日志记事件与标识,不记录密码、令牌、完整请求体;异常要么处理要么上抛,不要"记了日志又吞掉"。


11. 工具链的取舍

工具组合 维护成本 何时选
ruff(lint + format) 低 默认选择,规则够用
ruff + black 中 已有 black 历史
flake8+isort+black+pylint 高 遗留项目,暂不动

边界要分清:格式化是零争议的机械改写(交给工具,不进评审意见),lint 抓的是可疑模式(要人判断)。团队小于三人时,多工具链的配置同步成本超过它抓到的 bug 数。


12. CI 最小集

# .github/workflows/ci.yml
jobs:
  check:
    steps:
      - run: uv sync --frozen          # 依赖可复现
      - run: ruff format --check .
      - run: ruff check .
      - run: mypy src
      - run: pytest -q --maxfail=1
      - run: pip-audit                 # 依赖漏洞

顺序有讲究:依赖可复现 → 格式 → 类型 → 测试。格式失败最快也最机械;依赖锁定放第一,否则后面都在验证错误的基线。


13. 错误处理的纪律

写法 问题 改法
except Exception: pass 吞错、无法定位 捕获具体类型,记录并上抛
except Exception as e: return None 调用方无法区分"没有"与"失败" 抛自定义异常或返回结果对象
用异常做流程控制 性能与可读性都差 用返回值/状态判断
捕获后重抛新异常不带 from 丢原始栈 raise NewError(...) from e

判据:可恢复的(重试、降级、跳过单条)就地处理;不可恢复的(配置错、数据损坏)让它炸。半处理最糟:没恢复,也没留下现场。


14. 排错:四种"工程化之后仍出问题"

现象 真实原因 处理
本地能跑、CI 报导入错 缺 src 布局或没装包 改为可安装布局,CI 用 uv sync
类型全绿但线上 TypeError 外部输入没做运行时校验 边界加模型校验
测试全绿但上线回归 只测了单元,没测 SQL/序列化 补集成层,容器化依赖
构建昨天成功今天失败 没有锁文件或锁文件未提交 提交锁文件,CI 用 --frozen

15. 边界:什么时候别工程化

场景 结论 理由
一次性数据迁移脚本 不建包、不写测试 跑完即弃,结构是纯成本
团队小于三人 工具链保持最小 配置同步成本 > 收益
原型阶段 只要异常可见 需求会变,结构会白写
性能问题 用 profiler 类型不产生性能

一句话收尾:工程化买的是"可修改性",不是"正确性"。它让下次改动变便宜,但不会让这次逻辑变对——逻辑对不对,仍然只能靠测试和评审。


16. 自测

  1. 什么信号出现时,脚本才值得开始工程化?
  2. 类型注解、静态检查、运行时校验各自守的是哪条边界?
  3. 一次性打开 mypy --strict 最常见的坏结局是什么?
  4. 为什么 fixture 的 scope 要按"成本"和"污染"两个维度决定?
  5. 运行期依赖给区间、部署用锁文件,这两个决定分别防的是什么?
  6. except Exception: return None 让调用方失去了什么信息?
S10-A05 · Python 工程化:类型、测试与依赖治理
https://www.clxhxhhr.top/posts/6167/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。