0%

译:在Go中转向领域驱动设计

原文:Moving Towards Domain Driven Design in Go

本文的目的是帮助演示当应用程序随着时间不断推移不断演化时,我们如何利用领域驱动设计帮我们解决可能遇到的问题。为了实现这个目标,我们会通过一个琐碎的项目研究项目是如何随着时间一步步演化的。这不是一个完整的项目,示例代码并不能够直接编译,甚至有些导入以来没有全部列出。这只是一个简单的示例,也就是说,如果出现什么问题,你可以随时与我联系,我们将对问题进行调整或者你的问题及时解答(如果可以的话)。

首先,让我们讨论一下这个项目的背景。想象一下你在工作,老板要求你创建一种通过GitHub API对用户进行身份验证的方法。具体上说,你需要利用用户个人访问令牌,查找该用户及其所有组织。 这样,你以后就可以根据他们所属的组织来限制他们的访问。

注意:我们这里使用访问令牌来简化示例。

这听起来很容易,所以你启动编辑器并实现提供此功能的github包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package github

type User struct {
ID string
Email string
OrgIDs []string
}

type Client struct {
Key string
}

func (c *Client) User(token string) (User, error) {
// 与Github API进行交互,并返回用户。如果他们在一个组织中,返回组织的c.OrgID
}

注意:这里不是使用真实的Github API - 这只是一个演示例子。

接下来,你需要给github包编写一些中间件,这样你就可以在需要保护的HTTP handler中使用这个包。在中间件中,你通过头部中的basic auth中获得用户的访问令牌,然后调用Github的代码查找用户,检查他们是否是所提供组织的一部分,然后相应地授予权限或拒绝访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package mw

func AuthMiddleware(client *github.Client, reqOrgID string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, _, ok := r.BasicAuth()
if !ok {
http.Error(w, “Unauthorized”, http.StatusUnauthorized)
return
}
user, err := client.User(token)
if err != nil {
http.Error(w, “Unauthorized”, http.StatusUnauthorized)
return
}
permit := false
for _, orgID := range user.OrgIDs {
if orgId == reqOrgID {
permit = true
break
}
}
if !permit {
http.Error(w, “Unauthorized”, http.StatusUnauthorized)
return
}
// 用户已认证,放他们进来
next.ServeHTTP(w, r)
})
}

你将这些代码给你的同事看,他们担心缺乏测试。具体的说,没有一种方法能够验证AuthMiddleware能否按照预期工作。“没有问题”,你说,“我们可以使用interface保证我们可以方便的测试它!”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package mw

type UserService interface {
User(token string) (github.User, error)
}

func AuthMiddleware(us UserService, reqOrgID string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, _, ok := r.BasicAuth()
if !ok {
http.Error(w, “Unauthorized”, http.StatusUnauthorized)
return
}
user, err := us.User(token)
if err != nil {
http.Error(w, “Unauthorized”, http.StatusUnauthorized)
return
}
permit := false
for _, orgID := range user.OrgIDs {
if orgId == reqOrgID {
permit = true
break
}
}
if !permit {
http.Error(w, “Unauthorized”, http.StatusUnauthorized)
return
}
// user is authenticated, let them in
next.ServeHTTP(w, r)
})
}

现在,你可以使用模拟用户服务测试此代码。

1
2
3
4
5
6
7
8
9
package mock

type UserService struct {
UserFn func(token string) (github.User, error)
}

func (us *UserService) Authenticate(token string) (github.User, error) {
return us.UserFn(token)
}

创建模拟用户服务有很多方式,但是这个通常需要测试认证正常通过时和认证出现问题时的不同情况。

现在,我们对认证中间件完成测试、发布,生活似乎很愉快。

然后,悲剧发生了。你的CEO听说哥斯拉(译者注:哥斯拉毁灭世界?)正在前往旧金山,你的公司无法在未来不确定的情况下继续依赖Github。如果Github整个办公室被摧毁了,没有工程师继续维护该产品怎么办?不,完全不能接受!

