遗留系统改造-如何安全地修改原有代码
一个故事
在进入这个话题前,我们先讲一个故事。
开发同学从另一个团队接手了新的系统有一段时间了,但是平时都是加全新的功能,对已有的功能还没有完全熟悉。
这一天,我们的产品同学提了一个需求:我们需要在原来这个功能上新增一个东西,很简单,简单来说就是……。
开发同学听完需求后,发现这块功能并没有深入了解过,于是回去认真研究了下相关的产品功能,感觉改动不大,实现简单,于是信誓旦旦地对产品同学回复道:妥妥儿的,且看我一天搞定。
开发同学马不停蹄地打开IDE,一番摸索,很快找到相关功能所在的类,双击打开,IDE突然一阵卡顿。
开发同学有顿感不妙,仔细一看,一个庞大、难以理解的代码充斥着整个屏幕。
自己定的时间,含泪也得改完。
最后,开发同学在度日如年中,颤抖地完成了代码的提交,心里面却默默祈祷这次的改动不要引发其他问题。
不幸的是,最后还是出了故障。
不好的预感
墨菲定律:你越担心一件坏事发生,它就越可能发生。
上面故事中的场景,有没有一种似曾相识的感觉?
我们在平时工作中,是不是经常面临着时间紧迫,但必须修改的场景?
上线后,有没有问题全靠运气……
面对遗留系统,需要加入新的逻辑时,我们迫切需要一些具体的指导方案,能够安全地修改原有代码。
以下这些方法是你应该尝试的的方案:
- 使用TDD
- 使用新的方法
- 使用新的类
- 使用包裹方法
- 使用包裹类
- 安全消除重复代码
安全地修改方法
使用TDD
TDD(测试驱动开发)非常适合用于编写新的方法/类。
修改步骤
- 编写一个失败测试用例
- 让它编译通过
- 让测试通过
- 测试通过后再进行重构
使用TDD能够让我们更多时间去思考如何设计。
注意,我们一次操作只关注一件事情:重构或者编码。
我们大脑可不比计算机,如果同时处理多个事情,不仅仅降低效率,还容易引起问题。
使用新的方法
适用场景
若我们需要添加的代码连续出现在一个地方,使用新的方法来实现是一个好的做法。
修改步骤
确定修改点
public void scan(String x) {
String result = x + x;
display.show(result);
// TODO 新增功能
...
}
插入新方法调用并注释
public void scan(String x) {
String result = x + x;
display.show(result);
// TODO formatResult()
...
}
确定入参以及返回值
public void scan(String x) {
String result = x + x;
display.show(result);
// TODO String result = formatResult(result)
...
}
测试驱动开发新的方法
@Test
public void testFormatResultWithLowerCase() {
String result = new Demo().formatResult("x");
Assert.assertEquals("X", result);
}
@Test
public void testFormatResultWithUpperCase() {
String result = new Demo().formatResult("X");
Assert.assertEquals("X", result);
}
在写单元测试时,我们必须注意,每次只测试一种行为。
然后不断完善代码,保证测试全部通过。
protected String formatResult(String result) {
return result.toUpperCase();
}
去除注释,启用新方法
public void scan(String x) {
String result = x + x;
display.show(result);
String result = formatResult(result)
...
}
protected String formatResult(String result) {
return result.toUpperCase();
}
优点
- 新旧代码清晰隔离
- 新代码可以得到充分测试
缺点
- 原有方法依旧没有得到测试
- 新旧代码职责可能不清晰,导致进一步的混乱
使用新的类
适用场景
- 新功能是全新职责
- 新功能难以在原有类测试
修改步骤
修改步骤与新的方法基本一致,区别在于新特性在新的类实现。
我们需要记住,始终坚持TDD方式。
最终效果如下:
public class ScanResultFormatter {
public String format(String result) {
// 更多复杂的格式化逻辑
return newResult;
}
}
public void scan(String x) {
String result = x + x;
display.show(result);
String result = scanResultFormatter.format(result)
...
}
优点
所有特性实现都在新的类完成,我们可以更加安全地进行改动,以及进行更加优雅地设计,让代码更容易测试。
缺点
若新功能职责不清晰时使用新的类,可能使系统更加复杂和混乱。
使用包裹方法
适用场景
有时候,我们新增的功能与原来的逻辑并没有必然联系,仅仅是因为它们需要在一块执行,如果我们强行把功能塞到原有方法中,会使得原有方法职责混乱不清。
这个时候,使用新生方法/类就可能不太合适,手段外,使用包裹方法是另一个好的选择。
修改步骤
确定修改点
public void scan(String x) {
// TODO 新增功能
String result = x + x;
display.show(result);
...
}
将原有逻辑重命名
private String handleAndShowResult(String x) {
String result = x + x;
display.show(result);
}
创建新方法,与原有方法一致,保持签名
public String scan(String x) {
}
新方法调用重命名后的原方法
public String scan(String x) {
handleAndShowResult(x);
}
增加特性方法
新方法依旧使用TDD方法
public String scan(String x) {
addSomething(x);
handleAndShowResult(x);
}
protected void addSomething(String x) {
...
}
另一种修改步骤
不想改变原有行为,可以新增一个方法
public void scanWithAddSomthing(String x) {
addSomething(x);
scan(x);
}
优点
- 新代码可以得到充分测试
- 显式地使新功能独立于既有功能,不会跟另一意图的代码互相纠缠在一起。
缺点
- 添加的新特性无法跟旧特性的逻辑“交融”在一起。
- 得为原方法中的旧代码起一个新名字。
使用包裹类
适用场景
- 添加的行为是完全独立的,并且我们不希望让低层或者不相关的行为污染现有类。
- 原类已经够大了,不想一直在上面加功能。
本质与使用包裹方法一样,但是通过包裹类,我们可以更加优雅地添加新特性。
修改步骤
确定修改点
新建类,接受修改类参数
public class WrapATDDemo {
private Demo demo;
public WrapATDDemo(Demo demo) {
this.demo = demo;
}
public void scan(String x) {
addSomething(x);
demo.scan(x);
}
public void addSomething(String x) {
...
}
}
使用TDD为包裹类实现新特性
替换原来使用旧类的地方为包裹类
new WrapATDDemo(new Demo()).scan();
优点
- 不会污染原有方法
- 能够帮助发现类的特性,抽象为接口或者抽象类
- 可以通过组合,得到各种复杂的新功能
扩展
没错,这就是设计模式中的装饰模式。
Java中常用的各类输入输出流就是装饰模式的经典实现。
安全消除重复代码
我们在修改代码时,往往会发现大量的重复代码,不巧的是,我们需要使用这些代码来实现新的功能。
摆在我们面前有两个选择:
- 复制粘贴,一切尽在掌握之中。
- 开始重构。音乐在哪里?都起来high!
保持现状,会让系统继续腐烂;激进地重构,可能产生未知的问题。
我们需要一个安全的手段来消除这些重复代码。
修改步骤
使用TDD编写代码
- 复制粘贴实现功能
- 测试通过后再进行重构
重构
- 不急于设计最终的完美类
- 从抽离独立小块重复代码开始
- 即使是小小的重复块也不要忽略
- 编写公共类
- 相同流程,提供抽象类
- 相同代码,独立职责类
- 命名
- 尽量使用全称,而非缩写
- 新类/方法具有明确的含义
优点
消除重复是锤炼设计的强大手段,它可以使设计变得更灵活,同时让修改代码更容易。
故事的最后
开发同学决定开始编写测试,但一开始的时候是很糟糕的,他觉得写测试时间比写代码还多,感觉做了浪费了好多时间。
但是慢慢地,他开始发现,那些杂乱无章的遗留系统中出现了越来越多更好的代码,并且修改代码也变得越来越容易,bug也越来越少,这时,他仿佛觉得这么做又是值得的。
虽然编写测试花上了一些时间,但大部分情况下最终还是节省了时间,似乎不用再为每一次上线所祈祷,那些不起眼的的测试代码,仿佛安静却坚定地守护着那些美好的事情。