如何写出健壮的代码?

简介: 关于代码的健壮性,其重要性不言而喻。那么如何才能写出健壮的代码?阿里文娱技术专家长统将从防护式编程、如何正确使用异常和 DRY 原则等三个方面,并结合代码实例,分享本身的见解心得,但愿对同窗们有所启发。html

image.png
你不可能写出完美的软件。由于它未曾出现,也不会出现。git

每个司机都认为本身是最好的司机,咱们在鄙视那些闯红灯、乱停车、胡乱变道不遵照规则的司机同时,更应该在行驶的过程当中防卫性的驾驶,当心那些忽然冲出来的车辆,在他们给咱们形成麻烦的时候避开他。这跟编程有极高的类似性,咱们在程序的世界里要不断的跟他人的代码接合(那些不符合你的高标准的代码),并处理可能有效也可能无效的输入。无效的的输入就好像一辆横冲直撞的大卡车,这样的世界防护式编程也是必须的,但要驶得万年船咱们可能连本身都不能信任,由于你不知道冲出去的那辆是否是你本身的车。关于这点咱们将在防护式编程中讨论。程序员

没人可否认异常处理在 Java 中的重要性,但若是不能正确使用异常处理那么它带来的危害可能比好处更多。我将在正确使用异常中讨论这个问题。github

DRY,Don't Repeat Yourself. 不要重复你本身。咱们都知道重复的危害性,但重复时常还会出如今咱们的工做中、代码中、文档中。有时重复感受上是不得不这么作,有时你并无意识到是在重复,有时倒是由于懒惰而重复。spring

好借好还再借不难。这句俗话在编程世界中一样也是至理名言。只要在编程,咱们都要管理资源:内存、事物、线程、文件、定时器,全部数量有限的事物都称为资源。资源使用通常遵循的模式:你分配、你使用、你回收。数据库

防护式编程

防护式编程是提升软件质量技术的有益辅助手段。防护式编程的主要思想是:程序/方法不该该因传入错误数据而被破坏,哪怕是其余由本身编写方法和程序产生的错误数据。这种思想是将可能出现的错误形成的影响控制在有限的范围内。express

一个好程序,在非法输入的状况下,要么什么都不输出,要么输出错误信息。咱们每每会检查每个外部的输入(一切外部数据输入,包括但不只限于数据库和配置中心),咱们每每也会检查每个方法的入参。咱们一旦发现非法输入,根据防护式编程的思想一开始就不引入错误。编程

使用卫语句

对于非法输入的检查咱们一般会使用 if…else 去作判断,但每每在判断过程当中因为参数对象的层次结构会一层一层展开判断。数组

public void doSomething(DomainA a) {
  if (a != null) {
        assignAction;
    if (a.getB() != null) {
      otherAction;
      if (a.getB().getC() instanceof DomainC) {
        doSomethingB();
        doSomethingA();
        doSomthingC();
      }
    }
  }
}

上边的嵌套判断的代码我相信不少人都见过或者写过,这样作虽然作到了基本的防护式编程,可是也把丑陋引了进来。《Java 开发手册》中建议咱们碰到这样的状况使用卫语句的方式处理。什么是卫语句?咱们给出个例子来讲明什么是卫语句。安全

public void doSomething(DomainA a) {
    if (a == null) {
        return ; //log some errorA
    }
    if (a.getB() == null) {
        return ; //log some errorB
    }
    if (!(a.getB().getC instanceof DomainC)) {
        return ;//log some errorC
    }
    assignAction;
    otherAction
    doSomethingA();
    doSomethingB();
    doSomthingC();
}

方法中的条件逻辑令人难以看清正常的分支执行路径,所谓的卫语句的作法就是将复杂的嵌套表达式拆分红多个表达式,咱们使用卫语句表现全部特殊状况。

使用验证器 (validator)

验证器是我在开发中的一种实践,将合法性检查与 OOP 结合是一种很是奇妙的体验。

public List<DemoResult> demo(DemoParam dParam) {
    Assert.isTrue(dParam.validate(),()-> new SysException("参数验证失败-" + DemoParam.class.getSimpleName() +"验证失败:" + dParam));
    DemoResult demoResult = doBiz();
    doSomething();
    return demoResult;
}

