Go 项目结构:如何在 Go 项目中使用 MVC ?

Go

什么是 MVC?

在你学习编程时,你最终将开始注意到,有些代码库易于浏览,而有些则令人感到困惑,难以维护和更新。 开始的时候,很难说出是什么造成了这种差异,但是随着时间的推移,你将会很快发现最大的因素就是代码组织和结构的良好程度。

这样做的原因很简单 - 如果你需要进入程序为其添加新功能,修复漏洞或者对程序进行其他任何操作, 能够猜出代码中需要查找的位置将会使这些工作变得非常简单。 当你根本不知道从哪里开始时,就只能浏览数百个源文件,但是在代码结构良好的程序中,即使你过去从未阅读过源代码,也经常可以猜出代码的位置。

Model-View-Controller,通常称为MVC,是一种用于帮助组织和结构化代码的模式。具体来说,大多数代码将被归类为模型,视图或控制器。

视图

视图负责渲染数据。就是这样。给定我们想要渲染的特定页面以及该页面的数据,我们的视图负责生成正确的输出。如果我们使用 MVC 来实现应用程序,则通常是服务器渲染的 HTML,我们希望将其返回到最终用户的浏览器,但不一定必须如此。我们的视图可以同样轻松地处理 XML、JSON 或其他数据类型的渲染。

需要记住的重要一点是,视图中的代码应该尽可能少地执行逻辑;相反,它应该完全专注于显示数据。如果逻辑开始悄悄进入您的视图,您很可能做错了什么,而且以后可能会出现问题。

在我的应用程序中,我希望在所有模板中都有一些全局辅助函数可用,这些模板不是由 html/template 软件包提供的。例如,在导航栏中呈现当前用户的头像或电子邮件地址是很常见的,但是在每个单独的模板渲染中提供这些数据可能很乏味。或者,我可能需要辅助器来通过 Go 结构使用类似于我的 Form) 包来渲染 HTML 表单。这些额外功能都不是 view 软件包所要求的,但在考虑我的视图提供了什么时,我发现它们很有帮助。

控制器

Air traffic controllers are the people that inform each plane at an airport where to fly, when to land, and on which runway to land. They don’t actually do any piloting, but instead are in charge of telling everyone what to do so that an airport can operate smoothly.
空中交通管制员是通知机场每架飞机在哪里飞行、何时降落以及降落在哪条跑道上的人员。他们实际上不做任何驾驶,而是负责告诉每个人应该做什么,以便机场能够平稳运行。

MVC 中的控制器与空中交通管制员非常相似。它们不会直接负责写入数据库或创建 HTML 响应,而是会将传入的数据定向到适当的模型,视图和其他可用于完成请求工作的程序包。

与视图类似,控制器不应包含太多业务逻辑。相反,它们应该只解析数据并将其交付给其他函数,类型等进行处理。

模型

模型是 MVC 的最后一部分,它们的主要职责是与应用程序的数据交互。这通常意味着与您的数据库通信,但也可能意味着与来自其他服务或 API 的数据交互。它还可能意味着验证或规范化数据。

举个简单的例子 —— 大多数 Web 应用程序都会有一些用户的概念,他们会以某种方式注册 Web 应用程序并与之交互。在 MVC 应用程序中,我们可能会在 models 包中创建一个新的 type User struct {...} 来表示存储在数据库中的用户。然后,我们可以创建一些代码来帮助我们验证用户是否有效,还可以创建一些额外的代码来帮助我们在数据库中创建、更新和查询用户。

我想在这里指出的一个重要区别是,我认为应该使用 models 这个包包含数据库交互。在深入研究 MVC 的一些潜在问题时,我们将了解不这样做会如何导致一些潜在问题。

MVC可以通过多种方式实现

在谈论 MVC 时,请务必注意,MVC 没有单一的特定实现。这是一种通用的架构模式,而不是我们必须遵循的一组特定规则。因此,使用 MVC 的两个开发人员最终可能会得到非常不同的结构。

部分原因是我们确实需要考虑应用程序结构的两个方面:

  1. 如何将代码组织到 types/funcs (类型/函数) 中。
  2. 如何打包这些 types/funcs。

虽然 MVC 明确地为我们提供了有关如何将代码组织为类型/函数的指南,但并不一定要求我们将代码打包到 modelsviewscontrollers 目录中。因此,我将考虑以下两个示例都使用MVC。

示例1:根据代码层打包我们的代码

