发布于 2026-01-06 5 阅读
0

在 Go 中使用服务对象模式

在 Go 中使用服务对象模式

本文最初发布在我的网站calhoun.io上。

注意: 本文中的大部分代码和想法都是我亲身尝试过的。这并不意味着这些想法和经验没有价值,但确实意味着你不应该盲目照搬这种模式。它有其自身的优缺点,需要根据具体情况进行权衡。话虽如此,这种模式对我来说效果非常好,而且将数据解析与应用程序逻辑分离的设计是构建支持多种格式(HTML 和 JSON API)的 Web 应用程序的关键步骤,我们将在以后的文章中探讨这一点。

我们可能都见过类似这样的 Go 语言编写的 Web 应用程序,其中包含一个处理函数:

type WidgetHandler struct {
    DB *sql.DB
    // Renders the HTML page w/ a form to create a widget
    CreateTemplate *template.Template
    // Renders a list of widgets in an HTML page
    ShowTemplate *template.Template
}

func (handler *WidgetHandler) Create(w http.ResponseWriter, r *http.Request) {
    // Most HTML based web apps will use cookies for sessions
    cookie, err := r.Cookie("remember_token")
    if err != nil {
        http.Redirect(w, r, "/login", http.StatusFound)
        return
    }
    // Hash the value since we store remember token hashes in our db
    rememberHash := hash(cookie.Value)
    // Then look up the user in the database by hashed their remember token
    var user User
    row := handler.DB.QueryRow(`SELECT id, email FROM users WHERE remember_hash=$1`, rememberHash)
    err = row.Scan(&user.ID, &user.Email)
    if err != nil {
        http.Redirect(w, r, "/login", http.StatusFound)
        return
    }

    // From here on we can assume we have a user and move on to processing
    // the request
    var widget Widget
    widget.UserID = user.ID
    err = r.ParseForm()
    // TODO: handle the error
    widget.Name = r.FormValue("name")
    // postgres specific SQL
    const insertWidgetSql = `
INSERT INTO widgets (user_id, name) 
VALUES ($1, $2) 
RETURNING id`
    err = handler.DB.QueryRow(insertWidgetSql, widget.UserID, widget.Name).Scan(&widget.ID)
    if err != nil {
        // Render the error to the user and the create page
        w.Header().Set("Content-Type", "text/html")
        handler.CreateTemplate.Execute(w, map[string]interface{}{
            "Error":  "Failed to create the widget...",
            "Widget": widget,
        })
        return
    }
  // Redirect the user to the widget
  http.Redirect(w, r, fmt.Sprintf("/widgets/%d", widget.ID), http.StatusFound)
}

具体细节可能有所不同——例如,应用程序可能使用不同的数据库,可能创建UserService接口WidgetService而不是直接编写 SQL,也可能使用像echo这样的框架/路由,但总的来说,代码大致相同。处理程序的前几行用于解析数据,然后执行我们真正想要执行的操作,最后渲染任何结果或错误。

处理程序是数据解析和渲染层。

回顾我们最初的代码,你会惊讶地发现其中竟然有那么多代码实际上只是在进行解析和渲染。整个 cookie 获取部分仅仅是为了获取一个记住令牌,或者在出现错误时重定向用户。获取令牌后,我们会进行数据库查询,但紧接着就是错误处理和渲染逻辑。然后是解析表单、获取组件名称,以及渲染创建组件过程中出现的任何错误。最后,如果新组件已创建,我们可以将用户重定向到该组件,但仔细想想,重定向本身也基本上是渲染逻辑。

总的来说,我们大约 60% 的代码只是解析数据和渲染结果/错误。

解析这些数据本身并没有错,但我感到不安的是,在解析数据之前,需求并不明确。想想看——如果我给你这个函数定义并让你测试它,你能告诉我它需要什么数据吗?

func (handler *WidgetHandler) Create(w http.ResponseWriter, r *http.Request)

