【设计模式】规范与重构

1. 重构的目的?

重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。

1.1 重构不改变软件的可见行为

也就是在保证功能不变的前提下,利用设计思想、原则、模式编程规范等理论来优化代码,修改设计上的不足,提高代码质量。

2. 为什么要重构

  1. 重构是保证代码质量的有效手段
  2. 重构是避免前期过度设计的有效手段
  3. 重构可以提供工程师的代码能力

2.1 重构对于工程师能力提升的重要性

初级工程师在维护代码,高级工程师在写代码,资深工程师在重构代码。

意思是初级工程师在原有的代码上修改 bug,增加或修改功能。高级工程师从零开始设计代码结构、搭建代码框架;而资深工程师为代码质量负责,需要发觉代码存在的问题。

3. 重构的对象

根据重构的规模,分为大规模高层次重构和小规划低层次重构。

大型重构指的是:对顶层代码的重构,包括系统、模块、代码结构及类与类之间的关系等。常用的手段有:分层、模块化、解耦、抽象可复用组件等。重构的工具常用的有:设计思想、原则和设计模式。

小型重构指的是:对代码细节的重构,主要针对类、函数、变量等代码级别的重构。常见的有:规范命名、规范注释、消除超大类或函数、提取重复代码等。常用的工具有编码规范。

4. 重构的时机:什么时候重构

一般的重构策略是持续重构。平时事情不多的时候,就看看代码有哪些不好的地方,优化一下。或者在修改,添加某个功能的时候,顺便把存在问题的代码重构一下。

5. 重构的方法

5.1 大型重构的方法

对于大型重构而言,需要分阶段进行。每个阶段完成一小部分的代码重构,然后,提交、测试和运行。如果没有问题后,再进行下一阶段的重构。

5.2 小型重构的方法

由于小型重构往往影响较小,改动耗时较短,所以,只要你愿意,什么时候都可以进行重构。

5.3 重构的负责人

常常需要资深的工程师,项目 Leader 来负责。

6. 单元测试

6.1 什么单元测试

集成测试

集成测试的测试对象是整个系统或者某个功能模块。比如:用户注册、登录模块。

单元测试

单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期逻辑执行。

6.2 单元测试的作用

  1. 单元测试能有效地帮你发现代码中的 bug,写出 bug free 的代码
  2. 写单元测试能帮你发现代码设计上的问题,代码的可测试性是评判代码质量的一个重要标准
  3. 单元测试的对集成测试的补充,集成测试往往无法覆盖代码实现的方方面面
  4. 写单元测试的过程本身就是代码重构的过程,在写单元测试的过程中,就相当于是对代码的一次 Code View
  5. 阅读单元测试能帮助你快速熟悉代码
  6. 单元测试是 TDD 可落地执行的改进方案,先写代码,紧接着写单元测试,最后根据单元测试反馈出来问题,再回头对代码进行重构

6.3 单元测试覆盖率存在问题

单元测试覆盖率常常基于所有方法覆盖测试的百分比来计算的。

而在代码编写过程中,并不是所有方法都需要被覆盖的,比如:get/set,而实现应该关注的是:需要添加单元测试的类或函数的测试是否足够全面,是否覆盖了各种输入、异常、边界条件等测试用例

6.4 单元测试不需要了解代码的实现逻辑

单元测试不需要依赖被测试函数的具体实现逻辑,它只关心被测试函数实现了什么功能。

6.5 Google 内部对待单元测试的态度

很多项目几乎没有测试团队参与,代码的正确性完全靠开发团队来保障。

7. 代码的可测试性

什么是代码的可测试性?

所谓代码的可测试性,就是针对代码编写单元测试的难易程度。

7.1 单元测试改造前代码

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 productId, String orderId) {
    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, 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.getSingletonIntance().unlockTransction(id);
      }
    }
  }
}

该代码需要包含以下测试用例:

  1. 正常情况下,交易执行成功,交易状态设置为 EXECUTED,函数返回成功
  2. buyId、sellId 为 null,amount 小于 0,返回 InvalidTransactionException
  3. 交易已过期,交易状态为 EXPIRED,返回 false
  4. 交易已经执行了,不再重复执行,返回 true
  5. 钱包转钱失败,交易状态为 FAILED,函数返回 false
  6. 交易正在执行,不会被重复执行,返回 false

7.2 测试用例 1

单元测试主要是测试程序员自己写的代码逻辑是否存在问题,并不需要测试所依赖系统或服务逻辑的正确性。所以,如果代码中依赖了外部系统或者不可控组件,如:数据库,网络服务和文件系统等,就需要将被测试代码与外部系统解依赖,解依赖的方法就是 mock

1. 使用 mock 替换 WalletRpcService 服务