幸运的是,还有一家名为GitLab的替代公司,似乎做了很多类似GitHub的功能,不同的是他们是一个远程工作团队。这意味着哥斯拉永远无法同时消灭所有工程师,对吗? 🎉

高层似乎认同这个逻辑,他们开始进行迁移。你的工作?你的任务是保证所有认证代码能够在新系统上正常运行!

你花了一些时间查看GitLab API文档,好消息是,看起来他们采用了和Github类似的策略。GitLab同样也有个人访问令牌,组织,你只需要重新实现客户端即可。中间件中的代码完全不需要更改,因为你是一个聪明人,你使用了接口! 😁 现在你只需要创建一个Github客户端…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package gitlab

type User struct {
ID string
Email string
OrgIDs []string
}

type Client struct {
Key string
}

func (c *Client) User(token string) (User, error) {
// 与Gitlab API交互,返回用户和对应的组织c.OrgID
}

现在,把这个功能放入AuthMiddleware中,但是,它却不能正常工作了!

事实证明,即使接口也可能成为耦合的受害者。在这种情况下,这是因为你的接口期望通过User方法返回github.User

1
2
3
4
type UserService interface {
User(token string) (github.User, error)
^^^^^^^^^^^
}

怎么办?老板希望明天就要发布了!

现在,你有几种选择:

  1. 修改中间件,以便UserService接口返回gitlab.User而不是github.User
  2. 创建一个新的专门用于GitLab的身份验证中间件。
  3. 创建一个通用的用户类型,能够在AuthMiddleware中可以自由切换你的github和gitlab实现。

如果你确信自己的公司以后继续使用GitLab,则方案1就有意义。当然,你需要同时更改用户服务接口和mock,但是如果是一次性的修改,那么这种方案并不坏。

当然,你并不真正知道你们是否会坚持使用GitLab。也许哥斯拉会改变它的攻击计划?

如果程序中很多代码都依赖此程序包返回的github.User类型,这个方案也会带来很多麻烦。

方案2也是可行的,但是它看起来有点傻傻的。当逻辑不变时,为什么我们需要重写一遍所有的代码和测试?当然,一定有一种接口可以按照你最初的意图实现功能。毕竟,中间件不关心查询的用户,我们只是需要处理其中一些重要信息。

现在,你决定尝试一下方案3。你创建了一个mw包,定义了一个User类型,然后准备编写一个适配器将其与Github和Gitlab客户端连接。

1
2
3
4
5
6
7
8
9
package mw

type User struct {
OrgIDs []string
}

type UserService interface {
User(token string) (User, error)
}

当你编写代码时,你意识到,你实际上并不关心用户ID或者电子邮件等等其他信息,因此你完全可以从mw.User中删除这些字段,你只需要制定你关心的字段,这样就可以让代码更容易维护和测试。棒极了!

接下来,你需要创建一个适配器,以便你可以对其进行操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Package adapter probably isn’t a great package name, but this is a
// demo so deal with it.
package adapter

type GitHubUserService struct {
Client *github.Client
}

func (us *GitHubUserService) User(token string) (mw.User, error) {
ghUser, err := us.Client.User(token)
if err != nil {
return mw.User{}, err
}
return mw.User{
OrgIDs: ghUser.OrgIDs,
}, nil
}

type GitLabUserService struct {
Client *gitlab.Client
}

func (us *GitLabUserservice) User(token string) (mw.User, error) {
glUser, err := us.Client.User(token)
if err != nil {
return mw.User{}, err
}
return mw.User{
OrgIDs: glUser.OrgIDs,
}, nil
}

你也需要更新你的mock,不过这是一个相当快的修改。

1
2
3
4
5
6
7
8
9
package mock

type UserService struct {
UserFn func(token string) (mw.User, error)
}

func (us *UserService) Authenticate(token string) (mw.User, error) {
return us.UserFn(token)
}

现在,如果你想将我们的AuthMiddleware与GitHub或GitLab一起使用,则可以使用如下代码进行操作:

1
2
3
4
5
6
7
8
9
var myHandler http.Handler
var us mw.UserService
us = &GitLabUserservice{
Client: &gitlab.Client{
Key: “abc-123”,
},
}
// This protects your handler
myHandler = mw.AuthMiddleware(us, “my-org-id”, myHandler)

我们终于有了一个完全解耦的解决方案。我们可以轻松地在GitHub和GitLab之间切换,并且当新的源码管理公司出现时,我们也可以很快跟上潮流。

寻找中间方案

在前面的示例中,我们一步步看到了代码从紧密耦合到完全解耦的过程。我们使用adapter包来完成处理这些解耦后的mwgithub/gitlab包之间的转换。

这样做的主要好处是我们在最后的时候看到的:我们可以在自由选择使用GitHub或GitLab认证策略,而不需要修改handlers和认证中间件。

尽管这些好处非常棒,但在不考虑成本的情况下探索这些好处是不公平的。 所有这些更改提供了越来越多的代码,如果你回过头去查看gitlabmw软件包的原始版本,你会发现它们比需要使用adapter软件包的最终版本要简单得多。这样也会也可能导致更多的设置项,因为在我们代码的某个地方,我们需要实例化所有这些适配器并将它们连接在一起。

如果沿这条路线继续演进,我们可能会很快发现我们也需要许多不同的User类型。例如,我们可能需要在GitHub(或GitLab)等服务中将内部用户类型与外部用户ID关联。 这可能导致在我们的数据库包中定义一个ExternalUser ,然后编写一个适配器将github.User转换为这种类型,以便我们的数据库代码与我们正在使用的服务解耦。

I actually tried doing this on one project with my HTTP handlers just to see how it turned out. Specifically, I isolated every endpoint in my web application to its own package with no external dependencies specific to my web application and ended up with packages like this:
实际上,为了证明这个结果,我在一个项目上的HTTP处理程序上进行过尝试。具体来说,我将Web应用程序中的每个端点隔离到其自己的程序包中,而没有特定于Web应用程序的外部依赖关系,最终得到了像这样的程序包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
// Package enroll provides HTTP handlers for enrolling a user into a new
// course.
// This package is entirely for demonstrative purposes and hasn’t been tested,
// but if you do see obvious bugs feel free to let me know and I’ll address
// them.
package enroll

import (
“io”
“net/http”

“github.com/gorilla/schema”
)

// Data defines the data that will be provided to the HTML template when it is
// rendered.
type Data struct {
Form Form
// Map of form fields with errors and their error message
Errors map[string]string

User User
License License
}

// License is used to show the user more info about what they are enrolling in.
// Eg if they URL query params have a valid key, we might show them:
//
// “You are about to enroll in Gophercises - FREE using the key `abc-123`”
// ^ ^ ^
// Course Package Key
//
type License struct {
Key string
Course string
Package string
}

// User defines a user that can be enrolled in courses.
type User struct {
ID string
// Email is used when rendering a navbar with the user’s email address, among
// other areas of an HTML page.
Email string
Avatar string
// …
}

// Form defines all of the HTML form fields. It assumes the Form will be
// rendered using struct tags and a form package I created
// (https://github.com/joncalhoun/form), but it isn’t really mandatory as
// long as the form field names match the `schema` part here.
type Form struct {
License string `form:"name=license;label=License key;footer=You can find this in an email sent over by Gumroad after purchasing a course. Or in the case of Gophercises it will be in an email from me (jon@calhoun.io)." schema:"license"`
}

// Handler provides GET and POST http.Handlers
type Handler struct {
// Interfaces and function types here serve roughly the same purpose. funcs
// just tend to be easier to write adapters for since you don’t need a
// struct type with a method.
UserFn func(r *http.Request) (User, error)
LicenseFn func(key string) (License, error)

// Interface because this one is the least likely to need an adapter
Enroller interface {
Enroll(userID, licenseKey string) error
}

// Typically satisfied with an HTML template
Executor interface {
Execute(wr io.Writer, data interface{}) error
}
}