在这个示例中,方法的第一句话就是对验证器的调用,以得到当前参数是否合法。

在参数对象中实现验证接口,为字段配置验证注解,若是须要组合验证复写 validate0 方法。这样就把合法性验证逻辑封装到了对象中。

public class DemoParam extends BaseDO implements ValidateSubject {
    @ValidateString(strMaxLength = 128)
    private String aString;
    @ValidateObject(require = true)
    private List<SubjectDO> bList;
    @ValidateString(require = true,strMaxLength = 128)
    private String cString;
    @ValidateLong(require = true)
    private Long dLong;
    @Override
    public boolean validate0(ValidateSubject validateSubject) throws ValidateException {
        if (validateSubject instanceof DemoParam) {
            DemoParam param = (DemoParam)validateSubject;
            return StringUtils.isNotBlank(param.getAString())
                   && SubjectDO.allValidate(param.getBList());
        }
        return false;
    }
}

使用断言

当出现了一个突如其来的线上问题,我相信不少伙伴的心中必定闪现过这样一个念头。"这不科学啊...这不可能发生啊…","计数器怎么可能为负数","这个对象不可为null",但它就是真实的发生了,它就在那。咱们不要这样骗本身,特别是在编码时。若是它不可能发生,用断言确保它不会发生。

使用断言的重要原则就是,断言不能有反作用,也毫不能把必须执行的代码放入断言。

断言不能有反作用,若是我每一年增长错误检查代码却制造了新的错误,那是一件使人尴尬的事情。举一个反面例子:

while (iter.next() != null) {
    assert(iter.next()!=null);
    Object next = iter.next();
    //...
}

必须执行的代码也不能放入断言,由于生产环境极可能是关闭 Java 断言的。所以我更喜欢使用 Spring 提供的 Assert 工具,这个工具提供的断言只会返回 IllegalStateException,若是须要这个异常不能知足咱们的业务需求,咱们能够从新建立一个 Assert 类并继承 org.springframework.util.Assert,在新类中新增断言方法以支持自定义异常的传入。

public class Assert extends org.springframework.util.Assert {
    public static <T extends RuntimeException> void isTrue(boolean expression, Supplier<T> tSupplier) {
        if (!expression) {
            if (tSupplier != null) {
                throw tSupplier.get();
            }
            throw new IllegalArgumentException();
        }
    }
}
Assert.isTrue(crParam.validate(),()-> new SysException("参数验证失败-" + Calculate.class.getSimpleName() +"验证失败:" + crParam));

有人认为断言仅是一种调试工具,一旦代码发布后就应该关闭断言,由于断言会增长一些开销(微小的 CPU 时间)。因此在不少工程实践中断言确实是关闭的,也有很多大 V 有过这样的建议。Dndrew Hunt 和 David Thomas 反对这样的说法,在他们书里有一个比喻我认为很形象。

在你把程序交付使用时关闭断言就像是由于你曾经成功过,就不用保护网取走钢丝。
——《The pragmatic Programmer》

处理错误时的关键选择

防护式编程会预设错误处理。

在错误发生后的后续流程上一般会有两种选择,终止程序和继续运行。

  • 终止程序,若是出现了很是严重的错误,那最好终止程序或让用户重启程序。好比,银行的 ATM 机出现了错误,那就关闭设备,以防止取 100 块吐出 10000 块的悲剧发生。
  • 继续运行,一般也是有两种选择,本地处理和抛出错误。本地处理一般会使用默认值的方式处理,抛出错误会以异常或者错误码的形式返回。

在处理错误的时候咱们还面临着另一种选择,正确性和健壮性的选择。

  • 正确性,选择正确性意味着结果永远是正确的,若是出错,宁愿不给出结果也不要给定一个不许确的值。好比用户资产类的业务。
  • 健壮性,健壮性意味着经过一些措施,保证软件可以正常运行下去,即便有时候会有一些不许确的值出现。好比产品介绍超过页面展现范围

不管是使用卫语、断言仍是预设错误处理都是在用抱着对程序世界的敬畏态度在当心的驾驶,时刻提防着他人更提防着本身。

