Golang 中的领域驱动设计 - 战略设计

介绍

去年,我越来越多地使用 Golang,因为我以前是 100% PHP 程序员。

PHP 周围的社区非常棒,作为一种语言,有很多领域驱动设计 (DDD) 的拥护者。

我也受到了社区对这种方法论的影响,所以在过去的三年里我一直在研究和实践它。

当从 PHP 转向 Go 作为日常语言时,我注意到 DDD 在Golang 社区中并没有引起共鸣 ,因为大多数人认为它会影响编写 Go 代码时的“OOP 方法”,因为大多数关于模式的书籍和示例都是用面向对象设计编写。

我真诚地相信 Go 社区正在错过这种方法的好处,因此我决定写一些有关 Golang 和 DDD 的文章来消除这种误解。

Golang 如何帮助进行战略设计

你们中的一些人可能已经知道,领域驱动设计由两个不同的方面组成,即战略方面和战术方面。

简而言之,战略设计是您和领域专家分析领域、定义其有界上下文并寻找让他们沟通的最佳方式的设计。正如您可以轻松假设的那样,战略设计与编程语言无关。

然而,这并不意味着所有语言都允许你以相同的方式表达你和你的团队分析的内容,这也是原因之一,因为我认为 Go 非常适合该方法论。

注意:以下语句完全适合任何库和应用程序的设计,无论是整体式的还是面向服务的。

通过有界上下文进行打包

在 Golang 中,包是一个边界,提供一组用于创建和/或修改一组数据结构的 API,这些数据结构在给定的上下文中具有其专门的含义。

让我们选择一个电子邮件地址作为示例。在其通用定义中,它是消息发送到的电子邮件信箱的标识符。

我们可以在不同的上下文中结合并专门化它的含义。在管理区域中,我们允许使用“whatever.com”域的电子邮件地址,但在客户区域中,任何域都是有效的。

一种广泛使用但在我看来不是很有用的模式是按类型对事物进行分组,将不同的边界组合在一个地方。

这是按类型分组的示例:

package main

import "github.com/company/email"

func main(){
     aEmail, err := email.NewAdminEmail("info@whatever.com") 
    //...
     cEmail, err := email.NewCustomerEmail("name.surname@gmail.com")
    //...
} 

乍一看,这个解决方案可能看起来不错,因为我们仍在使用通用语言来区分域中的不同电子邮件地址。尽管如此,我们并没有考虑领域驱动设计最关键的主题之一:上下文。

当按种类分组时,我们通过设计来传达这样的信息:有一个包代表一个上下文,作为不同电子邮件地址含义的所有者。

当我们尝试使用电子邮件地址包时,糟糕的设计选择变得更加明显,go get因为如果只需要其中一个功能,它将与两个电子邮件地址功能一起下载。

现在有必要强调另一种解决此问题的方法,该方法由某些 DDD 参数支持,即按上下文对事物进行分组。

这种方法导致设计包时考虑域的边界,并避免重新考虑如何有效地分组或拆分应用程序的包。

package main

import (
    "github.com/company/service/admin"
    "github.com/company/service/customer"
)

func main(){
    aEmail, err := admin.NewEmail("info@whatever.com") 
    //...
    cEmail, err := customer.NewEmail("name.surname@gmail.com")
    //...
}

admincustomer赋予该NewEmail函数特殊的含义。这些包保护和隔离两个不同边界的不变量。

按上下文分组突出显示我们正在重复使用已经完成的领域分析,并且不需要任何额外的努力来再次塑造解决方案。

另一个更日常的技术示例是调度事件的应用程序。

我多次注意到,团队将与应用程序相关的所有事件放在同一个包中,将不同的域模型绑定在一起,即使这些模型由于需要而并不相关。

📂 app
 ┣ 📦customer
 ┃ ┗ 📜customer.go
 ┣ 📦events
 ┃ ┣ 📜customer_events.go
 ┃ ┣ 📜product_events.go
 ┃ ┗ 📜user_events.go
 ┣ 📦product
 ┃ ┗ 📜product.go
 ┗ 📦user
   ┗ 📜user.go

更好的方法是利用上下文并根据生成事件的模型来分割它们,让领域分析帮助您定义哪个边界包含什么。

根据我们团队的现有关系,我们可以通过两种方式对此进行建模。

如果此代码库由没有其他团队依赖于这些事件的团队维护,我们可以将它们分组到模型所在的同一包中,而无需创建不同的子包。

📂 app
 ┣ 📦customer
 ┃ ┣ 📜 events.go
 ┃ ┗ 📜customer.go
 ┣ 📦product
 ┃ ┣ 📜 events.go
 ┃ ┗ 📜product.go
 ┗ 📦user
   ┣ 📜 events.go
   ┗ 📜user.go

当其他团队依赖这些事件时,事情就开始不同。

