[AI 翻译] How I write HTTP services in Go after 13 years

status
Published
type
Post
slug
chinese-translation-how-i-write-http-services-in-go-after-13-years
date
Oct 1, 2024
tags
Share
Web
Go
summary
文章是一篇翻译,主要介绍了原作者在多年 Golang 开发的经验,主要以 HTTP 标准库来讲解服务的实践经验,文中提到了更推荐阅读原文:https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/

推荐查看原文!!!

💡
这是之前读到的一篇 Go 语言的最佳实践类文章,作者有着十余年的 Go 编程经验,经验丰富。文章仅围绕 Golang 标准库中的 http 展开,不涉及其他第三方 Web 框架或库。
在此通过 AI 粗浅翻译一下,既加深印象,也留档方便回顾。略去了其中一些东西, 文章内容需要有一定的 Golang 语言基础,可以通过 https://studygolang.gitbook.io/learn-go-with-tests 来学习,这也是原文中所推荐的。

NewServer 构造函数

我们先来看看所有 Go 服务的核心:serverNewServer 函数创建了 http.Handler。通常每个服务我都会创建一个,并且依赖 HTTP 路由将流量分发到每个服务中的具体 handler,因为:
  • NewServer 是一个大型构造函数,它将所有依赖项作为参数传入。
  • 一般它会返回一个 http.Handler,在更复杂的场景时,它可以是专用类型。
  • 它通常会配置自己的多路复用器并调用 routes.go
例如,你的代码可能如下所示:
在不需要全部依赖项的测试用例中,传入 nil 确保其不会被使用到。
NewServer 构造函数负责处理适用于所有接口的全局 HTTP 事务,比如 CORS、认证中间件和日志记录等:
配置服务器通常就是是使用 Go 的内置 http 包公开对外提供服务:

长参数列表

参数的个数有一个上限,超过再继续增加旧不太合适了,但大多数时候我乐于将依赖项列表添加为参数。尽管有时列表会变得很长,我觉得这样做仍然是值得的。
是的,虽然这样可以省去创建结构体的步骤,但真正的好处是通过参数获得了更好的类型安全性。我可以在结构体中跳过不喜欢的字段,而函数则会强制我这样做——如果不传递正确的参数,就无法调用函数,而设置结构体时,需要查找每个字段才能知道如何在结构体中设置它们。
如果像现代前端代码那样以垂直列表格式化它,其实看起来也没那么糟糕:

routes.go 中映射全部接口(API)

这个文件是服务中唯一列出所有路由的地方。
虽然有时难免会到处分散的有,但在每个项目中能通过一个文件来查看其所有的 API 接口还是体验不错的。
由于 NewServer 构造函数中依赖许多参数,通常会在路由函数中出现一样的情况。但话说回来,这也不算很糟。而且由于 Go 的类型检查,你能很快知道自己是否遗漏了参数或是搞错了顺序。
在我的例子中,addRoutes 不返回 error。任何可能抛出错误的操作都被移动到 run 函数中,并在到达这一步之前进行处理,从而使此函数简单和扁平化。当然,如果你的处理函数因某些原因返回 error,那么这个函数也可以返回 error

main() 函数中只调用 run() 函数

run 函数类似于 main 函数,不同的是它接收操作系统的基本参数,并会返回一个错误。
我希望 func main()func main() error。或者像 C 语言那样可以返回退出代码:func main() int。通过拥有一个极其简单的 main 函数,你也可以梦想成真:
EDIT:我曾经在 main 中执行 signal.NotifyContext 部分,但 Dave Henderson(以及其他几个人)指出 cancel 不会被调用,所以我已将其移至 run 函数中。
上面的代码直接调用 run,它创建一个上下文,该上下文由 Ctrl+C 或等效操作取消。如果 run 返回 nil,则函数正常退出。如果它返回错误,我们会将其写入 stderr 并以非零代码退出。如果我正在编写一个退出代码很重要的命令行工具,我也会返回一个 int,以便我可以编写测试来断言是否返回了正确的代码。
操作系统的基本参数作为参数传递给 run 函数。例如,如果支持标志,你可以传递 os.Args,甚至 os.Stdinos.Stdoutos.Stderr 这些依赖项。这样可以让你的程序更容易测试,因为测试代码可以调用 run 函数来执行程序,通过传入不同的参数来控制参数和所有流。
下表显示了 run 函数的输入参数示例:
类型
描述
os.Args
[]string
执行程序时传入的参数。它也用于解析标志。
os.Stdin
io.Reader
用于读取输入
os.Stdout
io.Writer
用于写入输出
os.Stderr
io.Writer
用于写入错误日志
os.Getenv
func(string) string
用于读取环境变量
os.Getwd
func() (string, error)
获取工作目录
如果避免使用任何全局作用域的数据,通常可以在更多地方使用 t.Parallel() 来加速测试套件。因为所有数据都是自有的,所以对 run 的多次调用不会相互干扰。
我经常得到如下所示的 run 函数签名:
现在我们进入 run 函数,可以继续编写正常的 Go 代码,其中可以毫无顾忌地返回 error。我们这些 gopher 就是喜欢返回 error,越早承认这一点,网络上的那些人就能早点“赢”并走开。

