设计模式之美总结(重构篇)


title: 设计模式之美总结(重构篇)
date: 2022-10-27 17:31:42
tags:

  • 设计模式
    categories:
  • 设计模式
    cover: https://cover.png
    feature: false

文章目录


前两篇见:

1. 概述

1.1 重构的目的:为什么要重构(why)?

软件设计大师 Martin Fowler 是这样定义重构的:“重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。”

实际上,当讲到重构的时候,很多书籍都会引用这个定义。这个定义中有一个值得强调的点:“重构不改变外部的可见行为”。可以把重构理解为,在保持功能不变的前提下,利用设计思想、原则、模式、编程规范等理论来优化代码,修改设计上的不足,提高代码质量

为什么要进行代码重构?

1、重构是时刻保证代码质量的一个极其有效的手段

项目在演进,代码不停地在堆砌。如果没有人为代码的质量负责任,代码总是会往越来越混乱的方向演进。当混乱到一定程度之后,量变引起质变,项目的维护成本已经高过重新开发一套新代码的成本,想要再去重构,已经没有人能做到了

优秀的代码或架构也不是一开始就能完全设计好的,就像优秀的公司和产品也都是迭代出来的。我们无法 100% 预见未来的需求,也没有足够的精力、时间、资源为遥远的未来买单,所以,随着系统的演进,重构代码也是不可避免的

2、重构是避免过度设计的有效手段

在维护代码的过程中,真正遇到问题的时候,再对代码进行重构,能有效避免前期投入太多时间做过度的设计,做到有的放矢

3、重构对一个工程师本身技术的成长也有重要的意义

重构实际上是对学习的经典设计思想、设计原则、设计模式、编程规范的一种应用。重构实际上就是将这些理论知识,应用到实践的一个很好的场景,能够锻炼我们熟练使用这些理论知识的能力

除此之外,重构能力也是衡量一个工程师代码能力的有效手段。所谓“初级工程师在维护代码,高级工程师在设计代码,资深工程师在重构代码”,这句话的意思是说,初级工程师在已有代码框架下修改 bug、修改添加功能代码;高级工程师从零开始设计代码结构、搭建代码框架;而资深工程师为代码质量负责,需要发觉代码存在的问题,重构代码,时刻保证代码质量处于一个可控的状态(当然这里的初级、高级、资深只是一个相对概念,并不是一个确定的职级)

1.2 重构的对象:到底重构什么(what)?

根据重构的规模,可以笼统地分为大规模高层次重构(以下简称为“大型重构”)和小规模低层次的重构(以下简称为“小型重构”)

大型重构指的是对顶层代码设计的重构,包括:系统、模块、代码结构、类与类之间的关系等的重构,重构的手段有:分层、模块化、解耦、抽象可复用组件等等。这类重构的工具就是学习过的那些设计思想、原则和模式。这类重构涉及的代码改动会比较多,影响面会比较大,所以难度也较大,耗时会比较长,引入 bug 的风险也会相对比较大

小型重构指的是对代码细节的重构,主要是针对类、函数、变量等代码级别的重构,比如规范命名、规范注释、消除超大类或函数、提取重复代码等等。小型重构更多的是利用编码规范。这类重构要修改的地方比较集中,比较简单,可操作性较强,耗时会比较短,引入 bug 的风险相对来说也会比较小

1.3 重构的时机:什么时候重构(when)?

搞清楚了为什么重构,到底重构什么,再来看一下,什么时候重构?是代码烂到一定程度之后才去重构吗?当然不是。因为当代码真的烂到出现“开发效率低,招了很多人,天天加班,出活却不多,线上 bug 频发,领导发飙,中层束手无策,工程师抱怨不断,查找 bug 困难”的时候,基本上重构也无法解决问题了

个人比较反对,平时不注重代码质量,堆砌烂代码,实在维护不了了就大刀阔斧地重构、甚至重写的行为。有时候项目代码太多了,重构很难做得彻底,最后又搞出来一个“四不像的怪物”,这就更麻烦了!所以,寄希望于在代码烂到一定程度之后,集中重构解决所有问题是不现实的,必须探索一条可持续、可演进的方式

提倡的重构策略是持续重构。平时没有事情的时候,可以看看项目中有哪些写得不够好的、可以优化的代码,主动去重构一下。或者,在修改、添加某个功能代码的时候,也可以顺手把不符合编码规范、不好的设计重
构一下。总之,就像把单元测试、Code Review 作为开发的一部分,如果能把持续重构也作为开发的一部分,成为一种开发习惯,对项目、对自己都会很有好处

尽管说重构能力很重要,但持续重构意识更重要。要正确地看待代码质量和重构这件事情。技术在更新、需求在变化、人员在流动,代码质量总会在下降,代码总会存在不完美,重构就会持续在进行。时刻具有持续重构意识,才能避免开发初期就过度设计,避免代码维护的过程中质量的下降

1.4 重构的方法:又该如何重构(how)?

按照重构的规模,重构可以笼统地分为大型重构和小型重构。对于这两种不同规模的重构,要区别对待:

对于大型重构来说,因为涉及的模块、代码会比较多,如果项目代码质量又比较差,耦合比较严重,往往会牵一发而动全身,本来觉得一天就能完成的重构,会发现越改越多、越改越乱,没一两个礼拜都搞不定。而新的业务开发又与重构相冲突,最后只能半途而废,revert 掉所有的改动,很失落地又去堆砌烂代码了

在进行大型重构的时候,要提前做好完善的重构计划,有条不紊地分阶段来进行。每个阶段完成一小部分代码的重构,然后提交、测试、运行,发现没有问题之后,再继续进行下一阶段的重构,保证代码仓库中的代码一直处于可运行、逻辑正确的状态。每个阶段,都要控制好重构影响到的代码范围,考虑好如何兼容老的代码逻辑,必要的时候还需要写一些兼容过渡代码。只有这样,才能让每一阶段的重构都不至于耗时太长(最好一天就能完成),不至于与新的功能开发相冲突

大规模高层次的重构一定是有组织、有计划,并且非常谨慎的,需要有经验、熟悉业务的资深同事来主导。而小规模低层次的重构,因为影响范围小,改动耗时短,所以,只要愿意并且有时间,随时都可以去做。实际上,除了人工去发现低层次的质量问题,还可以借助很多成熟的静态代码分析工具(比如 CheckStyle、FindBugs、PMD),来自动发现代码中的问题,然后针对性地进行重构优化

对于重构这件事情,资深的工程师、项目 leader 要负起责任来,没事就重构一下代码,时刻保证代码质量处在一个良好的状态。否则,一旦出现“破窗效应”,一个人往里堆了一些烂代码,之后就会有更多的人往里堆更烂的代码。毕竟往项目里堆砌烂代码的成本太低了。不过,保持代码质量最好的方法还是打造一种好的技术氛围,以此来驱动大家主动去关注代码质量,持续重构代码

2. 单元测试(Unit Testing)

很多程序员对重构这种做法还是非常认同的,面对项目中的烂代码,也想重构一下,但又担心重构之后出问题,出力不讨好。确实,如果要重构的代码是别的同事开发的,不是特别熟悉,在没有任何保障的情况下,重构引入 bug 的风险还是很大的

那如何保证重构不出错呢?需要熟练掌握各种设计原则、思想、模式,还需要对所重构的业务和代码有足够的了解。除了这些个人能力因素之外,最可落地执行、最有效的保证重构不出错的手段应该就是**单元测试(Unit Testing)**了。当重构完成之后,如果新的代码仍然能通过单元测试,那就说明代码原有逻辑的正确性未被破坏,原有的外部可见行为未变,符合重构的定义

2.1 什么是单元测试?

单元测试由研发工程师自己来编写,用来测试自己写的代码的正确性。常常将它跟集成测试放到一块来对比。单元测试相对于集成测试(Integration Testing)来说,测试的粒度更小一些。集成测试的测试对象是整个系统或者某个功能模块,比如测试用户注册、登录功能是否正常,是一种端到端(end to end)的测试。而单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试。如下例:

public class Text {
    private String content;
    public Text(String content) {
        this.content = content;
    }
    /**
     * 将字符串转化成数字,忽略字符串中的首尾空格;
     * 如果字符串中包含除首尾空格之外的非数字字符,则返回 null。
     */
    public Integer toNumber() {
        if (content == null || content.isEmpty()) {
            return null;
        }
        //... 省略代码实现...
        return null;
    }
}

要测试 Text 类中的 toNumber() 函数的正确性,应该如何编写单元测试呢?

实际上,写单元测试本身不需要什么高深技术。它更多的是考验程序员思维的缜密程度,看能否设计出覆盖各种正常及异常情况的测试用例,来保证代码在任何预期或非预期的情况下都能正确运行

为了保证测试的全面性,针对 toNumber() 函数,需要设计下面这样几个测试用例:

  • 如果字符串只包含数字:“123”,toNumber() 函数输出对应的整数:123
  • 如果字符串是空或者 null,toNumber() 函数返回:null
  • 如果字符串包含首尾空格:“ 123”,“123 ”,“ 123 ”,toNumber() 返回对应的整数:123
  • 如果字符串包含多个首尾空格:“ 123 ”,toNumber() 返回对应的整数:123
  • 如果字符串包含非数字字符:“123a4”,“123 4”,toNumber() 返回 null

当设计好测试用例之后,剩下的就是将其翻译成代码了。翻译成代码的过程非常简单,如下:(这里没有使用任何测试框架)

public class Assert {
    public static void assertEquals(Integer expectedValue, Integer actualValue) {
        if (actualValue != expectedValue) {
            String message = String.format(
                    "Test failed, expected: %d, actual: %d.", expectedValue, actualVa
                    System.out.println(message);
        } else {
            System.out.println("Test succeeded.");
        }
    }
    public static boolean assertNull(Integer actualValue) {
        boolean isNull = actualValue == null;
        if (isNull) {
            System.out.println("Test succeeded.");
        } else {
            System.out.println("Test failed, the value is not null:" + actualValue);
        }
        return isNull;
    }
}
public class TestCaseRunner {
    public static void main(String[] args) {
        System.out.println("Run testToNumber()");
        new TextTest().testToNumber();
        System.out.println("Run testToNumber_nullorEmpty()");
        new TextTest().testToNumber_nullorEmpty();
        System.out.println("Run testToNumber_containsLeadingAndTrailingSpaces()");
        new TextTest().testToNumber_containsLeadingAndTrailingSpaces();
        System.out.println("Run testToNumber_containsMultiLeadingAndTrailingSpaces
        new TextTest().testToNumber_containsMultiLeadingAndTrailingSpaces();
        System.out.println("Run testToNumber_containsInvalidCharaters()");
        new TextTest().testToNumber_containsInvalidCharaters();
    }
}

public class TextTest {
    public void testToNumber() {
        Text text = new Text("123");
        Assert.assertEquals(123, text.toNumber());
    }
    public void testToNumber_nullorEmpty() {
        Text text1 = new Text(null);
        Assert.assertNull(text1.toNumber());
        Text text2 = new Text("");
        Assert.assertNull(text2.toNumber());
    }
    public void testToNumber_containsLeadingAndTrailingSpaces() {
        Text text1 = new Text(" 123");
        Assert.assertEquals(123, text1.toNumber());
        Text text2 = new Text("123 ");
        Assert.assertEquals(123, text2.toNumber());
        Text text3 = new Text(" 123 ");
        Assert.assertEquals(123, text3.toNumber());
    }
    public void testToNumber_containsMultiLeadingAndTrailingSpaces() {
        Text text1 = new Text(" 123");
        Assert.assertEquals(123, text1.toNumber());
        Text text2 = new Text("123 ");
        Assert.assertEquals(123, text2.toNumber());
        Text text3 = new Text(" 123 ");
        Assert.assertEquals(123, text3.toNumber());
    }
    public void testToNumber_containsInvalidCharaters() {
        Text text1 = new Text("123a4");
        Assert.assertNull(text1.toNumber());
        Text text2 = new Text("123 4");
        Assert.assertNull(text2.toNumber());
    }
}

2.2 为什么要写单元测试?

单元测试除了能有效地为重构保驾护航之外,也是保证代码质量最有效的两个手段之一(另一个是 Code Review)。单元测试的好处如下:

  1. 单元测试能有效地发现代码中的 bug
    能否写出 bug free 的代码,是判断工程师编码能力的重要标准之一,通过单元测试能发现代码中的很多考虑不全面的地方
  2. 写单元测试能发现代码设计上的问题
    代码的可测试性是评判代码质量的一个重要标准。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很吃力,需要依靠单元测试框架里很高级的特性才能完成,那往往就意味着代码设计得不够合理,比如,没有使用依赖注入、大量使用静态函数、全局变量、代码高度耦合等
  3. 单元测试是对集成测试的有力补充
    程序运行的 bug 往往出现在一些边界条件、异常情况下,比如,除数未判空、网络超时。而大部分异常情况都比较难在测试环境中模拟。而单元测试可以利用 mock 的方式,控制 mock 的对象返回需要模拟的异常,来测试代码在这些异常情况的表现
    除此之外,对于一些复杂系统来说,集成测试也无法覆盖得很全面。复杂系统往往有很多模块。每个模块都有各种输入、输出、异常情况,组合起来,整个系统就有无数测试场景需要模拟,无数的测试用例需要设计,再强大的测试团队也无法穷举完备
    尽管单元测试无法完全替代集成测试,但如果能保证每个类、每个函数都能按照预期来执行,底层 bug 少了,那组装起来的整个系统,出问题的概率也就相应减少了
  4. 写单元测试的过程本身就是代码重构的过程
    写单元测试实际上就是落地执行持续重构的一个有效途径。设计和实现代码的时候,很难把所有的问题都想清楚。而编写单元测试就相当于对代码的一次自我 Code Review,在这个过程中,可以发现一些设计上的问题(比如代码设计的不可测试)以及代码编写方面的问题(比如一些边界条件处理不当)等,然后针对性的进行重构
  5. 阅读单元测试能快速熟悉代码
    读代码最有效的手段,就是先了解它的业务背景和设计思路,然后再去看代码,这样代码读起来就会轻松很多。但程序员都不怎么喜欢写文档和注释,而大部分程序员写的代码又很难做到“不言自明”。在没有文档和注释的情况下,单元测试就起了替代性作用。单元测试用例实际上就是用户用例,反映了代码的功能和如何使用。借助单元测试,不需要深入的阅读代码,便能知道代码实现了什么功能,有哪些特殊情况需要考虑,有哪些边界条件需要处理
  6. 单元测试是 TDD 可落地执行的改进方案
    测试驱动开发(Test-Driven Development,简称 TDD)是一个经常被提及但很少被执行的开发模式。它的核心指导思想就是测试用例先于代码编写。不过,要让程序员能彻底地接受和习惯这种开发模式还是挺难的,毕竟很多程序员连单元测试都懒得写,更何况在编写代码之前先写好测试用例了
    单元测试正好是对 TDD 的一种改进方案,先写代码,紧接着写单元测试,最后根据单元测试反馈出来问题,再回过头去重构代码。这个开发流程更加容易被接受,更加容易落地执行,而且又兼顾了 TDD 的优点

2.3 如何编写单元测试?

写单元测试就是针对代码设计覆盖各种输入、异常、边界条件的测试用例,并将这些测试用例翻译成代码的过程

在把测试用例翻译成代码的时候,可以利用单元测试框架,来简化测试代码的编写。比如,Java 中比较出名的单元测试框架有 Junit、TestNG、Spring Test 等。这些框架提供了通用的执行流程(比如执行测试用例的 TestCaseRunner)和工具类库(比如各种Assert 判断函数)等。借助它们,在编写测试代码的时候,只需要关注测试用例本身的编写即可,使用测试框架实现如下:

import org.junit.Assert;
import org.junit.Test;

public class TextTest {
    @Test
    public void testToNumber() {
        Text text = new Text("123");
        Assert.assertEquals(new Integer(123), text.toNumber());
    }
    @Test
    public void testToNumber_nullorEmpty() {
        Text text1 = new Text(null);
        Assert.assertNull(text1.toNumber());
        Text text2 = new Text("");
        Assert.assertNull(text2.toNumber());
    }
    @Test
    public void testToNumber_containsLeadingAndTrailingSpaces() {
        Text text1 = new Text(" 123");
        Assert.assertEquals(new Integer(123), text1.toNumber());
        Text text2 = new Text("123 ");
        Assert.assertEquals(new Integer(123), text2.toNumber());
        Text text3 = new Text(" 123 ");
        Assert.assertEquals(new Integer(123), text3.toNumber());
    }
    @Test
    public void testToNumber_containsMultiLeadingAndTrailingSpaces() {
        Text text1 = new Text(" 123");
        Assert.assertEquals(new Integer(123), text1.toNumber());
        Text text2 = new Text("123 ");
        Assert.assertEquals(new Integer(123), text2.toNumber());
        Text text3 = new Text(" 123 ");
        Assert.assertEquals(new Integer(123), text3.toNumber());
    }
    @Test
    public void testToNumber_containsInvalidCharaters() {
        Text text1 = new Text("123a4");
        Assert.assertNull(text1.toNumber());
        Text text2 = new Text("123 4");
        Assert.assertNull(text2.toNumber());
    }
}

2.4 总结

2.4.1 写单元测试真的是件很耗时的事情吗?

尽管单元测试的代码量可能是被测代码本身的 1~2 倍,写的过程很繁琐,但并不是很耗时。毕竟不需要考虑太多代码设计上的问题,测试代码实现起来也比较简单。不同测试用例之间的代码差别可能并不是很大,简单 copy-paste 改改就行

2.4.2 对单元测试的代码质量有什么要求吗?

单元测试毕竟不会在产线上运行,而且每个类的测试代码也比较独立,基本不互相依赖。所以,相对于被测代码,对单元测试代码的质量可以放低一些要求。命名稍微有些不规范,代码稍微有些重复,也都是没有问题的

2.4.3 单元测试只要覆盖率高就够了吗?

单元测试覆盖率是比较容易量化的指标,常常作为单元测试写得好坏的评判标准。有很多现成的工具专门用来做覆盖率统计,比如,JaCoCo、Cobertura、Emma、Clover。覆盖率的计算方式有很多种,比较简单的是语句覆盖,稍微高级点的有:条件覆盖、判定覆盖、路径覆盖

不管覆盖率的计算方式如何高级,将覆盖率作为衡量单元测试质量的唯一标准是不合理的。实际上,更重要的是要看测试用例是否覆盖了所有可能的情况,特别是一些 corner case。如下例:

public double cal(double a, double b) {
    if (b != 0) {
        return a / b;
    }
}

像上面这段代码,只需要一个测试用例就可以做到 100% 覆盖率,比如 cal(10.0, 2.0),但并不代表测试足够全面了,还需要考虑,当除数等于 0 的情况下,代码执行是否符合预期

实际上,过度关注单元测试的覆盖率会导致开发人员为了提高覆盖率,写很多没有必要的测试代码,比如 get、set 方法非常简单,没有必要测试。从过往的经验上来讲,一个项目的单元测试覆盖率在 60~70% 即可上线。如果项目对代码质量要求比较高,可以适当提高单元测试覆盖率的要求

2.4.4 写单元测试需要了解代码的实现逻辑吗?

单元测试不要依赖被测试函数的具体实现逻辑,它只关心被测函数实现了什么功能。切不可为了追求覆盖率,逐行阅读代码,然后针对实现逻辑编写单元测试。否则,一旦对代码进行重构,在代码的外部行为不变的情况下,对代码的实现逻辑进行了修改,那原本的单元测试都会运行失败,也就起不到为重构保驾护航的作用了,也违背了写单元测试的初衷

2.4.5 如何选择单元测试框架?

写单元测试本身不需要太复杂的技术,大部分单元测试框架都能满足。在公司内部,起码团队内部需要统一单元测试框架。如果自己写的代码用已经选定的单元测试框架无法测试,那多半是代码写得不够好,代码的可测试性不够好。这个时候,要重构自己的代码,让其更容易测试,而不是去找另一个更加高级的单元测试框架

2.4.6 单元测试为何难落地执行?

虽然很多书籍中都会讲到,单元测试是保证重构不出错的有效手段;也有非常多人已经认识到单元测试的重要性。但是有多少项目有完善的、高质量的单元测试呢?非常非常少

写单元测试确实是一件考验耐心的活儿。一般情况下,单元测试的代码量要大于被测试代码量,甚至是要多出好几倍。很多人往往会觉得写单元测试比较繁琐,并且没有太多挑战,而不愿意去做。有很多团队和项目在刚开始推行单元测试的时候,还比较认真,执行得比较好。但当开发任务紧了之后,就开始放低对单元测试的要求,一旦出现破窗效应,慢慢的,大家就都不写了,这种情况很常见

还有一种情况就是,由于历史遗留问题,原来的代码都没有写单元测试,代码已经堆砌了十几万行了,不可能再一个一个去补单元测试。这种情况下,首先要保证新写的代码都要有单元测试,其次,每次在改动到某个类时,如果没有单元测试就顺便补上,不过这要求工程师们有足够强的主人翁意识(ownership),毕竟光靠 leader 督促,很多事情是很难执行到位的

除此之外,还有人觉得,有了测试团队,写单元测试就是浪费时间,没有必要。程序员这一行业本该是智力密集型的,但现在很多公司把它搞成劳动密集型的,包括一些大厂,在开发过程中,既没有单元测试,也没有 Code Review 流程。即便有,做的也是差强人意。写好代码直接提交,然后丢给黑盒测试狠命去测,测出问题就反馈给开发团队再修改,测不出的问题就留在线上出了问题再修复

在这样的开发模式下,团队往往觉得没有必要写单元测试,但如果把单元测试写好、做好 Code Review,重视起代码质量,其实可以很大程度上减少黑盒测试的投入

3. 代码的可测试性

3.1 如何写出可测试性好的代码?

如下,Transaction 是经过抽象简化之后的一个电商系统的交易类,用来记录每笔订单交易的情况。Transaction 类中的 execute() 函数负责执行转账操作,将钱从买家的钱包转到卖家的钱包中。真正的转账操作是通过调用 WalletRpcService RPC 服务来完成的。除此之外,代码中还涉及一个分布式锁 DistributedLock 单例类,用来避免 Transaction 并发执行,导致用户的钱被重复转出

public class Transaction {
    private String id;
    private Long buyerId;
    private Long sellerId;
    private Long productId;
    private String orderId;
    private Long createTimestamp;
    private Double amount;
    private STATUS status;
    private String walletTransactionId;