北京第三区交通委提醒您,道路千万条,安全第一条,行车不规范,亲人两行泪。

正确使用异常

检查每个可能的错误是一种良好的实践,特别是那些意料以外的错误。

很是棒的是,Java 为咱们提供了异常机制。若是充分发挥异常的优势,能够提升程序的可读性、可靠性和可维护性,但若是使用不当,异常带来的负面影响也是很是值得咱们注意并避免的。

只在异常状况下使用异常

在《The pragmatic Programmer》和《Effective Java》中做者都有这样的观点。

我认为这有两重意思。一重意思如何处理识别异常状况并处理他,另外一重意思是只在异常状况下使用异常流程。

那什么是异常状况,又该如何处理?这个问题没法在代码模式上给出标准的答案,彻底看实际状况,要对每个错误了然于胸并检查每个可能发生的错误,并区分错误和异常。

即使一样是打开文件操做,读取"/etc/passwd"和读取一个用户上传的文件,一样是 FileNotFoundException,如何处理彻底取决于实际状况,Surprise!前者直接读取文件出现异常直接抛出让程序尽早崩溃,然后者应该先判断文件是否存在,若是存在但出现了 FileNotFoundException 则再抛出。

public static void openPasswd() throws FileNotFoundException {
        FileInputStream fs = new FileInputStream("/etc/passwd");
    }

读取"/etc/passwd"失败,Surprise!

public static boolean openUserFile(String path) throws FileNotFoundException {
        File f = new File(path);
        if (!f.exists()) {
            return false;
        }
        FileInputStream fs = new FileInputStream(path);
        return true;
    }

在文件存在的状况下读取文件失败,Surprise!

再啰嗦一遍,是否是异常状况关键在于它是否是给咱们一记 Surprise!,这就是本节开头检查每个错误是一种良好的实践想要表达的。

使用异常来控制正常流程的反面例子我就偷懒借用一下《Effective Java Second Edition》里的例子来讲明好了。

Integer[] range ={1,2,3};
//Horrible abuse of exceptions.Don't ever do this!
try {
  int i=0;
  println(range[i++].intValue());
} catch (ArrayIndexOutOfBoundsException e) {}

这个例子看起来根本不知道在干什,这段代码实际上是在用数组越界异常来控制遍历数组,这个脑洞开的很是拙劣。如何正确遍历一个数组我想不须要再给出例子,那是对读者的亵渎。

那为何有人这么开脑洞呢?由于这个作法企图使用 Java 错误判断机制来提升性能,由于 JVM 对每一次数组访问都会检查越界状况,因此他们认为检查到的错误才应该是循环终止的条件,然而 for-each 循环对已经检查到的错误视而不见,将其隐藏了,因此用应该避免使用 for-each。

对于这个脑洞的缘由 Joshua Bloch 给出了三点反驳:

  • 由于异常机制的设计初衷是用于不正常的情形,因此不多会有 JVM 实现试图对它们进行优化,使得与显示测试同样快速。
  • 把代码放在 try-catch 块中反而阻止了现代 JVM 实现原本可能要执行的某些特定优化。
  • 对数组进行遍历的标准模式并不会致使冗余的检查。有些现代的 JVM 实现会将他们优化掉。

还有一个例子是我曾经遇到的,可是因为年代久远已经找不到项目地址了。我一个朋友曾经给我看过一个 github 上的 MVC 框架项目,虽然时隔多年但令我印象深入的是这个项目使用自定义异常和异常码来控制 Dispatcher,把异常当成一种方便的结果传递方式来使用,当成 goto 来使用,这太可怕了。不过 try-catch 方式从字节码表述上来看,确实是一种 goto 的表述。这样的方式咱们最好想都不要想。

这两个例子主要就是为了说明,异常应该只用于异常的状况下;永远不该该用在正常的流程中,无论你的理由看着多么的聪明。这样作每每会弄巧成拙,使得代码可读性大大降低。

受检异常和非受检异常

曾经不止一次的见过有人提倡将系统中的受检异常都包装成非受检异常,对于这个建议我并不觉得然。由于 Java 的设计者实际上是但愿经过区分异常种类来指导咱们编程的。

