1. 重构的目的?
重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。
1.1 重构不改变软件的可见行为
也就是在保证功能不变的前提下,利用设计思想、原则、模式编程规范等理论来优化代码,修改设计上的不足,提高代码质量。
2. 为什么要重构
- 重构是保证代码质量的有效手段
- 重构是避免前期过度设计的有效手段
- 重构可以提供工程师的代码能力
2.1 重构对于工程师能力提升的重要性
初级工程师在维护代码,高级工程师在写代码,资深工程师在重构代码。
意思是初级工程师在原有的代码上修改 bug,增加或修改功能。高级工程师从零开始设计代码结构、搭建代码框架;而资深工程师为代码质量负责,需要发觉代码存在的问题。
3. 重构的对象
根据重构的规模,分为大规模高层次重构和小规划低层次重构。
大型重构指的是:对顶层代码的重构,包括系统、模块、代码结构及类与类之间的关系等。常用的手段有:分层、模块化、解耦、抽象可复用组件等。重构的工具常用的有:设计思想、原则和设计模式。
小型重构指的是:对代码细节的重构,主要针对类、函数、变量等代码级别的重构。常见的有:规范命名、规范注释、消除超大类或函数、提取重复代码等。常用的工具有编码规范。
4. 重构的时机:什么时候重构
一般的重构策略是持续重构。平时事情不多的时候,就看看代码有哪些不好的地方,优化一下。或者在修改,添加某个功能的时候,顺便把存在问题的代码重构一下。
5. 重构的方法
5.1 大型重构的方法
对于大型重构而言,需要分阶段进行。每个阶段完成一小部分的代码重构,然后,提交、测试和运行。如果没有问题后,再进行下一阶段的重构。
5.2 小型重构的方法
由于小型重构往往影响较小,改动耗时较短,所以,只要你愿意,什么时候都可以进行重构。
5.3 重构的负责人
常常需要资深的工程师,项目 Leader 来负责。
6. 单元测试
6.1 什么单元测试
集成测试
集成测试的测试对象是整个系统或者某个功能模块。比如:用户注册、登录模块。
单元测试
单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期逻辑执行。
6.2 单元测试的作用
- 单元测试能有效地帮你发现代码中的 bug,写出 bug free 的代码
- 写单元测试能帮你发现代码设计上的问题,代码的可测试性是评判代码质量的一个重要标准
- 单元测试的对集成测试的补充,集成测试往往无法覆盖代码实现的方方面面
- 写单元测试的过程本身就是代码重构的过程,在写单元测试的过程中,就相当于是对代码的一次 Code View
- 阅读单元测试能帮助你快速熟悉代码
- 单元测试是 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);
}
}
}
}
该代码需要包含以下测试用例:
- 正常情况下,交易执行成功,交易状态设置为 EXECUTED,函数返回成功
- buyId、sellId 为 null,amount 小于 0,返回 InvalidTransactionException
- 交易已过期,交易状态为 EXPIRED,返回 false
- 交易已经执行了,不再重复执行,返回 true
- 钱包转钱失败,交易状态为 FAILED,函数返回 false
- 交易正在执行,不会被重复执行,返回 false
7.2 测试用例 1
单元测试主要是测试程序员自己写的代码逻辑是否存在问题,并不需要测试所依赖系统或服务逻辑的正确性。所以,如果代码中依赖了外部系统或者不可控组件,如:数据库,网络服务和文件系统等,就需要将被测试代码与外部系统解依赖,解依赖的方法就是 mock
。
1. 使用 mock 替换 WalletRpcService 服务
对于上述代码中的 WalletRpcService 服务,需要将其 mock,具体做法为:
- 自定义 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;
}
}
- 对
excute()
方法进行重构,通过依赖注入的方式引入 WalletRpcService 服务
public class Transaction {
//...
// 添加一个成员变量及其set方法
private WalletRpcService walletRpcService;
public void setWalletRpcService(WalletRpcService walletRpcService) {
this.walletRpcService = walletRpcService;
}
// ...
public boolean execute() {
// ...
// 删除下面这一行代码
// WalletRpcService walletRpcService = new WalletRpcService();
// ...
}
}
- 使用 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(无法继承和重写方法),也无法通过依赖注入的方式来替换。
- 将单例类重新使用普通类封装
public class TransactionLock {
public boolean lock(String id) {
return RedisDistributedLock.getSingletonIntance().lockTransction(id);
}
public void unlock() {
RedisDistributedLock.getSingletonIntance().unlockTransction(id);
}
}
- 将 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();
}
}
//...
}
}
- 创建 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()
函数即可。
- 封装时间过期的逻辑
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;
}
//...
}
}
- 创建 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 的方法
- 继承原对象,并复写对应的方法,得到我们想要的任何结果
- 将原对象的依赖关系改为依赖注入的方式进行依赖
- 在单元测试代码中,将 mock 对象通过依赖注入的方式注入到类中
2. 对单例类进行 mock 的方法
- 将单例类提供的功能,通过新的普通类进行封装
- 创建 1 中的普通类的实例对象,并复写对应的方法,得到我们想要的任何结果
- 对原有代码进行重构,将直接依赖单例的地方,改为依赖封装了单例类的普通对象;并通过依赖注入的方式依赖此普通对象
- 在单元测试代码中,创建普通对象的实现,并将其通过依赖注入的方式注入到类中
3. 针对时间等未决行为的处理方法
- 将未决进行在原代码中进行重构,将其通过单独的方法封装
- 在单元测试代码中,创建待测试对象时,复写未决行为方法,返回我们想要的任何结果
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_betweenRange
和 testMove_exceedRange
顺序执行,由于全局变量的存在,testMove_betweenRange
方法执行后,posion 的值一直存在,而导致 testMove_exceedRange
方法执行断言失败。
3. 静态方法
主要原因是静态方法很难 mock。当然,需要分情况来看,只有静态方法耗时太长、依赖外部资源、逻辑复杂、存在未决行为等的情况下,我们才需要在单元测试中对其进行 mock 操作。如果只是简单的静态方法,如:math.abs()
,并不需要对其进行 mock。
4. 复杂继承
如果在父类中使用了外部对象,需要对其进行 mock 后才能运行单元测试,那么所有子类在编写单元测试的时候都需要 mock 这个依赖对象。
如果继承关系过于复杂,越是底层的子类,需要 mock 的依赖类就越多。
如果继承关系比较复杂的情况下,需要通过组合、接口和委托的方式对其进行重构。
5. 高耦合代码
如果一个类的职责很重,需要依赖十几个外部对象才能工作,在编写单元测试的时候,可能就需要编写十几个 mock 对象,这显然会大大增加编写单元测试的成本。
8. 如何给代码解耦
解耦的作用:是控制代码复杂度的有效手段,利用解耦的方法对代码重构,就是保证代码不至于复杂到无法控制的有效手段。
8.1 封装和抽象
通过封装和抽象,可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口。
8.2 引入中间层
让两两之间存在依赖关系中的多个类,利用中介者设计模式,让其共同依赖同一个中介类,来降低类之间依赖的复杂性。
同时,在重构的过程中,引入中间层可以起到过渡的作用,能够让开发和重构同步执行,不互相干扰。当某个接口开发设计得有问题,我们需要修改它的定义,同时,所有调用它的地方都要有相应的改动。如果新开发的代码也用到了这个接口,那开发和重构就冲突了。为了让重构能够小步快跑,可以分四个阶段来完成上述接口的修改:
- 引入一个中间层,包裹老的接口,同时提供新的接口定义
- 新开发的代码依赖中间层提供的新接口
- 将依赖老接口的代码改成依赖新的接口
- 确保所有的代码都依赖新接口后,删除老接口
8.3 模块化
- 对于一个复杂的系统来说,将系统划分成各个独立的模块,让不同的人复杂不同的模块,即使在不了解全部实现细节的情况下,管理者也可能协调各个模块,让系统稳定运转
- 对于软件开发来说,不同的模块之间通过 API 来进行通信,每个模块之间耦合很小,每个小团队只需要聚焦于一个独立的高内聚模块来开发
- 对于代码层面来说,合理地划分模块能有效地解耦代码,提高代码的可读性和可维护性。
8.4 单一职责原则
模块或类的职责设计单一,而不是大而全,依赖它的类或者它依赖的类就会比较少,代码耦合也就相应的降低了。
8.5 基于接口而非实现编程
通过接口这个中间层,隔离变化和具体的实现。在有依赖关系的模块或类之间,一方的改变,不会影响到另一方。
8.6 依赖注入
依赖注入是将代码之间的强耦合变为弱耦合,尽管依赖注入无法将本来有依赖关系的两个类解耦为没有依赖关系,但可以让依赖关系变得没有那么紧密,容易做到插拔。
8.7 多用组合少用继承
和依赖注入类似,或者说组合就是依赖注入的一种具体实现方式。通过组合让原本强耦合关系并成一种弱耦合关系,同样的,也容易做到插拔。
8.8 迪米特原则
不该有依赖关系的类之间不要有依赖;有依赖关系的两个类之间,尽量只依赖其必要的接口。明显看出,这也是一种降低类之间耦合度的一种方式。
9. 编程规范
9.1 命名
- 名字太长,由于代码列长度有限制的情况下,就会经常出现一条语句被分割成两行的情况,这其实会影响代码可读性。在能达意的情况下,尽量用较短的命名。比如:大家较熟悉的词,就建议用缩写;对于作用域比较小的变量(如函数内临时变量),可以用相对短的命名
- 利用上下文简化命名。如:类名为 User,那么里面的属性就可以直接使用 name,而没有必要再使用 username
- 命名要可读可搜索。可读指的是不要用大家都看不懂的单词来命名;可搜索指的是通过在写代码的时候,方便地联想出对应的函数,如:通过
get
就能找到获取当前对象中的所有函数,而不能说有的地方用的acquire
,这就要求大家在命名时,最好能符合项目的命名习惯 - 对于不同作用域的命名,我们可以适当选择不同的长度,作用域小的变量,可以适当选择一些短的命名方式
9.2 注释
类和函数一定要写注释,而且需要尽量详细一些,而函数内部的注释相对少一些,一般要通过好的命名、提炼函数、解释性变量、总结性注释来提高代码的可读性。
9.3 代码风格
- 对于函数的代码行数,一般不要超过一屏幕垂直高度,也就是让一个函数的代码完整地显示在屏幕上。对于类的代码行数有一个间接的评估标准,那就是,实现功能不知道要用某个函数了,想用哪个函数半天也没有找到,只用一个小功能,而需要引入整个类的时候,说明类的行数过多了
- 一行代码的最长不要超过一屏幕的宽度,如果需要通过鼠标才能查看一行的全部代码,显然不利用代码的阅读
- 善用代码行分割单元块。如果逻辑上可以将函数内部的实现分为相对独立的代码块,而代码块又不太需要抽成单独方法的时候,可以用空行来将其分割。以外,还可以在类的成员变量和函数之间,静态成员变量和成员变量之间、各函数之间、各成员变量之间通过空行来进行分割
- 类中函数和变量的排列顺序。静态成员变量 -> 成员变量 -> 静态方法 -> 普通方法,同时,成员变量之间或者方法之间,按照作用域,先写作用域大的,如:public 变量或方法
9.4 编程技巧
- 对于较复杂的逻辑,利用模块化和抽象思维,把代码分割成更小单元块
- 避免函数参数过多。函数参数大于 5 个左右的时候,就需要考虑是否需要将函数拆分成多个函数;或者使用对象来替代普通的参数传递
- 勿用函数参数来控制逻辑。不要使用 boolean 参数来控制函数的执行逻辑,而是通过分拆为两个函数来实现
- 函数设计尽可能职责单一
- 移除过深的代码嵌套层次。一般建议嵌套层次不超过 2 层
- 善于使用解释型变量来提高代码的可读性。如:常量代码魔法数;
说明
此文是根据王争设计模式之美相关专栏内容整理而来,非原创。