一篇文章带你认识重构

news2024/12/24 4:05:25

文章目录

    • 一、重构的目的:为什么要重构(why)?
    • 二、重构的对象:到底重构什么(what)?
    • 三、重构的时机:什么时候重构(when)?
    • 四、重构的方法:又该如何重构(how)?
    • 五、什么是单元测试?
    • 六、编写可测试案例实战
      • 1) 现在,我们就来看测试用例 1 的代码实现。具体如下所示:
      • 2) 现在,我们再来看测试用例 3:交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回 false。针对这个单元测试用例,我们还是先把代码写出来,然后再来分析。
    • 八、如何给代码“解耦”?
      • 1. 封装与抽象
      • 2. 中间层
      • 3. 模块化
      • 4. 其他设计思想和原则

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

虽然对于你来说,重构这个词可能不需要过多的解释,但我们还是简单来看一下,大师是怎么描述它的:“重构是一种软件内部结构的改善,目的是在不改变软件的课件行为情况下,使其更容易理解,修改成本更低。”

简单了解重构的定义之后,我们重点来看一下,为什么要进行代码重构?

首先,重构是时刻保证代码质量的一个极其有效的手段,不至于让代码腐化到无可救药的地
步。项目在演进,代码不停地在堆砌。如果没有人为代码的质量负责任,代码总是会往越来
越混乱的方向演进。当混乱到一定程度之后,量变引起质变,项目的维护成本已经高过重新
开发一套新代码的成本,想要再去重构,已经没有人能做到了。

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

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

除此之外,重构对一个工程师本身技术的成长也有重要的意义。

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

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

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

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

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

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

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

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

尽管我们说重构能力很重要,但持续重构意识更重要。我们要正确地看待代码质量和重构这
件事情。技术在更新、需求在变化、人员在流动,代码质量总会在下降,代码总会存在不完
美,重构就会持续在进行。时刻具有持续重构意识,才能避免开发初期就过度设计,避免代
码维护的过程中质量的下降。而那些看到别人代码有点瑕疵就一顿乱骂,或者花尽心思去构
思一个完美设计的人,往往都是因为没有树立正确的代码质量观,没有持续重构意识。

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

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

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

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

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

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

那如何保证重构不出错呢?你需要熟练掌握各种设计原则、思想、模式,还需要对所重构的
业务和代码有足够的了解。除了这些个人能力因素之外,最可落地执行、最有效的保证重构
不出错的手段应该就是单元测试(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;
    }
}

如果我们要测试 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());
     }
}
  1. 单元测试能有效地帮你发现代码中的 bug
  2. 写单元测试能帮你发现代码设计上的问题
  3. 单元测试是对集成测试的有力补充
  4. 写单元测试的过程本身就是代码重构的过程
  5. 阅读单元测试能帮助你快速熟悉代码
  6. 单元测试是 TDD 可落地执行的改进方案

六、编写可测试案例实战

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 > 14days) {
      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);
      }
    } 
  }

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

1) 现在,我们就来看测试用例 1 的代码实现。具体如下所示:

public void testExecute() {
  Long buyerId = 123L;
  Long sellerId = 234L;
  Long productId = 345L;
  Long orderId = 456L;
  Transction transaction = new Transaction(null, buyerId, sellerId, productId,
  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 的服务完全在我们的控制之下,模拟输出我们想要的数据。

我们通过继承 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,
  // 使用 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 不是我们维护的,我们无权去修改这部分代码,这个时候该怎么办
呢?

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,
  transaction.setWalletRpcService(new MockWalletRpcServiceOne());
  transaction.setTransactionLock(mockLock);
  
  boolean executedResult = transaction.execute();
  assertTrue(executedResult);
  assertEquals(STATUS.EXECUTED, transaction.getStatus());
}

至此,测试用例 1 就算写好了。我们通过依赖注入和 mock,让单元测试代码不依赖任何
不可控的外部服务。你可以照着这个思路,自己写一下测试用例 4、5、6。

2) 现在,我们再来看测试用例 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,
  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,
  protected boolean isExpired() {
      return true;
    }
  };
  boolean actualResult = transaction.execute();
  assertFalse(actualResult);
  assertEquals(STATUS.EXPIRED, transaction.getStatus());
}

通过重构,Transaction 代码的可测试性提高了。之前罗列的所有测试用例,现在我们都顺
利实现了。不过,Transaction 类的构造函数的设计还有点不妥。为了方便你查看,我把构
造函数的代码重新 copy 了一份贴到这里。

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,是可以不用写单元测试的。

八、如何给代码“解耦”?

