重构改善既有代码的设计

月伴飞鱼 2024-08-17 13:09:14
学习书籍 > 编程书籍
支付宝打赏 微信打赏

如果文章对你有帮助,欢迎点击上方按钮打赏作者!

书籍介绍:https://book.douban.com/subject/30468597/

书中强调重构不仅仅是改善代码的过程,也是一种发现代码潜在问题、提高设计质量和促进团队理解的手段。

重构定义

作为(名词):

  • 重构是指在不改变软件外在功能的前提下,调整其内部结构的过程
    • 这样的调整旨在提高软件的可理解性和降低修改成本

作为(动词):

  • 重构意味着通过一系列细微的步骤,不断地调整软件结构,以保持其设计的整洁和可维护性

重构是一种精练的技艺,它通过小的、计划好的修改来减少引入错误的风险

  • 本质上,重构是对已完成的代码进行设计上的改进

开展高效有序的重构,关键的心得是:

  • 小的步子可以更快前进,请保持代码永远处于可工作状态,小步修改累积起来也能大大改善系统的设计

为什么要重构

提高开发效率

  • 通过改善代码的可读性和可维护性,重构不仅提高了开发团队的效率,使其能更快地理解和修改代码。
  • 而且还增强了代码的灵活性和易修改性,支持敏捷开发的核心要求——快速响应变化。
    • 这样,当业务需求变动时,经过良好重构的代码库能够迅速适应新需求
      • 从而有效促进敏捷开发流程,而不会阻碍变更。

减少后期成本

  • 未经重构的代码会随着时间推移越来越难以维护。
    • 在敏捷开发中,这种情况会导致迭代速度下降和成本上升。
      • 通过定期重构,可以持续优化代码结构,减少后期的维护成本。

什么时候不需要重构

大部分情况下是不需要重构的。

比如我看见一堆凌乱的代码,但日常并不需要修改它而且它也比较稳定,那么我就不需要重构它。

  • 如果丑陋的代码能被隐藏在一个 API 之下,我就可以容忍它继续保持丑陋。

只有当我有痛点、需要去改动的时候,并且业务支撑扩展性、改动很费劲的时候,有痛点了对其进行重构才有价值

归根结底一句话:

  • 有痛点(线上问题、需求开发复杂、未来扩展性问题)并且重构风险可控的前提下,才需要重构

什么时候重构

线上痛点&风险可控

第一次做某件事时只管去做

第二次做类似的事会产生反感,但无论如何还是可以去做

第三次再做类似的事,你就应该考虑重构。

  • 重构的节奏是小步前进,保持代码始终处于可工作状态,从而大幅改善系统设计。

预备性重构:新需求功能更容易

预备性重构可以让添加新功能变得更加容易,而帮助理解的重构则使代码更易懂

重构的最佳时机就在添加新功能之前。

  • 在动手添加新功能之前,看看现有的代码库,此时经常会发现:
    • 如果对代码结构做一点微调,未来需求的工作会容易得多。

预备性重构:数据优化减负

在面对不断变化的业务需求时。

预备性重构不仅仅是对代码的改进,也包括对数据的优化和减负。

随着业务的发展和数据的积累,系统中的数据量会不断增加。

  • 这不仅会增加存储成本,还可能导致数据处理效率下降,进而影响系统的响应速度和用户体验。

通过预备性重构中的数据优化减负,我们可以提前解决这些潜在问题,确保系统的可扩展性和性能。

重构边界

在进行代码重构时,明确边界是至关重要的,以确保重构的效果能够提升代码质量而不引入新的问题。

在软件架构中,API(应用程序编程接口)和数据库(DB)的设计至关重要。

因为它们分别代表了系统的外部交互界面和内部数据存储机制。

良好的设计不仅能够提高系统的稳定性、可扩展性和可维护性

  • 而且在未来进行代码重构或系统升级时,也能大大减少对上游服务和数据迁移的影响。
