最恶魔般的Python反模式

Real Python 最佳实践 Python
2021-01-21 09:09:10
144 11 0

有很多方法可以编写糟糕的代码。但在python中,有一个特别的国王。

我们筋疲力尽,但仍兴高采烈。另外两个工程师分别花了三天时间试图修复一个神秘的Unicode错误,但都没有成功,我只花了一天时间就找到了原因。十分钟后,我们找到了一个候选人。

悲剧在于,我们本可以跳过七天,直接跳到十分钟。但我已经超越了我自己。

这是关键。以下代码是Python开发人员可以编写的最具自我破坏性的代码之一:

try:
   do_something()
except:
   pass


例如,有些变体与except Exception:except Exception as e:相当。它们都会造成同样的巨大危害:悄悄地、不可见地隐藏错误条件,否则可以快速检测和调度。

为什么我要宣称这是当今Python世界中最邪恶的反模式?

  • 人们这样做是因为他们期望那里会发生特定的错误。然而,捕捉Exception隐藏了所有的错误…甚至是那些完全出乎意料的错误。

  • 当错误最终被发现时,因为它经常出现在生产环境中,所以您可能几乎不知道或根本不知道它在代码库中的什么地方出错了。即使在try块中发生错误,也要花上相当令人沮丧的时间才能弄清楚。

  • 一旦您意识到错误正在那里发生,由于缺少关键信息,您的故障排除会受到极大的阻碍。什么是错误/异常类?涉及到什么调用或数据结构?错误源于哪一行代码和哪一个文件?

  • 您将丢掉堆栈跟踪信息-这实际上是无价的信息,可能会在数天或数分钟内对错误进行故障排除。是的,几分钟。稍后对此进行更多讨论。

  • 最糟糕的是,这很容易最终损害在代码库上工作的工程师的士气,幸福甚至自尊。当错误浮出水面时,疑难解答人员可能会花费数小时来单独了解根本原因。他们认为自己是一个糟糕的程序员,因为要花很长时间才能弄清楚。他们不是; 静默捕获Exception所产生的错误本质上难以识别,解决和修复。

在我用Python编写应用程序的近十年经验中,无论是单独编写还是作为团队的一部分编写,这种模式都是开发人员生产力和应用程序可靠性的最大消耗,尤其是长期以来。如果你认为你有更糟的候选人,我很乐意听你说。

为什么我们要这样对自己?

当然,没有人会故意编写代码,让您的开发伙伴感到压力,破坏应用程序的可靠性。我们这样做是因为try块中的代码在正常操作中有时会以某种特定的方式失败。乐观地尝试然后抓住一个异常是处理这种情况的一种极好的、完全是恶作剧的方法。

不知不觉地,抓住一个异常然后默默地继续,目前看来并不是那么可怕的想法。但是一旦你保存了文件,你就设置了你的代码来创建最坏的bug:

  • 在开发过程中可以逃脱检测的bug,并被推送到实时生产系统中。

  • 可以在生产代码中生存数分钟、数小时的bug,在您意识到错误一直在发生之前的几天或几周。

  • 很难排除的错误。

  • 即使您知道被抑制的异常是在哪里引发的,也很难修复的错误。

请注意,我并不是说永远不要捕获异常。有很好的理由捕捉异常,然后继续——只是不要沉默。一个很好的例子是一个任务关键型的过程,你根本不想失败。在这里,一个聪明的模式是注入try子句来捕获异常,记录严重性logging.ERROR或更高级别的完整堆栈跟踪,然后继续。

解决方案

如果我们不想捕获异常,我们该怎么做?有两种选择。

在大多数情况下,最好的选择是捕获更具体的异常。类似于这样:

try:
   do_something()
# Catch some very specific exception - KeyError, ValueError, etc.
except ValueError:
   pass

这是您应该尝试的第一件事。这需要对调用的代码有一点了解,这样您就知道它可能会引发什么类型的错误。当您第一次编写代码时,这比清理其他人的代码更容易做好。

如果某些代码路径必须广泛地捕获所有异常(例如,某个长期运行的持久化进程的顶层循环),那么每个捕获的异常都必须将完整堆栈跟踪写入日志或文件,还有时间戳。如果您使用的是Python的logging模块,那么这非常简单-每个logger对象都有一个名为exception的方法,它接受一个消息字符串。如果在except块中调用它,捕获的异常将自动被完全记录,包括跟踪。

import logging

def get_number():
   return int('foo')
try:
   x = get_number()
except Exception as ex:
   logging.exception('Caught an error')

日志将包含错误消息,然后是一个跨多行的格式化堆栈跟踪:

ERROR:root:Caught an error
Traceback (most recent call last):
 File "example-logging-exception.py", line 8, inx = get_number()
 File "example-logging-exception.py", line 5, in get_number
   return int('foo')
ValueError: invalid literal for int() with base 10: 'foo'

非常简单。

如果应用程序以其他方式进行日志记录-不使用logging模块,该怎么办?假设您不想重构应用程序来实现这一点,那么只需获取并格式化与异常相关联的回溯。这在Python3中是最简单的:

# The Python 3 version. It's a little less work.
import traceback

def log_traceback(ex):
   tb_lines = traceback.format_exception(ex.__class__, ex, ex.__traceback__)
   tb_text = ''.join(tb_lines)
   # I'll let you implement the ExceptionLogger class,
   # and the timestamping.
   exception_logger.log(tb_text)

try:
   x = get_number()
except Exception as ex:
   log_traceback(ex)

在Python2中,您需要做更多的工作,因为异常对象没有附加它们的回溯。您可以通过调用except块中的sys.exc_info()来实现这一点:

import sys
import traceback

def log_traceback(ex, ex_traceback):
   tb_lines = traceback.format_exception(ex.__class__, ex, ex_traceback)
   tb_text = ''.join(tb_lines)
   exception_logger.log(tb_text)

try:
   x = get_number()
except Exception as ex:
   # Here, I don't really care about the first two values.
   # I just want the traceback.
   _, _, ex_traceback = sys.exc_info()
   log_traceback(ex, ex_traceback)

事实证明,您可以定义一个可用于Python 2和Python 3的回溯日志函数:

import traceback

def log_traceback(ex, ex_traceback=None):
   if ex_traceback is None:
       ex_traceback = ex.__traceback__
   tb_lines = [ line.rstrip('\n') for line in
                traceback.format_exception(ex.__class__, ex, ex_traceback)]
   exception_logger.log(tb_lines)

您现在可以做什么

“好的,亚伦,你说服了我。在过去的所有时间里,我都为之哭泣和悲伤。我该如何赎罪?” 我很高兴你问。您可以从这里开始一些实践。

在您的编码准则中明确禁止使用

如果您的团队进行代码审查,则可能会有一个编码准则文档。如果没有,那么就很容易开始-这就像创建一个新的Wiki页面一样简单,您的第一个条目可以是这个页面。只需添加以下两个准则:

如果某个代码路径只是必须大致捕获所有异常(例如,某些长时间运行的持久性进程的顶级循环),则每个此类捕获的异常必须写入完整的堆栈跟踪以及时间戳记日志或文件。不仅是异常类型和消息,而且还有完整的堆栈跟踪。

对于所有其他除外条款(实际上应该占绝大多数),捕获的异常类型必须尽可能具体。诸如KeyError或ConnectionTimeout等之类的东西。

为现有的过境条款创建工单

上面的内容将有助于防止将新问题纳入您的代码库。现有的过度捕捞量如何?简单:在您的错误跟踪系统中制作票证或问题以进行修复。这是一个简单的操作步骤,大大增加了解决该问题且不会被遗忘的机会。认真地说,您现在就可以这样做。

我建议您为每个存储库或应用程序制作一张票证,以检查代码以查找捕获异常的每个位置。(您也许可以通过在“除外:”和“例外除外”的代码库中重复查找全部内容。)对于每种情况,都可以将其转换为捕获非常具体的异常类型;或者,如果还不能立即清除应有的内容,请修改except块以记录完整的堆栈跟踪。

(可选)审核开发人员可以为任何特定的try / except块创建其他票证。如果您觉得可以使异常类更加具体,但是又不十分了解代码部分以至于无法自信,那么这是一件好事。在这种情况下,您需要输入代码来记录完整的堆栈跟踪。创建单独的票证以进一步调查;并将其分配给可能更清晰的人。如果您发现自己花了超过五分钟的时间来思考一个特定的try / except块,建议您这样做并继续进行下一个。

教育同伴团队成员

你们定期举行工程会议吗?每周,每两周还是每月?在下一个请求五分钟的时间来解释这种反模式,它对团队的生产力产生的成本以及简单的解决方案。

更好的是,事先联系您的技术主管或工程经理,并告诉他们有关情况。这将更容易出售,因为他们至少像您一样关注团队的生产力。发送给他们这篇文章的链接。哎呀,如果需要的话,我会帮助您-与他们通电话,我会说服他们。

您可以在社区中接触到更多人。您会去当地的Python聚会吗?他们有闪电谈话,还是可以在下一次会议上谈判五到十五分钟的演讲时间?宣传这个崇高的事业,为您的工程师服务。

为什么记录完整堆栈跟踪?

在上面的几次中,我曾尝试记录整个堆栈跟踪,而不仅仅是记录异常对象的消息。如果这似乎需要更多工作,那是因为它可以是:跟踪包含换行符,这些新行可能会破坏日志记录系统的格式,您可能不得不考虑使用traceback模块,依此类推。仅记录消息本身还不够吗?

不,这不对。精心设计的异常消息仅告诉您except子句在哪里-什么文件和什么代码行。通常,它甚至不会缩小这么多,但让我们在这里假设最好的情况。只记录消息总比不记录任何消息要好,但是不幸的是,它不能告诉您有关错误起源的任何信息。通常,它可以位于完全不同的文件或模块中,并且通常很难猜到。

除此之外,团队开发的实际应用程序倾向于具有多个可以调用异常引发块的代码路径。可能仅在调用Foo类的方法bar时发生错误,而在调用函数bar()时则不会发生。仅记录消息不会帮助您区分这两者。

我最好的战争故事是来自一支大约五十人的中型工程团队。我相对较新,并且遇到了一个unicode错误,该错误会定期唤醒正在呼叫四个月以上的人员。捕获到异常,并记录了消息,但未记录其他信息。另外两名高级工程师各自工作了几天,然后放弃了,说他们无法解决。

这些都是强大而可怕的聪明工程师。最后,出于绝望,他们尝试将其传递给我。使用他们的广泛注释,我立即着手很好地重现该问题以获取堆栈跟踪。六个小时后,我终于明白了。一旦获得了令人垂涎的堆栈跟踪,您能猜出我花了多长时间进行修复?

10分钟。那就对了。一旦有了堆栈跟踪,修复就很明显了。一个文字一周的时间工程师可能已被保存,如果我们已经从一开始的日志记录的堆栈跟踪。还记得上面的内容,当我说堆栈跟踪可以在几天内解决错误和在几分钟内解决问题之间有所区别吗?我不是在开玩笑。

作者介绍

用微信扫一扫

收藏