这意味着我们有上游/下游关系,其中上游团队规定了这些事件的表示规则,但它仍然希望考虑下游团队的需求。

使用一些 DDD 术语,我们可以将其定义为一种Customer/Supplier关系。

由于两个团队希望找到一种智能且可维护的方式来让应用程序进行通信,因此我们可以创建一些定义通信边界的子包。

📂 app
 ┣ 📦customer
 ┃ ┣ 📦event
 ┃ ┃ ┗ 📜registered.go
 ┃ ┃ ┗ 📜activated.go
 ┃ ┃ ┗ 📜banned.go
 ┃ ┗ 📜customer.go
 ┣ 📦product
 ┃ ┣ 📦event
 ┃ ┃ ┗ 📜added.go
 ┃ ┃ ┗ 📜removed.go
 ┃ ┃ ┗ 📜published.go
 ┃ ┗ 📜product.go
 ┗ 📦user
   ┣ 📦event
   ┃ ┗ 📜logged.go
   ┃ ┗ 📜signed.go
   ┗ 📜user.go

现在有些人可能会担心创建的包的数量,因为我们将数量从 4 提高到 6,而且它可能看起来像面向对象的设计。

不用担心; 创建子包的需求是域分析中明确的需求,并且不会有任何包污染,因为我们不使用包作为命名空间。

如前所述,包是提供一组 API 的边界,用于创建和/或修改一组数据结构,保护和隔离不变量。

这意味着我们隔离了模型周围的事件,但不依赖于它,让其他团队可以重用事件包,而无需将模型携带到他们的代码库中。

定义不同包之间更好的通信策略

为了快速检测同一代码库中包的关系和通信,必须了解每个包之间的依赖关系。

文件夹结构需要解释域边界之间的依赖关系,并设置策略以避免不相关代码的高耦合或级联依赖。

如果在同一级别上没有导入包,则可能不会具有高耦合,因为这可能是错误域建模的标志,或者只是代码方面的非最佳设计选择。

当在同一级别导入包时,大多数时候,可以将两个包之一移动到另一个包中,从而定义更清晰的有界上下文。

想象一下这样的场景:我们有一个customer包含域模型表示的包和一个client用于从第三方服务检索客户数据的包。

包结构可能如下所示:

📂 app
 ┣ 📦customer
 ┃ ┗ 📜customer.go // import module/client
 ┗ 📦client
   ┗ 📜client.go

从打包的角度来看,这意味着这两个包不相关,因为它们显然不共享任何内容,但在查看代码实现时,我们发现该customer包依赖于该client包。

在我看来,将client包放入包中是一种更好的方法customer,因为它只能从包中使用。client将包视为独立包而不需要其父包也很重要,因为客户端返回客户的原始表示,所以分隔了两者的边界。

📂 app
 ┗ 📦customer
   ┣ 📦client
   ┃ ┗ 📜client.go
   ┗ 📜customer.go // import module/customer/client

这样我们就清楚地看到了两个边界的依赖关系。

提示:如果可以避免创建包client那就更好了!


有时,必须让两个或多个包进行通信,即使它们位于同一级别,这使得理解包之间的关系变得有点困难。

想象一下一个领域,其中交付团队需要交付产品,并且产品团队拥有产品模型。

两个团队希望解耦操作,两个团队使用的通用语言仅部分重叠,但没有任何充分的理由来定义上游/下游通信。

保存该域的包结构可能如下所示:

📂 app
 ┣ 📦delivery
 ┃ ┗ 📜delivery.go // may import module/product
 ┗ 📦product
   ┗ 📜product.go // or may import module/delivery

当这种情况发生时,就可以使用 DDD 战略设计中定义的模式。

目标是避免两个包之间的直接依赖关系。

最常用的战略模式之一是Anti-Corruption Layer. 当两个有界上下文需要共享有关域模型的详细信息,并且团队希望保护自己免受将模型数据或通用语言泄漏到所拥有的有界上下文的机会时,可以使用此模式。

这可以通过创建第三个包(示例)来实现pubsub,该包仅使用必需的字段(而不是每个包的整个结构)来转换包之间的数据,从而减少级联依赖性的数量。

 ┣ 📦delivery
 ┃ ┗ 📜delivery.go // imports module/pubsub
 ┣ 📦product
 ┃ ┗ 📜product.go // imports module/pubsub
 ┗ 📦pubsub
   ┗ 📜pubsub.go 

结论

在编写 Go 代码时,打包起着关键作用,我们必须充分利用语言提供和依赖的包机制。

我的经验法则是将包设计为有界上下文,并使用策略模式来识别包之间的通信来识别关系和契约。

这种方法也符合威廉·肯尼迪的《面向包装的设计》,值得一读。

您不同意我和我的设计理念吗?欢迎在下面的评论中分享您在使用 Go 开发代码库时做出的主要决定!