app/
  models/
    user.go
    course.go
  controllers/
    user.go
    course.go
  views/
    user.go
    course.go

在第一个示例中,我们基于其 「层」将代码组织到包中 —— 换句话说,我们将所有视图打包在一起,将所有控制器打包在一起,依此类推。到目前为止,这是使用 MVC 时最常见的布局。

示例2:基于资源打包我们的代码

app/
  user/
    controller.go
    model.go
    store.go
    view.go
  course/
    controller.go
    model.go
    store.go
    view.go

在第二个示例中,我们选择基于资源打包我们的代码,然后在每个包的内部拥有模型,视图和控制器。对于 MVC 应用程序,这种情况不太常见,但我仍然认为它是 MVC。

注:在本例中,我认为 model.gostore.go 都是 mvc 的 「模型」部分。

按照同样的推理,可以同时使用 扁平结构 和 MVC。

人们为什么喜欢MVC?

人们不喜欢在单个目录/程序包中看到 30 多个源文件。无论文件是否以逻辑方式分开,它都显得混乱。我不确定为什么会这样,但是开发人员只是想开始添加子目录并「组织」他们的代码。

如果不深入了解您正在构建的内容,将应用程序拆分成逻辑包可能是一项挑战。虽然 MVC 本身并没有为这个问题提供具体的解决方案,但是 MVC 确实为我们提供了一组初始的层。如果我们选择基于这些层 (请参见上面的 EX1) 将代码组织到包中,我们可以从 modelviewsController 目录开始,而无需真正深入了解我们正在构建的内容。换句话说,它允许我们立即选择一个结构,而不是等待一个结构演变,许多开发人员不喜欢等待。

MVC 也很有吸引力,因为它很熟悉。更改通常是令人困惑的,所以当您从 Django、Rails、ASP.NET 和无数其他使用 MVC 的语言/框架开始时,潜心研究一些熟悉的东西是很舒服的。我们已经了解了代码是如何组织的,所以用我们的 Go 应用程序做一些类似的事情可以让我们少做一个决定,而且 决策疲劳 是一件真实的事情。

最后,MVC 是从扁平结构的应用程序非常自然地演变而来的,在这种应用程序中,我们通常会将代码拆分成数据库、处理程序和渲染层。这三个都很好地映射到模型、控制器和视图。稍后对此进行更多讨论。

有效使用 MVC

与你在 Reddit 上看到的相反,MVC 在 Go 中可以非常有效地使用。像Buffalo 这样的框架利用了 MVC 结构,我已经成功地使用 MVC 模式构建了多个应用程序。

并非所有的东西都是模型、视图或控制器

在 Go 中使用 MVC 的第一个关键是要记住,并不是所有东西都必须是模型、视图或控制器。是的,这些分类是很好的起点,有助于最大限度地减少您的决策,但是您的应用程序的许多部分将不会落入这些类别中的任何一个,这是可以接受的。

例如,在我的课程 使用 Go 进行 Web 开发 中,我们使用了 MVC 模式,因为我发现它是一个非常有用的学习工具 (稍后将详细介绍),但我们很快就会开始引入额外的软件包。一个例子是与处理 HTTP 请求相关的 middleware,但并不真正适合 controller 软件包。发生这种情况时,可以在代码库中创建其他包。

不要拆的太散

使用 MVC 的第二个关键是避免无缘无故地把事情分解得太散。我见过一些人试图用这种方式实现 MVC:

app/
  models/
    lesson/
      models.go
      store.go
    course/
      models.go
      store.go
  # and so on...

这种结构的问题在于模型往往是关系型的;也就是说 Course(课程)可能有很多 Lesson,因此我们可能需要编写查询来返回 Course 及其所有 Lesson,在我刚刚展示的结构中,这通常需要额外的类型 (如CourseLesson),以避免循环性依赖。

我听说开发人员在这种模式下取得了成功,所以我并不是建议永远不要使用它,但我个人还没有发现它非常适合我的需要。因此,这不是我要做的。

如果您碰巧有此模式在哪里运行良好的示例,请随时将它们发送给我 --jon@calhoun.io

更一般地说,如果您发现自己具有循环依赖,则很有可能是您将事情分解得太散了,或者将类型/接口/函数放在了错误的位置。以她的演讲中的 Kat Zien's Layered example 为例,您如何构建您的 Go apps;在此示例中,代码无法编译,因为 storage 软件包和 models 软件包具有循环依赖关系。虽然这似乎指出了应用程序结构中的一个问题,但实际上我会争辩说,本例中的 modelsstorage 软件包实际上应该是一个软件包,因为它们都与存储有关。这种情况下的 models 正好是存储在所述存储中的类型的定义。