// Get handles rendering the Form for a user to enroll in a new course.
func (h *Handler) Get() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := h.UserFn(r)
if err != nil {
// redirect or render an error
return
}
var data Data
data.User = user

var form Form
err := r.ParseForm()
if err != nil {
// maybe log this? We can still render
}
dec := schema.NewDecoder()
dec.IgnoreUnknownKeys(true)
err = dec.Decode(&form, r.Form)
if err != nil {
// maybe log this? We can still render
}
data.Form = form

if form.License != “” {
lic, err := h.LicenseFn(form.License)
data.License = lic
if err != nil {
data.Errors = map[string]string{
“license”: “is not valid”,
}
}
}

h.Executor.Execute(r, data)
}
}

// Post handles processing the form and enrolling a user.
func (h *Handler) Post() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := h.UserFn(r)
if err != nil {
// redirect or render an error
return
}
var data Data
data.User = user

var form Form
err := r.ParseForm()
if err != nil {
// maybe log this? We can still render
}
dec := schema.NewDecoder()
dec.IgnoreUnknownKeys(true)
err = dec.Decode(&form, r.Form)
if err != nil {
// maybe log this? We can still render
}
data.Form = form

err = h.Enroller.Enroll(user.ID, form.License)
if err != nil {
data.Errors = map[string]string{
“license”: “is not valid”,
}
// Re-render the form
h.View.Execute(r, data)
return
}
http.Redirect(w, r, “/courses”, http.StatusFound)
}
}

从理论上讲,这个想法听起来很酷。 现在,我可以单独定义所有HTTP处理程序,不必担心应用程序的其余部分的影响。 每个包都可以轻松测试,当我编写这些独立的作品时,我发现自己的工作效率非常高。 我甚至有一个名为Executor的接口,谁不想在他们的代码中使用自定义的执行器?!

在实践中,这个想法对我的特定用例而言太糟糕了。是的,这种方法确实有好处,但是它们并没有超过编写所有这些代码的成本。 在创建enroll和类似软件包的内部组件时,我的工作效率很高,但是我花了很多时间编写适配器并将各个部分连接在一起,这使我的整体生产率下降了。 我找不到一种无需编写自定义UserFnLicenseFn的快速方法就可以将其引入到代码中的方案,而且我发现自己为带有HTTP处理程序的每个程序包编写了一堆几乎相同的UserFn变种。

这引出了本节的主题- 是否有办法实现可接受的中间方案?

我喜欢将我的代码与第三方依赖项解耦。我也喜欢编写可测试的代码。但是我不喜欢通过加倍的编码工作来实现这一目标。当然,这里必然有一个中间方案,可以在没有所有额外代码的情况下为我们提供大多数好处,对吗?

是的,是有中间方案的,找到它的关键不是消除所有耦合,而是有意地选择代码所耦合的内容。

让我们回到githubgitlab包的原始示例。在我们的第一个版本(紧密耦合版本)中,我们有一个mw包所依赖的github.User类型。 它满足使用,并且我们甚至可以围绕它构建接口,但是我们仍然与github包紧密耦合。

在第二个版本(解耦版本)中,我们有一个github.Usergitlab.Usermw.User。 这使我们能够解耦所有内容,但是我们必须创建将这些解耦的代码连接在一起的适配器。

The middle ground, and the third version we will explore, is to intentionally define a User type that every package is allowed to be tightly coupled to. By doing this, we are intentionally choosing where that coupling happens and can make that decision in a way that still makes it easy to test, swap implementations, and do everything else we desire from decoupled code.
我们即将探索的第三种中间方案,是有意允许定义每个包紧密耦合的User类型。通过这种操作,我们刻意选择发生耦合的位置,并且保证易于测试、方便实现交换和易于其他为解耦代码实现的一切方式。

首先是我们的User类型。 这将在domain包中创建,我们应用中的任何其他包都可以导入。

1
2
3
4
5
6
7
package domain