注意:本文可能是有关 DDD 和 Golang 的系列文章的一部分,如果您有兴趣了解更多有关如何在 Go 中实现战术模式的信息,请告诉我!

Golang 中的领域驱动设计 - 战略设计 | 家

面向封装的设计

更新于2017年2月28日

序幕

这篇文章是一系列文章的一部分,旨在让您思考自己在不同主题上的设计理念。如果您还没有阅读这些帖子,请先阅读:

发展你的设计理念
包装设计理念

介绍

面向包的设计允许开发人员确定包在 Go 项目中的位置以及包必须遵守的设计准则。它定义了什么是 Go 项目以及 Go 项目的结构。最后,它改善了团队成员之间的沟通,并促进了干净的包设计和可讨论的项目架构。

面向包的设计并不局限于单个项目结构,但指出项目结构对于应用良好的包设计指南至关重要。接下来,我将根据之前提出的设计理念提出一种可能的项目结构和要遵循的指南。

项目结构

我认为每个公司都应该建立一个Kit项目,然后Application为一起部署的不同程序集建立多个项目。

套件项目

将该Kit项目视为公司的标准库,因此应该只有一个。属于该Kit项目的包在设计时需要考虑到最高级别的可移植性。这些包应该可在多个项目中使用Application,并提供非常具体但基础的功能领域。为此,Kit项目不允许有vendor文件夹。如果任何软件包依赖于第 3 方软件包,则它们必须始终针对这些依赖项的最新版本进行构建。

一个典型的Kit项目可能如下所示:

清单 1

github.com/ardanlabs/kit
├── CONTRIBUTORS
├── LICENSE
├── README.md
├── cfg/
├── examples/
├── log/
├── pool/
├── tcp/
├── timezone/
├── udp/
└── web/

注意:将这些包中的每一个分解到它们自己的存储库中没有任何问题Kit。我不这样做是因为它会增加管理所有这些包的工作量。现有的供应工具可以让您从存储库中挑选需要供应的软件包。此功能允许您管理Kit包的单个存储库。

应用项目

Application项目包含一组一起部署的程序。该组程序可以包括服务、CLI 工具和后台程序。每个Application项目都绑定到一个存储库,其中包含该项目的所有源代码,包括第 3 方依赖项的所有源代码。您需要多少个Application项目取决于您自己,但始终采取“少即是多”的方法。

每个Application项目包含三个根级文件夹。这些是cmd/internal/vendor/platform/该文件夹内还有一个internal/文件夹,它与internal/.

一个典型的Application项目可能如下所示:

清单 2

github.com/servi-io/api
├── cmd/
│   ├── servi/
│   │   ├── cmdupdate/
│   │   ├── cmdquery/
│   │   └── servi.go
│   └── servid/
│       ├── routes/
│       │   └── handlers/
│       ├── tests/
│       └── servid.go
├── internal/
│   ├── attachments/
│   ├── locations/
│   ├── orders/
│   │   ├── customers/
│   │   ├── items/
│   │   ├── tags/
│   │   └── orders.go
│   ├── registrations/
│   └── platform/
│       ├── crypto/
│       ├── mongo/
│       └── json/
└── vendor/
    ├── github.com/
    │   ├── ardanlabs/
    │   ├── golang/
    │   ├── prometheus/
    └── golang.org/

小贩/

该文件夹的详细文档可以在 Daniel Theophanes 撰写的 Gopher Academy帖子vendor/中找到。出于本文的目的,第 3 方包的所有源代码都需要提供(或复制)到该文件夹​​中。这包括将在公司项目中使用的包。将项目中的包视为第 3 方包。vendor/KitKit

指令/

该项目拥有的所有程序都属于该cmd/文件夹。下面的文件夹cmd/始终以将要构建的每个程序命名。d使用程序文件夹末尾的字母将其表示为守护程序。每个文件夹都有一个包含该包的匹配源代码文件main

内部的/

需要由项目内的多个程序导入的包位于该internal/文件夹内。使用该名称的一个好处internal/是项目可以从编译器获得额外级别的保护。该项目之外的任何包都不能从internal/. 因此,这些包仅是该项目的内部包。

内部/平台/

该文件夹中包含基础但特定于项目的包internal/platform/。这些包为数据库、身份验证甚至编组等提供支持。

验证封装设计

面向包的设计的一个重要方面是验证包设计的能力。这是可能的,因为与包关联的准则基于其在项目中的位置。有七个验证步骤可以帮助您识别设计问题。

验证包的位置。

  • Kit
    • 为现有的不同项目提供基础支持的软件包Application
    • 日志记录、配置或 Web 功能。
  • cmd/
    • 为正在构建的特定程序提供支持的包。
    • 启动、关闭和配置。
  • internal/
    • 为项目拥有的不同程序提供支持的包。
    • CRUD,服务或业务逻辑。
  • internal/platform/
    • 为项目提供内部基础支持的软件包。
    • 数据库、身份验证或编组。

