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

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

TOC

推荐查看原文!!!

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。

例如,你的代码可能如下所示:

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、认证中间件和日志记录等:

var handler http.Handler = mux
handler = logging.NewLoggingMiddleware(logger, handler)
handler = logging.NewGoogleTraceIDMiddleware(logger, handler)
handler = checkAuthHeaders(handler)
return handler

配置服务器通常就是是使用 Go 的内置 http 包公开对外提供服务:

// 创建一个新的 Server 实例
srv := NewServer(  
	// 传递日志记录器
	logger,  
	// 传递配置信息
	config,  
	// 传递租户存储
	tenantsStore,  
	// 传递 Slack 链接存储
	slackLinkStore,  
	// 传递 Microsoft Teams 链接存储
	msteamsLinkStore,  
	// 传递代理
	proxy,  
)  
// 创建一个 HTTP 服务器
httpServer := &http.Server{  
	// 设置服务器地址
	Addr:    net.JoinHostPort(config.Host, config.Port),  
	// 设置服务器处理程序
	Handler: srv,  
}  
// 启动一个 goroutine 来监听和服务 HTTP 请求
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)  
	}  
}()  
// 创建一个 WaitGroup 用于等待所有 goroutine 完成
var wg sync.WaitGroup  
// 添加一个 goroutine 到 WaitGroup
wg.Add(1)  
// 启动一个 goroutine 来处理关闭操作
go func() {  
	// 延迟执行 Done() 函数
	defer wg.Done()  
	// 等待上下文被取消
	<-ctx.Done()  
	// 创建一个新的上下文,设置 10 秒的超时时间
	shutdownCtx := context.Background()  
	shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10 * time.Second)  
	// 延迟执行 cancel() 函数
	defer cancel()  
	// 关闭 HTTP 服务器
	if err := httpServer.Shutdown(shutdownCtx); err != nil {  
		// 如果发生错误,打印错误信息
		fmt.Fprintf(os.Stderr, "error shutting down http server: %s\n", err)  
	}  
}()  
// 等待所有 goroutine 完成
wg.Wait()  
// 返回 nil
return nil

长参数列表

参数的个数有一个上限,超过再继续增加旧不太合适了,但大多数时候我乐于将依赖项列表添加为参数。尽管有时列表会变得很长,我觉得这样做仍然是值得的。

是的,虽然这样可以省去创建结构体的步骤,但真正的好处是通过参数获得了更好的类型安全性。我可以在结构体中跳过不喜欢的字段,而函数则会强制我这样做——如果不传递正确的参数,就无法调用函数,而设置结构体时,需要查找每个字段才能知道如何在结构体中设置它们。

如果像现代前端代码那样以垂直列表格式化它,其实看起来也没那么糟糕:

srv := NewServer(
	logger,
	config,
	tenantsStore,
	commentsStore,
	conversationService,
	chatGPTService,
)

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

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

虽然有时难免会到处分散的有,但在每个项目中能通过一个文件来查看其所有的 API 接口还是体验不错的。

由于 NewServer 构造函数中依赖许多参数,通常会在路由函数中出现一样的情况。但话说回来,这也不算很糟。而且由于 Go 的类型检查,你能很快知道自己是否遗漏了参数或是搞错了顺序。

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 不返回 error。任何可能抛出错误的操作都被移动到 run 函数中,并在到达这一步之前进行处理,从而使此函数简单和扁平化。当然,如果你的处理函数因某些原因返回 error,那么这个函数也可以返回 error。

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

run 函数类似于 main 函数,不同的是它接收操作系统的基本参数,并会返回一个错误。

我希望 func main() 是 func main() error。或者像 C 语言那样可以返回退出代码:func main() int。通过拥有一个极其简单的 main 函数,你也可以梦想成真:

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)
	}
}
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.Stdinio.Reader用于读取输入
os.Stdoutio.Writer用于写入输出
os.Stderrio.Writer用于写入错误日志
os.Getenvfunc(string) string用于读取环境变量
os.Getwdfunc() (string, error)获取工作目录

如果避免使用任何全局作用域的数据,通常可以在更多地方使用 t.Parallel() 来加速测试套件。因为所有数据都是自有的,所以对 run 的多次调用不会相互干扰。

我经常得到如下所示的 run 函数签名:

func run(
	ctx    context.Context,
	args   []string,
	getenv func(string) string,
	stdin  io.Reader,
	stdout, stderr io.Writer,
) error

现在我们进入 run 函数,可以继续编写正常的 Go 代码,其中可以毫无顾忌地返回 error。我们这些 gopher 就是喜欢返回 error,越早承认这一点,网络上的那些人就能早点“赢”并走开。

优雅停止

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

控制环境变量

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

args := []string{
	"myapp",
	"--out", outFile,
	"--fmt", "markdown",
}
go run(ctx, args, etc.)

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

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 函数中,我们可以传入实际的参数:

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.Handler 或 http.HandlerFunc,而是返回它们。具体来说,它们返回 http.Handler 类型。

// handleSomething 处理你经常听到的那些 Web 请求之一。
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")
		}
	)
}

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

请确保只读取共享数据。如果 handler 修改了任何内容,则需要使用互斥锁或其他类似机制来保护。

在这里存储程序状态通常不是你想要的。在大多数云环境中,你不能指望代码能长时间运行。根据你的生产环境,服务器通常会关闭以节约资源,或者因其他原因崩溃。还有可能会有多个服务实例运行,请求的负载会以不可预知的方式分配。在这种情况下,一个实例只能访问它自己的本地数据。因此,在真实项目中,最好使用数据库或其他存储 API 来持久化数据。

在同一处解码/编码

每个服务都需要解码请求正文和编码响应正文。这是一种久经考验的抽象。

我通常有一对名为 encode 和 decode 的辅助函数。下面展示了一个使用泛型的示例版本,其实也就是封装了一些基本的代码,通常我不会这么做,但当你需要对所有 API 进行修改时,这就会变得很有用。(比如说,如果你有个还活在上个世纪的新上司,他想要添加 XML 支持。)

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 时不需要传递它:

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

但是由于它是 decode 中的返回参数,因此你需要指定你期望的类型:

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

我尽量不重载这些函数,但在过去,我对一个简单地融入 decode 函数的验证接口感到非常满意。

验证数据

我喜欢简单的接口。其实,我非常喜欢。单方法接口实现起来特别容易。所以在验证对象时,我喜欢这样做:

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

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

该方法可以执行验证结构字段所需的任何操作。例如,它可以检查以确保:

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

如果你需要做更复杂的事情,比如在数据库中检查字段,这应该在别处处理;这可能太重要,不适合被视为快速验证检查。而且你不会希望在这样的函数中看到这种操作,所以它可能会轻易被隐藏起来。

然后我使用类型断言来检查对象是否实现了接口。或者在用泛型时,我可能会选择通过修改 decode 方法来更明确地要求那个接口必须被实现。

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,但不会引发 panic。

中间件的适配器模式

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

一个例子是检查以确保用户是管理员:

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 文件中列出中间件:

package app

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

只需查看接口 URL,就可以非常清楚地了解将哪个中间件应用于哪个路由。如果列表开始变大,请尝试将它们拆分成多行——我知道,但你会习惯的。

有时我会返回中间件

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

问题是,你最终会得到如下所示的代码:

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))

这会使代码膨胀,并且不会真正提供任何有用的东西。相反,我会让中间件函数获取依赖项,但返回一个只获取下一个处理程序的函数。

func newMiddleware(
	logger Logger,
	db *DB,
	slackClient *slack.Client,
	rroll []byte,
) func(h http.Handler) http.Handler

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

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))

有些人,但不是我,喜欢将这种函数类型形式化如下:

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

这很好。如果你喜欢,就这样做。我不会去你的工作场所,在外面等你,然后用胳膊搂着你的肩膀以一种恐吓的方式走在你旁边,问你是否对自己感到满意。

我不这样做的原因是因为它提供了一个额外的间接层。当你查看上面 newMiddleware 函数的签名时,它非常清楚地说明了发生了什么。如果返回类型是中间件,则你需要做一些额外的工作。本质上,我这么处理是为了更好地读取代码,而不是编写代码。

隐藏请求/响应类型

如果一个接口有自己特定的请求和响应类型,通常它们只对那个特定的 handler 有用。

如果是这种情况,你可以在函数内部定义它们。

func handleSomething() http.HandlerFunc {
	type request struct {
		Name string
	}
	type response struct {
		Greeting string `json:"greeting"`
	}
	return func(w http.ResponseWriter, r *http.Request) {
		...
	}
}

这可以让代码保持清晰,并防止其他 handler 依赖你可能认为不稳定的数据。

在测试代码需要使用相同类型时,有时会遇到一些摩擦。说实话,如果你想要这样做,这是拆分它们的一个很好理由。

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

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

这是一个向以后的代码维护者描述的机会,他们需要理解你的代码。

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

func TestGreet(t *testing.T) {  
	// 创建一个新的测试辅助工具
	is := is.New(t)  
	// 定义一个测试用的 Person 结构体
	person := struct {  
		// 使用 `json:"name"` 标签指定 JSON 序列化时的字段名
		Name string `json:"name"`  
	}{  
		// 设置 Name 字段的值
		Name: "Mat Ryer",  
	}  
	// 创建一个新的 bytes.Buffer 用于存储 JSON 数据
	var buf bytes.Buffer  
	// 使用 json.NewEncoder 将 Person 结构体编码成 JSON 数据
	err := json.NewEncoder(&buf).Encode(person)  
	// 使用 is.NoErr() 断言编码操作没有错误
	is.NoErr(err) // json.NewEncoder  
	// 创建一个新的 HTTP 请求
	req, err := http.NewRequest(http.MethodPost, "/greet", &buf)  
	// 使用 is.NoErr() 断言创建请求没有错误
	is.NoErr(err)  
	//... more test code here
}

从这个测试中可以清楚地看出,我们唯一关心的字段是 Name 字段。

sync.Once defer 设置

如果我在准备 handler 时必须做任何耗时的事情,我会将其推迟到第一次调用时。这可以缩短应用程序启动时间。

func handleTemplate(files string...) http.HandlerFunc {  
	// 使用 sync.Once 保证模板只解析一次
	var (  
		init    sync.Once  
		tpl     *template.Template  
		tplerr  error  
	)  
	// 返回一个 http.HandlerFunc,用于处理请求
	return func(w http.ResponseWriter, r *http.Request) {  
		// 使用 sync.Once.Do() 保证模板解析只执行一次
		init.Do(func(){  
			// 解析模板文件
			tpl, tplerr = template.ParseFiles(files...)  
		})  
		// 如果模板解析发生错误,则返回错误信息
		if tplerr != nil {  
			http.Error(w, tplerr.Error(), http.StatusInternalServerError)  
			return  
		}  
		// 使用解析好的模板
		// ... 使用 tpl 
	}  
}

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

  • 错误检查在 init 函数之外,因此如果出现问题,我们仍然会显示错误,并且不会在日志中丢失它
  • 如果未调用 handler,则永远不会完成此任务——这可能会带来巨大的好处,取决于您的代码是如何部署的。

请记住,这样做会将初始化时间从启动转移到运行时(即首次访问接口时)。我经常使用 Google App Engine,所以这对我有意义,但您的情况可能不同,因此值得考虑在何处以及何时以这种方式使用 sync.Once。

面向测试而设计

这些模式的演变部分是因为它们易于测试代码。run 函数是一种直接从测试代码运行程序的简单方法。

在 Go 中进行测试时有很多选择,与其说是对错,不如说更重要的是:

  • 通过查看测试,了解你的程序的功能有多容易?
  • 在不担心破坏程序的情况下修改代码有多容易?
  • 如果你的所有测试都通过了,你可以将其发布到生产环境吗?还是需要覆盖更多测试内容?

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

按照这些模式,handler 本身也是可以独立测试的,但我通常不会这样做,我将在下面解释原因。你必须考虑哪种方法最适合你的项目。

要仅测试 handler,你可以:

  1. 调用该函数以获取 http.Handler——你必须传入所有必需的依赖项(这是一个特性)。
  2. 在返回的 http.Handler 上调用 ServeHTTP 方法,使用真实的 http.Request 和来自 httptest 包的 ResponseRecorder(请参阅https://pkg.go.dev/net/http/httptest#ResponseRecorder)
  3. 对响应进行断言(检查状态代码,解码正文并确保它是正确的,检查任何重要的标头等)

如果你这样做,你将跳过任何中间件(如身份验证之类),并直接进入 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:

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 请求(她真是超前)。

这是一个例子,说明我们为了让代码更易于测试而付出的努力,可以让我们了解用户的需求。他们可能也想知道服务是否准备就绪,那么为什么不提供一个官方的方式来了解呢?

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

// waitForReady 函数会调用指定的接口,直到它收到 200 响应,或者上下文被取消,或者超时。
func waitForReady(  
	// 传入上下文,用于取消操作
	ctx context.Context,   
	// 传入超时时间
	timeout time.Duration,   
	// 传入待检测的端点
	endpoint string,  
) error {  
	// 创建一个新的 HTTP 客户端
	client := http.Client{}  
	// 记录开始时间
	startTime := time.Now()  
	// 循环调用端点,直到收到 200 响应
	for {  
		// 创建一个新的 HTTP 请求,并设置上下文
		req, err := http.NewRequestWithContext(  
			ctx,   
			http.MethodGet,   
			endpoint,   
			nil,  
		)  
		// 如果创建请求失败,返回错误
		if err != nil {  
			return fmt.Errorf("failed to create request: %w", err)  
		}  
  
		// 执行 HTTP 请求
		resp, err := client.Do(req)  
		// 如果执行请求失败,打印错误信息并继续循环
		if err != nil {  
			fmt.Printf("Error making request: %s\n", err.Error())  
			continue  
		}  
		// 如果响应状态码为 200,则表示端点已就绪
		if resp.StatusCode == http.StatusOK {  
			fmt.Println("Endpoint is ready!")  
			// 关闭响应体
			resp.Body.Close()  
			// 返回 nil,表示成功
			return nil  
		}  
		// 关闭响应体
		resp.Body.Close()  
  
		// 使用 select 语句判断是否应该继续等待
		select {  
		// 如果上下文被取消,则返回错误信息
		case <-ctx.Done():  
			return ctx.Err()  
		// 否则,检查是否已超时
		default:  
			// 如果已经超时,则返回超时错误信息
			if time.Since(startTime) >= timeout {  
				return fmt.Errorf("timeout reached while waiting for endpoint")  
			}  
			// 等待一段时间,然后再次尝试请求
			// wait a little while between checks  
			time.Sleep(250 * time.Millisecond)  
		}  
	}  
}

将所有这些付诸实践

查看原文

https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/