非常清楚-我绝不是批评 Kat 构建应用程序的能力。我相信她是故意创建这种循环性依赖关系的,这在我的演讲中很有用,这也为我提供了很好的例子。哦,如果您还没有的话,应该完全去看看她的演讲! Kat 在 YouTube 上的演讲

我应该如何构造我的MVC应用程序?

无论我使用的是 MVC,域驱动设计 (DDD) 还是其他设计模式,我几乎总是喜欢按代码在体系结构中所服务的层打包代码。基本上,这只是一种花哨的表达方式,我喜欢将所有数据库代码都放在一个包中,中间件放在一个包中,处理程序放在一个包中,依此类推。

在某些情况下,以另一种方式打包代码肯定是有意义的,所以不要将此理解为您应该总是按层打包。这只是适合我的方法。

在 MVC 中,这看起来与您在 MVC 应用程序中可能期望的基本相同:

app/
  models/
    # user.go has all my database logic including:
    #   User - the model definition
    #   UserStore - used to perform user-related CRUD
    #     operations on the DB
    user.go
    course.go
  controllers/
    user.go
    course.go
  views/
    template.go # used to parse gohtml templates
    show_user.gohtml
    new_user.gohtml
    # ...

唯一需要注意的是,您实际上不需要将这些文件夹命名为 modelscontrollersviews。您可以,如果以任何方式使您的开发变得更容易,我认为这完全没问题,但是您也可以稍微更具体地命名这些名称。例如,如果我们的模型都存储在 PostgreSQL 数据库中,我们的控制器都旨在处理 HTTP 请求,并且我们的视图都用于渲染 HTML,那么最终可能会得到如下所示的包:

app/
  psql/ # models
  http/ # controllers
  html/ # views

现在更加清楚的是 psql.User 代表我们的 Postgres 数据库中的用户,而 http.UserHandler 显然是与用户有关的 http 请求的处理程序。

这并不意味着你必须一直这样做。有时候我觉得一个 models 包更有意义。例如,如果我知道我的应用程序将很小,而我的一个模型将是一个存储在磁盘上的文件(例如图像),我可能会选择将我的包命名为models,并将所有数据库和本地存储代码混合匹配到一个包中。

另一方面,您可以选择将这两个组件分成不同的包,其中一个包特定于 psql,另一个命名为 localdisk 或类似的东西。

我看不出在这里批评某人的选择有什么价值,因为哪个决策最好几乎总是取决于应用程序的大小和复杂程度。对我来说更重要的是,您了解您正在进行的权衡,并接受它们。

最后值得一提的是,有时软件包名称的原因与开发/代码完全无关。例如,我更喜欢在我的 Web 开发课程中保留 models 包名称,因为这通常会让人们更容易掌握我们是如何分离代码的,从而使他们更容易选择 Go。

models/sql 包中包含什么?

模型可能是需要正确处理的最重要的包,所以我们将从它开始。为了简单起见,我们假设它只表示 SQL 数据库交互。

模型软件包应包含几乎所有与数据存储相关的代码。其中包括特定于数据库的模型,验证,规范化等。还应认识到,此程序包通常不应在应用程序中导入其他程序包。与所有规则一样,也有例外,但是我发现models包最有效,如果您可以从字面上将其拉出应用程序并在另一个应用程序(例如CLI)中使用它而无需更改任何models包中的代码。从这个意义上讲,所有模型代码最终都是孤立的,并且仅限于与数据库实体进行交互。由于您的模型包对应用程序的其余部分一无所知,因此基本上不可能引入周期性依赖关系。
model 包应该包含几乎所有与数据存储相关的代码。这包括特定于数据库的模型、验证、规范化等。同样值得注意的是,这个包通常不应该导入应用程序中的其他包。与所有规则一样,也有例外,但我发现,如果您确实可以将其从您的应用程序中拉出并在另一个应用程序 (比如CLI) 中使用它,而不更改models 包中的任何代码,则 models 包工作得最好。从这个意义上说,您的所有模型代码最终都是孤立的,并且仅限于与数据库实体交互。这将使得基本上不可能引入循环依赖关系,因为您的应用程序包对应用程序的其余部分一无所知。

