重构之道:驯服大型混乱系统的策略与技巧

帅旋
帅旋
关注
充电
发布于 2023-11-26 | 更新于 2024-03-03

在一个项目团队中,原本维护项目的人员都离职了,新加入的人员并没有获取到太多的接手文档,只有一个大杂烩搬的遗留系统。产品经理提需求过来之后,开发同事一脸茫然,产品说完需求之后就嚷嚷着xx天后要上线,这上线时间的确定像极了菜市场买菜讨价还价的吆喝,暗藏的项目危机被所有人抛到脑后。

也许你也曾在这样的团队里探险,接手的项目像迷宫一样,七八层的if-else嵌套,业务逻辑纠缠得比头发还乱。让人怀疑,是不是进入了代码的“百慕大三角区”?稍微改造了某一个条件分支的的代码之后,另外一个不在本次调整范围的业务竟然离奇的如如鬼打墙般出bug了,而昨天测试才反馈的另一个模块的bug,却因为这个代码改动,神奇的自愈了,代码里面究竟暗藏了什么玄机?

也许这是一个新的需求引入的一个问题,业务团队急着上线一个新功能,你对这段代码已经忍了很久了,但是想想上线时间已经这么匆忙了,那继续在代码里面稍微调整兼容下也是可以的,虽然代码复杂度会变得越来越高,但是需求却可以快速上线。

Gilles Philippart针对遗留系统,提出了一个极为有力的标语:“不要投喂怪物!”,也就是说不要改进这个遗留的“大泥球”,否则只会让它活得更久。[1]

在软件开发行业里,有这样一种现象:庞大的遗留系统像是野草般疯狂生长,却意外地拥有极佳的生意,仿佛是江湖中的隐世高人,拥有不可思议的内力。于是,众多编程侠客被迫投入这片乱麻般的代码世界,试图在无尽的Bug与维护中寻找平衡。

若是这些古老代码写得高明,那便是“代码即文档”,犹如武林秘籍中的精妙招式,让后来者钦佩不已。但若代码写得糟糕,这便成了一场痛苦的探险。《重构:改善既有代码的设计》[^4]便提出了解决之道:先行理清代码的逻辑,逐步重构,使其重新焕发光彩。

面对这些遗留系统,我们不妨换一种心态:将其视为一次深入古老武林秘境的探险,每一次重构,都是对武功秘籍的再解读。在这场历练中,我们不仅仅是在维护代码,更是在积累经验,提升自身的编程内功。毕竟,优秀的程序员,不仅能写出好代码,还能让糟糕的代码重焕生机。

鸡汤送完,现实接招!IT宅(itzhai.com)带你实操遗留系统重构,让代码重生!🚀🛠️💡

更多相关维护遗留系统的技巧,以及文档编写技巧,阅读::Living Documentation: Continuous Knowledge Sharing by Design[2]一书。

使用影响草图梳理系统

Michael Feathers在《修改代码的艺术》一书中提到了影响草图的概念。

在跟踪遗留系统的代码时,你应该画一幅草图,作为手头的任务的领域范围内的地图,在调试代码过程中,找出并记录每个功能与临近功能之间的依赖关系。完成任务后,回顾笔记和草图,提取出关键的领域信息,逐步形成项目结构。

使用气泡上下文重写部分功能

气泡上下文(Bubble Context)是由Eric Evans在2013年提出的一种重构遗留系统的有效技术。这个概念为重写系统的部分区域提供了一个理想的框架,类似于Martin Fowler所阐述的绞杀者模式(Strangler Pattern)。这种方法的核心思想在于构建一个内部一致的功能区域,然后逐步地替换和淘汰旧系统的相关部分。

在气泡上下文的实践中,开发团队首先确定一个目标区域,创建一个相对独立的“气泡”来重新实现这部分功能。这个“气泡”被设计为与旧系统隔离,但能够与其进行有效的通信。随着新系统的逐步成熟和扩展,它最终将完全替代旧系统的对应部分。这种方法不仅减少了一次性重构的风险,而且允许团队在不影响现有业务的情况下,逐步引入更加现代和高效的解决方案。

在需要迭代需求的功能中,梳理功能相关代码,基于已有代码,重新设计一个新的功能模块,用在新需求中,然后逐步把遗留系统对原本老模块的依赖迁移到新模块上面,从而完成该模块的重写。

这个需要重写的模块,可以创建一个气泡上下文,定义了重写模块和系统其余部分之间的边界。在边界的内部,可以使用不同的方式重新编写代码,你可以发挥你的TDD,DDD的相关技能,从而构建更加可维护和可测试的模块。

