有一天,我收到一个关于我写的一些旧代码的问题,这些代码没有像读者所期望的那样为错误条件引发一个异常,而是返回一个错误对象。
对于你的EmailVerifyTokenGenerator类,为什么你要返回错误类而不是引发自定义错误?你仍然可以将电子邮件传递给一个自定义的VerifyExpired异常。
我想我太急于提出错误了,但也许我对类有什么遗漏😁!
有问题的代码在下面(稍作修改,删除了几个无趣的方法)。它是一个通过电子邮件中的魔法链接进行电子邮件地址验证的系统的一部分。
总而言之,我们有一个函数,从一个令牌中提取电子邮件地址,检查它所捆绑的HMAC签名。我们要处理的有3种可能性。
-
满意的情况 - 我们已经得到了一个有效的HMAC代码,我们只需要返回电子邮件地址。
-
我们有一个无效的签名。
-
我们有一个有效但过期的签名。我们想单独处理这个问题,因为我们想简化用户的体验,让他们得到一个新的令牌生成并发送给他们,这意味着我们需要返回电子邮件地址。
它使用Django的签名器函数来完成繁重的工作,但这对我们的目的来说并不重要,因为我们要把它包装起来。
要开始为这段代码设计我们的API,这里有一些不好的选择。
-
我们可以有一对方法或函数。
extract_email_from_token和check_signature,它们可以被独立使用。这很糟糕,因为你可以很容易地使用extract_email_from_token,而完全忘记使用check_signature。这里的原则是,我们希望使用这个API的开发者掉进成功的坑里。要么开发者应该把他们的代码弄得完全正确,如果他们不这样做,要么就是明显的破绽,根本无法工作,要么就是至少没有微妙的缺陷,有一些讨厌的错误,比如安全问题。
-
我们可以有
email_from_token()方法或函数,其返回值是一个包含(email_address: str, valid_and_not_expired_signature: bool)的元组。这有一个与上面类似的问题--调用代码可能会使用
email_address,而忘记检查布尔值的有效性。
在排除了这些问题之后,我们有两个主要的竞争者来设计email_from_token() 。
-
我们可以让它对 "无效 "或 "过期 "的情况产生异常。对于后者,我们需要传递额外的数据,但我们可以把它放在异常对象中--正如最初的提问者所指出的。
-
我们可以让它为错误的情况返回错误对象,如上面的编码。
这两种方法都满足 "成功的坑 "的标准。如果开发者不小心没有处理这些错误情况,他们就不会有一个错误,即我们验证了一个不应该被验证的电子邮件地址。相反,我们将可能有一个某种形式的崩溃者,在网络应用的情况下,比如这个,意味着一个500错误页面被看到,以及在我们的日志中的一些东西,让我们很清楚发生了什么。
如果我们选择引发异常,那些没有检查异常的天真的代码就不会有任何进展--异常会向上传播并终止处理程序。在第二个选项中,我们返回错误对象,这些对象不能被意外地转换成成功值--VerifyExpired 对象包含了电子邮件地址,但它是一个与快乐情况完全不同形状的值。
这两种方法,在某种程度上,都尊重了可以总结为Parse Don't Validate的原则。我们不是仅仅把验证一个标记和提取一个电子邮件地址作为两个独立的东西,而是解析一个标记,并把验证的结果编码成对象的类型,然后在程序中流动。
但哪个更好呢?
影响我思考的因素之一是Haskell和其他类似语言中类型的工作方式,它们使得创建类型和构造函数非常容易。在Haskell中,以下是你为这种函数定义返回类型所需要的所有代码,以及你所需要的3个不同的数据构造函数,它们在模式匹配中起着双重作用。
现在,Python没有那么简洁,但数据类是定义诸如VerifyExpired 的一大改进。
在 Haskell 中,由于静态类型检查,这种模式使得调用代码几乎不可能意外地不能正确处理返回值。但即使是在Python中,由于没有内置这种模式,我认为也有一些引人注目的优势。
-
我们希望调用的代码在某个点上处理所有不同的返回值,而且是在同一个点上。(这与一些代码不同,我们可以引发一个异常,但我们从不期望调用代码具体处理--它将由不同层的更多通用方法处理)。因此,我们把这3个值都当作同类的东西是有意义的--它们只是不同的返回值。
-
如果你转而引发异常,你就会立即迫使调用代码进入一个特殊的控制流结构,即
try/except舞,这可能是不方便的。 -
特别是,如果你想把值的处理交给其他函数或代码来处理,你就不容易做到。例如,像这样的代码用 "返回错误对象 "方法就可以了,但用 "提高异常 "方法就明显复杂了。
然而,在我写完这段代码后的几年里,错误对象方法出现了一些也许更有说服力的论据。
首先,通过一些小的改动(特别是删除了哨兵单体值),我们现在可以为email_from_token 添加一个类型签名。
(对于旧的 Python 版本,你可能需要typing.Union)
从文档的角度来看,这本身就是一个好处,而且可以获得更好的IDE/编辑器帮助。
我们可以进一步使用 mypy。我们可以将我们的调用代码结构如下,以利用mypy的穷举检查。
现在,如果我们删除了其中的一个块,比方说VerifyExpired (或者我们在email_from_token ),mypy会帮我们抓住它。
有了错误对象方法,我们也可以用结构模式匹配来写我们的处理代码。相等的代码,包括我们的mypy穷举检查,现在看起来像这样。
这里面内置了VerifyExpired 中的电子邮件地址的解构--它被绑定到该分支中的名称expired_token_email 。
希望这能为我对这段代码采取的方法提供一个很好的理由。有些时候,异常是更好的选择--通常是在上面提到的事情不适用,或者相反的情况下--但我认为错误对象也有它们的位置,而且有时是一个更好的解决方案。