前段时间,我接手了一个和Linux容器相关的项目,这个项目是之前的同事写的,功能倒是能跑,但是代码写得实在是不敢恭维,一个函数几百行,变量命名乱七八糟,注释几乎没有,耦合度很高,改一个地方要动很多地方,bug也很多。我花了两周的时间,对这个项目的代码进行了一次彻底的重构,从烂代码变成了优雅代码,重构之后,代码可读性、可维护性、可扩展性都提升了很多,bug也少了很多。今天就来聊聊这次代码重构的经历,聊聊我是怎么把烂代码重构成优雅代码的,以及代码重构的一些思路、方法和最佳实践。

一、重构前的代码有多烂

先说说重构前的代码有多烂,让大家有个直观的感受,也看看你们的项目有没有类似的问题。

这个项目是用Go语言写的,功能是实现一个简单的容器运行时,类似Docker的核心功能,包括创建容器、启动容器、停止容器、删除容器、容器的网络和存储管理等。功能不算特别复杂,但是代码写得是真的烂。

第一个问题:函数过长,职责不单一。 最长的一个函数有800多行,创建容器的整个流程都写在一个函数里,从解析参数、创建文件系统、设置Namespace、设置Cgroups、配置网络、启动进程,全在一个函数里,看得人头晕眼花。想改其中一个小功能,都要在800行代码里找半天,而且很容易改出bug。

第二个问题:变量命名混乱。 变量名各种风格都有,有的用拼音,有的用英文缩写,有的用单个字母,还有的拼写错误,比如把namespace写成namespce,把cgroups写成cgroupes,看代码的时候要猜这个变量是什么意思。还有很多魔法数字,比如if status == 3,你根本不知道3是什么意思,要去翻代码找。

第三个问题:几乎没有注释。 整个项目的注释不到10行,复杂的逻辑没有注释,函数没有文档注释,关键的业务逻辑没有说明,看代码完全靠猜,猜半天还不一定猜对。特别是Linux容器相关的代码,涉及很多系统调用和内核概念,没有注释真的很难懂。

第四个问题:重复代码很多。 同样的逻辑,在好几个地方都复制粘贴了一遍,比如执行命令的逻辑,设置Cgroups的逻辑,好几个函数里都有类似的代码,改一个地方,其他地方忘了改,就会出现不一致的bug。

第五个问题:耦合度高,分层不清晰。 所有的代码都在一个包里,没有分层,没有模块划分,UI层、业务逻辑层、数据访问层全混在一起,函数之间互相调用,关系错综复杂,改一个函数可能影响好几个其他的功能,牵一发而动全身。

第六个问题:错误处理不规范。 很多地方错误直接忽略了,err != nil 直接return nil,连个日志都不打,出了问题根本不知道哪里错了。还有的地方错误处理不一致,有的地方返回error,有的地方直接panic,有的地方返回bool,很混乱。

第七个问题:没有测试。 整个项目一个单元测试都没有,改完代码只能手动测试,很容易漏测,改了这个功能,那个功能又坏了,回归测试成本很高。

相信很多人看到这里都会有共鸣,很多项目的代码都是这样的,能跑,但是烂,维护起来很痛苦,改一个bug要花好几天,还容易引入新的bug。我刚接手这个项目的时候,也是很崩溃,但是没办法,既然接手了,就要把它做好,于是我决定进行一次彻底的重构。

二、重构前的准备工作

重构不是上来就改代码,那样很容易改出问题,特别是这种没有测试的项目,盲目重构风险很大。所以重构前的准备工作很重要。

第一步:理解现有代码的功能和逻辑。 我花了三天的时间,把整个项目的代码通读了一遍,一边读一边画流程图,把每个功能的执行流程都画出来,把函数之间的调用关系也画出来,搞清楚每个函数是干什么的,每个变量是什么意思,整个系统的架构是什么样的。虽然代码很烂,但是功能是能跑的,我要先搞清楚它是怎么跑的,才能在不改变功能的前提下重构。

第二步:搭建测试环境,写集成测试。 因为没有单元测试,重构之前我先写了一些集成测试,就是从用户的角度,测试整个容器的创建、启动、停止、删除等功能是否正常,把这些测试作为回归测试的基准。重构之后,跑这些集成测试,如果都通过了,说明功能没有被改坏。虽然集成测试不能覆盖所有的细节,但是至少能保证主要功能是正常的,大大降低了重构的风险。

