什么是防御性编程?(What is Defensive Programming?)

garbage in ,garbage out (GIGO),作为一条计算机界的“俗语”,一条相对“学院派”的设计理念,我们或多或少都有听过。

但在实际的工程环境下,GIGO已然成为了一种“不作为”、“缺乏安全性”的标志。

所以我们要的是:不论进来什么,好的程序都不会生成“垃圾”。

如何践行防御性编程?(How to make the program defensive)

检查所有源于外部的数据

检查所有来源千外部的数据的值当从文件、用户、网络或其他外部接口中获取数据时,应检查所获得的数据值,以确保它在允许的范围内。

对于数值,要确保它在可接受的取值范围内;对于字符串,要确保其不超长。如果字符串代表 的是某个特定范围内的数据(如金融交易ID或其他类似数据),那么要确认其取值合乎用途,否则就应该拒绝接受。

如果你在开发需要确保安全的应用程序,还要格外注意那些狡猾的可能是攻击你的系统的数据,包括企图令缓冲区溢出的数据、注入的SQL命令、注入的HTML或XML代码、整数溢出以及传递给系统调用的数据。

检查子程序所有输入的参数的值

检查子程序输入参数的值,事实上和检杳来源于外部的数值一样,只不过数据是来自千其他子程序而非外部接口。

同时保证子程序的“隔离性”,以子程序为单位保证自己的“防御性”,那么整体的防御性就能得到较为有效的保证。

决定如何处理错误的输入数据

见下文“错误处理手段”

通过“工具”帮助我们更好的构建程序

断言(Assertion)

断言(assertion)是指在开发期间使用的、让程序在运行时进行自检的代码(通常是一个子程序或宏)。断言为真,则表明程序运行正常,而断言为假,则意味着 它已经在代码中发现了意料之外的错误。

JAVA从1.4开始支持断言,默认JVM是关闭断言检测的,若要开启需要添加参数 - enableassertions

assert <布尔表达式> : <错误信息>

断言对千大型的复杂程序或可靠性要求极高的程序来说尤其有用。 通过使用 断言, 程序员能更快速地排查出因修改代码或者别的原因, 而导致进程序里的不匹配的接口假定和错误等。

断言适用范围:

输入参数或输出参数的取值处于预期的范围内;

子程序开始(或者结束)执行时文件或流是处于打开(或关闭)的状态;

子程序开始(或者结束)执行时,文件或流的读写位置处于开头(或结尾)处;

文件或流已用只读、 只写或可读可写方式打开;

输入的变量的值没有被子程序所修改;

指针非空;

传入子程序的数组或其他容器至少能容纳X个数据元素;

Collection是否已经被初始化

子程序开始(或结束)执行时, 某个容器是空的(或满的)

一个经过高度优化的复杂子程序的运算结果和目标子程序的运算结果相一致

断言的使用哲学:

1、断言是用来检查永远不该发生的情况,而异常处理是用来检查不太可能发生的非正常情况。异常处理的情况应该能在写代码的时候就预料到。

2、避免在断言中直接调用方法,因为jvm默认不开启断言,断言中调用的方法会被忽略。

如:

public static void main(String[] args) {
    assert test();
}
public static boolean test(){
    System.out.println("123");
    return true;
}

3、先使用断言,在处理异常

随着项目迭代周期的变长,我们可能会由不同的人、不同的团队处理项目中不同的模块,从而导致代码质量的不统一,以至于在产品交付之前发现所有bug是不现实的。为了应对这汇总情况,我们应同时用断言和错误处理代码来处理同一个错误,我们应在每个需要判断的地方加入断言,同时加入对此错误的异常处理,以保证整个功能的最大可用。

错误处理手段

1、返回中立值

与业务无关的非必需值出错,我们可以采用返回中立值,如 0 进行处理异常。但要注意,对于关键业务数据(如 持仓金额等)务必不要使用中立值,避免对实际业务产生误导性影响。

与业务相关的数据出错,我们可以采用返回null等,通过不展示的方式明确此部分数据异常。