您或许可以从WidgetHandler类型和函数名推断Create出这是用于创建小部件的,所以我们需要一些描述小部件的信息,但您知道这些数据应该是什么格式吗?您是否知道用户需要通过基于 cookie 的会话登录?

更糟糕的是,我们甚至无法推断出哪些部分需要WidgetHandler实例化才能使其正常工作。如果我们扫描代码,可以清楚地看到我们使用了该DB字段,而且看起来我们CreateTemplate在出现错误时会渲染该元素,因此我们需要设置该字段,但我们必须查看所有代码才能了解所有被使用的部分。

注意: 在这个例子中,我们使用的字段显而易见,但想象一下,如果我们的类型WidgetHandler用于创建、更新、发布以及对一个组件执行许多其他操作,那么它WidgetHandler就会包含更多字段,我们当然不需要为了测试这个处理程序而设置所有字段。

处理函数需要保持模糊性;实际上,如果不模糊定义传入的 HTTP 请求的格式,就无法创建一个有效的 HTTP 服务器,更无法编写代码来解析传入的数据。即使我们创建了可重用的中间件并利用其存储解析后的数据,仍然需要编写和测试这些中间件,而且这并不能解决处理函数数据需求不明确的问题。那么,我们该如何解决这个问题呢?

服务对象模式

与其抗拒在处理程序中解析数据这一事实,我发现更好的方法是接受它,并将这些处理程序完全定义为数据解析渲染层。也就是说,在我的 HTTP 处理程序中,我尽量避免任何与解析或渲染数据无关的逻辑,而是采用一种与Ruby 中的服务对象模式非常相似的模式。

注: 实际上,我甚至会尽可能地将数据渲染从处理程序中分离出来。更多实现方法请参见相关文档。

这种模式的工作原理非常简单——与其在处理程序中编写创建控件之类的逻辑,不如将这些逻辑提取到一个具有明确数据需求且易于测试的函数中。例如,在控件创建示例中,我可能会创建类似这样的函数:

func CreateWidget(db *sql.DB, userID int, name string) error {
  var widget Widget
  widget.Name = name
  widget.UserID = userID
    const insertWidgetSql = `
INSERT INTO widgets (user_id, name) 
VALUES ($1, $2) 
RETURNING id`
    err = db.QueryRow(insertWidgetSql, widget.UserID, widget.Name).Scan(&widget.ID)
    if err != nil {
    return err
  }
  return nil
}

现在我们更清楚地知道,要创建一个小部件,我们需要数据库连接、创建小部件的用户 ID 和小部件的名称。

注意: 您不必在此处设置如此具体的要求。例如,我经常会创建类似这样的函数,它们接受 `a`User和 `b`Widget作为参数,而不是更具体的 ` userIDand`name参数。您可以自行选择。

一个更有趣的例子

这个例子有点枯燥,我们来看一个更有趣的例子。假设我们要处理用户注册应用程序的情况,注册完成后,我们需要在数据库中创建用户,发送欢迎邮件,并将其添加到我们的邮件列表工具中。传统的处理程序可能如下所示:

func (handler *UserHandler) Signup(w http.ResponseWriter, r *http.Reqeust) {
  // 1. parse user data
  r.ParseForm()
  email = r.FormValue("email")
  password = r.FormValue("password")

  // 2. hash the pw and create the user, handling any errors
  hashedPw, err := handler.Hasher.Bcrypt(password)
  if err != nil {
    // ... handle this
  }
  var userID int
  err := handler.DB.QueryRow("INSERT INTO users .... RETURNING id", email, hashedPw).Scan(&userID)
  if err != nil {
    handler.SignupForm.Execute(...)
    return
  }

  // 3. Add the user to our mailing list
  err = handler.MailingService.Subscribe(email)
  if err != nil {
    // handle the error somehow
  }

  // 4. Send them a welcome email
  err = handler.Emailer.WelcomeUser(email)
  if err != nil {
    // handle the error
  }


  // 5. Finally redirect the user to their dashboard
  http.Redirect(...)
}