type User struct {
ID string
Email string
OrgIDs []string
}

接下来,我们将重写githubgitlab软件包以利用此domain.User类型。 这些基本相同,因为我去除了所有的真实逻辑,只展示其中一个。

1
2
3
4
5
6
7
8
9
10
11
package gitlab // github is the same basically

type Client struct {
Key string
}

// Note the return type is domain.User here - this code is now coupled to our
// domain.
func (c *Client) User(token string) (domain.User, error) {
// … interact with the gitlab API, and return a user if they are in an org with the c.OrgID
}

最后,我们有了mw软件包。

1
2
3
4
5
6
7
8
9
package mw

type UserService interface {
User(token string) (domain.User, error)
}

func AuthMiddleware(us UserService, reqOrgID string, next http.Handler) http.Handler {
// unchanged
}

我们甚至可以使用此编写一个模拟包。

1
2
3
4
5
6
7
8
9
package mock

type UserService struct {
UserFn func(token string) (domain.User, error)
}

func (us *UserService) Authenticate(token string) (domain.User, error) {
return us.UserFn(token)
}

领域驱动设计

到目前为止,我一直努力避免使用任何令人困惑的术语,因为我发现它们通常会使问题变得复杂而不是简化它们。如果你不相信我,请尝试阅读有关域驱动设计(DDD)的任何文章,书籍或其他资源。他们几乎总是使你在代码中实际实现想法带来更多的疑问和更少的明确答案。

我不是在说明DDD没有用,也不是在建议你不要读那些书。我的意思是,我的很多(大多数)读者在这里是为了寻求有关如何改进其代码的更实用建议,而不是讨论软件开发理论。

从实际的执行角度来看,领域驱动设计的主要好处是编写可以随时间变化而变化的软件。我发现在Go中实现此目标的最佳方法是清楚地定义你的领域类型,然后编写依赖于这些类型的实现。这仍然会导致代码耦合,但是由于你的领域与问题紧密相关,因此你解决这种耦合并不困难。实际上,我经常发现,需要对领域模型进行清晰的定义是有启发性的,而不是麻烦的。

注意:定义具体领域类型并将代码耦合到它们的想法并不是独创的或新颖的。本·约翰逊在2016年撰写的 这篇文章,对于任何新入门的Gopher来说仍然是非常有价值的文章。

回到前面的示例,我们看到在domain包中定义了我们的领域 :

1
2
3
4
5
6
7
package domain

type User struct {
ID string
Email string
OrgIDs []string
}

再进一步,我们甚至可以开始定义基本的构建块,而我们的其余应用可以实现或者在不与实现细节耦合的情况下使用。 例如我们的UserService :

1
2
3
4
5
6
7
package domain

type User struct { … }

type UserService interface {
User(token string) (User, error)
}

这是由githubgitlab包实现的接口,并在mw包中使用。 它也可以被我们代码中的其他软件包所使用,而不用关心它是如何实现的。而且由于它是在领域中定义的,因此我们不必担心每个实现的返回类型都会有所变化-它们都基于一个通用的定义可以构建。

随着应用程序的发展和变化,这种定义通用接口的想法变得更加强大。 例如,假设我们有一个稍微复杂的UserService :也许它会处理创建用户,验证用户,通过令牌查找用户,通过密码重置令牌,更改密码等操作。

1
2
3
4
5
6
7
8
9
package domain

type UserStore interface {
Create(NewUser) (*User, RememberToken, error)
Authenticate(email, pw string) (*User, RememberToken, error)
ByToken(RememberToken) (*User, error)
ResetToken(email string) (ResetToken, error)
UpdatePw(pw string, tok ResetToken) (RememberToken, error)
}

我们可能首先使用纯SQL代码和本地数据库来实现:

1
2
3
4
5
6
7
8
9
10
11
package sql

type UserStore struct {
DB *sql.DB
}

func (us *UserStore) Create(newUser domain.NewUser) (*domain.User, domain.RememberToken, error) {
// …
}

// … and more methods