优雅停止

如果你运行大量测试,每个测试完成后让程序停止很重要。(或者,你可以决定为所有测试保持一个实例一直运行,这取决于你。) 上下文(context)会被传递。如果终止信号进入程序,它会被取消,所以在每个层级都应该注意它。至少,要把它传给你的依赖项。最好是在任何长时间运行或循环的代码中检查 Err() 方法,如果返回 error,就停止并返回。这将帮助服务器优雅地关闭。如果启动其他 goroutine,也可以用上下文判断是否该停止它们。

控制环境变量

argsgetenv 参数为我们提供了两种通过标志和环境变量来控制程序行为的方式。标志使用 args 进行处理(只要你不使用标志的全局空间版本,而是在 run 内部使用 flags.NewFlagSet),因此我们可以使用不同的值调用 run:
如果你的程序使用环境变量而不是标志(或者两者都使用),那么 getenv 函数允许你在不更改实际环境的情况下插入不同的值。
对我来说,使用这种 getenv 比使用 t.SetEnv 来控制环境变量要好,因为你可以继续通过调用 t.Parallel() 并行运行你的测试,而 t.SetEnv 不允许这样做。
这种技术在编写命令行工具时更加有用,因为你经常希望使用不同的设置运行程序来测试其所有行为。
main 函数中,我们可以传入实际的参数:

Maker 函数返回处理程序

我的处理程序函数不会直接实现 http.Handlerhttp.HandlerFunc,而是返回它们。具体来说,它们返回 http.Handler 类型。
这种模式为每个处理程序提供了自己的闭包环境。你可以在此空间中进行初始化工作,并且数据将在调用处理程序时可用。
请确保只读取共享数据。如果 handler 修改了任何内容,则需要使用互斥锁或其他类似机制来保护。
在这里存储程序状态通常不是你想要的。在大多数云环境中,你不能指望代码能长时间运行。根据你的生产环境,服务器通常会关闭以节约资源,或者因其他原因崩溃。还有可能会有多个服务实例运行,请求的负载会以不可预知的方式分配。在这种情况下,一个实例只能访问它自己的本地数据。因此,在真实项目中,最好使用数据库或其他存储 API 来持久化数据。

在同一处解码/编码

每个服务都需要解码请求正文和编码响应正文。这是一种久经考验的抽象。
我通常有一对名为 encodedecode 的辅助函数。下面展示了一个使用泛型的示例版本,其实也就是封装了一些基本的代码,通常我不会这么做,但当你需要对所有 API 进行修改时,这就会变得很有用。(比如说,如果你有个还活在上个世纪的新上司,他想要添加 XML 支持。)
有趣的是,编译器能够从参数中推断出类型,因此你在调用 encode 时不需要传递它:
但是由于它是 decode 中的返回参数,因此你需要指定你期望的类型:
我尽量不重载这些函数,但在过去,我对一个简单地融入 decode 函数的验证接口感到非常满意。

验证数据

我喜欢简单的接口。其实,我非常喜欢。单方法接口实现起来特别容易。所以在验证对象时,我喜欢这样做:
Valid 方法接受一个上下文(它是可选的,但过去对我很有用)并返回一个映射。如果字段有问题,则使用其名称作为键,并将对问题的解释说明设置为值。
该方法可以执行验证结构字段所需的任何操作。例如,它可以检查以确保:
  • 必填字段不为空
  • 具有特定格式的字符串(如电子邮件)是正确的
  • 数字在可接受的范围内