    // ...get() methods...
    public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long p) {
        if (preAssignedId != null && !preAssignedId.isEmpty()) {
            this.id = preAssignedId;
        } else {
            this.id = IdGenerator.generateTransactionId();
        }
        if (!this.id.startWith("t_")) {
            this.id = "t_" + preAssignedId;
        }
        this.buyerId = buyerId;
        this.sellerId = sellerId;
        this.productId = productId;
        this.orderId = orderId;
        this.status = STATUS.TO_BE_EXECUTD;
        this.createTimestamp = System.currentTimestamp();
    }

    public boolean execute() throws InvalidTransactionException {
        if ((buyerId == null || (sellerId == null || amount < 0.0) {
            throw new InvalidTransactionException(...);
        }
        if (status == STATUS.EXECUTED) return true;
        boolean isLocked = false;
        try {
            isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(id)
            if (!isLocked) {
                return false; // 锁定未成功,返回 false,job 兜底执行
            }
            if (status == STATUS.EXECUTED) return true; // double check
            long executionInvokedTimestamp = System.currentTimestamp();
            if (executionInvokedTimestamp - createdTimestap > 14d ays){
                this.status = STATUS.EXPIRED;
                return false;
            }
            WalletRpcService walletRpcService = new WalletRpcService();
            String walletTransactionId = walletRpcService.moveMoney(id, buyerId, sell);
            if (walletTransactionId != null) {
                this.walletTransactionId = walletTransactionId;
                this.status = STATUS.EXECUTED;
                return true;
            } else {
                this.status = STATUS.FAILED;
                return false;
            }
        } finally {
            if (isLocked) {
                RedisDistributedLock.getSingletonIntance().unlockTransction(id);
            }
        }
    }
}

对比前面的 Text 类的代码,这段代码要复杂很多。如果给这段代码编写单元测试,该如何来写呢?

在 Transaction 类中,主要逻辑集中在 execute() 函数中,所以它是测试的重点对象。为了尽可能全面覆盖各种正常和异常情况,针对这个函数,设计了下面 6 个测试用例:

  1. 正常情况下,交易执行成功,回填用于对账(交易与钱包的交易流水)用的 walletTransactionId,交易状态设置为 EXECUTED,函数返回 true
  2. buyerId、sellerId 为 null、amount 小于 0,返回 InvalidTransactionException
  3. 交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回 false
  4. 交易已经执行了(status==EXECUTED),不再重复执行转钱逻辑,返回 true
  5. 钱包(WalletRpcService)转钱失败,交易状态设置为 FAILED,函数返回 false
  6. 交易正在执行着,不会被重复执行,函数直接返回 false

测试用例设计完了。现在看起来似乎一切进展顺利。但是,事实是,当将测试用例落实到具体的代码实现时,就会发现有很多行不通的地方。对于上面的测试用例,第 2 个实现起来非常简单,重点来看其中的 1 和 3。测试用例 4、5、6 跟 3类似

测试用例 1 的代码实现。具体如下所示:

public void testExecute() {
        Long buyerId = 123L;
        Long sellerId = 234L;
        Long productId = 345L;
        Long orderId = 456L;

        Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
        boolean executedResult = transaction.execute();
        assertTrue(executedResult);
}

execute() 函数的执行依赖两个外部的服务,一个是 RedisDistributedLock,一个 WalletRpcService。这就导致上面的单元测试代码存在下面几个问题:

  • 如果要让这个单元测试能够运行,需要搭建 Redis 服务和 Wallet RPC 服务。搭建和维护的成本比较高
  • 还需要保证将伪造的 transaction 数据发送给 Wallet RPC 服务之后,能够正确返回期望的结果,然而 Wallet RPC 服务有可能是第三方(另一个团队开发维护的)的服务,并不是可控的。换句话说,并不是想让它返回什么数据就返回什么
  • Transaction 的执行跟 Redis、RPC 服务通信,需要走网络,耗时可能会比较长,对单元测试本身的执行性能也会有影响
  • 网络的中断、超时、Redis、RPC 服务的不可用,都会影响单元测试的执行

回到单元测试的定义上来看一下。单元测试主要是测试程序员自己编写的代码逻辑的正确性,并非是端到端的集成测试,它不需要测试所依赖的外部系统(分布式锁、Wallet RPC 服务)的逻辑正确性。所以,如果代码中依赖了外部系统或者不可控组件,比如,需要依赖数据库、网络通信、文件系统等,那就需要将被测代码与外部系统解依赖,而这种解依赖的方法就叫作“mock”。所谓的 mock 就是用一个“假”的服务替换真正的服
务。mock 的服务完全在我们的控制之下,模拟输出我们想要的数据

那如何来 mock 服务呢?mock 的方式主要有两种,手动 mock 和利用框架 mock。利用框架 mock 仅仅是为了简化代码编写,每个框架的 mock 方式都不大一样。这里只展示手动 mock

通过继承 WalletRpcService 类,并且重写其中的 moveMoney() 函数的方式来实现 mock。具体的代码实现如下所示。通过 mock 的方式,可以让 moveMoney() 返回任意我们想要的数据,完全在我们的控制范围内,并且不需要真正进行网络通信

public class MockWalletRpcServiceOne extends WalletRpcService {
    public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amoun) {
        return "123bac";
    }
}

public class MockWalletRpcServiceTwo extends WalletRpcService {
    public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amoun) {
        return null;
    }
}

现在再来看,如何用 MockWalletRpcServiceOne、MockWalletRpcServiceTwo 来替换代码中的真正的 WalletRpcService 呢?

因为 WalletRpcService 是在 execute() 函数中通过 new 的方式创建的,无法动态地对其进行替换。也就是说,Transaction 类中的 execute() 方法的可测试性很差,需要通过重构来让其变得更容易测试。该如何重构这段代码呢?

依赖注入是实现代码可测试性的最有效的手段。可以应用依赖注入,将 WalletRpcService 对象的创建反转给上层逻辑,在外部创建好之后,再注入到 Transaction 类中。重构之后的 Transaction 类的代码如下所示:

public class Transaction {
    //...
    // 添加一个成员变量及其 set 方法
    private WalletRpcService walletRpcService;
    public void setWalletRpcService(WalletRpcService walletRpcService) {
        this.walletRpcService = walletRpcService;
    }
    // ...
    public boolean execute() {
       // ...
       // 删除下面这一行代码
       // WalletRpcService walletRpcService = new WalletRpcService();
       // ...
    }
}

现在,就可以在单元测试中,非常容易地将 WalletRpcService 替换成 MockWalletRpcServiceOne 或 WalletRpcServiceTwo 了。重构之后的代码对应的单元测试如下所示:

public void testExecute() {
        Long buyerId = 123L;
        Long sellerId = 234L;
        Long productId = 345L;
        Long orderId = 456L;

        Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
        // 使用 mock 对象来替代真正的 RPC 服务
        transaction.setWalletRpcService(new MockWalletRpcServiceOne()):
        boolean executedResult = transaction.execute();
        assertTrue(executedResult);
        assertEquals(STATUS.EXECUTED, transaction.getStatus());
}

WalletRpcService 的 mock 和替换问题解决了,再来看 RedisDistributedLock。它的 mock 和替换要复杂一些,主要是因为 RedisDistributedLock 是一个单例类。单例相当于一个全局变量,无法 mock(无法继承和重写方法),也无法通过依赖注入的方式来替换

如果 RedisDistributedLock 是我们自己维护的,可以自由修改、重构,那我们可以将其改为非单例的模式,或者定义一个接口,比如 IDistributedLock,让 RedisDistributedLock 实现这个接口。这样就可以像前面 WalletRpcService 的替换方式那样,替换 RedisDistributedLock 为 MockRedisDistributedLock 了。但如果
RedisDistributedLock 不是我们维护的,我们无权去修改这部分代码,这个时候该怎么办呢?

可以对 transaction 上锁这部分逻辑重新封装一下。具体代码实现如下所示:

public class TransactionLock {
    public boolean lock(String id) {
        return RedisDistributedLock.getSingletonIntance().lockTransction(id);
    }
    public void unlock() {
        RedisDistributedLock.getSingletonIntance().unlockTransction(id);
    }
}

public class Transaction {
    //...
    private TransactionLock lock;
    public void setTransactionLock(TransactionLock lock) {
        this.lock = lock;
    }
    public boolean execute() {
        //...
        try {
            isLocked = lock.lock();
        //...
        } finally {
            if (isLocked) {
                lock.unlock();
            }
        }
        //...
    }
}

针对重构过的代码,单元测试代码修改为下面这个样子。这样,就能在单元测试代码中隔离真正的 RedisDistributedLock 分布式锁这部分逻辑了

public void testExecute() {
    Long buyerId = 123L;
    Long sellerId = 234L;
    Long productId = 345L;
    Long orderId = 456L;

    TransactionLock mockLock = new TransactionLock() {
        public boolean lock(String id) {
            return true;
        }
        public void unlock() {}
    };
    Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
    transaction.setWalletRpcService(new MockWalletRpcServiceOne());
    transaction.setTransactionLock(mockLock);
    boolean executedResult = transaction.execute();
    assertTrue(executedResult);
    assertEquals(STATUS.EXECUTED, transaction.getStatus());
}

至此,测试用例 1 就算写好了。通过依赖注入和 mock,让单元测试代码不依赖任何不可控的外部服务

现在,再来看测试用例 3:交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回 false。针对这个单元测试用例,还是先把代码写出来,然后再来分析

public void testExecute_with_TransactionIsExpired() {
        Long buyerId = 123L;
        Long sellerId = 234L;
        Long productId = 345L;
        Long orderId = 456L;

        Transction transaction = new Transaction(null, buyerId, sellerId, productId,orderId);
        transaction.setCreatedTimestamp(System.currentTimestamp() - 14days);
        boolean actualResult = transaction.execute();
        assertFalse(actualResult);
        assertEquals(STATUS.EXPIRED, transaction.getStatus());
}

上面的代码看似没有任何问题。将 transaction 的创建时间 createdTimestamp 设置为 14 天前,也就是说,当单元测试代码运行的时候,transaction 一定是处于过期状态。但是,如果在 Transaction 类中,并没有暴露修改 createdTimestamp 成员变量的 set 方法(也就是没有定义 setCreatedTimestamp() 函数)呢?

这时可能会说,如果没有 createTimestamp 的 set 方法,就重新添加一个呗!实际上,这违反了类的封装特性。在 Transaction 类的设计中,createTimestamp 是在交易生成时(也就是构造函数中)自动获取的系统时间,本来就不应该人为地轻易修改,所以,暴露 createTimestamp 的 set 方法,虽然带来了灵活性,但也带来了不可控性。因为,无法控制使用者是否会调用 set 方法重设 createTimestamp,而重设 createTimestamp 并非我们的预期行为

那如果没有针对 createTimestamp 的 set 方法,那测试用例 3 又该如何实现呢?实际上,这是一类比较常见的问题,就是代码中包含跟“时间”有关的“未决行为”逻辑。一般的处理方式是将这种未决行为逻辑重新封装。针对 Transaction 类,只需要将交易是否过期的逻辑,封装到 isExpired() 函数中即可,具体的代码实现如下所示:

public class Transaction {
    protected boolean isExpired() {
        long executionInvokedTimestamp = System.currentTimestamp();
        return executionInvokedTimestamp - createdTimestamp > 14days;
    }
    public boolean execute() throws InvalidTransactionException {
        //...
        if (isExpired()) {
            this.status = STATUS.EXPIRED;
            return false;
        }
        //...
    }
}

针对重构之后的代码,测试用例 3 的代码实现如下所示:

public void testExecute_with_TransactionIsExpired() {
        Long buyerId = 123L;
        Long sellerId = 234L;
        Long productId = 345L;
        Long orderId = 456L;
  
        Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId) {
            protected boolean isExpired() {
                return true;
            }
        };
        boolean actualResult = transaction.execute();
        assertFalse(actualResult);
        assertEquals(STATUS.EXPIRED, transaction.getStatus());
}

通过重构,Transaction 代码的可测试性提高了。之前罗列的所有测试用例,现在都顺利实现了。不过,Transaction 类的构造函数的设计还有点不妥,如下:

public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long p) {
        if (preAssignedId != null && !preAssignedId.isEmpty()) {
            this.id = preAssignedId;
        } else {
            this.id = IdGenerator.generateTransactionId();
        }
        if (!this.id.startWith("t_")) {
            this.id = "t_" + preAssignedId;
        }
        this.buyerId = buyerId;
        this.sellerId = sellerId;
        this.productId = productId;
        this.orderId = orderId;
        this.status = STATUS.TO_BE_EXECUTD;
        this.createTimestamp = System.currentTimestamp();
}

构造函数中并非只包含简单赋值操作。交易 id 的赋值逻辑稍微复杂。最好也要测试一下,以保证这部分逻辑的正确性。为了方便测试,可以把 id 赋值这部分逻辑单独抽象到一个函数中,具体的代码实现如下所示:

public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long p
        //...
        fillTransactionId(preAssignId);
        //...
}

protected void fillTransactionId(String preAssignedId) {
        if (preAssignedId != null && !preAssignedId.isEmpty()) {
            this.id = preAssignedId;
        } else {
            this.id = IdGenerator.generateTransactionId();
        }
        if (!this.id.startWith("t_")) {
            this.id = "t_" + preAssignedId;
        }
}

到此为止,一步一步将 Transaction 从不可测试代码重构成了测试性良好的代码。不过可能还会有疑问,Transaction 类中 isExpired() 函数就不用测试了吗?对于 isExpired() 函数,逻辑非常简单,肉眼就能判定是否有 bug,是可以不用写单元测试的

实际上,可测试性差的代码,本身代码设计得也不够好,很多地方都没有遵守之前讲到的设计原则和思想,比如“基于接口而非实现编程”思想、依赖反转原则等。重构之后的代码,不仅可测试性更好,而且从代码设计的角度来说,也遵从了经典的设计原则和思想。这也印证了代码的可测试性可以从侧面上反应代码设计是否合理。除此之外,在平时的开发中,也要多思考一下,这样编写代码,是否容易编写单元测试,这也有利于设计出好的代码