如您所见,我们做了大量的错误处理,在每个代码if块中,我们都可能需要渲染错误页面、将用户重定向回注册页面或其他任何操作。此外,我们最终还会用到处理程序中的很多部分——`$($($($($($($($($($($($($($($(1)))`)`、`$($($($($($($($($($($($($($(1)))`))`)`)``)`、MailingService` SignupForm$ Emailer($($($($($($($($($($($($($($($($($($($($(1))))` Hasher...

更糟糕的是,测试这些单独的组件相当麻烦。如果我们只想验证调用这个端点是否在数据库中创建了用户,我们仍然至少需要对所有其他组件进行模拟。

在这种情况下,将我们的代码拆分成几个具有明确需求且可以独立测试的服务对象非常有用。

type UserCreator struct {
  DB *sql.DB
  Hasher
  Emailer
  MailingService
}

func (uc *UserCreator) Run(email, password string) (*User, error) {
  pwHash, err := uc.Hasher.BCrypt(password)
  if err != nil {
    return nil, err
  }
  user := User{
    Email: email,
  }
  row := uc.DB.QueryRow("INSERT INTO users .... RETURNING id", email, hashedPw)
  err = row.Scan(&user.ID)
  if err != nil {
    return nil, err
  }
  err = uc.MailingService.Subscribe(email)
  if err != nil {
    // log the error
  }
  err = uc.Emailer.WelcomeUser(email)
  if err != nil {
    // log the error
  }
  return &user, nil
}

现在我们可以轻松测试用于创建用户的代码;依赖关系清晰明了,无需处理 HTTP 请求。它只是普通的 Go 代码。

此外,我们还获得了简化处理程序代码的额外好处。它不再需要处理那些只需记录信息的非致命错误,我们可以将精力集中在解析数据上。

type UserHandler struct {
  signup func(email, password string) (*User, error)
}

func (handler *UserHandler) Signup(w http.ResponseWriter, r *http.Reqeust) {
  // 1. parse user data
  r.ParseForm()
  email = r.FormValue("email")
  password = r.FormValue("password")
  user, err := handler.signup(email, password)
  if err != nil {
    // render an error
  }
  http.Redirect(...)
}

要实现这段代码,我们可以这样写:

uc := &UserCreator{...}
uh := &UserHandler{signup: uc.Run}

这样我们就可以自由地在路由器中使用uhas s 上的方法了。http.HandlerFunc

更多但更清晰的代码

这种方法显然需要编写更多代码。现在我们需要设置一个UserCreator类型,然后将其Run函数设置为signup字段UserHandler,但这样做可以清晰地分离每个函数的角色,使代码测试变得更加容易。我们甚至不再需要数据库连接来测试处理程序,而是可以使用类似这样的代码进行测试:

uh := &UserHandler{
  signup: func(email, password) (*User, error) {
    return &User{
      ID: 123,
      Email: email,
    }, nil
  }
}

同样,在测试时,我们完全UserCreator不需要使用该软件包。太棒了!🙌httptest

最后,正如我们将在后续文章中看到的(我还在撰写中,篇幅较长),这也为编写几乎不受输入/输出格式限制的应用程序打开了大门。也就是说,我们可以轻松地为现有的 Web 应用程序添加 JSON API 支持。

你喜欢这篇文章吗?加入我的邮件列表吧!

如果您喜欢这篇文章,请考虑加入我的邮件列表

我大约每周会给您发送一封电子邮件,告知您正在撰写或最近发布的新文章(例如这篇)或视频教程(例如)。绝无垃圾邮件。绝不出售您的邮箱地址。绝无任何不正当行为——我会像对待自己的邮箱一样对待您的邮箱。

为了感谢您的加入,我还会向您发送我的课程《使用 Go 进行 Web 开发》的屏幕录像和电子书示例。

文章来源:https://dev.to/joncalhoun/using-the-service-object-pattern-in-go-36o8