13 年后,我如何用 Go 编写 HTTP 服务(译)

原文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 的一期节目。

如果你熟悉这篇文章的前几个版本,这一节包含了现在有什么不同的快速总结。如果你想从头开始,请跳到下一节。

  1. 我的 handler 过去是挂在 server 结构体上的方法,但现在不再这么做了。如果一个 handler 函数需要一个依赖项,它可以很好地将其作为参数请求。当你只是试图测试一个单独的 handler 时,不再有意外的依赖项。
  2. 我过去更喜欢http.HandlerFunc而不是http.Handler ------ 足够多的第三方库首先考虑的是http.Handler,所以接受这个事实是有意义的。http.HandlerFunc仍然非常有用,但现在大部分东西都被表示为接口类型。无论哪种方式,差别都不大。
  3. 我增加了更多关于测试的内容,包括一些观点 ™。
  4. 我增加了更多的章节,所以建议每个人都全文阅读。

(译者注:第 3 点结尾的 TradeMark 商标缩写,是一种幽默的说法,意味着作者对测试的观点是独特的,可能有些争议,但他自己非常坚信。)

NewServer构造函数

让我们从查看任何 Go 服务的核心开始:server 。NewServer函数创建主http.Handler。通常我每个服务有一个,依赖 HTTP 路由将流量引导到每个服务内的正确 handler ,因为:

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

例如,你的代码可能看起来类似这样:

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 中间件和日志:

go 复制代码
var handler http.Handler = mux
handler = logging.NewLoggingMiddleware(logger, handler)
handler = logging.NewGoogleTraceIDMiddleware(logger, handler)
handler = checkAuthHeaders(handler)
return handler

server 通常是使用 Go 的内置http包来暴露它:

go 复制代码
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

长参数列表

必须有一个限制点,到达这个点后,它就不再是正确的做法,但大多数时候,我都很乐意将依赖项列表作为参数添加进来。虽然它们有时会变得相当长,但我发现这仍然是值得的。

是的,它让我免于创建一个结构体,但真正的好处是,从参数中得到了稍微更多的类型安全性。我可以创建一个结构体,跳过不喜欢的任何字段,但函数强制我必须这样做。必须查找字段以知道如何在结构体中设置它们,而如果不传递正确的参数,我就无法调用函数。

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

go 复制代码
srv := NewServer(
    logger,
    config,
    tenantsStore,
    commentsStore,
    conversationService,
    chatGPTService,
)

routes.go中映射整个 API surface

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

有时候你无法避免让事情在一定程度上散布开来,但能够在每个项目中去一个文件中查看其 API surface 是非常有帮助的。

由于NewServer构造函数中的大量依赖项参数列表,通常会在你的路由函数中遇到相同的列表。但再次,这并不那么糟糕。如果你忘记了什么或者顺序错了,由于 Go 的类型检查,你很快就会知道。

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 函数,也可以实现你的梦想:

go 复制代码
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.Stdinos.Stdoutos.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函数声明:

go 复制代码
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 来决定是否停止它们。

控制环境

argsgetenv参数为我们提供了几种通过 flags 和环境变量控制程序行为的方式。flags 是通过 args 进行处理的(只要你不使用全局 flags,而是在run内部使用flags.NewFlagSet),所以我们可以通过不同的值来调用run

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

如果你的程序优先使用环境变量而不是 flags(或者两者都用),那么getenv函数允许你插入不同的值,而不用改变实际的env

go 复制代码
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函数中,我们可以传入真实的内容:

go 复制代码
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.Handlerhttp.HandlerFunc,而是返回自身。具体来说,它们返回http.Handler类型。

go 复制代码
// 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 支持。)

go 复制代码
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 时传递它:

go 复制代码
err := encode(w, r, http.StatusOK, obj)

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

go 复制代码
decoded, err := decode[CreateSomethingRequest](r)

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

验证数据

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

go 复制代码
// 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 方法来实现那个接口。

go 复制代码
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 。

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

go 复制代码
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文件中列出中间件:

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"数据的数组,用于以后的恶作剧),那我可能会有一个返回中间件的函数。

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

go 复制代码
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。

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

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

go 复制代码
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))

有些人(但不是我)喜欢将该函数作为类型定义,就像这样:

