背景
在软件开发的世界里,代码重构是提升项目质量、适应业务变化的关键步骤。最近,我重新翻阅了《重构:改善既有代码的设计 第二版》,这本书不仅重新点燃了我对重构的热情,还深化了我的理解:重构不仅仅是代码层面的整理,它更是一种软件开发的哲学,强调持续改进和适应变化的重要性。
书中通过详细的案例分析和代码示例,将理论与实践巧妙地融合在一起。我尤其赞赏作者如何将复杂的重构任务拆解成一系列的小步骤,每一步都被精心设计和考虑,大大降低了重构过程中的风险,同时提高了整个过程的可控性。
在这篇文章中,我将通过《重构:改善既有代码的设计 第二版》书中知识、以及结合在过去几年中的重构经历(大到系统架构、核心接口、底层数据存储、小到简单的一个方法),分享一些关于重构的感悟和心得。 通过有真实的场景、实际重构案例的剖析,我们可以更深刻地理解重构不仅是代码层面的改进,更是一种思维方式,指引我们如何在不断变化的业务需求面前,持续优化和提升软件的质量与效能。
一、重构的定义与理念
正确定义问题,比解决问题重要一百倍。那我们首先来搞清楚什么叫重构?
作为(名词),重构是指在不改变软件外在功能的前提下,调整其内部结构的过程。这样的调整旨在提高软件的可理解性和降低修改成本。
作为(动词),重构意味着通过一系列细微的步骤,不断地调整软件结构,以保持其设计的整洁和可维护性。
重构是一种精练的技艺,它通过小的、计划好的修改来减少引入错误的风险。本质上,重构是对已完成的代码进行设计上的改进。
开展高效有序的重构,关键的心得是:小的步子可以更快前进,请保持代码永远处于可工作状态,小步修改累积起来也能大大改善系统的设计。
二、重构边界与时机
1)重构边界
在进行代码重构时,明确边界是至关重要的,以确保重构的效果能够提升代码质量而不引入新的问题
在软件架构中,API(应用程序编程接口)和数据库(DB)的设计至关重要,因为它们分别代表了系统的外部交互界面和内部数据存储机制。良好的设计不仅能够提高系统的稳定性、可扩展性和可维护性,而且在未来进行代码重构或系统升级时,也能大大减少对上游服务和数据迁移的影响。
1.1)API设计的重要性
1.抽象层次:API作为系统与外界交互的接口,提供了一层抽象,隐藏了底层的业务逻辑和实现细节。这意味着,只要API的接口保持不变,系统内部的实现可以自由变化而不影响外部调用者。
2.稳定性与兼容性:良好设计的API应该考虑到向后兼容性,即使在系统升级或重构时,也能保证对现有客户端的支持。这减少了上游服务调整的需要,使得系统的迭代更加平滑。
1.2)数据库设计的重要性
1.扩展性和可维护性:随着系统的发展,数据量会增加,业务需求也会变化。一个设计良好的数据库能够更容易地进行扩展和维护,比如通过合理的索引设计、分表分库等策略来提高性能。
2.数据迁移的便利性:在系统升级或重构过程中,可能需要进行数据迁移。如果数据库设计考虑了未来可能的变化,那么数据迁移的工作会相对容易和安全。合理的数据版本控制和迁移脚本也是重要的一环。
1.3)代码重构的考虑
•分离关注点:即使内部代码结构复杂或混乱,通过良好设计的API和数据库,也可以将内部重构的影响限制在系统内部,避免波及到外部调用者或导致数据丢失、不一致等问题。
•迭代开发:在保持API接口稳定和数据库设计前瞻性的前提下,可以更自由地对内部代码进行迭代开发和重构,逐步改进系统的内部质量而不影响外部使用者。
•上游和下游的协调:良好的API和数据库设计,可以减少在系统升级或重构时对上游服务的影响和对数据库的数据迁移需求。这意味着,即使需要进行较大的内部修改,也能保障系统的整体稳定性和数据的一致性。
总之,API和底层数据库的设计是软件架构中的关键部分,它们的良好设计是确保系统长期健康发展的基石。 通过投入足够的时间和资源来设计和实现高质量的API和数据库,可以在系统的整个生命周期中节省大量的时间和成本,尤其是在进行必要的代码重构时。
2)为什么要重构
提高开发效率:通过改善代码的可读性和可维护性,重构不仅提高了开发团队的效率,使其能更快地理解和修改代码,而且还增强了代码的灵活性和易修改性,支持敏捷开发的核心要求——快速响应变化。这样,当业务需求变动时,经过良好重构的代码库能够迅速适应新需求,从而有效促进敏捷开发流程,而不会阻碍变更。
减少后期成本:未经重构的代码会随着时间推移越来越难以维护。在敏捷开发中,这种情况会导致迭代速度下降和成本上升。通过定期重构,可以持续优化代码结构,减少后期的维护成本。
3)什么时候重构
3.1)线上痛点&风险可控
书中给了一条准则:第一次做某件事时只管去做;第二次做类似的事会产生反感,但无论如何还是可以去做;第三次再做类似的事,你就应该考虑重构。重构的节奏是小步前进,保持代码始终处于可工作状态,从而大幅改善系统设计。
案例: 订单中间件xml节点解析代码重构 背景: 代码解析xml节点到处都是,根据业务不同解析不同节点,每次修改的点比较多,并且有时候容易遗漏,导致线上问题 重构前代码: 解析xml代码各种散乱
重构后: 节点解析统一收口
重构后效果: 1、重构后节点解析独立,每个节点对应1个方法,通用性强,以前门槛高,只能专人修改,现在团队都可修改 2、系统稳定性更健壮 3、需求迭代效率更高
3.2)预备性重构:新需求功能更容易
预备性重构可以让添加新功能变得更加容易,而帮助理解的重构则使代码更易懂,重构的最佳时机就在添加新功能之前。在动手添加新功能之前,看看现有的代码库,此时经常会发现:如果对代码结构做一点微调,未来需求的工作会容易得多
案例:XXX业务层重构建设 背景: 现在业务识别散乱在各个模块,导致每次业务需求,需要了解并修改所有模块的相关部分,同时测试也需要全模块覆盖,增加需求消耗,同时结构混杂,维护和新人接收都有一定的困难。
重构前代码方法混乱(总共470行)
重构后代码主方法(52行)职责清晰
重构后效果: 1、长期价值:代码结构清晰、可读性强、不容易出错、稳定性更健壮 2、长期价值:需求交付周期提升60+%,之前需求5+人日(需要修改N个地方),可提升到2人日(统一收口)
3.3)预备性重构:数据优化减负
在面对不断变化的业务需求时。预备性重构不仅仅是对代码的改进,也包括对数据的优化和减负。随着业务的发展和数据的积累,系统中的数据量会不断增加。这不仅会增加存储成本,还可能导致数据处理效率下降,进而影响系统的响应速度和用户体验。通过预备性重构中的数据优化减负,我们可以提前解决这些潜在问题,确保系统的可扩展性和性能。
案例: X缓存数据瘦身 背景: xxx 重构前: 缓存:存储空间使用率60%
重构后: 存储空间使用率30% 重构效果: 1、提高资源利用率,解决缓存数据量大问题(内存使用率从60%降低到30%)
2、增强业务扩展性,为未来的业务扩展打下坚实的基础。
4)什么时候不需要重构
上面讲解了什么时候时候重构,但大部分情况下是不需要重构的。比如我看见一堆凌乱的代码,但日常并不需要修改它而且它也比较稳定,那么我就不需要重构它。如果丑陋的代码能被隐藏在一个 API 之下,我就可以容忍它继续保持丑陋。只有当我有痛点、需要去改动的时候,并且业务支撑扩展性、改动很费劲的时候,有痛点了对其进行重构才有价值。
归根结底一句话:有痛点(线上问题、需求开发复杂、未来扩展性问题)并且重构风险可控的前提下,才需要重构
案例回顾: xxx重构尝试 背景简述: xxx的代码基础历史悠久,承载着复杂的业务逻辑,这些逻辑是经过多代开发人员的手逐渐叠加与优化的。随着时间的推移,虽然功能日益强大,但代码结构也变得愈加复杂,从而提升了维护的难度和风险。 重构目标: 本次重构的主要目标是实现代码结构的清晰化,以便于后续的维护和扩展。我们希望通过重构,将混乱的代码逻辑整理得更加条理清晰,同时保持现有功能的稳定性。 结论与决策: 尽管重构初衷良好,希望能为后续的维护和开发铺平道路,但在实际执行过程中,我们遇到了预期之外的挑战。在深入测试重构后的代码时,我们发现存在多个之前未被充分考虑的使用场景,这些场景的复杂性和多样性超出了原先的预期。随着更多场景的出现,整体的重构风险逐渐升高,不再处于一个可控的范围内。 经过慎重考虑,团队决定叫停此次重构尝试。我们认识到,在当前阶段继续推进重构,可能会引入更多不确定性和潜在的风险,从而影响到核心业务的稳定运行。虽然这一决定意味着短期内仍需应对现有代码结构的挑战,但从长远来看,确保业务的连续性和稳定性是我们的首要任务。 未来展望: 虽然这次重构未能如期完成,但它为我们提供了宝贵的经验和教训。我们将继续寻找合适的时机和方法,以更加细致和周全的计划来逐步优化代码结构。同时,我们也会加强对现有代码的理解和文档的完善,为未来的重构工作奠定坚实的基础。
三、重构的实践与步骤
软件重构是一个系统性的过程,涉及到对现有代码的一系列改进。以下是进行重构的一些常见实践和步骤
1)清晰的重构目标
明确重构的目的和目标,需要改进的区域。确保团队成员理解本次重构的价值。比如是因为线上老出问题,还是业务支撑复杂等。
2)逐步重构
梳理清晰: 在进行逐步重构的过程中,深入理解现有代码的功能和设计是前提。这不仅包括对代码逻辑的把握,还要理解代码背后的业务逻辑和设计初衷。只有全面理解了现有系统,我们才能确保重构的方向和步骤是正确的,同时避免对现有功能造成意外的影响。
逐步重构的精髓:重构并不意味着要一次性进行大规模的改动。相反,它是一个持续的、逐步的过程,通过细小且有序的改进来优化程序的结构。正确的做法是在完全理解现有代码的基础上,有条不紊地进行改进,每一次改动后都要通过严格且可靠的测试来确保这些改动没有引入新的错误。这种方法既可以提高代码质量,又能最大限度地减少对项目进度的影响。
大模型AI辅助重构:在这个过程中,充分利用大模型AI技术,可以为重构提供有力的支持。AI可以帮助我们快速理解复杂代码、发现潜在的重构机会,甚至直接提供重构建议。然而,需要注意的是,AI提供的建议并非总是完全准确。因此,使用AI技术辅助重构时,应将其视为一种参考和辅助工具。我们需要结合自己对项目的深入理解,对AI的建议进行评估和筛选,以确保最终的重构方案既符合项目需求,又能有效提升代码质量。
案例: 2个重复代码的方法重构合并一个 重构前代码:
AI建议重构后代码如下:
AI详细过程如下:
AI生成单测用例
3)测试和比对
在进行代码重构时,需要遵循一个不变的初始步骤:确保待修改代码具备一套可靠的测试。这一步至关重要,因为虽然遵循精心设计的重构策略能够规避大多数引入错误的风险,但作为工程师,出错的可能性始终存在。随着程序规模的扩大,不经意间破坏其他代码部分的风险也随之增加。
编写测试:确保有充分的测试覆盖,涵盖单元测试、集成测试和系统测试。这些测试在整个重构过程中,是保障功能稳定不受影响的关键。
持续集成与自动化测试:通过自动化测试和持续集成,可以确保在重构过程中能够及时发现并修正错误,从而降低引入新错误的可能性。
R2引流测试比对:重构的测试本质上是一种比对过程。由于每个系统的业务属性不尽相同,对于读操作,通过引流比对来验证功能是比较方便的方法。如果涉及到写操作,如订单保存等,则需要对数据的各个关键环节进行比对。
回归测试:完成上述步骤后,进行回归测试以确保所有现有功能仍然如预期般正常工作。
通过这样一套全面的测试和验证流程,能够确保重构不仅提升了代码的可维护性和清晰度,同时也保持了系统的稳定性和可靠性。这种方法在追求更好代码结构的同时,也最大限度地减少了对现有系统功能的影响。
4)切量验证
在进行重构后的切量验证时,我们可以依据不同的维度来进行灵活的验证,例如用户标识(pin)、订单的百分比、仓库等。这样的切量验证确保了全链路的一致性和稳定性。
为了更加谨慎地进行切量,我们建议采用渐进式的计划,从较小的比例和较长的时间开始,逐步增加。具体的切量步骤可以是:首先从1个或100个开始,然后按照1%、5%、10%、30%、50%、80%直至100%的顺序逐步扩大覆盖范围。这种方法可以帮助我们在每一步骤中细致地观察和评估变更的影响,从而确保重构的稳定性和效果。
如果在切量过程中遇到任何问题,我们可以利用DUCC开关快速切换回旧有功能。这种快速回退机制为我们的重构提供了一个安全网,确保了在任何不确定性出现时,我们能够迅速恢复服务的稳定性和可靠性,最大限度地减少对用户体验的影响。
通过这样细致且灵活的切量验证策略,我们不仅能够确保重构的质量和稳定性,还能够在发现问题时快速响应,确保服务的持续可用性。
5)重构后评估
在完成重构工作后,对重构成果进行全面评估是确保目标达成的关键一步。这不仅涉及到验证重构是否满足了预定目标,还包括了对系统性能、代码可维护性和可读性的综合评估。
性能评估:首先,我们需要对系统的性能进行再评估。这是为了确保重构工作没有导致任何性能上的退步。重构的目的往往是为了优化和改进,因此,验证性能是否至少保持不变(如果不是有所提升的话)是至关重要的。
维护性评估:接下来,我们要评估重构是否有效提高了代码的可维护性和可读性。代码的可维护性是软件质量的关键指标之一,优化代码结构、减少复杂度和增强代码的可读性都是重构的常见目标。通过评估这些方面的改进,我们可以确定重构是否达到了预期的效果。
通过遵循这些评估步骤,重构可以以一种有序和系统化的方式进行,这不仅最小化了引入新问题的风险,还有助于提升软件的整体质量。最终,这将使得软件更加健壮、易于维护,并且能够更好地适应未来的变化和需求。
四、重构的挑战
尽管重构对维持和提升软件质量至关重要,但它也伴随着一定的成本和风险挑战。理解这些成本和风险对于成功实施重构计划至关重要。
1)重构的成本
1.1)时间和资源消耗
重构是软件开发过程中一项至关重要的工作,但它确实需要投入相当的时间和人力资源,特别是在处理大型项目时。这种投入有时可能会对新功能的开发进度产生暂时的影响。开发团队必须在维护既有代码的稳定性和引入新功能之间寻找一个恰当的平衡点。
在重构阶段,一些新功能可能需要在两个不同的代码基础上实施:一是现有的未重构代码,二是正在重构的新代码。以一个为期三个月的重构周期为例,这期间上线的新功能不仅要在原有的代码架构中实现,还需要在新重构的代码中进行相应的集成。这实际上意味着同一个功能点需要被开发两次,以确保功能的连续性和系统的整体稳定性。
这种做法虽然在短期内增加了工作量,但从长远来看,是确保软件质量和可持续发展的必要步骤。通过这样的策略,我们可以在不牺牲软件稳定性和用户体验的前提下,逐步提升代码质量,同时确保新功能能够及时地交付给用户。
1.2) 延缓新功能开发
尤其是在紧迫的项目截止日期前,重构可能会对业务产生短期内的负面影响。在紧张的开发周期中,分配资源给重构可能会导致耗时较长的新功能延迟开发。
案例:XXX架构升级 重构目标: 1)打造更清晰的业务边界和更纯净的内核计算逻辑。引入一套高效的时效计算缓存机制,成功地节省了高达一半的硬件资源费用。 2)增强了系统的复用性,同一套核心计算模式能够适应预约和非预约两种不同的业务场景,包括结算、商详以及下单前后的处理 重构成本挑战: 面临的最大挑战之一是重构工作本身的成本,这不仅包括直接的开发、测试成本,还有可能因为重构而推迟开发新功能的机会成本。时间成本:从xxxx到xxxx时候。由于需求的迭代和调整,我们不得不将上线时间推迟至N月份,这意味着在这期间新需求需要再重构前和重构后两边都进行开发。
2)重构的风险
2.1)引入新的错误
尽管重构的根本目的是提升代码质量,但在修改现有代码的过程中,总存在引入新错误的风险。为了尽量避免这种情况,建立严格的测试流程至关重要,以确保重构过程不会损害现有功能的正确性和稳定性。
在面对复杂的历史代码和丰富的业务场景时,单靠人工梳理和自动化测试可能还不够,因为这些方法可能会遗漏一些细节。在这种情况下,重构测试的一个基本原则是进行精确的比对:确保重构前后,相同的输入(入参)会产生相同的输出(出参)。为了实现这一点,可以充分利用泰山R2流量录制回放技术。
通过灵活设定回放结果的比对策略,我们可以有效地减少排错的工作量。例如,确定哪些字段可以忽略不计,哪些输出字段是核心关注的点。这要求测试团队对API接口的输入输出参数以及业务逻辑非常熟悉,以便能够制定出合理的比对策略。根据不同的测试场景,可以灵活采用关键字段对比、结构对比等多种策略。
R2流量回放的优势在于,它能够利用线上的实际流量来丰富测试用例,从而使测试更加精准和全面。这种方法不仅提高了测试的效率,还大大增强了测试的覆盖范围,使得重构过程更加稳健,有效降低了引入新错误的风险。
五、重构小技巧
书中通过具体的代码示例展示了如何执行重构,并解释了每种重构的动机、做法和效果。以下是一些重要的重构技术和案例:
1)提炼函数(Extract Function)
提炼函数(Extract Function)是一种重构技术,它的目的是将一个大的函数拆分成若干个小的、功能单一的函数。这样做可以提高代码的可读性、可维护性,并且可以复用那些小的函数。
让我们通过一个简单的例子来说明这个概念。假设我们有一个函数,它的任务是为一个在线商店的用户创建一个账户,并发送一封欢迎邮件。
重构前:
public class AccountService {
public void createAccount(String email, String username, String pwd) {
if (email == null || email.isEmpty()) {
throw new IllegalArgumentException("Email cannot be empty.");
}
if (username == null || username.isEmpty()) {
throw new IllegalArgumentException("Username cannot be empty.");
}
if (pwd == null || pwd.isEmpty()) {
throw new IllegalArgumentException("pwd cannot be empty.");
}
// 在这里插入数据库操作代码,创建账户
// 发送欢迎邮件
String welcomeMessage="Dear " + username + ", welcome to our service!";
// 在这里插入邮件发送代码
}
}
在这段代码中,createAccount
方法同时负责验证输入、创建账户和发送邮件。我们可以通过提炼函数来拆分这个方法。
重构后:
public class AccountService {
public void createAccount(String email, String username, String pwd) {
validateAccountDetails(email, username, pwd);
insertAccountIntoDatabase(email, username, pwd);
sendWelcomeEmail(username);
}
private void validateAccountDetails(String email, String username, String pwd) {
if (email == null || email.isEmpty()) {
throw new IllegalArgumentException("Email cannot be empty.");
}
if (username == null || username.isEmpty()) {
throw new IllegalArgumentException("Username cannot be empty.");
}
if (pwd == null || pwd.isEmpty()) {
throw new IllegalArgumentException("pwd cannot be empty.");
}
}
private void insertAccountIntoDatabase(String email, String username, String password) {
// 在这里插入数据库操作代码,创建账户
}
private void sendWelcomeEmail(String username) {
String welcomeMessage="Dear " + username + ", welcome to our service!";
// 在这里插入邮件发送代码
}
}
在重构后的代码中,我们将createAccount
方法中的三个主要任务分别提炼到了三个独立的私有方法中。每个方法都有明确的职责:验证账户信息、插入账户到数据库和发送欢迎邮件。这样的代码更加清晰,每个部分都更容易理解和测试。此外,如果将来我们需要在其他地方验证账户信息或发送邮件,我们可以复用这些已经提炼出来的方法,增加了代码的可重用性。
2)内联函数(Inline Function)
内联函数(Inline Function)是一种重构技术,用于将一个函数的内容移动到该函数被调用的地方,然后移除原函数。这种技术通常用于当一个函数的体积非常小,而且只被使用一次或者函数的内容几乎和它的名字一样清晰时。
下面我们通过一个例子来说明内联函数的重构过程。
重构前的代码
在这个例子中,我们有一个LogisticsService
类,它有一个calculateShippingCost
方法,这个方法只是简单地调用了另一个方法getBaseShippingCost
。如果getBaseShippingCost
方法只在这里被调用,我们就可以考虑使用内联函数。
public class LogisticsService {
public double processOrder(Order order) {
// 其他处理逻辑...
doubleshippingCost= calculateShippingCost(order);
// 其他处理逻辑...
return shippingCost;
}
private double calculateShippingCost(Order order) {
return getBaseShippingCost(order);
}
private double getBaseShippingCost(Order order) {
doublebaseCost=0.0;
return baseCost;
}
}
重构后的代码
现在我们将calculateShippingCost
方法内联到processOrder
方法中,并移除calculateShippingCost
方法。
public class LogisticsService {
public double processOrder(Order order) {
// 其他处理逻辑...
double shippingCost= getBaseShippingCost(order);
// 其他处理逻辑...
return shippingCost;
}
private double getBaseShippingCost(Order order) {
double baseCost=0.0;
return baseCost;
}
}
在这个重构后的例子中,我们直接在processOrder
方法中调用getBaseShippingCost
来计算运费,从而去除了多余的calculateShippingCost
方法。这样做简化了代码结构,减少了一层不必要的抽象,使得代码更加直接和清晰。
内联函数是一种微妙的重构手法,需要谨慎使用,因为如果过度使用,可能会导致代码重复或者降低代码的可读性。通常只有当一个函数不再提供有用的抽象,或者它的内容和名称几乎同样描述性时,才应该考虑内联。
3)提炼变量(Extract Variable)
◦将表达式的结果赋给一个临时变量,以提高表达式的清晰度。
重构前:
if (order.getTotalPrice() - order.getDiscounts() > 100) {
// 逻辑处理
}
重构后:
doublenetPrice= order.getTotalPrice() - order.getDiscounts();
if (netPrice > 100) {
// 逻辑处理
}
4)内联变量(Inline Variable)
◦如果一个临时变量只被赋值一次,然后被直接使用,可以将其替换为直接使用赋值表达式。
重构前:
doublebasePrice= order.basePrice();
return (basePrice > 1000);
重构后:
return order.basePrice() > 1000;
5)引入参数对象(Introduce Parameter Object)
◦将多个函数参数替换为一个对象,当多个函数共享几个参数时尤其有用,常用context上下文数据传递
重构前:
public void trest(String logPrefix, A a,B b,C c) {
//业务逻辑处理
}
重构后:
public void trest(Context context) {
//业务逻辑处理
}
6)分解条件表达式(Decompose Conditional)
◦将复杂的条件逻辑分解为更清晰的逻辑块,提高其可读性。
重构前:
public void applyFee(Account account) {
if (account.getBalance() < 0 && account.isOverdraftEnabled()) {
account.addFee(OVERDRAFT_FEE);
}
}
重构后:
public void applyFee(Account account) {
if (shouldApplyOverdraftFee(account)) {
account.addFee(OVERDRAFT_FEE);
}
}
private boolean shouldApplyOverdraftFee(Account account) {
return account.getBalance() < 0 && account.isOverdraftEnabled();
}
7)合并条件表达式(Consolidate Conditional Expression)
◦将多个条件表达式合并为一个,简化逻辑判断。
重构前:
if (isSpecialDeal()) {
total = price * 0.95;
} else {
total = price * 0.98;
}
重构后:
total = price * (isSpecialDeal() ? 0.95 : 0.98);
8)移除死代码(Remove Dead Code)
◦删除不再被使用的代码,减少维护负担。
重构前:
重构后:
•重构切量验证完成后,确保老代码无用,可直接删除主赠老逻辑calcTransferTimeForGift方法以及下面依赖的方法(前提是这些方法没有其他地方依赖使用)。
书中强调重构不仅仅是改善代码的过程,也是一种发现代码潜在问题、提高设计质量和促进团队理解的手段。
六、总结
《重构:改善既有代码的设计 第二版》是一本值得每位专业程序员阅读的指南。这本书深入探讨了重构的概念、过程、技术和案例,旨在指导开发者如何通过一系列小的、控制风险的代码修改来逐步改进代码的内部结构,而不改变其外部行为。这不仅提升了软件的业务价值和灵活性,也使我们成为了能够写出人类易于理解代码的优秀程序员。
如果您有任何其他关于重构的建议或想法,欢迎评论交流,谢谢!