12.动手实践整洁架构 - 有意识地走捷径

153 阅读11分钟

在本书的序言中,我讲过这样一个事实:我们总是感觉被迫走捷径,积累了一大堆我们永远没有机会偿还的技术债务。

为了防止走捷径,我们必须能够识别它们,因此,本章的目标是提高人们对一些潜在捷径的认识并讨论其效果。

有了这些信息,我们就可以识别并修复意外的捷径。或者,如果合理的话,我们甚至可以有意识地选择捷径的效果。

为什么走捷径就像破碎的窗户

破窗理论的实验基础来自美国斯坦福大学心理学家菲利普·津巴多(Philip Zimbardo)于1969年进行的一项实验。

他在布朗克斯区停放了一辆没有牌照的汽车,在帕洛阿尔托一个据称“更好”的社区停放了另一辆汽车。然后他等待着。

布朗克斯区的汽车在 24 小时内就被清理干净了有价值的零件,然后路人开始随意毁坏它。 帕洛阿尔托的汽车已经一周没有动过,所以津巴多砸碎了一扇窗户。从此,这辆车就遭遇

了和布朗克斯那辆车类似的命运,在同样短的时间内被路过的人毁坏了。

参与抢劫和毁坏汽车的人来自各个社会阶层,其中包括遵纪守法、行为良好的公民。 这种人类行为被称为“破窗理论”。用我自己的话:

一旦某物看起来破旧、损坏、或通常无人照管,人类大脑就会觉得可以让它变得更加破旧、损坏。

该理论适用于生活的许多领域:

• 在破坏行为常见的社区,抢劫或损坏无人看管的汽车的门槛很低。

• 当汽车的窗户破损时,即使在“好”的社区,进一步损坏它的门槛也很低。

• 在凌乱的卧室里,把衣服扔在地上而不是放进衣柜的门槛很低。

• 在欺凌行为很常见的群体中,再欺凌一点的门槛很低。

• …

应用于代码工作,这意味着:

• 当处理低质量代码库时,添加更多低质量代码的门槛很低。

• 当处理具有大量编码违规的代码库时,添加另一个编码违规的门槛很低。

• 当处理具有大量走捷径的代码库时,添加另一个走捷径的方式的门槛很低。

• …

考虑到这一切,许多所谓的“遗留”代码库的质量随着时间的推移而严重恶化,这真的令人惊讶吗?

开始清洁责任

虽然使用代码并不真的感觉像抢劫汽车,但我们都无意识地受到破窗心理的影响。这使得干净地启动一个项目变得非常重要,尽可能少走捷径和减少技术债务。因为,一旦出现捷径,它就像一扇破窗一样,吸引更多的捷径。

由于软件项目通常是一项非常昂贵且长期运行的工作,因此作为软件开发人员,防止损坏的窗户是我们的巨大责任。我们甚至可能不是完成该项目的人,而必须由其他人接手。对于他们来说,这是一个他们没有连接的遗留代码库,但进一步降低了创建破损窗口的门槛。

然而,有时我们认为走捷径是务实的做法,无论是因为我们正在处理的代码部分对于整个项目来说并不那么重要,或者我们正在制作原型,或者出于经济原因。

我们应该非常小心地记录这种有意识的走捷径的方式。为了我们未来的自己和我们的继任者,我们应该做到这一点。如果团队的每个成员都了解此文档,甚至会减少“破窗”效应,因为团队会知道这些走捷径的方式是有意识地使用的,并且有充分的理由。

以下各节讨论了一种模式,该模式可以被视为本书中介绍的六边形架构风格的捷径方式。我们将看看捷径的效果以及支持和反对捷径的论点。

在用例之间共享模型

在第4章“实现用例”中,我认为不同的用例应该有不同的输入和输出模型,这意味着输入参数的类型和返回值的类型应该不同。

图 29 显示了两个用例共享相同输入模型的示例:

图有错

本例中共享(SendMoneyCommand)的效果是 SendMoneyUseCase 和 RevokeActivityUseCase 相互耦合。如果我们更改共享 SendMoneyCommand 类中的某些内容,这两个用例都会受到影响。他们在单一职责原则方面有共同的改变理由。如果两个用例共享相同的输出模型,情况也是如此。

如果用例在功能上绑定,则在用例之间共享输入和输出模型是有效的, 即如果他们有共同的要求。在这种情况下,我们实际上希望如果我们更改某个细节,两个用例都会受到影响。

然而,如果两个用例都能够彼此单独发展,那么这是一条捷径。在这种情况下,我们应该从一开始就将用例分开,即使这意味着复制输入和输出类(如果它们一开始看起来相同)。

因此,当围绕相似的概念构建多个用例时,有必要定期询问用例是否应该彼此单独发展的问题。一旦答案变成“是”,就该分离输入和输出模型了。

使用领域实体作为输入或输出模型

