开篇讲
真正进行过重构的工程师并不多,把持续重构作为开发的一部分的人,就更少了。原因如下:
- 重构对一个工程师能力的要求,要比单纯写代码高得多。重构需要工程师能洞察出代码存在的坏味道或者设计上的不足,并能合理、熟练地利用设计思想、原则、模式、编程规范等理论知识解决这些问题。
- 工程师对为什么要重构、到底重构什么、什么时候重构、如何重构等相关问题理解不深,对重构没有系统性、全局性的认识,面对一堆烂代码,没有重构技巧的指导。通常是想到哪改到哪,并不能全面地改善代码质量。
一、重构理论
1.1 重构的目的:为什么重构?
重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更容易理解,修改成本更低。
在保持功能不变的前提下,利用设计思想、原则、模式、编程规范等理论来优化代码,修改设计上的不足,提高代码质量。
重构是时刻保证代码质量的一个极其有效的手段,不至于让代码腐化到无可救药的地步。项目在演进,代码在不停地堆砌。如果没有人为代码的质量负责,代码总是会往越来越混乱的方向演进。当混乱到一定程度后,量变引起质量,项目的维护成本已经高过了重新开发一套新代码的成本,想要再去重构,已经没有人能做到了。
优秀的代码或架构不是一开始就能完全设计好的,优秀的公司和产品也都是迭代出来的。无法 100% 预见未来的需求,也没有足够的精力、时间、资源为遥远的未来买单,随着系统的演进,重构代码也是不可避免的。
重构是避免过度设计的有效手段。维护代码的过程中,真正遇到问题的时候,再对代码进行重构,能有效避免前期投入太多时间做过度的设计,做到有的放矢。
重构对一个工程师本身技术的成长也有重要的意义。重构实际上是对经典设计思想、设计原则、设计模式、编程规范的一种应用。将这些理论知识,应用到实践的一个很好的场景。另外,平时堆砌业务逻辑,可能总觉得没啥成长,而将一个比较烂的代码重构成一个比较好的代码,会很有成就感。
重构能力也是衡量一个工程师代码能力的有效手段。所谓“初级工程师在维护代码,高级工程师在设计代码,资深工程师在重构代码”。
1.2 重构的对象:重构什么?
- 大规模高层次重构(大型重构)
大型重构指的是对顶层代码设计的重构,包括:系统、模块、代码结构、类与类之间的关系等的重构。重构的手段有:分层、模块化、解耦、抽象可复用组件等等。重构的工具就是设计思想、原则和模式。
- 小规模低层次重构(小型重构)
小型重构指的是对代码细节的重构,主要是针对类、函数、变量等代码级别的重构,例如:规范命名、规范注释、消除超大类和函数、提取重复代码等等。利用编码规范,就可以实现小型重构。
1.3 重构的时机:什么时候重构?
代码烂到出现“开发效率低,招了很多人,天天加班,出活却不多,线上 bug 频发,领导发飙,中层束手无策,工程师抱怨不断,查找 bug 困难”的时候,基本上重构也无法解决问题。
反对平时不注重代码质量,堆砌烂代码,是在维护不了了就大刀阔斧地重构、甚至重写的行为,这样重构很难做得彻底,最后又搞出来一个“四不像的怪物”,那就更麻烦了。
提倡可持续、可演进的方式,平时看看项目中有哪些写得不够好的、可以优化的代码,主动去重构一下。在修改、添加某个功能的时候,顺手把不符合编程规范、不好的设计重构一下。把持续重构作为开发的一部分,成为一种开发习惯,对项目、对自己都很有好处。
需要有持续重构的意识,要正确地看待代码质量和重构这件事情。技术在更新、需求在变动、人员在流动,代码质量总会下降,代码总会存在不完美,重构就会持续在进行。时刻具有持续重构意识,才能避免开发初期就过度设计,避免代码维护过程中质量的下降。那些看到代码质量有点瑕疵就一顿乱骂,或者花尽心思去构思一个完美设计的人,往往都是因为没有树立正确的代码质量观,没有持续重构意识。
1.4 重构的方法:如何重构?
于大型重构而言,因为涉及的模块、代码会比较多,如果项目代码质量比较差,耦合严重,往往会牵一发而动全身,只会发现越改越多、越改越乱。而新的业务开发又与重构相冲突,最后只能半途而废,很失落地又去堆砌烂代码了。
进行大型重构的时候,要提前做好完善的重构计划,有条不紊地分阶段来进行。每个阶段完成一小部分代码的重构,然后提交、测试、运行,发现没有问题之后,再继续进行下一阶段的重构,保证代码仓库中的代码一直处于可运行、逻辑正确的状态。每个阶段,要控制好重构影响到的代码范围,考虑好如何兼容老的代码逻辑,必要的时候还需要写一些兼容过渡代码。让每一阶段的重构不至于耗时太久,不至于与新的功能开发相冲突。
大规模高层次的重构一定是有组织、有计划,并且非谨慎的,需要有经验、熟悉业务的工程师来主导。小规模低层次的重构,因为影响范围小,改动耗时短,只要愿意并且有时间,随时都可以去做。除去人工发现低层次的质量问题,还可以借助很多成熟的静态代码分析工具,来自动发现代码中的问题,然后针对性地进行重构优化。
对于重构这件事情,资深的工程师、项目 leader 要负起责任来,没事就重构一下代码,时刻保证代码质量处在一个良好的状态。否则,一旦出现“破窗效应”,一个人往里堆了一些烂代码,之后就会有更多的人往里堆更烂的代码。毕竟往项目里堆砌烂代码的成本太低了。保持代码质量最好的方法还是打造一种好的技术氛围,以此来驱动大家主动去关注代码质量,持续重构代码。
- 重构一定要在有比较完善的测试用例覆盖和回归用例库的情况下进行(可测试性),否则会相当危险。
- 重构最好有AB工具灰度比对,逐步切流。
- 重构最好有资深的成员共同CR,结合大家的意见,可能本次的重构也会引入一些怪味道。
- 重构一旦出问题会面临比较大的精神压力和信心挑战,会部分挫败重构者的积极性,这时候需要 team leader 的鼓励和支持,避免让员工感受到做多错多。
二、如何保证重构不出错
面对项目中的烂代码,大多程序员都想重构一下,但又担心重构之后出问题,出力不讨好。确实,如果要重构的代码还是别的同事开发的,在不熟悉,没有任何保障的情况下,重构引入 bug 的风险很大。
最可落地执行、最有效的保证重构不出错的手段应该就是单元测试(unit testing)。重构完成后,如果新的代码仍然能通过单元测试,说明代码原有逻辑的正确性未被破坏,原有的外部可见行为未变。
2.1 什么是单元测试?
单元测试用来验证研发工程师所写代码的正确性。写单元测试本身不需要什么高深技术,更多的是考验程序员思维的缜密程度,看能否设计出覆盖各种正常及异常情况的测试用例,来保证代码在任何预期或非预期的情况下都能正确运行。
- 单元测试:测试粒度小,测试对象是类或者函数,用来测试一个类或函数是否都按照预期的逻辑执行,代码层级的测试。
- 集成测试:测试粒度大,测试对象是整个系统或者某个功能模块,是一种端到端(end to end)的测试。
2.2 为什么要写单元测试?
-
单元测试能有效地帮你发现代码中的 bug
-
单元测试能帮你发现代码设计上的问题
代码的可测试性是评判代码质量的一个重要标准,如果很难为其编写单元测试,或者单元测试写起来很吃力,往往意味着代码设计得不够合理。
-
单元测试是对集成测试的有力补充
-
写单元测试的过程本身就是代码重构的过程
编写单元测试就相当于对代码的一次自我 code review,在这个过程中,可以发现一些设计上的问题(代码设计的不可测试)以及代码编写方面的问题(边界 case 处理不当)等,然后针对性进行重构。
-
阅读单元测试能帮助你快速熟悉代码
在没有文档和注释的情况下,单元测试可以起到替代性作用。单元测试用例就是用户用例,反映了代码的功能和如何使用。
-
单元测试是 TDD 可落地执行的改进方案
测试驱动开发,TDD(Test-Driven Development, TDD),核心指导思想:测试用例先于代码编写。
2.3 如何编写单元测试?
针对代码设计覆盖各种输入、异常、边界条件的测试用例,并将这些测试用例翻译成代码的过程。可以利用单元测试框架,简化测试代码的编写,关注测试用例本身的编写即可。
- 单元测试覆盖率是比较容易量化的指标,常常作为单元测试写得好坏的评判标准。将覆盖率作为衡量单元测试质量的唯一标准是不合理的;更重要的是要看测试用例是否覆盖了所有可能的情况,特别是一些 corner case。
- 写单元测试不需要了解代码的实现逻辑。单元测试不要依赖被测试函数的具体实现逻辑,只关心被测函数实现了什么功能。不能为了追求覆盖率,逐行阅读代码,针对实现逻辑编写单元测试。
三、代码的可测试性
- 代码的可测试性:就是针对代码编写单元测试的难易程度。
- 依赖注入是编写可测试性代码的最有效手段。
3.1 Anti- Patterns
典型的、常见的测试性不好的代码。
3.1.1 未决行为
未决行为的逻辑:代码的输出是随机或者不确定的,比如,跟时间、随机数有关的代码。 解决方案:
- 将时间、随机数单独抽取到方法中,方便替换测试。
- 将时间、随机数作为当前方法的入参。
3.1.2 全局变量
全局变量是一种面向过程的编程风格,滥用全局变量也让编写单元测试变得困难。多个单元测试方法使用同一个全局变量,由于执行单元测试用例的顺序不确定(可能顺序执行,也可能并发执行),导致单元测试也变得不稳定。
3.1.3 静态方法
类似于全局变量,静态方法也是一种面向过程的编程思维。主要原因在于静态方法很难 mock。
3.1.4 复杂继承
如果父类需要 mock 某个依赖对象才能进行单元测试,那所有的子类、子类的子类……在编写单元测试的时候,都要 mock 这个依赖对象。越底层的子类要 mock 的对象可能就会越多,导致底层子类在写单元测试的时候,要一个一个 mock 很多依赖对象,而且还要看父类代码,去了解该如何 mock 这些依赖对象。
利用组合而非继承来组织类之间的关系,类之间的结构层次比较扁平,在编写单元测试的时候,只需要 mock 类所组合依赖的对象即可。
3.1.5 高耦合代码
如果一个类职责很重,需要依赖十几个外部对象才能完成工作,代码高度耦合。那么写单元测试时,可能需要 mock 这十几个依赖的对象。从代码设计、编写单元测试角度来看,都是不合理的。
四、封装、抽象、模块化、中间层解耦代码
4.1 解耦为何如此重要?
“高内聚、松耦合”的特性可以让我们聚焦在某一模块或类中,不需要了解太多其他模块或类的代码,让我们的焦点不至于过于发散,降低了阅读和修改代码的难度。
代码“高内聚、松耦合”,也就意味着,代码结构清晰、分层和模块化合理、依赖关系简单、模块或类之间的耦合小,那么代码整体的质量就不会差。
4.2 如何判断代码是否需要解耦?
- 看修改代码会不会牵一发而动全身。
- 把模块与模块之间、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。
4.3 如何给代码解耦?
1. 封装与抽象
封装和抽象可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口。
2. 中间层
- 引入中间层能简化模块或类之间的依赖关系。让代码结构更加清晰。如下图所示:
- 在进行重构的时候,引入中间层可以起到过渡的作用,能够让开发和重构同步进行,不互相干扰。
3. 模块化
模块化是构建复杂系统常用的手段。对于一个大型复杂系统来说,没有人能掌控所有的细节。搭建出如此复杂的系统,并且能维护得了,最主要的原因就是将系统划分成各个独立的模块,让不同的人负责不同的模块,这样即便在不了解全部细节的情况下,管理者也能协调各个模块,让整个系统有效运转。
不同的模块之间通过 API 来进行通信,每个模块之间耦合很小。
如果追本溯源,模块化思想更加本质的东西就是分而治之。
4. 其它设计思想与原则
-
单一职责原则
-
基于接口而非实现编程
通过接口这样一个中间层,隔离变化和具体的实现。
-
依赖注入
-
少用继承,多用组合
-
迪米特法则
五、改善代码质量的编程规范
5.1 命名与注释 (naming and comments)
5.1.1 命名
命名这件事说难也不难,关键还是看重视程度,愿不愿意花时间。对于影响范围比较大的命名,比如包名、接口、类名,一定要反复斟酌、推敲。实在想不到好名字的时候,可以去 GitHub 上用相关的关键词联想搜索一下,看看类似的代码是怎么命名的。
1. 命名多长最合适?
在足够表达其含义的情况下,命名当然是越短越好。但是,大部分情况下,短的命名都没有长的命名更能达意。所以,很多书籍或者文章都不推荐在命名时使用缩写。对于一些默认的、大家都比较熟知的词,比较推荐用缩写。
对于作用域比较小的变量,可以使用相对短的命名,比如一些函数内的临时变量。相反,对于类名这种作用域比较大的,更推荐用长的命名方式。
命名的一个原则就是以能准确达意为目标。命名的时候,一定要学会换位思考,假设自己不熟悉这块代码,从代码阅读者的角度去考量命名是否足够直观。
2. 利用上下文简化命名
- 类内部的变量和方法命名可借助类名进行简化。
- 函数参数也可以借助函数这个上下文来简化命名。
3. 命名要可读、可搜索
- “可读”,指的是不要用一些特别生僻、难发音的英文单词来命名。
- 命名可搜索,在 IDE 中编写代码的时候,经常会用“关键词联想”的方法来自动补全和搜索。所以,在命名的时候,最好能符合整个项目的命名习惯。
- 统一规约是很重要的,能减少很多不必要的麻烦。
4.4. 如何命名接口和抽象类?
- 加前缀“I”,表示一个 Interface。比如 IUserService,对应的实现类命名为 UserService。
- 不加前缀,比如 UserService,对应的实现类加后缀“Impl”,比如 UserServiceImpl。
5.1.2 注释
很多书籍认为,好的命名完全可以替代注释。如果需要注释,那说明命名不够好,需要在命名上下功夫,而不是添加注释。这样的观点有点太过极端。命名再好,毕竟有长度限制,不可能足够详尽,此时注释就是一个很好的补充。 1. 注释到底该写什么?
注释的目的就是让代码更容易看懂。总结一下,注释的内容主要包含这样三个方面:做什么、为什么、怎么做。
2. 注释是不是越多越好?
注释太多,有可能意味着代码写得不够可读,需要写很多注释来补充。而且,后期的维护成本也比较高,有时候代码改了,注释忘了同步修改,就会让代码阅读者更加迷惑。
类和函数一定要写注释,而且要写得尽可能全面、详细,而函数内部的注释要相对少一些,一般都是靠好的命名、提炼函数、解释性变量、总结性注释来提高代码的可读性。
5.2 代码风格 (code style)
- 函数、类多大才合适?
- 一行代码多长最合适?
- 善用空行分割单元块
- 四格缩进还是两格缩进?
- 大括号是否要另起一行?
- 类中成员的排列顺序
5.3 编程技巧(coding tips)
1. 把代码分割成更小的单元块
阅读代码的习惯都是,先看整体再看细节。所以,要有模块化和抽象思维,善于将大块的复杂逻辑提炼成类或者函数,屏蔽掉细节。
2. 避免函数参数过多
函数包含 3、4 个参数的时候还是能接受的,大于等于 5 个的时候,就觉得参数有点过多了,会影响到代码的可读性,使用起来也不方便。
2 种处理方式:
- 考虑函数是否职责单一,是否能通过拆分成多个函数的方式来减少参数。
- 将函数的参数封装成对象。如果函数是对外暴露的远程接口,将参数封装成对象,还可以提高接口的兼容性。
3. 勿用函数参数来控制逻辑
函数中使用布尔类型的标识参数来控制内部逻辑,明显违背了单一职责原则和接口隔离原则。建议将其拆成两个函数,可读性上也要更好。
如果函数是私有函数,影响范围有限,或者拆分之后的两个函数经常同时被调用,可以酌情考虑保留标识参数。
还有一种“根据参数是否为 null”来控制逻辑的情况。针对这种情况,也应该将其拆分成多个函数。拆分之后的函数职责更明确,不容易用错。
4. 函数设计要职责单一
5. 移除过深的嵌套层次
代码嵌套层次过深往往是因为 if-else、switch-case、for 循环过度嵌套导致的。嵌套最好不超过两层,超过两层之后就要思考一下是否可以减少嵌套。
解决嵌套过深的方法也比较成熟,有下面 4 种常见的思路:
- 去掉多余的 if 或 else 语句;
- 使用编程语言提供的 continue、break、return 关键字,提前退出嵌套;
- 调整执行顺序来减少嵌套;
- 将部分嵌套逻辑封装成函数调用,以此来减少嵌套;
- 常用的还有通过使用多态来替代 if-else、switch-case 条件判断的方法。
6. 学会使用解释性变量
- 常量取代魔法数字
- 使用解释性变量来解释复杂表达式,如下所示:
if (date.after(SUMMER_START) && date.before(SUMMER_END)) {
// ...
} else {
// ...
}
// 引入解释性变量后逻辑更加清晰
boolean isSummer = date.after(SUMMER_START)&&date.before(SUMMER_END);
if (isSummer) {
// ...
} else {
// ...
}
5.4 统一编码规范
最后,还有一条非常重要的,那就是,项目、团队,甚至公司,一定要制定统一的编码规范,并且通过 Code Review 督促执行。