引言
我经常遇到一些开发者,他们对Python的错误处理机制了如指掌,但当我查看他们的代码时,却发现代码质量远远不够。Python的异常处理就是这样一个领域,它有一个广为人知的表层,以及一个更深层次、几乎不为人知的层面,许多开发者甚至没有意识到它的存在。如果你想测试一下自己对这个话题的理解,试着回答以下问题:
- 你何时应该捕获你调用的函数引发的异常,何时又不应该?
- 你如何确定应该捕获哪些异常类?
- 当你捕获到一个异常时,你应该如何处理它?
- 为什么说捕获所有异常是一种不好的做法,又在什么情况下这样做是可以接受的?
你准备好探索本文Python中错误处理的奥秘了吗?
捕获所有异常
你可能怀疑为什么类型4错误应该是你的应用程序中最常见的错误之一,因为如果让异常自由地冒泡,它们可能会一直冒泡到最顶层而没有在其他地方被捕获,导致应用程序崩溃。这是一个合理的担忧,但有一个简单的解决方案。
你应该设计你的应用程序,使其不可能让异常到达Python层。你可以通过在最顶层添加一个try/except块来捕获那些逃逸的异常。
如果你正在编写一个命令行应用程序,你可以这样做:
import sys
def my_cli()
# ...
if __name__ == '__main__':
try:
my_cli()
except Exception as error:
print(f"Unexpected error: {error}")
sys.exit(1)
在这个应用程序中,最顶层是在 if name == 'main' 条件判断中,它把所有到达这个级别的错误都视为可以恢复的。恢复的方式是向用户展示错误,然后以退出码1退出应用程序,这样会通知命令行或父进程应用程序已经失败。有了这样的逻辑,应用程序就知道如何以失败的方式退出,因此不需要在其他地方重新实现这一逻辑。应用程序可以简单地让错误继续冒泡,最终在这里被捕获,错误消息会被展示出来,然后应用程序会以错误代码退出。
你可能还记得我之前提到过,捕获所有异常是一种不好的做法。然而,这里正是我所做的!原因是在这个级别我们确实不能让任何异常到达Python层面,因为我们不希望这个程序崩溃,所以这是唯一一个捕获所有异常有意义的情况。这是一个例外,证明了规则。
拥有一个高层次的捕获所有异常的代码块实际上是大多数应用程序框架采用的一个常见模式。这里有两个例子:
- Flask Web框架:Flask将每个请求视为应用程序的独立运行,其中 full_dispatch_request() 方法是最外层。捕获所有异常的代码就在这里。 Tkinter
- GUI工具包(Python标准库的一部分):Tkinter将每个应用程序事件处理程序视为应用程序的独立小运行,并在每次调用处理程序时添加一个通用的捕获所有异常的代码块,以防止有缺陷的应用程序处理程序导致GUI崩溃。在这里查看代码。在这个代码片段中,注意Tkinter允许SystemExit异常(表示应用程序正在退出)继续冒泡,但捕获了所有其他异常以防止崩溃。
一个例子
我想向你展示一个例子,说明当你采用智能错误处理设计时如何改进你的代码。为此,我将使用Flask,但这同样适用于大多数其他框架或应用程序类型。
假设这是一个使用Flask-SQLAlchemy扩展的数据库应用程序。在我的咨询和代码审查工作中,我看到许多开发者在Flask端点中以如下方式编写数据库操作:
# NOTE: this is an example of how NOT to do exception handling!
@app.route('/songs/<id>', methods=['PUT'])
def update_song(id):
# ...
try:
db.session.add(song)
db.session.commit()
except SQLAlchemyError:
current_app.logger.error('failed to update song %s, %s', song.name, e)
try:
db.session.rollback()
except SQLAlchemyError as e:
current_app.logger.error('error rolling back failed update song, %s', e)
return 'Internal Service Error', 500
return '', 204
在这个路由中,它尝试将一首歌曲保存到数据库,并捕获所有继承自SQLAlchemyError异常类数据库错误。如果发生错误,它会将一条解释性信息记录到日志中,然后尝试回滚数据库会话。但很显然,回滚操作本身有时也会失败,因此还有一个额外的异常捕获块来处理回滚过程中可能出现的错误,并将它们也记录下来。经过这一连串操作后,向用户返回一个500错误代码,告知用户发生了服务器错误。这种模式在所有写入数据库的端点中反复出现。
这是一个非常糟糕的做法。首先,对于回滚错误,这个函数没有任何恢复的办法。如果发生了回滚错误,那意味着数据库遇到了严重的问题,你可能会持续遇到错误,记录一个回滚错误发生了对你没有任何帮助。其次,提交失败时记录错误信息乍一看似乎有用,但这个特定的日志缺少关键信息,尤其是错误堆栈跟踪,这在你之后调试时是最重要的工具。至少,这段代码应该使用logger.exception()而不是logger.error(),因为这样可以同时记录错误信息和堆栈跟踪。但我们完全可以做得更好。
由于这个端点属于类型4错误,可以采用“无为而治”的方法进行编码,从而得到一个更加优秀的实现:
@app.route('/songs/<id>', methods=['PUT'])
def update_song(id):
# ...
db.session.add(song)
db.session.commit()
return '', 204
- 为什么这种方法有效?
正如你之前看到的,Flask会捕获所有错误,因此你的应用程序不会因为漏捕错误而崩溃。在其处理过程中,Flask会将错误消息和堆栈跟踪自动记录到Flask日志中,这正是我们所需要的,所以我们无需亲自动手。Flask还会向客户端返回一个500错误码,表示发生了意外的服务器错误。此外,Flask-SQLAlchemy扩展会自动集成到Flask的异常处理机制中,当数据库错误发生时,为你自动回滚会话,这是我们需要的最后一项重要功能。在路由中真的没有什么留给我们去做了!
数据库错误的恢复过程在大多数应用程序中是相同的,因此你应该让框架为你完成这些繁重的工作,而你则可以从自己应用程序代码中更简单的逻辑中获益。
生产环境与开发环境中的错误处理
我提到过,尽可能将错误处理逻辑移动到应用程序调用栈的更高层次有一个好处,那就是你的应用程序代码可以让这些错误自然冒泡而不必显式捕获它们,从而使代码更易于维护和阅读。
将大部分错误处理代码集中到应用程序的一个独立部分的另一个好处是,你可以更好地控制应用程序如何应对错误。最好的例子就是你可以多么容易地改变应用程序在生产环境和开发环境中的错误行为。
在开发过程中,应用程序崩溃并显示堆栈跟踪实际上并没有任何问题。实际上,这是一件好事,因为你希望错误和缺陷被注意到并被修复。但当然,相同的应用程序在生产环境中必须坚如磐石,错误被记录,如果可行的话,通知开发者,而不向最终用户泄露任何内部或私有的错误细节。
当错误处理逻辑集中且与应用程序逻辑分离时,这变得容易实现。让我们回到我前面分享的命令行示例,但现在让我们添加开发和生产模式:
import sys
mode = os.environ.get("APP_MODE", "production")
def my_cli()
# ...
if __name__ == '__main__':
try:
my_cli()
except Exception as error:
if mode == "development":
raise # in dev mode we let the app crash!
else:
print(f"Unexpected error: {error}")
sys.exit(1)
这是不是很棒?在开发模式下,我们现在重新抛出异常以导致应用程序崩溃,这样我们就可以在工作时看到错误和堆栈跟踪。但我们这样做的同时,并没有削弱生产版本的稳定性,它继续捕获所有错误并防止崩溃。更重要的是,应用程序逻辑不需要了解这些配置上的差异。
这是否让你想起了 Flask、Django 以及其他 Web 框架的某些特性?许多 Web 框架都有一个开发或调试模式,它们会在你的控制台甚至有时直接在 Web 浏览器中展示错误。这和我在一个假想的命令行应用程序中展示给你的解决方案是一样的,只不过应用到了 Web 应用程序上!