如何写出好代码 — 防御式编程指南

引言

在日常工作当中,大家是否有这种感觉:“好”代码和“差”代码,都可以实现产品的需求。但是不同的人写出的代码,在效率、质量、可维护性、可扩展性、可读性等方面千差万别。想法、构思、架构设计得再好,写出来的代码三天两头踩坑,这是纸上谈兵。因此软件的健壮性是衡量一名工程师水平的重要标准。如何提升软件的健壮性?合理的顶层设计、完备的测试必不可少,但终其根本是提升代码质量。
防御式编程是一种安全编码的思维方式。它被看做是减少或消除墨菲定律的一种手段。在程序员奉为圭臬的著作《Code Complete》(代码大全)里,详细介绍了防御式编程。同时国内外有很多大厂把防御式编程作为质量建设的手段之一。本文将介绍防御式编程以及实际场景中如何应用防御式编程。
墨菲定律
如果有两种或两种以上的方式去做某件事情,而其中一种选择方式将导致灾难,则必定有人会做出这种选择。这是一种偏悲观的思想,认为所有可能出问题的坏情况都会发生。那么在抱有该想法去做设计时,就需要对最坏情况做出预测并采取相对应的措施。同时让使用人员不需要进行复杂的思考,通过直觉即可使用某个系统,即所谓的防呆设计。如3.5寸的软盘设计,就设计成只有一种情况可以插入进去。
 

什么是防御式编程?

防御式编程,其核心思想是 子程序应该不因传入错误数据而被破坏,哪怕是由其他子程序产生的错误数据。简单来说,就是怀疑一切,认为自身代码之外的环境都是不可信的,在这种情况下,考虑代码该怎么写。
 
防御式编程和防御式驾驶
防御式编程,这一概念来自防御式驾驶。在防御式驾驶中要建立这样一种思维,那就是你永远也不能确定另一位司机将要做什么。这样才能确保在其他人做出危险动作时你也不会受到伤害。你要承担起保护自己的责任,哪怕是其他司机犯的错误。
举个实际的场景,以一次SQL查询为例:
Class Main {
    private Connection con = = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD);
    
    public List<Student> doQuery(String name) {
        Statement stmt = conn.createStatement();
        ResultSet rs = stmt.executeQuery("SELECT id, grade, name, gender FROM students WHERE name=" + name);
        List<Student> studentList = new ArrayList<>();
        while(rs.next()) {
            long id = rs.getLong(1);
            long grade = rs.getLong(2);
            String name = rs.getString(3);
            String gender = convertGender(rs.getInt(4));
            Student student = new Student(id, grade, name, gender);
            studentList.add(student);
        }
        return studentList;
    }
    
    
    private String convertGender(int gender) {
        switch(gender) {
            case 0 : return "male";
            case 1 : return "female";
        }
        return null;
    }
}
上述代码比较简单,看上去也实现了我们想要的需求:查询符合名字的所有学生。但是有经验的开发同学,就能够发现不少问题,比如:是否正常建立了数据库连接?数据库的返回值有没有做校验?以上的这些问题总结下来,我们可以得出防御式编程的关键原则。

边界防御:检查所有的外部输入

在防御式编程的理念中,所有的外部输入都是不可信的,需要校验是否在可允许的范围内。这里需要检查的项包括,空指针、数组越界、不合法入参等。特别是当我们在写一个公共方法时,不确定这个方法会在未来某个时刻,被某个外部系统调用,做好输入检查既能保护自身程序运行的健壮性,又可以让外部系统放心调用。
 
在上面的这个case中有一个明显的传参漏洞,入参name有可能会被外部用户使用SQL注入攻击,如输入name = "zhangsan or 1 = 1",就可以获取所有students信息,显然这个是不符合我们要求的入参。
 
另外这个外部输入不光包括传入参数,还包括任何从方法外部获取到的数据,包括数据库查询到的数据等。

异常处理:在正确性和健壮性之间做好取舍

  • 正确性是指:程序永不返回不准确的结果,即使这样做会不返回结果或是直接退出程序。
  • 健壮性是指:系统在不正常的输入或不正常的外部环境下仍能正常运行,哪怕输出结果是错误的或者不完整的。