3.2 其他常见的 Anti-Patterns

总结一下,有哪些典型的、常见的测试性不好的代码,也就是常说的 Anti-Patterns:

3.2.1 未决行为

所谓的未决行为逻辑就是,代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码

3.2.2 全局变量

全局变量是一种面向过程的编程风格,有种种弊端。实际上,滥用全局变量也让编写单元测试变得困难,如下例:

RangeLimiter 表示一个 [-5, 5] 的区间,position 初始在 0 位置,move() 函数负责移动 position。其中,position 是一个静态全局变量。RangeLimiterTest 类是为其设计的单元测试,不过,这里面存在很大的问题

public class RangeLimiter {
    private static AtomicInteger position = new AtomicInteger(0);
    public static final int MAX_LIMIT = 5;
    public static final int MIN_LIMIT = -5;
    public boolean move(int delta) {
        int currentPos = position.addAndGet(delta);
        boolean betweenRange = (currentPos <= MAX_LIMIT) && (currentPos >= MIN_LIMI
        return betweenRange;
    }
}
public class RangeLimiterTest {
    public void testMove_betweenRange() {
        RangeLimiter rangeLimiter = new RangeLimiter();
        assertTrue(rangeLimiter.move(1));
        assertTrue(rangeLimiter.move(3));
        assertTrue(rangeLimiter.move(-5));
    }
    public void testMove_exceedRange() {
        RangeLimiter rangeLimiter = new RangeLimiter();
        assertFalse(rangeLimiter.move(6));
    }
}

上面的单元测试有可能会运行失败。假设单元测试框架顺序依次执行 testMove_betweenRange()testMove_exceedRange() 两个测试用例。在第一个测试用例执行完成之后,position 的值变成了 -1;再执行第二个测试用例的时候,position 变成了 5,move() 函数返回 true,assertFalse 语句判定失败。所以,第二个测试用例运行失败

当然,如果 RangeLimiter 类有暴露重设(reset)position 值的函数,可以在每次执行单元测试用例之前,把 position 重设为 0,这样就能解决刚刚的问题

不过,每个单元测试框架执行单元测试用例的方式可能是不同的。有的是顺序执行,有的是并发执行。对于并发执行的情况,即便每次都把 position 重设为 0,也并不奏效。如果两个测试用例并发执行,第 16、17、18、23 这四行代码可能会交叉执行,影响到 move() 函数的执行结果

3.2.3 静态方法

静态方法跟全局变量一样,也是一种面向过程的编程思维。在代码中调用静态方法,有时候会导致代码不易测试。主要原因是静态方法也很难 mock。但是,这个要分情况来看。只有在这个静态方法执行耗时太长、依赖外部资源、逻辑复杂、行为未决等情况下,才需要在单元测试中 mock 这个静态方法。除此之外,如果只是类似 Math.abs() 这样的简单静态方法,并不会影响代码的可测试性,因为本身并不需要 mock

3.2.4 复杂继承

相比组合关系,继承关系的代码结构更加耦合、不灵活,更加不易扩展、不易维护。实际上,继承关系也更加难测试。这也印证了代码的可测试性跟代码质量的相关性

如果父类需要 mock 某个依赖对象才能进行单元测试,那所有的子类、子类的子类……在编写单元测试的时候,都要 mock 这个依赖对象。对于层次很深(在继承关系类图中表现为纵向深度)、结构复杂(在继承关系类图中表现为横向广度)的继承关系,越底层的子类要 mock 的对象可能就会越多,这样就会导致,底层子类在写单元测试的时候,要一个一个 mock 很多依赖对象,而且还需要查看父类代码,去了解该如何 mock 这些依赖对象

如果利用组合而非继承来组织类之间的关系,类之间的结构层次比较扁平,在编写单元测试的时候,只需要 mock 类所组合依赖的对象即可

3.2.5 高耦合代码

如果一个类职责很重,需要依赖十几个外部对象才能完成工作,代码高度耦合,那在编写单元测试的时候,可能需要 mock 这十几个依赖的对象。不管是从代码设计的角度来说,还是从编写单元测试的角度来说,这都是不合理的

4. 解耦

前面讲到,重构可以分为大规模高层重构(简称“大型重构”)和小规模低层次重构(简称“小型重构”)。大型重构是对系统、模块、代码结构、类之间关系等顶层代码设计进行的重构。对于大型重构来说,最有效的一个手段,那就是“解耦”。解耦的目的是实现代码高内聚、松耦合

4.1 “解耦”为何如此重要?

软件设计与开发最重要的工作之一就是应对复杂性。人处理复杂性的能力是有限的。过于复杂的代码往往在可读性、可维护性上都不友好。那如何来控制代码的复杂性呢?手段有很多,个人认为最关键的就是解耦,保证代码松耦合、高内聚。如果说重构是保证代码质量不至于腐化到无可救药地步的有效手段,那么利用解耦的方法对代码重构,就是保证代码不至于复杂到无法控制的有效手段

“高内聚、松耦合”是一个比较通用的设计思想,不仅可以指导细粒度的类和类之间关系的设计,还能指导粗粒度的系统、架构、模块的设计。相对于编码规范,它能够在更高层次上提高代码的可读性和可维护性

不管是阅读代码还是修改代码,“高内聚、松耦合”的特性可以让我们聚焦在某一模块或类中,不需要了解太多其他模块或类的代码,让我们的焦点不至于过于发散,降低了阅读和修改代码的难度。而且,因为依赖关系简单,耦合小,修改代码不至于牵一发而动全身,代码改动比较集中,引入 bug 的风险也就减少了很多。同时,“高内聚、松耦合”的代码可测试性也更加好,容易 mock 或者很少需要 mock 外部依赖的模块或者类

除此之外,代码“高内聚、松耦合”,也就意味着,代码结构清晰、分层和模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差。即便某个具体的类或者模块设计得不怎么合理,代码质量不怎么高,影响的范围是非常有限的。可以聚焦于这个模块或者类,做相应的小型重构。而相对于代码结构的调整,这种改动范围比较集中的小型重构的难度就容易多了

4.2 代码是否需要“解耦”?

该怎么判断代码的耦合程度呢?或者说,怎么判断代码是否符合“高内聚、松耦合”呢?再或者说,如何判断系统是否需要解耦重构呢?

间接的衡量标准有很多,比如,看修改代码会不会牵一发而动全身。除此之外,还有一个直接的衡量标准,那就是把模块与模块之间、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构

如果依赖关系复杂、混乱,那从代码结构上来讲,可读性和可维护性肯定不是太好,那就需要考虑是否可以通过解耦的方法,让依赖关系变得清晰、简单。当然,这种判断还是有比较强的主观色彩,但是可以作为一种参考和梳理依赖的手段,配合间接的衡量标准一块来使用

4.3 如何给代码“解耦”?

4.3.1 封装与抽象

封装和抽象作为两个非常通用的设计思想,可以应用在很多设计场景中,比如系统、模块、lib、组件、接口、类等等的设计。封装和抽象可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口

比如,Unix 系统提供的 open() 文件操作函数,用起来非常简单,但是底层实现却非常复杂,涉及权限控制、并发控制、物理存储等等。通过将其封装成一个抽象的 open() 函数,能够有效控制代码复杂性的蔓延,将复杂性封装在局部代码中。除此之外,因为 open() 函数基于抽象而非具体的实现来定义,所以在改动 open() 函数的底层实现的时候,并不需要改动依赖它的上层代码,也符合前面提到的“高内聚、松耦合”代码的评判标准

4.3.2 中间层

引入中间层能简化模块或类之间的依赖关系。下面这张图是引入中间层前后的依赖关系对比图。在引入数据存储中间层之前,A、B、C 三个模块都要依赖内存一级缓存、Redis 二级缓存、DB 持久化存储三个模块。在引入中间层之后,三个模块只需要依赖数据存储一个模块即可。从图上可以看出,中间层的引入明显地简化了依赖关系,让代码结构更加清晰

在这里插入图片描述

除此之外,在进行重构的时候,引入中间层可以起到过渡的作用,能够让开发和重构同步进行,不互相干扰。比如,某个接口设计得有问题,需要修改它的定义,同时,所有调用这个接口的代码都要做相应的改动。如果新开发的代码也用到这个接口,那开发就跟重构冲突了。为了让重构能小步快跑,可以分下面四个阶段来完成接口的修改:

  1. 引入一个中间层,包裹老的接口,提供新的接口定义
  2. 新开发的代码依赖中间层提供的新接口
  3. 将依赖老接口的代码改为调用新接口
  4. 确保所有的代码都调用新接口之后,删除掉老的接口

这样,每个阶段的开发工作量都不会很大,都可以在很短的时间内完成。重构跟开发冲突的概率也变小了

4.3.3 模块化

模块化是构建复杂系统常用的手段。不仅在软件行业,在建筑、机械制造等行业,这个手段也非常有用。对于一个大型复杂系统来说,没有人能掌控所有的细节。之所以能搭建出如此复杂的系统,并且能维护得了,最主要的原因就是将系统划分成各个独立的模块,让不同的人负责不同的模块,这样即便在不了解全部细节的情况下,管理者也能协调各个模块,让整个系统有效运转

聚焦到软件开发上面,很多大型软件(比如 Windows)之所以能做到几百、上千人有条不紊地协作开发,也归功于模块化做得好。不同的模块之间通过 API 来进行通信,每个模块之间耦合很小,每个小的团队聚焦于一个独立的高内聚模块来开发,最终像搭积木一样将各个模块组装起来,构建成一个超级复杂的系统

再聚焦到代码层面。合理地划分模块能有效地解耦代码,提高代码的可读性和可维护性。所以,在开发代码的时候,一定要有模块化意识,将每个模块都当作一个独立的 lib 一样来开发,只提供封装了内部实现细节的接口给其他模块使用,这样可以减少不同模块之间的耦合度

实际上,模块化的思想无处不在,像 SOA、微服务、lib库、系统内模块划分,甚至是类、函数的设计,都体现了模块化思想。如果追本溯源,模块化思想更加本质的东西就是分而治之

4.4 其他设计思想和原则

“高内聚、松耦合”是一个非常重要的设计思想,能够有效提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。实际上,在前面的章节中,已经多次提到过这个设计思想。很多设计原则都以实现代码的“高内聚、松耦合”为目的,如下:

4.4.1 单一职责原则

内聚性和耦合性并非独立的。高内聚会让代码更加松耦合,而实现高内聚的重要指导原则就是单一职责原则。模块或者类的职责设计得单一,而不是大而全,那依赖它的类和它依赖的类就会比较少,代码耦合也就相应的降低了

4.4.2 基于接口而非实现编程

基于接口而非实现编程能通过接口这样一个中间层,隔离变化和具体的实现。这样做的好处是,在有依赖关系的两个模块或类之间,一个模块或者类的改动,不会影响到另一个模块或类。实际上,这就相当于将一种强依赖关系(强耦合)解耦为了弱依赖关系(弱耦合)

4.4.3 依赖注入

跟基于接口而非实现编程思想类似,依赖注入也是将代码之间的强耦合变为弱耦合。尽管依赖注入无法将本应该有依赖关系的两个类,解耦为没有依赖关系,但可以让耦合关系没那么紧密,容易做到插拔替换

4.4.4 多用组合少用继承

继承是一种强依赖关系,父类与子类高度耦合,且这种耦合关系非常脆弱,牵一发而动全身,父类的每一次改动都会影响所有的子类。相反,组合关系是一种弱依赖关系,这种关系更加灵活,所以,对于继承结构比较复杂的代码,利用组合来替换继承,也是一种解耦的有效手段

4.4.5 迪米特法则

迪米特法则讲的是,不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。从定义上,明显可以看出,这条原则的目的就是为了实现代码的松耦合

除了上面讲到的这些设计思想和原则之外,还有一些设计模式也是为了解耦依赖,比如观察者模式

5. 编程规范

关于编码规范、如何编写可读代码,很多书籍已经讲得很好了。不过,这里总结罗列了 20 条个人觉得最好用
的编码规范。掌握这 20 条编码规范,能最快速地改善代码质量。分为三个部分:命名与注释(Naming andComments)、代码风格(Code Style)和编程技巧(Coding Tips)

5.1 命名

大到项目名、模块名、包名、对外暴露的接口,小到类名、函数名、变量名、参数名,只要是做开发,就逃不过“起名字”这一关。命名的好坏,对于代码的可读性来说非常重要,甚至可以说是起决定性作用的。除此之外,命名能力也体现了一个程序员的基本编程素养

5.1.1 命名多长最合适?

这里有两种典型。第一种是特别喜欢用很长的命名方式,觉得命名一定要准确达意,哪怕长一点也没关系,所以项目里,类名、函数名都很长。第二种喜欢用短的命名方式,能用缩写就尽量用缩写,所以,项目里到处都是包含各种缩写的命名。这两种命名方式,哪种更值得推荐呢?

尽管长的命名可以包含更多的信息,更能准确直观地表达意图,但是,如果函数、变量的命名很长,那由它们组成的语句就会很长。在代码列长度有限制的情况下,就会经常出现一条语句被分割成两行的情况,这其实会影响代码可读性

实际上,在足够表达其含义的情况下,命名当然是越短越好。但是,大部分情况下,短的命名都没有长的命名更能达意。所以,很多书籍或者文章都不推荐在命名时使用缩写。但对于一些默认的、大家都比较熟知的词,比较推荐用缩写。这样一方面能让命名短一些,另一方面又不影响阅读理解,比如,sec 表示 second、str 表示 string、num 表示 number、doc 表示 document。除此之外,对于作用域比较小的变量,可以使用相对短的命名,比如一些函数内的临时变量。相反,对于类名这种作用域比较大的,更推荐用长的命名方式

总之,命名的一个原则就是以能准确达意为目标。不过,对于代码的编写者来说,自己对代码的逻辑很清楚,总感觉用什么样的命名都可以达意,实际上,对于不熟悉代码的同事来讲,可能就不这么认为了。所以,命名的时候,一定要学会换位思考,假设自己不熟悉这块代码,从代码阅读者的角度去考量命名是否足够直观

5.1.2 利用上下文简化命名

如下例:

public class User {
    private String userName;
    private String userPassword;
    private String userAvatarUrl;
    //...
}

在 User 类这样一个上下文中,没有必要在成员变量的命名中重复添加“user”这样一个前缀单词,而是直接命名为 name、password、avatarUrl。在使用这些属性时候,能借助对象这样一个上下文,表意也足够明确。具体代码如下所示:

User user = new User();
user.getName(); // 借助 user 对象这个上下文

除了类之外,函数参数也可以借助函数这个上下文来简化命名,如下例:

public void uploadUserAvatarImageToAliyun(String userAvatarImageUri);
// 利用上下文简化为:
public void uploadUserAvatarImageToAliyun(String imageUri);

5.1.3 命名要可读、可搜索

什么是命名可读。这里所说的“可读”,指的是不要用一些特别生僻、难发音的英文单词来命名

过去曾参加过两个项目,一个叫 plateaux,另一个叫 eyrie,从项目立项到结束,自始至终都没有几个人能叫对这两个项目的名字。在沟通的时候,每当有人提到这两个项目的名字的时候,都会尴尬地卡顿一下。虽然我们并不排斥一些独特的命名方式,但起码得让大部分人看一眼就能知道怎么读

在 IDE 中编写代码的时候,经常会用“关键词联想”的方法来自动补全和搜索。比如,键入某个对象“.get”,希望 IDE 返回这个对象的所有 get开头的方法。再比如,通过在 IDE 搜索框中输入“Array”,搜索 JDK 中数组相关的类。所以,在命名的时候,最好能符合整个项目的命名习惯。大家都用“selectXXX”表示查询,你就不要用“queryXXX”;大家都用“insertXXX”表示插入一条数据,你就要不用“addXXX”,统一规约是很重要的,能减少很多不必要的麻烦

5.1.4 如何命名接口和抽象类?

对于接口的命名,一般有两种比较常见的方式。一种是加前缀“I”,表示一个 Interface。比如 IUserService,对应的实现类命名为 UserService。另一种是不加前缀,比如 UserService,对应的实现类加后缀“Impl”,比如 UserServiceImpl

对于抽象类的命名,也有两种方式,一种是带上前缀“Abstract”,比如 AbstractConfiguration;另一种是不带前缀“Abstract”。实际上,对于接口和抽象类,选择哪种命名方式都是可以的,只要项目里能够统一就行

5.2 注释

命名很重要,注释跟命名同等重要。很多书籍认为,好的命名完全可以替代注释。如果需要注释,那说明命名不够好,需要在命名上下功夫,而不是添加注释。实际上,我个人觉得,这样的观点有点太过极端。命名再好,毕竟有长度限制,不可能足够详尽,而这个时候,注释就是一个很好的补充

5.2.1 注释到底该写什么?

注释的目的就是让代码更容易看懂。只要符合这个要求的内容,就可以将它写到注释里。总结一下,注释的内容主要包含这样三个方面:做什么、为什么、怎么做。如下例:

/**
 * (what) Bean factory to create beans.
 *
 * (why) The class likes Spring IOC framework, but is more lightweight.
 *
 * (how) Create objects from different sources sequentially:
 * user specified object > SPI > configuration > default object.
 */
public class BeansFactory {
    // ...
}

有些人认为,注释是要提供一些代码没有的额外信息,所以不要写“做什么、怎么做”,这两方面在代码中都可以体现出来,只需要写清楚“为什么”,表明代码的设计意图即可。我个人不是特别认可这样的观点,理由主要有下面 3 点:

1、注释比代码承载的信息更多

命名的主要目的是解释“做什么”。比如,void increaseWalletAvailableBalance(BigDecimal amount) 表明这个函数用来增加钱包的可用余额,boolean isValidatedPassword 表明这个变量用来标识是否是合法密码。函数和变量如果命名得好,确实可以不用再在注释中解释它是做什么的。但是,对于类来说,包含的信息比较多,一个简单的命名就不够全面详尽了。这个时候,在注释中写明“做什么”就合情合理了

2、注释起到总结性作用、文档的作用

代码之下无秘密。阅读代码可以明确地知道代码是“怎么做”的,也就是知道代码是如何实现的,那注释中是不是就不用写“怎么做”了?实际上也可以写。在注释中,关于具体的代码实现思路,可以写一些总结性的说明、特殊情况的说明。这样能够让阅读代码的人通过注释就能大概了解代码的实现思路,阅读起来就会更加容易

实际上,对于有些比较复杂的类或者接口,可能还需要在注释中写清楚“如何用”,举一些简单的 quick start 的例子,让使用者在不阅读代码的情况下,快速地知道该如何使用

3、一些总结性注释能让代码结构更清晰

对于逻辑比较复杂的代码或者比较长的函数,如果不好提炼、不好拆分成小的函数调用,那可以借助总结性的注释来让代码结构更清晰、更有条理

public boolean isValidPasword(String password) {
        // check if password is null or empty
        if (StringUtils.isBlank(password)) {
            return false;
        }
        // check if the length of password is between 4 and 64
        int length = password.length();
        if (length < 4 || length > 64) {
            return false;
        }
        // check if password contains only lowercase characters
        if (!StringUtils.isAllLowerCase(password)) {
            return false;
        }
        // check if password contains only a~z,0~9,dot
        for (int i = 0; i < length; ++i) {
            char c = password.charAt(i);
            if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
                return false;
            }
        }
        return true;
}