与此同时带来了新的问题:我们如何面对API,因为我们知道Api也可能采用相同的办法返回一个null

所以我们得到的结论是:我们不应相信任何一个api,最好进行null的判断

2、换用下一个正确数据

在处理数据流的时候,有时只需返回下一个正确的数据 即可。如果在读数据库记录并发现其中一条记录已经损坏时,你可以继续读下去直到又找到一条正确记录为止。

例如请求客户数据接口,可能会出现接口在某一时刻不可用的情况,可以采用重复请求的方式,试图获取正常的数据。

3、返回与前次相同的数据

在一些非敏感的数据环境,你可以采用返回前一次请求的数据,因为大部分数据在较短时间内不会产生太大改变(若业务数据与客户、用户等有关联,则可以考虑采用返回当前客户的前一条数据)。

4、换用最接近的合法值

此方式在实际业务中有较多使用。例如某产品某一天净值缺失,则取前一天或后一天的净值以补全。(此方法得到大多数客户的认可。)

5、把警告信息记录到日志中

此方法可以其他方法结合使用,尽量保证所有可预期的异常都打印响应日志,入参、必要信息等,方便后续bug排查。

6、调用错误处理子程序(结合异常处理)

通过统一的异常处理子程序或对象,对全局(或局部)异常统一处理,简化异常处理的整体流程。

7、当错误发生时显示出错消息

这种方法可以极大的减少错误处理的开销,但他也可能会让用户界面中出现的错误信息成为攻击者的“突破口”

8、用最妥当的方式在局部处理错误

一些设计方案要求能在局部解决所有遇到的错误,而具体使用何种错误处理方法,则留给设计和实现会遇到错误的这部分系统的程序员决定。

这种方法给予了开发者很大的灵活度,但系统的整体性将无法满足其对正确性和可靠性的需求,不同的开发人员处理特定错误的办法可能不尽相同,难以有统一标准。

9、关闭程序

简单粗暴,但能有效的阻止“致命”且无法处理的异常在系统中蔓延

如果用作控制治疗癌症病人的放 疗设备的软件接收到了错误的放射剂量输入数据, 那么怎样处理这一错误最好?

异常处理

审慎明智的使用异常处理,可以有效降低项目的复杂度,而草率不负责任的使用,则只会让代码变得无法理解。

1、用异常通知程序的其他部分,发生了不可忽略的错误:异常的好处就是,这种错误是无法被忽略,必须要处理的,而其他方式,如替换下一个正确数据,则很容易导致异常的扩散。

2、只在真正例外的情况下才抛出异常仅在真正例外的情况下才使用异常一换句话说,就是仅在其他编码实践方法无法解决的情况下才使用异常。

优秀的的产品,系统内部不应发生异常,异常仅用来处理不罕见甚至永远不该发生的情况,和应对外部依赖的变化。

且由于调用子程序的代码需要了解被 调用代码中可能会抛出的异常,因此异常弱化了封装性,这与降低代码复杂度的愿景是背道而驰的。

3、异常不是推卸责任的理由:如果能在局部处理的异常,请在局部处理。不要把原本可以处理的异常当做一个未捕获的异常抛出。

4、避免在构造函数和析构函数中抛出异常,除非你在同一地方把它们捕获当:从构造函数和析构函数里抛出异常时,处理异常的规则马上就会变得非常复杂。 比如说在C++里,只有在对象已完全构造之后才可能调用析构函数,也就是说, 如果在构造函数的代码中抛出异常,就不会调用析构函数,从而造成潜在的资源泄漏(Meyers1996, Stroustrup 1997)。在析构函数中抛出异常也有类似复杂的规则。

5、在异常消息中加入导致异常发生的全部信息:这有助于异常的排查,让异常“更有价值”

6、尽量避免空的catch,至少打印一下基本日志!!!

7、了解你所依赖的函数库,可能出现的异常。

系统设计的影响

健壮性与正确性