如果你需要做更复杂的事情,比如在数据库中检查字段,这应该在别处处理;这可能太重要,不适合被视为快速验证检查。而且你不会希望在这样的函数中看到这种操作,所以它可能会轻易被隐藏起来。
然后我使用类型断言来检查对象是否实现了接口。或者在用泛型时,我可能会选择通过修改 decode 方法来更明确地要求那个接口必须被实现。
在此代码中,T 必须实现 Validator 接口,并且 Valid 方法必须返回零问题才能将对象视为已成功解码。
返回 nil 是安全的,因为我们会检查 len(problems),对于一个 nil 映射,这个值为 0,但不会引发 panic。

中间件的适配器模式

中间件函数接受一个 http.Handler 并返回一个新的 http.Handler,它可以在调用原始处理程序之前和/或之后运行代码——或者它可以决定根本不调用原始处理程序。
一个例子是检查以确保用户是管理员:
处理程序内部的逻辑可以选择是否调用原始处理程序。在上面的示例中,如果 IsAdmin 为 false,则处理程序将返回 HTTP 404 Not Found 并返回(或中止);请注意,不会调用 h 处理程序。如果 IsAdmin 为 true,则允许用户访问路由,因此执行传递给 h 处理程序。
通常我在 routes.go 文件中列出中间件:
只需查看接口 URL,就可以非常清楚地了解将哪个中间件应用于哪个路由。如果列表开始变大,请尝试将它们拆分成多行——我知道,但你会习惯的。

有时我会返回中间件

上述方法适用于简单的情况,但如果中间件需要大量依赖项(记录器、数据库、一些 API 客户端、一个包含“Never Gonna Give You Up”数据的字节数组,用于以后的恶作剧),那么我常常会写一个函数,它返回中间件函数。
问题是,你最终会得到如下所示的代码:
这会使代码膨胀,并且不会真正提供任何有用的东西。相反,我会让中间件函数获取依赖项,但返回一个只获取下一个处理程序的函数。
返回类型 func(h http.Handler) http.Handler 是我们在设置路由时将调用的函数。
有些人,但不是我,喜欢将这种函数类型形式化如下:
这很好。如果你喜欢,就这样做。我不会去你的工作场所,在外面等你,然后用胳膊搂着你的肩膀以一种恐吓的方式走在你旁边,问你是否对自己感到满意。
我不这样做的原因是因为它提供了一个额外的间接层。当你查看上面 newMiddleware 函数的签名时,它非常清楚地说明了发生了什么。如果返回类型是中间件,则你需要做一些额外的工作。本质上,我这么处理是为了更好地读取代码,而不是编写代码。

隐藏请求/响应类型

如果一个接口有自己特定的请求和响应类型,通常它们只对那个特定的 handler 有用。
如果是这种情况,你可以在函数内部定义它们。
这可以让代码保持清晰,并防止其他 handler 依赖你可能认为不稳定的数据。
在测试代码需要使用相同类型时,有时会遇到一些摩擦。说实话,如果你想要这样做,这是拆分它们的一个很好理由。

使用内联请求/响应类型在测试中进行额外的故事讲述

如果你的请求/响应类型隐藏在处 handler 内部,你可以在你的测试代码中声明新的类型。
这是一个向以后的代码维护者描述的机会,他们需要理解你的代码。
例如,假设我们的代码中有一个 Person 类型,并且我们在许多接口上重用它。如果我们有一个 /greet 端点,我们可能只关心他们的名字,所以我们可以在测试代码中表达这一点:
从这个测试中可以清楚地看出,我们唯一关心的字段是 Name 字段。

sync.Once defer 设置

如果我在准备 handler 时必须做任何耗时的事情,我会将其推迟到第一次调用时。这可以缩短应用程序启动时间。
sync.Once 确保代码只执行一次,其他调用(其他人发出相同的请求)将阻塞,直到它完成。
  • 错误检查在 init 函数之外,因此如果出现问题,我们仍然会显示错误,并且不会在日志中丢失它
  • 如果未调用 handler,则永远不会完成此任务——这可能会带来巨大的好处,取决于您的代码是如何部署的。
请记住,这样做会将初始化时间从启动转移到运行时(即首次访问接口时)。我经常使用 Google App Engine,所以这对我有意义,但您的情况可能不同,因此值得考虑在何处以及何时以这种方式使用 sync.Once

