Exception异常架构设计:基础(01)

48 阅读11分钟

监控告警、故障诊断是运维工程师的核心工作之一,而异常是其基石,打好了异常处理这块地基,在上层治理时,便会事半功倍。异常的架构设计,是业务无关的,大多数开发工程师都不会特别关注,什么时候抛异常,什么时候处理异常都看心情和习惯。互联网上关于异常的系统性研究文章几乎没有,我将花费一段时间,由浅入深探究异常架构设计。

异常分为两部分来研究:异常架构设计、异常治理。前者侧重,后者侧重(道法术器)。


异常架构设计原则(个人总结的四项设计准则):

  • 防御性编程
  • fail-fast,快速失败
  • 用户友好
  • 可观测性(监控告警、异常日志查询、故障诊断)

本文面向开发工程师和SRE工程师:

  • 开发工程师:在日常编码中形成良好的异常处理习惯,提高代码可读性和健壮性
  • SRE 工程师:可观测性和故障治理基石,制定运维策略与规范,使用技术管控代码质量。

异常基础

异常是程序执行过程中发生的非正常情况,这些情况通常需要特殊处理。异常机制是现代编程语言中处理错误和异常情况的重要方式,不同语言在异常处理上有不同的哲学和实现。

Java为代表的受检异常机制通过编译器强制检查异常声明和处理,确保错误的显式管理,增强了代码可靠性但可能引入冗余,受检查异常机制体现了防御性编程的异常设计原则;以PythonJavaScript为代表的灵活运行时异常机制采用“请求原谅比许可更容易”的哲学,提供简洁的try结构块和异步错误处理,适合快速开发但缺乏编译时保障;C++Rust代表的资源安全与性能导向机制分别通过 RAII 模式和 Result 类型系统,在保证资源安全的同时追求零开销异常处理,适合系统编程;Go语言采用显式错误值返回模式,通过多返回值传递错误,强制调用者即时处理,避免了异常的控制流跳跃;这些不同范式反映了各语言在安全性、灵活性、性能及表达力之间的不同权衡。

示例代码选择PythonFlask),用户数量庞大,语法更简洁,可读性好

文中以最常见的应用级编程语言 PythonFlask),辅助JavaSpringBoot)为例,来介绍异常机制。

异常核心

开发者需要关注的异常核心三要素:try异常处理块、raise异常抛出和自定义异常。

异常处理块

异常处理块的关键字和结构为try-except-else-finally,其中except可以多个嵌套,支持在一条except语句中捕获多个异常。

try:
    # 可能引发异常的代码
    result = 10 / int(input("输入数字: "))
except ValueError as e:           # 捕获特定异常
    print(f"输入错误: {e}")
except ZeroDivisionError as e:    # 多个except子句
    print(f"除零错误: {e}")
except (TypeError, EOFError):     # 捕获多个异常
    print("类型或输入错误")
except Exception as e:            # 通用异常捕获
    print(f"其他错误: {e}")
else:                             # 无异常时执行
    print(f"结果: {result}")
finally:                          # 总是执行
    print("清理完成")

异常抛出

开发者可以使用raise主动抛出异常,一般有2个用处:主动抛出、异常转换。

# 抛出异常实例
raise ValueError("参数无效")

# 异常转换
try:
    # 可能引发异常的代码
    result = 10 / int(input("输入数字: "))
except ValueError as e:           # 捕获特定异常
    print(f"输入错误或: {e}")
except ZeroDivisionError as e:    # 多个except子句
    print(f"除零错误: {e}")
except Exception as e:            # 通用异常捕获
		raise MyException # 其他异常Exception转换为用户自定义异常MyException

自定义异常

ExceptionPython中所有的非系统退出类异常的父类,是所有自定义异常的基类,其本身几乎没有新增的专属字段/方法,其所有核心属性和方法都继承自父类BaseException

# 直接继承Exception
class AppException(Exception):
    pass

# 间接继承Exception
class MyAppException(AppException):
    pass

自定义异常需关注:

  • Exception异常参数元组args
  • __init__中调用super().__init__(message) → 正确设置args
  • 按需重写__str__ → 提供友好显示
# Exception异常参数
e = Exception("错误消息", "附加信息", 123)
print(e.args)  # ('错误消息', '附加信息', 123)

# 必须调用 super().__init__ 父类构造函数
class MyException(Exception):
    def __init__(self, message, **kwargs):
        super().__init__(message)  # 必须调用!
        # 然后添加额外字段
        for k, v in kwargs.items():
            setattr(self, k, v)

# 重写 __str__,字符串友好提示
class MyException(Exception):
    def __str__(self):
        # 默认返回args的字符串表示,通常需要重写
        return f"MyException: {super().__str__()}"