以下是一些我可能希望在模型包中找到的逻辑示例:

  1. 定义 User 类型的代码,该类型存储在数据库中。
  2. 将在创建新用户时检查用户的电子邮件地址的逻辑,以确认该电子邮件地址尚未被接收。
  3. 如果您的特定 SQL 变量区分大小写,请使用代码将用户的 email 转换为小写,然后再将其插入数据库。
  4. 用于将用户插入数据库的 users 表中的代码。
  5. 检索关系数据的代码;例如,用户及其所有评论

我没期望会在一个新的 models 包中找到特定于渲染 HTML 响应的代码。例如,我不希望它返回带有 HTTP 状态代码的错误。您 可以 返回一个像 NotFound 这样的错误,但控制器应该可以自由地解释和渲染它认为合适的错误。在 HTTP 上下文中,这最终可能是一个错误的 http.StatusNotFound 状态代码,但在 CLI 上下文中,它可能完全不同。

views 包中包含什么?

这个代码往往是最少的代码,因为它通常是:

  • 围绕 html/template.Template 包的美化包装器
  • encoding/json 包的包装器 (或直接使用)

我倾向于定制的主要方式是在解析模板 之前 将一堆 template.FuncMap 函数添加到模板中 (以便正确解析),然后如果需要,我将使用特定于请求的数据覆盖它们。例如:

package html

type Template struct {
  template *template.Template
}

func ParseFiles(files ...string) (*Template, error) {
  tpl, err := template.New("").Funcs(template.FuncMap{
    "flash": func() string { return "" },
  }).ParseFiles(files...)
  if err != nil {
    return nil, fmt.Errorf("parsing view files: %w", err)
  }
  return &Template{
    template: tpl,
  }, nil
}

func (t *Template) Execute(w http.ResponseWriter, r *http.Request, data interface{}) {
  // clone the template BEFORE adding user-specifics!
  clone := t.clone()

  // Get the flash cookie
    cookie, err := r.Cookie("flash")
    switch err {
  case nil:
    // override the flash msg
    clone.template = clone.template.Funcs(template.FuncMap{
      "flash": func() string { return cookie.Value },
    })
        // Delete the flash cookie so we don't repeat it.
        expired := http.Cookie{ ... }
        http.SetCookie(w, &expired)
    default:
        // noop
    }

  err = clone.template.Execute(w, data)
    if err != nil { ... }
}

我在一篇较旧的文章 Creating the V in MVC 中谈到了一些尝试使用视图包的其他想法。我不是说在这一点上我会全部推荐它们,但是作为一项智力锻炼,它们值得一看。

controllers 软件包中包含什么?

如果您正确选择了另外两个,这个软件包最终会变得非常乏味。基本上,您只需创建 http.Handler 来解析传入的数据,调用类似于您的 models 包提供的 UserStoreCourseStore 之类的方法,然后最终通过视图渲染结果 (或者在某些情况下将用户重定向到适当的页面)。

// Take note: our UserHandler has a UserStore injected in!
// Global data stores can be problematic long term.
type UserHandler struct {
  Users    models.UserStore
  Sessions models.SessionStore
}

func (uh *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
  err := r.ParseForm()
  if err != nil { ... }

  var form struct {
    Email    string `schema:"email"`
    Password string `schema:"password"`
  }
  schema.NewDecoder().Decode(&form, r.PostForm)
  user := sql.User{
    Email: form.Email,
    Password: form.Password,
  }
  err = uh.Users.Create(&user)
  if err != nil { ... }

  token, err := uh.Session.Create(&user)
  if err != nil { ... }
  // create session cookie using token

  http.Redirect(w, r, "/dashboard", http.StatusFound)
}

您可能偶尔会发现自己正在定义自定义类型来帮助渲染视图,直到今天,我仍然不确定放置它们的最佳位置在哪里。从某种意义上说,它们是特定于视图的,因为您的视图将依赖于传入的一组非常特定的数据来正确渲染。在这种情况下,它们可能应该在 views 包 (或嵌套包) 中定义。另一方面,您的控制器 可以 以各种格式渲染数据 —— HTML,JSON 或 XML —— 因此我还可以看到一个参数,用于放置一个自定义类型,该自定义类型从 controllers 包中的每个处理程序中「返回」。

我认为这两种类型都可以工作,所以如果您确实需要自定义类型,请选择适合您的类型并与之配合使用。即使您最终需要更改此决定,重构也不会太难。

不要盲目复制其他语言

