原文:Mat Ryer - 2024.02.09
大约六年前,我写了一篇博客文章,概述了我是如何用 Go 编写 HTTP 服务的,现在我再次告诉你,我是如何写 HTTP 服务的。
那篇原始的文章引发了一些热烈的讨论,这些讨论影响了我今天的做事方式。在主持 Go Time podcast、在X/Twitter上讨论 Go 以及通过多年的代码维护经验后,我认为是时候进行一次更新了。
(对于那些注意到 Go 并不完全有 13 年历史的吹毛求疵者,我开始用 Go 编写 HTTP 服务是在 version .r59。)
这篇文章涵盖了一系列与在 Go 中构建服务相关的主题,包括:
- 为了最大化的可维护性,如何结构化 servers 和 handlers
- 优化快速启动和优雅关闭的技巧和窍门
- 如何处理适用于多种类型请求的常见工作
- 如何深入测试你的服务
从小项目到大项目,这些实践对我来说经受住了时间的考验,我希望它们也能对你有所帮助。
这篇文章适合谁?
这篇文章适合你。它适合所有打算用 Go 写某种 HTTP 服务的人。如果你正在学习 Go,你可能也会发现这篇文章很有用,因为很多例子都遵循了良好的实践。经验丰富的 gopher 也可能会发现一些不错的模式。
要想最大限度地利用这篇文章,你需要了解 Go 的基础知识。如果你觉得自己还没有达到这个水平,强烈推荐你阅读 Chris James 的通过测试学习 Go。如果你想听到更多来自 Chris 的内容,可以查看我们和 Ben Johnson 在 The files & folders of Go projects上做的 Go Time 的一期节目。
如果你熟悉这篇文章的前几个版本,这一节包含了现在有什么不同的快速总结。如果你想从头开始,请跳到下一节。
- 我的 handler 过去是挂在 server 结构体上的方法,但现在不再这么做了。如果一个 handler 函数需要一个依赖项,它可以很好地将其作为参数请求。当你只是试图测试一个单独的 handler 时,不再有意外的依赖项。
- 我过去更喜欢
http.HandlerFunc
而不是http.Handler
—— 足够多的第三方库首先考虑的是http.Handler
,所以接受这个事实是有意义的。http.HandlerFunc
仍然非常有用,但现在大部分东西都被表示为接口类型。无论哪种方式,差别都不大。 - 我增加了更多关于测试的内容,包括一些观点 ™。
- 我增加了更多的章节,所以建议每个人都全文阅读。
(译者注:第 3 点结尾的 TradeMark 商标缩写,是一种幽默的说法,意味着作者对测试的观点是独特的,可能有些争议,但他自己非常坚信。)
NewServer
构造函数
让我们从查看任何 Go 服务的核心开始:server 。NewServer
函数创建主http.Handler
。通常我每个服务有一个,依赖 HTTP 路由将流量引导到每个服务内的正确 handler ,因为:
-
NewServer
是一个大的构造函数,它接受所有依赖项作为参数 - 如果可能,它会返回一个
http.Handler
,这可以是一个专用类型,用于处理更复杂的情况 - 它通常配置自己的 muxer(复用器),并调用
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
构造函数负责所有适用于所有 API 端点的* HTTP 事务,如 CORS、auth 中间件和日志:
var handler http.Handler = mux
handler = logging.NewLoggingMiddleware(logger, handler)
handler = logging.NewGoogleTraceIDMiddleware(logger, handler)
handler = checkAuthHeaders(handler)
return handler
server 通常是使用 Go 的内置http
包来暴露它:
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()
if err := httpServer.Shutdown(ctx); err != nil {
fmt.Fprintf(os.Stderr, "error shutting down http server: %s\n", err)
}
}()
wg.Wait()
return nil
长参数列表
必须有一个限制点,到达这个点后,它就不再是正确的做法,但大多数时候,我都很乐意将依赖项列表作为参数添加进来。虽然它们有时会变得相当长,但我发现这仍然是值得的。
是的,它让我免于创建一个结构体,但真正的好处是,从参数中得到了稍微更多的类型安全性。我可以创建一个结构体,跳过不喜欢的任何字段,但函数强制我必须这样做。必须查找字段以知道如何在结构体中设置它们,而如果不传递正确的参数,我就无法调用函数。
如果你将它格式化为一个垂直列表,就像我在现代前端代码中看到的那样,那么它并不那么糟糕:
srv := NewServer(
logger,
config,
tenantsStore,
commentsStore,
conversationService,
chatGPTService,
)
在routes.go
中映射整个 API surface
这个文件是你的服务中所有路由都列出的地方。
有时候你无法避免让事情在一定程度上散布开来,但能够在每个项目中去一个文件中查看其 API surface 是非常有帮助的。
由于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
不返回错误。任何可能抛出错误的事情都被移动到run
函数中,并在到达这一点之前解决,使这个函数保持简单和扁平。当然,如果你的任何 handler 因为某种原因返回错误,那么好的,这个也可以返回错误。
func 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 {
// ...
}
func main() {
ctx := context.Background()
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel()
if err := run(ctx, os.Stdout, os.Args); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
}
上述代码创建了一个 context,当Ctrl+C
或等效操作被调用时,它会被取消,并调用run
函数。如果run
返回nil
,函数正常退出。如果返回一个错误,我们将其写入 stderr 并以非零代码退出。如果正在写一个命令行工具,其中退出代码很重要,我会返回一个 int,这样就可以编写测试来断言返回了正确的代码。
操作系统的基础内容可以作为参数传入run
。例如,你可能会传入os.Args
(如果它支持 flag),甚至os.Stdin
、os.Stdout
、os.Stderr
依赖项。这使得你的程序更容易测试,因为测试代码可以调用run
来执行,通过传递不同的参数控制参数和所有流(streams)。
以下表格显示了运行函数的输入参数的示例:
值 | 类型 | 描述 |
---|---|---|
os.Args |
[]string |
执行程序时传入的参数。它也用于解析 flags。 |
os.Stdin |
io.Reader |
用于读取输入 |
os.Stdout |
io.Writer |
用于写入输出 |
os.Stderr |
io.Writer |
用于写入错误日志 |
os.Getenv |
func(string) string |
用于读取环境变量 |
os.Getwd |
func() (string, error) |
获取工作目录 |
如果你远离任何全局范围的数据,通常就可以在更多的地方使用t.Parallel()
,以加速测试套件(test suites)。所有的东西都是自包含的,所以多次调用run
不会相互干扰。
我经常会写这样的run
函数声明:
func run(
ctx context.Context,
args []string,
getenv func(string) string,
stdin io.Reader,
stdout, stderr io.Writer,
) error
现在我们在run
函数内部,可以编写正常的 Go 代码,可以随心的返回错误。我们 gophers 就喜欢返回错误,越早承认这一点,那些在互联网上的人就可以赢得胜利并离开。
优雅地关闭
如果你正在运行大量的测试,那么当每一个都完成时,程序停止是很重要的。(或者,你可能决定为所有的测试保持一个实例运行,但那取决于你。)
context 被传递下去。如果程序收到了终止信号,它就会被取消,所以在每个层级都要重视它。至少,将它传递给你的依赖项。最好在任何长时间运行或循环的代码中,检查Err()
方法,如果它返回一个错误,停止正在做的事情并将其返回。这将帮助 server 优雅地关闭。如果你启动了其他的 goroutines,也可以使用 context 来决定是否停止它们。
控制环境
args
和getenv
参数为我们提供了几种通过 flags 和环境变量控制程序行为的方式。flags 是通过 args 进行处理的(只要你不使用全局 flags,而是在run
内部使用flags.NewFlagSet
),所以我们可以通过不同的值来调用run
:
args := []string{
"myapp",
"--out", outFile,
"--fmt", "markdown",
}
go run(ctx, args, etc.)
如果你的程序优先使用环境变量而不是 flags(或者两者都用),那么getenv
函数允许你插入不同的值,而不用改变实际的env
。
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()
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel()
if err := run(ctx, os.Getenv, os.Stderr); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
}
Maker funcs 返回 handler
我的 handler 函数不直接实现http.Handler
或http.HandlerFunc
,而是返回自身。具体来说,它们返回http.Handler
类型。
// handleSomething handles one of those web requests
// that you hear so much about.
func handleSomething(logger *Logger) http.Handler {
thing := prepareThing()
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
// use thing to handle request
logger.Info(r.Context(), "msg", "handleSomething")
}
)
}
这种模式为每个 handler 提供了自己的闭包环境。你可以在这个空间中做一些初始化工作,当 handler 被调用时,数据将可用。
确保只读取共享数据。如果 handler 修改任何东西,你将需要一个互斥锁或其他东西来保护它。
在这里存储程序状态通常不是你想要的。在大多数云环境中,不能相信代码会在长时间内继续运行。根据你的生产环境,servers 通常会关闭以节省资源,甚至因为其他原因崩溃。也可能有许多服务实例正在运行,请求在它们之间以不可预测的方式负载均衡。在这种情况下,一个实例只能访问自己的本地数据。所以在真实项目中,最好使用数据库或其他存储 API 来持久化数据。
在一个地方处理解码/编码
每个服务都需要解码请求体和编码响应体。这是一个经得起时间考验的明智的抽象。
我通常有一对叫做 encode 和 decode 的辅助函数。一个使用泛型的例子向你展示了只包装了一些基本的代码,我通常不会这样做,然而当你需要为所有 API 在这里做出改变时,这变得有用。(例如,假设你有一个新老板被困在 90 年代,他们想添加 XML 支持。)
func encode[T any](w http.ResponseWriter, r *http.Request, status int, v T) error {
w.WriteHeader(status)
w.Header().Set("Content-Type", "application/json")
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 is an object that can be validated.
type Validator interface {
// Valid checks the object and returns any
// problems. If len(problems) == 0 then
// the object is valid.
Valid(ctx context.Context) (problems map[string]string)
}
Valid
方法接受一个 context(这是可选的,但过去对我有用)并返回一个 map。如果一个字段有问题,它的名字被用作键,一个详细解释被设置为值。
该方法可以做任何需要验证结构字段的事情。例如,它可以检查确保:
- 必需的字段不为空
- 具有特定格式(如电子邮件)的字符串是正确的
- 数字在可接受的范围内
如果你需要做任何更复杂的事情,比如在数据库中检查字段,那应该在其他地方进行;它可能太重要了,不能被视为一个快速的验证检查,而且你不希望在这样的函数中找到那种东西,所以它可能会很容易被隐藏起来。
然后我使用类型断言来看对象是否实现了接口。或者,在泛型世界中,可能会选择更明确地说明正在发生什么事情,通过改变 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
方法必须空 map,才能认为对象被成功解码。
对于校验的错误,返回nil
是安全的,因为我们将检查len(problems)
,对于nil
映射,它将是0
,不会引发 panic。
中间件的适配器模式
中间件函数(Middleware functions)接受http.Handler
参数并返回一个新的http.Handler
,它可以在调用原始 handler 之前和/或之后运行代码 —— 或者根本不调用原始 handler 。
一个例子是检查用户是否是管理员:
func (s *server) 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.ServeHTTP(w, r)
})
}
handler 内部的逻辑可以选择是否调用原始 handler 。在上述例子中,如果IsAdmin
为假, handler 将返回HTTP 404 Not Found
并返回(或中止);注意,h
处理器没有被调用。如果IsAdmin
为真,用户被允许访问路由,因此执行被传递给 h
处理器。
通常我会在routes.go
文件中列出中间件:
package app
func addRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/", s.handleAPI())
mux.HandleFunc("/about", s.handleAbout())
mux.HandleFunc("/", s.handleIndex())
mux.HandleFunc("/admin", s.adminOnly(s.handleAdminIndex()))
}
这使得代码非常清晰,只需查看 API 端点的映射,就可以知道哪个中间件应用于哪些路由。如果列表开始变得越来越大,尝试将它们分布在多行中 —— 我知道,我知道,但你会习惯的。
有时我会返回中间件
上述方法对于简单的情况非常好,但如果中间件需要大量的依赖项(一个 logger,一个 database,一些 API clients,一个包含“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))
这会使代码膨胀,而且没有提供任何有用的东西。相反,我会让中间件函数接受依赖项,然后返回一个函数,该函数只接受下一个 handler。
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 is a function that wraps http.Handlers
// proving functionality before and after execution
// of the h handler.
type middleware func(h http.Handler) http.Handler
这样做是可以的。如果你喜欢,就这样做。我不会来到你的工作地点,等你出来,然后用我的手臂搭在你的肩膀上以一种恐吓的方式走在你旁边,问你是否对自己感到满意。
我不这样做的原因是,它增加了一个额外的间接级别。当你查看上面的newMiddleware
函数的声明时,很清楚发生了什么事情。如果返回类型是中间件,你需要做一点额外的工作。从本质上讲,我优化的是阅读代码,而不是编写代码。
隐藏请求/响应类型的机会
如果一个 API 端点有自己的请求和响应类型,通常它们只对该特定 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
类型,并且我们在许多 API 端点上重用它。比如 /greet
端点,我们可能只关心他们的名字,可以在测试代码中表达这一点:
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) // json.NewEncoder
req, err := http.NewRequest(http.MethodPost, "/greet", &buf)
is.NoErr(err)
//... more test code here
从这个测试中可以看出,我们关心的唯一字段是Name
。
sync.Once
来推迟配置
如果我在准备 handler 时需要做任何昂贵的工作,我会推迟到该 handler 首次被调用。
这可以改善应用程序的启动时间。
func (s *server) handleTemplate(files