英文链接:Defensive Programming: Being Just-Enough Paranoid
每当程序员突然遇到某个bug并不知道怎么改的时候,他们会添加一些“防御性代码”来使编码更安全并且更容易找到问题的原因。有时这样做可以消除错误。他们加强了数据的有效性验证——检验输入框、输出框和返回值的内容;审查并改善错误处理——可能会添加一些针对于出现“异常”情况的验证代码;添加一些有用的日志和诊断。换句话说,就是最好一开始就应该出现在那儿的代码。
事先预料到无法预料的情况
防御性编程的关键在于未雨绸缪,防患于未然。
—— Steve McConnell, 《Code Complete》(中文版:《代码大全》)
在Steve McConnell的经典编程书籍,《Code Complete》里的几个简单章节里讲到了防御性编程的几条基本规则:
1. 保护你的代码不要受“外界”的无效数据影响,“外界”影响有很多种情况。外部系统的数据,某个用户的操作或模型/组件外面的数据。任何在控制范围之外的东西都是危险的,而任何在控制范围之内的都是安全的,所以要设立“安全区”。在安全区域的代码会验证所有的输入数据:检查所有输入参数的类型,长度和取值范围。可以通过双击来检测有没有溢出。
2. 当检验到了错误的数据后,可以考虑如何处理它。防御性编程并不意味着要忍受错误或是避开错误。它意味着要从健壮性(如果遇到你能处理的问题时能保持程序运行)和正确性(不会返回错误的结果)之间权衡最合适的处理方式。可以选择一种策略来处理错误的数据:报错并立刻停止程序(快速结束),返回一个替代的数据值,等等,总之要确保这个策略是明显一致的。
3. 不要以为在代码外进行函数调用和方法调用会像你所想的那般顺利。你要明白这一点,并在外部的API和库里测试你的错误处理。
4. 在开发和测试的情况下,可以使用断言来假设某种“可能出现”的条件并特别显示出来。这对于需要不同的人在各个时间进行维护的高可靠性大型系统来说尤其重要。
5. 添加诊断代码可以智能记录并追踪代码,它可以解释运行时当前的情况,尤其在遇到某个问题时它的帮助会更大。
6. 错误处理需要标准化。要考虑遇到“一般错误”、“预料中错误”和警告时的各种处理方式,决定好之后就不要再改了。
7. 只有在你需要的时候,并且你对编程语言的异常处理极为熟悉才可以使用异常处理。
在一般的错误处理中使用异常处理的程序会引起可读性和可维护性的代码问题。
——《The Pragmatic Programmer》(中文版:《程序员修炼之道:从小工到专家》)
我想再加两条规则。一个是Michael Nygard的Release It!中提到的,绝对不要去不断地等待某个外部的调用,尤其是远程调用。如果什么地方出现问题了,时间会非常漫长。使用暂停/重试的逻辑方法和他的Circuit Breaker稳定方案可以解决远程问题。
另一个规则是,对于像C和C++的语言,防御性编程也包括使用安全函数调用来避免缓冲区溢出和其他常见的代码错误。
不同类型的质疑
The Pragmatic Programmer把防御性编程描述为“防御性质疑”。它保护你的代码不受其他人的错误或你自己的错误影响。如果怀疑数据的有效性,可以检验数据的一致性和完整性。你不能测试所有的错误,所以要使用断言和异常处理来对付“发生了预料之外”的事情。在程序的测试中你会学到一些知识,如果程序出错了,去找找还有什么地方会出错。着重注意最核心的重要代码。
合理的质疑编程是一种正确的编程习惯。不过质疑太过分了可能过犹不及。在Clean Code(中文版:《代码整洁之道》)里关于错误处理的章节里,Michael Feathers提醒道:
(error handling)错误处理的代码可能会制约许多代码的本质意义
许多错误处理代码不仅会混淆代码的主要流程(也就是代码实际要做什么),还会混淆错误处理本身的逻辑——这样很难做到正确,很难审查和测试,很难在更改代码后不引起错误。代码不再灵活安全了,它实际上会变得更脆弱,容易引发问题。
防御性编程可以采取的,有合理的质疑方法,也有过分的质疑方法,还有近于疯狂的质疑方法。
我第一个接触的世界级系统是一个在跨越了美国加拿大的服务器上“Store and Forward”网路控制系统(也被称为微型电脑)。它在网路上的分布式系统,计划作业,和坐标报告之间分享数据。它被设计为遇到网路问题时能灵活处理,在遇到操作失误时会自动恢复和重启。这在当时是非常具有技术性挑战的。
最初维护这个系统的程序员并不相信网路,系统和操作会是永远正常的,也不相信其他人的代码,甚至是自己的代码是毫无破绽的。他是一个从化学专业自学转到系统工程师的,他喜欢在晚上很晚的时候喝很多酒,并在那种状态下写几千行松散的FORTRAN或汇编的代码。代码里充满了错误检查、自我诊断和错误校正,文件和数据包都有它们自己的校验和、文件级密码和隐藏的控制标签,也有很多代码可以控制计算异常和计时问题——代码几乎所有时间都在运行。如果代码遇到什么无法分析的问题,程序会崩溃并报告一个“退出标签”并且转储变量的内容——就像现在所说的堆栈跟踪。你理论上可以利用这些信息来检查代码并查出里面到底发生了什么。这些看起来都不是在学校里可以学到的。阅读和运行代码不会再觉得受限制。
如果遇到难以修改的bug,使代码不能继续运行。他会找一个办法来处理bug使系统可以保持运行。在他离开公司之后,如果代码遇到某个网路上的bug,我就可以通过那些“错误校验”代码找到并修改这个bug。当我解决完问题之后,就可以安全地移除这些“保护代码”,这样清理错误处理代码可以使我在维护系统时不会删掉重要的东西。我设立了安全区——其实我也不知道怎么称呼比较好——来分析什么数据是有效的,而什么是无效的。做到这点就能简化防御性代码以便我更改代码也不会引起系统本身的出错,并能保护核心代码不受无效数据、代码中某些错误或操作失误的影响。
维护代码安全很简单
防御性编码的要点是让代码更安全并减少维护代码的人的工作。防御性代码和普通的代码一样都有bug,因为防御性代码是用来处理异常的,所以测试尤其困难,也很难保证在代码运行时能正常工作。理解哪些条件下要使用防御性代码并使用多少防御性编码,需要在实际编程工作中多观察来积累经验。
许多涉及到设计和建立安全灵活系统的工作都是技术难以实现的或是花费消耗极大的。而防御性编程两者都没有——它有些像防御性驾驶,也就是所有人都很容易去理解的。它需要规范和意识,对细节加以注意,若我们想让代码变得安全,就都会用到它的。