5.2.2 注释是不是越多越好?

注释太多和太少都有问题。太多,有可能意味着代码写得不够可读,需要写很多注释来补充。除此之外,注释太多也会对代码本身的阅读起到干扰。而且,后期的维护成本也比较高,有时候代码改了,注释忘了同步修改,就会让代码阅读者更加迷惑。当然,如果代码中一行注释都没有,那只能说明这个程序员很懒,要适当督促一下,让他注意添加一些必要的注释

按照经验来说,类和函数一定要写注释,而且要写得尽可能全面、详细,而函数内部的注释要相对少一些,一般都是靠好的命名、提炼函数、解释性变量、总结性注释来提高代码的可读性

5.3 代码风格(Code Style)

5.3.1 类、函数多大才合适?

总体上来讲,类或函数的代码行数不能太多,但也不能太少。类或函数的代码行数太多,一个类上千行,一个函数几百行,逻辑过于繁杂,阅读代码的时候,很容易就会看了后面忘了前面。相反,类或函数的代码行数太少,在代码总量相同的情况下,被分割成的类和函数就会相应增多,调用关系就会变得更复杂,阅读某个代码逻辑的时候,需要频繁地在 n 多类或者 n 多函数之间跳来跳去,阅读体验也不好

那一个类或函数有多少行代码才最合适呢?

要给出一个精确的量化值是很难的。对于函数代码行数的最大限制,网上有一种说法,那就是不要超过一个显示屏的垂直高度。比如,在我的电脑上,如果要让一个函数的代码完整地显示在 IDE 中,那最大代码行数不能超过 50。这个说法我觉得挺有道理的。因为超过一屏之后,在阅读代码的时候,为了串联前后的代码逻辑,就可能需要频繁地上下滚动屏幕,阅读体验不好不说,还容易出错

对于类的代码行数的最大限制,这个就更难给出一个确切的值了。在前面也给出过一个间接的判断标准,那就是,当一个类的代码读起来让你感觉头大了,实现某个功能时不知道该用哪个函数了,想用哪个函数翻半天都找不到了,只用到一个小功能要引入整个类(类中包含很多无关此功能实现的函数)的时候,这就说明类的行数过多了

5.3.2 一行代码多长最合适?

在 Google Java Style Guide 文档中,一行代码最长限制为 100 个字符。不过,不同的编程语言、不同的规范、不同的项目团队,对此的限制可能都不相同。不管这个限制是多少,总体上来讲要遵循的一个原则是:一行代码最长不能超过 IDE 显示的宽度。需要滚动鼠标才能查看一行的全部代码,显然不利于代码的阅读。当然,这个限制也不能太小,太小会导致很多稍长点的语句被折成两行,也会影响到代码的整洁,不利于阅读

5.3.3 善用空行分割单元块

对于比较长的函数,如果逻辑上可以分为几个独立的代码块,在不方便将这些独立的代码块抽取成小函数的情况下,为了让逻辑更加清晰,除了可以用总结性注释的方法之外,还可以使用空行来分割各个代码块

除此之外,在类的成员变量与函数之间、静态成员变量与普通成员变量之间、各函数之间、甚至各成员变量之间,我们都可以通过添加空行的方式,让这些不同模块的代码之间,界限更加明确。写代码就类似写文章,善于应用空行,可以让代码的整体结构看起来更加有清晰、有条理

5.3.4 四格缩进还是两格缩进?

“PHP 是世界上最好的编程语言?代码换行应该四格缩进还是两格缩进?”这应该是程序员争论得最多的两个话题了。据我所知,Java 语言倾向于两格缩进,PHP 语言倾向于四格缩进。至于到底应该是两格缩进还是四格缩进,我觉得这个取决于个人喜好。只要项目内部能够统一就行了

当然,还有一个选择的标准,那就是跟业内推荐的风格统一、跟著名开源项目统一。当我们需要拷贝一些开源的代码到项目里的时候,能够让引入的代码跟我们项目本身的代码,保持风格统一

不过,个人比较推荐使用两格缩进,这样可以节省空间。特别是在代码嵌套层次比较深的情况下,累计缩进较多的话,容易导致一个语句被折成两行,影响代码可读性

除此之外,值得强调的是,不管是用两格缩进还是四格缩进,一定不要用 Tab 键缩进。因为在不同的 IDE 下,Tab 键的显示宽度不同,有的显示为四格缩进,有的显示为两格缩进。如果在同一个项目中,不同的同事使用不同的缩进方式(空格缩进或 Tab 键缩进),有可能会导致有的代码显示为两格缩进、有的代码显示为四格缩进,不过可以在 IDE 里设置 Tab 缩进的格数

5.3.5 大括号是否要另起一行?

左大括号是否要另起一行呢?这个也有争论。据我所知,PHP 程序员喜欢另起一行,Java程序员喜欢跟上一条语句放到一起。具体代码示例如下所示:

// PHP
class ClassName
{
    public function foo()
    {
        // method body
    }
}
// Java
public class ClassName {
    public void foo() {
        // method body
    }
}

个人还是比较推荐,将括号放到跟语句同一行的风格。理由跟上面类似,节省代码行数。但是将大括号另起新的一行的方式,也有它的优势。这样的话,左右括号可以垂直对齐,哪些代码属于哪一个代码块,更一目了然

不过,还是那句话,大括号跟上一条语句在同一行,还是另起新的一行,只要团队统一、业内统一、跟开源项目看齐就好了,没有绝对的优劣之分

5.3.6 类中成员的排列顺序

在 Java 类文件中,先要书写类所属的包名,然后再罗列 import 引入的依赖类。在 Google 编码规范中,依赖类按照字母序从小到大排列

在类中,成员变量排在函数的前面。成员变量之间或函数之间,都是按照“先静态(静态函数或静态成员变量)、后普通(非静态函数或非静态成员变量)”的方式来排列的。除此之外,成员变量之间或函数之间,还会按照作用域范围从大到小的顺序来排列,先写 public 成员变量或函数,然后是 protected 的,最后是 private 的

不过,不同的编程语言中,类内部成员的排列顺序可能会有比较大的差别。比如 C++ 中,成员变量会习惯性放到函数后面。除此之外,函数之间的排列顺序,会按照作用域的大小来排列。实际上,还有另外一种排列习惯,那就是把有调用关系的函数放到一块。比如,一个 public 函数调用了另外一个 private 函数,那就把这两者放到一块

5.4 编程技巧

5.4.1 把代码分割成更小的单元块

大部分人阅读代码的习惯都是,先看整体再看细节。所以,要有模块化和抽象思维,善于将大块的复杂逻辑提炼成类或者函数,屏蔽掉细节,让阅读代码的人不至于迷失在细节中,这样能极大地提高代码的可读性。不过,只有代码逻辑比较复杂的时候,其实才建议提炼类或者函数。毕竟如果提炼出的函数只包含两三行代码,在阅读代码的时候,还得跳过去看一下,这样反倒增加了阅读成本

如下例,重构前,在 invest() 函数中,最开始的那段关于时间处理的代码,是不是很难看懂?重构之后,将这部分逻辑抽象成一个函数,并且命名为 isLastDayOfMonth,从名字就能清晰地了解它的功能,判断今天
是不是当月的最后一天。这里,就是通过将复杂的逻辑代码提炼成函数,大大提高了代码的可读性

// 重构前的代码
public void invest(long userId, long financialProductId) {
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(date);
        calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1));

        if (calendar.get(Calendar.DAY_OF_MONTH) == 1) {
            return;
        }
        //...
}

// 重构后的代码:提炼函数之后逻辑更加清晰
public void invest(long userId, long financialProductId) {
        if (isLastDayOfMonth(new Date())) {
            return;
        }
        //...
}
public boolean isLastDayOfMonth(Date date) {
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(date);
        calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1));

        if (calendar.get(Calendar.DAY_OF_MONTH) == 1) {
            return true;
        }
        return false;
}

5.4.2 避免函数参数过多

函数包含 3、4 个参数的时候还是能接受的,大于等于 5 个的时候,就觉得参数有点过多了,会影响到代码的可读性,使用起来也不方便。针对参数过多的情况,一般有 2 种处理方法:

1、考虑函数是否职责单一,是否能通过拆分成多个函数的方式来减少参数

public void getUser(String username, String telephone, String email);
// 拆分成多个函数
public void getUserByUsername(String username);
public void getUserByTelephone(String telephone);
public void getUserByEmail(String email);

2、将函数的参数封装成对象

