重构到更深层的模型

作者 Paul Rayner,译者 刘嘉洋 发布于 2018年9月19日

原文地址 http://www.infoq.com/cn/articles/refactoring-deeper-model

本文要点

  • 重构有三个层次:代码层次微重构,模式重构,以及更深层的模型重构。
  • 无论是对系统还是你的理解来说,做许多小的变更可以形成复杂的大的变更。
  • 这里提出的案例是 Nexia Home Automation 的摄像机整合案例。重构之后,开发人员可以更方便地了解领域模型,以及系统中的 Java 和 Ruby 代码。
  • 重构加强了好的 DDD 实践,比如说强边界的上下文,以及跨边界的显式转换。
  • 使用功能开关和阶段性发布提供了一个推迟做出决策的选择,可以让你在掌握了足够的信息之后再做出明智的选择。

本文改编自 Explore DDD 2017 的一个演说

在化学的世界中,你可以提取不同的物质,每个物质本身都处在稳定的状态,然后你可以将它们组合起来,它们互相发生反应,成为比反应物加起来还要好的物质。相同的,在软件行业,也会有不同的重构反应物,每个都具有不同的工作量、频率和能力。当它们与领域驱动的探索发现过程催化剂相互碰撞的时候,这些重构反应物就会产生代码的 “化学” 反应,将代码转换为丰富的领域模型。

本文讲述了 Nexia Home Intelligence 中一个持续很久的摄像机支持系统的重构故事。Nexia 是一个大规模 Ruby on Rails 应用程序,需要支持使用成千上万个摄像机的客户集群需求。

我将从三个层次介绍重构。Martin Fowler 的《重构》一书中谈到了微重构,就是在代码级别不断进行小的变更,以实现增量提升。好的开发人员会花时间去记忆并养成如何使用重构工具的习惯,所以这些微重构就成为了第二天性。

Joshua Kerievsky 在《重构与模式》一书中谈到了更高阶的模型,比如说策略模式。他在书中还定义了各种 “坏味道”,比如霰弹式修改,进行一个小的变更也需要很多额外的其他变更。这让我很放心,因为我的设计没有必要一开始就完全正确,我随时可以开始开发,当碰到一种“坏味道” 的时候,我就有了可以重构的工具,但也只在必要的时候进行重构。

我将介绍第三层次的重构,即重构到更深层次的模型,Eric Evans 已经在《领域驱动设计》一书中为我们介绍过这一层次。当我第一次阅读这本书的时候,第三部分吸引了我的注意力。在第三部分中,他谈到了一个项目,在项目中模型并不适用,他们就提出了一个新的重构方式模型,它彻底改变了项目。

看一下这三个层次,如果你可以在你的模型中引入新的概念,这就是一次有用的重构。你需要精通于微重构,充分使用模式来重构到更深层次的模型中去。

有关 Nexia Home Automation

Nexia Home Automation 系统是 Ruby 语言编写的,可以帮助你完成各种家庭自动化工作,比如了解窗户是打开还是关上的,需要系统与运动传感器集成,并连接摄像机。Dan Sharp 和我负责摄像机系统,这也是我将介绍的内容。

家庭自动化不像银行业或保险业等其他领域,它是一个高科技领域,需要处理硬件和固件。这就意味着客户并不了解很多技术的问题,你不能直接问他们固件相关的问题。

我们的目标是不断研究新功能,同时提升对新的摄像机的支持。当新的摄像机面世后,通常需要几周或几个月的时间,通过大量霰弹式修改,才能添加 Nexia 对它的支持。我们希望能大大缩短这个时间。

如果你要完成向 Nexia 添加摄像机的过程设置,你会注意到它使用的一些术语。比如说,并不是添加摄像机,而是需要注册一个新的摄像机,之后进行激活步骤。注册步骤是想让 Nexia 知道相机的存在,需要在连接到 Nexia 之前完成。

架构处理