面向测试而设计

这些模式的演变部分是因为它们易于测试代码。run 函数是一种直接从测试代码运行程序的简单方法。
在 Go 中进行测试时有很多选择,与其说是对错,不如说更重要的是:
  • 通过查看测试,了解你的程序的功能有多容易?
  • 在不担心破坏程序的情况下修改代码有多容易?
  • 如果你的所有测试都通过了,你可以将其发布到生产环境吗?还是需要覆盖更多测试内容?

单元测试中的单元是什么?

按照这些模式,handler 本身也是可以独立测试的,但我通常不会这样做,我将在下面解释原因。你必须考虑哪种方法最适合你的项目。
要仅测试 handler,你可以:
  1. 调用该函数以获取 http.Handler——你必须传入所有必需的依赖项(这是一个特性)。
  1. 在返回的 http.Handler 上调用 ServeHTTP 方法,使用真实的 http.Request 和来自 httptest 包的 ResponseRecorder(请参阅https://pkg.go.dev/net/http/httptest#ResponseRecorder)
  1. 对响应进行断言(检查状态代码,解码正文并确保它是正确的,检查任何重要的标头等)
如果你这样做,你将跳过任何中间件(如身份验证之类),并直接进入 handler 代码。这在你想围绕某些特定复杂性构建测试支持时非常有用。然而,当你的测试代码以用户相同的方式调用 API 时,也有一个优势。我倾向于在这个层面进行端到端测试,而不是对所有内部部分进行单元测试。
我宁愿调用 run 函数来尽可能模拟它在生产环境中的运行方式来执行整个程序。这将解析全部参数,连接到所有依赖项,迁移数据库,以及其他在实际环境中会执行的操作,最终启动服务器。这样,当我从测试代码中访问 API 时,我会经过所有层,甚至与真实数据库进行交互。同时,我也在测试 routes.go
我发现这种方法能更早地发现更多问题,并且可以避免特别测试样板代码的事情。这也减少了测试中的重复。如果我认真测试每一层,可能会以稍微不同的方式多次表达同样的内容。你需要维护所有这些,如果想改变某些东西,更新一个函数和三个测试就显得不太高效。通过端到端测试,你只需要一套主要测试来描述用户和系统之间的交互。
在适当的情况下,我仍然使用单元测试。如果我使用 TDD(我经常这样做),那么通常会有很多测试已经完成,我乐于维护这些。但是,如果某些测试与端到端测试重复,我会回过头来删除这些测试。
这个抉择要考虑很多事情,从你周围人的意见到你项目的复杂性,所以就像这篇文章中的所有建议一样,如果它不适合你,就不要强迫自己这样做。

使用 run 函数进行测试

我喜欢从每个测试中调用 run 函数。每个测试都有自己独立的程序实例。对于每个测试,我可以传递不同的参数、标志值、标准输入和输出管道,甚至环境变量。
由于 run 函数接受 context.Context,并且由于我们的所有代码都使用上下文,我们可以通过调用 context.WithCancel 得到取消函数。通过延迟 cancel 函数,当测试函数返回时(即,当测试完成运行时),上下文将被取消,程序将正常关闭。在 Go 1.14 中,他们添加了 t.Cleanup 方法,它可以替代你自己使用 defer 关键字,如果你想了解更多关于为什么这样做,请查看此问题:https://github.com/golang/go/issues/37333
所有这些都只需很少的代码即可实现。当然,你还必须到处检查 ctx.Errctx.Done

等待准备就绪

由于 run 函数在 goroutine 中执行,我们并不知道它何时启动。如果我们要像真正的用户一样开始访问 API,我们需要知道它何时准备就绪。
我们可以设置一些方式来信号通知准备就绪,比如通道之类的——但我更喜欢在服务器上运行一个 /healthz /readyz 接口。就像我奶奶常说的,真正的验证在于实际的 HTTP 请求(她真是超前)。
这是一个例子,说明我们为了让代码更易于测试而付出的努力,可以让我们了解用户的需求。他们可能也想知道服务是否准备就绪,那么为什么不提供一个官方的方式来了解呢?
要等待服务准备就绪,你可以编写一个循环:

将所有这些付诸实践

查看原文

2024 © HK