即便当我们只有一个应用程序时,但是也许我们会成长为另一个Google,我们决定一个集中的用户管理系统,这样我们所有单独的应用都可以使用这套系统。

如果我们将代码耦合到sql实现,这基本很难实现,但是由于大多数代码都耦合到domain.UserService我们可以编写一个新的实现并使用。

1
2
3
4
5
6
7
8
9
10
11
package userapi

type UserStore struct {
HTTPClient *http.Client
}

func (us *UserStore) Create(newUser domain.NewUser) (*domain.User, domain.RememberToken, error) {
// interact with a third party API instead of a local SQL database
}

// …

一般而言,耦合到领域而不是特定的实现方式使我们不必担心诸如以下的细节:

  • 我们正在与微服务或本地数据库进行交互吗? 无论我们的用户管理系统是本地SQL数据库还是微服务,我们都可以在合理的时间内完成代码编写。
  • 我们是否通过JSON,GraphQL,gRPC或其他方式与用户API通信? 尽管我们的实现需要知道如何与用户API进行通信,但是无论我们使用哪种特定技术,我们其余的代码都将继续以相同的方式运行。
  • 其他更多…

从根本上讲,这就是我认为领域驱动设计的主要好处。这不是花哨的术语,色彩鲜艳的图形,也不是在同事面前炫耀聪明。纯粹是设计能够不断发展以满足不断变化的需求的软件。

为什么我们不从领域驱动设计开始?

显而易见的后续问题是:“如果它是如此出色,为什么我们不只是从领域驱动设计开始呢?”

任何有使用模型-视图-控制器(MVC)经验的人都会告诉你,它很容易发生紧密耦合。几乎我们所有的应用程序都将需要依赖于我们的模型,而我们只是探讨了这可能会带来问题。那有什么呢?

从公用领域进行构建虽然很有用,但如果滥用,也可能是一场噩梦。领域驱动设计具有相当陡峭的学习曲线:并不是因为这些想法特别难于掌握,而是因为在项目发展到合理规模之前,你很少了解在应用这些想法时哪里出错了。结果是可能要花几年的时间才能真正掌握所有涉及的动态演化。我已经写了很长一段时间的软件了,但我仍然感觉不到我对所有可能出错或变得复杂的方式都拥有完全的了解。

*注意:这是我花这么长时间发布本文的重要原因之一。在很多时候,我仍然对分享保有疑虑,我不觉得自己是这个主题的专家。但是,我最终决定分享,因为我相信其他人可以从我的有限理解中学到东西,并且我相信随着与其他开发人员进行讨论,本文会随着时间的推移而发展和改进。因此,随时欢迎与我进行讨论 -jon@calhoun.io *

MVC为你提供了组织代码的合理起点。数据库交互在模型层,http处理程序在控制器层,渲染代码在视图层。这可能会导致紧密耦合,但可以让你快速入门。

与MVC不同,领域驱动设计不会为你提供组织代码的合理起点。实际上,从DDD开始与从MVC开始几乎完全相反:不是直接进入构建控制器并查看模型如何发展,相反,你必须花大量时间预先确定领域应该是什么。这可能包括模拟一些想法并让同事进行审查,讨论什么是正确/不正确的,几个迭代周期,然后才可以投入到编写一些代码中。 你可以在 Ben Johnson的WTF Dial项目 中看到这一点,他在该 项目 中创建了PR,并与Peter Bourgon,Egon Elbre和Marcus Olsson讨论了领域相关内容。

这并不是一件坏事,但正确起来也不容易,它需要大量的前期工作。 因此,如果你有一个更大的团队,每个人都需要在某个共同领域上达成共识,然后才能开始开发,那么我通常会发现这种方法最有效。

鉴于我经常在较小的团队中(或由我自己)进行编码,所以我发现,如果我从简单的事情开始,我的项目就会自然发展。也许这是一个 平铺结构 ,也许是 一个mvc结构 ,或者也许完全是其他东西。 只要我对代码的发展保持开放的态度,我就不会太着迷于那些细节。这使它最终可以采用DDD之类的形式,但是不需要我从那里开始。如前所述,对于大型组织(每个人都一起开发同一应用程序)而言,这样做可能会比较困难,因此通常需要进行更多的前期设计讨论。

