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")
//...
}
包admin
和customer
赋予该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 中实现战术模式的信息,请告诉我!