image-20240817123623342

API设计的重要性

抽象层次:

  • API作为系统与外界交互的接口,提供了一层抽象,隐藏了底层的业务逻辑和实现细节。
    • 这意味着,只要API的接口保持不变,系统内部的实现可以自由变化而不影响外部调用者。

稳定性与兼容性:

  • 良好设计的API应该考虑到向后兼容性,即使在系统升级或重构时,也能保证对现有客户端的支持。
    • 这减少了上游服务调整的需要,使得系统的迭代更加平滑

数据库设计的重要性

扩展性和可维护性:

  • 随着系统的发展,数据量会增加,业务需求也会变化。
    • 一个设计良好的数据库能够更容易地进行扩展和维护
      • 比如通过合理的索引设计、分表分库等策略来提高性能。

数据迁移的便利性:

  • 在系统升级或重构过程中,可能需要进行数据迁移
    • 如果数据库设计考虑了未来可能的变化,那么数据迁移的工作会相对容易和安全。
      • 合理的数据版本控制和迁移脚本也是重要的一环。

代码重构的考虑

分离关注点:

  • 即使内部代码结构复杂或混乱,通过良好设计的API和数据库,也可以将内部重构的影响限制在系统内部
    • 避免波及到外部调用者或导致数据丢失、不一致等问题。

迭代开发:

  • 在保持API接口稳定和数据库设计前瞻性的前提下,可以更自由地对内部代码进行迭代开发和重构
    • 逐步改进系统的内部质量而不影响外部使用者。

上游和下游的协调:

  • 良好的API和数据库设计,可以减少在系统升级或重构时对上游服务的影响和对数据库的数据迁移需求。
    • 这意味着,即使需要进行较大的内部修改,也能保障系统的整体稳定性和数据的一致性。

API和底层数据库的设计是软件架构中的关键部分,它们的良好设计是确保系统长期健康发展的基石。

通过投入足够的时间和资源来设计和实现高质量的API和数据库

  • 可以在系统的整个生命周期中节省大量的时间和成本,尤其是在进行必要的代码重构时。

重构步骤

清晰的重构目标

明确重构的目的和目标,需要改进的区域。

确保团队成员理解本次重构的价值。

  • 比如是因为线上老出问题,还是业务支撑复杂等。

逐步重构

梳理清晰

  • 在进行逐步重构的过程中,深入理解现有代码的功能和设计是前提。
  • 这不仅包括对代码逻辑的把握,还要理解代码背后的业务逻辑和设计初衷
  • 只有全面理解了现有系统,我们才能确保重构的方向和步骤是正确的,同时避免对现有功能造成意外的影响。

逐步重构的精髓

  • 重构并不意味着要一次性进行大规模的改动。
  • 相反,它是一个持续的、逐步的过程,通过细小且有序的改进来优化程序的结构。
  • 正确的做法是在完全理解现有代码的基础上,有条不紊地进行改进
    • 每一次改动后都要通过严格且可靠的测试来确保这些改动没有引入新的错误。
    • 这种方法既可以提高代码质量,又能最大限度地减少对项目进度的影响。

大模型AI辅助重构

  • 在这个过程中,充分利用大模型AI技术,可以为重构提供有力的支持。
  • AI可以帮助我们快速理解复杂代码、发现潜在的重构机会,甚至直接提供重构建议。
  • 然而,需要注意的是,AI提供的建议并非总是完全准确。
  • 因此,使用AI技术辅助重构时,应将其视为一种参考和辅助工具。
    • 我们需要结合自己对项目的深入理解,对AI的建议进行评估和筛选
      • 以确保最终的重构方案既符合项目需求,又能有效提升代码质量。

测试和比对

在进行代码重构时,需要遵循一个不变的初始步骤:

确保待修改代码具备一套可靠的测试

