遗留系统开发之道

遗留系统之痛

问题

在软件这个行业里,有一个有意思的名词叫“祖传代码”。泛指那些结构混乱的遗留系统代码。相信大家或多或少在工作中都会遇到过遗留系统,你是否遇到过下面的问题?

  1. 应用大泥球的结构,代码看起来都很费劲,更别说改
  2. 代码已经改不动了,想要重构,但却一点信心都没有
  3. 修一个bug,又引起了另外的bug

原因

遗留系统常常有2个非常明显的特点。

  1. 代码耦合度高,相互依赖性高
  2. 没有足够的自动化测试覆盖,完成改动后要比较长的时间来反馈事情是否做对

这使得我们对代码修改的成本非常大,并且往往容易出错。

依赖性是软件开发中最为关键的问题之一。在处理遗留代码的过程中很大一部分工作都是围绕着“接触依赖性以便使改动变得更容易”这个目标来进行的

遗留代码开发心法

对付依赖代码的工作其实就是动手改,但不是随意地改。我们必须在做出能够带来价值的功能性改动的同时使系统中的更多部分覆盖足够的测试。同时在这个过程中也有一些套路及手法可以参考,我们不需要摸着石头过河。

选择恰当时机

  • 在修改遗留代码之前,对覆盖遗留代码的场景/服务增加大型/中型测试。
  • 在对功能扩展和修改时,对原有代码进行重构并加上对方法的小型测试
  • 在修复 Bug 时增加自动化测试保证问题不要再次出现

确定改动点

  • 仔细阅读被测代码、文档、用例,或找其他同事了解,最大程度理解要修改的功能相关代码

  • 参考常用的架构模式(MV*)和通用的设计原则,作为我们重构时的基准

找出测试点

  • 遗留系统中核心算法或者业务逻辑一般混杂在其他代码中,我们需要需要去识别核心的测试点

  • 合理设置测试策略,避免贸然修改代码带来的风险,最大化重构&自动化测试的 ROI

解依赖

解依赖往往是最难的地方。我们通常会遇到两个方面问题:一是难以在测试用例中实例化目标对象;二是难以在测试用例中运行方法。

这时候就需要我用运用恰当的解依赖手法进行重构,接触依赖。遗留代码解依赖三板斧

编写测试

  • 取一个“看名知其意”的测试名
  • 选择合适的测试替身
  • 编写测试代码

修改重构

对被测试保护起来的代码进行清理,遵守整洁代码及消除坏味道,提高代码可读性

遗留系统解依赖三板斧

参数化方法

假设你有一个方法,该方法在内部创建了某个对象,而你想要通过替换该对象来实现感知或分离。往往最简单的方法就是从外面将你的对象传进来

步骤

  1. 找出目标方法,将它复制一份
  2. 给其中一份增加一个参数,并将方法体中相应的对象创建语句去掉,改为使用刚增加的这个参数
  3. 将另一份复制的方法体删掉,代以对被参数化了的那个版本的调用,记得创建相应的对象作参数

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class TestCase {
public void run() {
TestResult result = new TestResult();
result.runTest();
}
}

class TestResult {
boolean flag = false;

void runTest() {
flag = true;
}
}

重构

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TestCase {
public void run(TestResult result) {
result.runTest();
}
}

class TestResult {
boolean flag = false;

void runTest() {
flag = true;
}
}

测试

1
2
3
4
5
6
7
8
9
10
public class TestCaseTest {

@Test
public void should_return_flag_is_true_when_call_run() {
TestCase testCase = new TestCase();
TestResult mockTestResult = new TestResult();
testCase.run(mockTestResult);
Assert.assertTrue(mockTestResult.flag);
}
}

参数适配

当无法对一个参数的类型使用接口提取,或者当该参数难以“伪装”的时候,可采用参数适配手法

步骤

  1. 创建将被用于该方法的新接口,该接口越简单且能表达意图越好。但也要注意,该接口不应导致需要对该方法的代码作大规模修改
  2. 为新接口创建一个用于产品代码的实现
  3. 为新接口创建一个用于测试的“伪造”实现
  4. 编写一个简单的测试用例,将伪对象传给该方法
  5. 对该方法作必要的修改以使其能使用新的参数
  6. 运行测试来确保你能使用伪对象来测试该方法