Java 一共提供了三类可抛出结构 (throwable),受检异常、非受检异常(运行时异常)和错误 (error)。他们的界限我也常常傻傻的分不清,不过仍是有迹可循的。

  • 受检异常:若是指望调用者可以适当的恢复,好比 RMI 每次调用必须处理的异常,设计者是指望调用者能够重试或别的方式来尝试恢复;好比上边提到的 FileInputStream 的构造方法,会抛出 FileNotFoundException,设计者或许但愿调用者尝试从其余目录来读取该文件,使得程序能够继续执行下去。
  • 非受检异常和错误:代表是编程错误,每每属于不可恢复的情景,并且是不该该被提早捕获的,应该快速抛出到顶层处理器,好比在服务接口的基类方法中统一处理非受检异常。这种非受检异常每每也说明了在编程中违反了某些约定。好比数组越界异常,说明违反了访问数组不能越界的前提约定。

总而言之,对于可恢复的状况使用受检异常;对于程序错误使用非受检异常。所以你本身程序内部定义的异常都应该是非受检异常;在面向接口或面向二方/三方库的方法尽可能使用受检异常。

说到面向接口或面向二/三方库,你可能碰到的就是一辆失控的汽车。搞清楚你所调用的接口或者库里的异常状况也是咱们可以码出健壮代码的一个强力保证。

不要忽略异常

这个建议显而易见,但却经常被违反。当一个 API 的设计者声明一个方法将抛出异常的时候,一般都是想要说明某件事发生了。忽略异常就是咱们一般说的吃掉异常,try-catch 但什么也不作。吃掉一个异常就比如破坏了一个报警器,当灾难真正来临没人搞清楚发生了什么。

对于每个 catch 块至少打印一条日志,说明异常状况或者说明为何不处理。

这个显而易见的建议同时适用于受检异常和非受检异常。

