可靠程序不是“从不出错”,而是能区分哪些错误可以恢复、哪些必须向上传播,并保证失败时资源仍被正确释放。与此同时,模块边界决定依赖方向,日志和配置决定程序能否被运维。
本文以 CPython 3.14 为基线,把异常、上下文管理、模块、包、导入、日志和配置组织成一套工程方法。
1. 异常是控制流,不是返回值替代品
Python 使用异常表示无法按契约完成操作:
1
2
3
4
5
6
7
8
9
| def parse_port(raw: str) -> int:
try:
port = int(raw)
except ValueError as error:
raise ValueError(f"端口不是整数:{raw!r}") from error
if not 1 <= port <= 65_535:
raise ValueError(f"端口超出范围:{port}")
return port
|
这里既保留原始异常,又补充业务上下文。raise ... from error 建立显式异常链,日志能同时展示转换失败的根因和更高层语义。
不要用异常处理正常且高频的分支,如果一个结果本来就可能缺失,返回 None 或明确的结果对象可能更合适;反过来,也不要用魔法值 -1 隐藏真正错误。
2. 精确捕获,保持异常边界
1
2
3
4
| try:
config = load_config(path)
except FileNotFoundError:
config = default_config()
|
只捕获你能处理的异常。下面的写法会掩盖退出信号以外的几乎所有异常,包括大量本应暴露的程序错误:
1
2
3
4
5
| # 反例
try:
run_job()
except Exception:
pass
|
except Exception 在应用最外层用于记录失败、转换协议响应或隔离单个任务时可以合理,但通常要记录完整堆栈并决定是否重新抛出。不要捕获 BaseException,除非你明确知道如何处理 KeyboardInterrupt、SystemExit 等控制异常。
try 语句的四部分职责不同:
1
2
3
4
5
6
7
8
| try:
value = parse_value(raw)
except ValueError as error:
report(error)
else:
persist(value)
finally:
release_temporary_state()
|
except 处理匹配异常;else 只在 try 正常完成时执行,能缩小被捕获代码范围;finally 无论成功、异常或提前返回都会执行,适合清理;finally 中不要随意 return,否则可能压制原异常或返回值。Python 3.14 会对从 finally 代码块跳出的 return、break 或 continue 发出 SyntaxWarning;这些写法在未来版本或其他实现中可以成为语法错误。
3. 自定义异常表达领域语义
1
2
3
4
5
6
7
8
| class DeviceError(Exception):
"""设备操作失败的基类。"""
class DeviceOfflineError(DeviceError):
def __init__(self, device_id: str) -> None:
super().__init__(f"设备离线:{device_id}")
self.device_id = device_id
|
调用方可以捕获稳定的领域异常,而不依赖底层串口、Socket 或驱动库的具体异常类型。转换时保留异常链:
1
2
3
4
| try:
transport.send(command)
except OSError as error:
raise DeviceOfflineError(device_id) from error
|
异常类型应能帮助调用者决定处理策略。仅仅为了改一段消息而建立几十个没有语义差异的异常类,反而增加负担。
4. 多个并发错误:ExceptionGroup
并发任务可能同时失败。Python 3.11 引入 ExceptionGroup 和 except*,允许按类型拆分处理:
1
2
3
4
5
6
7
8
9
10
11
| errors = ExceptionGroup(
"批处理失败",
[ValueError("坏数据"), TimeoutError("超时")],
)
try:
raise errors
except* ValueError as group:
print(f"数据错误:{len(group.exceptions)}")
except* TimeoutError as group:
print(f"超时错误:{len(group.exceptions)}")
|
except* 不是按顺序只选一个分支;一个异常组可被拆分后由多个分支处理。日常代码通常通过 asyncio.TaskGroup 等结构化并发 API 间接遇到它,不必手工把所有异常都包装成组。
5. 资源释放优先使用上下文管理器
文件、锁、事务和临时目录都有生命周期。相比手写 try/finally,with 更直接:
1
2
3
4
5
6
| from pathlib import Path
path = Path("config.json")
with path.open("r", encoding="utf-8") as file:
content = file.read()
|
多个资源可以写在同一个 with 中:
1
2
3
| with source.open("rb") as input_file, target.open("wb") as output_file:
while chunk := input_file.read(64 * 1024):
output_file.write(chunk)
|
动态数量的资源使用 ExitStack:
1
2
3
4
5
6
| from contextlib import ExitStack
with ExitStack() as stack:
files = [stack.enter_context(path.open(encoding="utf-8")) for path in paths]
contents = [file.read() for file in files]
|
资源所有权要明确:谁创建,谁关闭。函数若接收调用者提供的文件对象,通常不应擅自关闭它;函数自己打开的文件则应在返回前关闭。
6. 模块与包如何组织代码
一个 .py 文件通常是一个模块;包是可包含子模块的组织单元。推荐的小型项目结构:
1
2
3
4
5
6
7
8
9
10
11
| temperature-service/
├── pyproject.toml
├── src/
│ └── temperature_service/
│ ├── __init__.py
│ ├── domain.py
│ ├── config.py
│ ├── service.py
│ └── cli.py
└── tests/
└── test_domain.py
|
src 布局能降低测试误用仓库根目录源码、而不是已安装包的风险。它不是语言强制要求,但适合可发布包和中大型项目。
导入模块会经历查找、创建模块对象和执行模块顶层代码。成功导入后通常会缓存在 sys.modules 中,因此同一进程里的普通重复导入不会每次重新执行模块。
1
| from temperature_service.domain import Measurement
|
包内可使用显式相对导入:
1
| from .domain import Measurement
|
应用入口和公共代码通常优先绝对导入,关系更清楚。采用 src 布局时,包不会仅因终端位于项目根目录就自动变得可导入;需要先把项目以普通或可编辑方式安装到当前环境。完成安装后,再从项目根目录使用 python -m temperature_service.cli,不要通过直接运行包内部文件来绕过导入模型。
7. __init__.py 与公共 API
常规包通常包含 __init__.py。没有它时也可能形成命名空间包,但那是为跨多个目录分发同一逻辑包等场景设计的,不应因为漏文件偶然触发。
__init__.py 可以重导出稳定接口:
1
2
3
| from .domain import Measurement
__all__ = ["Measurement"]
|
保持包初始化轻量。不要在导入时连接数据库、启动线程或读取远程配置;导入副作用会影响测试发现、命令行工具和子进程启动。
循环导入通常说明模块职责或依赖方向不清晰。可选修法包括:
- 把共享抽象移到更底层模块;
- 在应用装配层连接对象;
- 仅为类型检查的导入放进
if TYPE_CHECKING:; - 必要时在函数内部延迟导入,但不要用它长期掩盖结构问题。
8. 配置:把来源与领域模型分开
环境变量适合部署时注入少量字符串配置,但读取后必须解析和验证:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| from dataclasses import dataclass
import os
@dataclass(frozen=True, slots=True)
class Settings:
endpoint: str
timeout_seconds: float
def load_settings() -> Settings:
endpoint = os.environ.get("APP_ENDPOINT", "http://localhost:8080")
raw_timeout = os.environ.get("APP_TIMEOUT_SECONDS", "5")
try:
timeout = float(raw_timeout)
except ValueError as error:
raise ValueError("APP_TIMEOUT_SECONDS 必须是数字") from error
if timeout <= 0:
raise ValueError("APP_TIMEOUT_SECONDS 必须大于零")
return Settings(endpoint=endpoint, timeout_seconds=timeout)
|
不要在业务模块导入时到处读取环境变量;集中加载后传入依赖,测试更容易控制。密钥不应写入源码、默认配置或日志。复杂配置格式要选择明确的解析器,并在边界转换为有约束的领域对象。
9. 日志:记录事件,不拼接秘密
标准库 logging 支持级别、处理器和格式化:
1
2
3
4
5
6
7
8
| import logging
logger = logging.getLogger(__name__)
def process(device_id: str) -> None:
logger.info("processing device_id=%s", device_id)
|
库代码只获取命名 logger,不应擅自调用 basicConfig() 改变宿主应用配置。应用入口统一配置:
1
2
3
4
5
6
7
8
| import logging
def configure_logging() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
|
使用参数化日志而不是先拼 f-string,能让未启用的日志级别避免不必要的字符串格式化,也便于日志系统保留结构。记录异常堆栈可使用:
1
2
3
4
5
| try:
run_job()
except DeviceError:
logger.exception("job failed")
raise
|
不要记录密码、访问令牌、完整连接串或不必要的个人数据。日志级别也不是越高越好:可恢复的预期输入错误不一定需要 ERROR,无法继续的系统故障也不应只记 DEBUG。
10. 一个清晰的应用边界
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| import logging
from .config import load_settings
from .service import ApplicationService
logger = logging.getLogger(__name__)
def main() -> int:
try:
settings = load_settings()
service = ApplicationService(settings)
service.run()
except ValueError as error:
logger.error("invalid configuration: %s", error)
return 2
except OSError:
logger.exception("infrastructure failure")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
|
这个入口把配置解析、对象装配、异常到退出码的转换放在边界,领域模块不需要知道命令行退出码或日志配置。
11. 小结
- 只捕获能够处理的异常,转换层级时使用
raise ... from ... 保留根因。 - 清理放进上下文管理器或
finally,并明确资源所有权。 - 模块导入会执行顶层代码;包初始化应轻量,避免连接外部资源等副作用。
- 集中加载、解析和校验配置,再把设置对象传入业务层。
- 库获取 logger,应用配置日志;任何层都不应泄露秘密。
下一篇将为这些模块和接口补上现代类型标注,解释静态检查能证明什么、又不能证明什么。
参考资料