在我们的示例应用程序中,我们做了与“让它不断发展”的概念非常相似的事情。每一步都是为特定目的而采取的:我们添加了UserService接口,因为我们需要测试身份验证中间件。 当我们开始从GitHub迁移到GitLab时,我们意识到我们的接口不够用,因此我们探索了其他选择。 围绕这一点,我认为一种让变得有意义DDD的方法,而不是猜测UserUserService接口,因为我们有一些实际的实现基础进行验证。

以DDD开头的另一个潜在问题是类型定义不足,因为我们经常在拥有具体用例之前就定义它们。例如,我们可能决定对用户进行身份验证如下所示:

1
2
3
type UserAuthenticator interface {
Authenticate(email, pw string) (error)
}

但是后来,我们意识到,实际上,每当我们对用户进行身份验证时,我们确实希望返回该用户(或记住的令牌),并且通过预先定义此接口,而之前我们错过了这一细节。现在,我们需要引入第二种方法来检索此信息,或者我们需要更改UserAuthenticator类型并重构实现利用此功能的任何代码。

同样的情况适用于你的模型。在实际实现githubgitlab软件包之前,我们可能会认为,我们在User模型上唯一需要的识别信息是Email字段,但是我们稍后可能会在实现这些服务时意识到电子邮件地址可以更改的,而我们还需要ID字段用于唯一标识用户。

在使用领域模型之前定义是一项挑战。除非我们已经非常了解我们正在研究的领域,否则我们极不可能知道我们需要做什么和不需要做什么。是的,这意味着我们必须在以后重构代码,但是如果你错误定义了领域,不使用DDD会很多比重构整个代码库更容易。 这是为什么我不介意从紧密耦合的代码开始并在以后进行重构的另一个原因。

最后,并非所有代码都需要这种解耦,它并不能总是能够带来好处,并且在某些情况下(例如DB),我们很少利用这种解耦。

对于不继续研发的项目,你可能不需要花费所有时间来解耦代码。如果代码没有继续研发,那么更改的可能性将大大降低,而为代码做准备而付出的额外努力可能只是浪费了。

此外,解耦并不总是能提供它所承诺的好处,我们也不会总是利用这种解耦的优势。 正如Mat Ryer所指出的那样 ,我们很少会换掉数据库实现。 即使我们确实实现了所有功能的分离,即使我们碰巧只在少数正在迁移数据库的应用程序中,这种迁移通常也需要彻底重新考虑我们与数据存储之间的交互方式。 毕竟,NoSQL数据库的行为与SQL数据库完全不同,要真正利用两者,我们必须编写所使用数据库的特定代码。最终结果是这些抽象并不总是为我们提供我们想要的神奇的“实现无关”的结果。

这并不意味着DDD不能提供优势,而是意味着我们不应该简单地 顶礼膜拜 并期待神奇效果。我们需要停下来自我反思。

综上所述

在本文中,我们直接研究了代码紧密耦合时遇到的问题,并探讨了定义领域类型和接口如何帮助改善这种耦合。 我们还讨论了为什么让我们的代码随着时间的推移而发展,而不是从开始进行这种分离的设计的原因。

在本系列的下一篇文章中,我希望展开讲解使用领域驱动设计编写Go代码的想法。 具体来说,我想讨论:

  • 接口测试如何帮助确保实现可以毫无问题地互换。
  • 子域如何衍生自不同的上下文。
  • 使用传统的DDD六边形来形象化这一切,以及像第三方库这样的代码如何适配。

我还想强调,本文绝不是硬性规定。这只是我微薄的尝试,试图分享一些帮助我改进Go软件的见解和想法。

我也不是第一个在Go中讨论或探索DDD和设计模式的人。 你应该查看以下内容以获得更全面的理解: