文章目录
-
- 一、前言
- [二、什么是 Python 工程化](#二、什么是 Python 工程化)
- 三、从脚本到工程
-
- [3.1 单文件脚本](#3.1 单文件脚本)
- [3.2 多模块项目](#3.2 多模块项目)
- [3.3 不要过度设计](#3.3 不要过度设计)
- 四、推荐项目目录
-
- [4.1 `src` 目录](#4.1
src目录) - [4.2 `tests` 目录](#4.2
tests目录) - [4.3 不要滥用 `utils.py`](#4.3 不要滥用
utils.py)
- [4.1 `src` 目录](#4.1
- 五、模块分层
-
- [5.1 API 层](#5.1 API 层)
- [5.2 Application 层](#5.2 Application 层)
- [5.3 Domain 层](#5.3 Domain 层)
- [5.4 Infrastructure 层](#5.4 Infrastructure 层)
- [六、使用 `pyproject.toml` 统一配置](#六、使用
pyproject.toml统一配置) -
- [6.1 完整配置示例](#6.1 完整配置示例)
- [6.2 `requires-python`](#6.2
requires-python) - [6.3 命令行入口](#6.3 命令行入口)
- 七、虚拟环境
-
- [7.1 使用标准库 `venv`](#7.1 使用标准库
venv) - [7.2 不提交虚拟环境](#7.2 不提交虚拟环境)
- [7.1 使用标准库 `venv`](#7.1 使用标准库
- [八、使用 uv 管理依赖](#八、使用 uv 管理依赖)
-
- [8.1 初始化项目](#8.1 初始化项目)
- [8.2 添加运行依赖](#8.2 添加运行依赖)
- [8.3 添加开发依赖](#8.3 添加开发依赖)
- [8.4 同步环境](#8.4 同步环境)
- [8.5 执行命令](#8.5 执行命令)
- [8.6 锁文件](#8.6 锁文件)
- [8.7 不要随意执行全量升级](#8.7 不要随意执行全量升级)
- 九、配置管理
-
- [9.1 配置与代码分离](#9.1 配置与代码分离)
- [9.2 Pydantic Settings 示例](#9.2 Pydantic Settings 示例)
- [9.3 `.env.example`](#9.3
.env.example) - [9.4 启动时验证配置](#9.4 启动时验证配置)
- 十、领域模型
-
- [10.1 金额使用 `Decimal`](#10.1 金额使用
Decimal) - [10.2 业务规则放入模型](#10.2 业务规则放入模型)
- [10.1 金额使用 `Decimal`](#10.1 金额使用
- [十一、依赖反转与 Repository](#十一、依赖反转与 Repository)
- 十二、异常设计
-
- [12.1 不要吞掉异常](#12.1 不要吞掉异常)
- [12.2 保留异常链](#12.2 保留异常链)
- 十三、日志工程化
-
- [13.1 日志配置](#13.1 日志配置)
- [13.2 日志等级](#13.2 日志等级)
- [13.3 日志字段](#13.3 日志字段)
- [13.4 使用结构化日志](#13.4 使用结构化日志)
- 十四、代码格式化与静态检查
-
- [14.1 Ruff](#14.1 Ruff)
- [14.2 不要一开始启用全部规则](#14.2 不要一开始启用全部规则)
- 十五、类型检查
-
- [15.1 类型注解的价值](#15.1 类型注解的价值)
- [15.2 使用 mypy](#15.2 使用 mypy)
- [15.3 类型检查不是运行时校验](#15.3 类型检查不是运行时校验)
- [15.4 逐步引入类型](#15.4 逐步引入类型)
- 十六、自动化测试
-
- [16.1 测试金字塔](#16.1 测试金字塔)
- [16.2 单元测试](#16.2 单元测试)
- [16.3 Fixture](#16.3 Fixture)
- [16.4 集成测试](#16.4 集成测试)
- [16.5 测试覆盖率](#16.5 测试覆盖率)
- 十七、外部服务调用
-
- [17.1 必须设置超时](#17.1 必须设置超时)
- [17.2 重试不是越多越好](#17.2 重试不是越多越好)
- [17.3 幂等](#17.3 幂等)
- 十八、同步与异步
-
- [18.1 适合异步](#18.1 适合异步)
- [18.2 不适合直接异步](#18.2 不适合直接异步)
- [18.3 不要为了异步而异步](#18.3 不要为了异步而异步)
- 十九、数据库工程化
-
- [19.1 事务边界](#19.1 事务边界)
- [19.2 数据库迁移](#19.2 数据库迁移)
- [19.3 避免 N+1 查询](#19.3 避免 N+1 查询)
- [19.4 不要拼接 SQL](#19.4 不要拼接 SQL)
- 二十、应用启动入口
- 二十一、资源生命周期
- [二十二、构建 Python 包](#二十二、构建 Python 包)
- [二十三、Docker 部署](#二十三、Docker 部署)
- [二十四、CI 持续集成](#二十四、CI 持续集成)
-
- [24.1 GitHub Actions 示例](#24.1 GitHub Actions 示例)
- [24.2 CI 不应自动修改代码](#24.2 CI 不应自动修改代码)
- 二十五、可观测性
-
- [25.1 日志指标](#25.1 日志指标)
- [25.2 服务指标](#25.2 服务指标)
- [25.3 业务指标](#25.3 业务指标)
- [25.4 健康检查](#25.4 健康检查)
- 二十六、安全工程化
-
- [26.1 Secret 管理](#26.1 Secret 管理)
- [26.2 依赖安全](#26.2 依赖安全)
- [26.3 输入校验](#26.3 输入校验)
- [26.4 最小权限](#26.4 最小权限)
- 二十七、版本与发布管理
- 二十八、常见工程化误区
-
- [28.1 所有代码都放在 `main.py`](#28.1 所有代码都放在
main.py) - [28.2 使用相对路径假设当前目录](#28.2 使用相对路径假设当前目录)
- [28.3 修改 `sys.path`](#28.3 修改
sys.path) - [28.4 直接提交 `.env`](#28.4 直接提交
.env) - [28.5 捕获所有异常后继续](#28.5 捕获所有异常后继续)
- [28.6 过度 Mock](#28.6 过度 Mock)
- [28.7 只追求覆盖率](#28.7 只追求覆盖率)
- [28.8 本地和生产使用不同命令](#28.8 本地和生产使用不同命令)
- [28.9 为了分层而分层](#28.9 为了分层而分层)
- [28.1 所有代码都放在 `main.py`](#28.1 所有代码都放在
- 二十九、落地实施顺序
- 三十、上线检查清单
- 三十一、总结
一、前言
很多 Python 项目最初都从一个脚本开始:
text
main.py
随着业务不断增加,项目逐渐变成:
text
main.py
utils.py
utils_new.py
utils_final.py
config.py
test.py
此时通常会出现以下问题:
- 代码全部堆在少数文件中;
- 模块之间循环依赖;
- 开发环境和生产环境依赖版本不同;
- 配置、密码和业务代码混在一起;
- 没有统一的格式化和代码检查;
- 修改代码后不知道是否影响其他功能;
- 测试依赖真实数据库和外部接口;
- 日志只有
print(),无法定位线上问题; - 本地可以运行,部署后却无法启动;
- 不同开发人员使用不同命令和目录结构。
所谓 Python 工程化,就是通过项目结构、开发规范、自动化工具和交付流程,让 Python 项目具备:
- 可维护性;
- 可测试性;
- 可复现性;
- 可观测性;
- 可部署性;
- 可扩展性;
- 团队协作能力。
本文将以一个订单服务为例,介绍现代 Python 项目的目录结构、pyproject.toml、依赖锁定、配置管理、日志、异常、类型检查、自动化测试、Docker 和 CI/CD。
二、什么是 Python 工程化
Python 工程化并不等于"安装很多工具"。
它真正要解决的是:
text
代码如何组织
依赖如何复现
配置如何隔离
质量如何检查
功能如何测试
问题如何定位
应用如何交付
变更如何自动验证
一个完整的工程流程可以表示为:
text
需求设计
│
▼
模块划分
│
▼
编码与类型检查
│
▼
格式化与静态检查
│
▼
单元测试与集成测试
│
▼
构建制品
│
▼
自动部署
│
▼
日志、指标与告警
工程化的目标不是让项目结构越复杂越好,而是让复杂度有明确的存放位置。
三、从脚本到工程
3.1 单文件脚本
对于一次性任务,单文件完全合理:
text
cleanup.py
适合:
- 临时数据处理;
- 本地自动化;
- 一次性迁移;
- 简单运维脚本。
3.2 多模块项目
当项目开始出现以下情况时,应考虑工程化:
- 多个业务模块;
- 多个开发人员;
- 需要长期维护;
- 需要连接数据库;
- 需要对外提供 API;
- 需要自动化测试;
- 需要部署到生产环境;
- 需要发布为 Python 包。
3.3 不要过度设计
一个只有几百行代码的小工具,不一定需要复杂的领域驱动架构。
项目结构应与业务复杂度匹配:
| 项目类型 | 推荐结构 |
|---|---|
| 一次性脚本 | 单文件或少量模块 |
| CLI 工具 | Package + Commands |
| Web API | API、Service、Repository |
| 数据处理 | Pipeline、Task、Adapter |
| 复杂业务系统 | Domain、Application、Infrastructure |
| 公共库 | 清晰 API、测试、构建和版本管理 |
四、推荐项目目录
下面是一套适合中小型服务的目录结构:
text
order-service/
├── pyproject.toml
├── uv.lock
├── README.md
├── .python-version
├── .env.example
├── .gitignore
├── Dockerfile
│
├── src/
│ └── order_service/
│ ├── __init__.py
│ ├── __main__.py
│ ├── config.py
│ ├── logging_config.py
│ ├── exceptions.py
│ │
│ ├── domain/
│ │ ├── __init__.py
│ │ └── order.py
│ │
│ ├── application/
│ │ ├── __init__.py
│ │ └── order_service.py
│ │
│ ├── infrastructure/
│ │ ├── __init__.py
│ │ └── order_repository.py
│ │
│ └── api/
│ ├── __init__.py
│ └── main.py
│
├── tests/
│ ├── unit/
│ │ └── test_order_service.py
│ ├── integration/
│ │ └── test_order_repository.py
│ └── conftest.py
│
├── migrations/
├── scripts/
└── .github/
└── workflows/
└── ci.yml
4.1 src 目录
业务代码放在:
text
src/order_service/
而不是直接放在项目根目录。
Python Packaging User Guide 指出,src 布局能够降低开发时意外导入项目根目录源码的风险,使测试更接近真实安装后的运行方式。src Layout vs Flat Layout
4.2 tests 目录
测试代码与生产代码分离:
text
tests/unit
tests/integration
这样可以明确区分:
- 单元测试;
- 数据库测试;
- 外部接口测试;
- 端到端测试。
pytest 的官方实践也推荐新项目结合 src 布局,并让测试针对已经安装的项目执行。pytest Good Integration Practices
4.3 不要滥用 utils.py
很多项目最后会出现一个几千行的:
text
utils.py
它通常混合了:
- 日期处理;
- HTTP 请求;
- 数据库函数;
- 加密函数;
- 业务计算;
- 文件处理。
更好的方式是按照能力拆分:
text
time_utils.py
http_client.py
money.py
security.py
如果函数只服务于订单业务,就应放在订单模块,而不是放入全局工具类。
五、模块分层
可以将项目划分为四个主要层次。
text
API 层
接收请求和返回响应
│
▼
Application 层
编排业务流程
│
▼
Domain 层
表达核心业务规则
│
▼
Infrastructure 层
数据库、缓存、消息和外部系统
5.1 API 层
负责:
- 参数解析;
- 身份认证;
- 输入校验;
- 调用应用服务;
- 转换响应;
- 映射错误码。
API 层不应包含大量业务规则。
5.2 Application 层
负责:
- 编排业务步骤;
- 控制事务边界;
- 调用领域对象;
- 调用 Repository;
- 发送领域事件。
5.3 Domain 层
负责:
- 核心业务模型;
- 状态转换;
- 业务约束;
- 金额和时间规则;
- 与框架无关的业务逻辑。
5.4 Infrastructure 层
负责:
- 数据库实现;
- Redis;
- 消息队列;
- HTTP 客户端;
- 对象存储;
- 第三方 SDK。
依赖方向应尽量保持:
text
外层依赖内层
内层不依赖外层框架
六、使用 pyproject.toml 统一配置
pyproject.toml 是现代 Python 项目的核心配置文件,可以用于:
- 项目元数据;
- Python 版本;
- 运行依赖;
- 开发依赖;
- 构建系统;
- 命令行入口;
- pytest 配置;
- Ruff 配置;
- mypy 配置;
- Coverage 配置。
Python Packaging User Guide 将其划分为:
[build-system];[project];- 各种
[tool.*]工具配置。
详细规范可以参考 Writing pyproject.toml。
6.1 完整配置示例
toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "order-service"
version = "0.1.0"
description = "A production-ready Python order service"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"pydantic-settings>=2,<3",
]
[project.scripts]
order-service = "order_service.__main__:main"
[dependency-groups]
dev = [
"mypy",
"pytest",
"pytest-cov",
"ruff",
]
[tool.hatch.build.targets.wheel]
packages = ["src/order_service"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = [
"--import-mode=importlib",
"--strict-config",
"--strict-markers",
"-ra",
]
markers = [
"integration: tests requiring infrastructure",
]
[tool.coverage.run]
branch = true
source = ["order_service"]
[tool.coverage.report]
show_missing = true
skip_covered = true
fail_under = 80
[tool.ruff]
line-length = 100
target-version = "py312"
src = ["src", "tests"]
[tool.ruff.lint]
select = [
"E",
"F",
"I",
"B",
"UP",
"SIM",
"RUF",
]
ignore = []
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.mypy]
python_version = "3.12"
strict = true
packages = ["order_service"]
warn_unused_configs = true
6.2 requires-python
toml
requires-python = ">=3.12"
它表示项目支持的 Python 版本范围。
不要仅仅在 README 中写版本要求,因为工具无法自动验证普通文档中的说明。
6.3 命令行入口
toml
[project.scripts]
order-service = "order_service.__main__:main"
安装项目后,可以直接运行:
bash
order-service
而不需要:
bash
python src/order_service/main.py
七、虚拟环境
不同项目可能依赖不同版本的第三方库。
如果全部安装到系统 Python:
text
项目 A 需要 package==1.x
项目 B 需要 package==2.x
就可能产生依赖冲突。
虚拟环境可以为项目提供隔离的 Python 环境。
7.1 使用标准库 venv
创建环境:
bash
python -m venv .venv
激活环境:
Linux 或 macOS:
bash
source .venv/bin/activate
Windows:
powershell
.venv\Scripts\activate
安装项目:
bash
python -m pip install -e .
安装开发依赖的具体方式取决于项目采用的依赖管理方案。
7.2 不提交虚拟环境
.gitignore 中应包含:
gitignore
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
dist/
build/
.env
虚拟环境应该在每台开发机和 CI 环境中根据依赖配置重新创建。
八、使用 uv 管理依赖
Python 项目可以使用:
- pip;
- uv;
- Poetry;
- PDM;
- Hatch;
- Conda。
本文以 uv 为例,但工程原则并不依赖某一个工具。
8.1 初始化项目
bash
uv init --package order-service
cd order-service
8.2 添加运行依赖
bash
uv add pydantic-settings
8.3 添加开发依赖
bash
uv add --dev pytest pytest-cov mypy ruff
8.4 同步环境
bash
uv sync
8.5 执行命令
bash
uv run pytest
uv run ruff check .
uv run mypy src
8.6 锁文件
uv 使用:
text
uv.lock
保存解析后的精确依赖版本。
text
pyproject.toml
描述允许使用哪些版本
│
▼
依赖解析
│
▼
uv.lock
记录实际安装版本
uv.lock 应提交到版本控制,使本地、CI 和部署环境尽量使用一致版本。uv 官方文档将锁定定义为把依赖解析为 Lockfile,将同步定义为从 Lockfile 安装依赖。uv Locking and Syncing
生产和 CI 中可以执行:
bash
uv sync --locked
如果 pyproject.toml 与锁文件不一致,命令会失败,而不是静默修改依赖。
8.7 不要随意执行全量升级
不建议在上线构建中自动执行:
bash
uv lock --upgrade
更合理的方式是:
text
创建依赖升级分支
│
▼
更新指定依赖
│
▼
执行测试和安全扫描
│
▼
人工审核变更
│
▼
合并并发布
九、配置管理
配置通常包括:
- 数据库地址;
- Redis 地址;
- 服务端口;
- 日志级别;
- 超时时间;
- 功能开关;
- 第三方 API 地址;
- 密钥引用。
9.1 配置与代码分离
不要这样写:
python
DATABASE_URL = "postgresql://admin:password@prod-db/orders"
更合理的方式是通过:
- 环境变量;
- 配置中心;
- Secret Manager;
- Kubernetes Secret;
- 密钥管理服务;
注入运行环境。
9.2 Pydantic Settings 示例
python
# src/order_service/config.py
from functools import lru_cache
from typing import Literal
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="APP_",
extra="ignore",
)
environment: Literal[
"local",
"test",
"staging",
"production",
] = "local"
database_url: SecretStr
log_level: str = "INFO"
request_timeout_seconds: float = 5.0
@lru_cache
def get_settings() -> Settings:
return Settings()
环境变量:
bash
export APP_DATABASE_URL="postgresql://..."
export APP_ENVIRONMENT="production"
export APP_LOG_LEVEL="INFO"
使用配置:
python
settings = get_settings()
database_url = settings.database_url.get_secret_value()
9.3 .env.example
仓库中可以提交:
dotenv
APP_ENVIRONMENT=local
APP_DATABASE_URL=postgresql://user:password@localhost:5432/orders
APP_LOG_LEVEL=DEBUG
APP_REQUEST_TIMEOUT_SECONDS=5
但不要提交真实的:
text
.env
9.4 启动时验证配置
配置缺失时,应该在应用启动阶段立即失败:
text
启动失败
缺少 APP_DATABASE_URL
而不是等到收到第一笔请求后才报错。
十、领域模型
领域模型用于表达业务,而不是数据库表的简单复制。
python
# src/order_service/domain/order.py
from dataclasses import dataclass
from decimal import Decimal
from enum import StrEnum
class OrderStatus(StrEnum):
CREATED = "CREATED"
PAID = "PAID"
CANCELLED = "CANCELLED"
@dataclass(slots=True)
class Order:
id: str
user_id: str
amount: Decimal
status: OrderStatus = OrderStatus.CREATED
def mark_paid(self) -> None:
if self.status != OrderStatus.CREATED:
raise ValueError(
f"Order {self.id} cannot be paid "
f"from status {self.status}"
)
self.status = OrderStatus.PAID
def cancel(self) -> None:
if self.status != OrderStatus.CREATED:
raise ValueError(
f"Order {self.id} cannot be cancelled "
f"from status {self.status}"
)
self.status = OrderStatus.CANCELLED
10.1 金额使用 Decimal
错误方式:
python
amount = 0.1 + 0.2
金融金额应使用:
python
from decimal import Decimal
amount = Decimal("0.10") + Decimal("0.20")
避免直接使用二进制浮点数进行金额计算。
10.2 业务规则放入模型
错误方式是在 Controller 中判断:
python
if order.status == "CREATED":
order.status = "PAID"
更好的方式:
python
order.mark_paid()
这样状态转换规则不会散落在多个接口和任务中。
十一、依赖反转与 Repository
业务服务不应该直接依赖某一个数据库驱动。
可以通过 Protocol 定义接口:
python
# src/order_service/application/order_service.py
from typing import Protocol
from order_service.domain.order import Order
class OrderRepository(Protocol):
def get_by_id(self, order_id: str) -> Order | None:
...
def save(self, order: Order) -> None:
...
class OrderNotFoundError(Exception):
pass
class OrderApplicationService:
def __init__(
self,
repository: OrderRepository,
) -> None:
self._repository = repository
def pay(self, order_id: str) -> Order:
order = self._repository.get_by_id(order_id)
if order is None:
raise OrderNotFoundError(order_id)
order.mark_paid()
self._repository.save(order)
return order
生产环境可以提供:
text
PostgreSQLOrderRepository
单元测试可以提供:
text
InMemoryOrderRepository
应用服务不需要知道底层使用 PostgreSQL、MySQL 还是内存。
十二、异常设计
不要在所有地方直接抛出:
python
Exception("error")
可以建立异常层次:
python
# src/order_service/exceptions.py
class ApplicationError(Exception):
"""Application-level base exception."""
class ValidationError(ApplicationError):
pass
class ResourceNotFoundError(ApplicationError):
pass
class ConflictError(ApplicationError):
pass
class ExternalServiceError(ApplicationError):
pass
API 层负责映射:
| 业务异常 | HTTP 状态 |
|---|---|
ValidationError |
400 |
ResourceNotFoundError |
404 |
ConflictError |
409 |
| 未知异常 | 500 |
12.1 不要吞掉异常
错误方式:
python
try:
process()
except Exception:
pass
这会让系统表面上运行正常,但业务数据可能已经没有被处理。
更合理的方式:
python
try:
process()
except ExpectedBusinessError:
logger.warning(
"business operation rejected",
exc_info=True,
)
raise
except Exception:
logger.exception(
"unexpected processing failure"
)
raise
12.2 保留异常链
python
try:
response = client.send()
except TimeoutError as error:
raise ExternalServiceError(
"payment service timeout"
) from error
from error 能够保留原始异常原因,方便排查问题。
十三、日志工程化
线上服务不应该依赖:
python
print("error")
应使用标准日志系统:
python
import logging
logger = logging.getLogger(__name__)
13.1 日志配置
python
# src/order_service/logging_config.py
from logging.config import dictConfig
def configure_logging(level: str = "INFO") -> None:
dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": (
"%(asctime)s "
"%(levelname)s "
"%(name)s "
"%(message)s"
),
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"level": level,
},
},
"root": {
"handlers": ["console"],
"level": level,
},
}
)
Python 标准库支持通过 dictConfig() 使用字典集中配置 Logger、Handler 和 Formatter。Python Logging HOWTO
13.2 日志等级
| 等级 | 使用场景 |
|---|---|
| DEBUG | 调试信息 |
| INFO | 正常业务节点 |
| WARNING | 可恢复的异常情况 |
| ERROR | 当前操作失败 |
| CRITICAL | 系统无法继续运行 |
13.3 日志字段
生产日志建议包含:
text
timestamp
level
service
environment
request_id
trace_id
user_id
operation
duration_ms
error_type
不要记录:
- 密码;
- Token;
- 私钥;
- 完整银行卡号;
- 完整身份证件;
- 数据库连接密码;
- 第三方 API Secret。
13.4 使用结构化日志
在容器环境中,推荐把日志写到标准输出,并采用 JSON 等结构化格式。
json
{
"level": "INFO",
"message": "order paid",
"order_id": "ORDER-10001",
"request_id": "req-123",
"duration_ms": 42
}
这样更便于日志平台进行检索、聚合和告警。
十四、代码格式化与静态检查
代码规范不应依赖人工记忆,应交给自动化工具。
14.1 Ruff
Ruff 可以用于:
- 代码格式化;
- 未使用导入检查;
- 导入顺序检查;
- 常见错误检查;
- 代码现代化;
- 部分复杂度和安全规则检查。
配置:
toml
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = [
"E",
"F",
"I",
"B",
"UP",
"SIM",
"RUF",
]
检查:
bash
uv run ruff check .
自动修复安全范围内的问题:
bash
uv run ruff check . --fix
格式化:
bash
uv run ruff format .
验证格式但不修改:
bash
uv run ruff format . --check
Ruff 可以通过 pyproject.toml、ruff.toml 或 .ruff.toml 配置。Ruff Configuration
14.2 不要一开始启用全部规则
直接启用所有规则可能产生:
- 大量历史问题;
- 互相冲突的风格要求;
- 团队无法一次完成整改;
- 开发人员绕过检查。
更合理的方式:
text
从基础规则开始
│
▼
清理现有问题
│
▼
CI 阻止新增问题
│
▼
逐步增加更严格规则
十五、类型检查
Python 是动态语言,但可以使用类型注解提高可维护性。
python
def calculate_total(
prices: list[Decimal],
) -> Decimal:
return sum(prices, start=Decimal("0"))
15.1 类型注解的价值
类型注解可以帮助发现:
- 参数类型错误;
- 返回值错误;
None未处理;- 不兼容接口实现;
- 重构遗漏;
- 容器元素类型错误。
15.2 使用 mypy
执行:
bash
uv run mypy src
严格配置:
toml
[tool.mypy]
python_version = "3.12"
strict = true
packages = ["order_service"]
mypy 支持在 pyproject.toml 的 [tool.mypy] 中进行配置。mypy Documentation
15.3 类型检查不是运行时校验
下面的注解:
python
def create_order(amount: Decimal) -> None:
...
不会自动阻止运行时传入字符串。
类型检查主要发生在开发和 CI 阶段。外部输入仍需要使用:
- Pydantic;
- Dataclass 转换;
- 显式校验;
- Web 框架参数模型;
进行运行时验证。
15.4 逐步引入类型
遗留项目不需要第一天就开启全局严格模式。
可以按照模块逐步执行:
text
新模块严格检查
核心业务优先改造
基础设施逐步补齐
最终提高全局标准
十六、自动化测试
测试不是为了追求百分之百覆盖率,而是为了降低变更风险。
16.1 测试金字塔
text
端到端测试
数量少、成本高
▲
│
集成测试
数据库、缓存、外部接口
▲
│
单元测试
数量多、执行快、定位准
16.2 单元测试
使用内存 Repository 测试业务逻辑:
python
# tests/unit/test_order_service.py
from decimal import Decimal
from order_service.application.order_service import (
OrderApplicationService,
)
from order_service.domain.order import (
Order,
OrderStatus,
)
class InMemoryOrderRepository:
def __init__(self) -> None:
self.orders: dict[str, Order] = {}
def get_by_id(
self,
order_id: str,
) -> Order | None:
return self.orders.get(order_id)
def save(self, order: Order) -> None:
self.orders[order.id] = order
def test_pay_order() -> None:
repository = InMemoryOrderRepository()
repository.save(
Order(
id="ORDER-10001",
user_id="USER-1",
amount=Decimal("99.00"),
)
)
service = OrderApplicationService(repository)
result = service.pay("ORDER-10001")
assert result.status == OrderStatus.PAID
assert (
repository.orders["ORDER-10001"].status
== OrderStatus.PAID
)
执行:
bash
uv run pytest
16.3 Fixture
公共测试对象可以放入:
python
# tests/conftest.py
from decimal import Decimal
import pytest
from order_service.domain.order import Order
@pytest.fixture
def created_order() -> Order:
return Order(
id="ORDER-10001",
user_id="USER-1",
amount=Decimal("99.00"),
)
测试中使用:
python
def test_order_default_status(
created_order: Order,
) -> None:
assert created_order.status.value == "CREATED"
16.4 集成测试
集成测试应验证真实边界,例如:
- SQL 是否正确;
- ORM 映射是否正确;
- 数据库事务是否生效;
- Redis 序列化是否正确;
- HTTP 请求和响应是否匹配;
- 消息队列是否能够正确消费。
不要把所有外部依赖都 Mock 掉后,就把测试称为集成测试。
16.5 测试覆盖率
执行:
bash
uv run pytest \
--cov=order_service \
--cov-report=term-missing
覆盖率可以帮助发现没有执行过的代码,但高覆盖率并不等于高质量测试。
需要重点覆盖:
- 核心业务规则;
- 边界条件;
- 异常分支;
- 状态转换;
- 金额计算;
- 权限控制;
- 幂等逻辑;
- 事务失败。
十七、外部服务调用
调用第三方服务时,需要统一处理:
- 超时;
- 重试;
- 限流;
- 熔断;
- 状态码;
- 幂等;
- 日志;
- 指标。
17.1 必须设置超时
错误方式:
python
response = http_client.get(url)
正确方式:
python
response = http_client.get(
url,
timeout=5.0,
)
没有超时的请求可能长时间占用:
- Worker;
- 线程;
- 数据库连接;
- HTTP 连接池;
- 整条业务链路。
17.2 重试不是越多越好
适合重试:
- 临时网络错误;
- 连接重置;
- 部分限流响应;
- 明确可恢复的服务异常。
不适合直接重试:
- 参数错误;
- 权限错误;
- 业务校验失败;
- 非幂等写操作;
- 已经确定的永久失败。
推荐使用:
text
有限次数
+
指数退避
+
随机抖动
+
最大等待时间
17.3 幂等
如果创建订单请求因为超时而重试,服务端必须判断:
text
第一次请求是否已经成功
可以使用:
text
Idempotency-Key
业务唯一号
数据库唯一约束
请求状态表
十八、同步与异步
Python 提供 asyncio,但异步并不适合所有任务。
18.1 适合异步
- HTTP 请求;
- 网络 I/O;
- 异步数据库驱动;
- WebSocket;
- 高并发 I/O 服务。
18.2 不适合直接异步
- CPU 密集计算;
- 图像处理;
- 大规模压缩;
- 机器学习推理;
- 使用阻塞式 SDK 的代码。
如果在异步函数中调用阻塞函数:
python
async def handle() -> None:
blocking_client.request()
可能阻塞整个事件循环。
18.3 不要为了异步而异步
异步会增加:
- 调试复杂度;
- 调用链复杂度;
- 资源生命周期管理难度;
- 测试复杂度;
- 错误传播复杂度。
应根据实际并发模型选择同步、线程、进程或异步。
十九、数据库工程化
数据库访问需要统一处理:
- 连接池;
- 事务;
- 超时;
- Migration;
- Repository;
- 慢查询;
- 幂等;
- 索引。
19.1 事务边界
事务通常由 Application 层控制:
text
开始事务
│
├── 查询订单
├── 更新状态
├── 写入流水
└── 保存 Outbox 事件
│
▼
提交事务
不要在一个业务流程中随意多次提交,导致部分成功、部分失败。
19.2 数据库迁移
数据库结构变更应通过 Migration 管理:
text
001_create_order.sql
002_add_order_status.sql
003_create_payment_record.sql
不要在生产环境中手工修改表结构后不留下记录。
Migration 需要:
- 进入版本控制;
- 在测试环境验证;
- 支持重复执行检查;
- 考虑大表锁表;
- 准备回滚或前向修复方案。
19.3 避免 N+1 查询
错误流程:
text
查询 100 个订单
│
▼
循环查询 100 次用户
应考虑:
- Join;
- 批量查询;
- 预加载;
- DataLoader;
- 缓存。
19.4 不要拼接 SQL
错误方式:
python
sql = (
"SELECT * FROM users "
f"WHERE name = '{user_input}'"
)
应使用参数化查询:
python
cursor.execute(
"SELECT * FROM users WHERE name = %s",
(user_input,),
)
二十、应用启动入口
__main__.py 可以作为统一启动入口:
python
# src/order_service/__main__.py
import logging
from order_service.config import get_settings
from order_service.logging_config import (
configure_logging,
)
def main() -> int:
settings = get_settings()
configure_logging(settings.log_level)
logger = logging.getLogger(__name__)
logger.info(
"service starting",
extra={
"environment": settings.environment,
},
)
# 初始化数据库、HTTP 客户端或 Web 服务
return 0
if __name__ == "__main__":
raise SystemExit(main())
运行方式:
bash
uv run order-service
也可以:
bash
uv run python -m order_service
统一入口可以集中完成:
- 配置加载;
- 日志初始化;
- 依赖创建;
- 资源注册;
- 优雅关闭。
二十一、资源生命周期
数据库连接池、HTTP Client 和消息客户端都需要正确关闭。
可以使用上下文管理器:
python
from contextlib import contextmanager
from collections.abc import Iterator
@contextmanager
def managed_resource() -> Iterator[Resource]:
resource = Resource()
try:
yield resource
finally:
resource.close()
使用:
python
with managed_resource() as resource:
resource.process()
对于异步资源:
python
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
@asynccontextmanager
async def managed_client() -> AsyncIterator[Client]:
client = Client()
try:
yield client
finally:
await client.close()
不要只考虑正常流程,还要考虑:
- 初始化失败;
- 运行时异常;
- 容器终止;
- 优雅停机;
- 部分资源创建成功。
二十二、构建 Python 包
构建项目:
bash
uv build
通常会生成:
text
dist/
├── order_service-0.1.0-py3-none-any.whl
└── order_service-0.1.0.tar.gz
其中:
- Wheel 用于快速安装;
- Source Distribution 包含源代码发行内容。
安装构建结果:
bash
python -m pip install \
dist/order_service-0.1.0-py3-none-any.whl
建议在 CI 中额外验证:
- Wheel 能否安装;
- 安装后能否导入;
- 命令行入口能否运行;
- 构建制品是否包含必要文件;
- 构建制品是否意外包含密码和测试数据。
二十三、Docker 部署
下面使用 uv 官方基础镜像构建应用:
dockerfile
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
COPY pyproject.toml uv.lock README.md ./
RUN uv sync \
--locked \
--no-dev \
--no-install-project
COPY src ./src
RUN uv sync \
--locked \
--no-dev \
--no-editable
ENV PATH="/app/.venv/bin:${PATH}"
RUN useradd \
--create-home \
--uid 10001 \
appuser
USER appuser
CMD ["order-service"]
生产环境还应注意:
- 固定基础镜像版本或摘要;
- 使用非 Root 用户;
- 不把
.env复制进镜像; - 不在镜像中保存密钥;
- 减少不必要的软件包;
- 设置健康检查;
- 限制 CPU 和内存;
- 扫描镜像漏洞;
- 记录镜像和代码版本。
二十四、CI 持续集成
每次提交代码后,CI 应自动执行:
text
安装锁定依赖
│
▼
格式检查
│
▼
静态检查
│
▼
类型检查
│
▼
自动化测试
│
▼
构建制品
24.1 GitHub Actions 示例
yaml
name: CI
on:
push:
pull_request:
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v9
with:
enable-cache: true
- name: Install Python
run: uv python install
- name: Sync dependencies
run: uv sync --locked --dev
- name: Lint
run: uv run ruff check .
- name: Check formatting
run: uv run ruff format . --check
- name: Type check
run: uv run mypy src
- name: Run tests
run: >
uv run pytest
--cov=order_service
--cov-report=term-missing
- name: Build
run: uv build
实际项目应将第三方 Action 固定到经过审核的 Commit SHA,降低供应链风险。uv 官方也推荐使用 astral-sh/setup-uv,并在 CI 中通过锁文件同步项目依赖。Using uv in GitHub Actions
24.2 CI 不应自动修改代码
CI 中应执行:
bash
ruff format . --check
ruff check .
而不是自动格式化后直接通过。
代码修改应由开发环境或单独的自动修复流程完成。
二十五、可观测性
成熟的 Python 服务需要同时具备:
text
Logs
发生了什么
Metrics
发生了多少次、速度如何
Traces
一条请求经过了哪些服务
25.1 日志指标
建议监控:
- ERROR 数量;
- 异常类型;
- 关键业务失败;
- 第三方服务错误;
- 慢请求;
- 重试次数。
25.2 服务指标
建议监控:
- 请求量;
- 成功率;
- P95 和 P99 延迟;
- CPU;
- 内存;
- 线程或任务数量;
- 数据库连接池;
- 消息积压。
25.3 业务指标
例如订单服务需要监控:
- 订单创建数量;
- 支付成功率;
- 支付失败原因;
- 重复请求数量;
- 长时间未完成订单;
- 补偿任务数量。
只监控 CPU 和内存,无法判断业务是否正常。
25.4 健康检查
健康检查可以分为:
text
Liveness
进程是否还活着
Readiness
当前是否能够接收流量
Startup
应用是否完成初始化
不要让普通健康检查执行复杂的大表 SQL 或调用所有下游系统,否则健康检查本身可能制造额外故障。
二十六、安全工程化
26.1 Secret 管理
不要把以下内容提交到仓库:
- 数据库密码;
- JWT 密钥;
- 私钥;
- API Secret;
- 云平台凭证;
- 生产环境
.env。
应使用:
- Secret Manager;
- Vault;
- Kubernetes Secret;
- CI Secret;
- KMS;
- HSM。
26.2 依赖安全
第三方依赖可能存在:
- 已知漏洞;
- 恶意包;
- 拼写相似包;
- 供应链攻击;
- 维护停止;
- 不兼容升级。
建议:
- 锁定依赖;
- 定期扫描漏洞;
- 审核新增依赖;
- 使用可信镜像源;
- 记录软件物料清单;
- 分批升级和回归测试。
26.3 输入校验
所有外部输入都不可信,包括:
- HTTP 参数;
- 消息队列数据;
- CSV 文件;
- 环境变量;
- 数据库历史数据;
- 第三方 API 响应。
需要校验:
- 类型;
- 长度;
- 范围;
- 格式;
- 枚举值;
- 业务关系。
26.4 最小权限
应用使用的数据库账号不应拥有:
- 创建管理员;
- 删除整个数据库;
- 修改系统配置;
- 访问无关业务库;
等不必要权限。
二十七、版本与发布管理
推荐使用语义化版本:
text
MAJOR.MINOR.PATCH
例如:
text
1.4.2
可以理解为:
- MAJOR:不兼容变更;
- MINOR:向后兼容的新功能;
- PATCH:向后兼容的问题修复。
一次发布应能够关联:
text
Git Commit
Git Tag
构建制品
Docker 镜像
依赖锁文件
数据库 Migration
发布记录
这样线上出现问题时,才能准确回答:
text
部署了哪一版代码?
使用了哪些依赖?
执行了哪些数据库变更?
能否回滚?
二十八、常见工程化误区
28.1 所有代码都放在 main.py
问题:
- 模块职责不清;
- 难以测试;
- 冲突频繁;
- 重构困难。
应按业务和职责拆分模块。
28.2 使用相对路径假设当前目录
错误方式:
python
open("./config.json")
从不同目录启动时可能失败。
应通过:
- Package Resource;
- 明确配置路径;
pathlib.Path;- 环境变量;
解析文件位置。
28.3 修改 sys.path
错误方式:
python
import sys
sys.path.append("../")
这通常说明项目没有被正确安装或目录结构存在问题。
应使用:
bash
uv sync
或者:
bash
pip install -e .
让项目以 Package 形式进入环境。
28.4 直接提交 .env
.env 通常包含敏感配置,不应进入 Git。
只提交:
text
.env.example
28.5 捕获所有异常后继续
python
try:
process()
except Exception:
return None
这会隐藏真实故障,并可能造成错误数据继续流转。
28.6 过度 Mock
如果测试把数据库、序列化、SQL 和 HTTP 全部 Mock 掉,只能证明 Mock 按照预期工作。
核心基础设施仍需要集成测试。
28.7 只追求覆盖率
为了覆盖率编写没有断言意义的测试,无法真正降低风险。
应该优先验证业务行为和边界条件。
28.8 本地和生产使用不同命令
如果本地运行:
bash
python app.py
CI 运行:
bash
pytest
生产运行另一套自定义脚本,容易出现环境差异。
应尽量统一为:
bash
uv run order-service
或者安装后的标准入口。
28.9 为了分层而分层
如果每一个简单函数都需要跨越五个抽象层,项目会变得难以理解。
抽象应服务于:
- 业务隔离;
- 可测试性;
- 可替换性;
- 变化控制。
没有明确变化点时,不要创建无意义接口。
二十九、落地实施顺序
遗留 Python 项目不适合一次完成所有改造。
可以按照以下顺序实施。
第一阶段:统一运行环境
完成:
- 固定 Python 版本;
- 建立虚拟环境;
- 整理依赖;
- 引入锁文件;
- 编写 README。
第二阶段:统一代码质量
完成:
- Ruff;
- 基础类型注解;
- 提交前检查;
- CI 质量门禁。
第三阶段:补充测试
优先覆盖:
- 金额计算;
- 状态转换;
- 权限控制;
- 核心业务流程;
- 历史故障场景。
第四阶段:拆分结构
逐步拆分:
- API;
- Application;
- Domain;
- Infrastructure。
不要在没有测试保护的情况下进行大规模重构。
第五阶段:完善交付
完成:
- 构建 Wheel;
- Docker 镜像;
- CI/CD;
- 配置中心;
- Secret 管理。
第六阶段:完善可观测性
增加:
- 结构化日志;
- 指标;
- Trace;
- 健康检查;
- 业务告警。
三十、上线检查清单
项目结构
- 使用清晰的 Package 名称;
- 生产代码与测试代码分离;
- 模块职责明确;
- 没有超大型
utils.py; - 没有随意修改
sys.path; - 具有统一启动入口。
依赖管理
- 声明最低 Python 版本;
- 使用独立虚拟环境;
- 运行依赖和开发依赖分离;
- 锁文件进入版本控制;
- CI 使用锁文件安装;
- 依赖升级经过测试;
- 定期执行安全扫描。
配置与安全
- 配置与代码分离;
- Secret 没有提交到 Git;
- 提供
.env.example; - 启动时验证必要配置;
- 日志不会输出敏感信息;
- 生产账号遵循最小权限。
代码质量
- Ruff 检查通过;
- 格式检查通过;
- 类型检查通过;
- 核心代码具有类型注解;
- 未使用导入和死代码得到清理;
- CI 能阻止不合格代码合并。
测试
- 核心业务具有单元测试;
- 数据库操作具有集成测试;
- 异常分支得到覆盖;
- 金额和边界条件得到覆盖;
- 测试可以重复运行;
- 测试不依赖执行顺序;
- 覆盖率门槛符合项目实际情况。
部署运维
- 可以构建 Wheel;
- Docker 使用非 Root 用户;
- 镜像中不包含 Secret;
- 支持优雅关闭;
- 具有健康检查;
- 日志输出到标准输出;
- 监控请求、错误和业务指标;
- 发布版本可以追溯。
三十一、总结
Python 工程化不是把简单代码变复杂,而是通过标准化结构和自动化流程控制项目不断增长的复杂度。
一套完整的 Python 工程体系可以概括为:
text
src 项目结构
│
▼
pyproject.toml 统一配置
│
▼
虚拟环境与依赖锁定
│
▼
配置、日志与异常规范
│
▼
Ruff 与类型检查
│
▼
单元测试和集成测试
│
▼
Wheel 与 Docker 构建
│
▼
CI/CD 自动验证
│
▼
日志、指标与链路追踪
小型项目可以从最基础的内容开始:
text
pyproject.toml
src 目录
虚拟环境
Ruff
pytest
README
随着业务增长,再逐步加入:
text
类型检查
依赖锁定
数据库 Migration
Docker
CI/CD
结构化日志
指标与 Trace
工程化的价值最终体现在:
- 新成员能够快速启动项目;
- 相同代码在不同环境中行为一致;
- 修改代码后能够及时发现问题;
- 线上故障能够被定位和追溯;
- 项目可以持续发布而不是依靠人工操作。
最重要的原则可以概括为:
Python 的灵活性适合快速开始,而工程化的意义,是让这种快速开发能力在项目变大、团队变多和运行时间变长以后依然可控。