[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 服务的核心:
server
。NewServer
函数创建了 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.Stdin
、os.Stdout
、os.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,也可以用上下文判断是否该停止它们。控制环境变量
args
和 getenv
参数为我们提供了两种通过标志和环境变量来控制程序行为的方式。标志使用 args 进行处理(只要你不使用标志的全局空间版本,而是在 run
内部使用 flags.NewFlagSet
),因此我们可以使用不同的值调用 run:如果你的程序使用环境变量而不是标志(或者两者都使用),那么
getenv
函数允许你在不更改实际环境的情况下插入不同的值。对我来说,使用这种
getenv
比使用 t.SetEnv
来控制环境变量要好,因为你可以继续通过调用 t.Parallel()
并行运行你的测试,而 t.SetEnv
不允许这样做。这种技术在编写命令行工具时更加有用,因为你经常希望使用不同的设置运行程序来测试其所有行为。
在
main
函数中,我们可以传入实际的参数:Maker 函数返回处理程序
我的处理程序函数不会直接实现
http.Handler
或 http.HandlerFunc
,而是返回它们。具体来说,它们返回 http.Handler
类型。这种模式为每个处理程序提供了自己的闭包环境。你可以在此空间中进行初始化工作,并且数据将在调用处理程序时可用。
请确保只读取共享数据。如果
handler
修改了任何内容,则需要使用互斥锁或其他类似机制来保护。在这里存储程序状态通常不是你想要的。在大多数云环境中,你不能指望代码能长时间运行。根据你的生产环境,服务器通常会关闭以节约资源,或者因其他原因崩溃。还有可能会有多个服务实例运行,请求的负载会以不可预知的方式分配。在这种情况下,一个实例只能访问它自己的本地数据。因此,在真实项目中,最好使用数据库或其他存储 API 来持久化数据。
在同一处解码/编码
每个服务都需要解码请求正文和编码响应正文。这是一种久经考验的抽象。
我通常有一对名为
encode
和 decode
的辅助函数。下面展示了一个使用泛型的示例版本,其实也就是封装了一些基本的代码,通常我不会这么做,但当你需要对所有 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,你可以:
- 调用该函数以获取
http.Handler
——你必须传入所有必需的依赖项(这是一个特性)。
- 在返回的
http.Handler
上调用ServeHTTP
方法,使用真实的http.Request
和来自httptest
包的ResponseRecorder
(请参阅https://pkg.go.dev/net/http/httptest#ResponseRecorder)
- 对响应进行断言(检查状态代码,解码正文并确保它是正确的,检查任何重要的标头等)
如果你这样做,你将跳过任何中间件(如身份验证之类),并直接进入 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.Err
或 ctx.Done
:等待准备就绪
由于
run
函数在 goroutine 中执行,我们并不知道它何时启动。如果我们要像真正的用户一样开始访问 API,我们需要知道它何时准备就绪。我们可以设置一些方式来信号通知准备就绪,比如通道之类的——但我更喜欢在服务器上运行一个
/healthz
或 /readyz
接口。就像我奶奶常说的,真正的验证在于实际的 HTTP 请求(她真是超前)。这是一个例子,说明我们为了让代码更易于测试而付出的努力,可以让我们了解用户的需求。他们可能也想知道服务是否准备就绪,那么为什么不提供一个官方的方式来了解呢?
要等待服务准备就绪,你可以编写一个循环:
将所有这些付诸实践
查看原文