如果需求方要求快速上线怎么办?

说真的,如果你真的对项目负责,你就应该在迭代新需求的同时,不断去优化现有代码的问题。也许需求很急,你也可以先为了应付快速上线,但是也要有一个同时在进行的代码重构,系统优化的方案在进行,不能因为这个事情麻烦,就直接不管了,然后随着代码复杂度越来越高,然后骂骂嘞嘞的跳到了另一家公司去重操旧业写起烂代码。这样不仅对于你来说,写代码的水平不会进步,而且对于团队和产品本身来说,也是对于发展极为不利的,作为一个有专业素养的程序员,别让你的项目慢慢变“烂”,而是要带领它从代码迷宫走向璀璨星海。一起,书写代码的新篇章!🚀💻🌟

需求方
“咱们系统里不是有个差不多的功能吗?稍微改改就能用了,时间就像金钱一样宝贵,业务增长等不得呀!”
程序员
你是说那个代码的‘屎山’吗?现在改当然行,可万一将来业务发展起来,引发代码‘屎崩’怎么办?到时候谁来收拾这摊子?
需求方
我只负责业务增长。
程序员
等到我们的系统卡得像开不动的老爷车,bug像秋天的落叶一样纷纷飘落,然后你们业务部门就能安心做旁观者了吗?我可见过这戏码:先是催着开发把烂代码推上线,然后又开始指责我们这些可怜的开发者,说我们做出来的东西到处漏水。噢,说到技术实现,那可是我们的专业领域,还是让专家来驾驭这匹‘野马’吧,你们就安心做好你们的业务去吧。
需求方
不行,我要的是马上上线。
项目经理
别吵了好吧,我们就当个魔术师,一手在这个‘代码屎山’上变魔术,一手又开始搭建一个崭新的模块,就像是在做一场双簧表演。这样既能满足需求方的要求,也可以持续改进项目质量。咱们这项目啊,可不是刚出生的小崽子,而是个满身伤痕、经历沧桑的老家伙。我们得小心翼翼地给它疗伤,同时还得给它换套新衣服。这可是个技术与耐心的双重考验啊!

叠加结构:换一种清晰的思维看待遗留系统

你可能期待中的系统应该是简洁明了的,像这样:

image-20231119214110992

但实际面对的系统却是庞杂且无序的,就像被迷雾笼罩,形如这般:

image-20231119214409009

即便系统被分成了不同的包,包内的代码仍旧是杂乱无章。在这种混沌中,你可能会发现不同职责的元素混杂在一起,详细观察一个对象,你可能会惊讶地发现它承载了大部分业务逻辑。任何一个业务流程的深入,似乎都离不开这个对象的参与,这正是我们所说的“高度耦合”。

这种以职能为基础的分包方式,并没有有效地对业务进行划分。通常这样的划分看似随意,每个包下可能包含来自不同领域的对象,仅因为它们都是数据访问对象(DAO),就被放置在同一个包中。这种方式的分割,往往会对你的理解造成更多的误导而非帮助。

1
2
3
4
5
6
7
8
- com.itzhai
|- dao
|- ProductDao
|- AIDao // 这是啥?
|- IT宅Dao // 这又是啥?
|- service
|- dto
|- controller

为了对遗留系统有一个清晰的认识,你不能一头扎进这些代码细节里面,最好的方式是通过各种途径收集整理系统的业务架构,最终输出一个理想的结构,可以通过以下途径收集:

  • 阅读代码主流程,不用细看;
  • 咨询以前的开发人员;
  • 尝试修复一些bug,跟一个流程,看看其运行机制,躺一遍坑;
  • 通过其他思维模型去构建理想的项目结构:业务管道(即业务生命周期的各个阶段),主要业务,领域划分等方式去识别结构。

这里强烈建议使用领域进行考虑划分系统,识别限界上下文。关于DDD,参考:领域驱动设计(DDD):重塑软件开发的思维模式[3]

也许你新梳理的结构跟遗留细节对不上,没关系,你可以把这个新的接口当成是对旧系统的一种投影,后续沟通遗留系统业务的时候也方便有一个统一的概念。

结构映射:把叠加结构(业务驱动结构)与遗留系统技术结构进行映射

我们梳理的叠加结构,一般是跟遗留系统的技术结构对应不上的,为此,需要进行映射,比如某一个dto类,包含了哪些我们识别到的领域dto等,记录下来之后,我们对遗留系统能够有更清晰的认识,也可以帮助对未来的工作做出更好的决策。