对于上述代码中的 WalletRpcService 服务,需要将其 mock,具体做法为:

  1. 自定义 mock 类继承 WalletRpcService 类
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;
  } 
}
  1. excute() 方法进行重构,通过依赖注入的方式引入 WalletRpcService 服务
public class Transaction {
  //...
  // 添加一个成员变量及其set方法
  private WalletRpcService walletRpcService;
  
  public void setWalletRpcService(WalletRpcService walletRpcService) {
    this.walletRpcService = walletRpcService;
  }
  // ...
  public boolean execute() {
    // ...
    // 删除下面这一行代码
    // WalletRpcService walletRpcService = new WalletRpcService();
    // ...
  }
}
  1. 使用 mock 替换 WalletRpcService 服务
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());
}

2. 使用 mock 替换 RedisDistributedLock

mock 单例类存在的问题:单例类相当于一个全局变量,我们无法 mock(无法继承和重写方法),也无法通过依赖注入的方式来替换。

  1. 将单例类重新使用普通类封装
public class TransactionLock {
  public boolean lock(String id) {
    return RedisDistributedLock.getSingletonIntance().lockTransction(id);
  }
  
  public void unlock() {
    RedisDistributedLock.getSingletonIntance().unlockTransction(id);
  }
}
  1. 将 RedisDistributedLock 重构为通过依赖注入引入
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();
      }
    }
    //...
  }
}
  1. 创建 TransactionLock 的 mock 对象,并复写其真实方法,返回我们想要的任何结果
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());
}

mock 是什么

所谓 mock 就是用一个“假”的服务替换掉真的服务,由于 mock 的服务完全在我们的控制之下,所以,完全可以模拟输出我们想要的数据。

7.3 测试用例 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);
  transaction.setCreatedTimestamp(System.currentTimestamp() - 14days);
  boolean actualResult = transaction.execute();
  assertFalse(actualResult);
  assertEquals(STATUS.EXPIRED, transaction.getStatus());
}

上面的代码来写单元测试没有问题,但是如果 setCreatedTimestamp() 没有提供,而是在构造函数中自动生成的,该如何完成单元测试呢?这同样的是在写单元测试过程中一类常见的问题,就是代码中包含时间有关的“未决行为”逻辑。

解决方法:将这种未决行为的行为逻辑重新封装。上面的代码中,我们需要把原有代码的实现进行重构,把将交易上否过期的逻辑,封装到 isExpired() 函数即可。

  1. 封装时间过期的逻辑
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;
      }
    //...
  }
}
  1. 创建 Transaction 对象,并复写 isExpired() 方法
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());
}

7.4 测试方法总结

1. 对普通对象进行 mock 的方法

  1. 继承原对象,并复写对应的方法,得到我们想要的任何结果
  2. 将原对象的依赖关系改为依赖注入的方式进行依赖
  3. 在单元测试代码中,将 mock 对象通过依赖注入的方式注入到类中

2. 对单例类进行 mock 的方法

  1. 将单例类提供的功能,通过新的普通类进行封装
  2. 创建 1 中的普通类的实例对象,并复写对应的方法,得到我们想要的任何结果
  3. 对原有代码进行重构,将直接依赖单例的地方,改为依赖封装了单例类的普通对象;并通过依赖注入的方式依赖此普通对象
  4. 在单元测试代码中,创建普通对象的实现,并将其通过依赖注入的方式注入到类中

3. 针对时间等未决行为的处理方法

  1. 将未决进行在原代码中进行重构,将其通过单独的方法封装
  2. 在单元测试代码中,创建待测试对象时,复写未决行为方法,返回我们想要的任何结果

7.5 常见的测试性不好的代码

1. 未决行为

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

2. 全局变量

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

如果上面的 testMove_betweenRangetestMove_exceedRange 顺序执行,由于全局变量的存在,testMove_betweenRange 方法执行后,posion 的值一直存在,而导致 testMove_exceedRange 方法执行断言失败。

3. 静态方法

主要原因是静态方法很难 mock。当然,需要分情况来看,只有静态方法耗时太长、依赖外部资源、逻辑复杂、存在未决行为等的情况下,我们才需要在单元测试中对其进行 mock 操作。如果只是简单的静态方法,如:math.abs(),并不需要对其进行 mock。

4. 复杂继承

如果在父类中使用了外部对象,需要对其进行 mock 后才能运行单元测试,那么所有子类在编写单元测试的时候都需要 mock 这个依赖对象。

如果继承关系过于复杂,越是底层的子类,需要 mock 的依赖类就越多。

如果继承关系比较复杂的情况下,需要通过组合、接口和委托的方式对其进行重构。

5. 高耦合代码

如果一个类的职责很重,需要依赖十几个外部对象才能工作,在编写单元测试的时候,可能就需要编写十几个 mock 对象,这显然会大大增加编写单元测试的成本。

