在使用Go七年后我如何编写Go HTTP服务

原文:https://medium.com/statuscode/how-i-write-go-http-services-after-seven-years-37c208122831
翻译:devabel
自从r59(一个1.0之前的版本)以来,我一直在写Go(那时还不叫Golang),并且在过去的七年里一直在Go中构建HTTP API和服务。
我在Machine Box工作,我的大多数技术工作都涉及构建各种API。 机器学习很复杂,大多数开发人员都无法掌握,因此我的工作是通过API接口简化这个过程,到目前为止我们已经得到了很好的反馈。
如果您还没有亲眼尝试过Machine Box开发者的体验,请试一试,让我知道您的想法。
多年来,我编写服务的方式发生了变化,因此今天我想分享如何编写服务 - 也许这种方式对您的工作有帮助。

A server struct

我的所有组件都有一个 server结构体,通常看起来像这样:

type server struct {
    db     *someDatabase
    router *someRouter
    email  EmailSender
}

共享依赖项是结构的字段

routes.go

我的每个组件中都有一个名为routes.go的文件,其中所有路由都在这个文件里:

package app
func (s *server) routes() {
    s.router.HandleFunc("/api/", s.handleAPI())
    s.router.HandleFunc("/about", s.handleAbout())
    s.router.HandleFunc("/", s.handleIndex())
}

这很方便,因为大多数代码维护都是以URL和错误报告开始的 - 所以只需浏览一下routes.go即可帮助我们调试。

处理程序挂起服务器

我的HTTP处理程序挂起了服务器:

func (s *server) handleSomething() http.HandlerFunc { ... }

处理程序可以通过s服务器变量访问依赖项。

返回处理程序

我的处理函数实际上并不处理请求,它们返回一个函数。
这给了我们一个闭包环境,我们的处理程序可以在其中运行

func (s *server) handleSomething() http.HandlerFunc {
    thing := prepareThing()
    return func(w http.ResponseWriter, r *http.Request) {
        // use thing        
    }
}

prepareThing仅被调用一次,因此您可以使用它来执行一次性每个处理程序初始化,然后在处理程序中使用该事物。
确保只读取共享数据,如果处理程序正在修改任何内容,请记住您需要一个互斥锁或其他东西来保护它。

获取特定于处理程序的依赖项的参数

如果特定处理程序具有依赖项,请将其作为参数。

func (s *server) handleGreeting(format string) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, format, "World")
    }
}

格式变量可供处理程序访问。

处理程序上的HandlerFunc

我现在几乎在所有情况下都使用http.HandlerFunc,而不是http.Handler。

func (s *server) handleSomething() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ...
    }
}

它们或多或少是可以互换的,所以只需选择更容易阅读的内容。 对我来说,这是http.HandlerFunc。

中间件只是一个Go函数

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

func (s *server) adminOnly(h http.HandlerFunc) http.HandlerFunc {
    return 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并返回(abort); 注意没有调用h处理程序。
如果IsAdmin为true,则执行将传递给传入的h处理程序。
通常我在routes.go文件中列出了中间件:

package app
func (s *server) routes() {
    s.router.HandleFunc("/api/", s.handleAPI())
    s.router.HandleFunc("/about", s.handleAbout())
    s.router.HandleFunc("/", s.handleIndex())
    s.router.HandleFunc("/admin", s.adminOnly(s.handleAdminIndex()))
}

请求和响应类型也可以在那里

如果一个接口有自己的请求和响应类型,通常它们仅对该特定处理程序有用。
如果是这种情况,您可以在函数内定义它们。

func (s *server) 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接口,我们可能只关心他们的名字,所以我们可以在测试代码中表达:

func TestGreet(t *testing.T) {
    is := is.New(t)
    p := struct {
        Name string `json:"name"`
    }{
        Name: "Mat Ryer",
    }
    var buf bytes.Buffer
    err := json.NewEncoder(&buf).Encode(p)
    is.NoErr(err) // json.NewEncoder
    req, err := http.NewRequest(http.MethodPost, "/greet", &buf)
    is.NoErr(err)
    //... more test code here

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

sync.Once设置依赖项

如果我在准备处理程序时必须做任何昂贵的事情,我会推迟到第一次调用该处理程序时。
这提高了应用程序启动时间

func (s *server) handleTemplate(files string...) http.HandlerFunc {
    var (
        init sync.Once
        tpl  *template.Template
        err  error
    )
    return func(w http.ResponseWriter, r *http.Request) {
        init.Do(func(){
            tpl, err = template.ParseFiles(files...)
        })
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        // use tpl
    }
}

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

  1. 错误检查在init函数之外,所以如果出现问题我们仍然会出现错误并且不会在日志中丢失它
    2.如果未调用处理程序,则永远不会完成昂贵的工作 - 这可能会带来很大的好处,具体取决于代码的部署方式
    请记住,执行此操作时,您将初始化时间从启动时移至运行时(首次访问端点时)。 我经常使用Google App Engine,所以这对我来说很有意义,但是你的情况可能会有所不同,所以值得思考何时何地使用sync.Once这样。

服务器是可测试的

我们的服务器类型非常便于测试。

func TestHandleAbout(t *testing.T) {
    is := is.New(t)
    srv := server{
        db:    mockDatabase,
        email: mockEmailSender,
    }
    srv.routes()
    req, err := http.NewRequest("GET", "/about", nil)
    is.NoErr(err)
    w := httptest.NewRecorder()
    srv.ServeHTTP(w, req)
    is.Equal(w.StatusCode, http.StatusOK)
}

在每个测试中创建一个服务器实例 - 如果昂贵的东西延迟加载,这将不会花费太多时间,即使对于大组件
通过在服务器上调用ServeHTTP,我们正在测试整个堆栈,包括路由和中间件等。如果你想避免这种情况,你当然可以直接调用处理程序方法。
使用httptest.NewRecorder记录处理程序正在执行的操作
此代码示例使用我的测试迷你框架(作为Testify的迷你替代品)

结论

我希望本文中涉及的内容有意义,并帮助您完成工作。 如果您不同意或有其他想法,请发推特给我。

推荐阅读更多精彩内容