如果我们有一个 Account 域实体和一个输入端口 SendMoneyUseCase,我们可能会想使用该实体作为输入端口的输入或输出模型,如图 30 所示:

输入端口对域实体有依赖性,这样做的结果是我们添加了账户实体更改的另一个原因。

等等,Account 实体不依赖于 SendMoneyUseCase 输入端口(反之亦然),那么输入端口如何成为实体更改的原因呢?

假设我们需要有关用例中某个账户的一些信息,而该信息当前在账户实体中不可用。然而,

此信息最终不会存储在账户实体中,而是存储在不同的域或有界上下文中。尽管如此,我们还是想向 Account 实体添加一个新字段,因为它已经在用例界面中可用。

对于简单的创建或更新用例,用例接口中的域实体可能没问题,因为该实体恰好包含我们在数据库中保留其状态所需的信息。 一旦用例不仅仅是更新数据库中的几个字段,而是实现更复杂的域逻辑(可能将部分域逻辑委托给丰富的域实体),我们就应该使用专用的输入和输出模型对于用例接口,因为我们不希望用例中的更改传播到域实体。

这种捷径之所以危险,是因为许多用例一开始只是一个简单的创建或更新用例,但随着时间的推移,它们却变成了复杂领域逻辑的野兽。在敏捷环境中尤其如此,我们从最小可行产品开始,并在前进的过程中增加复杂性。因此,如果我们一开始就使用领域实体作为输入模型,那么我们必须找到用独立于领域实体的专用输入模型替换它的时间点。

跳过输入端口

虽然输出端口对于反转应用层和输出适配器之间的依赖关系(使依赖关系指向内部)是必要的,但我们不需要输入端口来进行依赖关系反转。我们可以决定让输入适配器直接访问我们的应用服务,而无需中间的输入端口,如图 31 所示。

通过删除输入端口,我们减少了输入适配器和应用层之间的抽象层。去除抽象层通常感觉相当好。

然而,输入端口定义了进入应用核心的入口点。一旦我们删除它们,我们必须更多地了解应用的内部结构,以找出我们可以调用哪个服务方法来实现特定的用例。通过维护专用的输入端口,我们可以一目了然地识别应用的入口点,这使得新开发人员特别容易了解代码库。

保留输入端口的另一个原因是它们使我们能够轻松地实施架构。通过第 10 章“强化架构边界”中的强化选项,我们可以确保输入适配器仅调用输入端口,而不调用应用服务。这使得应用程序层的每个入口点都是一个非常有意识的决定。我们不能再意外地调用不应该从输入适配器调用的服务方法。

如果应用程序足够小或只有一个输入适配器,以便我们无需输入端口的帮助即可掌握整个控制流,那么我们可能希望不使用输入端口。然而,我们有多少次可以说我们知道应用程序保持很小或者在其整个生命周期中只会有一个输入适配器?

跳过应用服务

除了输入端口之外,对于某些用例,我们可能希望跳过整个应用层,如图 32 所示:

此处,输出适配器中的 AccountPersistenceAdapter 类直接实现传入端口,并替换通常实现输入端口的应用服务。

对于简单的 CRUD 用例来说,这样做是非常诱人的,因为在这种情况下,应用服务通常仅将创建、更新或删除请求转发到持久性适配器,而不添加任何域逻辑。我们可以让持久化适配器直接实现用例,而不是转发。

然而,这需要输入适配器和输出适配器之间有一个共享模型,在本例中是 Account 域实体,因此这通常意味着我们使用域模型作为输入模型,如上所述。

此外,我们的应用程序核心中不再有用例的表示。如果 CRUD 用例随着时间的推移变得更加复杂,那么很容易将域逻辑直接添加到输出适配器,因为用例已经在那里实现了。这分散了领域逻辑,使其更难查找和维护。

最后,为了防止样板传递服务,我们可能会选择跳过简单 CRUD 用例的应用服务。然而,一旦预期用例不仅仅执行创建、更新或删除实体的操作,团队就应该制定明确的指南来引入应用服务。

这如何帮助我构建可维护的软件?

有时从经济角度来看,走捷径是有意义的。本章对某些捷径可能产生的后果提供了一些见解,以帮助决定是否采取这些捷径。

讨论表明,为简单的 CRUD 用例引入快捷方式是很诱人的,因为对他们来说,实现整个架构感觉有点矫枉过正(而且快捷方式感觉不像快捷方式)。然而,由于所有应用程序都是从小规模开始的,因此当用例超出其 CRUD 状态时,团队达成一致非常重要。只有这样,团队才能用从长远来看更易于维护的架构来取代捷径。

有些用例永远不会脱离其 CRUD 状态。对于他们来说,永远保留快捷方式可能更务实,因为它们实际上并不需要维护开销。

无论如何,我们应该记录架构和我们选择某种捷径的决策,以便我们(或我们的继任者)可以在未来重新评估决策。