4、自定义异常:创建自定义异常类、抛出异常、异常中携带额外信息
讲完了 Python 内置的那些异常,说实话,在实际运维脚本里,光靠它们远远不够。你想想看,一个自动化部署脚本跑着跑着,突然报了个 ValueError,你根本不知道是哪个环节出了问题——是配置文件格式不对?还是环境变量没设好?还是远程服务器返回了意料之外的响应?
这时候,自定义异常就派上用场了。说白了,就是给异常打上专属标签,让它一出现,你就知道「哦,是那个地方出事了」。
4.1 为什么要自己造异常?
我刚开始写自动化脚本那会儿,也偷懒,全靠内置异常撑着。直到有一次线上事故——一个批量重启服务的脚本,因为某个节点 SSH 连接超时,抛了个 TimeoutError,结果被上层一个通用的 except Exception 给吞了,整个脚本继续往下跑,最后把生产环境搞乱了。
嗯,从那以后,我养成了一个习惯:每个关键模块都定义自己的异常类。好处很明显:
- 定位问题快:看到异常名就知道是哪个模块出的问题
- 携带上下文:可以带上主机名、端口号、错误码等额外信息
- 分层处理:不同层级的异常可以分别捕获,互不干扰
核心原则:自定义异常不是炫技,而是为了让你的脚本在出问题时,能给出最直接的线索。
4.2 创建自定义异常类
其实特别简单,继承 Exception 就行。我个人习惯是每个模块建一个 exceptions.py 文件,把相关的异常都放一起。
# exceptions.py
class SSHConnectionError(Exception):
"""SSH连接失败时抛出"""
pass
class ConfigParseError(Exception):
"""配置文件解析失败时抛出"""
pass
class CommandExecutionError(Exception):
"""远程命令执行异常"""
pass
你看,就这么几行。但别小看它——有了这些类名,你的 try/except 块就能写得非常精准:
try:
ssh_client.connect(host, port)
except SSHConnectionError as e:
# 只处理SSH连接问题
logger.error(f"SSH连接失败: {host}:{port}")
alert_ops_team(host, port)
我在项目中遇到过一种情况:有人把所有异常都写成 CustomError,然后靠错误消息字符串来区分。结果代码越写越乱,最后根本没法维护。记住:异常类名就是最好的文档。
4.3 抛出异常:raise 的正确姿势
抛出异常很简单,一个 raise 就搞定。但怎么抛、抛什么,这里头有讲究。
最基本的用法:
def check_config(config_path):
if not os.path.exists(config_path):
raise ConfigParseError(f"配置文件不存在: {config_path}")
# 继续解析...
但更实用的做法是——在异常中携带额外信息。你想想看,如果只抛一个异常名,上层捕获后还得自己去查上下文,多麻烦。
class SSHConnectionError(Exception):
def __init__(self, host, port, message=""):
self.host = host
self.port = port
self.message = message or f"SSH连接失败: {host}:{port}"
super().__init__(self.message)
# 使用
raise SSHConnectionError("192.168.1.100", 22, "认证超时")
这样上层捕获时,可以直接拿到主机和端口信息:
try:
connect_to_host("192.168.1.100", 22)
except SSHConnectionError as e:
print(f"请检查主机 {e.host}:{e.port} 的SSH服务状态")
# 甚至可以自动重试其他节点
小技巧:我习惯在异常类里加一个 to_dict() 方法,方便序列化成 JSON 写入日志或告警系统。
4.4 异常中携带额外信息的最佳实践
光带主机和端口还不够,真实场景下,你可能需要携带更多信息。比如一个批量部署脚本,每个节点执行结果都不一样:
class DeployError(Exception):
def __init__(self, host, service, exit_code, stderr):
self.host = host
self.service = service
self.exit_code = exit_code
self.stderr = stderr
self.timestamp = datetime.now().isoformat()
message = f"[{self.timestamp}] 部署失败: {host}/{service}, 退出码: {exit_code}"
super().__init__(message)
def to_dict(self):
return {
"host": self.host,
"service": self.service,
"exit_code": self.exit_code,
"stderr": self.stderr,
"timestamp": self.timestamp
}
这样,上层捕获后可以直接把异常信息写入数据库或发送到监控平台:
try:
deploy_service("web-01", "nginx")
except DeployError as e:
# 写入告警系统
alert_system.send(e.to_dict())
# 记录详细日志
logger.error(f"部署异常详情: {e.stderr}")
我曾经因为一个脚本没有携带足够信息,排查问题花了整整两个小时。后来加上了这些额外字段,再出问题,看一眼日志就知道该去哪个服务器、看哪个服务、查哪段输出。
4.5 异常链:别丢了根因
有时候,你的自定义异常是由另一个异常触发的。比如 SSH 连接失败,底层可能抛了个 socket.timeout。如果你直接 raise SSHConnectionError,原始异常就丢了。
Python 3 提供了 raise ... from ... 语法,可以保留异常链:
try:
sock.connect((host, port))
except socket.timeout as e:
raise SSHConnectionError(host, port, "连接超时") from e
这样,当你打印异常堆栈时,能看到完整的调用链:
SSHConnectionError: SSH连接失败: 192.168.1.100:22, 连接超时
└─ 由 socket.timeout: timed out 引起
注意:不要滥用 raise ... from e。如果你只是把异常类型转换一下,但原因完全不同,就别用 from。否则会误导排查方向。
4.6 实战:一个完整的自定义异常体系
最后,我分享一个我在自动化运维平台里用的异常体系结构,供你参考:
| 异常类 | 携带信息 | 使用场景 |
|---|---|---|
SSHConnectionError |
host, port, username, error_msg | SSH 连接失败 |
CommandTimeoutError |
host, command, timeout | 远程命令执行超时 |
ConfigValidationError |
config_path, field, expected_type | 配置文件校验失败 |
DeployRollbackError |
service, version, rollback_version, reason | 部署回滚失败 |
HealthCheckError |
host, service, check_type, actual_value | 健康检查不通过 |
每个异常类都遵循同样的模式:明确的类名 + 携带关键上下文 + 可序列化。这样,不管是写日志、发告警、还是做自动化恢复,都能拿到足够的信息。
一句话总结:自定义异常不是花架子,它是你脚本的「黑匣子」。出事的时候,它能告诉你最准确的事故原因。
下一节,我们会聊聊怎么把这些异常统一管理起来,配合日志系统,形成一个完整的异常监控闭环。嗯,到时候你会发现,前面这些准备工作,全都能派上用场。