安装在客户家庭的几千台摄像机和许多相机管理器组件通信。相机管理器是由 Java 编写的,通信是通过 HTTP 和 SSL 实现的。当消息从摄像机进入管理器之后,我们将这些消息放到 Redis 作业队列中。这些消息被 Portal Workers 从队列中去除,在后台中运行。Portal Workers 是由 Ruby 编写的。Nexia 需要回复摄像机,所以我们在 RabbitMQ 消息总线上将这些消息排队,这些消息会通过相机管理器处理。图 1 展示了这个架构很高层次的一个视图。


图 1:Nexia 架构

这个应用程序本身是 Rails 应用程序,代码库的部分内容如图 2 所示。如果你不熟悉 Rails 开发,models 文件夹通常不是控制器所在的地方,所以不要认为它是富领域模型。我特别展示了一些我提到的 workers,以及和自动化相关的 camera 文件。比如在日落时执行一些工作,或是在指定时间调暗灯光,都是 Nexia 的自动化例子。


图 2:Rails 应用程序架构

三个主要挑战

我们遇到的第一个挑战是代码很难推断。Java 相机管理器过度架构,它们使用了有许多抽象的元架构,让它可以和任何与 Nexia 连接的任何类型摄像机一起工作。实际上大多数摄像机都非常类似,比如说都使用 SSL 和 HTTP,我们不需要额外的抽象层。

举一个系统性问题的例子,图 3 展示的是handleRequest()方法的一部分。任何 DDD 从业者都会对这段代码的语言表达产生疑问。91 行引入了 Zombie 一词,什么是 Zombie?93 行提到了 “如果没有进行授权(isAuthorized)”,但是 94 行的注释提到的行的注释提到的认证(authenticated)和它并不是同一个东西。更糟糕的是,98 行将一个变量声明为auth,这既能代表前者,也能代表后者。虽然这仅仅是个小例子,但是这段代码也能代表我们相机管理器代码库中遇到的一些问题了。


图 3:相机管理器handleRequest()代码示例

在 Ruby 端,网站工作人员对于摄像机的支持随着时间推移而增长。由于大多数工作都是由不同的开发人员(主要是外包)按照需要完成的,所以过多地倾向于职责实现了,而未进行有目的地建模。

Ruby 代码的优点是十分简洁,可以在几行内表达很多内容。但是,CameraWorker情况不同,它负责验证并关闭摄像机的链接。先声明一下,由于不能本文中展示超过 130 多行代码,因此图 4 中展示了部分代码。在多个地方,worker 需要对摄像机对象进行状态修改,而不是声明所需的行为。我们还碰到了一些不太好的命名,比如 89 行的start_motion调用,看上去像是开始行动的命令,但其实并不是。


图 4:CameraWorker代码示例

和那段 Java 代码类似,这只是其中的一个小片段,但它也可以代表系统性问题了。这些都造成了代码很难推算。

遇到的第二个挑战是相机管理器与设备管理器过于耦合了。想要理解这个问题,就得先了解一些架构的历史了。相机管理器(CM)是从通用设备管理器(DM)发展而来的,后者可以管理各种类型的设备。这就导致需要和其他 Nexia 的部分共享内核。这成为了一个重大的部署问题,这意味着基本上我们就不能升级 Java 了。最终,我们认识到这种耦合是没有必要的。尽管摄像机是个设备,但是它和其他设备没有很多类似之处,比如门锁等等。

第三个挑战真正涉及到了 DDD,领域知识出现在错误的位置。大多数领域逻辑是在 Java 相机管理器代码之中,这就代表着增加新的功能会很复杂、耗时、容易发生错误以及很难测试。同时,修改代码代表着要对所有东西进行霰弹式修改。

DDD 关注点

我列出所有问题,不仅仅是吐槽糟糕的代码,而是要明确它们并不是不可克服的挑战。此外,DDD 提供了可以在很大程度上改善这种情况的技术。首先回顾一下 DDD 的四大关注点。

首先,我们希望在代码中演进并表达深层的领域模型。第二,我们希望将代码重构为通用的语言,内容一致易于理解,代码意图清晰。第三,我们要清楚地描述模型和模块的边界和职责。很难在没有明确边界的情况下实现高内聚和松耦合。最后,我们需要严格遵守模型边界(即有界的上下文),跨边界时进行显式转换。

从何开始?