public void postBlog(String title, String summary, String keywords, String cont
// 将参数封装成对象
public class Blog {
    private String title;
    private String summary;
    private String keywords;
    private Strint content;
    private String category;
    private long authorId;
}
public void postBlog(Blog blog);

除此之外,如果函数是对外暴露的远程接口,将参数封装成对象,还可以提高接口的兼容性。在往接口中添加新的参数的时候,老的远程接口调用者有可能就不需要修改代码来兼容新的接口了

5.4.3 勿用函数参数来控制逻辑

不要在函数中使用布尔类型的标识参数来控制内部逻辑,true 的时候走这块逻辑,false 的时候走另一块逻辑。这明显违背了单一职责原则和接口隔离原则。建议将其拆成两个函数,可读性上也要更好。如下例:

public void buyCourse(long userId, long courseId, boolean isVip);
// 将其拆分成两个函数
public void buyCourse(long userId, long courseId);
public void buyCourseForVip(long userId, long courseId);

不过,如果函数是 private 私有函数,影响范围有限,或者拆分之后的两个函数经常同时被调用,可以酌情考虑保留标识参数。示例代码如下所示:

// 拆分成两个函数的调用方式
boolean isVip = false;
//...省略其他逻辑...
if (isVip) {
    buyCourseForVip(userId, courseId);
} else {
    buyCourse(userId, courseId);
}
// 保留标识参数的调用方式更加简洁
boolean isVip = false;
//...省略其他逻辑...
buyCourse(userId, courseId, isVip);

除了布尔类型作为标识参数来控制逻辑的情况外,还有一种“根据参数是否为 null”来控制逻辑的情况。针对这种情况,也应该将其拆分成多个函数。拆分之后的函数职责更明确,不容易用错。具体代码示例如下所示:

public List<Transaction> selectTransactions(Long userId, Date startDate, Date endDate) {
    if (startDate != null && endDate != null) {
        // 查询两个时间区间的transactions
    }
    if (startDate != null && endDate == null) {
        // 查询startDate之后的所有transactions
    }
    if (startDate == null && endDate != null) {
        // 查询endDate之前的所有transactions
    }
    if (startDate == null && endDate == null) {
        // 查询所有的transactions
    }
}

// 拆分成多个public函数,更加清晰、易用
public List<Transaction> selectTransactionsBetween(Long userId, Date startDate, Date endDate) {
    return selectTransactions(userId, startDate, endDate);
}
public List<Transaction> selectTransactionsStartWith(Long userId, Date startDate, Date endDate) {
    return selectTransactions(userId, startDate, null);
}
public List<Transaction> selectTransactionsEndWith(Long userId, Date endDate) {
    return selectTransactions(userId, null, endDate);
}
public List<Transaction> selectAllTransactions(Long userId) {
    return selectTransactions(userId, null, null);
}
private List<Transaction> selectTransactions(Long userId, Date startDate, Date endDate) {
    // ...
}

5.4.4 函数设计要职责单一

前面讲到单一职责原则的时候,针对的是类、模块这样的应用对象。实际上,对于函数的设计来说,更要满足单一职责原则。相对于类和模块,函数的粒度比较小,代码行数少,所以在应用单一职责原则的时候,没有像应用到类或者模块那样模棱两可,能多单一就多单一,如下例:

public boolean checkUserIfExisting(String telephone, String username, String email) {
        if (!StringUtils.isBlank(telephone)) {
            User user = userRepo.selectUserByTelephone(telephone);
            return user != null;
        }
        if (!StringUtils.isBlank(username)) {
            User user = userRepo.selectUserByUsername(username);
            return user != null;
        }
        if (!StringUtils.isBlank(email)) {
            User user = userRepo.selectUserByEmail(email);
            return user != null;
        }
        return false;
}
// 拆分成三个函数
public boolean checkUserIfExistingByTelephone(String telephone);
public boolean checkUserIfExistingByUsername(String username);
public boolean checkUserIfExistingByEmail(String email);

5.4.5 移除过深的嵌套层次

代码嵌套层次过深往往是因为 if-else、switch-case、for 循环过度嵌套导致的。个人建议,嵌套最好不超过两层,超过两层之后就要思考一下是否可以减少嵌套。过深的嵌套本身理解起来就比较费劲,除此之外,嵌套过深很容易因为代码多次缩进,导致嵌套内部的语句超过一行的长度而折成两行,影响代码的整洁

解决嵌套过深的方法也比较成熟,有下面 4 种常见的思路:

1、去掉多余的 if 或 else 语句

// 示例一
public double caculateTotalAmount(List<Order> orders) {
        if (orders == null || orders.isEmpty()) {
            return 0.0;
        } else { // 此处的else可以去掉
            double amount = 0.0;
            for (Order order : orders) {
                if (order != null) {
                amount += (order.getCount() * order.getPrice());
            }
        }
        return amount;
        }
}

// 示例二
public List<String> matchStrings(List<String> strList,String substr) {
        List<String> matchedStrings = new ArrayList<>();
        if (strList != null && substr != null) {
            for (String str : strList) {
                if (str != null) { // 跟下面的if语句可以合并在一起
                    if (str.contains(substr)) {
                        matchedStrings.add(str);
                    }
                }
            }
        }
        return matchedStrings;
}

2、使用编程语言提供的 continue、break、return 关键字,提前退出嵌套

// 重构前的代码
public List<String> matchStrings(List<String> strList,String substr) {
        List<String> matchedStrings = new ArrayList<>();
        if (strList != null && substr != null){
            for (String str : strList) {
                if (str != null && str.contains(substr)) {
                    matchedStrings.add(str);
                    // 此处还有10行代码...
                }
            }
        }
        return matchedStrings;
}

// 重构后的代码:使用continue提前退出
public List<String> matchStrings(List<String> strList,String substr) {
        List<String> matchedStrings = new ArrayList<>();
        if (strList != null && substr != null){
            for (String str : strList) {
                if (str == null || !str.contains(substr)) {
                    continue;
                }
                matchedStrings.add(str);
                // 此处还有10行代码...
            }
        }
        return matchedStrings;
}

3、调整执行顺序来减少嵌套

// 重构前的代码
public List<String> matchStrings(List<String> strList,String substr) {
        List<String> matchedStrings = new ArrayList<>();
        if (strList != null && substr != null) {
            for (String str : strList) {
                if (str != null) {
                    if (str.contains(substr)) {
                        matchedStrings.add(str);
                    }
                }
            }
        }
        return matchedStrings;
}

// 重构后的代码:先执行判空逻辑,再执行正常逻辑
public List<String> matchStrings(List<String> strList,String substr) {
        if (strList == null || substr == null) { //先判空
            return Collections.emptyList();
        }
        List<String> matchedStrings = new ArrayList<>();
        for (String str : strList) {
            if (str != null) {
                if (str.contains(substr)) {
                    matchedStrings.add(str);
                }
            }
        }
        return matchedStrings;
}

4、将部分嵌套逻辑封装成函数调用,以此来减少嵌套

// 重构前的代码
public List<String> appendSalts(List<String> passwords) {
        if (passwords == null || passwords.isEmpty()) {
            return Collections.emptyList();
        }
        List<String> passwordsWithSalt = new ArrayList<>();
        for (String password : passwords) {
            if (password == null) {
                continue;
            }
            if (password.length() < 8) {
                // ...
            } else {
                // ...
            }
        }
        return passwordsWithSalt;
}

// 重构后的代码:将部分逻辑抽成函数
public List<String> appendSalts(List<String> passwords) {
        if (passwords == null || passwords.isEmpty()) {
            return Collections.emptyList();
        }
        List<String> passwordsWithSalt = new ArrayList<>();
        for (String password : passwords) {
            if (password == null) {
                continue;
            }
            passwordsWithSalt.add(appendSalt(password));
        }
        return passwordsWithSalt;
}
private String appendSalt(String password) {
        String passwordWithSalt = password;
        if (password.length() < 8) {
            // ...
        } else {
            // ...
        }
        return passwordWithSalt;
}

除此之外,常用的还有通过使用多态来替代 if-else、switch-case 条件判断的方法。这个思路涉及代码结构的改动

5.4.6 学会使用解释性变量

常用的用解释性变量来提高代码的可读性的情况有下面 2 种:

1、常量取代魔法数字

public double CalculateCircularArea(double radius) {
    return (3.1415) * radius * radius;
}
// 常量替代魔法数字
public static final Double PI = 3.1415;
public double CalculateCircularArea(double radius) {
    return PI * radius * radius;
}

2、使用解释性变量来解释复杂表达式

if (date.after(SUMMER_START) && date.before(SUMMER_END)) {
    // ...
} else {
    // ...
}

// 引入解释性变量后逻辑更加清晰
boolean isSummer = date.after(SUMMER_START)&&date.before(SUMMER_END);
if (isSummer) {
    // ...
} else {
    // ...
}

6. 通过一段 ID 生成器代码,学习如何发现代码质量问题

6.1 需求背景介绍

“ID”中文翻译为“标识(Identifier)”。这个概念在生活、工作中随处可见,比如身份证、商品条形码、二维码、车牌号、驾照号。聚焦到软件开发中,ID 常用来表示一些业务信息的唯一标识,比如订单的单号或者数据库中的唯一主键,比如地址表中的 ID 字段(实际上是没有业务含义的,对用户来说是透明的,不需要关注)

假设在一个后端业务系统的开发中,为了方便在请求出错时排查问题,在编写代码的时候会在关键路径上打印日志。某个请求出错之后,希望能搜索出这个请求对应的所有日志,以此来查找问题的原因。而实际情况是,在日志文件中,不同请求的日志会交织在一起。如果没有东西来标识哪些日志属于同一个请求,就无法关联同一个请求的所有日志

这听起来有点像微服务中的调用链追踪。不过,微服务中的调用链追踪是服务间的追踪,这里现在要实现的是服务内的追踪

借鉴微服务调用链追踪的实现思路,可以给每个请求分配一个唯一 ID,并且保存在请求的上下文(Context)中,比如,处理请求的工作线程的局部变量中。在 Java 语言中,可以将 ID 存储在 Servlet 线程的 ThreadLocal 中,或者利用 Slf4j 日志框架的 MDC(Mapped Diagnostic Contexts)来实现(实际上底层原理也是基于线程的 ThreadLocal)。每次打印日志的时候,从请求上下文中取出请求 ID,跟日志一块输出。这样,同一个请求的所有日志都包含同样的请求 ID 信息,就可以通过请求 ID 来搜索同一个请求的所有日志了

6.2 一份“能用”的代码实现

public class IdGenerator {
    private static final Logger logger = LoggerFactory.getLogger(IdGenerator.class);

    public static String generate() {
        String id = "";
        try {
            String hostName = InetAddress.getLocalHost().getHostName();
            String[] tokens = hostName.split("\\.");
            if (tokens.length > 0) {
                hostName = tokens[tokens.length - 1];
            }
            char[] randomChars = new char[8];
            int count = 0;
            Random random = new Random();
            while (count < 8) {
                int randomAscii = random.nextInt(122);
                if (randomAscii >= 48 && randomAscii <= 57) {
                    randomChars[count] = (char)('0' + (randomAscii - 48));
                    count++;
                } else if (randomAscii >= 65 && randomAscii <= 90) {
                    randomChars[count] = (char)('A' + (randomAscii - 65));
                    count++;
                } else if (randomAscii >= 97 && randomAscii <= 122) {
                    randomChars[count] = (char)('a' + (randomAscii - 97));
                    count++;
                }
            }
            id = String.format("%s-%d-%s", hostName,
                    System.currentTimeMillis(), new String(randomChars));
        } catch (UnknownHostException e) {
            logger.warn("Failed to get the host name.", e);
        }
        return id;
    }
}

上面的代码生成的 ID 示例如下所示。整个 ID 由三部分组成。第一部分是本机名的最后一个字段。第二部分是当前时间戳,精确到毫秒。第三部分是 8 位的随机字符串,包含大小写字母和数字。尽管这样生成的 ID 并不是绝对唯一的,有重复的可能,但事实上重复的概率非常低。对于日志追踪来说,极小概率的 ID 重复是完全可以接受的

103-1577456311467-3nR3Do45
103-1577456311468-0wnuV5yw
103-1577456311468-sdrnkFxN
103-1577456311468-8lwk0BP0

6.3 如何发现代码质量问题?

从大处着眼的话,可以参考之前讲过的代码质量评判标准,看这段代码是否可读、可扩展、可维护、灵活、简洁、可复用、可测试等等。落实到具体细节,可以从以下几个方面来审视代码:

  • 目录设置是否合理、模块划分是否清晰、代码结构是否满足“高内聚、松耦合”?
  • 是否遵循经典的设计原则和设计思想(SOLID、DRY、KISS、YAGNI、LOD 等)?
  • 设计模式是否应用得当?是否有过度设计?
  • 代码是否容易扩展?如果要添加新功能,是否容易实现?
  • 代码是否可以复用?是否可以复用已有的项目代码或类库?是否有重复造轮子?
  • 代码是否容易测试?单元测试是否全面覆盖了各种正常和异常的情况?
  • 代码是否易读?是否符合编码规范(比如命名和注释是否恰当、代码风格是否一致等)?

以上是一些通用的关注点,可以作为常规检查项,套用在任何代码的重构上。除此之外,还要关注代码实现是否满足业务本身特有的功能和非功能需求。这里罗列了一些比较有共性的问题,如下所示。这份列表可能还不够全面,剩下的需要针对具体的业务、具体的代码去具体分析:

  • 代码是否实现了预期的业务需求?
  • 逻辑是否正确?是否处理了各种异常情况?
  • 日志打印是否得当?是否方便 debug 排查问题?
  • 接口是否易用?是否支持幂等、事务等?
  • 代码是否存在并发问题?是否线程安全?
  • 性能是否有优化空间,比如,SQL、算法是否可以优化?
  • 是否有安全漏洞?比如输入输出校验是否全面?

现在,对照上面的检查项,来看一下,6.2 的代码有哪些问题

1、IdGenerator 的代码比较简单,只有一个类,所以,不涉及目录设置、模块划分、代码结构问题,也不违反基本的 SOLID、DRY、KISS、YAGNI、LOD 等设计原则。它没有应用设计模式,所以也不存在不合理使用和过度设计的问题

2、IdGenerator 设计成了实现类而非接口,调用者直接依赖实现而非接口,违反基于接口而非实现编程的设计思想。实际上,将 IdGenerator 设计成实现类,而不定义接口,问题也不大。如果哪天 ID 生成算法改变了,只需要直接修改实现类的代码就可以。但是,如果项目中需要同时存在两种 ID 生成算法,也就是要同时存在两个 IdGenerator 实现类。比如,需要将这个框架给更多的系统来使用。系统在使用的时候,可以灵活地选择它需要的生成算法。这个时候,就需要将 IdGenerator 定义为接口,并且为不同的生成算法定义不同的实现类

3、把 IdGenerator 的 generate() 函数定义为静态函数,会影响使用该函数的代码的可测试性。同时,generate() 函数的代码实现依赖运行环境(本机名)、时间函数、随机函数,所以 generate() 函数本身的可测试性也不好,需要做比较大的重构。除此之外,也没有编写单元测试代码,需要在重构时对其进行补充

4、虽然 IdGenerator 只包含一个函数,并且代码行数也不多,但代码的可读性并不好。特别是随机字符串生成的那部分代码,一方面,代码完全没有注释,生成算法比较难读懂,另一方面,代码里有很多魔法数,严重影响代码的可读性。在重构的时候,需要重点提高这部分代码的可读性

刚刚参照跟业务本身无关的、通用的代码质量关注点,对代码进行了评价。现在,再对照业务本身的功能和非功能需求,重新审视一下代码

1、前面提到,虽然代码生成的 ID 并非绝对的唯一,但是对于追踪打印日志来说,是可以接受小概率 ID 冲突的,满足预期的业务需求。不过,获取 hostName 这部分代码逻辑貌似有点问题,并未处理“hostName 为空”的情况。除此之外,尽管代码中针对获取不到本机名的情况做了异常处理,但是对异常的处理是在 IdGenerator 内部将其吐掉,然后打印一条报警日志,并没有继续往上抛出。这样的异常处理是否得当呢?

2、代码的日志打印得当,日志描述能够准确反应问题,方便 debug,并且没有过多的冗余日志。IdGenerator 只暴露一个 generate() 接口供使用者使用,接口的定义简单明了,不存在不易用问题。generate() 函数代码中没有涉及共享变量,所以代码线程安全,多线程环境下调用 generate() 函数不存在并发问题

3、性能方面,ID 的生成不依赖外部存储,在内存中生成,并且日志的打印频率也不会很高,所以代码在性能方面足以应对目前的应用场景。不过,每次生成 ID 都需要获取本机名,获取主机名会比较耗时,所以,这部分可以考虑优化一下。还有,randomAscii 的范围是 0~122,但可用数字仅包含三段子区间(0~9,a~z,A~Z),极端情况下会随机生成很多三段区间之外的无效数字,需要循环很多次才能生成随机字符串,所以随机字符串的生成算法也可以优化一下

有一些代码质量问题不具有共性,没法一一罗列,需要针对具体的业务、具体的代码去具体分析。那像这份代码,还能发现有哪些具体问题吗?

generate() 函数的 while 循环里面,三个 if 语句内部的代码非常相似,而且实现稍微有点过于复杂了,实际上可以进一步简化,将这三个 if 合并在一起

6.4 重构

前面讲到系统设计和实现的时候,多次讲到要循序渐进、小步快跑。重构代码的过程也应该遵循这样的思路。每次改动一点点,改好之后,再进行下一轮的优化,保证每次对代码的改动不会过大,能在很短的时间内完成。所以,将上面发现的代码质量问题,分成四次重构来完成,具体如下所示:

  1. 提高代码的可读性
  2. 提高代码的可测试性
  3. 编写完善的单元测试
  4. 所有重构完成之后添加注释

6.4.1 提高代码的可读性

首先,要解决最明显、最急需改进的代码可读性问题。具体有下面几点:

  • hostName 变量不应该被重复使用,尤其当这两次使用时的含义还不同的时候
  • 将获取 hostName 的代码抽离出来,定义为 getLastfieldOfHostName() 函数
  • 删除代码中的魔法数,比如,57、90、97、122
  • 将随机数生成的代码抽离出来,定义为 generateRandomAlphameric() 函数
  • generate() 函数中的三个 if 逻辑重复了,且实现过于复杂,我们要对其进行简化
  • 对 IdGenerator 类重命名,并且抽象出对应的接口

这里重点讨论下最后一个修改。实际上,对于 ID 生成器的代码,有下面三种类的命名方式。哪种更合适呢?

在这里插入图片描述

1、将接口命名为 IdGenerator,实现类命名为 LogTraceIdGenerator,这可能是很多人最先想到的命名方式了。在命名的时候,要考虑到,以后两个类会如何使用、会如何扩展。从使用和扩展的角度来分析,这样的命名就不合理了

首先,如果扩展新的日志 ID 生成算法,也就是要创建另一个新的实现类,因为原来的实现类已经叫 LogTraceIdGenerator 了,命名过于通用,那新的实现类就不好取名了,无法取一个跟 LogTraceIdGenerator 平行的名字了

其次可能会说,假设没有日志 ID 的扩展需求,但要扩展其他业务的 ID 生成算法,比如针对用户的(UserldGenerator)、订单的(OrderIdGenerator),第一种命名方式是不是就是合理的呢?答案也是否定的。基于接口而非实现编程,主要的目的是为了方便后续灵活地替换实现类。而 LogTraceIdGenerator、UserIdGenerator、OrderIdGenerator 三个类从命名上来看,涉及的是完全不同的业务,不存在互相替换的场
景。也就是说,不可能在有关日志的代码中,进行下面这种替换。所以,让这三个类实现同一个接口,实际上是没有意义的

IdGenearator idGenerator = new LogTraceIdGenerator();
替换为:
IdGenearator idGenerator = new UserIdGenerator();

2、第二种命名方式是不是就合理了呢?答案也是否定的。其中,LogTraceIdGenerator 接口的命名是合理的,但是 HostNameMillisIdGenerator 实现类暴露了太多实现细节,只要代码稍微有所改动,就可能需要改动命名,才能匹配实现

3、第三种命名方式是比较推荐的。在目前的 ID 生成器代码实现中,生成的 ID 是一个随机 ID,不是递增有序的,所以,命名成 RandomIdGenerator 是比较合理的,即便内部生成算法有所改动,只要生成的还是随机的 ID,就不需要改动命名。如果需要扩展新的 ID 生成算法,比如要实现一个递增有序的 ID 生成算法,那可以命名为 SequenceIdGenerator

实际上,更好的一种命名方式是,抽象出两个接口,一个是 IdGenerator,一个是 LogTraceIdGenerator,LogTraceIdGenerator 继承 IdGenerator。实现类实现接口 LogTraceIdGenerator,命名为 RandomIdGenerator、SequenceIdGenerator 等。这样,实现类可以复用到多个业务模块中,比如前面提到的用户、订单

根据上面的优化策略,对代码进行第一轮的重构,重构之后的代码如下所示:

public interface IdGenerator {
    String generate();
}

public interface LogTraceIdGenerator extends IdGenerator {
}

public class RandomIdGenerator implements IdGenerator {
    private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

    @Override
    public String generate() {
        String substrOfHostName = getLastfieldOfHostName();
        long currentTimeMillis = System.currentTimeMillis();
        String randomString = generateRandomAlphameric(8);
        String id = String.format("%s-%d-%s",
                substrOfHostName, currentTimeMillis, randomString);
        return id;
    }
    private String getLastfieldOfHostName() {
        String substrOfHostName = null;
        try {
            String hostName = InetAddress.getLocalHost().getHostName();
            String[] tokens = hostName.split("\\.");
            substrOfHostName = tokens[tokens.length - 1];
            return substrOfHostName;
        } catch (UnknownHostException e) {
            logger.warn("Failed to get the host name.", e);
        }
        return substrOfHostName;
    }
    private String generateRandomAlphameric(int length) {
        char[] randomChars = new char[length];
        int count = 0;
        Random random = new Random();
        while (count < length) {
            int maxAscii = 'z';
            int randomAscii = random.nextInt(maxAscii);
            boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
            boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
            boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
            if (isDigit|| isUppercase || isLowercase) {
                randomChars[count] = (char) (randomAscii);
                ++count;
            }
        }
        return new String(randomChars);
    }
}

// 代码使用举例
LogTraceIdGenerator logTraceIdGenerator = new RandomIdGenerator();

6.4.2 提高代码的可测试性

关于代码可测试性的问题,主要包含下面两个方面:

  1. generate() 函数定义为静态函数,会影响使用该函数的代码的可测试性
  2. generate() 函数的代码实现依赖运行环境(本机名)、时间函数、随机函数,所以 generate() 函数本身的可测试性也不好

对于第一点,已经在第一轮重构中解决了。将 RandomIdGenerator 类中的 generate() 静态函数重新定义成了普通函数。调用者可以通过依赖注入的方式,在外部创建好 RandomIdGenerator 对象后注入到自己的代码中,从而解决静态函数调用影响代码可测试性的问题

对于第二点,需要在第一轮重构的基础之上再进行重构。重构之后的代码如下所示,主要包括以下几个代码改动:

  1. getLastfieldOfHostName() 函数中,将逻辑比较复杂的那部分代码剥离出来,定义为 getLastSubstrSplittedByDot() 函数。因为 getLastfieldOfHostName() 函数依赖本地主机名,所以,剥离出主要代码之后这个函数变得非常简单,可以不用测试。重点测试 getLastSubstrSplittedByDot() 函数即可
  2. generateRandomAlphameric()getLastSubstrSplittedByDot() 这两个函数的访问权限设置为 protected。这样做的目的是,可以直接在单元测试中通过对象来调用两个函数进行测试
  3. generateRandomAlphameric()getLastSubstrSplittedByDot() 两个函数添加 Google Guava 的 annotation @VisibleForTesting。这个 annotation 没有任何实际的作用,只起到标识的作用,告诉其他人说,这两个函数本该是 private 访问权限的,之所以提升访问权限到 protected,只是为了测试,只能用于单元测试中

public class RandomIdGenerator implements LogTraceIdGenerator {
  private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

  @Override
  public String generate() {
    String substrOfHostName = getLastfieldOfHostName();
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }

  private String getLastfieldOfHostName() {
    String substrOfHostName = null;
    try {
      String hostName = InetAddress.getLocalHost().getHostName();
      substrOfHostName = getLastSubstrSplittedByDot(hostName);
    } catch (UnknownHostException e) {
      logger.warn("Failed to get the host name.", e);
    }
    return substrOfHostName;
  }

  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(String hostName) {
    String[] tokens = hostName.split("\\.");
    String substrOfHostName = tokens[tokens.length - 1];
    return substrOfHostName;
  }

  @VisibleForTesting
  protected String generateRandomAlphameric(int length) {
    char[] randomChars = new char[length];
    int count = 0;
    Random random = new Random();
    while (count < length) {
      int maxAscii = 'z';
      int randomAscii = random.nextInt(maxAscii);
      boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
      boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
      boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
      if (isDigit|| isUppercase || isLowercase) {
        randomChars[count] = (char) (randomAscii);
        ++count;
      }
    }
    return new String(randomChars);
  }
}

打印日志的 Logger 对象被定义为 static final 的,并且在类内部创建,这是否影响到代码的可测试性?是否应该将 Logger 对象通过依赖注入的方式注入到类中呢?

依赖注入之所以能提高代码可测试性,主要是因为,通过这样的方式能轻松地用 mock 对象替换依赖的真实对象。那为什么要 mock 这个对象呢?这是因为,这个对象参与逻辑执行(比如,要依赖它输出的数据做后续的计算)但又不可控。对于 Logger 对象来说,只往里写入数据,并不读取数据,不参与业务逻辑的执行,不会影响代码逻辑的正确性,所以,没有必要 mock Logger 对象

除此之外,一些只是为了存储数据的值对象,比如 String、Map、UserVo,也没必要通过依赖注入的方式来创建,直接在类中通过 new 创建就可以了

6.4.3 编写完善的单元测试

经过上面的重构之后,代码存在的比较明显的问题,基本上都已经解决了。现在为代码补全单元测试。RandomIdGenerator 类中有 4 个函数:

public String generate();
private String getLastfieldOfHostName();
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName);
@VisibleForTesting
protected String generateRandomAlphameric(int length);

先来看后两个函数。这两个函数包含的逻辑比较复杂,是测试的重点。而且,在上一步重构中,为了提高代码的可测试性,已经将这两个部分代码跟不可控的组件(本机名、随机函数、时间函数)进行了隔离。所以,只需要设计完备的单元测试用例即可。具体的代码实现如下所示(注意,这里使用了 JUnit 测试框架):

