脚本长成服务没有分界线:先加 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. 自测
- 什么信号出现时,脚本才值得开始工程化?
- 类型注解、静态检查、运行时校验各自守的是哪条边界?
- 一次性打开
mypy --strict最常见的坏结局是什么? - 为什么 fixture 的 scope 要按"成本"和"污染"两个维度决定?
- 运行期依赖给区间、部署用锁文件,这两个决定分别防的是什么?
except Exception: return None让调用方失去了什么信息?