当你遇到这样的代码的时候你会怎么做?现在有一些选择,我知道现在一些人已经试过某些选择了,但并没有成功。一个选择是 “清除积水”,尝试删除所有旧的代码,重新开始。人们可能需要空出几个礼拜进行重大重构,直到“修复” 的时候再解脱出来。第二种选择是将问题甩给别人,自己不做任何处理。

我喜欢选择进行试验,看看你能做什么。我喜欢 2012 年夏季奥运会英国自行车队获得金牌的故事。这个团队进行了许多试验,找了很多方法,从小的地方开始改进,并做了许多改变。英国自行车队负责人 Sir Dave Brailsford 说:“我觉得我们应该从小处进行改变,通过小收益的累积,采取不断提升的哲学思想。抛弃完美,关注事情的进展,尝试各种改进。” 对于敏捷软件开发人员来说,持续改进的思想并不陌生。

一小步

在我们的项目里,我们尝试了各种不同的事情,但大多数都没有用。在 2014 年 3 月,我们尝试了 “一小步”,我们意识到摄像机的概念实际上需要完成两个不同的任务。它充当了物理设备,也叫实体。但同时它还需要作为命令处理程序,提供向物理设备发送命令和查询的接口。

首先看一下 Ruby 代码,我们发现这两个任务都在摄像机对象中。它是设备的子类,有许多问题。由于没有进一步的子类,所有的逻辑都在巨大的 “上帝” 对象摄像机中。

我们首先决定添加新的领域服务,而不是改变摄像机对象,这遵循了开放和修改的原则。这个新的Camera::CommandService存放所有的摄像机指令和查询。由于我们将它作为扩展来写,我们可以用好的测试驱动开发和结对编程实践来实现它,在不破坏其他东西的情况下创造更高质量的设计工作。我们有一个很好的测试组件,它覆盖了 controllers、 workers 和 collaborators,我们可以更放心地进行更新。这一小步实现了虽然小,但是不可忽视的改进。

寻找接缝

Martin Fowler 在其《修改代码的艺术》一书中聊到了寻找代码的接缝,也就是你可以加入新东西。我们召开了事件风暴会,了解设备注册 Nexia 的不同方法,这有助于可视化这些工作流中的相似之处。通过查看 Java 代码,我们发现相机管理器组件太过 “智能”,它仅需要管理摄像机会话就可以了,但却做了太多其他事情。我们希望让相机管理器成为通用的 http 代理,由于所有指令都是 HTTP 调用,并整合 Ruby 的所有摄像机逻辑。

我们在接缝相机管理器加入了新的通用send_url()指令。我们将 http 代理模型运用在连接管理、验证、摄像机到入口消息传递和日志记录上(来帮助故障排除以及未来计划)。在 Ruby 端,我们可以在前一年的进展上,使用Camera:CommandService向摄像机发送任何指令。

迁移领域逻辑

在推行了一小步并发现新的接缝一年之后,我们可以将摄像机的领域逻辑从 Java 端迁移到 Ruby 端。我们将Camera::CommandService指令(例如 Pan-Tilt)迁移到使用通用的相机管理器接口。这个方法最棒的地方是不需要修改 Java 代码。作为 Ruby 端的内部重构,我们可以只使用一个指令进行测试,并迭代它直到可以运行。

我聊到这个故事的时候,通常会被问一个问题:“你怎么验证这些重构?” 我指出,我们正在继续交付应用程序的功能,这是我们随时可以进行的工作。同时,这些小的步骤也获得了一些进展。由于我们为摄像机提供通用的 URLs,可以从 Ruby 发送指令,因此我们可以在所有安装的相机上批量升级固件。此外,我们可以在 Ruby 端简单、快速地进行变更,这代表着我们可以进行试验,发现其他的边界改进。在这之前,需要 Java 和 Ruby 合作才能完成变更。

我想再次强调小进展的重要性。非技术利益相关者并不关心你是否重构代码。我相信一般来说,他们相信你是专业人士,会竭尽最大努力写可维护的代码。这代表着你必须建立信任和信誉,可以通过实现小的进展来完成这一点。

