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

建造地鼠

建造地鼠

本文最初发布在我的网站calhoun.io上,基于我 2018 年在Gotham Go大会上发表的演讲。本文内容与演讲内容不会完全相同,但应涵盖相同的主题并传达相同的信息。

本次演讲的幻灯片可以在这里找到

什么是 Gophercises?

Gophercises是我创建的一个免费课程,它由一系列迷你练习组成,旨在帮助 Go 语言新手(Go 开发者)练习编写 Go 代码,并熟悉这门语言的各个方面。我坚信,成为一名优秀开发者的唯一途径就是编写大量的程序。当然,成为一名优秀的开发者还涉及其他因素,但如果你连基本的代码都写不出来,甚至连你正在使用的语言中一些比较简单的方面都感到困惑,那么你的能力就会受到限制。Gophercises 的目标就是通过练习来帮助你突破这个限制。

这篇文章与Gophercises课程无关。

虽然我很喜欢这门课程,而且可以滔滔不绝地讲上一整天,但这并非本文或原演讲的重点。相反,我想谈谈我们是如何把软件设计得过于复杂,最终却让我们感到懊恼的。我打算以 Gophercises 网站为例,说明我们如何编写更简洁却依然能为用户提供巨大价值的软件。

我们把软件做得太复杂了。

想想上次有人问“我应该如何设计我的xxx”——xxx一个基础的Web应用程序——是什么时候?你很可能看到过这样的回答:

  • 你应该使用JSON API和微服务架构,这样它就能很好地扩展,而且每个微服务都可以独立开发。
  • 不妨考虑使用 Docker 和 Kubernetes 进行部署。它们支持轻松扩展、多区域部署等功能。”
  • 不要使用框架或 ORM!直接使用标准库!

我们应该做什么、不应该做什么的清单没完没了,我们不断地向人们灌输他们应该做的所有事情,或者他们应该使用的所有技术。

更糟糕的是,我们往往会让人觉得这些技术是必不可少的。仿佛不用它们就意味着你是个糟糕的开发者,或者会导致开发出一个性能低下、稍有增长就会崩溃的应用程序。

我并不是说所有这些技术都不好,也不是要针对任何特定的技术,但我主要的问题在于,我们往往在真正需要之前就鼓励构建过于复杂的软件架构。简单来说,我们建议人们在构建第一个版本之前,就先构建我认为是第20个版本的应用程序,而第一个版本是用来学习和迭代的。

最终结果不难预料。如今,整整一代开发者都在实践所谓的“炒作驱动开发”。他们构建的软件更加复杂、更难维护、开发周期更长,而且由于这些额外的组件,更容易出现bug。

如果这种情况只发生在经验丰富的开发人员负责的项目中,他们能够做出明智的决策并权衡利弊,那我或许可以袖手旁观,什么也不说。同样,如果有人因为熟悉某种技术而选择使用它,那也无可厚非。真正让我感到困扰的是,当我们开始向那些对此一无所知的开发人员提供这种建议时;这些开发人员或许并非新手,但他们根本不理解选择使用微服务、单页应用(SPA)以及我们推荐的其他任何技术时,他们所承担的额外复杂性。

在这篇文章中,我希望证明,即使不做所有“应该”做的事情,你也能构建出实用且可扩展的软件。为了阐明这一点,我将讨论我在构建 Gophercises 时所做的选择、做出这些选择的原因以及它们如何影响了我的代码。

内容管理

我们先从我的内容管理系统(CMS)说起。或者用其他人的话来说——我缺乏一个合适的内容管理系统。

从长远来看,我预计我的 Go 课程需要一个更复杂的后端。目前我已经发布了两门课程,正在开发第三门。如果每门课程都使用自定义后端,最终可能会变得难以扩展,而且非常麻烦。用户需要在每个课程页面上创建帐户,这会很烦人;对我来说,维护所有这些极其相似的应用程序也很麻烦。简而言之,我可能需要为所有课程构建一个统一的后端。

