【读书笔记】《Go With The Domain》8. 整洁架构
📔【读书笔记】《Go With The Domain》8. 整洁架构
2023-10-4
| 2024-12-18
0  |  0 分钟
type
status
date
slug
summary
tags
category
icon
password
Sub-item
Last edited time
Dec 18, 2024 09:29 AM
Parent item
领域
虽然耦合似乎主要与跨多个团队的微服务相关,但我们发现松散耦合的架构对于团队内的工作同样有用。保持架构标准使并行工作成为可能,并有助于新团队成员的加入。
您可能听说过“低耦合、高内聚”的概念,但如何实现它却很少是显而易见的。好消息是,这是整洁架构的主要好处。该模式不仅是启动项目的绝佳方式,而且对重构设计不佳的应用程序时也很有帮助。
本章我重点讨论后者。我展示了真实应用程序的重构,因此应该清楚如何在项目中应用类似的更改。我们还注意到这种方法还有其他好处:
  • 标准结构,因此很容易在项目中找到方法
  • 从长远来看开发速度更快
  • 对依赖做mock在单位测试中变得微不足道
  • 轻松从原型转换为适当的解决方案(例如,将内存存储更改为SQL数据库)

整洁架构

该模式有很多叫法。有整洁架构,洋葱架构,六边形架构以及端口(Ports)和适配器(Adapters)模式。在过去的几年中,我们试图以惯用方式使用这些模式。它涉及尝试一些方法,失败,更改它们并重试。
我们融合了上面的想法,有时并不严格遵循原始模式,但我们发现它在GO中效果很好。我将通过对我们的示例应用程序Wild Workouts进行重构来展示我们的方法。我想指出,它的很大一部分是抽象实施细节,这是技术的标准,尤其是软件。
它的另一个名称是分离关注点。这个概念现在已经很古老了,它存在于多个层次上:结构,命名空间,模块,软件包,甚至(微型)服务。所有这些都是为了将相关事物保持在边界内。有时,感觉就像常识:
  • 如果您必须优化SQL查询,则不想冒险更改显示格式
  • 如果更改HTTP响应格式,则不想更改数据库架构。
我们的整洁架构方法是两个想法的结合:将端口和适配器分开,并限制代码相互引用的方式

在我们开始之前

在 Wild Workouts 中介绍整洁架构之前,我对项目进行了一些重构。这些变化来自我们在前几章中分享的模式。
  • 第一个是对数据库实体和 HTTP 响应使用单独的模型。我在何时远离 DRY中介绍了user服务的变化。我现在在trainer和trainings中也应用了相同的模式。
  • 第二个变化遵循 Robert 在存储库模式中介绍的存储库模式。将trainings中与数据库相关的代码移至单独的结构中。

分离端口和适配器

端口和适配器可以有不同的名称,也可以叫接口和基础设施。其核心思想是将这两个类别与应用程序代码的其余部分明确分开
我们将这些组中的代码放入不同的包中。我们将它们称为“层”。我们通常使用的层是适配器、端口、应用程序和领域
  • 适配器是您的应用程序与外部世界对话的方式。您必须调整内部结构以适应外部 API 的期望。想想 SQL 查询、HTTP 或 gRPC 客户端、文件读取器和写入器、Pub/Sub 消息发布者。
  • 端口是应用程序的输入,也是外部世界访问它的唯一途径。它可以是 HTTP 或 gRPC 服务器、CLI 命令或 Pub/Sub 消息订阅者。
  • 应用程序逻辑是一个薄层,它将其他层“粘合在一起”。它也称为“用例”。如果您阅读此代码但无法分辨它使用什么数据库或调用什么 URL,这是一个好兆头。有时它很短,那很好。将其视为协调者。
  • 如果您还遵循领域驱动设计,则可以引入仅包含业务逻辑的领域层。