正确性和健壮性往往是相互矛盾的,当我们检查出错误数据后,还需要决定如何处理它。防御性编程不会掩盖错误,也不会隐藏bug。这需要在健壮性和正确性之间做权衡。在对异常低容忍度的场景,比如火箭发射系统或医疗系统,正确性要优于健壮性;在电商等消费场景中,要优先考虑健壮性,毕竟选择商品不成功,重新刷新一下就好了。
 
在上述case中,如果数据库连接失败,比较好的做法是重试或者返回错误码,而不是直接让程序退出。

应检尽检:没有完全可靠的外部环境

我们在coding时,会有很多外部方法的调用和交互,要对所有的外部调用保持警惕。这些API或者三方类库也是人写的,人写的就意味着可能有bug。一种好的思路是尽可能地按照自身逻辑,对外部调用做检查和异常处理(exception handle)。
 
在上述case中,存在两个地方的问题:
1、调用数据库并没有检查是否成功;
2、调用完数据库并没有手动释放资源,这很有可能造成内存泄漏。

显示约束:简单直接的代码风格

在防御式编程中,我们提倡使用“最笨”的方式写代码,尽量少的使用一些语法糖或者隐性规约。
 
比如很多面试题中都会碰到的"a++ = b++",是防御式编程当中不提倡的,不如写成"a = b; a++; b++",虽然代码多了两行,但是意义清晰,且容易理解。
另外一种情况是使用显示约束。比如:多用const final static,避免使用select *,字段名前加上表名。在上述的case中,"SELECT id, grade, name, gender FROM students WHERE name=" 里的name应该加上表名: students.name,因为name是mysql中的关键字。

减少依赖:write once, run anywhere

"write once, run anywhere"是Sun公司用来展示Java程序设计语言的跨平台特性的口号,这本身就是一种防御式编程的理念体现,即在代码中减少对环境的依赖,确保外界环境改变了,程序依然可以正常运行。
 
在我们的项目中,比较典型的就是国产化问题。国产化问题经常涉及到数据库的适配,那么在做程序设计时,我们就需要考虑到业务逻辑和底层数据调用的解耦。再举一个例子,"i++ != j++" 在不同的编译器中执行结果是不同的,这种也是我们需要避免的。

傻瓜式注释

代码是给系统看的,注释是给人看的。要想代码具有比较好的可阅读性,应该把自己当作傻子去添加注释。(这个和clean code 的理念不同,clean code提倡代码即注释,个人认为这是一种理想化的情境)
 
但注释并不是写废话,好的注释应该出现在:复杂的业务逻辑、非常规的写法、可能有坑的地方、临时解决方案、项目当中核心的类或者方法。写注释是一个优秀工程师必备的技能,大家可以参考下很多优秀项目上的注释写法。

契约式编程

契约式编程(Contract Programming),顾名思义,在设计阶段就已经确定好每个方法的边界,包括每个方法的参数、返回值,以及它们的类型和所有可能的值。这个术语最早由伯特兰·迈耶于1986年提出。他设计了Eiffel程式语言来实现这种程式设计方法,在《物件导向软体建构》(Object-Oriented Software Construction)一书中,又提出两个后继版本。
 
契约式编程强调了三个概念,即前置条件,后置条件和不变式。这几个概念实际上是脱胎于Eiffel语言的一些特性的,不熟悉Eiffel的同学会觉得有点晦涩难懂。
  • 前置条件:期望所有调用它的客户模块都保证一定的进入条件,比如非NULL、非0等要求;
  • 后置条件:保证退出时给出特定的属性,比如程序退出时会释放数据库连接;
  • 不变式:在进入时假定,并在退出时保持一些特定的属性。

契约式编程是比防御式编程更加乐观的一种编程思路,强调约定和断言,具体想了解契约式编程的同学,可以移步:https://www.eiffel.com/values/design-by-contract/introduction/

避免过度设计

过度的防御式编程,也会带来新的问题:
 
首先是预防不可能会发生的错误,如上面的case所示,对于数据库返回的结果,采用rs.next()即可判断是否有值,而不需要对rs进行非null判断;
其次,过多的防御式代码,会导致整体程序显得臃肿、难以维护,代码里充斥着大量的判断和非业务代码;同时,程序的性能也会受此影响;
然后,当代码中有非常多的异常捕捉和处理时,可能会导致异常被吞掉,没有正常地报出来。