第三步:制定重构计划,分阶段进行。 这么大的项目,不可能一次重构完,我制定了一个分阶段的重构计划,先做什么,后做什么,每个阶段做什么,都列得清清楚楚。第一阶段是代码整理,不改逻辑,只做一些不改变功能的整理,比如统一命名、加注释、提取魔法数字为常量;第二阶段是函数重构,把长函数拆成小函数,消除重复代码;第三阶段是架构重构,分层、模块化、解耦;第四阶段是错误处理和日志规范化;第五阶段是补单元测试。分阶段进行,每个阶段完成后都跑一遍集成测试,确保功能正常,再进入下一个阶段,这样风险可控。

第四步:和团队沟通,获得支持。 重构是一件需要时间的事情,而且短期内看不到明显的收益,反而可能会引入bug,所以要和团队、领导沟通,说明重构的必要性和收益,获得他们的支持,安排出专门的时间来做重构,不要一边做需求一边重构,那样两边都做不好。

三、重构的具体步骤和方法

准备工作做好之后,就开始正式重构了,按照计划分阶段进行。

第一阶段:代码整理,不改变逻辑。

这一阶段不改变代码的逻辑,只做一些表面的整理,风险最小,但是能快速提升代码的可读性。

  • 统一命名规范。 把所有的变量名、函数名、文件名都统一成有意义的英文命名,遵循Go语言的命名规范,驼峰式,见名知意。把拼音命名、缩写命名、拼写错误都改掉,比如把namespce改成namespace,把cgroupes改成cgroups。把单个字母的变量改成有意义的名字,比如把n改成containerName,把c改成containerConfig。
  • 提取魔法数字和魔法字符串为常量。 把代码里的魔法数字和魔法字符串都提取成有意义的常量,比如把status == 3改成status == ContainerStopped,把"/var/lib/container"改成DefaultContainerDir,这样一看就知道是什么意思,而且要改的时候只需要改常量定义的地方,不用到处找。
  • 加注释。 给每个函数加文档注释,说明函数的功能、参数、返回值;给复杂的逻辑块加注释,说明这段代码是干什么的,为什么这么写;给关键的业务逻辑加注释,说明业务背景。特别是Linux容器相关的代码,涉及很多系统调用,比如clone、mount、pivot_root等,都要加注释说明这个系统调用是干什么的,为什么要这么用,方便后来的人理解。
  • 统一代码格式。 用代码格式化工具把整个项目的代码格式统一,比如Go的gofmt,缩进、空格、换行都统一,代码看起来整齐舒服。

这一阶段完成之后,代码的可读性已经提升了很多,虽然逻辑还是一样的,但是看起来舒服多了,也容易理解了。而且因为没有改变逻辑,风险很小,集成测试都能通过。

第二阶段:函数重构,拆分长函数,消除重复代码。

这一阶段开始改变代码的结构,但是不改变功能,主要是把长函数拆成小函数,消除重复代码,提升代码的可维护性。

  • 拆分长函数。 把那些几百行的长函数,按照职责拆成多个小函数,每个函数只做一件事,也就是单一职责原则。比如那个800行的创建容器的函数,我拆成了好几个小函数:parseContainerConfig(解析配置)、createContainerRootfs(创建文件系统)、setupContainerNamespaces(设置Namespace)、setupContainerCgroups(设置Cgroups)、setupContainerNetwork(配置网络)、startContainerProcess(启动进程),每个函数几十行,职责单一,一看就知道是干什么的。原来的主函数就变成了调用这些小函数的流程控制,很清晰。
  • 提取重复代码为公共函数。 把多处重复的代码提取成公共函数,比如执行系统命令的逻辑,在好几个地方都有,我提取了一个execCommand函数,统一处理命令执行、错误处理、日志记录,其他地方直接调用就行。还有设置Cgroups的逻辑,也提取成了公共函数,统一管理。消除重复代码之后,代码量减少了,而且改的时候只需要改一个地方,不会出现不一致的问题。
  • 函数参数优化。 有些函数参数特别多,七八个参数,调用的时候很容易传错,我把相关的参数封装成结构体,比如把容器的配置都封装成ContainerConfig结构体,函数只需要传一个结构体就行,清晰又不容易错。
  • 消除嵌套过深。 有些函数嵌套很深,if里面套if,套了四五层,看得人头晕。我用提前返回(early return)的方式来减少嵌套,比如如果参数不合法,直接返回错误,不用把整个逻辑都包在if里面,这样嵌套层级就少了,代码更清晰。