public class RandomIdGeneratorTest {
  @Test
  public void testGetLastSubstrSplittedByDot() {
    RandomIdGenerator idGenerator = new RandomIdGenerator();
    String actualSubstr = idGenerator.getLastSubstrSplittedByDot("field1.field2.field3");
    Assert.assertEquals("field3", actualSubstr);

    actualSubstr = idGenerator.getLastSubstrSplittedByDot("field1");
    Assert.assertEquals("field1", actualSubstr);

    actualSubstr = idGenerator.getLastSubstrSplittedByDot("field1#field2#field3");
    Assert.assertEquals("field1#field2#field3", actualSubstr);
  }

  // 此单元测试会失败,因为在代码中没有处理hostName为null或空字符串的情况
  @Test
  public void testGetLastSubstrSplittedByDot_nullOrEmpty() {
    RandomIdGenerator idGenerator = new RandomIdGenerator();
    String actualSubstr = idGenerator.getLastSubstrSplittedByDot(null);
    Assert.assertNull(actualSubstr);

    actualSubstr = idGenerator.getLastSubstrSplittedByDot("");
    Assert.assertEquals("", actualSubstr);
  }

  @Test
  public void testGenerateRandomAlphameric() {
    RandomIdGenerator idGenerator = new RandomIdGenerator();
    String actualRandomString = idGenerator.generateRandomAlphameric(6);
    Assert.assertNotNull(actualRandomString);
    Assert.assertEquals(6, actualRandomString.length());
    for (char c : actualRandomString.toCharArray()) {
         Assert.assertTrue(('0' <= c && c <= '9') || ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'));
    }
  }

  // 此单元测试会失败,因为在代码中没有处理length<=0的情况
  @Test
  public void testGenerateRandomAlphameric_lengthEqualsOrLessThanZero() {
    RandomIdGenerator idGenerator = new RandomIdGenerator();
    String actualRandomString = idGenerator.generateRandomAlphameric(0);
    Assert.assertEquals("", actualRandomString);

    actualRandomString = idGenerator.generateRandomAlphameric(-1);
    Assert.assertNull(actualRandomString);
  }
}

再来看 generate() 函数。这个函数也是我们唯一一个暴露给外部使用的 public 函数。虽然逻辑比较简单,最好还是测试一下。但是,它依赖主机名、随机函数、时间函数,我们该如何测试呢?需要 mock 这些函数的实现吗?

实际上,这要分情况来看。前面讲过,写单元测试的时候,测试对象是函数定义的功能,而非具体的实现逻辑。这样才能做到,函数的实现逻辑改变了之后,单元测试用例仍然可以工作。那 generate() 函数实现的功能是什么呢?这完全是由代码编写者自己来定义的

比如,针对同一份 generate() 函数的代码实现,可以有 3 种不同的功能定义,对应 3 种不同的单元测试:

  1. 如果把 generate() 函数的功能定义为:“生成一个随机唯一 ID”,那只要测试多次调用 generate() 函数生成的 ID 是否唯一即可
  2. 如果把 generate() 函数的功能定义为:“生成一个只包含数字、大小写字母和中划线的唯一 ID”,那不仅要测试 ID 的唯一性,还要测试生成的 ID 是否只包含数字、大小写字母和中划线
  3. 如果把 generate() 函数的功能定义为:“生成唯一 ID,格式为:{主机名 substr}-{时间戳}-{8 位随机数}。在主机名获取失败时,返回:null-{时间戳}-{8 位随机数}”,那不仅要测试 ID 的唯一性,还要测试生成的 ID 是否完全符合格式要求

单元测试用例如何写,关键看函数如何定义。针对 generate() 函数的前两种定义,不需要 mock 获取主机名函数、随机函数、时间函数等,但对于第 3 种定义,需要 mock 获取主机名函数,让其返回 null,测试代码运行是否符合预期

最后来看下 getLastfieldOfHostName() 函数。实际上,这个函数不容易测试,因为它调用了一个静态函数(InetAddress.getLocalHost().getHostName();),并且这个静态函数依赖运行环境。但是,这个函数的实现非常简单,肉眼基本上可以排除明显的 bug,所以可以不为其编写单元测试代码。毕竟,写单元测试的目的是为了减少代码 bug,而不是为了写单元测试而写单元测试

当然,如果真的想要对它进行测试,也是有办法的。一种办法是使用更加高级的测试框架。比如 PowerMock,它可以 mock 静态函数。另一种方式是将获取本机名的逻辑再封装为一个新的函数。不过,后一种方法会造成代码过度零碎,也会稍微影响到代码的可读性,这个需要自己去权衡利弊来做选择

6.4.3 添加注释

注释不能太多,也不能太少,主要添加在类和函数上。有人说,好的命名可以替代注释,清晰的表达含义。这点对于变量的命名来说是适用的,但对于类或函数来说就不一定对了。类或函数包含的逻辑往往比较复杂,单纯靠命名很难清晰地表明实现了什么功能,这个时候就需要通过注释来补充。比如,前面提到的对于 generate() 函数的 3 种功能定义,就无法用命名来体现,需要补充到注释里面

对于如何写注释,主要就是写清楚:做什么、为什么、怎么做、怎么用,对一些边界条件、特殊情况进行说明,以及对函数输入、输出、异常进行说明

/**
 * Id Generator that is used to generate random IDs.
 *
 * <p>
 * The IDs generated by this class are not absolutely unique,
 * but the probability of duplication is very low.
 */
public class RandomIdGenerator implements LogTraceIdGenerator {
  private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

  /**
   * Generate the random ID. The IDs may be duplicated only in extreme situation.
   *
   * @return an random ID
   */
  @Override
  public String generate() {
    //...
  }

  /**
   * Get the local hostname and
   * extract the last field of the name string splitted by delimiter '.'.
   *
   * @return the last field of hostname. Returns null if hostname is not obtained.
   */
  private String getLastfieldOfHostName() {
    //...
  }

  /**
   * Get the last field of {@hostName} splitted by delemiter '.'.
   *
   * @param hostName should not be null
   * @return the last field of {@hostName}. Returns empty string if {@hostName} is empty string.
   */
  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(String hostName) {
    //...
  }

  /**
   * Generate random string which
   * only contains digits, uppercase letters and lowercase letters.
   *
   * @param length should not be less than 0
   * @return the random string. Returns empty string if {@length} is 0
   */
  @VisibleForTesting
  protected String generateRandomAlphameric(int length) {
    //...
  }
}

6.5 异常处理

可以把函数的运行结果分为两类。一类是预期的结果,也就是函数在正常情况下输出的结果。一类是非预期的结果,也就是函数在异常(或叫出错)情况下输出的结果。比如,在上面获取本机名的函数,在正常情况下,函数返回字符串格式的本机名;在异常情况下,获取本机名失败,函数返回 UnknownHostException 异常对象

在正常情况下,函数返回数据的类型非常明确,但是,在异常情况下,函数返回的数据类型却非常灵活,有多种选择。除了刚刚提到的类似 UnknownHostException 这样的异常对象之外,函数在异常情况下还可以返回错误码、NULL 值、特殊值(比如 -1)、空对象(比如空字符串、空集合)等

每一种异常返回数据类型,都有各自的特点和适用场景。但有的时候,在异常情况下,函数到底该返回什么样的数据类型,并不那么容易判断。比如,在本机名获取失败的时候,ID 生成器的 generate() 函数应该返回什么呢?是异常?空字符?还是 NULL 值?又或者是其他特殊值(比如 null-15293834874-fd3A9KBn,null 表示本机名未获取到)呢?

函数是代码的一个非常重要的编写单元,而函数的异常处理,又是在编写函数的时候,时刻都要考虑的。所以,如何设计函数在异常情况下的返回数据类型是很重要的

前面把一份非常简单的 ID 生成器的代码,从“能用”重构成了“好用”。最终给出的代码看似已经很完美了,但是如果再用心推敲一下,代码中关于出错处理的方式,还有进一步优化的空间

public class RandomIdGenerator implements IdGenerator {
    private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

    @Override
    public String generate() {
        String substrOfHostName = getLastFiledOfHostName();
        long currentTimeMillis = System.currentTimeMillis();
        String randomString = generateRandomAlphameric(8);
        String id = String.format("%s-%d-%s",
                substrOfHostName, currentTimeMillis, randomString);
        return id;
    }
    private String getLastFiledOfHostName() {
        String substrOfHostName = null;
        try {
            String hostName = InetAddress.getLocalHost().getHostName();
            substrOfHostName = getLastSubstrSplittedByDot(hostName);
        } catch (UnknownHostException e) {
            logger.warn("Failed to get the host name.", e);
        }
        return substrOfHostName;
    }
    @VisibleForTesting
    protected String getLastSubstrSplittedByDot(String hostName) {
        String[] tokens = hostName.split("\\.");
        String substrOfHostName = tokens[tokens.length - 1];
        return substrOfHostName;
    }
    @VisibleForTesting
    protected String generateRandomAlphameric(int length) {
        char[] randomChars = new char[length];
        int count = 0;
        Random random = new Random();
        while (count < length) {
            int maxAscii = 'z';
            int randomAscii = random.nextInt(maxAscii);
            boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
            boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
            boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
            if (isDigit|| isUppercase || isLowercase) {
                randomChars[count] = (char) (randomAscii);
                ++count;
            }
        }
        return new String(randomChars);
    }
}

这段代码中有四个函数。针对这四个函数的出错处理方式,有下面这样几个问题:

  1. 对于 generate() 函数,如果本机名获取失败,函数返回什么?这样的返回值是否合理?
  2. 对于 getLastFiledOfHostName() 函数,是否应该将 UnknownHostException 异常在函数内部吞掉(try-catch 并打印日志)?还是应该将异常继续往上抛出?如果往上抛出的话,是直接把 UnknownHostException 异常原封不动地抛出,还是封装成新的异常抛出?
  3. 对于 getLastSubstrSplittedByDot(String hostName) 函数,如果 hostName 为 NULL 或者是空字符串,这个函数应该返回什么?
  4. 对于 generateRandomAlphameric(int length) 函数,如果 length 小于 0 或者等于 0,这个函数应该返回什么?

6.5.1 函数出错应该返回啥?

关于函数出错返回数据类型,总结了 4 种情况,它们分别是:错误码、NULL 值、空对象、异常对象

6.5.1.1 返回错误码

C 语言中没有异常这样的语法机制,因此,返回错误码便是最常用的出错处理方式。而在 Java、Python 等比较新的编程语言中,大部分情况下,都用异常来处理函数出错的情况,极少会用到错误码

在 C 语言中,错误码的返回方式有两种:一种是直接占用函数的返回值,函数正常执行的返回值放到出参中;另一种是将错误码定义为全局变量,在函数执行出错时,函数调用者通过这个全局变量来获取错误码。针对这两种方式,示例如下:

// 错误码的返回方式一:pathname/flags/mode为入参;fd为出参,存储打开的文件句柄。
int open(const char *pathname, int flags, mode_t mode, int* fd) {
    if (/*文件不存在*/) {
        return EEXIST;
    }
    if (/*没有访问权限*/) {
        return EACCESS;
    }
    if (/*打开文件成功*/) {
        return SUCCESS; // C语言中的宏定义:#define SUCCESS 0
    }
    // ...
}
//使用举例
int fd;
int result = open(“c:\test.txt”, O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO, &fd);
if (result == SUCCESS) {
    // 取出fd使用
} else if (result == EEXIST) {
    //...
} else if (result == EACESS) {
    //...
}

// 错误码的返回方式二:函数返回打开的文件句柄,错误码放到errno中。
int errno; // 线程安全的全局变量
int open(const char *pathname, int flags, mode_t mode){
    if (/*文件不存在*/) {
        errno = EEXIST;
        return -1;
    }
    if (/*没有访问权限*/) {
        errno = EACCESS;
        return -1;
    }
    // ...
}
// 使用举例
int hFile = open(“c:\test.txt”, O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO);
if (-1 == hFile) {
    printf("Failed to open file, error no: %d.\n", errno);
    if (errno == EEXIST ) {
        // ...
    } else if(errno == EACCESS) {
        // ...
    }
    // ...
}

实际上,如果你熟悉的编程语言中有异常这种语法机制,那就尽量不要使用错误码。异常相对于错误码,有诸多方面的优势,比如可以携带更多的错误信息(exception 中可以有 message、stack trace 等信息)等

6.5.1.2 返回 NULL 值

在多数编程语言中,用 NULL 来表示“不存在”这种语义。不过,网上很多人不建议函数返回 NULL 值,认为这是一种不好的设计思路,主要的理由有以下两个:

  1. 如果某个函数有可能返回 NULL 值,在使用它的时候,忘记了做 NULL 值判断,就有可能会抛出空指针异常(Null Pointer Exception,缩写为 NPE)
  2. 如果定义了很多返回值可能为 NULL 的函数,那代码中就会充斥着大量的 NULL 值判断逻辑,一方面写起来比较繁琐,另一方面它们跟正常的业务逻辑耦合在一起,会影响代码的可读性

如下例:

public class UserService {
    private UserRepo userRepo; // 依赖注入
    public User getUser(String telephone) {
        // 如果用户不存在,则返回null
        return null;
    }
}

// 使用函数getUser()
User user = userService.getUser("18917718965");
if (user != null) { // 做NULL值判断,否则有可能会报NPE
    String email = user.getEmail();
    if (email != null) { // 做NULL值判断,否则有可能会报NPE
        String escapedEmail = email.replaceAll("@", "#");
    }
}

那是否可以用异常来替代 NULL 值,在查找用户不存在的时候,让函数抛出 UserNotFoundException 异常呢?个人觉得,尽管返回 NULL 值有诸多弊端,但对于以 get、find、select、search、query 等单词开头的查找函数来说,数据不存在,并非一种异常情况,这是一种正常行为。所以,返回代表不存在语义的 NULL 值比返回异常更加合理

不过,话说回来,刚刚讲的这个理由,也并不是特别有说服力。对于查找数据不存在的情况,函数到底是该用 NULL 值还是异常,有一个比较重要的参考标准是,看项目中的其他类似查找函数都是如何定义的,只要整个项目遵从统一的约定即可。如果项目从零开始开发,并没有统一约定和可以参考的代码,那选择两者中的任何一种都可以。只需要在函数定义的地方注释清楚,让调用者清晰地知道数据不存在的时候会返回什么就可以了

再补充说明一点,对于查找函数来说,除了返回数据对象之外,有的还会返回下标位置,比如 Java 中的 indexOf() 函数,用来实现在某个字符串中查找另一个子串第一次出现的位置。函数的返回值类型为基本类型 int。这个时候,就无法用 NULL 值来表示不存在的情况了。对于这种情况,有两种处理思路,一种是返回 NotFoundException,一种是返回一个特殊值,比如 -1。不过,显然 -1 更加合理,理由也是同样的,也就是说“没有查找到”是一种正常而非异常的行为

6.5.1.3 返回空对象

上面讲到,返回 NULL 值有各种弊端。应对这个问题有一个比较经典的策略,那就是应用空对象设计模式(Null Object Design Pattern)。这里讲两种比较简单、比较特殊的空对象,那就是空字符串和空集合

当函数返回的数据是字符串类型或者集合类型的时候,可以用空字符串或空集合替代 NULL 值,来表示不存在的情况。这样,在使用函数的时候,就可以不用做 NULL 值判断。示例代码如下:

// 使用空集合替代NULL
public class UserService {
    private UserRepo userRepo; // 依赖注入
    public List<User> getUsers(String telephonePrefix) {
        // 没有查找到数据
        return Collectiosn.emptyList();
    }
}

// getUsers使用示例
List<User> users = userService.getUsers("189");
for (User user : users) { //这里不需要做NULL值判断
    // ...
}

// 使用空字符串替代NULL
public String retrieveUppercaseLetters(String text) {
    // 如果text中没有大写字母,返回空字符串,而非NULL值
    return "";
}
// retrieveUppercaseLetters()使用举例
String uppercaseLetters = retrieveUppercaseLetters("wangzheng");
int length = uppercaseLetters.length(); // 不需要做NULL值判断
System.out.println("Contains " + length + " upper case letters.");
6.5.1.4 抛出异常对象

尽管上面讲了很多函数出错的返回数据类型,但是,最常用的函数出错处理方式就是抛出异常。异常可以携带更多的错误信息,比如函数调用栈信息。除此之外,异常可以将正常逻辑和异常逻辑的处理分离开来,这样代码的可读性就会更好

不同的编程语言的异常语法稍有不同。像 C++ 和大部分的动态语言(Python、Ruby、JavaScript 等)都只定义了一种异常类型:运行时异常(Runtime Exception)。而像 Java,除了运行时异常外,还定义了另外一种异常类型:编译时异常(Compile Exception)

对于运行时异常,在编写代码的时候,可以不用主动去 try-catch,编译器在编译代码的时候,并不会检查代码是否有对运行时异常做了处理。相反,对于编译时异常,在编写代码的时候,需要主动去 try-catch 或者在函数定义中声明,否则编译就会报错。所以,运行时异常也叫作非受检异常(Unchecked Exception),编译时异常也叫作受检异常(Checked Exception)

如果你熟悉的编程语言中,只定义了一种异常类型,那用起来反倒比较简单。如果你熟悉的编程语言中(比如 Java),定义了两种异常类型,那在异常出现的时候,应该选择抛出哪种异常类型呢?是受检异常还是非受检异常?

对于代码 bug(比如数组越界)以及不可恢复异常(比如数据库连接失败),即便捕获了,也做不了太多事情,所以,倾向于使用非受检异常。对于可恢复异常、业务异常,比如提现金额大于余额的异常,更倾向于使用受检异常,明确告知调用者需要捕获处理

如下例,当 Redis 的地址(参数 address)没有设置的时候,直接使用默认的地址(比如本地地址和默认端口);当 Redis 的地址格式不正确的时候,希望程序能 fail-fast,也就是说,把这种情况当成不可恢复的异常,直接抛出运行时异常,将程序终止掉

// address格式:"192.131.2.33:7896"
public void parseRedisAddress(String address) {
    this.host = RedisConfig.DEFAULT_HOST;
    this.port = RedisConfig.DEFAULT_PORT;
    if (StringUtils.isBlank(address)) {
        return;
    }
    String[] ipAndPort = address.split(":");
    if (ipAndPort.length != 2) {
        throw new RuntimeException("...");
    }
    this.host = ipAndPort[0];
    // parseInt()解析失败会抛出 NumberFormatException 运行时异常
    this.port = Integer.parseInt(ipAndPort[1]);
}

实际上,Java 支持的受检异常一直被人诟病,很多人主张所有的异常情况都应该使用非受检异常。支持这种观点的理由主要有以下三个:

  1. 受检异常需要显式地在函数定义中声明。如果函数会抛出很多受检异常,那函数的定义就会非常冗长,这就会影响代码的可读性,使用起来也不方便
  2. 编译器强制必须显示地捕获所有的受检异常,代码实现会比较繁琐。而非受检异常正好相反,不需要在定义中显示声明,并且是否需要捕获处理,也可以自由决定
  3. 受检异常的使用违反开闭原则。如果给某个函数新增一个受检异常,这个函数所在的函数调用链上的所有位于其之上的函数都需要做相应的代码修改,直到调用链中的某个函数将这个新增的异常 try-catch 处理掉为止。而新增非受检异常可以不改动调用链上的代码。可以灵活地选择在某个函数中集中处理,比如在 Spring 中的 AOP 切面中集中处理异常

不过,非受检异常也有弊端,它的优点其实也正是它的缺点。从刚刚的表述中,可以看出,非受检异常使用起来更加灵活,怎么处理的主动权这里就交给了程序员。前面也讲到,过于灵活会带来不可控,非受检异常不需要显式地在函数定义中声明,那在使用函数的时候,就需要查看代码才能知道具体会抛出哪些异常。非受检异常不需要强制捕获处理,那程序员就有可能漏掉一些本应该捕获处理的异常

对于应该用受检异常还是非受检异常,网上的争论有很多,但并没有一个非常强有力的理由能够说明一个就一定比另一个更好。所以,只需要根据团队的开发习惯,在同一个项目中,制定统一的异常处理规范即可

讲了两种异常类型,再来讲下,如何处理函数抛出的异常?总结一下,一般有下面三种处理方法:

1、直接吞掉

public void func1() throws Exception1 {
    // ...
}
public void func2() {
    //...
    try {
        func1();
} catch(Exception1 e) {
    log.warn("...", e); //吐掉:try-catch打印日志
}
    //...
}

2、原封不动地 re-throw

public void func1() throws Exception1 {
    // ...
}
public void func2() throws Exception2 {
    //...
    try {
        func1();
    } catch(Exception1 e) {
        throw new Exception2("...", e); // wrap成新的Exception2然后re-throw
}
    //...
}

3、包装成新的异常 re-throw

public void func1() throws Exception1 {
    // ...
}
public void func2() throws Exception2 {
    //...
    try {
        func1();
    } catch(Exception1 e) {
        throw new Exception2("...", e); // wrap成新的Exception2然后re-throw
    }
    //...
}

当面对函数抛出异常的时候,应该选择上面的哪种处理方式呢?这里总结了下面三个参考原则:

  1. 如果 func1() 抛出的异常是可以恢复,且 func2() 的调用方并不关心此异常,完全可以在 func2() 内将 func1() 抛出的异常吞掉
  2. 如果 func1() 抛出的异常对 func2() 的调用方来说,也是可以理解的、关心的 ,并且在业务概念上有一定的相关性,可以选择直接将 func1 抛出的异常 re-throw
  3. 如果 func1() 抛出的异常太底层,对 func2() 的调用方来说,缺乏背景去理解、且业务概念上无关,可以将它重新包装成调用方可以理解的新异常,然后 re-throw

总之,是否往上继续抛出,要看上层代码是否关心这个异常。关心就将它抛出,否则就直接吞掉。是否需要包装成新的异常抛出,看上层代码是否能理解这个异常、是否业务相关。如果能理解、业务相关就可以直接抛出,否则就封装成新的异常抛出

6.5.2 重构 ID 生成器项目中各函数的异常处理代码

平时进行软件设计开发的时候,除了要保证正常情况下的逻辑运行正确之外,还需要编写大量额外的代码,来处理有可能出现的异常情况,以保证代码在任何情况下,都在我们的掌控之内,不会出现非预期的运行结果。程序的 bug 往往都出现在一些边界条件和异常情况下,所以说,异常处理得好坏直接影响了代码的健壮性。全面、合理地处理各种异常能有效减少代码 bug,也是保证代码质量的一个重要手段

1、重构 generate() 函数

首先来看,对于 generate() 函数,如果本机名获取失败,函数返回什么?这样的返回值是否合理?

public String generate() {
    String substrOfHostName = getLastFiledOfHostName();
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
        substrOfHostName, currentTimeMillis, randomString);
    return id;
}

ID 由三部分构成:本机名、时间戳和随机数。时间戳和随机数的生成函数不会出错,唯独主机名有可能获取失败。在目前的代码实现中,如果主机名获取失败,substrOfHostName 为 NULL,那 generate() 函数会返回类似“null-16723733647-83Ab3uK6”这样的数据。如果主机名获取失败,substrOfHostName 为空字符串,那 generate() 函数会返回类似“-16723733647-83Ab3uK6”这样的数据

在异常情况下,返回上面两种特殊的 ID 数据格式,这样的做法是否合理呢?这个其实很难讲,要看具体的业务是怎么设计的。不过,更倾向于明确地将异常告知调用者。所以,这里最好是抛出受检异常,而非特殊值

按照这个设计思路,我们对 generate() 函数进行重构。重构之后的代码如下所示:

public String generate() throws IdGenerationFailureException {
    String substrOfHostName = getLastFiledOfHostName();
    if (substrOfHostName == null || substrOfHostName.isEmpty()) {
        throw new IdGenerationFailureException("host name is empty.");
    }
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
    substrOfHostName, currentTimeMillis, randomString);
    return id;
}

2、重构 getLastFiledOfHostName() 函数

对于 getLastFiledOfHostName() 函数,是否应该将 UnknownHostException 异常在函数内部吞掉(try-catch 并打印日志),还是应该将异常继续往上抛出?如果往上抛出的话,是直接把 UnknownHostException 异常原封不动地抛出,还是封装成新的异常抛出?

private String getLastFiledOfHostName() {
    String substrOfHostName = null;
    try {
        String hostName = InetAddress.getLocalHost().getHostName();
        substrOfHostName = getLastSubstrSplittedByDot(hostName);
    } catch (UnknownHostException e) {
        logger.warn("Failed to get the host name.", e);
    }
    return substrOfHostName;
}

现在的处理方式是当主机名获取失败的时候,getLastFiledOfHostName() 函数返回 NULL值。前面讲过,是返回 NULL 值还是异常对象,要看获取不到数据是正常行为,还是异常行为。获取主机名失败会影响后续逻辑的处理,并不是我们期望的,所以,它是一种异常行为。这里最好是抛出异常,而非返回 NULL 值

至于是直接将 UnknownHostException 抛出,还是重新封装成新的异常抛出,要看函数跟异常是否有业务相关性。getLastFiledOfHostName() 函数用来获取主机名的最后一个字段,UnknownHostException 异常表示主机名获取失败,两者算是业务相关,所以可以直接将 UnknownHostException 抛出,不需要重新包裹成新的异常

按照上面的设计思路,对 getLastFiledOfHostName() 函数进行重构。重构后的代码如下所示:

private String getLastFiledOfHostName() throws UnknownHostException{
    String substrOfHostName = null;
    String hostName = InetAddress.getLocalHost().getHostName();
    substrOfHostName = getLastSubstrSplittedByDot(hostName);

    return substrOfHostName;
}

getLastFiledOfHostName() 函数修改之后,generate() 函数也要做相应的修改。需要在 generate() 函数中,捕获 getLastFiledOfHostName() 抛出的 UnknownHostException 异常。当捕获到这个异常之后,应该怎么处理呢?

按照之前的分析,ID 生成失败的时候,需要明确地告知调用者。所以,不能在 generate() 函数中,将 UnknownHostException 这个异常吞掉。那应该原封不动地抛出,还是封装成新的异常抛出呢?

这里选择后者。在 generate() 函数中,需要捕获 UnknownHostException 异常,并重新包裹成新的异常 IdGenerationFailureException 往上抛出。之所以这么做,有下面三个原因:

  1. 调用者在使用 generate() 函数的时候,只需要知道它生成的是随机唯一 ID,并不关心 ID 是如何生成的。也就说是,这是依赖抽象而非实现编程。如果 generate() 函数直接抛出 UnknownHostException 异常,实际上是暴露了实现细节
  2. 从代码封装的角度来讲,不希望将 UnknownHostException 这个比较底层的异常,暴露给更上层的代码,也就是调用 generate() 函数的代码。而且,调用者拿到这个异常的时候,并不能理解这个异常到底代表了什么,也不知道该如何处理
  3. UnknownHostException 异常跟 generate() 函数,在业务概念上没有相关性

按照上面的设计思路,对 generate() 的函数再次进行重构。重构后的代码如下所示:

public String generate() throws IdGenerationFailureException {
    String substrOfHostName = null;
    try {
        substrOfHostName = getLastFiledOfHostName();
    } catch (UnknownHostException e) {
        throw new IdGenerationFailureException("host name is empty.");
    }

    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
    substrOfHostName, currentTimeMillis, randomString);

    return id;
}

3、重构 getLastSubstrSplittedByDot() 函数

对于 getLastSubstrSplittedByDot(String hostName) 函数,如果 hostName 为 NULL 或者空字符串,这个函数应该返回什么?

@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
    String[] tokens = hostName.split("\\.");
    String substrOfHostName = tokens[tokens.length - 1];
    return substrOfHostName;
}

理论上讲,参数传递的正确性应该由程序员来保证,无需做 NULL 值或者空字符串的判断和特殊处理。调用者本不应该把 NULL 值或者空字符串传递给 getLastSubstrSplittedByDot() 函数。如果传递了,那就是 code bug,需要修复。但是,话说回来,谁也保证不了程序员就一定不会传递 NULL 值或者空字符串。那到底该不该做 NULL 值或空字符串的判断呢?

如果函数是 private 类私有的,只在类内部被调用,完全在你自己的掌控之下,自己保证在调用这个 private 函数的时候,不要传递 NULL 值或空字符串就可以了。所以,可以不在 private 函数中做 NULL 值或空字符串的判断。如果函数是 public 的,你无法掌控会被谁调用以及如何调用(有可能某个同事一时疏忽,传递进了 NULL 值,这种情况也是存在的),为了尽可能提高代码的健壮性,最好是在 public 函数中做 NULL 值或空字符串的判断

这里可能会说,getLastSubstrSplittedByDot() 是 protected 的,既不是 private 函数,也不是 public 函数,那要不要做 NULL 值或空字符串的判断呢?

之所以将它设置为 protected,是为了方便写单元测试。不过,单元测试可能要测试一些 corner case,比如输入是 NULL 值或者空字符串的情况。所以,这里最好也加上 NULL 值或空字符串的判断逻辑。虽然加上有些冗余,但多加些检验总归不会错的

按照这个设计思路,我们对 getLastSubstrSplittedByDot() 函数进行重构。重构之后的代码如下所示:

@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
    if (hostName == null || hostName.isEmpty()) {
        throw IllegalArgumentException("..."); //运行时异常
    }
    String[] tokens = hostName.split("\\.");
    String substrOfHostName = tokens[tokens.length - 1];
    return substrOfHostName;
}

按照上面讲的,在使用这个函数的时候,自己也要保证不传递 NULL 值或者空字符串进去。所以,getLastFiledOfHostName() 函数的代码也要作相应的修改。修改之后的代码如下所示:

private String getLastFiledOfHostName() throws UnknownHostException{
    String substrOfHostName = null;
    String hostName = InetAddress.getLocalHost().getHostName();
    if (hostName == null || hostName.isEmpty()) { // 此处做判断
        throw new UnknownHostException("...");
    }
    substrOfHostName = getLastSubstrSplittedByDot(hostName);
    return substrOfHostName;
}

4、重构 generateRandomAlphameric() 函数

对于 generateRandomAlphameric(int length) 函数,如果 length < 0 或 length = 0,这个函数应该返回什么?

@VisibleForTesting
protected String generateRandomAlphameric(int length) {
    char[] randomChars = new char[length];
    int count = 0;
    Random random = new Random();

    while (count < length) {
        int maxAscii = 'z';
        int randomAscii = random.nextInt(maxAscii);
        boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
        boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
        boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';

        if (isDigit|| isUppercase || isLowercase) {
            randomChars[count] = (char) (randomAscii);
            ++count;
        }
    }
    return new String(randomChars);
}

先来看 length < 0 的情况。生成一个长度为负值的随机字符串是不符合常规逻辑的,是一种异常行为。所以,当传入的参数 length < 0 的时候,抛出 IllegalArgumentException 异常

再来看 length = 0 的情况。length = 0 是否是异常行为呢?这就看怎么定义了。既可以把它定义为一种异常行为,抛出 IllegalArgumentException 异常,也可以把它定义为一种正常行为,让函数在入参 length = 0 的情况下,直接返回空字符串。不管选择哪种处理方式,最关键的一点是,要在函数注释中,明确告知 length = 0 的情况下,会返回什么样的数据

重构之后的 RandomIdGenerator 代码

public class RandomIdGenerator implements IdGenerator {
    private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

    @Override
    public String generate() throws IdGenerationFailureException {
        String substrOfHostName = null;
        try {
            substrOfHostName = getLastFiledOfHostName();
        } catch (UnknownHostException e) {
            throw new IdGenerationFailureException("...", e);
        }
        long currentTimeMillis = System.currentTimeMillis();
        String randomString = generateRandomAlphameric(8);
        String id = String.format("%s-%d-%s",
                substrOfHostName, currentTimeMillis, randomString);
        return id;
    }
    private String getLastFiledOfHostName() throws UnknownHostException{
        String substrOfHostName = null;
        String hostName = InetAddress.getLocalHost().getHostName();
        if (hostName == null || hostName.isEmpty()) {
            throw new UnknownHostException("...");
        }
        substrOfHostName = getLastSubstrSplittedByDot(hostName);
        return substrOfHostName;
    }
    @VisibleForTesting
    protected String getLastSubstrSplittedByDot(String hostName) {
        if (hostName == null || hostName.isEmpty()) {
            throw new IllegalArgumentException("...");
        }
        String[] tokens = hostName.split("\\.");
        String substrOfHostName = tokens[tokens.length - 1];
        return substrOfHostName;
    }
    @VisibleForTesting
    protected String generateRandomAlphameric(int length) {
        if (length <= 0) {
            throw new IllegalArgumentException("...");
        }
        char[] randomChars = new char[length];
        int count = 0;
        Random random = new Random();
        while (count < length) {
            int maxAscii = 'z';
            int randomAscii = random.nextInt(maxAscii);
            boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
            boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
            boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
            if (isDigit|| isUppercase || isLowercase) {
                randomChars[count] = (char) (randomAscii);
                ++count;
            }
        }
        return new String(randomChars);
    }
}

7. 总结

包括前两篇:

在这里插入图片描述

7.1 代码质量评判标准

7.1.1 如何评价代码质量的高低?

代码质量的评价有很强的主观性,描述代码质量的词汇也有很多,比如可读性、可维护性、灵活、优雅、简洁。这些词汇是从不同的维度去评价代码质量的。它们之间有互相作用,并不是独立的,比如,代码的可读性好、可扩展性好就意味着代码的可维护性好。代码质量高低是一个综合各种因素得到的结论。我们并不能通过单一维度去评价一段代码的好坏

7.1.2 最常用的评价标准有哪几个?

最常用到几个评判代码质量的标准有:可维护性、可读性、可扩展性、灵活性、简洁性、可复用性、可测试性。其中,可维护性、可读性、可扩展性又是提到最多的、最重要的三个评价标准

7.1.3 如何才能写出高质量的代码?

要写出高质量代码,就需要掌握一些更加细化、更加能落地的编程方法论,这就包含面向对象设计思想、设计原则、设计模式、编码规范、重构技巧等

在这里插入图片描述

7.2 面向对象

7.2.1 面向对象概述

现在,主流的编程范式或者编程风格有三种,它们分别是面向过程、面向对象和函数式编程。面向对象这种编程风格又是这其中最主流的。现在比较流行的编程语言大部分都是面向对象编程语言。大部分项目也都是基于面向对象编程风格开发的。面向对象编程因为其具有丰富的特性(封装、抽象、继承、多态),可以实现很多复杂的设计思路,是很多设计原则、设计模式编码实现的基础

7.2.2 面向对象四大特性

封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方法来访问内部信息或者数据。它需要编程语言提供权限访问控制语法来支持,例如 Java 中的 private、protected、public 关键字。封装特性存在的意义,一方面是保护数据不被随意修改,提高代码的可维护性;另一方面是仅暴露有限的必要接口,提高类的易用性

如果说封装主要讲如何隐藏信息、保护数据,那抽象就是讲如何隐藏方法的具体实现,让使用者只需要关心方法提供了哪些功能,不需要知道这些功能是如何实现的。抽象可以通过接口类或者抽象类来实现。抽象存在的意义,一方面是修改实现不需要改变定义;另一方面,它也是处理复杂系统的有效手段,能有效地过滤掉不必要关注的信息

继承用来表示类之间的 is-a 关系,分为两种模式:单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类。为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持。继承主要是用来解决代码复用的问题

多态是指子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。多态这种特性也需要编程语言提供特殊的语法机制来实现,比如继承、接口类、duck-typing。多态可以提高代码的扩展性和复用性,是很多设计模式、设计原则、编程技巧的代码实现基础

7.2.3 面向对象 VS 面向过程

面向对象编程相比面向过程编程的优势主要有三个:

  1. 对于大规模复杂程序的开发,程序的处理流程并非单一的一条主线,而是错综复杂的网状结构。面向对象编程比起面向过程编程,更能应对这种复杂类型的程序开发
  2. 面向对象编程相比面向过程编程,具有更加丰富的特性(封装、抽象、继承、多态)。利用这些特性编写出来的代码,更加易扩展、易复用、易维护
  3. 从编程语言跟机器打交道方式的演进规律中,可以总结出:面向对象编程语言比起面向过程编程语言,更加人性化、更加高级、更加智能

面向对象编程一般使用面向对象编程语言来进行,但是,不用面向对象编程语言,照样可以进行面向对象编程。反过来讲,即便我们使用面向对象编程语言,写出来的代码也不一定是面向对象编程风格的,也有可能是面向过程编程风格的

面向对象和面向过程两种编程风格并不是非黑即白、完全对立的。在用面向对象编程语言开发的软件中,面向过程风格的代码并不少见,甚至在一些标准的开发库(比如 JDK、Apache Commons、Google Guava)中,也有很多面向过程风格的代码

不管使用面向过程还是面向对象哪种风格来写代码,最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。只要能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌控范围内为我们所用,就大可不用避讳在面向对象编程中写面向过程风格的代码

7.2.4 面向对象分析、设计与编程

面向对象分析(OOA)、面向对象设计(OOD)、面向对象编程(OOP),是面向对象开发的三个主要环节。简单点讲,面向对象分析就是要搞清楚做什么,面向对象设计就是要搞清楚怎么做,面向对象编程就是将分析和设计的的结果翻译成代码的过程

需求分析的过程实际上是一个不断迭代优化的过程。不要试图一下就给出一个完美的解决方案,而是先给出一个粗糙的、基础的方案,有一个迭代的基础,然后再慢慢优化。这样一个思考过程能让我们摆脱无从下手的窘境

面向对象设计和实现要做的事情就是把合适的代码放到合适的类中。至于到底选择哪种划分方法,判定的标准是让代码尽量地满足“松耦合、高内聚”、单一职责、对扩展开放对修改关闭等各种设计原则和思想,尽量地做到代码可复用、易读、易扩展、易维护

面向对象分析的产出是详细的需求描述。面向对象设计的产出是类。在面向对象设计这一环节中,将需求描述转化为具体的类的设计。这个环节的工作可以拆分为下面四个部分:

  1. 划分职责进而识别出有哪些类
    根据需求描述,把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,可否归为同一个类
  2. 定义类及其属性和方法
    识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选出真正的方法,把功能点中涉及的名词,作为候选属性,然后同样再进行过滤筛选
  3. 定义类与类之间的交互关系
    UML 统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖。从更加贴近编程的角度,对类与类之间的关系做了调整,保留了四个关系:泛化、实现、组合、依赖
  4. 将类组装起来并提供执行入口
    将所有的类组装在一起,提供一个执行入口。这个入口可能是一个 main() 函数,也可能是一组给外部用的 API 接口。通过这个入口,就能触发整个代码跑起来

7.2.5 接口 VS 抽象类

抽象类不允许被实例化,只能被继承。它可以包含属性和方法。方法既可以包含代码实现,也可以不包含代码实现。不包含代码实现的方法叫作抽象方法。子类继承抽象类,必须实现抽象类中的所有抽象方法

接口不能包含属性(Java 可以定义静态常量),只能声明方法,方法不能包含代码实现(Java8 以后可以有默认实现)。类实现接口的时候,必须实现接口中声明的所有方法

抽象类是对成员变量和方法的抽象,是一种 is-a 关系,是为了解决代码复用问题。接口仅仅是对方法的抽象,是一种 has-a 关系,表示具有某一组行为特性,是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性

什么时候该用抽象类?什么时候该用接口?实际上,判断的标准很简单。如果要表示一种 is-a 的关系,并且是为了解决代码复用问题,就用抽象类;如果要表示一种 has-a 关系,并且是为了解决抽象而非代码复用问题,那就用接口

7.2.6 基于接口而非实现编程

应用这条原则,可以将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性

实际上,“基于接口而非实现编程”这条原则的另一个表述方式是,“基于抽象而非实现编程”。后者的表述方式其实更能体现这条原则的设计初衷。在软件开发中,最大的挑战之一就是需求的不断变化,这也是考验代码设计好坏的一个标准

越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一

7.2.7 多用组合少用继承

为什么不推荐使用继承?

继承是面向对象的四大特性之一,用来表示类之间的 is-a 关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。在这种情况下,应该尽量少用,甚至不用继承

组合相比继承有哪些优势?

继承主要有三个作用:表示 is-a 关系、支持多态特性、代码复用。而这三个作用都可以通过组合、接口、委托三个技术手段来达成。除此之外,利用组合还能解决层次过深、过复杂的继承关系影响代码可维护性的问题

如何判断该用组合还是继承?

尽管鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。在实际的项目开发中,还是要根据具体的情况,来选择该用继承还是组合。如果类之间的继承结构稳定,层次比较浅,关系不复杂,就可以大胆地使用继承。反之,就尽量使用组合来替代继承。除此之外,还有一些设计模式、特殊的应用场景,会固定使用继承或者组合

7.2.8 贫血模型 VS 充血模型

平时做 Web 项目的业务开发,大部分都是基于贫血模型的 MVC 三层架构,把它称为传统的开发模式。之所以称之为“传统”,是相对于新兴的基于充血模型的 DDD 开发模式来说的。基于贫血模型的传统开发模式,是典型的面向过程的编程风格。相反,基于充血模型的 DDD 开发模式,是典型的面向对象的编程风格

不过,DDD 也并非银弹。对于业务不复杂的系统开发来说,基于贫血模型的传统开发模式简单够用,基于充血模型的 DDD 开发模式有点大材小用,无法发挥作用。相反,对于业务复杂的系统开发来说,基于充血模型的 DDD 开发模式,因为前期需要在设计上投入更多时间和精力,来提高代码的复用性和可维护性,所以相比基于贫血模型的开发模式,更加有优势

基于充血模型的 DDD 开发模式跟基于贫血模型的传统开发模式相比,主要区别在 Service 层。在基于充血模型的开发模式下,将部分原来在 Service 类中的业务逻辑移动到了一个充血的 Domain 领域模型中,让 Service 类的实现依赖这个 Domain 类。不过,Service 类并不会完全移除,而是负责一些不适合放在 Domain 类中的功能。比如,负责与 Repository 层打交道、跨领域模型的业务聚合功能、幂等事务等非功能性的工作

基于充血模型的 DDD 开发模式跟基于贫血模型的传统开发模式相比,Controller 层和 Repository 层的代码基本上相同。这是因为,Repository 层的 Entity 生命周期有限,Controller 层的 VO 只是单纯作为一种 DTO。两部分的业务逻辑都不会太复杂。业务逻辑主要集中在 Service 层。所以,Repository 层和 Controller 层继续沿用贫血模型的设计思路是没有问题的

在这里插入图片描述

7.3 设计原则

7.3.1 SOLID 原则:SRP 单一职责原则

一个类只负责完成一个职责或者功能。单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、松耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性

不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:

  • 类中的代码行数、函数或者属性过多
  • 类依赖的其他类过多或者依赖类的其他类过多
  • 私有方法过多
  • 比较难给类起一个合适的名字
  • 类中大量的方法都是集中操作类中的某几个属性

7.3.2 SOLID 原则:OCP 开闭原则

如何理解“对扩展开放、修改关闭”?

添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。关于定义,有两点要注意。第一点是,开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。第二点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”

如何做到“对扩展开放、修改关闭”?

要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上

很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是 23 种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)

7.3.3 SOLID 原则:LSP 里式替换原则

子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏

里式替换原则是用来指导继承关系中子类该如何设计的一个原则。理解里式替换原则,最核心的就是理解“design by contract,按照协议来设计”这几个字。父类定义了函数的“约定”(或者叫协议),那子类可以改变函数的内部实现逻辑,但不能改变函数的原有“约定”。这里的“约定”包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明

理解这个原则,还要弄明白,里式替换原则跟多态的区别。虽然从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,用来指导继承关系中子类该如何设计,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑及不破坏原有程序的正确性

7.3.4 SOLID 原则:ISP 接口隔离原则

接口隔离原则的描述是:客户端不应该强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。理解“接口隔离原则”的重点是理解其中的“接口”二字。这里有三种不同的理解:

  1. 如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口
  2. 如果把“接口”理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数
  3. 如果把“接口”理解为 OOP 中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数

单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考的角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一

7.3.5 SOLID 原则:DIP 依赖倒置原则

控制反转: 实际上,控制反转是一个比较笼统的设计思想,并不是一种具体的实现方法,一般用来指导框架层面的设计。这里所说的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程通过框架来控制。流程的控制权从程序员“反转”给了框架

依赖注入: 依赖注入和控制反转恰恰相反,它是一种具体的编码技巧。不通过 new 的方式在类内部创建依赖类的对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或“注入”)给类来使用

依赖注入框架: 通过依赖注入框架提供的扩展点,简单配置一下所有需要的类及其类与类之间的依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情

依赖反转原则: 依赖反转原则也叫作依赖倒置原则。这条原则跟控制反转有点类似,主要用来指导框架层面的设计。高层模块不依赖低层模块,它们共同依赖同一个抽象。抽象不需要依赖具体实现细节,具体实现细节依赖抽象

7.3.6 KISS、YAGNI 原则

KISS 原则的中文描述是:尽量保持简单。KISS 原则是保持代码可读和可维护的重要手段。KISS 原则中的“简单”并不是以代码行数来考量的。代码行数越少并不代表代码越简单,还要考虑逻辑复杂度、实现难度、代码的可读性等。而且,本身就复杂的问题,用杂的方法解决,也并不违背 KISS 原则。除此之外,同样的代码,在某个业务场景下满足 KISS 原则,换一个应用场景可能就不满足了

对于如何写出满足 KISS 原则的代码,总结了下面几条指导原则:

  • 不要使用同事可能不懂的技术来实现代码
  • 不要重复造轮子,善于使用已经有的工具类库
  • 不要过度优化

YAGNI 原则的英文全称是:You Ain’t Gonna Need It。直译就是:你不会需要它。这条原则也算是万金油了。当用在软件开发中的时候,它的意思是:不要去设计当前用不到的功能;不要去编写当前用不到的代码。实际上,这条原则的核心思想就是:不要做过度设计

YAGNI 原则跟 KISS 原则并非一回事儿。KISS 原则讲的是“如何做”的问题(尽量保持简单),而 YAGNI 原则说的是“要不要做”的问题(当前不需要的就不要做)

7.3.7 DRY 原则

DRY 原则中文描述是:不要重复自己,将它应用在编程中,可以理解为:不要写重复的代码,这里讲到了三种代码重复的情况:实现逻辑重复、功能语义重复、代码执行重复

  • 实现逻辑重复,但功能语义不重复的代码,并不违反 DRY 原则
  • 实现逻辑不重复,但功能语义重复的代码,也算是违反 DRY 原则
  • 而代码执行重复也算是违反 DRY 原则

除此之外,还讲到了提高代码复用性的一些手段,包括:减少代码耦合、满足单一职责原则、模块化、业务与非业务逻辑分离、通用代码下沉、继承、多态、抽象、封装、应用模板等设计模式。复用意识也非常重要。在设计每个模块、类、函数的时候,要像设计一个外部 API 一样去思考它的复用性

在第一次写代码的时候,如果当下没有复用的需求,而未来的复用需求也不是特别明确,并且开发可复用代码的成本比较高,那就不需要考虑代码的复用性。在之后开发新的功能的时候,发现可以复用之前写的这段代码,那就重构这段代码,让其变得更加可复用

相比于代码的可复用性,DRY 原则适用性更强些。可以不写可复用的代码,但一定不能写重复的代码

7.3.8 LOD 原则

如何理解“高内聚、松耦合”?

“高内聚、松耦合”是一个非常重要的设计思想,能够有效提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。“高内聚”用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计。所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中。所谓“松耦合”指的是,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动也不会或者很少导致依赖类的代码改动

如何理解“迪米特法则”?

迪米特法则的描述为:不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。迪米特法则是希望减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少

在这里插入图片描述

7.4 规范与重构

7.4.1 重构概述

重构的目的:为什么重构(why)?

对于项目来言,重构可以保持代码质量持续处于一个可控状态,不至于腐化到无可救药的地步。对于个人而言,重构非常锻炼一个人的代码能力,并且是一件非常有成就感的事情。它是我们学习的经典设计思想、原则、模式、编程规范等理论知识的练兵场

重构的对象:重构什么(what)?

按照重构的规模,可以将重构大致分为大规模高层次的重构和小规模低层次的重构。大规模高层次重构包括对代码分层、模块化、解耦、梳理类之间的交互关系、抽象复用组件等等。这部分工作利用的更多的是比较抽象、比较顶层的设计思想、原则、模式。小规模低层次的重构包括规范命名、注释、修正函数参数过多、消除超大类、提取重复代码等编程细节问题,主要是针对类、函数级别的重构。小规模低层次的重构更多的是利用编码规范这一理论知识

重构的时机:什么时候重构(when)?

一定要建立持续重构意识,把重构作为开发必不可少的部分融入到开发中,而不是等到代码出现很大问题的时候,再大刀阔斧地重构

重构的方法:如何重构(how)?

大规模高层次的重构难度比较大,需要有组织、有计划地进行,分阶段地小步快跑,时刻保持代码处于一个可运行的状态。而小规模低层次的重构,因为影响范围小,改动耗时短,所以,只要你愿意并且有时间,随时随地都可以去做

7.4.2 单元测试

什么是单元测试?

单元测试是代码层面的测试,用于测试“自己”编写的代码的逻辑正确性。单元测试顾名思义是测试一个“单元”,这个“单元”一般是类或函数,而不是模块或者系统

为什么要写单元测试?

单元测试能有效地发现代码中的 Bug、代码设计上的问题。写单元测试的过程本身就是代码重构的过程。单元测试是对集成测试的有力补充,能帮助我们快速熟悉代码,是 TDD 可落地执行的折中方案

如何编写单元测试?

写单元测试就是针对代码设计覆盖各种输入、异常、边界条件的测试用例,并将其翻译成代码的过程。可以利用一些测试框架来简化测试代码的编写。对于单元测试,需要建立以下正确的认知:

  1. 编写单元测试尽管繁琐,但并不是太耗时
  2. 可以稍微放低单元测试的质量要求
  3. 覆盖率作为衡量单元测试好坏的唯一标准是不合理的
  4. 写单元测试一般不需要了解代码的实现逻辑
  5. 单元测试框架无法测试多半是代码的可测试性不好

单元测试为何难落地执行?

一方面,写单元测试本身比较繁琐,技术挑战不大,很多程序员不愿意去写。另一方面,国内研发比较偏向“快糙猛”,容易因为开发进度紧,导致单元测试的执行虎头蛇尾,最后,没有建立对单元测试的正确认识,觉得可有可无,单靠督促很难执行得很好

7.4.3 代码的可测试性

什么是代码的可测试性?

粗略地讲,所谓代码的可测试性,就是针对代码编写单元测试的难易程度。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很费劲,需要依靠单元测试框架很高级的特性,那往往就意味着代码设计得不够合理,代码的可测试性不好

编写可测试性代码的最有效手段

依赖注入是编写可测试性代码的最有效手段。通过依赖注入,在编写单元测试代码的时候,可以通过 mock 的方法将不可控的依赖变得可控,这也是在编写单元测试的过程中最有技术挑战的地方。除了 mock 方式,还可以利用二次封装来解决某些代码行为不可控的情况

常见的 Anti-Patterns

典型的、常见的测试不友好的代码有下面这 5 种:

  1. 代码中包含未决行为逻辑
  2. 滥用可变全局变量
  3. 滥用静态方法
  4. 使用复杂的继承关系
  5. 高度耦合的代码

7.4.4 大型重构:解耦

“解耦”为何如此重要?

过于复杂的代码往往在可读性、可维护性上都不友好。解耦,保证代码松耦合、高内聚,是控制代码复杂度的有效手段。如果代码高内聚、松耦合,也就是意味着,代码结构清晰、分层、模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差

代码是否需要“解耦”?

间接的衡量标准有很多,比如:改动一个模块或类的代码受影响的模块或类是否有很多、改动一个模块或者类的代码依赖的模块或者类是否需要改动、代码的可测试性是否好等等。直接的衡量标准是把模块与模块之间及其类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构

如何给代码“解耦”?

给代码解耦的方法有:封装与抽象、中间层、模块化,以及一些其他的设计思想与原则,比如:单一职责原则、基于接口而非实现编程、依赖注入、多用组合少用继承、迪米特法则。当然,还有一些设计模式,比如观察者模式

7.4.5 小型重构:编码规范

命名与注释

  1. 命名的关键是能准确的达意。对于不同作用域的命名,可以适当的选择不同的长度,作用域小的命名,比如临时变量等,可以适当的选择短一些的命名方式。除此之外,命名中个也可以使用一些耳熟能详的缩写
  2. 借助类的信息来简化属性、函数的命名,利用函数的信息来简化函数参数的命名
  3. 命名要可读、可搜索。不要使用生僻的、不好读的英文单词来命名。除此之外,命名要符合项目的统一规范,也不要用些反直觉的命名
  4. 接口有两种命名方式。一种是在接口中带前缀"I",另一种是在接口的实现类中带后缀“Impl”。两种命名方式都可以,关键是要在项目中统一。对于抽象类的命名,更倾向于带有前缀“Abstract”
  5. 注释的目的就是让代码更容易看懂,只要符合这个要求,就可以写。总结一下的话,注释主要包含这样三个方面的内容:做什么、为什么、怎么做。对于一些复杂的类和接口,我们可能还需要写明“如何用”
  6. 注释本身有一定的维护成本,所以并非越多越好。类和函数一定要写注释,而且要写的尽可能全面详细些,而函数内部的注释会相对少一些,一般都是靠好的命名和提炼函数、解释性变量、总结性注释来做到代码易读

编程技巧

  1. 将复杂的逻辑提炼拆分成函数和类
  2. 通过拆分成多个函数的方式来处理参数过多的情况
  3. 通过将参数封装为对象来处理参数过多的情况
  4. 函数中不要使用参数来做代码执行逻辑的控制
  5. 移除过深的嵌套层次,方法包括:去掉多余的 if 或 else 语句,使用 continue、break、return 关键字提前退出嵌套,调整执行顺序来减少嵌套,将部分嵌套逻辑抽象成函数
  6. 用字面常量取代魔法数
  7. 利用解释性变量来解释复杂表达式

在这里插入图片描述

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Fan 

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值