当一个异常触发了另一个异常时,通过这两个属性__cause____context__可以追溯原始异常Python解释器会自动处理其赋值,也可通过语法手动指定,是实现异常链的核心。

例如,在异常处理时,发生了新的异常,就会形成一条异常链:Exception A -> Exception B -> ...,在异常调试的时候非常有用。

# 1. __cause__ (显式链,使用 from)
try:
    int("abc")
except ValueError as e1:
    raise RuntimeError("处理失败") from e1  # ← e1存入__cause__

# 2. __context__ (隐式链,自动设置)
try:
    int("abc")
except ValueError as e1:
    raise RuntimeError("处理失败")  # ← e1自动存入__context__

当没有使用raise ... from语法时,若在一个异常的处理过程中(except/finally块)触发了另一个异常,Python 会自动将前一个异赋值给后一个异常的__context__属性,这是隐式关联,无需手动干预。__cause__字段则是手动指定的,是为异常的显示关联,其优先级高于__context__

特别地,当使用raise MyException from None可以屏蔽所有异常关联(__cause____context__均为None),只打印当前异常。另一方面:__context____cause__的语义存在差别,前者是上下文信息,后者是直接原因,这在打印异常信息的时候会存在语义差别。

# __context__,在处理底层异常的过程中,又发生了一个新的异常
During handling of the above exception, another exception occurred:

# __cause__,新异常是由底层异常直接导致的
The above exception was the direct cause of the following exception:

一句话理解:__context____cause__均为Exception对象,存储当前异常的关联异常,__context__是自动关联的,而__cause__是手段关联的,使用raise MyException from e语句。


聊到了这里,就先简单异常处理时异常链的用法,在异常转换时需要主动抛出新的异常。

# DataAceessException 是用户自定义异常,屏蔽底层的信息,向上呈现统一友好提示,同时使用__cause__来记录原始异常
DataAcessException -> requests.exceptions.ConnectTimeout
DataAccessException -> pymysql.MySQLError
DataAccessException -> FileNotFoundError

在业务层返回异常DataAccessException用来屏蔽底层技术性异常,但是在打印日志的时候也应该记录原始的异常信息,这样便于进行异常诊断。异常处理和日志打印的架构设计,将在后续详细介绍。

异常延伸

异常层次结构

Python中所有的异常都是BaseException的子类。主要层级:

BaseException
 ├── SystemExit           # 程序退出
 ├── KeyboardInterrupt    # 用户中断(Ctrl+C)
 ├── GeneratorExit        # 生成器关闭
 └── Exception            # 常规异常基类
      ├── ArithmeticError
      ├── LookupError
      ├── OSError
      ├── ValueError
      └── ... (所有内置异常)

ExceptionSystemExitKeyboardInterrupt是并列关系,因此使用except Exception不会捕获系统退出信号,可以使用except BaseException捕获所有异常。

try:
    raise SystemExit("退出程序")
except Exception as e:  # 不会捕获SystemExit
    print("不会执行这里")
except BaseException as e:  # 会捕获
    print(f"捕获到BaseException: {e}")

assert断言

assert断言,可以看作是一种带有调试标志的语法糖,但不完全等价于简单的if-raiseassert仅在调试时使用,可以使用python -O main.py-O模式忽略掉,类似于忽略掉注释代码。

# assert语句
assert condition, "错误信息"

# 大致等效的if-raise实现
if not condition:
    raise AssertionError("错误信息")

# assert的实际等效代码
if __debug__:
    if not condition:
        raise AssertionError("错误信息")

with块在资源管理上,非常有用,可以简化代码,提高可读性。在资源处理时即使发生异常,也可以保证资源被释放,有效避免资源泄露问题。在Java中也有类似的try-with-resource语句。

# 资源泄露
file = None
try:
    file = open("file.txt", "r")
    content = file.read()
except Exception as e:
    pass # 发生异常是不对file进行关闭,会造成文件资源泄露

# 传统方式
file = None
try:
    file = open("file.txt", "r")
    content = file.read()
finally:
    if file:
        file.close()

# with语句方式(更简洁)
with open("file.txt", "r") as file:
    content = file.read()

在异常发生后,函数的执行可以延伸很多,后期将单独写一篇文章讲解,其中exceptfinallyreturn语句的执行顺序,是面试的一个高频考点。

异常进阶