健壮性:系统在不正常输入或不正常外部环境下仍能够表现正常的程度。我们在编程时往往会设定一定的规约,即输入一些数据并且将这些数据经过处理后进行输出,但是有时用户会输入一些非法数据,有可能会使程序做出一些未期望的行为并且使程序非法终止,所以为了使程序在这种情况下依然能够准确无歧义的向用户展示全面的错误信息以有助于DEBUG,程序的健壮性就显得十分重要。

正确性:正确性是最重要的质量指标,是程序按照spec加以执行的能力。在程序出现bug时,正确性着重在于永不给用户错误的结果,而健壮性则倾向于尽可能保持软件运行而不是总是退出。

即正确性倾向于直接报错,而健壮性倾向于容错。

所以如何平衡健壮性和准确性,将贯穿整个代码设计、产品设计始终。

隔离程序

在代码设计时,以某一个功能为边界,设计子程序,同时维护这个子程序整体的“防御性”。这样的设计非常清晰的划分我们防御的“边界”,让一部分模块负责“防御”,从而解放的大多数模块,也从某个侧面提升了逻辑的“纯粹”,提高了代码的可读性,让复用变得更加容易。

当然有时候我们可能需要复数的过滤模块,把控过滤模块的数量、模块内的复杂度,有助于我们维护好过滤模块之间的关系,提升整个子程序的稳定性。

在开发过程中合理的消耗资源,引入辅助调试代码,以便及时对错误进行诊断(可被及时移除)

复杂项目从开发早期开始,引入一些辅助调试代码,可以有效的降低我们甄别定位错误的成本。当然这会花费额外的开发成本,但对于复杂程序或调试困难的部分来讲,这是值得的。

当然我们也可以采用一些工具,例如skyWalking等,通过探针等技术实现与辅助代码相同的功能。

注意:我们自行编写的辅助代码,需要在代码交付时可以轻松的被移除或禁用。

进攻性编程、防御的偏执

当然,防御性编程是好的。但凡是过犹不及,当我们太过在意防御,而陷入了某种偏执,便会让代码产生“异味”。

public String badlyImplementedGetData(String urlAsString) {
    // Convert the string URL into a real URL
    URL url = null;
    try {
        url = new URL(urlAsString);
    } catch (MalformedURLException e) {
        logger.error("Malformed URL", e);
    }
 
    // Open the connection to the server
    HttpURLConnection connection = null;
    try {
        connection = (HttpURLConnection) url.openConnection();
    } catch (IOException e) {
        logger.error("Could not connect to " + url, e);
    }
 
    // Read the data from the connection
    StringBuilder builder = new StringBuilder();
    try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
        String line;
        while ((line = reader.readLine()) != null) {
            builder.append(line);
        }
    } catch (Exception e) {
        logger.error("Failed to read data from " + url, e);
    }
    return builder.toString();
}

此代码只是将URL的内容作为字符串读取。 数量惊人的代码可以完成非常简单的任务,这很java。

java的check Exception可以让你忽略这些问题,并继续处理。甚至java在孤立你这么做。

“防御性编程”或“健壮性”的信奉者可能会认为,这很nice,这样程序并不会崩溃。但我们在真的遇到问题的时候,我们已经失去了真实的上下文,且程序并没报告任何错误。

此时,我们可以引入一个新的概念“攻击性编程”,并暴力的重构这段代码

public String getData(String url) throws IOException {
    HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
 
    // Read the data from the connection
    StringBuilder builder = new StringBuilder();
    try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
        String line;
        while ((line = reader.readLine()) != null) {
            builder.append(line);
        }
    }
    return builder.toString();
}

OK,我们暴力的简化了这段代码,如果出现错误,则用户和日志(大概)会收到正确的错误消息。

“攻击性编程”的核心思想,就是在开发阶段,尽可能“暴露”问题,及问题发生的环境,并加剧它产生的破坏,以此来警示开发者,修复这个问题。

如何采取“攻击性编程”:

1、确保断言语句使程序终止运行。不要让程序员养成坏习惯,一碰到已知问 题就按回车键把它跳过。让问题引起的麻烦越大越好,这样它才能被修复。 完全填充分配到的所有内存,这样可以让你检测到内存分配错误。

2、完全填充已分配到的所有文件或流,这样可以让你排查出文件格式错误。 确保每一个case语句中的default分支或else分支都能产生严重错误(比 如说让程序终止运行),或者至少让这些错误不会被忽视。

3、在删除一个对象之前把它填满垃圾数据。

4、让程序把它的错误日志文件用电子邮件发给你,这样你就能了解到在已发 布的软件中还发生了哪些错误——如果这对于你所开发的软件适用的话。

5、信任内部数据的有效性

6、信任引用的组件

离经叛道却又有其合理性,最好的防守正是大胆进攻。在开发时惨痛地失败,能让你在发布产 品后不会败得太惨。

当然,这种设计方式仅适用于开发阶段,我们最后还是要完善各个地方的异常处理,保证产品在上线时能尽量少的暴露问题,让程序稳定的处理发生的异常或停止。

我们需要多少防御式代码?

1、保留哪些之检查重要错误的代码:我们在设计之初,就应该明确哪些部分是“不能忍受”错误的,哪些部分是可以接受错误的。

例如:我们可以接受页面上某个oss资源或描述文案不显示,但不接受涉及金额的用户数据“出错”。

2、关闭检查细微错误的代码:如果一个错误带来的影响微乎其微,那么可以把检查他的代码“关闭”,注意:关闭 ,而非删除。

3、去掉可以导致程序硬性崩溃的代码。如上述“攻击性编程”涉及的代码,会导致项目停止或其他严重后果的代码,不应出现在正式生产环境中。

4、为“开发人员”记录正确的异常信息

5、确认异常信息是“交互友好的”。

我们需要做什么?

务实

理性且克制

一些其他的Tips

1、不要主动延长对象的使用周期

例如:使用迭代器时,尽量使用for而非while,因为使用while循环,迭代器将声明在循环外,容易产生错误的重用。

2、补充读物

《Defensive Coding Guide》https://developers.redhat.com/articles/defensive-coding-guide

3、NASA编码标准 简化为JS编码标准 《NASA coding standards, defensive programming and reliability》

  • NASA => JavaScript

  • No function should be longer than a single sheet of paper 📝 => 1 function should do only 1 simple thing

  • Only use simple control flows, no goto statements and recursion => write predictable code, follow coding standards and use static analysis

  • Do not use dynamic memory allocation after initialization => Measure (benchmark) and compare (profile, memory snapshots) to detect possible memory leaks. Use object pooling, write clean code and use ESLint no-unused-vars.

  • All loops must have a fixed set of the upper bound. => recommend not to follow this rule, we need flexibility and recursion.

  • Assertion density should be at least 2 per function. => Minimal amount of tests is 2 per function, having a higher density is better of course. Watch for runtime anomalies.

  • Data objects must be declared at the smallest possible level of scope. => No shared state.

  • The return of functions must be checked by each calling function, and the validity of parameters must be checked inside each function. => we should skip it

  • Use of preprocessor must be limited. => JavaScript is transpiled by each browser, so we must monitor the performance of our code.

  • The use of pointers should be restricted. => Call chains and loose coupling should be used more often.

  • Code must be compiled with warnings enabled. ☠️ **** => Keep the project green from the first day of dev. If the tests are failing: prioritize, refactor and add new tests.

参考:

《代码大全》第二版 第八章

《Defensive Programming Techniques》 https://www.linkedin.com/pulse/defensive-programming-techniques-omar-ismail

《Offensive programming》 https://www.javacodegeeks.com/2013/09/offensive-programming.html

《Defensive Programming Techniques Explained with Examples》https://www.golinuxcloud.com/defensive-programming/

《NASA coding standards, defensive programming and reliability》 https://coder.today/tech/2017-11-09_nasa-coding-standards-defensive-programming-and-reliability-a-postmortem-static-analysis./

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