当我搭建起统一的后端时,一个更复杂的内容管理系统(CMS)就势在必行了。我需要一种方法来添加新课程、更新视频、标记课程是付费还是免费,以及其他数不清的功能,但短期内完全不需要。短期内我只需要一种方法将新的练习推送到 Gophercises,而且只有我一个人负责这项工作。我没有其他非开发人员或任何人来管理这些内容,所以为什么要大费周章地搭建一个 CMS 呢?为什么不直接编写一些代码,将这些信息导入到我的数据库中呢?

app.Exercise{
  Number:      1,
  Title:       "Quiz Game",
  Link:        "/quiz",
  Description: "Create a program to run timed quizzes via ...",
  Topics:      []string{"strings", "channels", "goroutines", "flags"},
  Duration:    36*time.Minute + 52*time.Second,
  Videos: []app.Video{
    app.Video{Name: "Overview", VimeoID: "..."},
    app.Video{Name: "Solution - Part 1", VimeoID: "..."},
    app.Video{Name: "Solution - Part 2", VimeoID: "..."},
  },
  Solutions: []app.Solution{
    app.Solution{Name: "Part 1", Branch: "solution-p1"},
    app.Solution{Name: "Part 2", Branch: "solution-p2"},
  },
},

我的应用程序中的实际代码包含这些练习的一部分,这些练习会在每次部署时用于初始化数据库。

这段代码不仅编写起来极其简洁快速,而且还能通过编译器验证数据的大部分有效性。当然,我们可以争论一整天,说这并非内容管理系统(CMS),从技术上讲,你的说法没错,但CMS的目标正是提供一种将新数据导入应用程序的方法,而这段代码恰好解决了这个问题。对我来说,争论其他细节无关紧要;我只关心解决实际问题。

Gophercises 没有传统的身份验证系统

再次强调,如果我的所有课程都使用同一个统一后端,我就需要一个真正的身份验证系统。用户需要登录、修改密码、查看他们有权访问的课程、添加支付方式以及调整其他各种细节。

设想未来几个版本之后的发展方向很容易,也很容易以此为由现在就开发这些功能,但短期内我希望尽快发布。这样我就可以开始收集用户对课程和课程网站的反馈,从而了解哪些方面做得好,网站有哪些不足。我真正需要的只是一个邮箱地址,作为课程资料的交换。有了这个邮箱地址,我就可以向用户发送课程登录链接,以及课程更新和其他我认为他们可能感兴趣的课程或文章(比如这篇)。

为了实现这个功能,我构建了一个类似于密码重置流程的系统。首先,用户在表单中输入他们的电子邮件地址。之后,我在后台进行一些操作来创建账户并完成其他必要的步骤,最后向他们发送一封包含“登录”链接的电子邮件。当用户点击此链接时,我假定他们是该电子邮件地址的所有者,并为其登录,这与密码重置流程的工作方式类似。之后,我会像标准会话一样,让用户保持登录状态几周。如果用户丢失了包含登录链接的电子邮件,他们只需重新输入电子邮件地址即可获得新的帐户。

有些重置流程不会让您登录,而是允许您使用令牌更改密码,然后使用新密码登录,因此从总体上看它们大致相同,但在细节上有一些细微的差别。

现在,这种身份验证系统确实收到了一些抱怨,我首先承认,它可能比传统的用户名/密码登录表单更令人恼火,但它也有一些很多人常常忽略的优点:

  1. 这种方式搭建起来快得多,而且如果以后要搭建真正的身份验证系统,大部分代码都可以复用。这意味着我可以更快地开始制作练习视频,也能比我事先花时间搭建身份验证系统更快地收集课程和课程网站的反馈。
  2. 使用这种方法,用户不必担心又有网站因为安全措施不完善而泄露密码。我并不是说我的身份验证系统做得一塌糊涂,而是说用户无法真正验证我是否清楚自己在做什么,或者我是否偷工减料。所以,当你用在其他地方用过的密码注册时,你基本上是在冒险,完全信任开发者。我的注册流程则不需要这么大的信任(你只需要提供一个邮箱地址)。

总而言之,这套系统并不完美,但它让我快速上手,满足了我的基本需求。与其考虑未来版本可能需要的功能,我觉得目前这样就足够了,以后可以再进行改进。