此时,我希望您开始了解如何在Go中成功实现 MVC,但在结束本文之前,我必须讨论一下您可能会尝试从使用 MVC 的其他语言中复制的一些错误。

让我们花点时间看一下模型在Ruby on Rails之类的MVC框架中的作用:

  1. 虽然模型通常映射到数据库,但还被期望满足整个应用程序的需求 (例如UI渲染)
  2. 您的应用程序可以全局访问模型和其他实体,如 current_user
  3. 模型具有基于关系数据触发其他 SQL 查询的方法

尽管可以在 Go 中实现许多的这些「功能」,但我绝不推荐。因此,让我们看看替代方案。

您将需要多次定义资源

在 Rails 中,我们可能只定义一次模型,然后期望它在整个应用程序中都能工作。我们有几种方法可以做到这一点,但主要围绕以下几个方面:

  1. 创建转换数据的方法
  2. 创建包装类型,如装饰器

虽然可以创建一个 Go Web 应用程序,在其中定义资源的唯一时机是在 models 包中,但您最终很有可能需要定义每个资源的第二个版本。例如,假设我们正在为 JSON 响应渲染一个 User —— 如果要使用用户的 models 定义,我们需要将所有这些 json struct 标签添加到模型包,即使它与那里的代码无关。我们还可能需要在其中编写自定义的编组代码,以处理以下事实:存储在数据库中的内容与我们要呈现给用户的内容不完全相同。

创建一个新类型并编写代码以在两者之间进行转换,这几乎总是更好的选择:

package json // view

type User struct { ... }
package sql // model

type User struct { ... }
package http // controller

func sqlUserToJson(user *sql.User) json.User { ... }

是的,代码更多了,但您 (希望) 没有选择 Go,因为它是您可用的最不冗长的语言。

Globals 是个坏主意

在像 Ruby 或 Python 这样的语言中,您的服务器很可能一次只能处理一个 Web 请求。这不是最快的处理方式,但是它确实使开发变得更加容易,因为您可以创建诸如 current_user 之类的全局函数,并且知道它们仅与当前请求有关。

从这个意义上讲,Go 与 Ruby 或 Python 完全不同。当 Web 请求进入您的 Go 服务器时,标准库将启动一个新的 goroutine 来处理它,然后开始将请求传递给您的处理程序。从开发人员的角度来看,这非常方便,因为我们无需做任何事情即可使我们的 Web 服务器并发工作,但这也意味着像添加 current_user 之类的全局函数将导致各种竞争状况。

即使是不容易出错的全局数据库连接也是不明智的。尽管您可能没有争用条件,但全局数据库将允许在应用程序中的任何位置执行 SQL 查询。相反,如果只是将数据存储注入到其余代码中,那么跟踪查询的来源就容易得多,测试也更容易,你的生活通常也会好得多。

func main() {
  userStore := sql.NewUserStore(...)
  // Inject the userStore where it is needed!
  userController := http.NewUserHandler(userStore)

  // ... setup the rest of your app
}

不要嵌入数据库连接以实现关系查询

虽然可以像在动态语言中一样使关系查询在 Go 中工作,但我不建议这样做。

package sql

type OrderStore struct {
  db *sql.DB
}

func (os *OrderStore) ByUserID(id int) ([]Order, error) {
  // ...
}

type User struct {
  ID int
  // ...

  // Don't do this
  db *sql.DB
}

func (u *User) Orders() ([]Order, error) {
  os := OrderStore{u.db}
  return os.ByUserID(u.ID)
}

原因与我们避免全局数据库连接的原因基本相同;它可能导致各种隐藏的复杂性。我在我的文章 ORM 的微妙问题以及如何避免它们 中对此做了更多的描述,但主要的结论是,在可能的情况下,SQL 查询实际上可以从应用程序中的任何地方触发,而不仅仅是在您注入 OrderStore 的地方触发,因此这会使管理您的应用程序变得非常困难。

综上所述…

我们在本文的最后一部分中讨论了不要做的事情,但实际上,我希望您从本文中学到的是之前的所有内容。我想让您了解,MVC 可能并且确实已在 Go 中工作。 MVC 与其说是关于包的命名,不如说是关于您如何组织代码。如果您牢记这一点,我想您在下一个 Web 应用程序中使用 MVC 不会有任何问题。

本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://www.calhoun.io/using-mvc-to-stru...

译文地址:https://learnku.com/go/t/48112

本文为协同翻译文章,如您发现瑕疵请点击「改进」按钮提交优化建议
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!