目录
前言
防御式编程来自防御式驾驶。在防御式驾驶中要建立这样一种思维,那就是你永远也不能确定另一位司机将要做什么。这样才能确保在其他人做出危险动作时你也不会受到伤害。你要承担起保护自己的责任,哪怕是其他司机犯的错误。防御式编程的主要思想是:子程序应该不因传入错误数据而被破坏,哪怕是由其他子程序产生的错误数据。更一般地说,其核心想法是要承认程序都会有问题,都需要被修改,聪明的程序员应该根据这一点来编程序。
本章就是要讲述如何面对严酷的非法数据的世界、在遇到 “绝不会发生”的事件以及其他程序员犯下的错误时保护你自己。
个人结论
我是服务的提供者(方法是public的,别人可能会调用我的接口、组件、方法),那么调用者就需要遵守我的规则:该传的参数必须要传。
只要是public的接口,那么参数校验是少不了的。web服务是一个领域、ser服务也是一个领域、repo服务又是一个领域,都得做校验。
8.1 保护程序免遭非法输入数据的破坏
8.1.1 三种方式处理“垃圾进”
“垃圾进,垃圾出”的程序显然不符合程序安全的标准。通常有三种方法来处理进来垃圾的情况。
1、检查所有来源于外部的数据的值 。当从文件、用户、网络或其他外部接口中获取数据时,应检查所获得的数据值,以确保它在允许的范围内。
- 对于数值,要确保它在可接受的取值范围内;
- 对于字符串,要确保其不超长。如果字符串代表的是某个特定范围内的数据(如金融交易 ID 或其他类似数据),那么要确认其取值合乎用途,否则就应该拒绝接受。
- 如果你在开发需要确保安全的应用程序,还要格外注意那些狡猾的可能是攻击你的系统的数据,包括企图令缓冲区溢出的数据、注入的 SQL 命令、注入的 HTML 或 XML 代码、整数溢出以及传递给系统调用的数据,等等。
- 补充:对于外部数据,不仅仅是简单的对输入参数进行非空校验,更应该结合业务场景增加校验。比如对于输入的文件,你需要检查这个文件是否完整;对于修改某条记录,你可以先去根据ID查询该记录是否存在,如果不存在返回一个提示。
2、 检查子程序所有输入参数的值 。检查子程序输入参数的值,事实上和检查来源于外部的数值一样,只不过数据是来自于其他子程序而非外部接口。第 8.5节“隔离程序,使之包容由错误造成的损害”阐述了一种实用方法可用于确定哪些子程序需要检查其输入数据。
3、决定如何处理错误的输入数据 。一旦检测到非法的参数,你该如何处理它呢?根据情况的不同,你可以从十几种不同的方案中选择其一,在本章后面第 8.3节“错误处理技术〞中会详细描述这些技术。
8.2.2 思考:程序输出时也应该增加防御
当提到‘输入’时,我们是处于「服务提供者」的角色,也就是别人来调用我的程序;当提到‘输出’时,我们是处于「服务调用者」的角色,也就是我们来调用别人的程序。
处于服务调用者角色的时候,我们也应该对获取到的数据进行校验。比较常见的是:对获取的数据进行非空检查。
8.2.3 保留”证据“
当服务被调用的时候,可以优先进行日志输出,以保留服务被调用时的‘痕迹’。
8.2 断言
断言可以用于在代码中说明各种假定,澄清各种不希望的情形。可以用断言检查如下这类假定:
- 输入参数或输出参数的取值处于预期的范围内;
- 子程序开始(或者结束)执行时文件或流是处于打开(或关闭)的状态;
- 子程序开始(或者结束)执行时,文件或流的读写位置处于开头(或结尾)处;
- 文件或流已用只读、只写或可读可写方式打开;
- 仅用于输入的变量的值没有被子程序所修改;
- 指针非空;
- 枚举值是否符合预期;
- 传入子程序的数组或其他容器至少能容纳X个数据元素;
- 表已初始化,存储着真实的数值;
- 子程序开始(或结束)执行时,某个容器是空的(或满的);
- 一个经过高度优化的复杂子程序的运算结果和相对缓慢但代码清晰的子程序的运算结果相一致。
当然,这里列出的只是一些基本假定,你在子程序中还可以包括更多可以用断言来说明的假定。
8.2.1 建立自己的断言机制
C++、Java等语言都支持断言。如果你想在原有的基础上,构建自己的断言机制,可以对原有的断言进行封装:
8.2.2 使用断言的指导建议
下面是关于使用断言的一些指导性建议。
1、用错误处理代码(try catch)来处理预期会发生的状况,用断言(assert)来处理绝不应该发生的状况。
2、避免把需要执行的代码放到断言中。
3、用断言来注解并验证前条件和后条件。 前条件(preconditions)和后条件(postconditions)是一种名为“契约式设计 (design by contract )” 的程序设计和开发方法的一部分(Meyer 1997)。前条件是调用方代码(服务的提供者)对其所调用的代码要承担的义务。后条件是子程序或类在执行结束后要确保为真的属性,后置条件是子程序或类(服务的调用者)对调用方代码所承担的责任。
8.3 错误处理技术
断言可以用于处理代码中不应发生的错误。那么又该如何处理那些预料中可能要发生的错误呢?根据所处情形的不同,你可以:
- 返回中立值(neutral value)
- 换用下一个正确数据
- 返回与前次相同的值
- 换用最接近的有效值
- 在日志文件中记录警告信息
- 返回一个错误码
- 调用错误处理子程序或对象
- 显示出错信息或者关闭程序
- ......
- ——或把这些技术结合起来使用。
8.3.1 健壮性和正确性
处理错误最恰当的方式要根据出现错误的软件的类别而定。错误处理方式有时更侧重于正确性,而有时则更侧重于健壮性。
开发人员倾向于非形式地使用这两个术语,但严格来说,这两个术语在程度上是截然相反的。
- 正确性〈correctness)意味着永不返回不准确的结果,哪怕不返回结果也比返回不准确的结果好。人身安全攸关的软件往往更倾向于正确性而非健壮性。不返回结果也比返回错误的结果要好。
- 然而,健壮性(robustness)则意味着要不断尝试采取某些措施,以保证软件可以持续地运转下去,哪怕有时做出一些不够准确的结果。
8.3.2 高层次对错误处理方式的影响
既然有这么多的选择, 你就必须注意,应该在整个程序里采用一致的方式处理非法的参数。对错误进行处理的方式会直接关系到软件能否满足在正确性、健壮性和其他非功能性指标方面的要求。确定一种通用的处理错误参数的方法,是架构层次(或称高层次)的设计决策,需要在那里的某个层次上解决。
一旦确定了某种方法, 就要确保始终如一地贯彻这一方法。如果你决定让高层的代码来处理错误,而低层的代码只需简单地报告错误,那么就要确保高层的代码真的处理了错误!有些语言允许你忽略“函数返回的是错误码”这一事实—在 C++中,你无须对函数的返回值做任何处理——但千万不要忽略错误信息!检查函数的返回值。即使你认定某个函数绝对不会出错,也无论如何要去检查一下。防御式编程全部的重点就在于防御那些你末曾预料到的错误。
8.4 异常
异常是把代码中的错误或异常事件传递给调用方代码的一种特殊手段。对出错的前因后果不甚了解的代码,可以把对控制权转交给系统中其他能更好地解释错误并采取措施的部分。
异常和继承有一点是相同的,即:审慎明智地使用时,它们都可以降低复杂度;而草率粗心地使用时,只会让代码变得几乎无法理解。下面给出的一些建议可以让你在使用异常时扬长避短,并避免与之相关的一些难题:
- 用异常通知程序的其他部分,发生了不可忽略的错误 。异常机制的优越之处在于它能提供一种无法被忽略的错误通知机制 (Meyers 1996)。其他的错误处理机制有可能会导致错误在不知不觉中向外扩散,而异常则消除了这种可能性。
- 不能用异常来推卸责任 。如果某种的错误情况可以在局部处理,那就应该在局部处理掉它。
- 避免在构造函数和析构函数中抛出异常,除非你在同一地方把它们捕获。当从构造函数和析构函数里抛出异常时,处理异常的规则马上就会变得非常复杂。
- 在异常消息中加入关于导致异常发生的全部信息。 每个异常都是发生在代码抛出异常时所遇到的特殊情况下。这一信息对于读取异常消息的人们来说是很有价值的,因此要确保该消息中含有为理解异常抛出原因所需的信息。
- 了解所用函数库可能抛出的异常 。如果你所用的编程语言不要求子程序或类定义它可能抛出的异常,那你一定要了解所用的函数库都会抛出哪些异常。末能捕获由函数库抛出的异常将会导致程序崩溃,就如同未能捕获由自己代码抛出的异常一样。如果函数库没有说明它可能抛出哪些异常,可以通过编写一些原型代码来演练该函数库,找出可能发生的异常。
- 考虑创建一个集中的异常报告机制 。有种方法可以确保异常处理的一致性,即创建一个集中的异常报告机制。这个集中报告机制能够为一些与异常有关的信息提供一个集中的存储,如所发生的异常种类、每个异常该被如何处理以及如何格式化异常消息等。
- 把项目中对异常的使用标准化。 为了保持异常处理尽可能便于管理,你可以用以下几种途径把对异常的使用标准化。
- 如果你在用一种像 C++一样的语言,其中允许抛出多种多样的对象、数据及指针的话,那么就应该为到底可以抛出哪些种类的异常建立一个标准。为了与其他语言相兼容,可以考虑只抛出从 std::exception 基类派生出的对象。
- 考虑创建项目的特定异常类,它可用做项目中所有可能抛出的异常的基类。这样就能把记录日志、报告错误等操作集中起来并标准化。
- 规定在何种场合允许代码使用 throw-catch 语句在局部对错误进行处理。
- 规定在何种场合允许代码抛出不在局部进行处理的异常。
- 确定是否要使用集中的异常报告机制。
- 规定是否允许在构造函数和析构函数中使用异常。
有些程序员用异常来处理错误,只是因为他所用的编程语言提供了这种特殊的错误处理机制。你心里应该自始至终考虑各种各样的错误处理机制:在局部处理错误、使用错误码来传递错误、在日志文件中记录调试信息、关闭系统或其他的一些方式等。
仅仅因为编程语言提供了异常处理机制而使用异常,是典型的“为用而用〞;这也是典型的“在一种语言上编程〞而非“深入一种语言去编程”的例子。(有关这两者的区别,请参阅第4.3节“你在技术浪潮中的位置”和第34.4节“以所用语言编程,但思路不受其约束”)
8.5 隔离程序,使之包容由错误造成的损害
以防御式编程为目的而进行隔离的一种方法,是把某些接口选定为“安全”区域的边界。对穿越安全区域边界的数据进行合法性校验,并当数据非法时做出敏锐反映。
让软件的某些部分处理“不干净的”数据,而让另一些部分处理“干净的”数据,即可让大部分代码无须再担负检查错误数据的职责:
也同样可以在类的层次采用这种方法。类的公用方法可以假设数据是不安全的,它们要负责检查数据并进行清理。一旦类的公用方法接受了数据,那么类的私用方法就可以假定数据都是安全的了。
在输入数据时将其转换为恰当的类型 。 输入的数据通常都是字符串或数字的形式。这些数据有时要被映射为“是”或“否”这样的布尔类型,有时要被映射为像 Color_Red、 Color_Green 和 Color_Blue 这样的枚举类型。在程序中长时间传递类型不明的数据,会增加程序的复杂度和崩溃的可能性——比如说有人在需要输入颜色枚举值的地方输入了“是’〞。因此,应该在输入数据后立即将其转换到恰当的类型。
8.5.1 隔离和断言的关系
隔栏的使用使断言和错误处理有了清晰的区分。隔栏外部的程序应使用错误处理技术,在那里对数据做的任何假定都是不安全的。而隔栏内部的程序里就应使用断言技术,因为传进来的数据应该已在通过隔栏时被清理过了。如果隔栏内部的某个子程序检测到了错误的数据,那么这应该是程序里的错误而不是数据里的错误。隔栏的使用还展示了“在架构层次上规定应该如何处理错误”的价值。规定隔栏内外的代码是一个架构层次上的决策。
核对表:防御式编程
一般事宜
- 子程序是否保护自己免遭有害输入数据的破坏?
- 你用断言来说明编程假定吗?其中包括了前条件和后条件吗?
- 断言是否只是用来说明从不应该发生的情况?
- 你是否在架构或高层设计中规定了一组特定的错误处理技术?
- 你是否在架构或高层设计中规定了是让错误处理更倾向于健壮性还是正确性?
- 你是否建立了隔栏来遏制错误可能造成的破坏?是否减少了其他需要关注错误处理的代码的数量?
- 代码中用到辅助调试的代码了吗?
- 如果需要启用或禁用添加的辅助助手的话,是否无须大动干戈?
- 在防御式编程时引入的代码量是否适宜一既不过多,也不过少?
- 你在开发阶段是否采用了进攻式编程来使错误难以被忽视?
异常
- 你在项目中定义了一套标准化的异常处理方案吗?
- 是否考虑过异常之外的其他替代方案?
- 如果可能的话,是否在局部处理了错误而不是把它当成一个异常抛到外部?
- 代码中是否避免了在构造函数和析构函数中抛出异常?
- 所有的异常是否都与抛出它们的子程序处于同一抽象层次上?
- 每个异常是否都包含了关于异常发生的所有背景信息?
- 代码中是否没有使用空的 catch 语句?(或者如果使用空的 catch 语句确实很合适,那么明确说明了吗?)
安全事宜
- 检查有害输入数据的代码是否也检查了故意的缓冲区溢出、SQL注入、HTML 注入、整数溢出以及其他恶意输入数据?
- 是否检查了所有的错误返回码?
- 是否捕获了所有的异常?
- 出错消息中是否避免出现有助于攻击者攻入系统所需的信息?
项目中使用
1、如果a方法被十几二十几处被调用。
- 那么建议在所有调用a的地方进行异常捕获,且异常msg进行统一描述; 原因:一旦系统出现异常,在日志系统中可以通过对异常msg进行搜索,然后快速捕获处到底是十几二十几处那几处调用失败。
- 然后再根据实际业务情况,对这个捕获了的异常进行抛出或者吞掉。
2、凡是从对象获取到的属性,然后再去调用该属性的某个方法,都需要对这个属性进行空校验:
如:message.getShielded() == null ? 0 : (message.getShielded() ? 1 : 0)。