遗留系统改造-理解代码并编写测试

前言

当我们开发一个新功能的时候,也曾经有过深入了解遗留系统的冲动,但阅读那些错综复杂的旧代码让人感觉头痛不已——不仅仅需要耗费大量的时间,而且好像对实现新功能没有太大的帮助。

但不理解整体代码,会让我们在修改遗留代码的过程中非常被动,原有逻辑往往充满了各种各样的陷阱,一个修改就可能引发各种血案。

我们迫切想知道如何够快速理解代码,哪些代码需要测试,以及怎样编写测试。

本文将深入探讨这些问题,并给出相关的解决方法。

如何理解代码

我们在看代码时,往往会一头钻入各种各样的实现细节。而我们的大脑并不擅长记忆,看完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时

在探索、感知代码的过程中,我们很可能会发现上古期间遗留下来的深坑。

因为这个探索并不是任务驱动,我们应该如何做处理?

放任不管或者等到下次关联任务再修改?

都不是。

发现问题时,只有一个原则:尽快修复。

如果功能还未使用,主动修复。
如果功能已使用,需要分析造成的影响,然后尽快修复。

未雨绸缪,永远好于亡羊补牢。

总结

遗留系统往往让人觉得深不可测。

这就需要我们花更多的时间,耐心的、充分的理解代码,才能避免深坑,甚至主动填坑。

在动手改造系统前,最关键的是为先那些忠实的测试代码们安家置业,只有它们落地生根了,我们才能安心开疆拓土。

下期我们将主动出击,直面那些遗留系统中最纯正血统的继承者们:大类和大的方法。

中美合拍,敬请期待_

你可能感兴趣的:(遗留系统改造-理解代码并编写测试)