notion image
💡
注意:如果分层的想法仍然不清楚,请查看您的智能手机。如果你仔细想想,它也使用类似的概念。您可以使用物理按钮、触摸屏或语音助手来控制智能手机。无论您按下“音量增大”按钮、向上滑动音量条还是说“Siri,音量增大”,效果都是一样的。 “更改卷”逻辑有多个入口点(端口)。当您播放音乐时,您可以听到扬声器发出的声音。如果插入耳机,音频将自动更改为耳机。你的音乐应用程序不在乎。它不是直接与硬件通信,而是使用操作系统提供的适配器之一。 您能想象创建一个必须了解连接到智能手机的耳机型号的移动应用程序吗?这好比将 SQL 查询直接包含在应用程序逻辑中是类似的:它公开了实现细节。
让我们在trainings服务中引入层来开始重构。到目前为止,该项目看起来像这样:
这部分重构比较简单:
  1. 创建 ports, adapters, 和 app 目录.
  1. 把文件移动到合适的目录下.
我在trainer服务中引入了类似的包。这次我们不会对user服务做任何改变。那里没有应用程序逻辑,总的来说,它很小。与每种技术一样,在有意义的地方应用整洁架构。
💡
注意 如果项目规模增大,您可能会发现添加另一级子目录很有帮助。例如,adapters/hour/mysql_repository.goports/http/hour_handler.go
您可能注意到app包中没有文件。我们现在必须从 http 处理程序中提取应用程序逻辑。

应用程序层

让我们看看我们的应用程序逻辑位于哪里。先看trainings服务中的 CancelTraining 方法。
该方法是应用程序的入口点。这里没有太多逻辑,所以让我们深入研究 db.CancelTraining 方法。
notion image
notion image
在 Firestore 事务内部,有很多不属于数据库处理的代码。更糟糕的是,该方法内部的实际应用程序逻辑使用数据库模型(TrainingModel)进行决策:
将业务规则(例如何时可以取消训练)与数据库模型混合会减慢开发速度,因为代码变得难以理解和推理。测试这样的逻辑也很困难。为了解决这个问题,我们在app层添加了一个中间类型Training
现在,应该立马就能看清楚训练是否可以取消。我们无法确定如何将训练存储在数据库或HTTP API中使用的JSON格式中,那是一个好兆头。
现在,我们可以更新数据库层方法,以返回此通用应用程序类型,而不是数据库层的结构(triebermodel)。数据结构的映射是微不足道的,因为结构具有相同的字段(但是从现在开始,它们可以彼此独立发展)。

应用服务

然后,我们在app包中创建一个 TrainingsService 结构,它将作为培训应用程序逻辑的入口点。
那么我们现在如何调用数据库呢?让我们尝试复制到目前为止在 HTTP 处理程序中使用的内容
代码会编译失败:
我们需要决定各层如何相互引用。

依赖倒置原则

端口、适配器和应用程序逻辑之间的明确分离本身就很有用。 整洁架构通过依赖倒置进一步改进了它。
该规则规定外层(实现细节)可以引用内层(抽象)但反之则不然。相反,内层应该依赖于接口
  • 领域对其他层一无所知。它包含纯粹的业务逻辑。
  • 应用程序可以导入领域,但对外层一无所知。它不知道它是由 HTTP 请求、Pub/Sub 处理程序还是 CLI 命令调用的。
  • 端口可以导入内层。端口是应用程序的入口点,因此它们经常执行应用程序服务或命令。但是,他们无法直接访问适配器。
  • 适配器可以导入内层。通常,它们将对应用程序和域中找到的类型进行操作,例如从数据库中检索它们。