部署

这可能是第一个不讨论我如何删减功能,而是选择使用更简单的技术而不是行业标准的章节。

虽然现在部署应用程序似乎只能依靠 Docker、Kubernetes 等工具,但实际上我们可以用更简单的方式部署 Go 应用程序。我选择了一种我熟悉且行之有效的方法:在本地构建,将二进制文件上传到服务器,最后ssh登录服务器并重启 systemd 服务。以下是我的部署流程(实际代码)的简要概述。

# 1. Build the app
$ mage prod

# 2. Upload the binary to the production server
$ rsync -azP prod root@gophercises.com:/path/to/app/prod

# 3. Stop the service on the server
$ ssh root@gophercises.com "sudo service gophercises.com stop"

# 4. Reseed the database with exercise data
$ ssh root@gophercises.com "/path/to/app/prod seed --db /path/to/db"

# 5. Restart the application on the server
$ ssh root@gophercises.com "sudo service gophercises.com restart"

没有微服务——Gophercises 托管在单个 Digital Ocean droplet 上(一个 5 美元的 droplet!)。

这里没有负载均衡、自动扩缩容、Docker,或者其他任何你可能想到的功能。我只是依靠 systemd 来维持服务的运行,而这对我来说非常有效。

作为参考,Gophercises 目前拥有约 1 万用户,因此我怀疑只需使用更强大的 Web 服务器,这种方法就能很好地扩展。

这显然不适用于所有人,但我再次强调,我选择技术是基于我的需求和经验,对我来说,这是最简单的方法,即使它不是行业标准,也可能不被认可。

服务资产

如果你仔细观看了上一张幻灯片,你可能会想:“如何提供图像、CSS 文件以及其他可能未包含在二进制文件中的可变资源?”

简而言之——我不会提供任何未包含在我的二进制文件中的内容。相反,我使用packr(Buffalo“框架”(或如 Mark 所称的“Web 生态系统”)中的一个库)将所有这些资源嵌入到我的二进制文件中。以下是实现此功能的代码的简要预览。

// Assets are compiled into the binary with gobuffalo/packr
images := packr.NewBox("../assets/img")


// Assets can be accessed via the Bytes method
imageBytes := images.Bytes(imagePath)


// Packr boxes can also be used as an http.FileSystem and then
// served via the http.FileServer handler
mux.Handle("/img/",
  http.StripPrefix("/img", http.FileServer(images)))

第一段代码创建了一个新的 Packr 盒子。这大致相当于文件系统中的一个目录,但可以集成到你的二进制文件中。

第二段代码演示了如何访问 Packr 镜像中特定文件的字节。这与从本地文件系统访问文件非常相似,但所有操作都是在内存中完成的。

第三段也是最后一段代码示例演示了如何将 Packr Box 用作 Webhttp.FileSystem服务器,从而可以像在本地文件系统中托管目录一样轻松地在 Web 服务器上托管 Packr Box 中的资源。在我看来,这正是 Packr Box 简洁易用的优势所在。

打包的优缺点

这种方法显然存在一些巨大的缺点。例如,我的应用占用内存更大,构建速度更慢(因为需要将资源添加到二进制文件中),其性能可能不如 CDN,而且我可能还遗漏了一些其他缺点。

尽管存在上述种种缺点,但使用 packr 的一些非常明显的优势最终促使我决定将其用于此应用程序:

  1. 如果我拥有正确的二进制版本,就能保证拥有该版本所需的正确资源。我无需担心因服务器端二进制文件版本过旧而缺少正确资源导致的各种奇怪 bug。
  2. 上传和发布只需要处理单个文件,使得部署变得非常简单。

其他工具——Mage、Cobra 和 BoltDB

我还使用了一些其他工具,它们非常符合我的需求,简化了构建和部署过程。

建造法师

我之前提到过,将资源构建到我的二进制文件中速度很慢,所以我希望能找到一种方法来改善这种体验。我没有放弃 packr,而是选择寻找任何快速的解决方案,最终找到了mage

