最近一直担心如果 Dubbo 分布式服务框架维护人员增多或变更,会出现质量的下降的问题, 我在想,有没有什么规则是需要大家共同遵守的。根据平时写代码时的一习惯,总结了以下在写代码过程中,尤其是框架代码,要时刻牢记的细节。可能下面要讲的这些,大家都会觉得很简单,很基础,但要做到时刻牢记。在每一行代码中都考虑这些因素,是需要很大耐心的, 大家经常说,魔鬼在细节中,确实如此。
这是我最不喜欢看到的异常,尤其在核心框架中,我更愿看到信息详细的参数不合法异常。这也是一个编写健壮程序的开发人员,在写每一行代码都应在潜意识中防止的异常。基本上要能确保每一次写完的代码,在不测试的情况下,都不会出现这两个异常才算合格。
对于框架的开发人员,对线程安全性和可见性的深入理解是最基本的要求。需要开发人员,在写每一行代码时都应在潜意识中确保其正确性。因为这种代码,在小并发下做功能测试时,会显得很正常。但在高并发下就会出现莫明其妙的问题,而且场景很难重现,极难排查。
尽早失败也应该成为潜意识,在有传入参数和状态变化时,均在入口处全部断言。一个不合法的值和状态,在第一时间就应报错,而不是等到要用时才报错。因为等到要用时,可能前面已经修改其它相关状态,而在程序中很少有人去处理回滚逻辑。这样报错后,其实内部状态可能已经混乱,极易在一个隐蔽分支上引发程序不可恢复。
这里的可靠是狭义的指是否会抛出异常或引起状态不一致,比如,写入一个线程安全的 Map,可以认为是可靠的,而写入数据库等,可以认为是不可靠的。开发人员必须在写每一行代码时,都注意它的可靠性与否,在代码中尽量划分开,并对失败做异常处理,并为容错,自我保护,自动恢复或切换等补偿逻辑提供清晰的切入点,保证后续增加的代码不至于放错位置,而导致原先的容错处理陷入混乱。
这里讲的异常防御,指的是对非必须途径上的代码进行最大限度的容忍,包括程序上的 BUG,比如:获取程序的版本号,会通过扫描 Manifest 和 jar 包名称抓取版本号,这个逻辑是辅助性的,但代码却不少,初步测试也没啥问题,但应该在整个 getVersion() 中加上一个全函数的 try-catch 打印错误日志,并返回基本版本,因为 getVersion() 可能存在未知特定场景异常,或被其他的开发人员误修改逻辑(但一般人员不会去掉 try-catch),而如果它抛出异常会导致主流程异常,这是我们不希望看到的。但这里要控制个度,不要随意 try-catch,更不要无声无息的吃掉异常。
如果一个类可以成为不变类(Immutable Class),就优先将它设计成不变类。不变类有天然的并发共享优势,减少同步或复制,而且可以有效帮忙分析线程安全的范围。就算是可变类,对于从构造函数传入的引用,在类中持有时,最好将字段 final,以免被中途误修改引用。不要以为这个字段是私有的,这个类的代码都是我自己写的,不会出现对这个字段的重新赋值。要考虑的一个因素是,这个代码可能被其他人修改,他不知道你的这个弱约定,final 就是一个不变契约。
前面不停的提到代码被其他人修改,这也开发人员要随时紧记的。这个其他人包括未来的自己,你要总想着这个代码可能会有人去改它。我应该给修改的人一点什么提示,让他知道我现在的设计意图,而不要在程序里面加潜规则,或埋一些容易忽视的雷,比如:你用 null 表示不可用,size 等于 0 表示黑名单,这就是一个雷,下一个修改者,包括你自己,都不会记得有这样的约定,可能后面为了改某个其它 BUG,不小心改到了这里,直接引爆故障。对于这个例子,一个原则就是永远不要区分 null 引用和 empty 值。
这里的可测性主要指 Mock 的容易程度,和测试的隔离性。至于测试的自动性,可重复性,非偶然性,无序性,完备性(全覆盖),轻量性(可快速执行),一般开发人员,加上 JUnit 等工具的辅助基本都能做到,也能理解它的好处,只是工作量问题。这里要特别强调的是测试用例的单一性(只测目标类本身)和隔离性(不传染失败)。现在的测试代码,过于强调完备性,大量重复交叉测试,看起来没啥坏处,但测试代码越多,维护代价越高。经常出现的问题是,修改一行代码或加一个判断条件,引起 100 多个测试用例不通过。时间一紧,谁有这个闲功夫去改这么多形态各异的测试用例?久而久之,这个测试代码就已经不能真实反应代码现在的状况,很多时候会被迫绕过。最好的情况是,修改一行代码,有且只有一行测试代码不通过。如果修改了代码而测试用例还能通过,那也不行,表示测试没有覆盖到。另外,可 Mock 性是隔离的基础,把间接依赖的逻辑屏蔽掉。可 Mock 性的一个最大的杀手就是静态方法,尽量少用。