这里可以使用yaml文档,把遗留系统代码和理想结构的关系映射记录下来,这样就可以做到团队成员共享了。

比如下面把理想的结构模块下的各种职能类找出来,映射到遗留代码里面,key是我们理想结构的包划分,value是对应遗留代码的包:

1
2
3
4
5
6
com.itzhai.mall.product:
controller:
ProductInfoController: com.itzhai.controller.ProductController
ProductTypeController: com.itzhai.controller.ProductController
dao:
ProductDao: ...

也许有人会说,这没啥用用,代码还是那样,新的接口跟代码一点也对应不上。虽然我们当前面对的是一团糟的代码,但这正是我们重新定义混乱的绝佳机会。我们的心智地图正在绘制出理想的结构蓝图,让我们在迭代的每一步都清楚如何行动。想象一下,随着新需求的迭代,我们不仅在构建新功能,还在悄悄地对旧代码进行整容手术,逐步让这个老旧系统焕发新生。这不仅仅是编程,这是一场逐步美化的艺术行动!

绞杀旧代码模块

绞杀旧代码模块不是一个一簇而就的过程,一般会持续很长时间。

但是,一旦你开始绞杀旧代码模块时候,团队里面的每个开发人员都需要指导他们需要开始在新的绞杀者应用程序里面取迭代功能了,而不应该继续在旧代码上面去迭代功能,为此,你可以添加一个自定义注解标识旧代码正在被绞杀,比如我们现在在写一个新模块:Product作为绞杀者,绞杀旧代码,那么需要再被绞杀的旧代码上面做标识:

1
2
@StrangledBy("Product")
public class ...

这样当其他开发人员碰到标识有这种注解的类时,就知道这个模块正在重构或者重写,应该往新的模块代码上面去迭代新功能,不应该继续往旧代码上面去制造更多的混乱了。


IT宅(itzhai.com)的程序员加油站: 刷新、链接、升级你的代码人生!:

抱着那种完成需求就完事的心态的程序员,是永远都得不到进一步成长的,这也是为什么需求排的越满越紧凑,挑战不可能的deadline的团队,项目质量越糟糕的原因,糟糕的项目质量引发各种现网问题,导致开发人员疲于应对。在内耗完了精力之后,便心生去意。赶需求产生的技术债务必须抽时间清理,盲目无节制的快速推进新需求,最终会导致项目走向毁灭的边缘。

想象一下,一个团队挤满了各种需求,就像是一只被塞得满满的饺子,皮都快撑破了。他们不停地在不可能的截止日期间奔波,就像是在赶着参加一场又一场的马拉松。结果呢?项目质量自然是一落千丈,各种现网问题层出不穷,开发人员忙得像是陀螺,转得眼都花了。

当然,对于业务同事来说,有些需求不得不快速推进,以满足特定的市场需求,这需要管理者做出合理的决策,让项目健康发展:

  • 平衡速度与质量:与开发团队协商,找到在保证代码质量的前提下加快开发速度的方法;
  • 优先级管理:确定哪些功能是核心必需的,哪些可以稍后实现,以便集中精力先完成最关键的任务;
  • 透明沟通:与产品经理和团队保持开放的沟通,确保所有人都了解当前的压力和风险,以及我们的应对策略;
  • 敏捷开发:采用敏捷开发方法,快速迭代,早期发布最小可行产品(MVP),然后根据反馈不断改进。

让我们不要忘记,编程之路是一场漫长的修行,急于求成只会让我们迷失方向。在这条路上,我们不仅要学会编写代码,更要学会怎样让自己的内功日渐精深。

References


  1. Cyrille Martraire. 与代码共同演进. 黄晓丹,译. 人民邮电出版社. 14.8 ↩︎

  2. Cyrille Martraire. Living Documentation: Continuous Knowledge Sharing by Design ↩︎

  3. 领域驱动设计(DDD):重塑软件开发的思维模式. Retrieved from https://www.itzhai.com/columns/architecture/domain-driven-design/from-entry-to-practice.html ↩︎

本文作者: 帅旋

本文链接: https://www.itzhai.com/articles/the-art-of-refactoring-strategies-and-skills-to-tame-large-chaotic-systems.html

版权声明: 版权归作者所有,未经许可不得转载,侵权必究!联系作者请加公众号。

×
IT宅

关注公众号及时获取网站内容更新。