因为虽然遵循精心设计的重构策略能够规避大多数引入错误的风险,但作为工程师,出错的可能性始终存在。

  • 随着程序规模的扩大,不经意间破坏其他代码部分的风险也随之增加。

编写测试:

  • 确保有充分的测试覆盖,涵盖单元测试、集成测试和系统测试。
  • 这些测试在整个重构过程中,是保障功能稳定不受影响的关键。

持续集成与自动化测试:

  • 通过自动化测试和持续集成,可以确保在重构过程中能够及时发现并修正错误,从而降低引入新错误的可能性。

R2引流测试比对重构的测试本质上是一种比对过程

由于每个系统的业务属性不尽相同,对于读操作,通过引流比对来验证功能是比较方便的方法。

如果涉及到写操作,如订单保存等,则需要对数据的各个关键环节进行比对。

回归测试

完成上述步骤后,进行回归测试以确保所有现有功能仍然如预期般正常工作。

通过这样一套全面的测试和验证流程,能够确保重构不仅提升了代码的可维护性和清晰度

  • 同时也保持了系统的稳定性和可靠性。

  • 这种方法在追求更好代码结构的同时,也最大限度地减少了对现有系统功能的影响。

切量验证

在进行重构后的切量验证时,我们可以依据不同的维度来进行灵活的验证,例如用户标识(pin)、订单的百分比、仓库等。

这样的切量验证确保了全链路的一致性和稳定性

为了更加谨慎地进行切量,我们建议采用渐进式的计划,从较小的比例和较长的时间开始,逐步增加

具体的切量步骤可以是:

  • 首先从1个或100个开始,然后按照1%、5%、10%、30%、50%、80%直至100%的顺序逐步扩大覆盖范围。
  • 这种方法可以帮助我们在每一步骤中细致地观察和评估变更的影响,从而确保重构的稳定性和效果。

如果在切量过程中遇到任何问题,我们可以利用DUCC开关快速切换回旧有功能。

这种快速回退机制为我们的重构提供了一个安全网,确保了在任何不确定性出现时

  • 我们能够迅速恢复服务的稳定性和可靠性,最大限度地减少对用户体验的影响。

通过这样细致且灵活的切量验证策略,我们不仅能够确保重构的质量和稳定性

  • 还能够在发现问题时快速响应,确保服务的持续可用性。

重构后评估

在完成重构工作后,对重构成果进行全面评估是确保目标达成的关键一步。

这不仅涉及到验证重构是否满足了预定目标,还包括了对系统性能、代码可维护性和可读性的综合评估。

性能评估:

  • 首先,我们需要对系统的性能进行再评估。

  • 这是为了确保重构工作没有导致任何性能上的退步。

  • 重构的目的往往是为了优化和改进,因此,验证性能是否至少保持不变(如果不是有所提升的话)是至关重要的。

维护性评估:

  • 接下来,我们要评估重构是否有效提高了代码的可维护性和可读性。
  • 代码的可维护性是软件质量的关键指标之一,优化代码结构、减少复杂度和增强代码的可读性都是重构的常见目标。
  • 通过评估这些方面的改进,我们可以确定重构是否达到了预期的效果。

通过遵循这些评估步骤,重构可以以一种有序和系统化的方式进行,

  • 这不仅最小化了引入新问题的风险,还有助于提升软件的整体质量。

最终,这将使得软件更加健壮、易于维护,并且能够更好地适应未来的变化和需求。

重构挑战

重构的成本

时间和资源消耗

重构是软件开发过程中一项至关重要的工作,但它确实需要投入相当的时间和人力资源,特别是在处理大型项目时。

这种投入有时可能会对新功能的开发进度产生暂时的影响。

  • 开发团队必须在维护既有代码的稳定性和引入新功能之间寻找一个恰当的平衡点。