8. 如何给代码解耦

解耦的作用:是控制代码复杂度的有效手段,利用解耦的方法对代码重构,就是保证代码不至于复杂到无法控制的有效手段。

8.1 封装和抽象

通过封装和抽象,可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口。

8.2 引入中间层

让两两之间存在依赖关系中的多个类,利用中介者设计模式,让其共同依赖同一个中介类,来降低类之间依赖的复杂性。

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

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

8.3 模块化

  1. 对于一个复杂的系统来说,将系统划分成各个独立的模块,让不同的人复杂不同的模块,即使在不了解全部实现细节的情况下,管理者也可能协调各个模块,让系统稳定运转
  2. 对于软件开发来说,不同的模块之间通过 API 来进行通信,每个模块之间耦合很小,每个小团队只需要聚焦于一个独立的高内聚模块来开发
  3. 对于代码层面来说,合理地划分模块能有效地解耦代码,提高代码的可读性和可维护性。

8.4 单一职责原则

模块或类的职责设计单一,而不是大而全,依赖它的类或者它依赖的类就会比较少,代码耦合也就相应的降低了。

8.5 基于接口而非实现编程

通过接口这个中间层,隔离变化和具体的实现。在有依赖关系的模块或类之间,一方的改变,不会影响到另一方。

8.6 依赖注入

依赖注入是将代码之间的强耦合变为弱耦合,尽管依赖注入无法将本来有依赖关系的两个类解耦为没有依赖关系,但可以让依赖关系变得没有那么紧密,容易做到插拔。

8.7 多用组合少用继承

和依赖注入类似,或者说组合就是依赖注入的一种具体实现方式。通过组合让原本强耦合关系并成一种弱耦合关系,同样的,也容易做到插拔。

8.8 迪米特原则

不该有依赖关系的类之间不要有依赖;有依赖关系的两个类之间,尽量只依赖其必要的接口。明显看出,这也是一种降低类之间耦合度的一种方式。

9. 编程规范

9.1 命名

  1. 名字太长,由于代码列长度有限制的情况下,就会经常出现一条语句被分割成两行的情况,这其实会影响代码可读性。在能达意的情况下,尽量用较短的命名。比如:大家较熟悉的词,就建议用缩写;对于作用域比较小的变量(如函数内临时变量),可以用相对短的命名
  2. 利用上下文简化命名。如:类名为 User,那么里面的属性就可以直接使用 name,而没有必要再使用 username
  3. 命名要可读可搜索。可读指的是不要用大家都看不懂的单词来命名;可搜索指的是通过在写代码的时候,方便地联想出对应的函数,如:通过 get 就能找到获取当前对象中的所有函数,而不能说有的地方用的 acquire,这就要求大家在命名时,最好能符合项目的命名习惯
  4. 对于不同作用域的命名,我们可以适当选择不同的长度,作用域小的变量,可以适当选择一些短的命名方式

9.2 注释

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

9.3 代码风格

  1. 对于函数的代码行数,一般不要超过一屏幕垂直高度,也就是让一个函数的代码完整地显示在屏幕上。对于类的代码行数有一个间接的评估标准,那就是,实现功能不知道要用某个函数了,想用哪个函数半天也没有找到,只用一个小功能,而需要引入整个类的时候,说明类的行数过多了
  2. 一行代码的最长不要超过一屏幕的宽度,如果需要通过鼠标才能查看一行的全部代码,显然不利用代码的阅读
  3. 善用代码行分割单元块。如果逻辑上可以将函数内部的实现分为相对独立的代码块,而代码块又不太需要抽成单独方法的时候,可以用空行来将其分割。以外,还可以在类的成员变量和函数之间,静态成员变量和成员变量之间、各函数之间、各成员变量之间通过空行来进行分割
  4. 类中函数和变量的排列顺序。静态成员变量 -> 成员变量 -> 静态方法 -> 普通方法,同时,成员变量之间或者方法之间,按照作用域,先写作用域大的,如:public 变量或方法

9.4 编程技巧

  1. 对于较复杂的逻辑,利用模块化和抽象思维,把代码分割成更小单元块
  2. 避免函数参数过多。函数参数大于 5 个左右的时候,就需要考虑是否需要将函数拆分成多个函数;或者使用对象来替代普通的参数传递
  3. 勿用函数参数来控制逻辑。不要使用 boolean 参数来控制函数的执行逻辑,而是通过分拆为两个函数来实现
  4. 函数设计尽可能职责单一
  5. 移除过深的代码嵌套层次。一般建议嵌套层次不超过 2 层
  6. 善于使用解释型变量来提高代码的可读性。如:常量代码魔法数;

说明

此文是根据王争设计模式之美相关专栏内容整理而来,非原创。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270