这一阶段完成之后,代码的结构清晰了很多,每个函数都短小精悍,职责单一,可读性和可维护性都大大提升。这一阶段因为改变了代码结构,有一定的风险,所以每拆完一个函数,就跑一遍集成测试,确保功能正常,有问题及时修复。

第三阶段:架构重构,分层模块化,解耦。

这一阶段是重构的核心,对整个项目的架构进行重构,分层、模块化、解耦,提升代码的可扩展性和可维护性。

原来的代码所有东西都在一个包里,没有分层,耦合度很高。我按照领域驱动设计的思路,把项目分成了几个层次和模块:

  • cmd层: 命令行入口,负责解析命令行参数,调用业务层的接口,不包含业务逻辑。
  • api层: 对外的API接口层,定义了容器运行时的对外接口,比如CreateContainer、StartContainer、StopContainer等,接口定义清晰,实现和接口分离。
  • service层: 业务逻辑层,实现了具体的业务逻辑,比如容器的生命周期管理,这一层只做业务逻辑,不关心具体的技术实现。
  • domain层: 领域模型层,定义了核心的领域模型,比如Container、Image、Network、Volume等,以及领域相关的接口定义,这一层是最核心的,不依赖任何外部技术。
  • infra层: 基础设施层,实现了具体的技术细节,比如Linux的Namespace操作、Cgroups操作、文件系统操作、网络操作等,这一层依赖具体的Linux系统调用,但是对上层暴露的是领域接口。

分层之后,每层的职责清晰,依赖关系明确,上层依赖下层的接口,不依赖具体实现,这样就实现了解耦。比如业务层只依赖容器的领域接口,不关心底层是用Linux的Namespace实现的,还是用其他技术实现的,以后如果要支持其他容器技术,只需要在基础设施层加实现就行,业务层不用改。

除了分层,我还按照功能做了模块化,把容器管理、镜像管理、网络管理、存储管理分成了不同的模块,每个模块独立,模块之间通过接口通信,高内聚低耦合。

这一阶段是改动最大的,也是风险最高的,我花了一周的时间才完成,每改完一个模块,就跑一遍集成测试,确保功能正常。改完之后,整个项目的架构清晰了很多,代码的可扩展性和可维护性都大大提升,以后加新功能也方便了很多。

第四阶段:错误处理和日志规范化。

架构重构完成之后,我对错误处理和日志进行了规范化。

  • 错误处理规范化: 统一错误处理方式,所有可能出错的地方都要检查error,不能忽略;错误信息要清晰有意义,包含足够的上下文信息,方便排查问题;错误要向上传递,不要在底层就打日志然后返回nil,要把错误传递给上层,由上层决定怎么处理;定义统一的错误类型,区分用户错误和系统错误,用户错误要给用户友好的提示,系统错误要记录详细日志。
  • 日志规范化: 统一日志格式,包含时间、级别、模块、消息等信息;日志级别要合理,DEBUG、INFO、WARN、ERROR分别用在不同的场景;关键的操作要记日志,比如容器创建、启动、停止,方便排查问题;错误的时候要记详细的错误日志,包含堆栈信息,方便定位问题;不要打无用的日志,也不要泄露敏感信息,比如密码、密钥等。

错误处理和日志规范化之后,系统出了问题能快速定位,排查问题的效率提升了很多。

第五阶段:补单元测试。

最后,我给核心的模块补了单元测试,特别是业务逻辑层和领域模型层,核心的函数都有单元测试,覆盖率达到了80%以上。有了单元测试之后,以后改代码就放心多了,改完跑一遍单元测试,就知道有没有改坏,回归测试的成本大大降低。

四、重构后的效果

经过两周的重构,整个项目的代码焕然一新,从烂代码变成了优雅代码,效果非常明显。

可读性大大提升。 代码结构清晰,命名规范,注释齐全,函数短小精悍,新人接手项目,看代码的时间从原来的一周缩短到了一天,很快就能上手。

可维护性大大提升。 分层清晰,模块独立,耦合度低,改一个功能只需要改对应的模块,不会牵一发而动全身,改bug的时间从原来的几天缩短到了几小时,而且不容易引入新的bug。

可扩展性大大提升。 接口和实现分离,依赖倒置,以后加新功能很方便,比如要支持新的容器网络模式,只需要加一个实现,不用改现有的代码,符合开闭原则。

