理论一:什么情况下要重构?到底重构什么?又该如何重构?
重构代码对一个工程师能力的要求,要比单纯写代码高得多。重构需要你能洞察出代码存在的坏味道或者设计上的不足,并且能合理、熟练地利用设计思想、原则、模式、编程规范等理论知识解决这些问题。
软件设计大师 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());
}
}
可以看到,单元测试无需高深的技术,也无需集成重量级的框架。它更多的是考验程序员思维的缜密程度,看能否设计出覆盖各种正常及异常情况的测试用例,来保证代码在任何预期或非预期的情况下都能正确运行。
为什么要写单元测试?
- 单元测试能有效地帮你发现代码中的 bug
- 写单元测试能帮你发现代码设计上的问题
代码的可测试性是评判代码质量的一个重要标准。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很吃力,需要依靠单元测试框架里很高级的特性才能完成,那往往就意味着代码设计得不够合理,比如,没有使用依赖注入、大量使用静态函数、全局变量、代码高度耦合等。 - 单元测试是对集成测试的有力补充
程序运行的 bug 往往出现在一些边界条件、异常情况下,比如,除数未判空、网络超时。而大部分异常情况都比较难在测试环境中模拟。而单元测试可以利用mock 的方式,控制 mock 的对象返回我们需要模拟的异常,来测试代码在这些异常情况的表现。
尽量保证底层代码不出问题,为集成测试复杂情况做保障。 - 写单元测试的过程本身就是代码重构的过程
写单元测试实际上就是落地执行持续重构的一个有效途径。设计和实现代码的时候,我们很难把所有的问题都想清楚。而编写单元测试就相当于对代码的一次自我 Code Review,在这个过程中,我们可以发现一些设计上的问题(比如代码设计的不可测试)以及代码编写方面的问题(比如一些边界条件处理不当)等,然后针对性的进行重构。 - 阅读单元测试能帮助你快速熟悉代码
没有文档和注释的情况下,单元测试起到了文档替代作用。单元测试用例实际上就是用户用例,反映了代码的功能和如何使用。借助单元测试,我们不需要深入的阅读代码,便能知道代码实现了什么功能,有哪些特殊情况需要考虑,有哪些边界条件需要处理。 - 单元测试是 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 个测试用例。
- 正常情况下,交易执行成功,回填用于对账(交易与钱包的交易流水)用的 walletTransactionId,交易状态设置为 EXECUTED,函数返回 true。
- buyerId、sellerId 为 null、amount 小于 0,返回 InvalidTransactionException。
- 交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回 false。
- 交易已经执行了(
status==EXECUTED
),不再重复执行转钱逻辑,返回 true。 - 钱包(WalletRpcService)转钱失败,交易状态设置为 FAILED,函数返回 false。
- 交易正在执行着,不会被重复执行,函数直接返回 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 持久化存储三个模块。在引入中间层之后,三个模块只需要依赖数据存储一个模块即可。
引入中间层可以起到过渡的作用
引入中间层能够让开发和重构同步进行,不互相干扰。比如,某个接口设计得有问题,我们需要修改它的定义,同时,所有调用这个接口的代码都要做相应的改动。如果新开发的代码也用到这个接口,那开发就跟重构冲突了。为了让重构能小步快跑,我们可以分下面四个阶段来完成接口的修改:
- 引入一个中间层,包裹老的接口,提供新的接口定义。
- 新开发的代码依赖中间层提供的新接口。
- 将依赖老接口的代码改为调用新接口。
- 确保所有的代码都调用新接口之后,删除掉老的接口。
3.模块化
将系统划分成各个独立的模块,让不同的人负责不同的模块,这样即便在不了解全部细节的情况下,管理者也能协调各个模块,让整个系统有效运转。
不同的模块之间通过 API 来进行通信,每个模块之间耦合很小,每个小的团队聚焦于一个独立的高内聚模块来开发,最终像搭积木一样将各个模块组装起来,构建成一个超级复杂的系统。
开发时建立模块化意识,将每个模块都当作一个独立的 lib 一样来开发,只提供封装了内部实现细节的接口给其他模块使用,这样可以减少不同模块之间的耦合度。
模块化的思想无处不在,像 SOA、微服务、lib 库、系统内模块划分,甚至是类、函数的设计,都体现了模块化思想。如果追本溯源,模块化思想更加本质的东西就是分而治之。
4.其他
- 单一职责原则(类/模块小了)
- 基于接口而非实现编程(通过接口这样一个中间层,隔离变化和具体的实现。)
- 依赖注入(将代码之间的强耦合变为弱耦合,容易做到插拔替换。)
- 多用组合少用继承(继承是强依赖关系,组合是弱依赖关系)
- 迪米特法则(不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。)
日常开发场景下的解耦
- Spring中的事件监听机制,是解耦的设计,利用观察者模式(消息队列)
- 微服务中服务注册与发现是解耦的设计,引入中间层注册中心来实现
- 调用链路跟踪是解耦的设计,将调用链的收集和业务代码解耦,利用动态代理来实现
- Ribbon的客户端负载均衡也能算是一种解耦的设计,利用策略模式和模版方法,解耦了具体的负载算法的实现,而且还可以自定义
- 最近在了解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 {
// ...
}