文章目录
- 一、重构的目的:为什么要重构(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());
}
}
- 单元测试能有效地帮你发现代码中的 bug
- 写单元测试能帮你发现代码设计上的问题
- 单元测试是对集成测试的有力补充
- 写单元测试的过程本身就是代码重构的过程
- 阅读单元测试能帮助你快速熟悉代码
- 单元测试是 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 个测试用
例。
- 正常情况下,交易执行成功,回填用于对账(交易与钱包的交易流水)用的walletTransactionId,交易状态设置为 EXECUTED,函数返回 true。
- buyerId、sellerId 为 null、amount 小于 0,返回 InvalidTransactionException。
- 交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回false。
- 交易已经执行了(status==EXECUTED),不再重复执行转钱逻辑,返回 true。
- 钱包(WalletRpcService)转钱失败,交易状态设置为 FAILED,函数返回 false。
- 交易正在执行着,不会被重复执行,函数直接返回 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多平台发布