Mage 是一个构建工具,你可以用 Go 语言编写所有的构建命令。虽然你们中的许多人可能知道如何创建 Makefile 并且喜欢使用它们,但我自己编写 Makefile 的经验并不多。另一方面,我确实有很多编写 Go 代码的经验;我每天都用 Go 写代码!

Mage 让我能够用我熟悉的语言编写构建脚本,而且操作非常简单轻便。这促使我创建了两个构建流程——一个用于构建,一个用于开发。

# Mage is used for multiple build targets
$ mage
Targets:
  dev    Builds the development binary that reads assets from disk
  prod   Builds the production binary with embedded assets

第一个选项dev会为我的本地操作系统构建二进制文件,并指定 packr 从磁盘读取资源文件,而不是将它们打包到二进制文件中。这些选项我在开发过程中 99.99% 的情况下都会使用,因此将其设为默认值完全合理。

第二次构建——prod构建生产环境二进制文件。这意味着要针对我的生产服务器操作系统(Ubuntu Linux)进行构建,并将所有资源打包到二进制文件中。使用此命令的构建需要更多时间,但仍然相对较快,并且无需记住所有正确的生产环境设置。

Cobra 子命令

在之前的一些代码片段中,我展示了类似这样的代码行:

# Cobra allows me to build one binary with many subcommands
$ ./app server --db /path/to/db # starts the server
$ ./app seed --db /path/to/db   # seeds the database

Cobra 让向我的二进制文件添加子命令和标志变得极其简单。这一点至关重要,因为我不想意外地运行一个版本二进制文件的种子命令,然后又用另一个版本运行服务器。通过将所有这些打包到一个二进制文件中,我再次避免了版本不匹配造成的任何混淆或潜在问题。

BoltDB 数据库

我需要一种方法来跟踪注册用户、练习元数据以及其他任何通常存储在数据库中的信息。虽然我熟悉 PostgreSQL,而且它也是一个不错的选择,但我还是决定尝试使用 BoltDB for Gophercises。

选择 BoltDB 有几个原因。首先,BoltDB 非常符合我的使用习惯。BoltDB 非常适合读取密集型、写入稀少的场景,而 Gophercises 只有在用户注册时才会进行少量写入操作。除此之外,99% 的数据库操作都是读取操作。

BoltDB 也是用纯 Go 语言编写的。通常我不会仅仅因为某项技术是用某种语言编写的就对其抱有偏见,但这次的情况不同,这意味着我可以构建一个包含 BoltDB 逻辑的二进制文件。也就是说,我无需担心在服务器上安装数据库的问题。只要导入 BoltDB 包,所有逻辑都已集成到我的二进制文件中,这再次简化了服务器设置和部署等操作。

事实上,有了 BoltDB、packr 和 Cobra,我基本上只需要一个文件,就可以交给任何拥有 Linux 服务器的人来启动 Gophercises 的本地副本。他们使用的数据库文件与我使用的并不相同,因此不会拥有所有相同的用户数据,但他们仍然可以获得所有预设的练习和其他相关数据。

我的构建和部署流程以我的需求为中心。

在继续之前,我想先明确一点:我并不是建议你们完全照搬我这里的配置。对大多数人来说,这样做并不合适,因为你们的需求与我的需求并不相同。

我解释构建和部署流程的目的是为了证明:

  1. 你并不需要 Docker、Kubernetes 等工具,
  2. 您可以根据自己的熟悉程度和当前需求来选择使用哪些工具,即使这些选择可能不太寻常。

渲染 HTML 页面

虽然现在这可能看起来不是一个流行的选择,但 Gophercises 不使用 JavaScript 前端,而是使用标准库的html/template包在服务器端渲染所有 HTML。

从长远来看,采用带有 API 的 JavaScript 前端可能是一个不错的选择。例如,我可能有一个非常复杂的 UI,需要借助 JS 框架;移动应用也使用相同的 API;此外,还有许多其他原因促使我考虑采用 JSON API + JS 前端的设计方案。