在重构阶段,一些新功能可能需要在两个不同的代码基础上实施

  • 一是现有的未重构代码,二是正在重构的新代码。
  • 以一个为期三个月的重构周期为例,这期间上线的新功能不仅要在原有的代码架构中实现
    • 还需要在新重构的代码中进行相应的集成。
  • 这实际上意味着同一个功能点需要被开发两次,以确保功能的连续性和系统的整体稳定性。

这种做法虽然在短期内增加了工作量,但从长远来看,是确保软件质量和可持续发展的必要步骤。

通过这样的策略,我们可以在不牺牲软件稳定性和用户体验的前提下,逐步提升代码质量

  • 同时确保新功能能够及时地交付给用户。

延缓新功能开发

尤其是在紧迫的项目截止日期前,重构可能会对业务产生短期内的负面影响。

在紧张的开发周期中,分配资源给重构可能会导致耗时较长的新功能延迟开发。

重构的风险

引入新的错误

尽管重构的根本目的是提升代码质量,但在修改现有代码的过程中,总存在引入新错误的风险

  • 为了尽量避免这种情况,建立严格的测试流程至关重要,
    • 以确保重构过程不会损害现有功能的正确性和稳定性。

在面对复杂的历史代码和丰富的业务场景时,单靠人工梳理和自动化测试可能还不够,因为这些方法可能会遗漏一些细节。

在这种情况下,重构测试的一个基本原则是进行精确的比对

  • 确保重构前后,相同的输入(入参)会产生相同的输出(出参)。
  • 为了实现这一点,可以充分利用泰山R2流量录制回放技术。

通过灵活设定回放结果的比对策略,我们可以有效地减少排错的工作量。

例如,确定哪些字段可以忽略不计,哪些输出字段是核心关注的点。

  • 这要求测试团队对API接口的输入输出参数以及业务逻辑非常熟悉,以便能够制定出合理的比对策略。

根据不同的测试场景,可以灵活采用关键字段对比、结构对比等多种策略。

R2流量回放的优势在于,它能够利用线上的实际流量来丰富测试用例,从而使测试更加精准和全面

这种方法不仅提高了测试的效率,还大大增强了测试的覆盖范围,使得重构过程更加稳健,有效降低了引入新错误的风险。

重构技巧

提炼函数(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!";
        // 在这里插入邮件发送代码
    }
}

内联函数(Inline Function

内联函数(Inline Function)是一种重构技术

  • 用于将一个函数的内容移动到该函数被调用的地方,然后移除原函数。

这种技术通常用于当一个函数的体积非常小,而且只被使用一次或者函数的内容几乎和它的名字一样清晰时。

我们通过一个例子来说明内联函数的重构过程。


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;
    }
}

重构后的代码


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;
    }
}

提炼变量(Extract Variable)

将表达式的结果赋给一个临时变量,以提高表达式的清晰度。

if (order.getTotalPrice() - order.getDiscounts() > 100) {
    // 逻辑处理
}

重构后:

doublenetPrice= order.getTotalPrice() - order.getDiscounts();
if (netPrice > 100) {
    // 逻辑处理
}

内联变量(Inline Variable)

如果一个临时变量只被赋值一次,然后被直接使用,可以将其替换为直接使用赋值表达式。

doublebasePrice= order.basePrice();
return (basePrice > 1000);

重构后:

return order.basePrice() > 1000;

分解条件表达式(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();
}

合并条件表达式(Consolidate Conditional Expression)

将多个条件表达式合并为一个,简化逻辑判断。

if (isSpecialDeal()) {
    total = price * 0.95;
} else {
    total = price * 0.98;
}

重构后:

total = price * (isSpecialDeal() ? 0.95 : 0.98);

移除死代码(Remove Dead Code)

删除不再被使用的代码,减少维护负担。

重构切量验证完成后,确保老代码无用,可直接删除主赠老逻辑calcTransferTimeForGift方法以及下面依赖的方法

  • 前提是这些方法没有其他地方依赖使用。
支付宝打赏 微信打赏

如果文章对你有帮助,欢迎点击上方按钮打赏作者!