go 复制代码
// 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 有用。

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

go 复制代码
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端点,我们可能只关心他们的名字,可以在测试代码中表达这一点:

go 复制代码
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 首次被调用。

这可以改善应用程序的启动时间。

go 复制代码
func (s *server) 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函数之外,如果出现问题,我们仍然会显示错误,并且不会在日志中丢失
  • 如果 handler 没有被调用,昂贵的工作就不会被完成 ------ 这可以根据你的代码部署方式带来很大的好处

请记住,通过这样做,你将初始化时间从启动时移动到运行时(当第一次访问 API 端点时)。我经常使用 Google App Engine,所以这对我有意义,但你的情况可能会有所不同,所以值得思考在何处以及何时以这种方式使用sync.Once

为可测试性设计

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

在 Go 中进行测试时,你有很多选择,它无关对错,更多的是:

  • 通过查看测试,是否能帮助你理解程序的功能
  • 更改现有代码时,是否不再担心改坏老功能
  • 如果所有的测试都通过,能否推送到生产环境?还是它需要覆盖更多的内容?

单元测试(UT)的单元是什么?

遵循这些模式,handler 本身也可以独立地进行测试,但我倾向于不这样做,这将在下面解释原因。必须考虑什么是你项目的最佳方法。

只测试 handler,你可以:

  1. 调用函数来获取 http.Handler ------ 你必须传入所有的依赖项(这是一个特性)。
  2. 使用 http.RequesthttptestResponseRecorder 返回的 http.Handler 调用 ServeHTTP 方法包(参见 https://pkg.go.dev/net/http/httptest#ResponseRecorder
  3. 对响应进行断言(检查状态码,解码 body 并确保它是正确的,检查任何重要的 headers 等)。

如果这样做,你将裁剪掉像 auth 这样的中间件,直接执行 handler 代码。如果你想要围绕某些特定的复杂性构建一些测试支持,这是很好的。然而,当你的测试代码以与你的用户以相同的方式调用 API 时,这是一个优势。我倾向于在这个级别进行端到端测试,而不是对所有内部的片段进行单元测试。

我宁愿调用 run 函数来尽可能接近生产环境的方式执行整个程序。这将解析任何参数,连接到任何依赖项,迁移数据库,无论在实际运行中它会做什么,最终启动服务器。然后,当我从测试代码中调用 API 时,遍历所有的层级,甚至与一个真实的数据库进行交互,同时也在测试 routes.go

我发现通过这种方法可以更早地发现更多问题,并且可以避免专门测试样板代码 (译者注:比如错误处理、数据库连接、日志记录等代码)。它也减少了我测试中的重复工作。如果认真测试每一层,我可能会以稍微不同的方式多次重复相同的内容。你必须维护所有这些,如果之后你想改变一些代码,更新一个函数和三个测试并不感觉很有成效。使用端到端测试,你只需要有一套描述用户和系统之间交互的主要测试 case。

我仍然在适当的地方使用单元测试。如果使用 TDD(我经常这样做)那么通常已经完成了很多测试,我很乐意维护这些 UT。但如果 UT 与端到端测试重复了相同的内容,我会删除 UT。

这个决定将取决于很多事情,从你周围的人的观点到项目的复杂性,所以就像这篇文章中的所有建议一样,如果它不适合你,不要强行采用。

使用 run 函数进行测试

我喜欢在每个测试中调用 run 函数,它们都有自己的程序实体。对于每个测试,我可以传递不同的参数,flag 值,标准输入和输出流,甚至环境变量。

由于 run 函数接受一个 context.Context,并且由于我们所有的代码都重视上下文(对吧,大家?它重视上下文,是吧?)我们可以通过调用 context.WithCancel 获取一个取消函数。通过推迟执行 cancel 函数,当测试函数返回时(即,测试完成运行)上下文将被取消,程序被优雅地关闭。在 Go 1.14 中,他们添加了 t.Cleanup 方法,这是一个替代你自己使用 defer 关键字的方法,如果你想了解更多原因,请查看此问题:https://github.com/golang/go/issues/37333

这一切都可以用令人惊讶的少量代码实现。当然,你也必须在各处不断检查 ctx.Errctx.Done

go 复制代码
func Test(t *testing.T) {
    ctx := context.Background()
    ctx, cancel := context.WithCancel(ctx)
    t.Cleanup(cancel)
    go run(ctx)
    // test code goes here

等待准备就绪

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

我们可以设置某种方式的准备就绪信号,比如通道或其他 ------ 但我更喜欢在服务器上运行一个 /healthz/readyz API 端点。就像我老奶奶常说的,布丁的证明在于实际的 HTTP 请求(她在那的时代遥遥领先)。

(译者注:该处使用了英文谚语 "The proof of the pudding is in the eating" 来比喻,"布丁好不好吃,吃了才知道")

在这个示例中,我们努力使代码更具可测试性,这使我们能够深入了解用户的需求。他们可能想知道服务是否已准备好,那么为什么不通过官方方式来找出答案呢?

为了等待服务准备就绪,可以写一个循环:

go 复制代码
// waitForReady calls the specified endpoint until it gets a 200
// response or until the context is cancelled or the timeout is
// reached.
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")
            }
            // wait a little while between checks
            time.Sleep(250 * time.Millisecond)
        }
    }
}

