并行变更
2014年5月13日
对影响所有使用者的接口进行更改需要两种思维模式:实现更改本身,然后更新所有使用位置。如果您尝试同时执行这两项操作,这可能会很困难,尤其是在您更改的是具有多个或外部客户端的已发布接口时。
并行变更,也称为扩展和收缩,是一种以安全的方式实现接口向后不兼容更改的模式,它将更改分为三个不同的阶段:扩展、迁移和收缩。
为了理解该模式,让我们以一个简单的Grid
类为例,该类使用一对x
和y
整数坐标存储和提供有关其单元格的信息。单元格在内部存储在一个二维数组中,客户端可以使用addCell()
、fetchCell()
和isEmpty()
方法与网格进行交互。
class Grid { private Cell[][] cells; … public void addCell(int x, int y, Cell cell) { cells[x][y] = cell; } public Cell fetchCell(int x, int y) { return cells[x][y]; } public boolean isEmpty(int x, int y) { return cells[x][y] == null; } }
作为重构的一部分,我们发现x
和y
是一个数据块,并决定引入一个新的Coordinate
类。但是,这对于Grid
类的客户端来说将是一个向后不兼容的更改。我们决定应用并行更改模式,而不是一次性更改所有方法和内部数据结构。
在*扩展*阶段,您需要增强接口以同时支持旧版本和新版本。在我们的示例中,我们引入了一个新的Map<Coordinate, Cell>
数据结构和可以接收Coordinate
实例的新方法,而无需更改现有代码。
class Grid { private Cell[][] cells; private Map<Coordinate, Cell> newCells; … public void addCell(int x, int y, Cell cell) { cells[x][y] = cell; } public void addCell(Coordinate coordinate, Cell cell) { newCells.put(coordinate, cell); } public Cell fetchCell(int x, int y) { return cells[x][y]; } public Cell fetchCell(Coordinate coordinate) { return newCells.get(coordinate); } public boolean isEmpty(int x, int y) { return cells[x][y] == null; } public boolean isEmpty(Coordinate coordinate) { return !newCells.containsKey(coordinate); } }
现有客户端将继续使用旧版本,并且可以逐步引入新更改,而不会影响它们。
在*迁移*阶段,您需要将所有使用旧版本的客户端更新到新版本。这可以逐步完成,并且在外部客户端的情况下,这将是最长的阶段。
一旦所有使用都已迁移到新版本,您就可以执行*收缩*阶段以删除旧版本并更改接口,使其仅支持新版本。
在我们的示例中,由于在删除旧方法后不再使用内部二维数组,因此我们可以安全地删除该数据结构并将newCells
重命名回cells
。
class Grid { private Map<Coordinate, Cell> cells; … public void addCell(Coordinate coordinate, Cell cell) { cells.put(coordinate, cell); } public Cell fetchCell(Coordinate coordinate) { return cells.get(coordinate); } public boolean isEmpty(Coordinate coordinate) { return !cells.containsKey(coordinate); } }
这种模式在实践持续交付时特别有用,因为它允许您在上述三个阶段中的任何一个阶段发布代码。它还允许您迁移客户端并逐步测试新版本,从而降低了更改的风险。
即使您可以控制接口的所有使用,遵循此模式仍然很有用,因为它可以防止您一次性将损坏传播到整个代码库。迁移阶段可能很短,但它是依靠编译器查找所有需要修复的使用位置的替代方案。
此模式的一些应用示例是
- *重构*:更改方法或函数签名时,尤其是在进行长期重构或更改已发布接口时。在重构期间,此模式的一种变体实现是根据新 API 实现旧方法,并使用内联方法一次性更新所有使用位置。将旧方法委托给新方法也是将迁移阶段分解为更小、更安全的步骤的一种方法,允许您先更改内部实现,然后再更改向客户端公开的 API。当迁移阶段较长时,这很有用,因此您不必维护两个独立的实现。
- *数据库重构*:这是演进式数据库设计的关键组成部分。大多数数据库重构都遵循并行更改模式,其中迁移阶段是原始架构和新架构之间的过渡期,直到所有数据库访问代码都已更新为使用新架构。
- *部署*:金丝雀发布和蓝绿部署等部署技术是并行更改模式的应用,您可以在其中并排部署代码的旧版本和新版本,并逐步将用户从一个版本迁移到另一个版本,从而降低更改的风险。在微服务架构中,它还可以消除由于服务之间的版本依赖关系而导致的复杂部署编排需求。
- *远程 API 演进*:当您无法以向后兼容的方式进行更改时,可以使用并行更改来演进远程 API(例如 REST Web 服务)。这是在公开 API 中使用显式版本的替代方法。您可以在对给定端点上 API 接受或返回的有效负载进行更改时应用该模式,或者您可以引入一个新的端点来区分旧版本和新版本。在同一端点中使用并行更改的情况下,遵循Postel 法则是避免在扩展有效负载时消费者中断的好方法。
在迁移阶段,可以使用功能标志来控制使用哪个版本的接口。客户端上的功能切换允许它与供应商的新版本向前兼容,这将供应商的发布与客户端分离。
在实现抽象分支时,并行更改是在客户端和供应商之间引入抽象层的好方法。它也是一种无需在供应商端引入抽象层作为替换接缝即可执行大规模更改的替代方法。但是,当您有大量客户端时,使用抽象分支是缩小更改范围并在迁移阶段减少混乱的更好策略。
使用并行更改的缺点是,在迁移阶段,供应商必须支持两个不同的版本,并且客户端可能会混淆哪个版本是新版本,哪个版本是旧版本。如果未执行收缩阶段,您最终可能会陷入比开始时更糟糕的状态,因此您需要纪律才能成功完成过渡。添加弃用说明、文档或 TODO 说明可能有助于通知客户端和其他在同一代码库上工作的开发人员哪个版本正在被替换。
延伸阅读
Industrial Logic 的重构专辑记录并演示了执行并行更改的示例。
致谢
该技术最初由 Joshua Kerievsky 在 2006 年记录为一种重构策略,并在其于 2010 年在精益软件和系统会议上发表的演讲有限红色社会中进行了介绍。
感谢 Joshua Kerievsky 对本文初稿的反馈。还要感谢许多 Thoughtworks 同事的反馈:Greg Dutcher、Badrinath Janakiraman、Praful Todkar、Rick Carragher、Filipe Esperandio、Jason Yip、Tushar Madhukar、Pete Hodgson 和 Kief Morris。