go语言过渡到DDD(三)

247 阅读7分钟

这是我参与2022首次更文挑战的第21天,活动详情查看:2022首次更文挑战

本文为译文,原文链接:www.calhoun.io/moving-towa…

接上文,我们继续之前那个案例的讲解。

发现一个折中的立场

在前面的例子中,我们最终看到代码从我认为有点耦合到完全解耦的转变。我们使用了adapter包来实现,这个包处理了这些解耦的mwgithub/gitlab包的转换工作。

最主要是好处是我们看到了最后面的-我们现在可以决定当建立我们的handlers处理器时,是使用一个Github还是Gitlab的鉴权策略,并且我们的鉴权中间件完全对我们的选择不可知。

尽管这些收益都是非常哇塞的,在不考虑成本的情况下探讨这些好处是不公平的。所有的这些修改带来越来越多的代码,并且如果你看到原始版本的githubmw包时,它们明显比最终的版本要简单于需要组装adapter包的。这个最终的设置也会导致更多的设置,因为我们需要在代码中的某个地方来实例化所有的这些adapter并且把所有东西接入到一起。

如果我们继续顺着这条路,我们可能很快会发现我们还需要很多不同的User类型。例如,我们可能需要在一些像Github或者Gitlab中,使用外部的用户ID关联一个内部用户类型。这会导致在我们的数据库包中定义了一个ExternalUser,然后写一个adapter来转换成github.User到这个类型中,这样,我们的数据库代码对我们使用的是哪个service是不可知的。

我实际上尝试用我的HTTP处理程序在一个项目上这样做,只是想看看结果如何。具体来说,我将我的web应用程序中的每一个端点隔离到它自己的包中,没有特定于我的web应用程序的外部依赖,并以这样的包结束:

// 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 handlers而不需要担心我的应用程序中的其他地方。每一个包都可以很容易测试,并且当我在写这些独立的代码时,我发现自己非常高效。我甚至有一个接口叫做Executor,谁不想要在他们的代码中有一个执行器executor。

在实践中,这个想法对于我的特定用例来说是糟糕的。是的,有好处,但是并不比写这些代码的代价重要。当我创建enroll的内部和相似的包时,我是高效的,但是我花费了太多时间来写这些适配器并且把它们连接到一起,这从整体上破坏了我的工作效率。我无法找到一个快速的方式在不需要写一个自定义的UserFnLicenseFn来接入我的代码,然后我发现自己为每个带有http处理程序的包编写了一堆几乎相同的UserFn变体。

这引出了这一节的主题-就是有没有一种方式来想出一个合理的中间立场?

我喜欢从第三方依赖来解耦我的代码。我喜欢写可测试的代码。但我不喜欢为了实现这一点而加倍编码工作。当然肯定会有一种中间立场来给与我们更多好处而不需要所有那些额外的代码,对吗?

是的,有一个中间立场,并且关键是找到它并不是删除所有的耦合,而是有意地挑选并选择你的代码耦合到哪里。

让我们回到我们的原始的githubgitlab包的例子。在我们第一个版本中--轻微耦合的版本--我们有一个github.User类型,我们的mw包依赖了它。它很容易启动,我们甚至可以围绕它来建接口,但是我们仍然优先耦合到github包了。

在我们的第二个版本--耦合的版本--我们有一个github.Usergitlab.Usermw.User。这允许我们把所有事情解耦,但是我们不得不创建很多适配器来把这些代码一起解耦。

中间立场,然后第三个版本我们将会探索的是,有意地定义一个User类型来让每一个包都允许轻微耦合它。通过这些,我们有意选择耦合发生的地方并且从解耦的代码中让测试,交换实现,并且任何我们希望做的事情也变得简单。

首先就是我们的User类型。这将会被创建在一个domain包下,这个包是我们程序中的任何其他包都可以导入的。

package domain

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

接着我们会重写githubgitlab包来充分利用domain.User类型。它们基本上是一样的,因为我把所有的逻辑都拼出来了,所以我只会展示一个。

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的包。

package mw

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

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

我们甚至可以写一个mock包来使用这个设置。

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的入门。