设计模式 3.规范与重构

本文围绕Java代码展开,介绍了重构的目的、对象、时机和方法,强调持续重构。阐述了单元测试保证重构不出错的作用、编写方法及覆盖率问题。分析了代码可测试性及解耦方法,如封装、抽象、模块化等。还给出快速改善代码质量的编程规范,包括命名、注释、代码风格和编码技巧。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

理论一:什么情况下要重构?到底重构什么?又该如何重构?

重构代码对一个工程师能力的要求,要比单纯写代码高得多。重构需要你能洞察出代码存在的坏味道或者设计上的不足,并且能合理、熟练地利用设计思想、原则、模式、编程规范等理论知识解决这些问题。
软件设计大师 Martin Fowler 这样定义重构:“重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。”
强调:重构不改变软件外部的可见行为。
理解:在保持功能不变的前提下,利用设计思想、原则、模式与编码规范等理论来优化代码、设计上的不足,提高代码质量。

重构的目的(Why)

首先,重构是时刻保证代码质量的一个极其有效的手段,不至于让代码腐化到无可救药的地步。
其次,优秀的代码或架构不是一开始就能完全设计好的,就像优秀的公司和产品也都是迭代出来的。随着系统的演进,重构代码是不可避免的。
最后,重构是避免过度设计的有效手段。在我们维护代码的过程中,真正遇到问题的时候,再对代码进行重构,能有效避免前期投入太多时间做过度的设计,做到有的放矢。

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

重构的对象(What)

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

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

小型重构指的是对代码细节的重构,主要是针对类、函数、变量等代码级别的重构,比如规范命名、规范注释、消除超大类或函数、提取重复代码等等。小型重构更多的是利用我们能后面要讲到的编码规范。这类重构要修改的地方比较集中,比较简单,可操作性较强,耗时会比较短,引入 bug 的风险相对来说也会比较小。你只需要熟练掌握各种编码规范,就可以做到得心应手。

重构的时机(when)

不能够等代码烂到一定程度之后,集中重构解决所有问题;要探索一条可持续、可演进的方式——我们提倡的重构策略是持续重构,把重构作为开发必不可少的部分,融入到日常开发中,而不是等到代码出现很大问题的时候,再大刀阔斧地重构。

重构的方法(how)

在进行大型重构的时候,我们要提前做好完善的重构计划,有条不紊地分阶段来进行。

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

小规模低层次的重构,因为影响范围小,改动耗时短,所以,只要你愿意并且有时间,随时都可以去做。
除了人工去发现低层次的质量问题,我们还可以借助很多成熟的静态代码分析工具(比如 CheckStyle、FindBugs、PMD),来自动发现代码中的问题,然后针对性地进行重构优化。

理论二:为了保证重构不出错,有哪些非常能落地的技术手段?

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

什么是单元测试?

单元测试由研发工程师自己来编写,用来测试自己写的代码的正确性。
单元测试相对于集成测试来说,测试的粒度更小一些。单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试。
集成测试(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;
  }
}

单元测试:

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, actualValue);
      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());
  }
}

可以看到,单元测试无需高深的技术,也无需集成重量级的框架。它更多的是考验程序员思维的缜密程度,看能否设计出覆盖各种正常及异常情况的测试用例,来保证代码在任何预期或非预期的情况下都能正确运行。

为什么要写单元测试?

  1. 单元测试能有效地帮你发现代码中的 bug
  2. 写单元测试能帮你发现代码设计上的问题
    代码的可测试性是评判代码质量的一个重要标准。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很吃力,需要依靠单元测试框架里很高级的特性才能完成,那往往就意味着代码设计得不够合理,比如,没有使用依赖注入、大量使用静态函数、全局变量、代码高度耦合等。
  3. 单元测试是对集成测试的有力补充
    程序运行的 bug 往往出现在一些边界条件、异常情况下,比如,除数未判空、网络超时。而大部分异常情况都比较难在测试环境中模拟。而单元测试可以利用mock 的方式,控制 mock 的对象返回我们需要模拟的异常,来测试代码在这些异常情况的表现。
    尽量保证底层代码不出问题,为集成测试复杂情况做保障。
  4. 写单元测试的过程本身就是代码重构的过程
    写单元测试实际上就是落地执行持续重构的一个有效途径。设计和实现代码的时候,我们很难把所有的问题都想清楚。而编写单元测试就相当于对代码的一次自我 Code Review,在这个过程中,我们可以发现一些设计上的问题(比如代码设计的不可测试)以及代码编写方面的问题(比如一些边界条件处理不当)等,然后针对性的进行重构。
  5. 阅读单元测试能帮助你快速熟悉代码
    没有文档和注释的情况下,单元测试起到了文档替代作用。单元测试用例实际上就是用户用例,反映了代码的功能和如何使用。借助单元测试,我们不需要深入的阅读代码,便能知道代码实现了什么功能,有哪些特殊情况需要考虑,有哪些边界条件需要处理。
  6. 单元测试是 TDD 可落地执行的改进方案
    测试驱动开发(Test-Driven Development,简称 TDD)是一个经常被提及但很少被执行的开发模式。它的核心指导思想就是测试用例先于代码编写。( 参考CS61B)

如何编写单元测试?

写单元测试就是针对代码设计覆盖各种输入、异常、边界条件的测试用例,并将这些测试用例翻译成代码的过程。
在把测试用例翻译成代码的时候,我们可以利用单元测试框架,来简化测试代码的编写。比如,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());
  }
}

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

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

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

理论三:什么是代码的可测试性?如何写出可测试性好的代码?

可测试代码案例实战

/**
 * Transaction 是经过抽象简化之后的一个电商系统的交易类,
 * 用来记录每笔订单交易的情况。
 */
@Data
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;


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

    /**
     * 执行转账操作,将钱从买家的钱包转到卖家的钱包中。
     * @return
     * @throws InvalidTransactionException
     */
    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.getSingletonInstance()
            .lockTransaction(id);
            if (!isLocked) {
                return false; // 锁定未成功,返回false,job兜底执行
            }
            // double check
            if (status == STATUS.EXECUTED) return true; 
            long executionInvokedTimestamp = System
            .currentTimeMillis();
            // executionInvokedTimestamp - createdTimestamp > 14days
            if (executionInvokedTimestamp - 
		            this.createTimestamp > 14) {
                this.status = STATUS.EXPIRED;
                return false;
            }
            WalletRpcService walletRpcService = 
            new WalletRpcService();
            // 创建支付流水,返回流水号
            String walletTransactionId = walletRpcService
	            .moveMoney(id, buyerId, sellerId, amount);
            if (walletTransactionId != null) {
                this.walletTransactionId = walletTransactionId;
                this.status = STATUS.EXECUTED;
                return true;
            } else {
                this.status = STATUS.FAILED;
                return false;
            }
        } finally {
            if (isLocked) {
                RedisDistributedLock
	                .getSingletonInstance().unlockTransaction(id);
            }
        }
    }
}

在 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。

测试用例1:

public class TransactionTest {
    /**
     * 正常情况下,交易执行成功,回填用于对账(交易与钱包的交易流水)
     * 用的 walletTransactionId,交易状态设置为 EXECUTED,函数返回 true。
     */
    @Test
    public void case1() throws InvalidTransactionException {
        Long buyerId = 123L;
        Long sellerId = 234L;
        Long productId = 345L;
        String orderId = "o_10708976520231219160211892";
        Transaction transaction = new Transaction(null, buyerId, sellerId, productId, orderId, 897.50);
        boolean executedResult = transaction.execute();
        Assertions.assertTrue(executedResult);
    }
}

存在以下问题:

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

单元测试的定义:
单元测试主要是测试程序员自己编写的代码逻辑的正确性,并非是端到端的集成测试,它不需要测试所依赖的外部系统(分布式锁、Wallet RPC 服务)的逻辑正确性。

所以,如果代码中依赖了外部系统或者不可控组件,比如,需要依赖数据库、网络通信、文件系统等,那我们就需要将被测代码与外部系统解依赖,而这种解依赖的方法就叫作“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 amount) {
    return "123bac";
  } 
}

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

如何用 MockWalletRpcServiceOne、MockWalletRpcServiceTwo 来替换代码中的真正的 WalletRpcService 呢?

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

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

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

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

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

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

public class TransactionLock {
  public boolean lock(String id) {
    return RedisDistributedLock
	    .getSingletonInstance().lockTransaction(id);
  }
  
  public void unlock(String id) {
    RedisDistributedLock.getSingletonInstance().unlockTransaction(id);
  }
}

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

最终的 case1 测试用例如下:

public class TransactionTest {
    /**
     * 正常情况下,交易执行成功,回填用于对账(交易与钱包的交易流水)
     * 用的 walletTransactionId,交易状态设置为 EXECUTED,函数返回 true。
     */
    @Test
    public void case1() throws InvalidTransactionException {
        Long buyerId = 123L;
        Long sellerId = 234L;
        Long productId = 345L;
        String orderId = "o_10708976520231219160211892";
        Transaction transaction = new Transaction(null, buyerId,
	         sellerId, productId, orderId, 897.50);

        // 创建 Mock RpcService
        MockWalletRpcServiceOne mockRpc = 
	        new MockWalletRpcServiceOne();
        // 注入 Mock Rpc 服务
        transaction.setWalletRpcService(mockRpc);
        // 封装解耦全局变量,匿名子类模拟
        TransactionLock mockLock = new TransactionLock() {
            public boolean lock(String id) {
                return true;
            }

            public void unlock(String id) {
                ;
            }
        };
        boolean executedResult = transaction.execute();
        Assertions.assertTrue(executedResult);
    }
}

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

	@Test
    public void testExecute_with_TransactionIsExpired() throws InvalidTransactionException {
        Long buyerId = 123L;
        Long sellerId = 234L;
        Long productId = 345L;
        String orderId = "456L";
        Double amount = 60.6;
        Transaction transaction = new Transaction(null, buyerId,
	         sellerId, productId, orderId, amount);
		// 将 transaction 的创建时间 createdTimestamp 设置为 14 天前
        transaction.setCreateTimestamp(System.currentTimeMillis() 
	        - 14days);
        boolean actualResult = transaction.execute();
        Assertions.assertFalse(actualResult);
        Assertions.assertEquals(STATUS.EXPIRED, 
	        transaction.getStatus());
    }

在 Transaction 类的设计中,createTimestamp 是在交易生成时(也就是构造函数中)自动获取的系统时间,本来就不应该人为地轻易修改,所以,暴露 createTimestamp 的 set 方法,虽然带来了灵活性,但也带来了不可控性。因为,我们无法控制使用者是否会调用 set 方法重设 createTimestamp,而重设 createTimestamp 并非我们的预期行为。

有一类比较常见的问题,就是代码中包含跟“时间”有关的“未决行为”逻辑。我们一般的处理方式是将这种未决行为逻辑重新封装。针对 Transaction 类,我们只需要将交易是否过期的逻辑,封装到 isExpired() 函数中即可,具体的代码实现如下所示:

protected boolean isExpired() {
        long executionInvokedTimestamp = System.currentTimeMillis();
        return executionInvokedTimestamp - createTimestamp > 14;
    }

    /**
     * 执行转账操作,将钱从买家的钱包转到卖家的钱包中。
     * @return
     * @throws InvalidTransactionException
     */
    public boolean execute() throws InvalidTransactionException {
        // ……
//            long executionInvokedTimestamp = 
//			  System.currentTimeMillis();
//            if (executionInvokedTimestamp
//                    - this.createTimestamp > 14days) {
//                this.status = STATUS.EXPIRED;
//                return false;
//            }
            if (isExpired()) {
                this.status = STATUS.EXPIRED;
                return false;
            }
            // 创建支付流水,返回流水号
            String walletTransactionId = walletRpcService
                    .moveMoney(id, buyerId, sellerId, amount);
			// ……
    }

所以测试类中可以通过重写 isExpired 函数来 Mock

public void testExecute_with_TransactionIsExpired() throws InvalidTransactionException {
        Long buyerId = 123L;
        Long sellerId = 234L;
        Long productId = 345L;
        String orderId = "456L";
        Double amount = 60.6;
        // 创建一个匿名内部类,该匿名内部类是Transaction的子类
        Transaction transaction = new Transaction(null, buyerId, 
	        sellerId, productId, orderId, amount) {
            @Override
            protected boolean isExpired() {
                return true;
            }
        };
        boolean actualResult = transaction.execute();
        Assertions.assertFalse(actualResult);
        Assertions.assertEquals(STATUS.EXPIRED,
	         transaction.getStatus());
    }

通过重构,Transaction 代码的可测试性提高了。
但是,Transaction 类的构造函数的设计还有点不妥。因为它包含了有些复杂的 Id 生成逻辑。

public Transaction(String preAssignedId, Long buyerId, Long sellerId, 
				   Long productId, String orderId, Double amount) {
        if (preAssignedId != null && !preAssignedId.isEmpty()) {
            this.id = preAssignedId;
        } else {
            this.id = IdGenerator.generateTransactionId();
        }
        if (!this.id.startsWith("t_")) {
            this.id = "t_" + preAssignedId;
        }
        this.buyerId = buyerId;
        this.sellerId = sellerId;
        this.productId = productId;
        this.orderId = orderId;
        this.amount = amount;
        this.status = STATUS.TO_BE_EXECUTED;
        this.createTimestamp = System.currentTimeMillis();
    }

为了保证这段逻辑的正确性,将这段逻辑抽象为一段函数.

public Transaction(String preAssignedId, Long buyerId, Long sellerId, 
				   Long productId, String orderId, Double amount) {
        fillTransactionId(preAssignedId);
        this.buyerId = buyerId;
        this.sellerId = sellerId;
        this.productId = productId;
        this.orderId = orderId;
        this.amount = amount;
        this.status = STATUS.TO_BE_EXECUTED;
        this.createTimestamp = System.currentTimeMillis();
    }

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

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

其他常见的 Anti-Patterns

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

1.未决行为

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

public class Demo {
  public long caculateDelayDays(Date dueTime) {
    long currentTimestamp = System.currentTimeMillis();
    if (dueTime.getTime() >= currentTimestamp) {
      return 0;
    }
    long delayTime = currentTimestamp - dueTime.getTime();
    long delayDays = delayTime / 86400;
    return delayDays;
  }
}

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_LIMIT);
    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));
  }
}

直接批量执行测试用例很可能会导致第2个测试用例报错。
这需要提供一个 resetPosition 的函数在每次测试前将全局变量重置。
但是如果存在并发执行的情况,即便我们每次都把 position 重设为 0,也并不奏效。如果两个测试用例并发执行,多行代码交叉执行,还是会影响到 move() 函数的测试。

3.静态方法

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

4.复杂继承

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

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

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

5.高耦合代码

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

理论四:如何通过封装、抽象、模块化、中间层等解耦代码?

解耦的目的是实现代码高内聚、松耦合。

为什么要解耦?

如果说重构是保证代码质量不至于腐化到无可救药地步的有效手段,那么利用解耦的方法对代码重构,就是保证代码不至于复杂到无法控制的有效手段。

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

“高内聚、松耦合”的特性:

  • 聚焦在某一模块或类中,不需要了解太多其他模块或类的代码
  • 依赖关系简单,耦合小,修改代码不至于牵一发而动全身,代码改动比较集中(聚焦小的模块进行重构)
  • 可测试性也更加好,容易 mock 或者很少需要 mock 外部依赖的模块或者类
  • 代码结构清晰、分层和模块化合理、依赖关系简单、模块或类之间的耦合小,差代码影响范围小

如何判断代码是否需要解耦?

把模块与模块之间、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。

如何给代码解耦?

1.封装与抽象

封装和抽象可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口。
Unix 系统提供的 open() 文件操作函数,我们用起来非常简单,但是底层实现却非常复杂,涉及权限控制、并发控制、物理存储等等。
将代码复杂性封装在局部代码中。
open() 函数基于抽象而非具体的实现来定义,所以我们在改动 open() 函数的底层实现的时候,并不需要改动依赖它的上层代码,也符合我们前面提到的“高内聚、松耦合”代码的评判标准。

2.中间层

引入中间层能简化模块或类之间的依赖关系。
在引入数据存储中间层之前,A、B、C 三个模块都要依赖内存一级缓存、Redis 二级缓存、DB 持久化存储三个模块。在引入中间层之后,三个模块只需要依赖数据存储一个模块即可。
引入中间层可以起到过渡的作用
引入中间层能够让开发和重构同步进行,不互相干扰。比如,某个接口设计得有问题,我们需要修改它的定义,同时,所有调用这个接口的代码都要做相应的改动。如果新开发的代码也用到这个接口,那开发就跟重构冲突了。为了让重构能小步快跑,我们可以分下面四个阶段来完成接口的修改:

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

3.模块化

将系统划分成各个独立的模块,让不同的人负责不同的模块,这样即便在不了解全部细节的情况下,管理者也能协调各个模块,让整个系统有效运转。
不同的模块之间通过 API 来进行通信,每个模块之间耦合很小,每个小的团队聚焦于一个独立的高内聚模块来开发,最终像搭积木一样将各个模块组装起来,构建成一个超级复杂的系统。

开发时建立模块化意识,将每个模块都当作一个独立的 lib 一样来开发,只提供封装了内部实现细节的接口给其他模块使用,这样可以减少不同模块之间的耦合度。
模块化的思想无处不在,像 SOA、微服务、lib 库、系统内模块划分,甚至是类、函数的设计,都体现了模块化思想。如果追本溯源,模块化思想更加本质的东西就是分而治之。

4.其他

  • 单一职责原则(类/模块小了)
  • 基于接口而非实现编程(通过接口这样一个中间层,隔离变化和具体的实现。)
  • 依赖注入(将代码之间的强耦合变为弱耦合,容易做到插拔替换。)
  • 多用组合少用继承(继承是强依赖关系,组合是弱依赖关系)
  • 迪米特法则(不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。)

日常开发场景下的解耦

  1. Spring中的事件监听机制,是解耦的设计,利用观察者模式(消息队列)
  2. 微服务中服务注册与发现是解耦的设计,引入中间层注册中心来实现
  3. 调用链路跟踪是解耦的设计,将调用链的收集和业务代码解耦,利用动态代理来实现
  4. Ribbon的客户端负载均衡也能算是一种解耦的设计,利用策略模式和模版方法,解耦了具体的负载算法的实现,而且还可以自定义
  5. 最近在了解Service Mesh,sidecar 的 Proxy 也算是解耦的设计,利用边车模式代理了服务间的网络通信、监控等和实际业务无关的通用逻辑

理论五:快速地改善代码质量的编程规范

命名

大到项目名、模块名、包名、对外暴露的接口,小到类名、函数名、变量名、参数名,都需要思考如何起名。

实在想不到好名字的时候,可以去 GitHub 上用相关的关键词联想搜索一下,看看类似的代码是怎么命名的。

1.命名长度

在足够表达其含义的情况下,命名当然是越短越好。
对于一些默认的、大家都比较熟知的词,如 doc、num、sec 等推荐使用缩写;
对于作用域比较小的变量,如函数内部的临时变量,可以使用缩写;
对于类名这种作用域比较大的,更推荐用长的命名方式。

2.利用上下文简化命名

属性或者函数可以借助类名来缩短其命名:
如下:

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

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

3.命名要可读、可搜索

避免 plateaux、eyrie等生僻词汇,选用简单的组合,如 inkstone
统一项目的命名规范,大家都用 queryXXX,就不要写 getXXXX;大家都用 addXXX,就不要写 insertXXX;

4.接口和抽象类的命名

对于接口的命名,一般有两种比较常见的方式。一种是加前缀“I”,表示一个 Interface。比如 IUserService,对应的实现类命名为 UserService。另一种是不加前缀,比如 UserService,对应的实现类加后缀“Impl”,比如 UserServiceImpl。
对于抽象类的命名,也有两种方式,一种是带上前缀“Abstract”,比如 AbstractConfiguration;另一种是不带前缀“Abstract”。实际上,对于接口和抽象类,选择哪种命名方式都是可以的,只要项目里能够统一就行。

注释

注释的目的就是让代码更容易看懂。
注释的内容主要包含这样三个方面:做什么、为什么、怎么做。

对于类来说,包含的信息比较多,一个简单的命名就不够全面详尽了。这个时候,在注释中写明“做什么”就合情合理了。如下所示:

/**
* (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 {
  // ...
}

在注释中,关于具体的代码实现思路,我们可以写一些总结性的说明、特殊情况的说明。这样能够让阅读代码的人通过注释就能大概了解代码的实现思路,阅读起来就会更加容易。

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

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

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 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;
}

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

代码风格

1. 类、函数多大才合适?

对于函数代码行数的最大限制,网上有一种说法,那就是不要超过一个显示屏的垂直高度。比如 40 行-50 行;

2. 一行代码多长最合适?

在Google Java Style Guide文档中,一行代码最长限制为 100 个字符。
一行代码最长不能超过 IDE 显示的宽度。

3. 善用空行分割单元块

对于比较长的函数,如果逻辑上可以分为几个独立的代码块,在不方便将这些独立的代码块抽取成小函数的情况下,以使用空行来分割各个代码块。

在类的成员变量与函数之间、静态成员变量与普通成员变量之间、各函数之间、甚至各成员变量之间,我们都可以通过添加空行的方式,让这些不同模块的代码之间,界限更加明确。

4. 四格缩进还是两格缩进?

团队统一即可。
一定不要用 tab 键缩进。因为在不同的 IDE 下,tab 键的显示宽度不同,有的显示为四格缩进,有的显示为两格缩进。

5. 大括号是否要另起一行?

推荐将左括号放到跟语句同一行的风格。

6. 类中成员的排列顺序

在 Java 类文件中,先要书写类所属的包名,然后再罗列 import 引入的依赖类。在 Google 编码规范中,依赖类按照字母序从小到大排列(可以规范化为Checkstyle规则配置在idea中)。

在类中,成员变量排在函数的前面。成员变量之间或函数之间,都是按照“先静态(静态函数或静态成员变量)、后普通(非静态函数或非静态成员变量)”的方式来排列的。

成员变量之间或函数之间,还会按照作用域范围从大到小的顺序来排列,先写 public 成员变量或函数,然后是 protected 的,最后是 private 的。

还有另外一种排列习惯,那就是把有调用关系的函数放到一块。比如,一个 public 函数调用了另外一个 private 函数,那就把这两者放到一块。

编码技巧

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

代码逻辑比较复杂的时候,善用模块化和抽象思维将大块的复杂逻辑提炼成类或者函数,屏蔽掉细节。
例子:

// 重构前的代码
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;
}

2. 避免函数参数过多

函数包含 3、4 个参数的时候还是能接受的,大于等于 5 个的时候,我们就觉得参数有点过多了,会影响到代码的可读性,使用起来也不方便。
办法:

  • 考虑函数是否职责单一,是否能通过拆分成多个函数的方式来减少参数。
// 查询条件过多
public User getUser(String username, String telephone, String email);

// 拆分成多个函数
public User getUserByUsername(String username);
public User getUserByTelephone(String telephone);
public User getUserByEmail(String email);
  • 将函数的参数封装成对象
public void postBlog(String title, String summary, String keywords, String content, String category, long authorId);

// 将参数封装成对象
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);

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

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) {
  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) {
  // ...
}

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. 移除过深的嵌套层次

代码嵌套层次过深往往是因为 if-else、switch-case、for 循环过度嵌套导致的。
方法:

  • 去掉多余的 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;
}
  • 使用编程语言提供的 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;
}
  • 调整执行顺序来减少嵌套。
// 重构前的代码
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;
}
  • 将部分嵌套逻辑封装成函数调用,以此来减少嵌套。
// 重构前的代码
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;
}

6. 学会使用解释性变量

  • 常量取代魔法数字。
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;
}
  • 使用解释性变量来解释复杂表达式
if (date.after(SUMMER_START) && date.before(SUMMER_END)) {
  // ...
} else {
  // ...
}

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值