验证依赖项选择。

  • All
    • 验证每个依赖项的成本/收益。
    • 为了共享现有类型而质疑导入。
    • 问题导入到同一级别的其他包。
    • 如果一个包想要导入同一级别的另一个包:
      • 对这些封装当前的设计选择提出质疑。
      • 如果合理,请将包移动到要导入它的包的源代码树中。
      • 使用源树来显示依赖关系。
  • internal/
    • 无法导入来自这些位置的软件包:
      • cmd/
  • internal/platform/
    • 无法导入来自这些位置的软件包:
      • cmd/
      • internal/

验证正在实施的政策。

  • Kitinternal/platform/
    • 不允许设置有关任何应用程序问题的策略。
    • 不允许记录,但对跟踪信息的访问必须解耦。
    • 配置和运行时更改必须解耦。
    • 检索度量值和遥测值必须分离。
  • cmd/,internal/
    • 允许设置有关任何应用程序问题的策略。
    • 允许本地记录和处理配置。

验证如何接受/返回数据。

  • All
    • 验证给定类型的值/指针语义的一致使用。
    • 当使用接口类型接受值时,重点必须放在所需的行为上,而不是值本身。
    • 如果不需要行为,请使用具体类型。
    • 如果合理,请在声明新类型之前使用现有类型。
    • 来自泄漏到导出的 API 中的依赖项的问题类型。
      • 现有类型可能不再适合使用。

验证如何处理错误。

  • All
    • 处理错误意味着:
      • 错误已被记录。
      • 应用程序恢复到 100% 完整性。
      • 不再报告当前错误。
  • Kit
    • 不允许使应用程序恐慌。
    • 不允许错误换行。
    • 仅返回根本原因错误值。
  • cmd/
    • 允许使应用程序恐慌。
    • 如果未处理,则用上下文包装错误。
    • 大多数处理错误都发生在这里。
  • internal/
    • 不允许使应用程序恐慌。
    • 如果未处理,则用上下文包装错误。
    • 少数处理错误发生在这里。
  • internal/platform/
    • 不允许使应用程序恐慌。
    • 不允许错误换行。
    • 仅返回根本原因错误值。

验证测试。

  • cmd/
    • 允许使用第三方测试包。
    • 可以有一个test用于测试的文件夹。
    • 更多地关注集成而不是单元测试。
  • kit/internal/internal/platform/
    • 坚持go中的测试包。
    • 测试文件属于包内。
    • 更多地关注单元测试而不是集成测试。

验证恢复恐慌。

  • cmd/
    • 可以恢复任何恐慌。
    • 仅当系统可以恢复到 100% 完整性时。
  • kit/internal/internal/platform/
    • 无法从恐慌中恢复,除非:
      • Goroutine 归包所有。
      • 可以向应用程序提供有关恐慌的事件。

快速示例

下面是一个简单示例,说明我们如何查看Application项目以了解项目是如何组合在一起并验证每个包的依赖项选择的。

  • cmd/
    • Application项目构建了两个程序:serviservid
      • servid是一项网络服务。
      • servi是一个cli工具。
    • 里面的包都不servid/能从里面导入任何包servi/
      • routes包无法导入该cmdupdate包。
    • routes包确实导入了该handlers包。
  • internal/
    • 该项目有四个根级内部包。
      • attachmentslocationsordersregistrations
    • 该项目有三个internal/platform/包。
      • cryptomongosg
    • 四个根级包不允许互相导入。
      • 他们处于同一水平。
      • attachments无法导入任何其他internal/唯一的包。
    • orders包内部声明了三个包。
      • customersitemstags
      • internal/文件夹内,只能orders导入这些包。
      • 这三个包不能互相导入。
    • 该文件夹内的任何包都internal/可以从 导入internal/platform/
      • attachments包可以导入mongo
      • internal/platform/可以互相导入。
        • 可以crypto导入mongo.

结论

面向包的设计促进对话和审查,以确保包保持最佳的用途、可用性和可移植性。这推动了整个项目中任何包的干净包设计。为了使面向包的设计有效,您需要有关项目结构的严格规则。我分享的项目结构是在过去三年中开发出来的,并且在多个项目中有效地发挥了作用。其他项目结构也可能同样有效,我希望随着时间的推移,我将继续重构我的项目结构和指南。

我所教的一切目标都是让你开始思考你在做什么以及为什么这样做。让你开始提出问题并验证你正在做的一切。我希望您开始考虑面向包的设计,并开始正式确定如何为您所从事的项目构建项目和设计包。

posted @ 2024-03-15 08:52  CharyGao  阅读(27)  评论(0编辑  收藏  举报