示例

1
2
3
4
5
6
7
8
9
10
11
public class ARMDispatcher {

List<String> marketBinding = new ArrayList<>();

public void populate(HttpsParameters parameters) {
String[] values = parameters.getCipherSuites();
if (values != null && values.length > 0) {
marketBinding.add(values[0]);
}
}
}

重构

1
2
3
4
5
6
7
8
9
10
11
12
public class ARMDispatcher {

List<String> marketBinding = new ArrayList<>();

public void populate(ParameterSource parameterSource) {
String values = parameterSource.getParameterValue();
if (values != null) {
marketBinding.add(values);
}
}

}
1
2
3
interface ParameterSource {
String getParameterValue();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class HttpParameterSource implements ParameterSource {
HttpsParameters mHttpsParameters;

public HttpParameterSource(HttpsParameters httpsParameters) {
mHttpsParameters = httpsParameters;
}

@Override
public String getParameterValue() {
String[] values = mHttpsParameters.getCipherSuites();
if (values != null && values.length > 0) {
return values[0];
}
return "";
}
}

测试

1
2
3
4
5
6
7
8
9
class FakeParameterSource implements ParameterSource {

public String value;

@Override
public String getParameterValue() {
return value;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
public class ARMDispatcherTest {

@Test
public void testPopulate() {
ARMDispatcher armDispatcher = new ARMDispatcher();
FakeParameterSource fakeParameterSource = new FakeParameterSource();
fakeParameterSource.value = "hello world";
armDispatcher.populate(fakeParameterSource);
assertThat(armDispatcher.marketBinding.size(),is(1));
assertThat(armDispatcher.marketBinding.get(0),is("hello world"));
}
}

接口提取

提取接口时并不一定要提取类上的所有公有方法,考虑递增式地扩充该接口。

步骤

  1. 创建一个新接口,给它起一个好名字。暂时不要往里面添加任何方法
  2. 令你提取接口的目标类实现该接口。这一步不会破坏任何东西,因为接口上还没有任何方法。但你也可以编译确认一下
  3. 将你想要使用伪对象的地方从引用原类改为引用你新建的接口
  4. 编译系统。如果编译器汇报接口上缺少某某方法,则添加对应的方法(同时也往伪类上面添加一个空的实现),直到编译通过

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PaydayTransaction {
TransactionLog transactionLog;

public PaydayTransaction(TransactionLog transactionLog) {
this.transactionLog = transactionLog;
}

public void run() {
transactionLog.saveTransaction();
}
}

class TransactionLog {
public void saveTransaction() {
//call database
}

public void recordError(int code) {

}
}

重构

1
2
3
public interface TransactionRecorder {
void saveTransaction();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class PaydayTransaction {
TransactionRecorder transactionRecorder;

public PaydayTransaction(TransactionRecorder transactionRecorder) {
this.transactionRecorder = transactionRecorder;
}

public void run() {
transactionRecorder.saveTransaction();
}
}

class TransactionLog implements TransactionRecorder {
@Override
public void saveTransaction() {
//call database
}

public void recordError(int code) {

}
}

测试

1
2
3
4
5
6
7
8
class FakeTransactionLog implements TransactionRecorder {
public boolean isSave = false;

@Override
public void saveTransaction() {
isSave = true;
}
}
1
2
3
4
5
6
7
8
9
10
public class PaydayTransactionTest {

@Test
public void testPayday() {
FakeTransactionLog fakeTransactionLog = new FakeTransactionLog();
PaydayTransaction paydayTransaction = new PaydayTransaction(fakeTransactionLog);
paydayTransaction.run();
Assert.assertThat(fakeTransactionLog.isSave, is(true));
}
}

更多解依赖手法的源码参考,LegacyCode

参考

《修改代码的艺术》

关于

欢迎关注我的个人公众号

微信搜索:一码一浮生,或者搜索公众号ID:life2code