DRY (Don't Repeat Yourself)

DRY 原则最早在《The pragmatic Programmer》被提出,现在已经被业界普遍的认知,我相信每一个软件工程师都认识它。我想有不少人对它的认识含混不清仅仅是不要有重复的代码;也有些人对此原则不屑一顾抽象什么的都是浪费时间快速上线是大义;也有人誓死捍卫这个原则不能忍受任何重复。今天咱们来谈谈这个熟悉又陌生的话题。

DRY 是什么

DRY 的原则是“系统中的每一部分,都必须有一个单一的、明确的、权威的表明”,指的是(由人编写而非机器生成的)代码和测试所构成的系统,必须可以表达所应表达的内容,可是不能含有任何重复代码。当 DRY 原则被成功应用时,一个系统中任何单个元素的修改都不须要与其逻辑无关的其余元素发生改变。此外,与之逻辑上相关的其余元素的变化均为可预见的、均匀的,并如此保持同步。

这段定义来自于中文维基百科,但这个定义彷佛与 Andrew Hunt 和 David Thomas 给出的定义有所出入。寻根溯源在《The pragmatic Programmer》做者是这样定义这个原则的:

EVERY PIECE OF KNOWLEDGE MUST HAVE A SINGLE, UNAMBIGUOUS, AUTHORITATIVE REPRESENTATION WITHIN A SYSTEM.

系统中的每一项知识都必须具备单1、无歧义、权威的表示。

做者所提倡禁止的是知识 (knowledge) 的重复而不是单纯的代码上的重复。那什么是知识呢?我斗胆给一个本身的理解,知识就是系统中对于一个逻辑的解释/定义,系统中的逻辑都是要对外输出或者让外界感知的。逻辑的定义/解释包括代码和写在代码上的文档还有宏观上实现。咱们要避免的是在改动时的一个逻辑的时候须要去修改十处,若是漏掉了任何一处就会形成 bug 甚至线上故障。变动在软件开发中又是一个常态,在互联网行业中更是如此,而在一个处处是重复的系统中维护变动是很是艰难的。

没有文档比错误的文档更好

编写代码时同时编写文档在多数程序员看来是一个好习惯,但有至关一部分程序开发人员又没有这样的习惯,这一点反而使得代码更干 (dry)——有点可笑。由于底层知识应该放在代码中,底层代码应该是职责单1、逻辑简单的代码,在底层代码上添加注释就是在作重复的事情,就有可能由于对于知识的过时解释,而读注释比读代码更容易,可怕的事情每每就这样发生;把注释放在更上层的复杂的复杂逻辑中。满篇的注释并非好代码,也不是好习惯,好的代码是不须要注释的。

CP 大法,禁止!

每一个项目都有时间压力,这每每是诱惑咱们使用 CP 大法最重要缘由。可是"欲速则不达",你也许如今省了十分钟,之后却须要花几个小时处理各类各样的线上问题。由于变动是常态,咱们当初留下的一个坑队友可能会帮你挖的更深更大一些,而后咱们就掉进了本身挖的坑,咱们还会埋怨猪队友,到底谁才是猪队友。这实际上是我带过的一个团队里真实发生的事情。

把知识的解释/定义放在一处!

PS:感觉一下程序员的冷幽默。违背 DRY 原则的代码,程序员称之为 WET 的,能够理解为 Write Everything Twice(任何东西写两遍),We Enjoying Typing(咱们享受敲键盘)或 Waste Everyone’s Time(浪费全部人的时间)。

关于 DRY 原则的争论

DRY 原则提出以来一直以来都存在着一些争议和讨论,有粉也有黑。若是有一个百分比,对于这条原则我会选择 95% 服从。

《The pragmatic Programmer》告诉咱们 Once and only once。

《Extreme Programing》又告诉咱们 You aren't gonna need it (YAGNI),指的是你自觉得有用的功能,实际上都是用不到的。这里好像出现了一个问题,DRY 与 YAGNI 不彻底兼容。DRY 要求花精力去抽象追求通用,而 YAGNI 要求快和省,你花精力作的抽象极可能用不到。

这个时候咱们的第三选择是什么?《Refactoring》提出的 Rule Of Three 像是一个很好的折中方案。它的涵义是,第一次用到某个功能时,你写一个特定的解决方法;第二次又用到的时候,你拷贝上一次的代码;第三次出现的时候,你才着手"抽象化",写出通用的解决方法。这样作有几个理由:

省事

若是一种功能只有一到两个地方会用到,就不须要在"抽象化"上面耗费时间了。

容易发现模式

"抽象化"须要找到问题的模式,问题出现的场合越多,就越容易看出模式,从而能够更准确地"抽象化"。好比,对于一个数列来讲,两个元素不足以判断出规律:

1,2,_,_,_,_

第三个元素出现后,规律就变得较清晰了:

1,2,4,_,_,_

防止过分冗余

若是一种功能同时有多个实现,管理起来很是麻烦,修改的时候须要修改多处。在实际工做中,重复实现最多能够容忍出现一次,再多就没法接受了。

我认为以上三个原则都不能当作银弹,仍是要根据实际状况作出正确的选择。

DRY 原则理论上来讲是没有问题的,但在实际应用时切忌死搬教条。它只能起指导做用,没有量化标准,不然的话理论上一个程序每一行代码都只能出现一次才行,这是很是荒谬的。

Rule of Three 不是重复的代码必定要出现三次才能够进行抽象,我认为三次不该该成为一个度量标准,对于将来的预判和对于项目走向等因素也应该放在是否抽象的考虑中。

PS:王垠曾经写过一篇《DRY 原则的危害》有兴趣的朋友能够读一读:如何评价王垠最新文章,《DRY 原则的危害》?
https://www.zhihu.com/question/31278077

后记

原则不是银弹,原则是沙漠中的绿洲亦或是沙漠中海市蜃楼中的绿洲。面对所谓的原则要求咱们每个人都有辨识能力,不盲目听从先哲大牛,要具备独立思考的能力。具有辨识和思考能力首先就须要有足够多的输入和足够多的实践。

参考
[1]《The pragmatic Programmer:From Journeyman to Master》
做者:Andrew Hunt、David Thomas
[2]《Effective Java Second Edition》
做者 :Joshua Bloch
[3]《Java 开发手册》
[4]中文维基百科
[5]代码的抽象三原则-阮一峰
http://www.ruanyifeng.com/blog/2013/01/abstraction_principles.html
相关文章
相关标签/搜索