我们还发现,重构可以帮助清理代码。在Camera::CameraWorker中就有三个验证。首先,在重新连接的时候验证已存在的摄像机。其次,处理新的摄像机的创建和验证。第三,去掉 “僵尸” 摄像机,就是已经连接但没有验证的摄像机。通过重构到更深层的模型,代码可以更容易地推断,正如图 5 中的 8-14 行所示。


图 5:验证的三个不同方向

在我们引入了更多的领域逻辑之后,Ruby 代码占据了 Nexia 普遍用的语言的比重更高了。我们不需要给摄像机对象进行许多修改,并设置许多属性,我们发现工厂模式更加适合。普遍使用的语言包括心跳的概念,对于这个系统来说就是摄像机连接到 Nexia,就像它们或者一样。之后我们创造了名为update_from_heartbeatcreate_from_heartbeat的工厂方法,来分别处理现有的摄像机和新的摄像机。

Java 端也得益于重构。较之前在图 3 中部分展示的handleRequest()方法,变成了图 6 中的 5 行代码。对一些提取的方法进行重构,功能变得更加容易理解。


图 6:新 Java 代码示例(与图 3 相比较)

摄像机类很大,因此你经常会跑到这段代码里。小贴士,处理这种情况并不需要通过代码重构使它更加清晰。当代码杂乱不堪时,简单地重新排列一下代码会很有效,虽然它只是个简单的设计技巧。看一看模式,把类似的方法放在一起,这将缓解你在处理庞大代码域时的认知负担。

重构到更深层次

处理一个庞大、混乱的代码库就像雾中漫步,你不能看到周围的一切,你可能看到的只是一棵树,或是一座山。当你做了一些小的变更之后(如重组织代码,提取方法),这些小的收益会累积,雾开始消散。实现微重构和 Fowler 和 Kerievsky 提到的模式能产生累积的效果,因此可以对模型有更深层次的了解。

比如说,在 Ruby 端,我们发现我们正在向摄像机发送指令。所以我们按照 Kerievsky 的建议,使用命令模式,使之大大简化了。我们为指令设置了基类,以及标准的execute()方法。之后我们创建了 camera/command 文件夹,开始写每个指令,实现这个基类。此外,我们还引入了功能开关,帮助旧代码继续执行,直到相应的指令已经转换。我强烈推荐使用功能开关来帮助你安全地进行重构。

我推荐的另一个方法是阶段性发布。我们希望避免每台摄像机突然断开连接的情况,大多数现有的客户群的产品都有共同的目标,就是不要影响到所有客户。在第一个月我们仅仅部署到 Nexia IP 地址,让 QA、开发人员和支持人员在部署到客户之前先尝试新系统。第二个月我们加大部署,部署到一部分客户,但只使用一个生产服务器。直到第三个月我们才会部署到所有的摄像机、所有的客户和所有的生产服务器上。这比我职业生涯中参与的其他生产环境发布都要顺利。

功能开关和阶段性发布的另一个好处是它们提供了有价值的选择。我推荐《Commitment: Novel about Managing Project Risk》一书,介绍了实际选择权的概念。通常,当某人在会议中说 “我们需要作出决定” 的时候,就会有两个选择,一个是根据有限的知识作出选择,要么不做选择。实际选择权指出还有第三个选项,在我们更好地理解之前,战略性地推迟决策。功能开关和阶段性发布都可以帮助你推迟做出决定,直到你可以做出更好的决定为止,这非常关键。

回顾

回看一开始的时候,我们已经获得了很大的成就。之前,添加新的摄像机需要几周甚至几个月。现在,我们可以在几小时内添加新的摄像机。我们不再需要在 Java 和 Ruby 中都作出变更,并保持代码的同步,我们只需要在 Ruby 中进行修改。尽管旧代码在一些方面不一致,但新的代码更加内聚,也很容易推断,因为上下文很清晰。我们移除了相机管理器设备管理器之间粗糙的依赖,所以我们可以更新 Java 了。

根据这些经验,有一些通用的重构技巧。不要只选择一个变更的实现方法,比如说命名新的东西,尝试至少三种语言和 / 或模型选项。在你的日常工作中,同样要注意一些小的收益。我们往往太过于高估大变更的效果,却低估了小的累积的变化的力量。

2018/10/8 posted in  Reactive