短期来看,这样做并不合理。目前版本的 Gophercises 仅由几个独立的页面组成;没有用于创建新练习的自定义表单的管理门户。视频托管在 Vimeo 上,因此即使是这部分应用也相当简单。我最初考虑使用 JavaScript 前端的唯一原因是它能很好地严格分离前后端逻辑,但即使不使用 JavaScript 前端也能轻松实现这一点,而且我编写 Go 代码的经验远比 JavaScript 丰富。因此,我选择在 Go 服务器端完成所有 HTML 渲染,并使用以下组织技巧将视图相关的逻辑与后端逻辑隔离:

  • 更小的、组件式的模板
  • 装饰图案
  • 服务对象

附注:如果您熟悉 Ruby 开发,很可能听说过装饰器模式和服务对象。这两者在 Go 语言中非常相似,但并不完全相同。

更小的、组件式的模板

这里的基本思路并不独特或新颖;与其为页面创建一个大型模板,不如像在 React 等框架中那样,为各个组件创建模板。以下是一个示例。

{{define "exerciseWidget"}}
<div class="panel widget widget-exercise">
  <!-- ... some code omitted for brevity -->
  <div class="col-xs-12">
    <p class="mb0">Exercise {{.Number}}</p>
    <h4 class="m0">{{.Title}}</h4>
  </div>
  <!-- ... -->
  <div class="col-xs-4 text-right text-top">
    <p class="mb0">Length</p>
    <p class="m0 h4 length">{{.Duration}}</p>
  </div>
  <!-- ... -->
</div>
{{end}}

通过将模板拆分成小的组件,您可以轻松地在不同的页面中重复使用它们,并且管理每个组件也变得更加容易。

装饰图案

装饰器模式最好用例子来解释。我们先从一个简化的类型示例开始Exercise

package app

type Exercise struct {
  Duration  time.Duration
  // + other fields
}

在我们的Exercise类型中,有一个Duration字段的类型为 `int` time.Duration。实际上,它本质上是一个整数,用于存储以纳秒为单位的持续时间。显然,这对最终用户来说没什么用。没人想看到“这个练习需要 1000000000 纳秒”。你很难判断自己是否有足够的时间观看一段视频,但如果使用像“10 分钟”这样的时间单位,就容易多了。

现在我们不想将此逻辑附加到我们的 UI 中app.Exercise,因为这是 UI 特有的逻辑,但我们需要将其放在某个地方,那么我们该怎么办呢?

另一种方法是在模板中添加函数,但这同样不是理想的解决方案。在模板中添加逻辑会导致代码难以维护和测试。

我没有选择上述任何一种方法,而是创建了一个名为 的新包html,在该包中创建了一个新Exercise类型,然后将 嵌入app.Exercise到新类型中。

package html

type Exercise struct {
  app.Exercise
}

从现在开始,每当我想要将一个元素传递app.Exercise到 HTML 模板中时,我都会创建一个元素html.Exercise并嵌入原始练习,然后将其传递到模板中。

var orig app.Exercise
forTemplate := html.Execise{
  Exercise: orig,
}
tpl.Execute(w, forTemplate)

这段代码的工作方式与旧代码几乎完全相同,但它允许我们通过添加同名的方法来“覆盖”(暂且这么说)字段。

package html

type Exercise struct {
  app.Exercise
}

func (e Exercise) Duration() string {
  if e.Exercise.Duration < 0 {
    return "TBD"
  }
  // returns a string like “1:22:03”
  return fmt.Sprintf("%d:%02d:%02d", e.Hours(),
    e.Minutes(), e.Seconds())
}

在大多数 Go 代码中,这会引发问题,因为我们需要区分调用函数和用括号引用函数。也就是说,在下面的例子中,最后两行代码并不相同。

var e html.Exercise
e.Duration // get the function as a value, but don't call it
e.Duration() // actually call the function

幸运的是,HTML模板并非如此,这意味着我们模板中以前引用该Duration字段的任何位置现在都会调用新Duration方法。

{{define "exerciseWidget"}}
<div class="panel widget widget-exercise">
  <!-- ... some code omitted for brevity -->
    <p class="m0 h4 length">{{.Duration}}</p>
  <!-- ... -->
