我在13年后如何编写Go的HTTP服务|Grafana Labs

将近六年前,我写了一篇博客文章,概述了我如何编写Go的HTTP服务,现在,我再次告诉你,我如何编写HTTP服务。


Grafana Labs的首席工程师、Go Time播客的主持人Mat Ryer分享了他在编写Go的HTTP服务方面超过十几年的经验。
原文链接:https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/

那篇原始的博文引发了一些热议,对我现在的编码方式产生了一些影响。经过多年主持Go Time播客、在Twitter上讨论Go、以及在维护这样的代码方面积累了更多的经验后,我觉得是时候进行一次更新了。

(对于那些注意到Go并不完全有13年历史的学究们,我开始在Go 版本 .r59中编写HTTP服务。)
本文涵盖了与使用Go构建服务相关的各种主题,包括:

  • 为了最大限度地提高可维护性而构建服务器和处理程序的结构
  • 优化快速启动和优雅关闭的技巧和窍门
  • 如何处理适用于多种类型请求的常见工作
  • 深入探讨如何正确测试您的服务

从小型项目到大型项目,这些实践对我来说经受住了时间的考验,我希望它们对你也同样有效。

这篇文章适合谁?

这篇文章适合你。它适用于计划使用Go编写某种类型的HTTP服务的每个人。如果你正在学习Go,你可能会发现这个有用,因为很多示例都遵循了良好的实践。有经验的Go开发者也可能会学到一些不错的模式。

要使这篇文章对你最有用,你需要了解Go的基础知识。如果你觉得自己还没有掌握,我强烈推荐阅读Chris James的《通过测试学习Go》。如果你想听更多关于Chris的内容,你可以看看我们在Go Time上与Ben Johnson一起讨论的《Go项目的文件和文件夹》的那一集。

如果你熟悉之前版本的这篇文章,本节将对现在的不同之处进行了快速总结。如果你想从头开始阅读,可以跳到下一节。

  1. 我的处理程序过去是作为服务器结构的方法存在的,但现在我不再这样做了。如果处理程序函数需要某个依赖项,它可以直接作为参数传递。当你只是想测试单个处理程序时,不再会出现意外的依赖项。
  2. 我过去更喜欢使用http.HandlerFunc而不是http.Handler,但现在大多数第三方库都优先考虑http.Handler,所以采用它是有道理的。http.HandlerFunc仍然非常有用,但现在大多数东西都表示为接口类型。无论选择哪种方式,都没有太大区别。
  3. 我增加了更多关于测试的内容,包括一些“意见”。
  4. 我增加了更多的章节,所以建议每个人都进行全面阅读。

NewServer构造函数

让我们首先看一下任何Go服务的核心部分:服务器。NewServer函数创建主要的http.Handler。通常我每个服务只有一个NewServer,并且我依赖HTTP路由将流量导向每个服务中的正确处理程序,原因如下:

  • NewServer是一个大型的构造函数,它将所有依赖项作为参数传入
  • 如果可能的话,它返回一个http.Handler,对于更复杂的情况可以是一个专用类型
  • 它通常会配置自己的muxer并调用routes.go

例如,你的代码可能类似于以下示例:

1
2
3
4
5
6
7
8
9
func NewServer(logger *Logger, config *Config, commentStore *commentStore, anotherStore *anotherStore) http.Handler {
mux := http.NewServeMux()
addRoutes(mux, logger, config, commentStore, anotherStore)
var handler http.Handler = mux
handler = someMiddleware(handler)
handler = someMiddleware2(handler)
handler = someMiddleware3(handler)
return handler
}

在不需要所有依赖项的测试用例中,我将nil作为信号传入,表示不会使用它们。

NewServer构造函数负责适用于所有端点的顶层HTTP内容,例如CORS、身份验证中间件和日志记录:

1
2
3
4
5
var handler http.Handler = mux
handler = logging.NewLoggingMiddleware(logger, handler)
handler = logging.NewGoogleTraceIDMiddleware(logger, handler)
handler = checkAuthHeaders(handler)
return handler

通常,通过使用Go的内置http包将服务器设置起来是一个简单的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
srv := NewServer(logger, config, tenantsStore, slackLinkStore, msteamsLinkStore, proxy)
httpServer := &http.Server{
Addr: net.JoinHostPort(config.Host, config.Port),
Handler: srv,
}
go func() {
log.Printf("listening on %s\n", httpServer.Addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "error listening and serving: %s\n", err)
}
}()

var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done() // make a new context for the Shutdown (thanks Alessandro Rosetti)
shutdownCtx := context.Background()
shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
fmt.Fprintf(os.Stderr, "error shutting down http server: %s\n", err)
}
}()
wg.Wait()
return nil

长参数列表

在某个点上,可能会达到不适合继续添加依赖项的限制,但大多数情况下,我乐意将依赖项列表作为参数添加进去。虽然有时参数列表可能会变得很长,但我发现这样做仍然是值得的。

是的,这样做可以避免创建一个结构体,但真正的好处是,通过参数,我可以获得稍微更多的类型安全性。我可以创建一个跳过我不喜欢的任何字段的结构体,但函数会强制我必须查找字段,才能知道如何在结构体中设置它们,否则无法调用函数。

如果将其格式化为垂直列表,就不会那么糟糕,就像我在现代前端代码中看到的那样:

1
2
3
4
5
6
7
8
srv := NewServer(
logger,
config,
tenantsStore,
commentsStore,
conversationService,
chatGPTService,
)

routes.go中映射整个API接口

这个文件是服务中列出所有路由的地方。

有时你可能无法避免将它们分散在不同地方,但能够在每个项目的一个文件中查看其API接口是非常有帮助的。

由于NewServer构造函数中有大型的依赖参数列表,您通常会在路由函数中看到相同的列表。但同样,这并不是很糟糕。而且,由于Go的类型检查功能,如果您忘记了某些内容或者顺序不正确,您很快就会发现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func addRoutes(
mux *http.ServeMux,
logger *logging.Logger,
config Config,
tenantsStore *TenantsStore,
commentsStore *CommentsStore,
conversationService *ConversationService,
chatGPTService *ChatGPTService,
authProxy *authProxy,
) {
mux.Handle("/api/v1/", handleTenantsGet(logger, tenantsStore))
mux.Handle("/oauth2/", handleOAuth2Proxy(logger, authProxy))
mux.HandleFunc("/healthz", handleHealthzPlease(logger))
mux.Handle("/", http.NotFoundHandler())
}

在我的示例中,addRoutes不返回错误。任何可能引发错误的内容都被移到了run函数中,在到达这一点之前进行了处理,从而使该函数保持简单和扁平。当然,如果任何处理程序由于某种原因返回错误,那么这个函数也可以返回错误。

func main()只调用run()

run函数类似于main函数,只是它接受操作系统的基本功能作为参数,并返回错误。

我希望func main()func main() error。或者像在C语言中一样,可以返回退出代码:func main() int。通过拥有一个非常简单的main函数,你也可以实现自己的梦想:

1
2
3
4
5
6
7
8
9
10
11
12
13
func run(ctx context.Context, w io.Writer, args []string) error {
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel()
// ...
}

func main() {
ctx := context.Background()
if err := run(ctx, os.Stdout, os.Args); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
}

编辑:我过去在main函数中执行了signal.NotifyContext的部分,但是Dave Henderson(和其他几个人)指出cancel函数不会被调用,所以我将其移到了run函数中。

上面的代码直接调用了run函数,它会创建一个上下文,并在接收到Ctrl+C或等效信号时取消。如果run函数返回nil,则函数会正常退出。如果返回错误,则将其写入stderr并以非零代码退出。如果我正在编写一个需要考虑退出代码的命令行工具,我还可以返回一个整数,这样我就可以编写测试来断言返回的正确代码。

操作系统的基本功能作为参数传递给run。例如,如果它支持标志,则可以传入os.Args,甚至可以传入os.Stdinos.Stdoutos.Stderr等依赖项。这样,你的程序在测试时会更容易,因为测试代码可以调用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函数的签名:

1
2
3
4
5
6
7
func run(
ctx context.Context,
args []string,
getenv func(string) string,
stdin io.Reader,
stdout, stderr io.Writer,
) error

现在我们进入了run函数,可以回到正常的Go代码编写,可以像没有人在乎一样返回错误。我们gopher们喜欢返回错误,我们越早承认这一点,那些在互联网上的人就越早能赢得胜利并消失。

优雅关闭

如果你运行大量的测试,当每个测试完成时,让你的程序停止是很重要的。(或者你可能决定保持一个实例运行所有的测试,但这取决于你。)

上下文被传递。如果终止信号进入程序,它将被取消,因此在每个级别都尊重它是很重要的。至少,将其传递给你的依赖项。最好,检查任何长时间运行或循环代码中的Err()方法,如果返回错误,则停止正在进行的操作并将其返回。这将有助于服务器优雅地关闭。如果你启动了其他的goroutine,你也可以使用上下文来决定是否停止它们。

控制环境

argsgetenv参数为我们提供了一些控制程序行为的方法,通过标志和环境变量。标志使用args进行处理(只要你不使用全局空间版本的flags,并在run内部使用flags.NewFlagSet),因此我们可以使用不同的值调用run:

1
2
3
4
5
6
args := []string{
"myapp",
"--out", outFile,
"--fmt", "markdown",
}
go run(ctx, args, etc.)

如果你的程序使用环境变量而不是标志(甚至两者都使用),那么getenv函数允许你插入不同的值,而不需要更改实际的环境。

1
2
3
4
5
6
7
8
9
10
11
getenv := func(key string) string {
switch key {
case "MYAPP_FORMAT":
return "markdown"
case "MYAPP_TIMEOUT":
return "5s"
default:
return ""
}
}
go run(ctx, args, getenv)

对我来说,使用这种getenv技术比使用t.SetEnv控制环境变量更好,因为你可以通过调用t.Parallel()继续并行运行测试,而t.SetEnv不允许这样做。

当然,当你在编写命令行工具时,这种技术更加有用,因为你经常希望以不同的设置运行程序,以测试其所有行为。

main函数中,我们可以传入真正的东西:

1
2
3
4
5
6
7
func main() {
ctx := context.Background()
if err := run(ctx, os.Getenv, os.Stderr); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
}

使用Maker函数返回处理程序

我的处理程序函数不直接实现http.Handlerhttp.HandlerFunc,它们返回它们。具体来说,它们返回http.Handler类型。

1
2
3
4
5
6
7
8
// handleSomething处理那些你经常听到的网络请求。
func handleSomething(logger *Logger) http.Handler {
thing := prepareThing()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 使用thing处理请求
logger.Info(r.Context(), "msg", "handleSomething")
})
}

这种模式为每个处理程序提供了独立的闭包环境。你可以在这个空间中进行初始化工作,并且当调用处理程序时,数据将可用。

确保只读取共享数据。如果处理程序修改任何内容,你将需要一个互斥锁或其他东西来保护它。

通常情况下,不建议在这里存储程序状态。在大多数云环境中,你不能保证代码会持续运行很长时间。根据你的生产环境,服务器通常会关闭以节省资源,或者仅仅因为其他原因崩溃。你的服务可能会以不可预测的方式在许多实例之间负载均衡。在这种情况下,每个实例只能访问自己的本地数据。因此,在真实项目中最好使用数据库或其他存储API来持久化数据。

在一个地方处理解码/编码

每个服务都需要解码请求体和编码响应体。这是一个经得起时间考验的合理抽象。

我通常会有一对辅助函数叫做encode和decode。使用泛型的示例版本向您展示,实际上您只是在包装几行基本代码,我通常不会这样做,但当您需要为所有API进行更改时,这将变得非常有用。(例如,假设您的新老板还停留在上世纪90年代,并且他们想要添加XML支持。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func encode[T any](w http.ResponseWriter, r *http.Request, status int, v T) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
return fmt.Errorf("encode json: %w", err)
}
return nil
}

func decode[T any](r *http.Request) (T, error) {
var v T
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
return v, fmt.Errorf("decode json: %w", err)
}
return v, nil
}

有趣的是,编译器能够从参数中推断出类型,因此在调用encode时不需要传递类型:

1
err := encode(w, r, http.StatusOK, obj)

但由于decode中的返回参数,您需要指定您期望的类型:

1
decoded, err := decode[CreateSomethingRequest](r)

我尽量不过度使用这些函数,但过去我对一个简单的验证接口非常满意,它很好地适应了decode函数。

验证数据

我喜欢简单的接口。实际上,我非常喜欢它们。单方法接口很容易实现。所以当涉及到验证对象时,我喜欢这样做:

1
2
3
4
5
6
// Validator是一个可以进行验证的对象。
type Validator interface {
// Valid检查对象并返回任何问题。
// 如果len(problems) == 0,则对象有效。
Valid(ctx context.Context) (problems map[string]string)
}

Valid方法接受一个上下文(这是可选的,但在过去对我很有用),并返回一个映射。如果字段有问题,它的名称将用作键,并将问题的人类可读解释设置为值。

该方法可以执行任何需要验证结构体字段的操作。例如,它可以检查以下内容:

  • 必填字段不能为空
  • 具有特定格式(如电子邮件)的字符串是否正确
  • 数字是否在可接受范围内

如果您需要执行更复杂的操作,比如在数据库中检查字段,那么应该在其他地方进行;它可能太重要,以至于不能被视为快速验证检查的一部分,并且您不会期望在这样的函数中找到这种类型的内容,因此它可能很容易被隐藏起来。

然后,我使用类型断言来判断对象是否实现了该接口。或者,在泛型世界中,我可能选择更明确地说明正在发生的事情,通过更改decode方法来坚持要求实现该接口。

1
2
3
4
5
6
7
8
9
10
func decodeValid[T Validator](r *http.Request) (T, map[string]string, error) {
var v T
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
return v, nil, fmt.Errorf("decode json: %w", err)
}
if problems := v.Valid(r.Context()); len(problems) > 0 {
return v, problems, fmt.Errorf("invalid %T: %d problems", v, len(problems))
}
return v, nil, nil
}

在此代码中,T必须实现Validator接口,并且Valid方法必须返回零个问题,以便将对象视为成功解码。

对于问题,返回nil是安全的,因为我们将检查len(problems),对于nil映射,它将为0,但不会引发恐慌。

适配器模式用于中间件

中间件函数接受一个http.Handler并返回一个新的http.Handler,可以在调用原始处理程序之前和/或之后运行代码,或者甚至可以决定根本不调用原始处理程序。

一个示例是检查用户是否为管理员:

1
2
3
4
5
6
7
8
9
func adminOnly(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !currentUser(r).IsAdmin {
http.NotFound(w, r)
return
}
h(w, r)
})
}

处理程序内部的逻辑可以选择是否调用原始处理程序。在上面的示例中,如果IsAdmin为false,则处理程序将返回HTTP 404 Not Found并返回(或中止);请注意,不调用h处理程序。如果IsAdmin为true,则允许用户访问路由,因此将执行传递给h处理程序。

通常,我将中间件列在routes.go文件中:

1
2
3
4
5
6
7
8
package app

func addRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/", handleAPI())
mux.HandleFunc("/about", handleAbout())
mux.HandleFunc("/", handleIndex())
mux.HandleFunc("/admin", adminOnly(handleAdminIndex()))
}

通过查看端点映射,这使得非常清晰,可以知道应用了哪些中间件。如果列表开始变得更长,请尝试将它们分成多行 - 我知道,我知道,但您会习惯的。

有时返回中间件

上述方法对于简单情况非常好,但如果中间件需要许多依赖项(一个记录器,一个数据库,一些API客户端,一个包含“Never Gonna Give You Up”数据的字节数组,供以后的恶作剧使用),那么我可能会编写一个返回中间件函数的函数。

问题是,您最终会得到这样的代码:

1
2
3
4
mux.Handle("/route1", middleware(logger, db, slackClient, rroll []byte, handleSomething(handlerSpecificDeps))
mux.Handle("/route2", middleware(logger, db, slackClient, rroll []byte, handleSomething2(handlerSpecificDeps))
mux.Handle("/route3", middleware(logger, db, slackClient, rroll []byte, handleSomething3(handlerSpecificDeps))
mux.Handle("/route4", middleware(logger, db, slackClient, rroll []byte, handleSomething4(handlerSpecificDeps))

这样会使代码膨胀,并且实际上并没有提供任何有用的东西。相反,我会让中间件函数接受依赖项,但返回一个只接受下一个处理程序的函数。

1
2
3
4
5
6
func newMiddleware(
logger Logger,
db *DB,
slackClient *slack.Client,
rroll []byte,
) func(h http.Handler) http.Handler

返回类型func(h http.Handler) http.Handler是我们在设置路由时将调用的函数。

1
2
3
4
5
middleware := newMiddleware(logger, db, slackClient, rroll)
mux.Handle("/route1", middleware(handleSomething(handlerSpecificDeps))
mux.Handle("/route2", middleware(handleSomething2(handlerSpecificDeps))
mux.Handle("/route3", middleware(handleSomething3(handlerSpecificDeps))
mux.Handle("/route4", middleware(handleSomething4(handlerSpecificDeps))

有些人喜欢(但我不喜欢)以这种方式正式化函数类型:

1
2
3
// middleware是一个包装http.Handlers的函数
// 在执行h处理程序之前和之后提供功能
type middleware func(h http.Handler) http.Handler

这样也可以。如果您喜欢,请这样做。我不会在您的工作周围等待,然后在您身边走来走去,用一种令人生畏的方式搂着您的肩膀,问您是否对自己感到满意。

我不这样做的原因是因为它增加了额外的间接性。当您查看上面的newMiddleware函数的签名时,很明显正在发生什么。如果返回类型是middleware,则需要额外的工作。实际上,我优化的是阅读代码,而不是编写代码。

隐藏请求/响应类型的机会

如果一个端点有自己的请求和响应类型,通常它们只对该特定处理程序有用。

如果是这样的情况,您可以在函数内部定义它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
func handleSomething() http.HandlerFunc {
type request struct {
Name string
}

type response struct {
Greeting string `json:"greeting"`
}

return func(w http.ResponseWriter, r *http.Request) {
...
}
}

这样可以使全局空间保持清晰,并防止其他处理程序依赖于您可能不认为是稳定的数据。

当您的测试代码需要使用相同的类型时,有时会遇到这种方法的摩擦。公平地说,这是一个将它们拆分出来的好理由,如果您想这样做的话。

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

如果您的请求/响应类型在处理程序内部隐藏,您可以在测试代码中声明新类型。

这是一个机会,可以向未来需要理解您的代码的人讲述一些故事。

例如,假设我们的代码中有一个Person类型,并且我们在许多端点上重复使用它。如果我们有一个/greet端点,我们可能只关心他们的姓名,所以我们可以在测试代码中表达这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func TestGreet(t *testing.T) {
is := is.New(t)

person := struct {
Name string `json:"name"`
}{
Name: "Mat Ryer",
}

var buf bytes.Buffer
err := json.NewEncoder(&buf).Encode(person)
is.NoErr(err)

req, err := http.NewRequest(http.MethodPost, "/greet", &buf)
is.NoErr(err)

//... more test code here
}

从这个测试中可以明确看出,我们只关心Name字段。

使用sync.Once延迟设置

如果在准备处理程序时需要执行任何昂贵的操作,我会将其推迟到首次调用该处理程序时。

这提高了应用程序的启动时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func handleTemplate(files string...) http.HandlerFunc {
var (
init sync.Once
tpl *template.Template
tplerr error
)

return func(w http.ResponseWriter, r *http.Request) {
init.Do(func() {
tpl, tplerr = template.ParseFiles(files...)
})

if tplerr != nil {
http.Error(w, tplerr.Error(), http.StatusInternalServerError)
return
}

// use tpl
}
}

sync.Once确保代码只执行一次,并且其他调用(其他人发起相同请求)将阻塞,直到完成。

  • 错误检查位于init函数之外,因此如果出现问题,我们仍然会显示错误,并且不会在日志中丢失它。
  • 如果不调用处理程序,则不会执行昂贵的工作 - 这可能在很大程度上有益,这取决于代码的部署方式。

请记住,通过这样做,您将初始化时间从启动时移动到运行时(首次访问端点时)。我经常使用Google App Engine,所以对我来说这是有意义的,但您的情况可能不同,因此值得考虑何时何地以这种方式使用sync.Once

为可测试性设计

这些模式的发展部分是因为它们在测试代码中非常容易测试代码。run函数是从测试代码中直接运行程序的简单方法。

在Go中,有很多测试选项,它们不是关于对与错的问题,而是关于:

  • 查看测试代码是否可以轻松理解您的程序在做什么?
  • 您是否可以轻松更改代码而不必担心破坏其他部分?
  • 如果所有测试都通过,您是否可以推送到生产环境,还是还需要涵盖更多内容?

单元测试的单元是什么?

遵循这些模式,处理程序本身也是可以独立测试的,但我通常不这样做,我将在下面解释为什么。您必须考虑对于您的项目来说什么是最佳方法。

要仅测试处理程序,您可以:

  1. 调用函数以获取http.Handler - 您必须传递所有所需的依赖项(这是一项功能)。
  2. 使用真实的http.Request和来自httptest包的ResponseRecorder调用ServeHTTP方法(请参阅https://pkg.go.dev/net/http/httptest#ResponseRecorder)。
  3. 对响应进行断言(检查状态码,解码主体并确保正确,检查任何重要的标头等)。

如果这样做,您将跳过任何像身份验证这样的中间件,直接进入处理程序代码。如果有特定的复杂性需要构建一些测试支持,这是很好的。然而,当您的测试代码像真实用户一样调用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

1
2
3
4
5
6
func Test(t *testing.T) {
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
t.Cleanup(cancel)
go run(ctx) // 测试代码放在这里
}

等待就绪状态

由于run函数在一个goroutine中执行,我们不知道它何时准备就绪。如果我们要像真正的用户一样开始使用API,我们需要知道何时它准备就绪。

我们可以设置一种信号就绪的方式,比如一个通道之类的东西,但我更喜欢在服务器上运行一个/healthz/readyz端点。正如我年迈的祖母常说的那样,布丁的真正好坏在于实际的HTTP请求(她当时就很先进)。

这是一个例子,我们努力使代码更具可测试性,也让我们了解到用户的需求。他们可能也想知道服务是否已经就绪,那为什么不提供一种官方的方式来获取这个信息呢?

要等待服务就绪,你可以编写一个循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// waitForReady调用指定的端点,直到收到200响应,或者上下文被取消,或者超时达到。
func waitForReady(
ctx context.Context,
timeout time.Duration,
endpoint string,
) error {
client := http.Client{}
startTime := time.Now()
for {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
endpoint,
nil,
)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Error making request: %s\n", err.Error())
continue
}
if resp.StatusCode == http.StatusOK {
fmt.Println("Endpoint is ready!")
resp.Body.Close()
return nil
}
resp.Body.Close()
select {
case <-ctx.Done():
return ctx.Err()
default:
if time.Since(startTime) >= timeout {
return fmt.Errorf("timeout reached while waiting for endpoint")
}
// 每次检查之间等待一小段时间
time.Sleep(250 * time.Millisecond)
}
}
}

将所有这些付诸实践

使用这些技术来构建简单的API仍然是我最喜欢的方法。它符合我追求的目标,即通过易于阅读、易于通过复制模式扩展、易于新人使用、易于修改而无需担心、明确地不使用任何魔法来实现可维护性的卓越代码。即使在我使用像我们自己的Oto包这样的代码生成框架来根据我自定义的模板为我编写样板代码的情况下,这一点仍然成立。

在更大的项目或较大的组织中,特别是像Grafana Labs这样的组织,你经常会遇到影响这些决策的特定技术选择。gRPC就是一个很好的例子。在已经形成了一些模式和经验、或者其他广泛使用的工具或抽象存在的情况下,你经常会发现自己做出实用主义的选择,跟随潮流,尽管我怀疑(或者说希望?)这篇文章对你仍然有一些有用的东西。

我的日常工作是与Grafana Labs内部的一组才华横溢的人员一起构建新的Grafana IRM套件。本文讨论的模式帮助我们交付可靠的工具。听到你在屏幕前大喊“告诉我更多关于这些伟大的工具的事情!”。

大多数人使用Grafana来可视化他们的系统运行情况,并且通过Grafana Alerting在指标超出可接受范围时收到通知。有了Grafana OnCall,你的计划和升级规则将自动化处理出现问题时与正确人员联系的过程。

Grafana Incident让你管理那些不可避免的全员参与时刻,这对我们大多数人来说都太熟悉了。它为你创建Zoom会议室,一个专用的Slack频道,并跟踪事件的时间线,让你专注于解决问题。在Slack中,你在频道中以机器人表情符号作为反应标记的任何内容都将添加到时间线中。这样,在进行总结或事后审查讨论时,很容易收集关键事件。