防御性编程与疯狂偏执性编程

jopen 10年前

防御性编程与疯狂偏执性编程

啊,这里要小心!   ——Sergeant Esterhaus,《每日简报》

当程序员遇到意想不到又不能修复的 bug 时,他们会“添加一些防御性的代码”,这不但可以使得代码更安全,还更容易发现问题。有时候这样的行为甚至可以直接消灭问题。开发人员还会进行数据验证 ——确保检查输入和输出域和返回值;审查和改进错误处理——可能会围绕一些“不可能”的条件做一些检查;添加一些有用的日志记录和诊断。换句话说,问题代 码优先。

期待意外

防御性编程的整体要点就是防范你不想要出现的错误。——Steve McConnell,《Code Complete》

Steve McConnell 的经典编程之书——《Code Complete》,用一个短篇解释了防御性编程的一些基本规则:

1、保护你的代码远离来自“外部”的无效数据,无论这个“外部”的概念被定 位为什么。它可以是来自于外部系统、用户、文件的数据,也可以是模块/组件以外的数据,由你决定。树立“路障”、“安全区”或“信任边界”——在边界之外 的一切都是危险的,界限之内的所有都是安全的。关于“路障”代码,需要验证所有的输入数据:检查所有输入参数的类型、长度和值域是否正确。还要加倍检查限 制和界限。

2. 当我们检查出错误数据后,还需要决定如何处理它。防御性编程不会掩盖错误,也不会隐藏 bug。这需要在健壮性(如果问题可以处理那就继续运行)和正确性(不返回不准确的结果)之间做权衡。选择好策略来应对错误数据:返回错误就马上停止,返 回中性值就替换数据值……确保策略明确且一贯。

3. 不要将代码外部的函数调用或方法调用想得太过美好。请确保你调用外部的 API 和库之前理解并测试了错误。

4. 至少在开发和测试阶段,要使用断言记录假设,并高亮“不可能”的条件。这在大型系统中显得尤为重要,因为随着时间的推移,将会有不同的程序员用高度可靠的代码来维护这些大型系统。

5. 添加诊断代码,智能地记录和跟踪以帮助解释在运行时发生的事情,尤其是当你遇到问题的时候。

6. 标准化的错误处理。想好如何处理“正常错误”、“预期错误”以及警告,并对此习以为常。

7. 只有当你真的需要的时候,才使用异常处理,并确保你得彻底理解该编程语言的异常处理程序。

如果一个程序将异常作为正常进程的一部分,那就会饱受所有经典的可读性和可维护性问题导致的代码混乱不堪的困扰。

–《The Pragmatic Programmer》(程序员修炼之道)

此外,我还想补充几点,来自于 Michael Nygard 的《Release It》:

  • 千万不要等着外部调用,尤其是远程调用。因为一旦出现问题,就会耗费你很长的时间。
  • 使用超时/重试逻辑以及断路器稳定模式来处理远程故障。
  • 对于像C和 C++ 这类的编程语言,防御性编程还包括使用安全的函数调用,以避免缓冲区溢出和常见的编码错误。

偏执的不同种类

在《The Pragmatic Programmer》一书中将防御性编程形容为“务实的偏执”。保护你的代码避免受到别人和自己错误的侵袭。有疑问,就验证。检查数据的一致性和完整 性。由于我们不能测试每一个错误,所以使用断言和异常处理程序来应对“不应该发生”的事情。从测试和产品失败中学习 ——出现失败,就找找看还有哪里也会失败。关注代码的关键部分——核心,执行目的的那部分代码。

健康的偏执型编程是正确的编程形式。但偏执程度却可大可小。在《Clean Code》的错误处理章节,Michael Feathers 告诫说,

“在很多代码库中,错误处理占据了主导地位。”   –Michael Feathers,《Clean Code》

如果代码中有太多的错误处理,那么不仅会掩盖代码的主路径(代码的实际目标),也会遮蔽错误处理本身的逻辑——以至于很难纠正、很难审查和测试,也很难不犯错误地更改,最后只能束手无策。这非但不会让代码更有弹性和更安全,实际上还会导致代码更容易出错和更脆弱。

有健康的偏执,有错误检查过度的偏执,还有疯狂而有害的偏执——以及防御性编程这四种。

我搞的第一个真正意义上的全球性系统是为服务器(当时还被叫做小型机)跨越美国和加拿大研发的“Store and Forward”网络控制系统。它在分布式系统、调度作业以及协调整个网络报告之间分享数据。它的设计目的为可适应网络问题,并且面对操作失误可以自动恢 复和重启。这在那时可谓是史无前例的,但却是技术人员的噩梦和地狱。

此系统的原有程序员不信任网络,不信任O/S,不信任操作运算,不信任别人的代码,甚至也不信任他自己的代码——理由振振有词。他曾是一名化学 工程师,自学成为系统程序员——熬夜写代码的时候会喝很多酒,然后在酒精的影响下写下成千上万行非结构化的 FORTRAN 和 Assembler 代码。代码中充满了错误检查、自我诊断和纠错码,文件和数据包有各自的校验和、文件级密码和隐藏的控件标签,并有大量的代码来处理序列记录异常和关于时序 的问题。如果出现问题,它就无法恢复,程序也会崩溃,同时报告“label of exit”并清空变量内容——有点像今天的堆栈跟踪。理论上你可以使用这些信息追溯代码来弄清楚到底发生了什么。但是所有这一切和我在学校里学到的完全不 同。阅读和使用这些代码,感觉能让人彻底疯掉。

这个顽固的系统程序员,即使是没法修复的 bug,也不会阻碍他前进的脚步,因为他会找到一种方法来解决这些 bug,保持系统的运行。然后,在他离开公司以后,我接手了这个系统。我又发现了 bug,特别开心自己修复了它,然而却不小心在其他地方毁坏了一些“纠错”代码,事实上,这些“纠错”代码其实依赖于网络中的 bug 而生存。所以,当我终于理清各种关系之后,我会先尽可能安全地将这些“保护伞”移除,并清理错误处理,这样我就可以放心大胆地去维护系统了。我为代码设置 了信任边界——当然那个时候我还不知道应该这样叫——用来决定什么样的数据不能被信任,而什么样的数据是可以信任的。这样做了之后,我发现我简化了防御代 码,这不但有助于在做出修改的同时不引起系统混乱,同时又能保护核心代码免受不良数据、剩余代码错误以及操作问题的干扰。

让代码更安全其实很简单

防御性编码的要点就是让代码更安全,并帮助其他人维护和支持代码——而不是使得程序员的工作更为困难。不过,防御性代码也是代码——只要是代码 就会有 bug,但是,由于防御性代码用于处理异常,所以想要给它做测试并且确保它能够有效工作就会显得非常非常难。理解检查条件和明确需要怎么样的防御代码是需 要经验积累的,处理产品中的代码并预见现实世界中会出现的问题,同样如此。

想要设计出一种长效的系统不但是个技术老大难而且成本非常高。防御性编程却两者皆非——因为每个人都能理解并办到。只是,它需要磨练和警觉,需要我们能够做到注重细节。但是如果我们想要让世界变得更安全,那么这是我们必须要走的独木桥。