我们的代码已被破坏了几个星期。 编译器错误,测试失败,错误行为困扰着我们的团队。 为什么? 因为我们被盲目蛙跳打了。 通过对关键组件进行多次并发更改以希望对其进行改进,我们已经从其丑陋但稳定且可工作的状态飞跃到了破碎的沼泽。 我们最好的意图给我们造成了严重破坏,一些工作日的预期工作使我们失去了一个多月的时间,直到改变最终得以恢复(暂时)。
经验教训:避免蛙跳。 相反,请遵循肯特·贝克(Kent Beck)的“ 冲刺C”的策略- 循序渐进 ,安全而又小巧地进行,不会破坏代码。 经常(最好是每天)将其部署到生产中,以迫使自己进行非常小的且非常安全的更改。 请勿同时更改多个不相关的内容。 不要以为您知道代码是如何工作的。 不要以为您要进行的更改是简单的更改。 进行全面测试(不要过于信任您的测试套件)。 通过运行测试,执行代码,运行生产中的代码,让计算机为您提供有关更改的反馈和确凿的事实。
发生了什么? 我们有批处理作业,其配置属性可以通过(1)命令行参数或(2)特定于作业或(3)文件中的共享条目来设置。 通过静态调用Configuration.get('my.property')
访问它的作业。 由于全局的,自动加载的配置使得无法对具有不同配置的作业进行单元测试,因此我们想用传递过来的配置实例代替单例。 我将简要描述我们失败的重构,提出一种更好的方法,并讨论如何开发和重构没有此类失败的软件。
沼泽之路
我们试图产生这种情况:
![](https://i-blog.csdnimg.cn/blog_migrate/cc441e5ee93ef9c644859a41d64c81c0.png)
大型重构失败
我们首先用三个可实例化的类替换了静态Configuration
,每个可实例化的类仅具有一个职责( SRP )。
然后,我们修改了每个作业以接受/创建所需的作业。
我们还将一些命令行参数和属性重命名为更易理解的名称,并进行了各种小的改进。
最后,我们用更好的方法代替了配置系统的(错误)用法,该配置系统用于存储有关作业上次完成工作的位置(“书签”)的信息。
作为副作用,还进行了其他一些更改,例如,不再从类路径上的某个位置加载默认配置,而是从相对文件系统路径中加载了默认配置,并且对命令行参数的处理几乎没有什么不同。
除一项测试外,其他所有测试均已通过,且一切正常。
它变得更加复杂(更改需要其他更改,原始设计过于简单等),因此花费的时间比预期的长,但是我们终于解决了这一问题。
但是,当我们尝试运行该应用程序时,它不起作用。 它找不到其配置文件(因为不再在类路径中查找它),一些用于尊重作业特定值的属性也不再这样做,并且我们在不知不觉中引入了一些不一致和缺陷。 事实证明,要找出各个配置属性之间的交互方式并确保在必要的所有位置以正确的顺序尊重所有配置源(命令行参数,共享属性和特定于作业的属性)非常困难。 我们修复它的尝试花了很长时间而且徒劳。 (首先修复您以前不了解的损坏的旧代码库,这绝对不容易。)
即使我们设法解决了所有问题,仍然会存在另一个问题。 更改不向后兼容。 为了能够将它们部署到生产中,我们需要停止所有操作,(正确地)更新所有配置和我们的cron作业。 潜在的许多错误。
有更好的方法吗?
这是否意味着改进软件的风险太大而无法获得回报? 否,如果我们更加谨慎,请以较小的,安全的,经过验证的步骤进行,并尽量减少或避免破坏性变化。 让我们看看如果遵循这些原则怎么办。
![](https://i-blog.csdnimg.cn/blog_migrate/71e1000514a921a38913ec49e5e953e1.png)
介绍配置实例
作为第一步(见图2),我们可以离开几乎一切,因为它是,只介绍一个临时ConfigurationInstance
1类相同的API(为了使下拉更换更容易)作为Configuration
,但是非静态和修改Configuration
将所有呼叫转发到其自己的(单个) ConfigurationInstance
实例2 。
一个小,简单,安全的更改。
然后,我们可以修改我们的工作,一个接一个,使用一个ConfigurationInstance
通过获得Configuration.getInstance()
默认情况下并在创建时也可以选择接受它的一个实例。
(请注意,在此过程中的任何时候,我们都可以并且应该将其部署到生产中。)
代码如下所示:
class Configuration {
private static ConfigurationInstance instance = new ConfigurationInstance();
public static ConfigurationInstance getInstance() { return instance; }
public static void setTestInstance(ConfigurationInstance config) { instance = config; } // for tests only
public static String get(String property) { instance.get(property); }
}
class ConfigurationInstance {
...
public String get(String property) { /** some code ... */ }
}
class MyUpdatedJob {
private ConfigurationInstance config;
/** The new constructor */
MyUpdatedJob(ConfigurationInstance config) { this.config = config; }
/** @deprecated The old constructor kept for now for backwards-compatibility */
MyUpdatedJob() { this.config = Configuration.getInstance(); }
doTheJob() { ... config.get('my.property.xy') ... }
}
class OldNotUpdatedJob {
doTheJob() { ... Configuration.get('my.property.xy') /* not updated yet, not testable */ ... }
}
对所有作业完成此操作后,我们可以更改作业的实例化以传递到ConfigurationInstance
。 接下来,我们可以删除对Configuration
所有其余引用,将其删除,然后将ConfigurationInstance
重命名为Configuration
。 我们可以定期将其部署到我们的测试/过渡环境中,最后部署到生产中,以确保一切仍然正常(对于更改而言应该最小)。
接下来,作为一项独立的更改,我们可以考虑并更改“书签”的存储方式。 其他改进(例如重命名配置属性)也应在以后独立完成。 (毫不奇怪,您所做的更改越多,出现问题的风险就越大。)
我们也可以/应该引入代码,以自动从旧配置迁移到新配置,例如,如果不存在新格式的书签,则以旧格式读取书签并将其存储在新配置中。 对于属性,我们可以添加代码以同时检查旧名称和新名称(并在仍然使用旧名称时发出警告)。 因此,部署更改将不需要我们与配置和执行更改同步。
-
1 )请注意,Java不允许我们使用相同名称的静态和非静态方法,因此我们需要在Configuration中使用不同名称创建实例方法,或者像我们一样创建另一个类。 我们希望保留相同的名称,以使从静态配置迁移到实例配置成为一个简单而安全的搜索和替换过程(在将字段configuration
添加到目标类之后,将“ Configuration.
”替换为“ configuration.
”。) “ ConfigurationInstance”是一个丑陋的名称,但以后可以轻松安全地进行更改。
2 )迈克尔·费瑟斯(Michael Feathers)的开创性著作《有效地使用旧版代码 》中介绍的引入实例委托人重构
安全软件演进的原理
更改遗留的代码(结构不良,测试欠佳的代码)是有风险的,但是对于防止其进一步威慑,使其更好,从而降低其维护成本而言是必需的。 如果我们谨慎行事,并采取小而安全的步骤进行操作,同时定期(通过测试和部署到阶段/生产中)对变更进行定期验证,则很有可能将风险降到最低。
什么是安全的零钱?
这取决于。 但是一个很好的经验法则可能是这样的变化:(1)其他人每天都可以合并,然后(2)可以部署到阶段(例如,一天之后,进入生产)。 如果每天都有可能合并,则它必须相对较小且无损。 如果应每天进行部署,则它必须是安全的并且向后兼容(或自动迁移旧数据)。 如果您的更改大于或大于此更改,则说明更改太大/有风险。
安全成本与收益
以安全的方式更改软件并非易事,也不是“便宜”的事情。 它要求我们认真思考,有时使它变得难看,花费一些资源来维护(临时)向后兼容性。 在不顾及这种安全性的情况下更改代码似乎更加有效-但只有在您遇到意料之外的问题并花几天时间尝试修复问题,陷入困境时(即,队友不断更改的代码库),这才更有效率。 它与TDD类似-速度较慢,但由于您无需浪费时间调试和排除生产问题,因此可以得到回报。 (不幸的是,显示您*不要*放松团队或管理层的时间)。
冲刺C –系列微小变化
为了以小而安全的步骤进行更改,我们通常需要对其进行分解,并通过一系列小更改不断地朝着目标设计发展代码,每个小更改都在代码库的有限区域内并沿单个轴进行,仅一件事。 更改越小越安全,我们执行和验证它们的速度就越快,因此随着时间的推移会发生很多变化–这就是Kent Beck所说的Sprinting Centipede策略。
并行设计 :有时无法真正分解更改,例如用另一个替换数据存储。 在这种情况下,我们可以应用例如并行设计技术,即在保持旧代码不变的情况下发展新代码。 例如,我们可以首先只编写代码以将数据存储在新数据库中; 然后开始从中读取数据,同时还从旧数据库中读取数据,验证结果是否相同并返回旧数据库; 然后我们可以开始返回新结果(仍然保留旧代码以便能够切换回去); 最后,我们可以淘汰旧代码和存储。
先决条件
当然,只有在您可以构建,测试和部署并Swift收到有关可能出现的问题的警告时,才可以遵循Sprinting Centipede策略。 测试和部署(以及反馈)的周期越长,您必须执行的步骤就越长,否则您将花费大部分时间等待。
常问问题
我如何将不确定的变更部署到生产中?
代码,尤其是遗留代码,很少能进行真正的彻底测试,因此我们永远无法完全确定其正确性。 但是我们不能放弃改进代码,否则只会变得更糟。 恐惧和停滞不是解决方案,而是对代码库和流程的改进。 我们必须谨慎考虑风险并进行相应的测试。 更改越小,监视和回滚或快速修复的能力越好,则可能造成的缺陷影响就越小。 (如果有出现缺陷的机会,那么将会更早或更晚。)
结论
遵循Sprinting Cantipede软件演化策略似乎很慢,即以小规模,安全,很大程度上无中断的增量步骤进行,同时与主要开发部门定期合并,并经常通过运行测试和部署到生产环境来验证更改, 。 但是我们的经验表明,由于意外的原因,盲目青蛙的飞跃-希望驱动的大型更改或批量更改-可能实际上要慢得多,并且有时是不可行的,这是由于意外发生,但总是会出现复杂性和缺陷以及随之而来的延误,分支分散等。而且我相信这种情况经常发生。 因此,一次只更改一件事情,最好是在不进行其他更改(配置等)的情况下部署更改,收集反馈,确保您可以随时停止重构,同时从中获得尽可能多的价值。它(而不是将大量精力投入到大型更改中,并冒着将其完全放弃的风险)。 你有什么经验?
值得注意的是,类似的问题经常在项目级别上发生。 人们尝试一次生产太多产品,而不是根据反馈而不是相信来做最少的事情,部署和继续开发。
资源资源
- 有限红色协会 ( 此处是一个很好的总结 )– Joshua Kerievsky讨论了在开发软件时减少“红色”时间段的需求(还介绍了Parallel Change,在上面我称为Parallel Design)
- 肯特·贝克(Kent Beck)关于低功能延迟和高吞吐量的软件设计最佳实践的演讲摘要
- Mikado方法一书 (在线)在“ mikado方法–失败的产物”部分(当前版本为1.2)中很好地描述了典型的失败重构。
致谢
我要感谢我的同事Anders,Morten和Jeanine的帮助和反馈。 对不起,安德斯,我不能再说短了。 你知道,每个字都是我的孩子。
参考: The Sprinting enti策略: The Holy Java博客上来自JCG合作伙伴 Jakub Holy的如何在不破坏软件的情况下改进软件 。