把这些都付诸实践

使用这些技术实现简单的 API 仍然是我最喜欢的方式。它符合我的目标,即实现优秀的可维护性,代码易读,易于通过复制模式进行扩展,新人易于使用,易于改变而不用担心,明确地没有任何魔法。即使我使用代码生成框架(如我们自己的 Oto 包)根据自定义的模板为我编写样板代码的情况下,也是如此。

在更大的项目或组织中,特别是像 Grafana Labs 这样的组织,经常会遇到影响这些决策的特定技术选择。gRPC 就是一个很好的例子。在已建立的模式和经验,或其他广泛使用的工具或抽象的情况下,你通常会发现自己做出了与流行方式一致的务实选择,尽管我怀疑(或者是希望?)这篇文章对你仍然有所帮助。

我目前的工作是与 Grafana Labs 内的一群才华横溢的人一起构建新的 Grafana IRM 套件。本文中讨论的模式帮助我们提供人们可以依赖的工具。我听到你在显示器前大喊:"告诉我更多关于这些优秀工具的信息!"。

大多数人使用 Grafana 来可视化他们的系统运行情况,而 Grafana Alerting 则在指标超出边界值时通知他们。使用 Grafana OnCall,你的日程安排和升级规则可以在出现问题时自动联系合适的人员。

Grafana Incident 让你管理那些我们大多数人熟悉的不可避免的全员参与的时刻。它为你创建一个 Zoom 房间讨论问题,一个专门的 Slack 频道,并在你专注于灭火的同时跟踪事件的时间线。在 Slack 中,你在频道中用 emoji 表情作为反应标记的任何事情都会被添加到时间线中。这使得可以非常轻松地收集关键事件,从而使汇报或事故后审查讨论变得更加容易。

今天就在 Grafana Cloud 中试试,或者如果你有幸有 Grafana 联系人,就联系他们询问一下。

Grafana Cloud 是开始使用 metrics, logs, traces, dashboards 等的最简单的方式。我们有一个慷慨的永久免费套餐和计划。现在就免费注册

相关推荐
魔尔助理顾问2 小时前
一个简洁高效的Flask用户管理示例
后端·python·flask
红豆和绿豆5 小时前
如何发起http的请求,在系统中集成
网络·网络协议·http
李长渊哦6 小时前
使用Druid连接池优化Spring Boot应用中的数据库连接
数据库·spring boot·后端
web135085886356 小时前
【Spring Boot】Spring AOP动态代理,以及静态代理
spring boot·后端·spring
nbsaas-boot7 小时前
Go 自动升级依赖版本
开发语言·后端·golang
zzyh1234567 小时前
springcloud的组件及作用
后端·spring·spring cloud
陈译8 小时前
Grafana——如何迁移Grafana到一台新服务器
运维·服务器·grafana
尚学教辅学习资料8 小时前
基于SpringBoot的图书借阅小程序+LW参考示例
spring boot·后端·小程序·java毕设·图书借阅
wangjun51598 小时前
linux redis ipv6、ipv4 只接收本地访问、接收本地和远程访问
linux·运维·服务器
x66ccff8 小时前
【nvidia】NCCL禁用P2P后果权衡
服务器·网络协议·p2p