bug率大大降低。 重构过程中就发现并修复了好几个隐藏的bug,重构之后因为代码清晰,逻辑明确,bug也少了很多,线上故障率下降了60%以上。

开发效率大大提升。 代码好懂了,好改了,加新功能的速度也快了很多,原来一个需求要做一周,现在两三天就能做完,团队的开发效率大大提升。

团队的同事都说,重构之后的代码看着就舒服,改起来也顺心,再也不用对着烂代码头疼了。

五、代码重构的最佳实践和经验总结

通过这次重构,我总结了一些代码重构的最佳实践和经验,分享给大家。

1. 重构不是重写,是在不改变功能的前提下改善代码结构。 很多人以为重构就是把代码推倒重写,其实不是,重构是在不改变外部功能的前提下,改善代码的内部结构,提升代码质量。重写风险很大,很容易改出问题,而且周期长,重构是小步快跑,逐步改善,风险可控。除非代码已经烂到完全无法维护了,否则不要轻易重写,优先选择重构。

2. 重构前一定要有测试,没有测试不要重构。 测试是重构的安全网,有了测试,你改完代码跑一遍测试,就知道有没有改坏,心里有底。如果没有测试,盲目重构风险很大,很容易改出bug,而且自己还不知道。所以重构前一定要先补测试,哪怕只是集成测试,也比没有强。

3. 重构要小步快跑,分阶段进行,每一步都要可验证。 不要想着一次就把整个项目重构完,那样风险太大,而且周期长,中间很容易出问题。要分阶段,小步快跑,每次只改一小部分,改完就跑测试,验证功能正常,再继续下一步。这样每一步都可控,出了问题也能快速回滚。

4. 重构要和团队沟通,获得支持,安排专门的时间。 重构是一件需要时间和精力的事情,而且短期内看不到明显的业务收益,所以要和团队、领导沟通,说明重构的必要性和长期收益,获得他们的支持,安排专门的时间来做。不要一边赶需求一边重构,那样两边都做不好,而且很容易因为赶进度而放弃重构。

5. 不要在重构的同时加新功能。 重构的时候只改代码结构,不加新功能,不然你既改了结构又加了功能,出了问题不知道是重构导致的还是新功能导致的,排查起来很麻烦。重构和加新功能要分开,先重构,重构完验证没问题了,再加新功能。

6. 重构是持续的,不是一次就完了。 代码质量的提升不是一次重构就能永久解决的,以后写代码如果不注意,还是会慢慢变烂。所以重构应该是持续的,日常开发中就要注意代码质量,看到烂代码就顺手重构一下,"童子军规则":离开的时候让营地比你来的时候更干净。每次改代码的时候,顺便把周围的烂代码改善一点,日积月累,代码质量就会越来越好。

7. 好代码是改出来的,不是写出来的。 没有人能一次就写出完美的代码,好代码都是在不断的修改、重构中慢慢打磨出来的。所以不要怕代码写得不好,写出来之后,不断地优化、重构,慢慢就会变成好代码。重要的是要有改善代码质量的意识,愿意花时间去重构,去打磨。

六、写在最后

Linux容器原理代码重构:从烂代码到优雅代码。

以上就是我这次代码重构的完整经历,从重构前的烂代码,到重构的准备工作、具体步骤、重构后的效果,以及总结的最佳实践和经验。这次重构让我深刻体会到了代码质量的重要性,烂代码维护起来真的很痛苦,改一个bug要花好几天,还容易引入新的bug;而好代码看着就舒服,改起来也顺心,开发效率高,bug也少。

很多团队都不重视代码质量,觉得只要功能能跑就行,重构是浪费时间,但是实际上,烂代码的维护成本是很高的,短期看省了时间,长期看浪费了更多的时间,而且bug多,用户体验差,影响产品质量。所以,技术债务是要还的,早还比晚还好,主动还比被动还好。

希望我的这次重构经历能给大家一些启发,如果你也在维护一个烂代码的项目,不要抱怨,也不要放弃,试着去重构它,一步一步来,小步快跑,你会发现,从烂代码到优雅代码,其实没有那么难,而且重构的过程也是一个学习和成长的过程。

最后用一句话结尾:"代码是写给人看的,顺便能在机器上运行。"愿我们都能写出优雅的代码,也能勇敢地去重构烂代码,让代码世界更美好。