不管是阅读代码还是修改代码,“高内聚、松耦合”的特性可以让我们聚焦在某一模块或类
中,不需要了解太多其他模块或类的代码,让我们的焦点不至于过于发散,降低了阅读和修
改代码的难度。而且,因为依赖关系简单,耦合小,修改代码不至于牵一发而动全身,代码
改动比较集中,引入 bug 的风险也就减少了很多。同时,“高内聚、松耦合”的代码可测
试性也更加好,容易 mock 或者很少需要 mock 外部依赖的模块或者类。
除此之外,代码“高内聚、松耦合”,也就意味着,代码结构清晰、分层和模块化合理、依
赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差。即便某个具体的类或者
模块设计得不怎么合理,代码质量不怎么高,影响的范围是非常有限的。我们可以聚焦于这
个模块或者类,做相应的小型重构。而相对于代码结构的调整,这种改动范围比较集中的小
型重构的难度就容易多了。

1. 封装与抽象

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

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

2. 中间层

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

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

  • 第一阶段:引入一个中间层,包裹老的接口,提供新的接口定义。
  • 第二阶段:新开发的代码依赖中间层提供的新接口。
  • 第三阶段:将依赖老接口的代码改为调用新接口。
  • 第四阶段:确保所有的代码都调用新接口之后,删除掉老的接口

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

3. 模块化

4. 其他设计思想和原则

本文由mdnice多平台发布

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/912020.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Transformer在医学影像中的应用综述-分类

文章目录 COVID-19 Diagnosis黑盒模型可解释的模型 肿瘤分类黑盒模型可解释模型 视网膜疾病分类小结 总体结构 COVID-19 Diagnosis 黑盒模型 Point-of-Care Transformer(POCFormer)&#xff1a;利用Linformer将自注意的空间和时间复杂度从二次型降低到线性型。POCFormer有200…

Unscreen-AI自动删除视频背景工具 无需绿屏

Unscreen是什么 Unscreen是一款AI自动删除视频背景工具&#xff0c;可以帮你把视频和GIF中的背景去除&#xff0c;目前支持的格式&#xff1a;.mp4、.webm、.ogg、.ogg、.ogv、.mov、.gif&#xff0c;用户不需要手动选择像素、颜色和掩码&#xff0c;而是使用AI 自动分析视频&…

ms-tpm-20-ref构建脚本说明

bootstrap 这个脚本主要做了以下几件事情&#xff1a; 1. 设置环境变量&#xff1a;将 AUTORECONF 环境变量设置为 autoreconf&#xff0c;如果该变量已经定义&#xff0c;则不会进行重新设置。 2. 生成文件列表&#xff1a;通过 src_listvar 函数生成各个目录下的源文件列表&…

易云维智慧实验室综合管理平台以技术赋能和数据驱动,助力实验室设备集中管控和能耗管理

目前&#xff0c;我国实验室存在纸质记录多、信息孤岛、不可追溯、安全隐患多、运行能耗高、管理难等问题&#xff1b;同时&#xff0c;国内LIMS实验室信息系统专业化程度低&#xff0c;功能结构单一&#xff0c;不能满足现阶段实验室管理需要&#xff1b;构建一个基于物联网技…

macOS M1使用TensorFlow GPU加速

本人是在pycharm运行代码&#xff0c;安装了tensorflow版本2.13.0 先运行代码查看有没有使用GPU加速&#xff1a; import tensorflow as tf# Press the green button in the gutter to run the script. if __name__ __main__:physical_devices tf.config.list_physical_dev…

Leetcode每日一题:849. 到最近的人的最大距离(2023.8.22 C++)

目录 849. 到最近的人的最大距离 题目描述&#xff1a; 实现代码与解析&#xff1a; 双指针 原理思路&#xff1a; 849. 到最近的人的最大距离 题目描述&#xff1a; 给你一个数组 seats 表示一排座位&#xff0c;其中 seats[i] 1 代表有人坐在第 i 个座位上&#xff0c…

jQuery Editable Select可搜索下拉选项框

前言 可搜索的下拉选项框 源码地址:https://github.com/indrimuska/jquery-editable-select 可搜索的下拉选项框 引入依赖 <script src"//code.jquery.com/jquery-1.12.4.min.js"></script> <script src"//rawgithub.com/indrimuska/jquery…

CentOS中Oracle11g进程有哪些

最近遇到Oracle数据库运行过程实例进程由于某种原因导致中止的问题&#xff0c;专门看了下正常Oracle数据库启动后的进程有哪些&#xff0c;查阅资料了解了下各进程的作用&#xff0c;记录如下。 oracle 3032 1 0 07:36 ? 00:00:00 ora_pmon_orcl oracle …

数据结构——线性数据结构(数组,链表,栈,队列)

文章目录 1. 数组2. 链表2.1. 链表简介2.2. 链表分类2.2.1. 单链表2.2.2. 循环链表2.2.3. 双向链表2.2.4. 双向循环链表 2.3. 应用场景2.4. 数组 vs 链表 3. 栈3.1. 栈简介3.2. 栈的常见应用常见应用场景3.2.1. 实现浏览器的回退和前进功能3.2.2. 检查符号是否成对出现3.2.3. 反…