notion image
再说一次,这不是一个新想法。依赖倒置原则就是 SOLID 中的“D”。您认为它只适用于 OOP 吗?恰好 Go 接口与它完美匹配。该原则解决了包如何相互引用的问题。也许这就是为什么一些开发人员声称最好避免“嵌套”并将所有代码保存在一个包中。但包的存在是有原因的,那就是关注点分离
回到我们的例子,我们应该如何引用数据库层呢?因为 Go 接口不需要显式实现,所以我们可以在需要它们的代码旁边定义它们
因此应用程序服务可以定义为:“我需要一种方法来取消给定 UUID 的训练。我不在乎你怎么做,但我相信如果你实现这个接口,你就能做对”
数据库方法调用trainer和users服务的gRPC客户端。这不是合适的地方,因此我们引入了该服务将使用的两个新接口。
💡
请注意,本文中的“user”和“trainer”不是微服务,而是应用程序(业务)概念。恰好在这个项目中,它们生活在同名的微服务范围内。
我们将这些接口的实现移至适配器,如 UsersGrpc和 TrainerGrpc。作为奖励,时间戳转换现在也发生在那里,对于应用程序服务来说是不可见的。

提取应用程序逻辑

代码可以编译,但我们的应用程序服务还没有做太多事情。现在是提取逻辑并将其放在适当位置的时候了。最后,我们可以使用存储库模式中的更新函数模式从存储库中提取应用程序逻辑。
逻辑量表明我们可能希望在将来的某个时候引入领域。现在,让我们保持原样。我仅描述了单个 CancelTraining 方法的过程。请参阅完整的 diff 以了解我如何重构所有其他方法。

依赖注入

如何告诉服务使用哪个适配器?首先,我们为服务定义一个简单的构造函数。
然后,在 main.go 中我们注入适配器。
使用 main 函数是注入依赖项的最简单的方法。随着项目在以后的章节中变得更加复杂,我们将研究wire库。

添加测试

最初,该项目混合了所有层,并且不可能模拟依赖关系。测试它的唯一方法是使用集成测试,并运行适当的数据库和所有服务。虽然用这样的测试来覆盖某些场景是可以的,但它们往往速度较慢,而且不像单元测试那样有趣。引入更改后,我能够使用单元测试 suite来覆盖 CancelTraining 。我使用表驱动测试的标准 Go 方法来使所有案例都易于阅读和理解。
我没有引入任何用于mock的库。如果您愿意,您可以使用它们,但是您的接口通常应该足够小,可以简单地编写专用的模拟。
您是否注意到repositoryMock 中未实现的方法数量异常多?这是因为我们对所有方法使用单一的训练服务,因此我们需要实现完整的接口,即使只测试其中一种方法也是如此。我们将在基本 CQRS 中改进它(第 10 章)

样板怎么样?

您可能想知道我们是否没有引入太多样板。该项目的规模确实随着代码行数的增加而增加,但这本身并没有任何害处。这是对松耦合的投资,随着项目的发展将会得到回报。
将所有内容放在一个包中一开始似乎更容易,但是当您考虑在团队中工作时,划定界限会有所帮助。如果您的所有项目都有相似的结构,那么新团队成员的入职就很简单。

处理应用程序错误

我添加的另一件事是与端口无关的 slugs 错误。它们允许应用程序层返回可由 HTTP 和 gRPC 处理程序处理的通用错误。
上述错误将转换为端口中的 401 Bad Request HTTP 响应。它包括一个可以在前端翻译并显示给用户的 slug。这是避免将实现细节泄露给应用程序逻辑的另一种模式。

还有什么?

我鼓励您阅读完整的 commit,了解我如何重构 Wild Workouts 的其他部分。
您可能想知道如何强制正确使用层?在代码审查中是否还有另一件事需要记住?
幸运的是,可以通过静态分析来检查规则。您可以在本地使用 Robert 的 go-cleanarch linter 检查您的项目,或者将其包含在 CI 管道中。
随着层的分离,我们准备引入更高级的模式。在下一章中,我们将展示如何通过应用 CQRS 来改进项目
架构设计
  • 读书笔记
  • 技术架构
  • 【编码风格规范】Google的Go指南篇【读书笔记】《Go With The Domain》14. 战略 DDD 简介
    目录