2022-04-19 代码重构之持续重构
为了让你了解重构相关概念,我接下来从三方面来介绍,
- 重构概括性介绍,包括:重构的目的(why)、对象(what)、时机(when)、方法(how);
- 保证重构不出错的手段,主要介绍单元测试(UT)和代码可测试性。
- 不同规模重构的关注点和方法,重点介绍大规模高层次重构(比如系统、模块、代码结构、类与类之间的交互等的重构)和小规模低层次重构(类、函数、变量等的重构)
总结:在日常需求的开发中,要时刻保持重构意识,将持续重构和UT 以及code review 一样作为日常开发中的一部分。在添加或修改某个功能代码的时候,可以顺手把不符合编码规范、不好的代码重构下来使得代码始终保持在一个可控的范围。
重构概念介绍
重构的定义
软件设计大师 Martin Fowler 是这样定义重构的:“重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。”
我们可以把重构理解为,在保持功能不变的前提下,利用设计思想、原则、模式、编程规范等理论来优化代码,修改设计上的不足,提高代码质量。
重构的目的
- 对于项目来言,重构可以保持代码质量持续处于一个可控状态,不至于腐化到无可救药的地步。
- 对于个人而言,重构非常锻炼一个人的代码能力,并且是一件非常有成就感的事情。它是我们学习的经典设计思想、原则、模式、编程规范等理论知识的练兵场。
重构对象
按照重构的规模,我们可以将重构大致分为大规模高层次的重构和小规模低层次的重构。
大规模高层次重构包括对代码分层、模块化、解耦、梳理类之间的交互关系、抽象复用组件等等。这部分工作利用的更多的是比较抽象、比较顶层的设计思想、原则、模式。小规模低层次的重构包括规范命名、注释、修正函数参数过多、消除超大类、提取重复代码等等编程细节问题,主要是针对类、函数级别的重构。
小规模低层次的重构更多的是利用编码规范这一理论知识。
重构时机
我们一定要建立持续重构意识,把重构作为开发必不可少的部分,融入到日常开发中,而不是等到代码出现很大问题的时候,再大刀阔斧地重构。
重构方法
大规模高层次的重构难度比较大,需要有组织、有计划地进行,分阶段地小步快跑,时刻让代码处于一个可运行的状态。而小规模低层次的重构,因为影响范围小,改动耗时短,所以,只要你愿意并且有时间,随时随地都可以去做。
保证重构不出错手段
单元测试
单元测试的定义(what)
单元测试是代码层面的测试,由研发自己来编写,用于测试“自己”编写的代码的逻辑的正确性。单元测试顾名思义是测试一个“单元”,有别于集成测试,这个“单元”一般是类或函数,而不是模块或者系统。
单元测试的意义(why)
写单元测试的过程本身就是代码 Code Review 和重构的过程,能有效地发现代码中的 bug 和代码设计上的问题。除此之外,单元测试还是对集成测试的有力补充,还能帮助我们快速熟悉代码,是 TDD (测试驱动开发 Test-Driven Development)可落地执行的改进方案。
如何编写单元测试(how)
写单元测试就是针对代码设计各种测试用例,以覆盖各种输入、异常、边界情况,并将其翻译成代码。我们可以利用一些测试框架来简化单元测试的编写。除此之外,对于单元测试,我们需要建立以下正确的认知:
-
编写单元测试尽管繁琐,但并不是太耗时;
-
我们可以稍微放低对单元测试代码质量的要求;
-
覆盖率作为衡量单元测试质量的唯一标准是不合理的;
/** * 删除操作 * * 反例:像下面这段代码,我们只需要一个测试用例就可以做到 100% 覆盖率,比如 cal(10.0, 2.0), * 但并不代表测试足够全面了,我们还需要考虑,当除数等于0的情况下,代码执行是否符合预期。 * * @param a * @param b * @return */ public double cal(double a, double b) { if (b != 0) { return a / b; } }
-
单元测试是对代码的功能的测试,不要依赖被测代码的具体实现逻辑;比如代码重构导致逻辑改变的场景,此时如果UT依赖具体实现逻辑是不合理的;
-
单元测试框架无法测试,多半是因为代码的可测试性不好。
单元测试案例
public class Text {
private String content;
public Text(String content) {
this.content = content;
}
/**
* 将字符串转化成数字,忽略字符串中的首尾空格;
* 如果字符串中包含除首尾空格之外的非数字字符,则返回null。
*
* @return
*/
public Integer toNumber(){
if(content == null || content.isEmpty()){
return null;
}
return Integer.parseInt(content);
}
}
/**
* TextTest类
*
* 如果我们要测试 Text 类中的 toNumber() 函数的正确性,应该如何编写单元测试呢?
*
* 单元测试更多的是考验程序员思维的缜密程度,看能否设计出覆盖各种正常及异常情况的测试用例
* (围绕入参,出参,异常进行编写),来保证代码在任何预期或非预期的情况下都能正确运行。
*
* 为了保证测试的全面性,针对 toNumber() 函数,我们需要设计下面这样几个测试用例。
*/
public class TextTest {
/**
* 1. 如果字符串只包含数字:“123”,toNumber() 函数输出对应的整数:123。
*
*/
public void testToNumber() {
Text text = new Text("123");
Assert.assertEquals(123, text.toNumber());
}
/**
* 2. 如果字符串是空或者 null,toNumber() 函数返回:null。
*
*/
public void testToNumber_nullorEmpty() {
Text text1 = new Text(null);
Assert.assertNull(text1.toNumber());
Text text2 = new Text("");
Assert.assertNull(text1.toNumber());
}
/**
* 3. 如果字符串包含首尾空格:“ 123”,“123 ”,“ 123 ”,toNumber() 返回对应的整数:123。
*
*/
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());
}
/**
* 4. 如果字符串包含多个首尾空格:“ 123 ”,toNumber() 返回对应的整数:123;
*
*/
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());
}
/**
* 5. 如果字符串包含非数字字符:“123a4”,“123 4”,toNumber() 返回 null;
*
*/
public void testToNumber_containsInvalidCharaters(){
Text text1 = new Text("123a4");
Assert.assertNull(text1.toNumber());
Text text2 = new Text("123 4");
Assert.assertNull(text2.toNumber());
}
}
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.err.println(message);
} else {
System.err.println("Test successed.");
}
}
public static void assertNull(Integer actualValue) {
boolean isNull = actualValue == null;
if(isNull){
System.out.println("Test successed.");
}else {
String message = String.format("Test failed, the value is not null: %d.", actualValue);
System.out.println(message);
}
}
}
/**
* TestRunner类
*
* 测试启动类
*/
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();
}
}
代码可测试性
什么是代码的可测试性?
粗略地讲,所谓代码的可测试性,就是针对代码编写单元测试的难易程度。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很费劲,需要依靠单元测试框架中很高级的特性,那往往就意味着代码设计得不够合理,代码的可测试性不好。
编写可测试性代码的最有效手段
依赖注入是编写可测试性代码的最有效手段。通过依赖注入,我们在编写单元测试的时候,可以通过 mock 的方法解依赖外部服务,这也是我们在编写单元测试的过程中最有技术挑战的地方。
常见的 Anti-Patterns
常见的测试不友好的代码有下面这 5 种:
- 代码中包含未决行为逻辑
- 滥用可变全局变量
- 滥用静态方法
- 使用复杂的继承关系
- 高度耦合的代码
代码可测试性示例
//v0 -- 代码测试性不好的代码
/**
* Transaction类是抽象简化之后的一个电商系统的交易类,用来记录每笔订单交易的情况。
*/
@Getter
public class Transaction {
private static final long FOURTEEN_DAYS = 14 * 3600 * 24 * 1000l;
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_" + this.id;
}
this.buyerId = buyerId;
this.sellerId = sellerId;
this.productId = productId;
this.orderId = orderId;
this.status = STATUS.TO_BE_EXECUTD;
this.createTimestamp = System.currentTimeMillis();
}
/**
* 执行转账操作,将钱从买家的钱包转到卖家的钱包中。真正的转账操作是通过调用 WalletRpcService RPC 服务
* 来完成的。除此之外,代码中还涉及一个分布式锁 DistributedLock 单例类,用来避免 Transaction 并发执行
* ,导致用户的钱被重复转出。
*
* @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.getSingletonIntance().lockTransction(id);
if (!isLocked) {
return false; //锁定未成功,返回false,job兜底执行
}
if (status == STATUS.EXECUTED) return true; // double check
long executionInvokedTimestamp = System.currentTimeMillis();
if (executionInvokedTimestamp - createTimestamp > FOURTEEN_DAYS) {
this.status = STATUS.EXPIRED;
return false;
}
WalletRpcService walletRpcService = new WalletRpcService();
String walletTransactionId = walletRpcService.moveMoney(id, buyerId, sellerId, amount);
if (walletTransactionId != null) {
this.status = STATUS.EXECUTED;
return true;
} else {
this.status = STATUS.FAILED;
return false;
}
} finally {
if (isLocked) {
RedisDistributedLock.getSingletonIntance().unlockTransction(id);
}
}
}
}
/**
* TransactionTest类
*
* 在 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,
*
* 6. 函数返回 false。交易正在执行着,不会被重复执行,函数直接返回 false。
*/
public class TransactionTest {
/**
* 1. 正常情况下,交易执行成功,回填用于对账(交易与钱包的交易流水)用的 walletTransactionId,
* 交易状态设置为 EXECUTED,函数返回 true。
*
* 分析:execute() 函数的执行依赖两个外部的服务,一个是 RedisDistributedLock,
* 一个 WalletRpcService。这就导致单元测试不能按照我们的预期数据进行返回,这时候就需要被测代码与
* 外部系统解依赖,这种解依赖的方法就叫作“mock”
*
* 所谓的 mock 就是用一个“假”的服务替换真正的服务。mock 的服务完全在我们的控制之下,
* 模拟输出我们想要的数据。
*
* 针对WalletRpcService 我们通过用 MockWalletRpcServiceOne、MockWalletRpcServiceTwo
* 来替换代码中的真正的 WalletRpcService,
*
* 具体改动为:应用依赖注入,将 WalletRpcService 对象的创建反转给上层逻辑,在外部创建好之后,
* 再注入到 Transaction 类中
*
* 针对RedisDistributedLock,因为RedisDistributedLock 是一个单例类。单例相当于一个全局变量,
* 我们无法 mock(无法继承和重写方法),也无法通过依赖注入的方式来替换。
*
* 场景一:如果 RedisDistributedLock 是我们自己维护的,可以自由修改、重构,那我们可以将其改为非单例的模式,
* 或者定义一个接口,比如 IDistributedLock,让 RedisDistributedLock 实现这个接口。这样我们就可
* 以像前面 WalletRpcService 的替换方式那样,替换 RedisDistributedLock 为
* MockRedisDistributedLock 了。
*
* 场景二:如果 RedisDistributedLock 不是我们维护的,我们无权去修改这部分代码,这时候我们可以针对上锁这部分
* 逻辑重新封装,我们创建一个TransactionLock来封装加锁和释放锁逻辑,然后在Transaction依赖注册TransactionLock
* 来解决。
*/
@Test
public void testExecute() throws InvalidTransactionException {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
String orderId = "456";
Transaction transaction = new Transaction(null, buyerId, sellerId,
productId, orderId);
boolean executedResult = transaction.execute();
assertTrue(executedResult);
}
@Test
public void testExecute_new() throws InvalidTransactionException {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
String orderId = "456";
TransactionLock mockLock = new TransactionLock() {
public boolean lock(String id) { return true; }
public void unlock() {}
};
TransactionNew transaction = new TransactionNew(null, buyerId, sellerId,
productId, orderId);
// 使用mock对象来替代真正的RPC服务
transaction.setWalletRpcService(new MockWalletRpcServiceOne());
//使用mock对象来替代RedisDistributedLock
transaction.setTransactionLock(mockLock);
boolean executedResult = transaction.execute();
assertTrue(executedResult);
assertEquals(STATUS.EXECUTED, transaction.getStatus());
}
/**
* 交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回 false。
*
* 分析:直接使用setCreatedTimestamp()方法是否合理?
*
* 答案:违反了封装特性,不合理。在 Transaction 类的设计中,createTimestamp
* 是在交易生成时(也就是构造函数中)自动获取的系统时间,本来就不应该人为地轻易修改,所以,
* 暴露 createTimestamp 的 set 方法,虽然带来了灵活性,但也带来了不可控性。因为,
* 我们无法控制使用者是否会调用 set 方法重设 createTimestamp,而重设 createTimestamp
* 并非我们的预期行为。
*
* 如何优化:代码中包含跟“时间”有关的“未决行为”逻辑。我们一般的处理方式是将这种未决行为逻辑重新封装。
* 针对 Transaction 类,我们只需要将交易是否过期的逻辑,封装到 isExpired() 函数中即可
*/
@Test
public void testExecute_with_TransactionIsExpired() throws InvalidTransactionException {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
String orderId = "456";
Transaction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
//创建时间设置为14天前
transaction.setCreatedTimestamp(System.currentTimeMillis() - 14*24*3600*1000l);
boolean actualResult = transaction.execute();
assertFalse(actualResult);
assertEquals(STATUS.EXPIRED, transaction.getStatus());
}
@Test
public void testExecute_with_TransactionIsExpired() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
String orderId = "456";
TransactionNew transaction = new TransactionNew(null, buyerId, sellerId, productId, orderId) {
protected boolean isExpired() {
return true;
}
};
boolean actualResult = transaction.execute();
assertFalse(actualResult);
assertEquals(STATUS.EXPIRED, transaction.getStatus());
}
}
优化点1:
public class MockWalletRpcServiceOne extends WalletRpcService {
public String moveMoney(String id, Long buyerId, Long sellerId, Double amount) {
return "123bac";
}
}
public class MockWalletRpcServiceTwo extends WalletRpcService {
public String moveMoney(String id, Long buyerId, Long sellerId, Double amount) {
return null;
}
}
优化点2:
public class TransactionLock {
public boolean lock(String lockName){
return RedisDistributedLock.getSingletonIntance().lockTransction(lockName);
}
public boolean unlock(String lockName){
return RedisDistributedLock.getSingletonIntance().unlockTransction(lockName);
}
}
最终优化结果:
/**
* TransactionNew类是抽象简化之后的一个电商系统的交易类,用来记录每笔订单交易的情况。
*
*/
@Getter
public class TransactionNew {
private static final long FOURTEEN_DAYS = 3600 * 24 * 14 * 1000l;
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;
//优化点1:依赖注入
private WalletRpcService walletRpcService;
//优化点2:依赖注入
private TransactionLock transactionLock;
public TransactionNew(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
//优化点4
//if (preAssignedId != null && !preAssignedId.isEmpty()) {
// this.id = preAssignedId;
//} else {
// this.id = IdGenerator.generateTransactionId();
//}
//
//if (this.id.startsWith("t_")) {
// this.id = "t_" + this.id;
//}
fillTransactionId(preAssignedId);
this.buyerId = buyerId;
this.sellerId = sellerId;
this.productId = productId;
this.orderId = orderId;
this.status = STATUS.TO_BE_EXECUTD;
this.createTimestamp = System.currentTimeMillis();
}
/**
* 构造函数中并非只包含简单赋值操作。交易id 的赋值逻辑稍微复杂
*
* 我们可以把id 赋值这部分逻辑单独抽象到fillTransactionId函数中进行测试。
*
* @param preAssignedId
*/
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_" + this.id;
}
}
/**
* 执行转账操作,将钱从买家的钱包转到卖家的钱包中。真正的转账操作是通过调用 WalletRpcService RPC 服务
* 来完成的。除此之外,代码中还涉及一个分布式锁 DistributedLock 单例类,用来避免 Transaction 并发执行
* ,导致用户的钱被重复转出。
*
* @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 {
//优化点2
//isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(id);
isLocked = transactionLock.lock(id);
if (!isLocked) {
return false; //锁定未成功,返回false,job兜底执行
}
if (status == STATUS.EXECUTED) return true; // double check
//优化点3
//long executionInvokedTimestamp = System.currentTimeMillis();
//if (executionInvokedTimestamp - createTimestamp > FOURTEEN_DAYS) {
if(isExpired()){
this.status = STATUS.EXPIRED;
return false;
}
//优化点1:WalletRpcService walletRpcService = new WalletRpcService();
String walletTransactionId = walletRpcService.moveMoney(id, buyerId, sellerId, amount);
if (walletTransactionId != null) {
this.status = STATUS.EXECUTED;
return true;
} else {
this.status = STATUS.FAILED;
return false;
}
} finally {
if (isLocked) {
//优化点2
//RedisDistributedLock.getSingletonIntance().unlockTransction(id);
transactionLock.unlock(id);
}
}
}
//优化点1:注入外部依赖
public void setWalletRpcService(WalletRpcService walletRpcService) {
this.walletRpcService = walletRpcService;
}
//优化点2:注入新加锁对象
public void setTransactionLock(TransactionLock transactionLock) {
this.transactionLock = transactionLock;
}
//优化点3: 未决行为处理方法 -- 封装
protected boolean isExpired(){
long executionInvokedTimestamp = System.currentTimeMillis();
return executionInvokedTimestamp - createTimestamp > FOURTEEN_DAYS;
}
}