“解放 Arweave“优惠:4EVERLAND的无缝上传教程

为了进一步展示 Arweave 的能力&#xff0c;4EVERLAND 骄傲地推出了“解放 Arweave”活动。我们认识到 Arweave 在数据完整性、抗审查性以及长期保存方面的无与伦比的优势&#xff0c;因此我们与这个去中心化的存储巨头建立了强大的集成。 克服了过去与加密货币支付逻辑相关的…

2023网络建设与运维模块三:服务搭建与运维

任务描述: 随着信息技术的快速发展,集团计划2023年把部分业务由原有的X86架构服务器上迁移到ARM架构服务器上,同时根据目前的部分业务需求进行了部分调整和优化。 一、X86架构计算机操作系统安装与管理 1.PC1系统为ubuntu-desktop-amd64系统(已安装,语言为英文),登录用户…

半导体市场遇寒冬,“热停机”成为新趋势 | 百能云芯

半导体制程市况不佳&#xff0c;晶圆代工商降价效果差。为削减成本&#xff0c;韩国主要代工厂如三星&#xff0c;启用“热停机”策略。此趋势蔓延至联电、世界先进、力积电等台湾代工厂&#xff0c;揭示短期订单前景黯淡&#xff0c;制程市况严峻。 据韩媒&#xff0c;三星、K…

console的几个常用用法

console.log() 其一、主要表示&#xff1a;向 Web 控制台输出一条消息; 其二、而具体是什么信息就以传递的实参为准&#xff0c;然后就是在控制台就能显示自己传递参数的结果&#xff1b; console.log([1,3,5,7]) // 输出 [1, 3, 5, 7] console.log({}) // 输出 {} conso…

*看门狗原理及注意事项

1.看门狗的作用是&#xff1a;当单片机因为软硬件问题或者外界干扰导致死机时&#xff0c;可以自动复位单片机&#xff0c;使系统重新正常工作。 当电脑的系统死机了&#xff0c;我们可以人为手动的进行重启&#xff0c;使电脑重新正常工作。但当火星的单片机系统死机了&#x…

无人机电力巡检:探索电力设施维护的新模式

电力巡检一直是电力行业中关键的环节&#xff0c;它的目的是确保电力设施的正常运行和安全稳定&#xff0c;对提高电力设施的可靠性、确保电力供应的稳定性和提高电力企业的管理水平具有重要的意义。传统的电力巡检方式通常采用人工的方式进行&#xff0c;这种方式存在很多的问…

Win10c盘满了怎么清理?快速清理,5个方法!

“快救救孩子吧&#xff01;我的电脑是win10系统的&#xff0c;现在c盘满了&#xff0c;根本没法继续使用电脑了。怎么才能快速的释放内存呢&#xff1f;非常着急&#xff01;感谢大家&#xff01;” C盘是Windows系统中重要的分区&#xff0c;当其存储空间满了&#xff0c;可能…

C++核心编程——类和对象(二)、友元、多态

C对象模型和this指针 4.3.1 成员变量和成员函数分开存储 在C中&#xff0c;类内的成员变量和成员函数分开存储 只有非静态成员变量才属于类的对象上 空类&#xff08;类里面是空的&#xff09;&#xff0c;空对象占用内存空间为&#xff1a;1字节。 静态成员变量&#xff0…

Simulink仿真模块 - Random Number

Random Number模块的功能是生成正态分布的随机数。它所在的库为: Simulink / Sources 如图所示: Random Number 模块可生成正态分布的随机数。要生成均匀分布的随机数,可以使用 Uniform Random Number 模块。两个模块都使用正态(高斯)随机数生成器(v4:rng 函数…

eSIM是未来?谷歌Pixel8取消物理卡槽,将于今年秋季推出

根据近期的报道&#xff0c;谷歌计划在今年秋季推出的Pixel 8系列手机上取消物理SIM卡卡槽&#xff0c;而是仅支持使用eSIM卡方案。这意味着用户将不再需要插入实体SIM卡来激活移动网络服务&#xff0c;而是可以通过在手机设置中添加和激活eSIM卡来实现。 这一步骤将更加简化手…

视觉SLAM:一直在入门,如何能精通,CV领域的绝境长城,

目录 前言 福利&#xff1a;文末有chat-gpt纯分享&#xff0c;无魔法&#xff0c;无限制 1 什么是SLAM&#xff1f; 2 为什么用SLAM&#xff1f; 3 视觉SLAM怎么实现&#xff1f; 4 前端视觉里程计 5 后端优化 6 回环检测 7 地图构建 8 结语 前言 上周的组会上&…