理解变化中的依赖关系
在第 1 章《掌握测试驱动开发》中,我们讨论了在编写代码时遵循红-绿-重构(Red-Green-Refactor)TDD 技术进行代码重构。这意味着在编写代码时限制清理工作。然而,随着我们继续进行 TDD 的实践,必须考虑我们的代码如何随着时间的发展而演变,并考虑更大规模的代码重构或重写。
代码重构通常与代码重新设计互换使用,但它们代表了不同级别的代码修改。代码重新设计涉及改变代码库/服务的功能,而代码重构则涉及改变服务交付现有功能的方式。实际上,如果做得正确,代码重构对于服务的内部和外部用户应该是不可见的。
代码重构的目的
开发人员进行代码重构的目的是为了使代码更加高效、可维护和可扩展。代码重构带来许多好处:更好的可读性、性能提升以及使开发人员能更高效地修改代码。这些合起来被称为*非功能性需求*。 |
一个项目的测试策略是验证和支持高效代码变更的关键,它有助于开发人员避免以下问题:
-
功能回归:重构后的代码不应破坏现有的功能,导致回归问题。集成测试能够识别哪些组件可能不再正确协作,而端到端测试则能精确找出哪些问题影响了面向用户的功能。
-
性能下降:重构后的代码不应比现有功能更慢。集成测试能够识别在特定场景或操作中哪些组件变得更慢,从而提示开发人员应该调查哪些组件。端到端测试能够识别影响用户的性能问题,但可能不容易隔离问题,因为它们没有提供足够细粒度的系统组件信息。然而,它们可以为开发人员提供关于性能问题严重性的宝贵信息,帮助开发人员合理优先处理问题。在第 8 章《测试微服务架构》中,我们将更详细地讨论性能测试。
-
超出预期范围的变动:重构后的代码不应影响预期范围以外的组件。这个问题对于遗留代码库尤其重要,因为开发人员可能没有清晰的不同组件之间的依赖关系图。单元测试能够帮助识别当前代码库/服务中哪些包可能受到重构影响,而集成测试则能揭示不同服务之间的 API 是否被破坏。
这些问题的潜在成本包括:
-
在潜在故障期间可能失去的业务/交易量
-
性能下降时增加的基础设施/云成本
-
如果开发人员需要更长时间交付代码更改而增加的开发成本
因此,代码重构必须易于进行和验证。
代码重构步骤和技术
现在我们已经理解了代码重构的基本需求,让我们探索一些代码重构技巧。这些技巧不仅限于 Go 开发,但理解我们如何修改代码的过程对于有效验证其输出至关重要。图7.1 展示了代码重构的基本工作流程:

代码重构步骤依赖于测试来验证:
-
开发人员确定他们希望对现有实现进行的更改。
-
然后,他们进行所需的更改,确保代码仍能编译。这可能需要同时更改实现代码和测试代码。
-
一旦第一次更改完成,开发人员运行测试套件以验证他们的实现更改。
-
如果测试通过,那么重构成功,开发人员已成功实现此更改。他们可以继续提交并发布。
-
如果测试失败,则说明重构失败,开发人员必须修订他们的重构更改。这可能意味着需要进一步修改实现代码或测试代码,或只是调整新的代码更改。
这个工作过程与我们在前面章节中看到的 红-绿-重构 过程密切相关。开发人员在没有通过测试套件的成功验证之前,不应提交任何更改。这通常通过提交检查和测试运行验证作为构建/发布管道的一部分来强制执行。
如图7.2 所示,代码重构应由一系列小的代码更改或修改组成,确保重构后的代码保留相同的主要功能:

与许多代码开发过程中的其他方面一样,发布小的、独立的更改优于发布大量的代码更改。这使得开发人员能够验证每个代码更改,并依次发布它们。此外,在出现问题的情况下,回滚一个小的代码更改比回滚多个天数内提交的大量代码更改要容易得多。
图7.3 展示了五种流行的代码重构技巧的概述:

这五种技巧可以一起使用,以改善代码的复杂性和重复性:
-
红-绿-重构 是我们已经熟悉的技巧。实现代码与相应的测试一起编写,从一个失败的测试开始,使其通过,然后根据需要重构编写的代码。这种方法确保所有功能都被测试所覆盖,并且重构作为初始实现的一部分进行。由于测试是与代码一起编写的,因此这种技巧很可能会在代码重构过程中需要修改测试。
-
提取(Extract) 是一种技巧,涉及从一个可能较大的函数中提取出已有的代码片段,并将其独立成一个函数。这个函数的名称应描述被提取的代码片段实现的功能,从而改善之前包含多个功能块的大函数的可读性。由于此技巧仅仅是提取代码,而不是重写代码,因此通常不需要更改测试。
-
简化(Simplify) 是改善大函数复杂度的技巧。这可以通过重构条件表达式、调整方法调用、重构函数参数或调整接口签名来完成。由于此技巧涉及更改函数签名,因此很可能需要修改测试。
-
内联(Inline) 是与提取技巧相对的技巧。它通过将冗余函数的内容直接放到现有函数调用的位置来移除冗余的函数。这减少了代码的间接性,降低了阅读代码时开发人员的认知负担。除非被测试的方法被移除,否则这种技巧通常不需要修改测试。
-
抽象(Abstraction) 是最适合大型代码重构的技巧。这种技巧涉及引入新的抽象层,例如接口,以消除重复并允许跨多个包重用行为。由于新接口需要使用模拟(
mocks
)并且是大范围的重构,因此这种技巧通常会需要修改测试。
这些流行的技巧将帮助你重构代码,并确保代码继续遵循我们在第三章《Mocking 与断言框架》中讨论的 SOLID 原则。
技术债务
代码重构是开发生命周期中一个极其重要且不可避免的部分。当代码不被定期重构和维护时,它就会开始积累技术债务。如何有效地管理技术债务在工程社区中已被广泛讨论,因为工程经理容易优先考虑交付具有直接货币价值的新功能,而忽视处理没有立即后果或成本的技术债务。
Agile 中的技术债务
在敏捷开发中,技术债务是指优先追求速度而非质量所带来的后果。虽然代码已经过功能测试,确保其功能正确,但其内部结构可能是为了追求速度而做出的不良架构选择的结果。 |
技术债务的后果可能以多种方式影响你的项目:
-
错误(Bugs):随着代码积累技术债务,重复代码和高耦合度可能导致错误,这些错误不仅难以修复且难以检测。如果这些错误导致系统中断或影响用户操作,它们可能会带来财务上的损失。
-
生产力下降(Decreased productivity):由于技术债务不遵循 SOLID 原则,并且与其他代码库的结构不一致,开发人员可能难以对其进行修改以实现新功能。此外,技术债务通常没有得到很好的文档记录,因此也很难推理出预期的行为。
-
限制新功能(Limits new features):随着技术债务的积累,开发人员可能需要花费大量时间修复错误和性能问题,导致他们没有时间交付新功能。这种“灭火式”的工作方式令人沮丧,甚至可能导致更高的员工流失率。
技术债务常被比作财务债务。如果我们长时间不处理代码中的问题,并继续扩展那些设计不良的代码,这些债务就会变得越来越大,处理起来也越来越困难,类似于财务债务累积利息。为了避免这些后果,技术债务应当与其他工作一起处理,并纳入敏捷的工作方式。
图7.4展示了敏捷团队如何通过优先级工作待办事项来通常安排工作,以解决技术债务:

冲刺待办事项是功能工作和技术债务的结合:
-
开发团队和产品团队各自维护自己的待办事项。通常,这些待办事项以 Jira 票据或 GitHub 问题的形式呈现,详细列出要完成的工作。技术债务工作通常涉及重构现有代码,而功能工作则包含添加新功能。冲刺待办事项试图在这两种类型的工作之间找到平衡。
-
在冲刺规划中,相关的利益相关者会对工作进行优先级排序。将开发团队纳入规划中是一个好习惯,因为这样可以确保整个团队对即将到来的冲刺目标有充分的理解。工程团队由专家组成,他们能够确定应该承担哪些重构工作,并且通常知道系统中哪些部分需要关注。
-
冲刺规划会议的结果是一个按优先级排列的工作清单,这个清单构成了冲刺待办事项。根据他们的专业知识,技术团队通常会为要完成的工作提供时间估算。这些估算会用于确定哪些工作可以在团队的能力范围内完成。重构和功能工作被视为同等重要,根据提供的估算,时间会分配给每一项工作。
虽然技术债务看似没有立即的成本,但重要的是,团队应该有时间去重构和维护他们的代码。
技术债务的规划
最佳的做法是采用 “少而频繁” 的方法,将技术债务与功能工作一起规划,而不是采用 “大爆炸” 式的方法,即将整个冲刺都用于修复问题和进行大规模的代码重构。 |
更改依赖关系
现在我们已经很好地理解了如何规划和进行代码重构,接下来我们将关注代码重构的一个特殊案例——更改依赖项。如第 3 章《Mocking 和断言框架》中所述,依赖项通常会被包装在我们自己的接口中。Go 强大的接口机制也帮助我们在依赖项发生变化时重构代码。
接口使得我们的代码更易重构,并且通过以下方式减少了耦合度:
-
清晰的预期行为:接口在调用方定义,明确了外部依赖在包内的预期行为。开发人员可以清楚地指明外部依赖需要提供哪些功能和方法签名。
-
编译器强制的方法签名:一旦预期行为被定义,编译器会验证任何传递给接口的结构体是否满足这些签名。因此,代码永远不会进入某个方法未定义并导致运行时错误的状态。
-
包之间的隔离:由于接口存在于调用包内,它为调用包与外部依赖之间提供了隔离层。依赖项可以进行重构或实现新功能,而无需在调用包中处理这些变化。
-
实现的不可见性:调用包对外部依赖一无所知。这使得我们可以轻松地用另一个具体实现替换当前的实现,从而使得在重构时更改依赖项变得简单。我们在第3章《Mocking和断言框架》中已经看过这个例子,展示了如何为UUT的依赖项提供mock,以便我们能在不依赖其他具体依赖项的情况下测试它的行为。
一个很好的例子是 BookSwap 应用中的 PostingService
。该服务的目的是处理订单并提供所有书籍邮寄功能:
// PostingService接口封装了外部的邮寄功能。
type PostingService interface {
NewOrder(b Book) error
}
如预期,我们已将 PostingService
定义为接口,并且该服务未被实现,因为我们将此服务的实现视为完全外部的。请注意,这只是一个虚拟的服务,用于演示如何提供和使用外部依赖项。
BookService
使用这个定义的接口作为依赖项:
// NewBookService 初始化一个给定依赖项的 BookService。
func NewBookService(db *gorm.DB, ps PostingService) *BookService {
return &BookService{
DB: db,
ps: ps,
}
}
我们可以提供任何实现该接口的方法,只要它满足接口定义的方法签名。这使得我们能够为该方法提供任何实现,而不需要在该包内进行其他代码更改,从而使得重构的范围仅限于实现包。
Nil 值作为依赖项
接口的零值是 |
虽然在依赖项的签名保持不变时,实现很容易被替换,但更改接口方法签名就不那么容易了,这将需要我们对调用包进行更改,因为调用包已经定义了自己的包装接口。
更改接口签名时通常需要进行以下更改:
-
如果接口是项目的一部分,首先对接口的实现进行更改。
-
更新测试代码,以确保重构后的代码正确工作。这将确保你没有引入任何错误或回归。
-
根据编译器的错误信息,您可以轻松识别哪些包使用了该实现类型作为依赖项,因为它们将不再满足旧的接口方法签名。然后,您可以对任何包装该实现的接口进行相应的更改。
-
如果你使用了
mock
生成工具,可以根据新更新的接口定义重新生成mock
。 -
根据编译错误,您可以对测试代码进行任何更改。这些更改可能是在重新生成
mock之后
,或者是为了测试重构后的新行为。
编译器是你的指南
接口签名的强制执行将帮助你识别哪些包必须进行修改,并确保代码不会进入不稳定的状态。编译器会突出显示任何需要修改的代码,并在重构工作中为你提供指导。 |
由于更改接口需要大量的重工作,开发人员通常会尽量避免这种更改。然而,花时间按照良好的架构原则设计代码应该能帮助你避免频繁进行这种大规模的代码更改。