前言
当我们开发一个新功能的时候,也曾经有过深入了解遗留系统的冲动,但阅读那些错综复杂的旧代码让人感觉头痛不已——不仅仅需要耗费大量的时间,而且好像对实现新功能没有太大的帮助。
但不理解整体代码,会让我们在修改遗留代码的过程中非常被动,原有逻辑往往充满了各种各样的陷阱,一个修改就可能引发各种血案。
我们迫切想知道如何够快速理解代码,哪些代码需要测试,以及怎样编写测试。
本文将深入探讨这些问题,并给出相关的解决方法。
如何理解代码
我们在看代码时,往往会一头钻入各种各样的实现细节。而我们的大脑并不擅长记忆,看完A逻辑,等到C逻辑的时候,你可能已经忘记什么是A逻辑了。
更加雪上加霜的是,遗留代码的实现往往非常混乱,业务逻辑与技术细节相互纠缠,让我们无法看清整体脉络,看着看着,就可能迷失了方向。
既然大脑的记忆能力有限,那我们就把这个工作交给合适的工具,让它有时间处理最擅长的工作:思考。
把握全局
遗留代码量往往非常大,我们可以选择一部分感兴趣的模块或者功能进行深入理解。
在深入每个类的细节前,首先要先了解核心类或者方法之间的关联与职责。
使用注记或者草图,写下它们之间的关联逻辑,非常有助于我们梳理思路。
注意,我们并不是要整理详细的UML类图,而是关键类和方法的意图以及关联,能够用你的纸和笔就可以快速勾勒的草图。
随着草图的逐渐完善,原本看似零散的和方法将呈现他们清晰的关联和作用。
时刻谨记,克制住自己深入细节,特别是技术细节,这个环节最重要的任务是把握全局。
把握细节
当我们对全局功能有了初步了解后,可以进一步了解实现细节。
但遗留代码的结构往往惨不忍睹,大类和大的方法随处可见,你可能会迷失在一个几千行的类,或者是几百行的一个方法。
我们在对付这些实现细节时,同样可以运用全局观的方法,避免进入细节迷宫。
职责分离
把一个类中的不同方法,或者一个方法中的不同代码,按照它们的职责进行分组排序,并添加相应的注释。
分组排序后,对帮助阅读和理解代码有着非常好的提升效果。
样例1
public class Demo {
public static final String MULTIPART_RESOLVER_BEAN_NAME = "multipartResolver";
private MultipartResolver multipartResolver;
private FlashMapManager flashMapManager;
private LocaleResolver localeResolver;
public static final String LOCALE_RESOLVER_BEAN_NAME = "localeResolver";
private MultipartResolver multipartResolver;
public static final String THEME_RESOLVER_BEAN_NAME = "themeResolver";
}
调整后:
public class Demo {
public static final String MULTIPART_RESOLVER_BEAN_NAME = "multipartResolver";
public static final String THEME_RESOLVER_BEAN_NAME = "themeResolver";
public static final String LOCALE_RESOLVER_BEAN_NAME = "localeResolver";
private MultipartResolver multipartResolver;
private LocaleResolver localeResolver;
private MultipartResolver multipartResolver;
private FlashMapManager flashMapManager;
}
样例2
processedRequest = checkMultipart(request);
multipartRequestParsed = (processedRequest != request);
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null || mappedHandler.getHandler() == null) {
noHandlerFound(processedRequest, response);
return;
}
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
调整后:
processedRequest = checkMultipart(request);
multipartRequestParsed = (processedRequest != request);
// Determine handler for the current request.
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null || mappedHandler.getHandler() == null) {
noHandlerFound(processedRequest, response);
return;
}
// Determine handler adapter for the current request.
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
// Actually invoke the handler.
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
理解方法结构
在面对超长的代码块时,除了常规注释,我们还可以使用开始结束标记,配合IDE的展开/收缩功能,可以大大帮助我们回忆代码意图。
// 发送邮件开始
- { ... }
// 发送邮件结束
// -------------
// 打印日志开始
- { ... }
// 打印日志结束
理解修改影响
在较好地理解代码后,我们开始考虑如何修改代码,在思考过程中,将改动所影响的变量/方法做记号,确保不会遗留。
// TODO 需要修改
private String a;
private String say(){
// TODO xx修改开始
...
// TODO xx修改结束
}
无用的代码
在阅读代码过程中,我们经常会发现一些不用的代码,甚至主动产生一些无用的代码。
新的功能不需要这段逻辑,你可能会注释掉某些方法的引用。这些被注释掉的,或者无人引用的代码,就变成了无用代码。
我们应该如何处理它们呢?
不要犹豫,直接删除它们。
或许你自己没有察觉,当你注意到那些无用代码的时候,你的注意力已经被它分散了,不管这个持续时长有多少。
更加糟糕的情况是,如果无用代码对你造成了某些困惑,那我们浪费的时间就更多了。
如果我们将来需要这些代码怎么办?
放心,那些先进的代码版本库,可以轻松帮你找回来。
草稿式重构
在进一步了解代码后,我们可能会受到遗留代码的一万点伤害——有太多太多可以吐槽的地方了。
我们已经堆积了不少的想法,只是碍于重构需要改动的地方太多,没有测试的保护无法动手。
而等待正常的需求来迭代优化这些代码,可能需要漫长的时间。
难道你就只能按捺住那颗燥热的重构之心?
当然不。
认识代码的最佳技术就是重构。
如果不考虑那些众多的测试,不考虑是否破坏已有的功能,不考虑所有历史的负担,我们使用自己最喜欢的方式,对变量、对类名、对代码进行大胆的重构,是否能够完全释放你的灵感?
你是这块全新代码的主人,在重构过程中,你的设想得以验证,新的想法相互碰撞。
这就是草稿式重构。
仅仅用于理解代码、验证想法、获取灵感的临时重构。
因为没有测试保护,这些重构代码不能直接用于生产环境。但也因为无需测试和背负历史负担,我们可以快速重构,它往往能够给你带来意想不到的效果。
所以,拿起你的键盘,尝试草稿式重构吧,它可以让你更加深入理解代码,还能为你提供更多好的想法,在未来正式重构中提供更多的帮助。
确定应该测试的代码
在充分理解代码后,我们终于有信心面对修改。
但是在修改代码前,我们必须先确定好应该测试哪些代码,否则就无法判断是否影响原有逻辑。
确定应该测试的代码最关键的地方,就在于确定修改产生的影响。
推测代码修改影响
一开始的时候,我们可能没有办法准确把握所有影响范围,可以先初步列出影响范围,并把它们记录下来。
IDE的查找引用功能是一个非常强大的手段,可以帮助我们快速定位修改地方被调用的范围,进而观察调用方如何使用返回值。
但很多时候,正真的陷阱却是在那些难以察觉的地方:
- 方法会修改入参的引用对象。
- 修改后被用到全局或者静态数据。
这些是我们需要特别注意的地方,也是我们写代码时应该尽量避免的做法。
应该怎样写测试
万事俱备,只欠东风。
我们前面所有的准备,都是为了正确编写测试。
如果我们只是忙于寻找和修补Bug,这个工作永无止境,而且过程痛苦不堪,因为我们永远处于被动地防守。
只有手持的测试盾牌,我们才能主动反击。
特征测试
虽然我们已经对代码有所了解,但对它们的效果还是存有疑虑,因为我们无法完全确定目标代码的所有行为。
在修改代码前,最重要的事情就是确定当前系统或者代码能够做什么。
很多人都不经过验证,凭感觉认为它应该可以做什么,而这种感觉往往会让你掉坑。
所以我们需要一个方法,能够客观判断实际行为,这个方法就是特征测试。
在修改前通过编写特征测试来观察代码的实际行为,确保修改后不会影响原有行为。
步骤
- 对目标代码块编写测试
- 编写失败断言
- 从失败中得知代码行为
- 修改测试,让它与预期目标代码的实际行为
- 查看目标代码
- 直接断言目标代码实际结果,确保未来修改不会改变原有结果
- 不断重复上述步骤
寻找交汇点
我们在编写特征测试时,希望尽可能覆盖所有关键行为和代码路径。
如果能够找到一个合适的交汇点,只需要对少数几个方法测试就能覆盖大量场景,同时能有效减少编写测试的工作量(解依赖等)。
需要注意的是,若我们寻找到的旧系统交汇点组合了大量的方法,那么它就不太适合作为一个测试入口,因为这会引导你编写出一个迷你型的集成测试,这可不是我们想要的东西。
通过寻找交汇点,不仅仅有利于简化旧代码测试,还可以判断代码设计的好坏。
那些不合适测试的交汇点,就是我们未来需要重构的地方。
但是,修改、重构之旅往往都是漫长的,我们需要不断完善测试用例,直到完全理解行为,才可以大展身手。
通过测试感知系统
有些时候,我们出于好奇心想深入探索类的行为,测试也是一种非常好的手段,能够帮助我们快速了解系统的主要意图。
我们可以寻找代码中的复杂部分,引入变量进行感知。
随着我们对代码的熟悉程度逐渐加深,会发现一些问题或者存在一些疑问,把它们加入待测试清单,持续编写测试触发,直到完全了解行为。
当发现bug时
在探索、感知代码的过程中,我们很可能会发现上古期间遗留下来的深坑。
因为这个探索并不是任务驱动,我们应该如何做处理?
放任不管或者等到下次关联任务再修改?
都不是。
发现问题时,只有一个原则:尽快修复。
如果功能还未使用,主动修复。
如果功能已使用,需要分析造成的影响,然后尽快修复。
未雨绸缪,永远好于亡羊补牢。
总结
遗留系统往往让人觉得深不可测。
这就需要我们花更多的时间,耐心的、充分的理解代码,才能避免深坑,甚至主动填坑。
在动手改造系统前,最关键的是为先那些忠实的测试代码们安家置业,只有它们落地生根了,我们才能安心开疆拓土。
下期我们将主动出击,直面那些遗留系统中最纯正血统的继承者们:大类和大的方法。
中美合拍,敬请期待_