在[异常基础](# 异常基础)章节,主要介绍编程语言原生的异常机制,本章节介绍应用级开发框架下的异常机制。

Flask异常HTTPException(待补充)

应用级开发框架是基于RESTful API设计的,因此有专门封装面向HTTP的异常类,如Flask中的HTTPException

常见HTTP异常

abort(404)           # NotFound
abort(400)           # BadRequest
abort(401)           # Unauthorized
abort(403)           # Forbidden
abort(500)           # InternalServerError

abort可以看作assert是一个语法糖,根据代码抛出具体的异常。

# raise HTTPException
user = User.query.get(user_id)
    if not user:
        raise werkzeug.exceptions.NotFound(f"用户 {user_id} 不存在")
# abort
user = User.query.get(user_id)
    if not user:
    		abort(404, f"用户 {user_id} 不存在")

异常处理器 handler

异常抛出后,可以被异常处理器所捕获并进行处理。自定义异常处理器在函数头上加 @app.errorhandler(ExceptionType)注解

# 基于状态码的处理器
@app.errorhandler(404)
def handle_404(error):
    """处理404错误"""
    if request.path.startswith('/api/'):
        return jsonify({"error": "资源不存在"}), 404
    return render_template('404.html'), 404

# 基于异常类的处理器
@app.errorhandler(ValidationError)
def handle_validation_error(error):
    """处理验证错误"""
    return jsonify({
        "error": "验证失败",
        "message": error.message,
        "details": error.details
    }), error.code

上述代码是全局异常处理,可以定义蓝图级的异常处理函数。

from flask import Blueprint

api_bp = Blueprint('api', __name__, url_prefix='/api')

# 蓝图局部异常处理器
@api_bp.errorhandler(404)
def api_404(error):
    """只在api蓝图内有效的404处理器"""
    return jsonify({"error": "API资源不存在"}), 404

可以发现,异常处理器中的ExceptionType可以有多个,抛出的异常是如何匹配的?优先级?

异常处理优先级

异常处理优先级:具体异常类 -> 父异常类 -> 父类的父类 > .. > Exception > BaseException

from werkzeug.exceptions import NotFound, HTTPException

# 定义多个处理器
@app.errorhandler(404)
def handler_404(error):
    print("执行: 404状态码处理器")
    return "404状态码处理器", 404

@app.errorhandler(NotFound)
def handler_not_found_class(error):
    print("执行: NotFound异常类处理器")
    return "NotFound异常类处理器", 404

@app.errorhandler(HTTPException)
def handler_http_exception(error):
    print("执行: HTTPException基类处理器")
    return "HTTPException基类处理器", error.code

# 测试路由
@app.route('/test-priority')
def test_priority():
    abort(404)  # 抛出NotFound异常
  
# 访问 /test-priority 的输出:
# 执行: NotFound异常类处理器
# 只有这个处理器被执行,其他不会执行

# 删除@app.errorhandler(NotFound),执行404状态码处理器
# abort实际执行的是 raise NotFound

一般地,可使用@app.errorhandler(Exception)来实现全局异常兜底处理机制,保证所有的异常都不会无限向上抛出。

@app.errorhandler(Exception)
def handler_general_exception(error):
    print("执行: 通用Exception处理器")
    return "通用Exception处理器", 500

如果异常没有被自定义异常捕获并处理,会走Flask默认异常处理机制:开发环境 (DEBUG=True)下,返回带完整异常栈的调试页面,能按到所有异常信息。生产环境下转换为500 Internal Server Error,通用的无信息的错误页。

有无必要使用@app.errorhandler(BaseException)兜底所有异常?没有必要Exception是所有非中断异常的基类,对于会严重错误,而导致系统异常退出的异常,应该执行退出逻辑。BaseException是所有异常的基类,包括退出和系统中断

from flask import Flask
import sys

app = Flask(__name__)

# ❌ 危险的BaseException处理器
@app.errorhandler(BaseException)
def handle_base_exception(e):
    """这会捕获所有异常,包括系统退出信号!"""
    print(f"捕获到BaseException: {type(e).__name__}")
    return "错误已处理", 500

@app.route('/exit')
def exit_app():
    """这个路由会触发系统退出"""
    sys.exit(1)  # 抛出SystemExit异常

@app.route('/keyboard')
def keyboard():
    """模拟键盘中断(在实际服务器中很难触发)"""
    raise KeyboardInterrupt()

# 访问 /exit 时:
# 1. 应该让程序正常退出
# 2. 但BaseException处理器会捕获SystemExit
# 3. 返回HTTP响应,程序继续运行!
# 4. 这破坏了正常的程序控制流

因此,大多数情况下应用开发时关注Exception即可,无需捕获BaseException

讨论问题:什么情况下需要BaseException处理器?

关注微信公众号,获取运维资讯

如果此篇文章对你有所帮助,感谢你的点赞收藏,也欢迎在评论区友好交流。

微信搜索关注公众号:持续运维