总结

防御式编程是一种安全的编程思想,本质上是要求开发人员对代码和线上环境报以辩证的态度和敬畏之心。它通过以下途径,从而来提升系统健壮性:
  • 提高工程质量——减少bug和问题;
  • 提高源码可读性—— 源码应该变得可读且可理解,并且能经受code review;
  • 让软件能通过预期的行为来处理不可预期的用户操作。
作为一名优秀的开发者,不能将希望完全寄托于测试,测试驱动开发,而是在设计、开发阶段,对系统的异常和边界有充分的认知和考量,这是防御式编程带给我们的思考。

附录:防御式编程checkList

一般事宜
  • 子程序是否保护自己免遭有害输入数据的破坏?
  • 你用断言来说明编程假定吗?其中包括了前条件和后条件吗?
  • 断言是否只是用来说明从不应该发生的情况?
  • 你是否在架构或高层设计中规定了一组特定的错误处理技术?
  • 你是否在架构或高层设计中规定了是让错误处理更倾向于健壮性还是正确性?
  • 你是否建立了隔栏来遏制错误可能造成的破坏?是否减少了其他需要关注错误处理的代码的数量?
  • 代码中用到辅助调试的代码了吗?
  • 如果需要启用或禁用添加的辅助助手的话,是否无需大动干戈?
  • 在防御式编程时映入的代码量是否适宜–既不过多,也不过少?
  • 在开发阶段是否采用了进攻式编程来使错误难以被忽视?
异常
  • 你在项目中定义了一套标准化的异常处理方案吗?
  • 是否考虑过异常之外的其他替代方案?
  • 如果可能的话,是否在局部处理了错误而不是把它当成一个异常抛到外部?
  • 代码中是否避免了在构造函数和析构函数中抛出异常?
  • 所有的异常是否都与抛出它们的子程序处于同一抽象层次上?6).每个异常是否都包含了关于异常发生的所有背景信息?
  • 代码中是否没有使用空的catch语句?(或者如果使用空的catch语句确实很合适,那么明确说明了吗?)
安全事宜
  • 检查有害输入数据的代码是否也检查了故意的缓冲区溢出、SQL注入、HTML注入、证书溢出一级其他恶意输入数据?
  • 是否检查了所有的错误返回码
  • 是否捕获了所有的异常?
  • 出错消息中是否避免出现有助于攻击者攻入系统所需的信息?
要点:
  • 最终产品代码中对错误的处理方式要比“垃圾进,垃圾出”复杂的多。
  • 防御式编程技术可以让错误更容易发现、更容易修改,并减少错误对产品代码的破坏。
  • 断言可以帮助人尽早发现错误,尤其是在大型系统和高可靠性的系统中,以及快速变化的代码中。
  • 关于如何处理错误输入的决策是一项关键的错误处理决策,也是一项关键的高层设计决策。
  • 异常提供了一种与代码正常流程角度不同的错误处理手段。如果留心使用异常,它可以成为程序员们知识工具箱中的一项有益补充,同时也应该在异常和其他错误处理手段之间进行权衡比较。
  • 针对产品代码的限制并不适用于开发中的软件。你可以利用这一优势在开发中添加有助于更快地排查错误的代码。

作者简介

云智慧架构部,长期致力于智能运维领域工程架构的建设和发展,打造高性能、高可用、高易用的运维工程框架,提升公司技术水位线。

开源福利

云智慧已开源数据可视化编排平台 FlyFish 。通过配置数据模型为用户提供上百种可视化图形组件,零编码即可实现符合自己业务需求的炫酷可视化大屏。 同时,飞鱼也提供了灵活的拓展能力,支持组件开发、自定义函数与全局事件等配置, 面向复杂需求场景能够保证高效开发与交付。

点击下方地址链接,欢迎大家给 FlyFish 点赞送 Star。参与组件开发,更有万元现金等你来拿。

微信扫描识别下方二维码,备注【飞鱼】加入 AIOps 社区飞鱼开发者交流群,与 FlyFish 项目 PMC 面对面交流~

{{o.name}}
{{m.name}}

猜你喜欢

转载自my.oschina.net/yunzhihui/blog/5547812