</div>
{{end}}

现在我们有了一种方法,可以在不污染模板的情况下隔离特定于视图的逻辑,这基本上就是装饰器模式。

服务对象

最后需要解决的是HTTP处理程序和服务对象。

type UserCreator struct {
  userService *db.UserService
  mgClient    *mailgun.Client
}

func (uc *UserCreator) Create(email string) (string, error) {
  token, err := uc.userService.Create(email)
  if err != nil {
    return "", err
  }
  err = uc.mgClient.Welcome(email, token)
  return token, err
}

所以我不会详细说明它们是什么,但使用这种模式的主要好处是:

  • 代码易于测试,数据需求清晰明确。
  • HTTP 处理程序之所以简化,是因为它们通常只调用一到两个服务对象方法。
  • 将来迁移到 JSON API 非常简单,只需重用服务对象即可。

通过应用所有这些技术,我能够在不使用 JS 前端的情况下,隔离所有特定于视图的逻辑。

Gophercises 没有测试

这可能是我在构建 Gophercises 时做出的最具争议性的决定;我选择不编写任何测试。

首先我要声明,我非常推崇测试。我目前正在开发一门教授 Go 语言测试的课程,我绝不会提倡放弃测试。尽管如此,我仍然觉得不测试这个应用程序是没有意义的。

为什么?

  1. Gophercises 非常简单,所以测试起来既快捷又容易。
  2. 我不会频繁地对 Gophericses 进行修改。虽然每次发布新练习时我都会发布新版本的应用程序,但这些版本更新通常不会涉及任何逻辑或功能上的更改。
  3. 本网站的当前版本旨在让用户深入了解课程网站哪些功能有效,哪些功能无效。

前两点相辅相成。如果一个应用易于测试且不经常更新,这意味着我们实际花费在手动测试上的时间可能比编写自动化测试的时间更少。但第三个原因也至关重要,因为它表明我将来无需再为这个应用编写测试。相反,我可能会舍弃大部分代码,并利用我所学到的知识来构建一个包含测试的全新改进型课程应用。

记住,编写一些可以暂时丢弃的代码来学习是完全可以的。并非每个人都能第一次就写出完美的代码,也并非所有代码都能在不重构的情况下轻松测试。有时候,最好的方法就是接受代码无法通过测试的事实,并将其作为学习实验。

要点总结

本文旨在传达的主要信息是,您可以使用 Go 构建更简单的软件,而不会被所有“应该”做的事情所困扰

尽管我知道我应该为 Gophercises 编写测试题,但我并没有这样做。我仔细权衡了这个决定的利弊,最终认为收益不足以弥补成本。

另一个例子是来自 Rails、Django 或 Laravel 背景的开发者转而使用 Go;对于这些开发者来说,框架可能是学习和熟悉 Go 的理想选择,尽管大多数人并不提倡使用框架。我并不是说他们应该永远使用框架——有很多充分的理由可以考虑不使用框架——但如果框架能够为开发者提供更多共同的基础,并帮助他们更快地学习和掌握 Go,那么就不应该忽视或排斥它作为一种学习工具。

别再纠结别人说你应该做什么。别因为大家都吹捧就非得用最新最炫的技术。记住,用 Go 语言也能写出更简单的软件,而“简单”的定义会因开发者的经验而有所不同。

想看看 Gophericses 吗?

如果您想了解本文中提到的课程,可以访问以下链接(免费):Gophercises.com

在本课程中,你将学习编写 Go 代码的各种技巧,但更重要的是,你将获得大量的实践机会,这将帮助你成为 Go 软件编写专家!

哦,对了,为了庆祝 Gophercises 系列第 20 个也是最后一个练习的发布,我的高级课程《Go 语言 Web 开发》正在进行促销。促销活动将于 6 月 4 日结束。在本课程中,你将学习如何从零开始用 Go 语言构建一个真正的、可用于生产环境的 Web 应用程序,例如从头开始构建一个完整的身份验证系统。

文章来源:https://dev.to/joncalhoun/building-gophercises-2ok9