Go-秘籍-四-

67 阅读34分钟

Go 秘籍(四)

原文:Go Recipes

协议:CC BY-NC-SA 4.0

七、构建 HTTP 服务器

Go 是一种通用编程语言,可用于构建各种应用程序。说到 web 编程,Go 是构建后端 API 的一个很好的技术栈。Go 可能不是构建传统 web 应用程序的理想选择,在传统 web 应用程序中,web 应用程序使用服务器端模板执行 UI 呈现。当您为各种系统(包括 web 前端、移动应用程序和许多现代应用程序场景)构建支持后端系统的 RESTful APIs 时,Go 是最好的堆栈。一些现有的技术栈适合构建轻量级 RESTful APIs,但是当 HTTP 请求具有 CPU 密集型任务并且 API 在分布式环境中与其他后端系统通信时,这些系统最终会失败。Go 是构建大规模可伸缩后端系统和 RESTful APIs 的理想技术栈。在本章中,您将学习如何构建 HTTP 服务器来构建您的后端 API。

带有大量可扩展性的标准库包net/http,为在 Go 中编写 web 应用程序提供了基础层。如果您想为您的 Go web 应用程序使用服务器端模板,您可以利用标准库包html/template来呈现用户界面。在 Go 中,只需使用标准的库包,就可以构建全功能的 web 应用和 RESTful APIs,因此对于大多数 web 编程场景,尤其是构建 RESTful 服务,都不需要 web 框架。在大多数用例中,使用标准库包;每当您需要额外的功能时,请使用扩展标准库包的第三方库。

简而言之,web 编程基于请求-响应范例,其中客户端向 web 服务器发送 HTTP 请求,请求在 web 服务器上被处理,然后它向客户端发回 HTTP 响应。为了以这种方式处理 HTTP 请求和发送 HTTP 响应,包net/http提供了两个主要组件:

  • ServeMux 是一个 HTTP 请求多路复用器(HTTP 请求路由器),它将传入的 HTTP 请求的统一资源标识符(URIs)与预定义的 URI 模式列表进行比较,然后执行为 URI 模式配置的相关处理程序。struct type http.ServeMux提供了一个作为 HTTP 请求多路复用器的实现。
  • 处理程序负责将消息头和消息体写入 HTTP 响应。在包net/http中,Handler是一个接口,因此当您编写 HTTP 应用程序时,它提供了更高级别的可扩展性。因为处理程序实现只是寻找一个具体类型的Handler接口,所以您可以提供自己的实现来服务 HTTP 请求。

net/http是为可扩展性和可组合性而设计的,所以通过扩展net/http提供的功能,它为您编写 web 应用程序提供了很大的灵活性。Go 社区提供了很多第三方包来扩展包net/http,可以用于你的 Go web 应用。

7-1.创建自定义 HTTP 处理程序

问题

如何为 HTTP 服务器创建服务于 HTTP 请求的定制处理程序?

解决办法

HTTP 处理程序是通过提供http.Handler接口的实现来创建的。

它是如何工作的

在 Go 中,如果你能提供一个http.Handler接口的实现,任何对象都可以是 HTTP 处理程序的实现。下面是http包中Handler接口的定义:

type Handler interface {
        ServeHTTP(ResponseWriter, *Request)
}

接口http.Handler有一个方法ServeHTTP,它有两个参数:一个接口类型http.ResponseWriter和一个指向结构类型http.Request的指针。方法ServeHTTP应该用于将报头和数据写入ResponseWriter

让我们通过向 struct 类型提供方法ServeHTTP来创建一个定制的处理程序:

type textHandler struct {
        responseText string
}

func (th *textHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, th.responseText)
}

声明了一个结构类型textHandler,它有一个字段responseText,将用于向ResponseWriter写入数据。方法ServeHTTP被附加到textHandler上,因此它是接口http.Handler的一个实现。通过将 struct type textHandler配置为带有ServeMux的处理程序,可以为 HTTP 请求提供服务。清单 7-1 展示了一个示例 HTTP 服务器,它使用两个定制处理程序来服务 HTTP 请求。

package main

import (
        "fmt"
        "log"
        "net/http"
)

type textHandler struct {go get gopkg.in/mgo.v2
        responseText string
}

func (th *textHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, th.responseText)
}

type indexHandler struct {
}

func (ih *indexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        w.Header().Set(
                "Content-Type",
                "text/html",
        )
        html :=
                `<doctype html>
        <html>
        <head>
                <title>Hello Gopher</title>
        </head>
        <body>
                <b>Hello Gopher!</b>
        <p>
          <a href="/welcome">Welcome</a> |  <a href="/message">Message</a>
        </p>
        </body>
</html>`
        fmt.Fprintf(w, html)

}

func main() {
        mux := http.NewServeMux()
        mux.Handle("/", &indexHandler{})

        thWelcome := &textHandler{"Welcome to Go Web Programming"}
        mux.Handle("/welcome", thWelcome)

        thMessage := &textHandler{"net/http package is used to build web apps"}
        mux.Handle("/message", thMessage)

        log.Println("Listening...")
        http.ListenAndServe(":8080", mux)
}

Listing 7-1.HTTP Server with Custom Handlers

HTTP 服务器使用两种处理程序实现:textHandlerindexHandler;两者都是通过提供方法ServeHTTP的实现来实现http.Handler接口的结构类型。textHandler的方法ServeHTTP使用函数fmt.Fprintf将通过其属性访问的文本字符串写入ResponseWriter

func (th *textHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, th.responseText)
}

对于indexHandler,方法ServeHTTP声明一个 HTML 字符串,并将其写入ResponseWriter

func (ih *indexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        w.Header().Set(
                "Content-Type",
                "text/html",
        )
        html :=
                `<doctype html>
        <html>
        <head>
                <title>Hello Gopher</title>
        </head>
        <body>
                <b>Hello Gophe!</b>
        <p>
          <a href="/welcome">Welcome</a> |  <a href="/message">Message</a>
        </p>
        </body>
</html>`
        fmt.Fprintf(w, html)

}

在函数main中,ServeMux的对象被创建,然后通过提供统一资源定位符(URL)模式及其相应的处理程序值来配置 HTTP 请求多路复用器。

func main() {
        mux := http.NewServeMux()
        mux.Handle("/", &indexHandler{})

        thWelcome := &textHandler{"Welcome to Go Web Programming"}
        mux.Handle("/welcome", thWelcome)

        thMessage := &textHandler{"net/http package is used to build web apps"}
        mux.Handle("/message", thMessage)

        log.Println("Listening...")
        http.ListenAndServe(":8080", mux)
}

ServeMux的功能Handle允许您向相关的处理程序注册一个 URL 模式。在这里,URL "/"被映射为一个indexHandler值作为处理程序,URL"/welcome""/message"被映射为textHandler值作为处理 HTTP 请求的处理程序。因为您已经实现了一个带有ServeMux值的 HTTP 请求多路复用器,它使用了两个定制的处理程序来处理 HTTP 请求,所以现在您可以启动您的 HTTP 服务器了。函数ListenAndServe使用给定的地址和处理程序启动 HTTP 服务器。

http.ListenAndServe(":8080", mux)

函数ListenAndServe的第一个参数是 HTTP 服务器在给定传输控制协议(TCP)网络地址监听的地址,第二个参数是http.Handler接口的实现。这里你给了一个ServeMux值作为处理程序。结构类型ServeMux也实现了方法ServeHTTP,因此它可以作为调用函数ListenAndServe的处理程序。通常,您提供一个ServeMux值作为调用函数ListenAndServe的第二个参数。我们将在本章后面更详细地讨论这一点。

函数http.ListenAndServe通过使用给定的参数创建结构类型http.Server的一个实例,调用它的(http.Server值)ListenAndServe方法监听 TCP 网络地址,然后用一个处理程序调用方法Serve(属于http.Server值)来处理传入连接的请求。http.Server定义了运行 HTTP 服务器的参数。

让我们运行程序来启动一个 HTTP 服务器,该服务器将在端口号 8080 进行侦听。图 7-1 显示了 HTTP 服务器对"/"请求的响应。

A337881_1_En_7_Fig1_HTML.jpg

图 7-1。

Server response for the request to "/"

图 7-2 显示了 HTTP 服务器对"/welcome"请求的响应。

A337881_1_En_7_Fig2_HTML.jpg

图 7-2。

Server response for the request to "/welcome"

图 7-3 显示了 HTTP 服务器对"/message"请求的响应。

A337881_1_En_7_Fig3_HTML.jpg

图 7-3。

Server response for the request to "/message"

7-2.使用适配器将普通函数用作处理程序

问题

为 HTTP 请求创建定制的处理程序将是一项单调乏味的工作。如何使用适配器将普通函数用作 HTTP 处理程序,从而不需要创建自定义处理程序类型?

解决办法

通过使用 func 类型http.HandlerFunc,您可以使用普通函数作为 HTTP 处理程序。HandlerFunc有一个接口http.Handler的实现,因此它可以被用作 HTTP 处理程序。您可以提供带有适当签名的普通函数,作为HandlerFunc的参数,将其用作 HTTP 处理程序。在这里,func type HandlerFunc作为普通函数的适配器,用作 HTTP 处理程序。

它是如何工作的

HandlerFunc是一个适配器,允许您使用普通函数作为 HTTP 处理程序。下面是http包中HandlerFunc类型的声明:

type HandlerFunc func(ResponseWriter, *Request)

如果fn是一个具有适当签名(func(ResponseWriter, *Request))的函数,HandlerFunc(fn)是一个调用fn的处理程序。清单 7-2 展示了一个示例 HTTP 服务器,它使用HandlerFunc来使用普通函数作为 HTTP 处理程序。

package main

import (
        "fmt"
        "log"
        "net/http"
)

func index(w http.ResponseWriter, r *http.Request) {
        w.Header().Set(
                "Content-Type",
                "text/html",
        )
        html :=
                `<doctype html>
        <html>
        <head>
                <title>Hello Gopher</title>
        </head>
        <body>
                <b>Hello Gopher!</b>
        <p>
            <a href="/welcome">Welcome</a> |  <a href="/message">Message</a>
        </p>
        </body>
</html>`
        fmt.Fprintf(w, html)
}

func welcome(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Welcome to Go Web Programming")
}
func message(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "net/http package is used to build web apps")
}

func main() {
        mux := http.NewServeMux()
        mux.Handle("/", http.HandlerFunc(index))
        mux.Handle("/welcome", http.HandlerFunc(welcome))
        mux.Handle("/message", http.HandlerFunc(message))        

        log.Println("Listening...")
        http.ListenAndServe(":8080", mux)
}

Listing 7-2.HTTP Server That Uses Normal Functions as HTTP Handlers

这里用签名func(ResponseWriter, *Request)声明函数,通过将这些函数提供给HandlerFunc来将它们用作 HTTP 处理程序。

mux := http.NewServeMux()
mux.Handle("/", http.HandlerFunc(index))
mux.Handle("/welcome", http.HandlerFunc(welcome))
mux.Handle("/message", http.HandlerFunc(message))        

将这种方法与清单 7-1、中编写的程序进行比较,其中您创建了一个结构类型并提供了一个方法ServeHTTP来实现接口http.Handler,这种方法更容易,因为您可以简单地使用普通函数作为 HTTP 处理程序。

7-3.使用普通函数作为 HTTP 处理程序。HandleFunc

问题

如何在不显式调用http.HandlerFunc类型的情况下使用普通函数作为 HTTP 处理程序?

解决办法

ServeMux提供了一个方法HandleFunc,允许你注册一个普通函数作为给定 URI 模式的处理程序,而不需要显式调用 func 类型http.HandlerFunc

它是如何工作的

ServeMux的方法HandleFunc是一个 helper 函数,内部调用ServeMux的方法Handle,其中给定的处理函数用于调用http.HandlerFunc来提供http.Handler的实现。下面是包http中函数HandleFunc的源代码:

func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
        mux.Handle(pattern, HandlerFunc(handler))
}

清单 7-3 展示了一个示例 HTTP 服务器,它使用ServeMuxHandleFunc来使用普通函数作为 HTTP 处理程序,而没有显式使用HandlerFunc

package main

import (
        "fmt"
        "log"
        "net/http"
)

func index(w http.ResponseWriter, r *http.Request) {
        w.Header().Set(
                "Content-Type",
                "text/html",
        )
        html :=
                `<doctype html>
        <html>
        <head>
                <title>Hello Gopher</title>
        </head>
        <body>
                <b>Hello Gopher!</b>
        <p>
            <a href="/welcome">Welcome</a> |  <a href="/message">Message</a>
        </p>
        </body>
</html>`
        fmt.Fprintf(w, html)
}

func welcome(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Welcome to Go Web Programming")
}
func message(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "net/http package is used to build web apps")
}

func main() {
        mux := http.NewServeMux()
        mux.HandleFunc("/", index)
        mux.HandleFunc("/welcome", welcome)
        mux.HandleFunc("/message", message)
        log.Println("Listening...")
        http.ListenAndServe(":8080", mux)
}

Listing 7-3.HTTP Server That Uses HandleFunc of ServeMux

HandleFunc只是一个助手函数,它通过提供http.HandlerFunc作为处理程序来调用ServeMux的函数Handle

7-4.使用默认 ServeMux 值

问题

如何使用包http,提供的默认ServeMux值作为ServeMux,,当使用默认ServeMux值时,如何注册处理函数?

解决办法

http提供了一个名为DefaultServeMux的默认ServeMux值,它可以用作 HTTP 请求多路复用器,这样您就不需要从代码中创建一个ServeMux。当使用DefaultServeMux作为ServeMux值时,可以使用函数http.HandleFunc配置 HTTP 路由,该函数将给定模式的处理函数注册到DefaultServeMux中。

它是如何工作的

默认情况下,包http提供了一个名为DefaultServeMuxServeMux实例。当您调用函数http.ListenAndServe来运行您的 HTTP 服务器时,您可以提供一个nil值作为第二个参数的自变量(一个http.Handler的实现)。

http.ListenAndServe(":8080", nil)

如果您提供一个nil值,包http将把DefaultServeMux作为ServeMux值。当使用DefaultServeMux作为ServeMux值时,可以使用函数http.HandleFunc为给定的 URL 模式注册一个处理函数。在函数http.HandleFunc内部,调用DefaultServeMux的函数HandleFunc。然后ServeMuxHandleFunc通过使用给定的处理函数提供http.HandlerFunc调用来调用ServeMux的函数Handle

清单 7-4 展示了一个示例 HTTP 服务器,它使用DefaultServeMux作为ServeMux值,并使用http.HandleFunc注册一个处理函数。

package main

import (
        "fmt"
        "log"
        "net/http"
)

func index(w http.ResponseWriter, r *http.Request) {
        w.Header().Set(
                "Content-Type",
                "text/html",
        )
        html :=
                `<doctype html>
        <html>
        <head>
                <title>Hello Gopher</title>
        </head>
        <body>
                <b>Hello Gopher!</b>
        <p>
            <a href="/welcome">Welcome</a> |  <a href="/message">Message</a>
        </p>
        </body>
</html>`
        fmt.Fprintf(w, html)
}

func welcome(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Welcome to Go Web Programming")
}
func message(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "net/http package is used to build web apps")
}

func main() {
        http.HandleFunc("/", index)
        http.HandleFunc("/welcome", welcome)
        http.HandleFunc("/message", message)
        log.Println("Listening...")
        http.ListenAndServe(":8080", nil)
}

Listing 7-4.HTTP Server That Uses DefaultServeMux and http.HandleFunc

函数http.HandleFunc用于向DefaultServeMux.注册一个处理函数

7-5.自定义 http。计算机网络服务器

问题

如何定制用于运行 HTTP 服务器的http.Server的值?

解决办法

要定制http.Server并使用它来运行 HTTP 服务器,用所需的值创建一个http.Server的实例,然后调用它的方法ListenAndServe

它是如何工作的

在前面的食谱中,您已经使用功能http.ListenAndServe运行了一个 HTTP 服务器。当您调用函数http.ListenAndServe时,它通过提供地址的字符串值和http.Handler值在内部创建http.Server的实例,并使用http.Server值运行服务器。因为http.Server的实例是从函数http.ListenAndServe内部创建的,所以您不能自定义http.Server的值。http.Server定义了运行 HTTP 服务器的参数。如果你想定制http.Server值,你可以从你的程序中显式的创建一个http.Server的实例,然后调用它的方法ListenAndServe

清单 7-5 展示了一个定制http.Server并调用其方法ListenAndServe来运行 HTTP 服务器的示例 HTTP 服务器。

package main

import (
        "fmt"
        "log"
        "net/http"
        "time"
)

func index(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Welcome to Go Web Programming")
}

func main() {

        http.HandleFunc("/", index)

        server := &http.Server{
                Addr:           ":8080",
                ReadTimeout:    60 * time.Second,
                WriteTimeout:   60 * time.Second,                
        }

        log.Println("Listening...")
        server.ListenAndServe()
}

Listing 7-5.HTTP Server That Uses the Method ListenAndServe of http.Server

这个例子定制了用于运行 HTTP 服务器的http.Server的字段ReadTimeoutWriteTimeout

7-6.编写 HTTP 中间件

问题

如何编写一个 HTTP 中间件函数,用一段可插入的代码来包装 HTTP 处理程序,从而为 HTTP 应用程序提供共享行为?

解决办法

要编写 HTTP 中间件函数,请编写带有签名func(http.Handler) http.Handler的函数,这样 HTTP 中间件函数就可以接受一个处理程序作为参数值,并且可以在中间件函数内部提供一段可插入的代码。因为它返回http.Handler,中间件函数可以作为Handler向 HTTP 请求多路复用器注册。

它是如何工作的

HTTP 中间件是包装 web 应用程序的 HTTP 处理程序的可插入和自包含的代码。这些类似于典型的 HTTP 处理程序,但是它们包装了另一个 HTTP 处理程序,通常是普通的应用程序处理程序,为 web 应用程序提供共享行为。它作为 HTTP 请求处理周期中的另一层,注入一些可插入的代码来执行共享行为,如身份验证和授权、日志记录、缓存等。

下面是编写 HTTP 中间件的基本模式:

func middlewareHandler(next http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    // Middleware logic goes here before executing application handler
    next.ServeHTTP(w, r)
   // Middleware logic goes here after executing application handler
  })
}

这里,中间件函数接受一个http.Handler值并返回一个http.Handler值。因为中间件函数返回http.Handler,所以它可以通过将应用程序处理程序包装为中间件函数的一个参数,注册为一个带有http.ServeMuxHandler。要从中间件调用给定处理程序的逻辑,调用它的方法ServeHTTP

next.ServeHTTP(w, r)

中间件逻辑可以在执行应用处理程序之前和之后执行。在执行给定的Handler(句柄获取为参数值)前写中间件逻辑,在调用ServeHTTP前写,在执行参数值Handler后调用ServeHTTP执行中间件逻辑后写。

清单 7-6 展示了一个示例 HTTP 服务器,它用一个名为loggingHandler.的中间件函数包装应用程序处理程序

package main

import (
        "fmt"
        "log"
        "net/http"
        "time"
)

// loggingHandler is an HTTP Middleware that logs HTTP requests.
func loggingHandler(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                // Middleware logic before executing given Handler
                start := time.Now()
                log.Printf("Started %s %s", r.Method, r.URL.Path)
                next.ServeHTTP(w, r)
                // Middleware logic after executing given Handler
                log.Printf("Completed %s in %v", r.URL.Path, time.Since(start))
        })
}

func index(w http.ResponseWriter, r *http.Request) {
        w.Header().Set(
                "Content-Type",
                "text/html",
        )
        html :=
                `<doctype html>
        <html>
        <head>
                <title>Hello Gopher</title>
        </head>
        <body>
                <b>Hello Gopher!</b>
        <p>
            <a href="/welcome">Welcome</a> |  <a href="/message">Message</a>
        </p>
        </body>
</html>`
        fmt.Fprintf(w, html)
}

func welcome(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Welcome to Go Web Programming")
}
func message(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "net/http package is used to build web apps")
}

func main() {
        http.Handle("/", loggingHandler(http.HandlerFunc(index)))
        http.Handle("/welcome", loggingHandler(http.HandlerFunc(welcome)))
        http.Handle("/message", loggingHandler(http.HandlerFunc(message)))
        log.Println("Listening...")
        http.ListenAndServe(":8080", nil)
}

Listing 7-6.HTTP Middleware That Wraps Application Handlers

名为loggingHandler的 HTTP 中间件用于记录所有 HTTP 请求及其响应时间。函数loggingHandler接受一个http.Handler值,因此您可以将应用程序处理程序作为参数传递给中间件函数,并且可以将中间件处理程序注册到ServeMux,因为它返回http.Handler

http.Handle("/", loggingHandler(http.HandlerFunc(index)))
http.Handle("/welcome", loggingHandler(http.HandlerFunc(welcome)))
http.Handle("/message", loggingHandler(http.HandlerFunc(message)))

因为中间件函数的参数类型是http.Handler,通过使用http.HandlerFunc调用中间件,应用处理函数被转换为http.Handler。您可以用中间件函数链来包装您的应用程序处理程序,因为您用signature func(http.Hanlder) http.Handler编写中间件函数。

让我们运行应用程序并导航到所有已配置的 URL 模式。您应该会看到 HTTP 中间件提供的日志消息,如下所示:

2016/08/05 15:34:29 Started GET /
2016/08/05 15:34:29 Completed / in 5.0039ms
2016/08/05 15:34:34 Started GET /welcome
2016/08/05 15:34:34 Completed /welcome in 9.0082ms
2016/08/05 15:34:40 Started GET /message
2016/08/05 15:34:40 Completed /message in 6.0077ms

7-7.用 Go 和 MongoDB 编写 RESTful API

问题

您希望在 Go 中使用 MongoDB 作为持久化存储来编写 RESTful APIs。

解决办法

标准库包http提供了构建 RESTful APIs 的所有必要组件。包http是为可扩展性而设计的,因此当您编写 HTTP 应用程序时,您可以使用第三方包和您自己的定制包来扩展包的功能。包mgo是使用 MongoDB 最流行的包,用于 REST API 示例的数据持久化。

它是如何工作的

让我们构建一个 REST API 示例来演示如何用 Go 和 MongoDB 构建一个 RESTful API。虽然包http足以构建 web 应用程序,但我们希望使用第三方包Gorilla mux ( github.com/gorilla/mux)作为 HTTP 请求多路复用器,而不是http.ServeMux。包mux为指定 HTTP 路由提供了丰富的功能,这对于指定 RESTful 端点很有用。例如,http.ServeMux不支持为 URL 模式指定 HTTP 动词,这对于定义 RESTful 端点是必不可少的,但是包mux为定义应用程序的路由提供了很大的灵活性,包括为 URL 模式指定 HTTP 动词。第三方包mgo用于在 MongoDB 数据库上执行持久化,这是一个流行的 NoSQL 数据库。

应用程序的目录结构

我们将 REST API 应用程序组织成多个包。图 7-4 显示了用于 REST API 应用程序的高级目录结构。

A337881_1_En_7_Fig4_HTML.jpg

图 7-4。

Directory structure of the REST API application

图 7-5 显示了 REST API 应用程序完整版本的目录结构和相关文件。

A337881_1_En_7_Fig5_HTML.jpg

图 7-5。

Directory structure and associated files of the completed application

除了目录keys,其他目录都代表 Go 包。keys目录包含用于签署 JSON web 令牌(JWT)及其验证的密钥。这用于通过 JWT 对 API 进行认证。

REST API 应用程序被分成以下几个包:

  • Common:包common提供实用函数,为应用提供初始化逻辑。
  • Controllers:包controllers为应用程序提供 HTTP 处理函数。
  • Store:包store用 MongoDB 数据库提供持久化逻辑。
  • model:包model描述了应用的数据模型。
  • routers:包routers为 REST API 实现 HTTP 请求路由器。

书中的示例代码主要关注名为Bookmark的实体,并讨论构建 REST API 的基本部分。REST API 应用程序的完整版本,包括 JWT 认证、日志等等,可以从本书的代码库中获得,代码库位于 https://github.com/shijuvar/go-recipes

数据模型

model为 REST API 应用程序提供了数据模型。清单 7-7 展示了 REST API 示例的数据模型。

package model

import (
        "time"

        "gopkg.in/mgo.v2/bson"
)

// Bookmark type represents the metadata of a bookmark.

type Bookmark struct {
                ID          bson.ObjectId `bson:"_id,omitempty"`
                Name        string        `json:"name"`
                Description string        `json:"description"`
                Location    string        `json:"location"`
                Priority    int           `json:"priority"` // Priority (1 -5)
                CreatedBy   string        `json:"createdby"`
                CreatedOn   time.Time     `json:"createdon,omitempty"`
                Tags        []string      `json:"tags,omitempty"`
        }

Listing 7-7.Data Model in models.go

类型Bookmark代表应用程序中书签的元数据。该模型被设计为与 MongoDB 一起工作,因此字段类型ID被指定为bson.ObjectId。示例应用程序允许用户添加、编辑、删除和查看书签的元数据,这些元数据可以用优先级和标签来组织。

资源模型

上一步定义了要使用的应用程序的数据模型,即 NoSQL 数据库 MongoDB。既然您已经对数据库进行了数据建模,那么让我们为 REST APIs 定义资源模型。资源建模定义了一个 REST API,它向客户端应用程序提供 API 的端点。这可以利用 URIs、使用各种 HTTP 方法的 API 操作等等。根据 Roy Fielding 关于 REST 的论文,“REST 中信息的关键抽象是资源。任何可以命名的信息都可以是资源:文档或图像、时态服务(例如,“洛杉矶今天的天气”)、其他资源的集合、非虚拟对象(例如人)等等。换句话说,任何可能成为作者超文本参考目标的概念都必须符合资源的定义。资源是到一组实体的概念性映射,而不是在任何特定时间点对应于该映射的实体。

这里您定义了一个名为“/bookmarks”的资源,它代表了一个书签实体的集合。通过在资源"/bookmarks"上使用 HTTP Post,您可以创建一个新的资源。URI " /bookmarks/{id}"可以用来表示单个书签实体。通过在“/bookmarks/{id}”上使用 HTTP Get,可以检索单个书签的数据。表 7-1 显示了针对书签实体设计的资源模型。

表 7-1。

Resource Model for the Bookmark Entity

| 上呼吸道感染 | HTTP 动词 | 功能 | | --- | --- | --- | | `/bookmarks` | 邮政 | 创建新书签 | | `/bookmarks/{id}` | 放 | 更新给定 ID 的现有书签 | | `/bookmarks` | 得到 | 获取所有书签 | | `/bookmarks/{id}` | 得到 | 获取给定 ID 的单个书签 | | `/bookmarks/users/{id}` | 得到 | 获取与单个用户关联的所有书签 | | `/bookmarks/{id}` | 删除 | 删除给定 ID 的现有书签 |
将 REST API 资源配置到 HTTP 复用器中

让我们将 REST API 的资源映射到 HTTP 请求多路复用器中。包mux被用作这个应用程序的 HTTP 请求多路复用器。以下命令安装软件包mux:

go get github.com/gorilla/mux

要使用包mux,您必须将github.com/gorilla/mux添加到导入列表中。

import " github.com/gorilla/mux "

清单 7-8 显示了函数SetBookmarkRoutes,它将资源端点和Bookmark实体的相应应用程序处理程序注册到 HTTP 请求多路复用器中。在这里,您希望在单独的函数中组织每个实体的多路复用器配置,以便您可以轻松地维护应用程序的 HTTP 路由。如果您想为User实体添加一个多路复用器配置,您可以在另一个函数中组织它。这些函数最终从routers.go的函数InitRoutes中调用。应用处理程序被组织到包controllers中。

package routers

import (
        "github.com/gorilla/mux"

        "github.com/shijuvar/go-recipes/ch07/bookmarkapi/controllers"
)

// SetBookmarkRoutes registers routes for bookmark entity.
func SetBookmarkRoutes(router *mux.Router) *mux.Router {
        router.HandleFunc("/bookmarks", controllers.CreateBookmark).Methods("POST")

        router.HandleFunc("/bookmarks/{id}", controllers.UpdateBookmark).Methods("PUT")

        router.HandleFunc("/bookmarks", controllers.GetBookmarks).Methods("GET")

        router.HandleFunc("/bookmarks/{id}", controllers.GetBookmarkByID).Methods("GET")

        router.HandleFunc("/bookmarks/users/{id}", controllers.GetBookmarksByUser).Methods("GET")

        router.HandleFunc("/bookmarks/{id}", controllers.DeleteBookmark).Methods("DELETE")

        return router
}

Listing 7-8.Configuration for the HTTP Request Multiplexer in routers/bookmark.go

类型mux.Router用于注册 HTTP 路由及其相应的处理函数。它实现了接口http.Handler,因此它与包http的类型ServeMux兼容。函数HandleFunc向 URL 路径的匹配器注册一个新的路由。该功能的工作方式类似于http.ServeMux的功能HandleFunc。从routers.go的函数 I nitRoutes中调用函数SetBookmarkRoutes,如清单 7-9 所示。

package routers

import (
        "github.com/gorilla/mux"
)

// InitRoutes registers all routes for the application.
func InitRoutes() *mux.Router {
        router := mux.NewRouter().StrictSlash(false)
        // Routes for the Bookmark entity
        router = SetBookmarkRoutes(router)

        // Call other router configurations        
        return router
}

Listing 7-9.Initializing Routes in routers/routers.go

通过调用函数mux.NewRouter创建一个新的mux.router实例。从包mainmain.go中调用函数InitRoutes,以配置应用程序的路由,供 HTTP 服务器使用。

管理 mgo。会议

CChapter 6 讨论了如何使用包mgo处理 MongoDB 数据库。当包mgo用于 MongoDB 时,首先通过调用mgo.Dialmgo.DialWithInfo获得一个mgo.Session值。mgo.Session实例用于对 MongoDB 集合执行 CRUD 操作。但是,不建议在应用程序中对所有 CRUD 操作使用全局mgo.Session值。使用mgo.Session值的一个良好实践是使用从全局mgo.Session值复制的mgo.Session值用于数据持久化会话。当您编写 web 应用程序时,一个好的实践是为每个 HTTP 请求生命周期使用全局mgo.Session值的复制值。类型mgo.Session提供函数Copy,该函数可用于创建mgo.Session值的副本。您还可以使用函数Clone,该函数提供了mgo.Session值的克隆版本,以制作mgo.Session的副本,从而为数据持久化会话执行 CRUD 操作。复制和克隆的会话都将重用来自全局mgo.Session的同一个连接池,该连接池是使用DialDialWithInfo获得的。函数Clone就像Copy一样工作,但是也重用了与原始会话相同的套接字。REST API 示例使用函数Copy生成一个复制的mgo.Session值,该值将在单个 HTTP 请求生命周期中使用。

清单 7-10 显示了包commonmongo_utils.go的源代码,它提供了使用 MongoDB 的帮助函数,包括一个名为DataStore的结构类型,该结构类型提供了用于每个 HTTP 请求生命周期的全局mgo.Session的副本。

package common

import (
        "log"
        "time"

        "gopkg.in/mgo.v2"
)

var session *mgo.Session

// GetSession returns a MongoDB Session
func getSession() *mgo.Session {
        if session == nil {
                var err error
                session, err = mgo.DialWithInfo(&mgo.DialInfo{
                        Addrs:    []string{AppConfig.MongoDBHost},
                        Username: AppConfig.DBUser,
                        Password: AppConfig.DBPwd,
                        Timeout:  60 * time.Second,
                })
                if err != nil {
                        log.Fatalf("[GetSession]: %s\n", err)
                }
        }
        return session
}
func createDBSession() {
        var err error
        session, err = mgo.DialWithInfo(&mgo.DialInfo{
                Addrs:    []string{AppConfig.MongoDBHost},
                Username: AppConfig.DBUser,
                Password: AppConfig.DBPwd,
                Timeout:  60 * time.Second,
        })
        if err != nil {
                log.Fatalf("[createDbSession]: %s\n", err)
        }
}

// DataStore for MongoDB
type DataStore struct {
        MongoSession *mgo.Session
}

// Close closes an mgo.Session value.
// Used to add defer statements for closing the copied session.
func (ds *DataStore) Close() {
        ds.MongoSession.Close()
}

// Collection returns mgo.collection for the given name
func (ds *DataStore) Collection(name string) *mgo.Collection {
        return ds.MongoSession.DB(AppConfig.Database).C(name)
}

// NewDataStore creates a new DataStore object to be used for each HTTP request.
func NewDataStore() *DataStore {
        session := getSession().Copy()
        dataStore := &DataStore{
                MongoSession: session,
        }
        return dataStore
}

Listing 7-10.Helper Functions for mgo.Session in common/mongo_utils.go

函数createDBSession创建一个全局mgo.Session值,在运行 HTTP 服务器之前,这个函数将被立即调用。函数getSession返回全局mgo.Session值。通过创建mgo.Session的副本,从应用程序处理程序创建结构类型DataStore的实例,以与 MongoDB 数据库一起工作。函数NewDataStore通过提供全局mgo.Session值的副本来创建DataStore的新实例。

func NewDataStore() *DataStore {
        session := getSession().Copy()
        dataStore := &DataStore{
                MongoSession: session,
        }
        return dataStore
}

JSON 资源的模型

示例 REST API 应用程序是一个基于 JSON 的 REST API,其中 JSON 格式用于在 HTTP 请求和响应中发送和接收数据。为了满足 JSON API 规范( http://jsonapi.org/ ),让我们定义用于 HTTP 请求和 HTTP 响应的数据模型。这里您定义了 JSON 表示的模型,其中元素名"data"被定义为 HTTP 请求和 HTTP 响应主体中所有 JSON 表示的根。清单 7-11 展示了 JSON 表示的数据模型。

package controllers

import (
        "github.com/shijuvar/go-recipes/ch07/bookmarkapi/model"
)
//Models for JSON resources
type (        
        // BookmarkResource for Post and Put - /bookmarks
        // For Get - /bookmarks/id
        BookmarkResource struct {
                Data model.Bookmark `json:"data"`
        }
        // BookmarksResource for Get - /bookmarks
        BookmarksResource struct {
                Data []model.Bookmark `json:"data"`
        }
)

Listing 7-11.Data Models for JSON Resources in controllers/resources.go

应用程序处理程序使用该类型从http.Request的主体接收数据,并将数据写入http.ResponseWriter

书签资源的 HTTP 处理程序

以下是为Bookmarks资源配置的路线:

router.HandleFunc("/bookmarks", controllers.CreateBookmark).Methods("POST")
router.HandleFunc("/bookmarks/{id}", controllers.UpdateBookmark).Methods("PUT")
router.HandleFunc("/bookmarks", controllers.GetBookmarks).Methods("GET")
router.HandleFunc("/bookmarks/{id}", controllers.GetBookmarkByID).Methods("GET")
router.HandleFunc("/bookmarks/users/{id}", controllers.GetBookmarksByUser).Methods("GET")
router.HandleFunc("/bookmarks/{id}", controllers.DeleteBookmark).Methods("DELETE")

Bookmarks资源的 HTTP 处理函数是用bookmark_controller.go编写的,它被组织到包controllers中。清单 7-12 显示了为Bookmarks资源提供 HTTP 请求的处理函数。

package controllers

import (
        "encoding/json"
        "net/http"

        "github.com/gorilla/mux"
        "gopkg.in/mgo.v2"
        "gopkg.in/mgo.v2/bson"

        "github.com/shijuvar/go-recipes/ch07/bookmarkapi/common"
        "github.com/shijuvar/go-recipes/ch07/bookmarkapi/store"
)

// CreateBookmark insert a new Bookmark.
// Handler for HTTP Post - "/bookmarks
func CreateBookmark(w http.ResponseWriter, r *http.Request) {
        var dataResource BookmarkResource
        // Decode the incoming Bookmark json
        err := json.NewDecoder(r.Body).Decode(&dataResource)
        if err != nil {
                common.DisplayAppError(
                        w,
                        err,
                        "Invalid Bookmark data",
                        500,
                )
                return
        }
        bookmark := &dataResource.Data
        // Creates a new DataStore value to work with MongoDB store.
        dataStore := common.NewDataStore()
        // Add to the mgo.Session.Close()
        defer dataStore.Close()
        // Get the mgo.Collection for "bookmarks"
        col := dataStore.Collection("bookmarks")
        // Creates an instance of BookmarkStore
        bookmarkStore := store.BookmarkStore{C: col}
        // Insert a bookmark document
        err = bookmarkStore.Create(bookmark)
        if err != nil {
                common.DisplayAppError(
                        w,
                        err,
                        "Invalid Bookmark data",
                        500,
                )
                return
        }

        j, err := json.Marshal(BookmarkResource{Data: *bookmark})
        // If error has occurred,
        // Send JSON response using helper function common.DisplayAppError
        if err != nil {
                common.DisplayAppError(
                        w,
                        err,
                        "An unexpected error has occurred",
                        500,
                )
                return
        }
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusCreated)
        // Write the JSON data to the ResponseWriter
        w.Write(j)

}

// GetBookmarks returns all Bookmark documents
// Handler for HTTP Get - "/Bookmarks"
func GetBookmarks(w http.ResponseWriter, r *http.Request) {
        dataStore := common.NewDataStore()
        defer dataStore.Close()
        col := dataStore.Collection("bookmarks")
        bookmarkStore := store.BookmarkStore{C: col}
        bookmarks := bookmarkStore.GetAll()
        j, err := json.Marshal(BookmarksResource{Data: bookmarks})
        if err != nil {
                common.DisplayAppError(
                        w,
                        err,
                        "An unexpected error has occurred",
                        500,
                )
                return
        }
        w.WriteHeader(http.StatusOK)
        w.Header().Set("Content-Type", "application/json")
        w.Write(j)
}

// GetBookmarkByID returns a single bookmark document by id
// Handler for HTTP Get - "/Bookmarks/{id}"
func GetBookmarkByID(w http.ResponseWriter, r *http.Request) {
        // Get id from the incoming url
        vars := mux.Vars(r)
        id := vars["id"]

        dataStore := common.NewDataStore()
        defer dataStore.Close()
        col := dataStore.Collection("bookmarks")
        bookmarkStore := store.BookmarkStore{C: col}

        bookmark, err := bookmarkStore.GetByID(id)
        if err != nil {
                if err == mgo.ErrNotFound {
                        w.WriteHeader(http.StatusNoContent)

                } else {
                        common.DisplayAppError(
                                w,
                                err,
                                "An unexpected error has occurred",
                                500,
                        )

                }
                return
        }
        j, err := json.Marshal(bookmark)
        if err != nil {
                common.DisplayAppError(
                        w,
                        err,
                        "An unexpected error has occurred",
                        500,
                )
                return
        }
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        w.Write(j)
}

// GetBookmarksByUser returns all Bookmarks created by a User
// Handler for HTTP Get - "/Bookmarks/users/{id}"
func GetBookmarksByUser(w http.ResponseWriter, r *http.Request) {
        // Get id from the incoming url
        vars := mux.Vars(r)
        user := vars["id"]
        dataStore := common.NewDataStore()
        defer dataStore.Close()
        col := dataStore.Collection("bookmarks")
        bookmarkStore := store.BookmarkStore{C: col}
        bookmarks := bookmarkStore.GetByUser(user)
        j, err := json.Marshal(BookmarksResource{Data: bookmarks})
        if err != nil {
                common.DisplayAppError(
                        w,
                        err,
                        "An unexpected error has occurred",
                        500,
                )
                return
        }
        w.WriteHeader(http.StatusOK)
        w.Header().Set("Content-Type", "application/json")
        w.Write(j)
}

// UpdateBookmark update an existing Bookmark document
// Handler for HTTP Put - "/Bookmarks/{id}"
func UpdateBookmark(w http.ResponseWriter, r *http.Request) {
        // Get id from the incoming url
        vars := mux.Vars(r)
        id := bson.ObjectIdHex(vars["id"])
        var dataResource BookmarkResource
        // Decode the incoming Bookmark json
        err := json.NewDecoder(r.Body).Decode(&dataResource)
        if err != nil {
                common.DisplayAppError(
                        w,
                        err,
                        "Invalid Bookmark data",
                        500,
                )
                return
        }
        bookmark := dataResource.Data
        bookmark.ID = id
        dataStore := common.NewDataStore()
        defer dataStore.Close()
        col := dataStore.Collection("bookmarks")
        bookmarkStore := store.BookmarkStore{C: col}
        // Update an existing Bookmark document
        if err := bookmarkStore.Update(bookmark); err != nil {
                common.DisplayAppError(
                        w,
                        err,
                        "An unexpected error has occurred",
                        500,
                )
                return
        }
        w.WriteHeader(http.StatusNoContent)

}

// DeleteBookmark deletes an existing Bookmark document
// Handler for HTTP Delete - "/Bookmarks/{id}"
func DeleteBookmark(w http.ResponseWriter, r *http.Request) {
        vars := mux.Vars(r)
        id := vars["id"]
        dataStore := common.NewDataStore()
        defer dataStore.Close()
        col := dataStore.Collection("bookmarks")
        bookmarkStore := store.BookmarkStore{C: col}
        // Delete an existing Bookmark document
        err := bookmarkStore.Delete(id)
        if err != nil {
                common.DisplayAppError(
                        w,
                        err,
                        "An unexpected error has occurred",
                        500,
                )
                return
        }
        w.WriteHeader(http.StatusNoContent)
}

Listing 7-12.HTTP Handler Functions for Bookmarks Resource in controllers/bookmark_controller.go

HTTP Post 和 HTTP Put 的 HTTP 处理函数对来自请求体的 JSON 数据进行解码,并将其解析到为 JSON 资源创建的模型中。在这里,它被解析成结构类型BookmarkResource。这里是controllers包装resources.go中写的BookmarkResource声明。

BookmarkResource struct {
        Data model.Bookmark `json:"data"`
}

通过访问BookmarkResource的属性Data,传入的数据被映射到域模型model.Bookmark,并使用它的值执行数据持久化逻辑。

var dataResource BookmarkResource
// Decode the incoming Bookmark json

err := json.NewDecoder(r.Body).Decode(&dataResource)

bookmark := &dataResource.Data

结构类型common.DataStore用于维护全局mgo.Session值的复制版本,在单个 HTTP 请求生命周期中使用。DataStore的方法Collection返回一个mgo.Collection值。mgo.Collection值用于创建store.BookmarkStore的实例。包store的结构类型BookmarkStore提供了针对数据模型Bookmark的持久化逻辑,该数据模型针对名为"bookmarks"的 MongoDB 集合工作。

dataStore := common.NewDataStore()
// Add to the mgo.Session.Close()
defer dataStore.Close()
// Get the mgo.Collection for "bookmarks"
col := dataStore.Collection("bookmarks")
// Creates an instance of BookmarkStore
bookmarkStore := store.BookmarkStore{C: col}

BookmarkStore的方法用于对 MongoDB 数据库执行 CRUD 操作。BookmarkStore的函数Create用于向 MongoDB 集合中插入一个新文档。

// Insert a bookmark document
err=bookmarkStore.Create(bookmark)

如果在执行了BookmarkStore的持久化逻辑之后,返回的error值是nil,那么一个适当的 HTTP 响应被发送到 HTTP 客户端。下面是从 HTTP Post 的处理函数发送到"/bookmarks"的 HTTP 响应:

j, err := json.Marshal(BookmarkResource{Data: *bookmark})

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
// Write the JSON data to the ResponseWriter
w.Write(j)

这里,使用model.Bookmark的值创建一个结构类型BookmarkResource,并使用json.Marshal将其编码到 JSON 中。如果从处理函数接收到任何error值,将使用一个助手函数common.DisplayAppError以 JSON 格式发送 HTTP 错误消息。

// Insert a bookmark document
err = bookmarkStore.Create(bookmark)
if err != nil {
        common.DisplayAppError(
                w,
                err,
                "Invalid Bookmark data",
                500,
        )
        return
}

下面是助手函数DisplayAppError的实现:

// DisplayAppError provides app specific error in JSON
func DisplayAppError(w http.ResponseWriter, handlerError error, message string, code int) {
        errObj := appError{
                Error:      handlerError.Error(),
                Message:    message,
                HTTPStatus: code,
        }        
        w.Header().Set("Content-Type", "application/json; charset=utf-8")
        w.WriteHeader(code)
        if j, err := json.Marshal(errorResource{Data: errObj}); err == nil {
                w.Write(j)
        }
}

HTTP Put、Get 和 Delete 的处理函数从 HTTP 请求的 URL 的路由变量中检索值。包mux提供了一个函数Vars,它返回当前请求的路由变量,作为类型为map[string]string的集合的键/值对。下面的代码块检索路由变量"id"的值。

vars := mux.Vars(r)
id := vars["id"]

MongoDB 的数据持久化

bookmark_controller.go的 HTTP 处理函数使用数据持久化逻辑的结构类型BookmarkStore对名为"bookmarks"的 MongoDB 集合执行 CRUD 操作。清单 7-13 显示了BookmarkStore提供的数据持久逻辑。

package store

import (
        "time"

        "gopkg.in/mgo.v2"
        "gopkg.in/mgo.v2/bson"

        "github.com/shijuvar/go-recipes/ch07/bookmarkapi/model"
)

// BookmarkStore provides CRUD operations against the collection "bookmarks".
type BookmarkStore struct {
        C *mgo.Collection
}

// Create inserts the value of struct Bookmark into collection.
func (store BookmarkStore) Create(b *model.Bookmark) error {
        // Assign a new bson.ObjectId
        b.ID = bson.NewObjectId()
        b.CreatedOn = time.Now()
        err := store.C.Insert(b)
        return err
}

// Update modifies an existing document of a collection.
func (store BookmarkStore) Update(b model.Bookmark) error {
        // partial update on MogoDB
        err := store.C.Update(bson.M{"_id": b.ID},
                bson.M{"$set": bson.M{
                        "name":        b.Name,
                        "description": b.Description,
                        "location":    b.Location,
                        "priority":    b.Priority,
                        "tags":        b.Tags,
                }})
        return err
}

// Delete removes an existing document from the collection.
func (store BookmarkStore) Delete(id string) error {
        err := store.C.Remove(bson.M{"_id": bson.ObjectIdHex(id)})
        return err
}

// GetAll returns all documents from the collection.
func (store BookmarkStore) GetAll() []model.Bookmark {
        var b []model.Bookmark
        iter := store.C.Find(nil).Sort("priority", "-createdon").Iter()
        result := model.Bookmark{}
        for iter.Next(&result) {
                b = append(b, result)
        }
        return b
}

// GetByUser returns all documents from the collection.
func (store BookmarkStore) GetByUser(user string) []model.Bookmark {
        var b []model.Bookmark
        iter := store.C.Find(bson.M{"createdby": user}).Sort("priority", "-createdon").Iter()
        result := model.Bookmark{}
        for iter.Next(&result) {
                b = append(b, result)
        }
        return b
}

// GetByID returns a single document from the collection.
func (store BookmarkStore) GetByID(id string) (model.Bookmark, error) {
        var b model.Bookmark
        err := store.C.FindId(bson.ObjectIdHex(id)).One(&b)
        return b, err
}

Listing 7-13.Data Persistence Logic in store/bookmark_store.go

运行 HTTP 服务器

REST API 的 HTTP 服务器从main.go开始创建和运行。清单 7-14 显示了main.go.的来源

package main

import (
        "log"
        "net/http"

        "github.com/shijuvar/go-recipes/ch07/bookmarkapi/common"
        "github.com/shijuvar/go-recipes/ch07/bookmarkapi/routers"
)

// Entry point of the program
func main() {

        // Calls startup logic
        common.StartUp()
        // Get the mux router object
        router := routers.InitRoutes()
        // Create the Server
        server := &http.Server{
                Addr:    common.AppConfig.Server,
                Handler: router,
        }
        log.Println("Listening...")
        // Running the HTTP Server
        server.ListenAndServe()
}

Listing 7-14.Running HTTP Server in main.go

在函数main内部,使用函数common.StartUp执行一些启动逻辑。在运行 HTTP 服务器之前,对common.StartUp的调用执行几个必需的函数。这包括读取应用程序配置文件并将值加载到 struct 实例中,使用函数mgo.DialWithInfo连接到 MongoDB 数据库,获得一个mgo.Session值,等等。包common提供了运行 HTTP 服务器之前所需的启动逻辑。通过调用返回mux.Routerrouters.InitRoutes来创建http.Handler值。mux.Router有一个接口http.Handler的实现,因此它被用作 HTTP 服务器的处理程序。

测试 REST API 服务器

让我们运行 HTTP 服务器并测试书签资源的一些 API 端点。Postman ( https://www.getpostman.com/ )用于测试 API 端点。图 7-6 显示了对"/bookmarks"的 HTTP Post 请求。

A337881_1_En_7_Fig6_HTML.jpg

图 7-6。

Sending HTTP Post to "/bookmarks"

图 7-7 显示了 API 服务器对 HTTP Post 到"/bookmarks"的响应。它显示了新创建的资源的 HTTP 状态代码 201 和 JSON。

A337881_1_En_7_Fig7_HTML.jpg

图 7-7。

HTTP Response for the HTTP Post to "/bookmarks"

让我们再向服务器发送一个 HTTP Post 请求,并测试 HTTP Get 请求。图 7-8 显示了对"/bookmarks"的 HTTP Get 请求的响应。它显示了所有书签资源的 JSON 数据。

A337881_1_En_7_Fig8_HTML.jpg

图 7-8。

HTTP Response for the HTTP Get to "/bookmarks"

图 7-9 显示了对"/bookmarks/{id}"的 HTTP Get 请求的响应。它显示了给定书签 ID 的单个书签资源的 JSON 数据。

A337881_1_En_7_Fig9_HTML.jpg

图 7-9。

HTTP Response for the HTTP Get request to "/bookmarks/{id}"

图 7-10 显示了对"/bookmarks/users/{id}"的 HTTP Get 请求的响应。它显示了与给定用户 ID 的用户相关联的所有书签资源的 JSON 数据。

A337881_1_En_7_Fig10_HTML.jpg

图 7-10。

HTTP Response for the HTTP Get request to "/bookmarks/users/{id}"

REST API 应用程序的完整版本可从本书的代码库中获得,地址为 https://github.com/shijuvar/go-recipes

八、测试 Go 应用

软件工程是一个进化过程,在这个过程中,你将应用程序作为一个进化系统来开发,并且不断地修改和重构应用程序。您应该能够随时修改应用程序的功能并重构其代码,而不会破坏应用程序的任何部分。当您将应用程序开发为一个进化产品并修改应用程序代码时,不应该破坏应用程序的任何部分。您可能需要采用一些良好的工程实践来确保您的应用程序的质量。自动化测试是一项重要的工程实践,可以用来确保软件系统的质量。在自动化测试过程中,您针对应用程序中最小的可测试软件(称为单元)编写单元测试,以确定每个单元的功能是否完全符合您的预期。在本章中,你将学习如何在 Go 中编写单元测试。

8-1.编写单元测试

问题

你如何编写单元测试来确保你的 Go 包如你所愿的那样运行?

解决办法

标准库包testing为编写 Go 包的单元测试提供支持。命令运行用包testing编写的单元测试。

它是如何工作的

testing为编写单元测试提供了所有必要的支持,旨在与运行单元测试的go test命令一起使用。go test命令通过查看函数中的以下模式来识别单元测试函数:

func TestXxx(*testing.T)

您编写的单元测试函数带有前缀Test,后跟一个以大写字母开头的字母数字字符串。要编写单元测试函数,您必须创建一个测试套件文件,其名称以_test.go结尾,包含带有签名func TestXxx(*testing.T)的单元测试函数。您通常将测试套件文件放在被测试的同一个包中。当您使用go buildgo install构建包时,它不包括测试套件文件,当您使用go test运行单元测试时,它包括测试套件文件。

要获得运行go test的帮助,请运行以下命令:

go help test

要获得关于go test命令使用的各种标志的帮助,请运行以下命令:

go help testflag

清单 8-1 展示了一个示例包,稍后您将为其编写一个单元测试。

package calc

import "math"

// Sum returns sum of integer values
func Sum(nums ...int) int {
        result := 0
        for _, v := range nums {
                result += v
        }
        return result
}

// Average returns average of integer values
// The output provides a float64 value in two decimal points
func Average(nums ...int) float64 {
        sum := 0
        for _, v := range nums {
                sum += v
        }
        result := float64(sum) / float64(len(nums))
        pow := math.Pow(10, float64(2))
        digit := pow * result
        round := math.Floor(digit)
        return round / pow

}

Listing 8-1.Package calc with Two Utility Functions in calc.go

列表 8-2 显示了测试包calc中函数SumAverage行为的单元测试。

package calc

import "testing"

// Test case for the Sum function
func TestSum(t *testing.T) {
        input, expected := []int{7, 8, 10}, 25
        result := Sum(input...)
        if result != expected {

                t.Errorf("Result: %d, Expected: %d", result, expected)
        }

}

// Test case for the Sum function
func TestAverage(t *testing.T) {
        input, expected := []int{7, 8, 10}, 8.33
        result := Average(input...)
        if result != expected {

                t.Errorf("Result: %f, Expected: %f", result, expected)
        }
}

Listing 8-2.Unit Tests for Package calc in calc_test.go

编写了两个测试用例来验证包calc中函数的行为。单元测试函数的名称以前缀Test开始,后面是一个以大写字母开始的字符串。在单元测试函数中,函数的输出值验证期望值,并调用方法Errorf来通知失败。为了发出测试用例失败的信号,你可以调用ErrorFail或者testing.T类型的相关方法。ErrorFail方法发出测试用例失败的信号,但是它将继续执行剩余的单元测试。如果您想在测试用例失败时停止执行,您可以调用类型testing.T的方法FailNowFatal。方法FailNow调用方法Fail并停止执行。Fatal相当于Log后跟FailNow。在这些单元测试功能中,方法Errorf被用于发出测试用例失败的信号。

if result != expected {

        t.Errorf("Result: %d, Expected: %d", result, expected)
}

运行单元测试

要运行单元测试,从您的package目录运行go test命令:

go test

您应该会看到类似如下的输出:

PASS
ok      github.com/shijuvar/go-recipes/ch08/calc        0.233s

这个测试的输出不是很有描述性。当您执行单元测试时,verbose (-v)标志提供了描述性的信息。

go test –v

这将产生类似于以下内容的输出:

=== RUN   TestSum
--- PASS: TestSum (0.00s)
=== RUN   TestAverage
--- PASS: TestAverage (0.00s)
PASS
ok      github.com/shijuvar/go-recipes/ch08/calc        0.121s

请注意,单元测试是按顺序执行的。在这些测试中,它首先执行测试函数TestSum,在完成执行后,它接着执行测试函数TestAverage

测试覆盖率

当您运行单元测试时,您可以测量由测试用例执行的测试量。go test命令提供了一个覆盖率(-cover)标志,帮助你获得针对你的代码编写的测试用例的覆盖率。让我们运行带有覆盖率标志的单元测试来确定包calc的测试覆盖率:

go test –v –cover

您应该会看到类似如下的输出:

=== RUN   TestSum
--- PASS: TestSum (0.00s)
=== RUN   TestAverage
--- PASS: TestAverage (0.00s)
PASS
coverage: 100.0% of statements
ok      github.com/shijuvar/go-recipes/ch08/calc        0.139s

这个测试输出显示包calc有 100%的测试覆盖率。

8-2.跳过长期运行的测试

问题

在运行测试时,您希望能够灵活地跳过一些单元测试。在运行单元测试时,如何跳过一些单元测试的执行?

解决办法

go test命令允许您传递一个简短的(-short)标志,让您在执行过程中跳过一些单元测试。在单元测试函数中,您可以通过调用包testing中的函数Short来检查是否提供了短标志,如果您想跳过这些测试,可以通过调用testing.T类型的函数Skip来跳过测试的执行。

它是如何工作的

当您执行单元测试时,您可能需要跳过其中的一些。有时,您可能想要阻止某些单元测试在某些用例中执行。例如,您可能想跳过一些耗时的单元测试。另一个示例场景是,一些单元测试可能依赖于在这些测试执行期间不可用的配置文件或环境变量,因此您可以跳过这些测试的执行,而不是让它们失败。

类型testing.T提供了一个可以用来跳过单元测试的方法Skip。为了跳过那些单元测试,您可以通过向go test命令提供一个短的(-short)标志来给出一个信号。清单 8-3 显示了三个单元测试函数,其中如果你给go test命令提供一个短的(-short)标志,在测试执行过程中会跳过一个测试。

package calc

import (
        "testing"
        "time"
)

// Test case for the Sum function
func TestSum(t *testing.T) {
        input, expected := []int{7, 8, 10}, 25
        result := Sum(input...)
        if result != expected {

                t.Errorf("Result: %d, Expected: %d", result, expected)
        }

}

// Test case for the Sum function
func TestAverage(t *testing.T) {
        input, expected := []int{7, 8, 10}, 8.33
        result := Average(input...)
        if result != expected {

                t.Errorf("Result: %f, Expected: %f", result, expected)
        }
}

// TestLongRun is a time-consuming test
func TestLongRun(t *testing.T) {
        // Checks whether the short flag is provided
        if testing.Short() {
                t.Skip("Skipping test in short mode")
        }
        // Long running implementation goes here
        time.Sleep(5 * time.Second)
}

Listing 8-3.Unit Tests in Which One Test is Skipped in Execution

在这些单元测试中,如果您可以为go test命令提供一个短标志,那么您可以跳过函数TestLongRun的测试执行。函数testing.Short用于识别是否提供了短标志。如果是,通过调用函数Skip跳过单元测试的执行。当您调用函数Skip时,您可以提供一个字符串值。

// Checks whether the short flag is provided
           if testing.Short() {
        t.Skip("Skipping test in short mode")
}

如果不提供短标志,函数TestLongRun将作为正常的单元测试运行。让我们通过提供短标志来运行测试:

go test -v -short

您应该会看到类似如下的输出:

=== RUN   TestSum
--- PASS: TestSum (0.00s)
=== RUN   TestAverage
--- PASS: TestAverage (0.00s)
=== RUN   TestLongRun

--- SKIP: TestLongRun (0.00s)

        calc_test.go:36: Skipping test in short mode

PASS
ok      github.com/shijuvar/go-recipes/ch08/calc        0.241s

测试输出显示单元测试函数TestLongRun在执行过程中被跳过。现在让我们在不提供短标志的情况下运行测试:

go test -v

这将产生类似于以下内容的输出:

=== RUN   TestSum
--- PASS: TestSum (0.00s)
=== RUN   TestAverage
--- PASS: TestAverage (0.00s)
=== RUN   TestLongRun

--- PASS: TestLongRun (5.00s)

PASS
ok      github.com/shijuvar/go-recipes/ch08/calc        5.212s

测试输出显示函数TestLongRun正常运行。

8-3.编写基准测试

问题

如何通过编写测试来对 Go 代码进行基准测试?

解决办法

testing允许你为基准 Go 函数编写测试。为了编写基准,编写模式为func BenchmarkXxx(*testing.B)的函数,当go test命令的-bench标志被提供时,这些函数被执行。

它是如何工作的

当您使用go test命令运行测试时,您可以传递-bench标志来执行 bechmark 测试,其中具有模式func BenchmarkXxx(*testing.B)的函数被视为基准。您在_test.go文件中编写基准函数。清单 8-4 显示了对软件包calc功能的基准测试(参见清单 8-1 )。

package calc

import "testing"

// Test case for function Sum
func TestSum(t *testing.T) {
        input, expected := []int{7, 8, 10}, 25
        result := Sum(input...)
        if result != expected {

                t.Errorf("Result: %d, Expected: %d", result, expected)
        }

}

// Test case for function Average
func TestAverage(t *testing.T) {
        input, expected := []int{7, 8, 10}, 8.33
        result := Average(input...)
        if result != expected {

                t.Errorf("Result: %f, Expected: %f", result, expected)
        }
}

// Benchmark for function Sum
func BenchmarkSum(b *testing.B) {
        for i := 0; i < b.N; i++ {
                Sum(7, 8, 10)
        }
}

// Benchmark for function Average
func BenchmarkAverage(b *testing.B) {
        for i := 0; i < b.N; i++ {
                Average(7, 8, 10)
        }
}

Listing 8-4.Unit Tests with Benchmarks in Package calc

编写了两个基准测试来测试包calc中函数的性能。您必须使用循环结构运行目标代码b.N次,以便以可靠的方式执行要进行基准测试的函数。在执行基准测试期间,b.N的值将被调整。基准测试为您提供了每个循环的可靠响应时间。当您向go test命令提供-bench标志时,您需要提供一个正则表达式来指示要执行哪些基准测试。要运行所有基准,使用-bench .-bench=.

让我们通过提供-bench .来运行测试

go test -v -bench .

您应该会看到类似如下的输出:

=== RUN   TestSum
--- PASS: TestSum (0.00s)
=== RUN   TestAverage
--- PASS: TestAverage (0.00s)
BenchmarkSum-4          100000000               23.1 ns/op
BenchmarkAverage-4      10000000               224 ns/op
PASS
ok      github.com/shijuvar/go-recipes/ch08/calc        4.985s

8-4.并行运行单元测试

问题

如何并行执行单元测试?

解决办法

您可以通过调用类型为testing.T的方法Parallel来并行运行单元测试。在单元测试函数内部,对方法Parallel的调用表明这个测试将与其他并行测试并行运行。

它是如何工作的

默认情况下,单元测试按顺序执行。如果您想并行运行一个单元测试来加速执行,那么在编写测试逻辑之前调用测试函数内部的方法Parallel。方法Parallel表明这个单元测试将与其他并行测试并行运行。您可以为任何想要并行运行的单元测试函数调用方法Parallel

清单 8-5 提供了几个并行运行的单元测试。

package calc

import (
        "testing"
        "time"
)

// Test case for the function Sum to be executed in parallel
func TestSumInParallel(t *testing.T) {
        t.Parallel()
        // Delaying 1 second for the sake of demonstration
        time.Sleep(1 * time.Second)
        input, expected := []int{7, 8, 10}, 25
        result := Sum(input...)
        if result != expected {

                t.Errorf("Result: %d, Expected: %d", result, expected)
        }

}

// Test case for the function Sum to be executed in parallel
func TestAverageInParallel(t *testing.T) {
        t.Parallel()
        // Delaying 1 second for the sake of demonstration
        time.Sleep(2 * time.Second)
        input, expected := []int{7, 8, 10}, 8.33
        result := Average(input...)
        if result != expected {

                t.Errorf("Result: %f, Expected: %f", result, expected)
        }
}

Listing 8-5.Unit Tests to Be Run in Parallel

在测试函数内部,方法Parallel作为第一个代码语句被调用,以表示该测试将并行运行,这样并行测试的执行将不会等待测试函数的完成,而是与其他并行测试并行运行。

t.Parallel()

如果您编写混合了并行测试和普通测试的单元测试函数,它将顺序执行普通测试,并与其他并行测试并行执行并行测试。用go test命令运行测试:

go test -v  

您应该会看到类似如下的输出:

=== RUN   TestSumInParallel
=== RUN   TestAverageInParallel
--- PASS: TestSumInParallel (1.00s)
--- PASS: TestAverageInParallel (2.00s)
PASS
ok      github.com/shijuvar/go-recipes/ch08/calc        2.296s

输出显示TestSumInParallelTestAverageInParallel正在并行运行,并且没有等待一个测试完成就运行另一个测试。

8-5.编写用于验证示例代码的测试

问题

如何编写测试来验证示例代码?

解决办法

testing提供了对编写测试来验证示例代码的支持。要编写示例函数,请使用以前缀Example开头的名称来声明函数。

它是如何工作的

示例函数验证为包、类型和函数编写的示例代码。示例函数也可以在由godoc工具生成的 Go 文档中找到。当您使用godoc工具生成 Go 文档时,示例函数将作为 Go 包和各种类型的示例代码。示例函数以前缀Example开头的名称声明。下面是用于声明包示例的命名约定,一个函数F,一个类型T,以及类型T上的方法M:

func Example()    // Example test for package
func ExampleF()   // Example test for function F
func ExampleT()   // Example test for type T
func ExampleT_M() // Example test for M on type T

在示例函数中,通常包含一个以Output:开头的结束行注释。当使用go test命令执行测试功能时,它将给定输出与功能输出进行比较。清单 8-6 展示了包calc中的示例函数。

package calc

import "fmt"

// Example code for function Sum
func ExampleSum() {
        fmt.Println(Sum(7, 8, 10))
        // Output: 25
}

// Example code for function Average
func ExampleAverage() {
        fmt.Println(Average(7, 8, 10))
        // Output: 8.33
}

Listing 8-6.Example Functions for Package calc

编写函数Sum示例代码的约定是ExampleSum,函数Average的约定是ExampleAverage。在示例测试函数中,提供了一个以Output:开头的总结行注释。行注释的输出与函数的标准输出进行比较。在示例函数ExampleSum中,行注释的输出与对Sum的函数调用的输出进行比较。

让我们用go test命令运行示例函数:

go test -v

您应该会看到类似如下的输出:

=== RUN   ExampleSum
--- PASS: ExampleSum (0.00s)
=== RUN   ExampleAverage
--- PASS: ExampleAverage (0.00s)
PASS
ok      github.com/shijuvar/go-recipes/ch08/calc        0.165s

示例测试功能将作为示例代码出现在由godoc工具生成的文档中。图 8-1 显示了函数Sum的文档,其中包括取自测试函数ExampleSum的示例代码。

A337881_1_En_8_Fig1_HTML.jpg

图 8-1。

Documentation for function Sum generated by godoc tool

图 8-2 显示了函数Average的文档,其中包括取自测试函数ExampleAverage的示例代码。

A337881_1_En_8_Fig2_HTML.jpg

图 8-2。

Documentation for function Average generated by godoc tool

8-6.测试 HTTP 应用程序

问题

如何为 HTTP 应用程序编写测试?

解决办法

标准库包net/http/httptest提供了测试 HTTP 应用程序的工具。

它是如何工作的

httptest为测试 HTTP 应用程序提供支持。为了测试 HTTP 应用程序,包httptest提供了ResponseRecorderServer结构类型。

ResponseRecorderhttp.ResponseWriter的实现,它记录 HTTP 响应以检查单元测试中的响应。您可以通过使用记录处理函数中http.ResponseWriter突变的ResponseRecorder来验证http.ResponseWriter在测试中的行为。当您使用ResponseRecorder测试您的 HTTP 应用程序时,您不需要使用 HTTP 服务器。通过调用包httptest的函数NewRecorder创建一个ResponseRecorder实例。

w := httptest.NewRecorder()

Server是一个测试 HTTP 服务器,它监听本地环回接口(127.0.0.1)上系统选择的端口,用于端到端 HTTP 测试。这允许您通过从 HTTP 客户端向测试服务器发送 HTTP 请求,使用 HTTP 服务器测试您的 HTTP 应用程序。通过提供一个http.Handler的实例,调用包httptest的函数NewServer来创建测试 HTTP 服务器。

server := httptest.NewServer(r) // r is an instance of http.Handler

HTTP API 服务器

清单 8-7 展示了一个例子 HTTP API 服务器,它是为稍后用包httptest编写单元测试而创建的。

package main

import (
        "encoding/json"
        "net/http"

        "github.com/gorilla/mux"
)

// User model
type User struct {
        FirstName string `json:"firstname"`
        LastName  string `json:"lastname"`
        Email     string `json:"email"`
}

// getUsers serves requests for Http Get to "/users"
func getUsers(w http.ResponseWriter, r *http.Request) {
        data := []User{
                User{
                        FirstName: "Shiju",
                        LastName:  "Varghese",
                        Email:     "shiju@xyz.com",
                },

                User{
                        FirstName: "Irene",
                        LastName:  "Rose",
                        Email:     "irene@xyz.com",
                },
        }
        users, err := json.Marshal(data)
        if err != nil {
                w.WriteHeader(http.StatusInternalServerError)
                return
        }
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        w.Write(users)
}

func main() {
        r := mux.NewRouter()
        r.HandleFunc("/users", getUsers).Methods("GET")
        http.ListenAndServe(":8080", r)
}

Listing 8-7.Example HTTP Server in main.go

清单 8-7 创建了一个简单的带有单个端点的 HTTP 服务器:HTTP Get to "/users"返回一个集合User实体。

使用 ResponseRecorder 测试 HTTP 应用程序

清单 8-8 显示了用ResponseRecorder测试在清单 8-7 中创建的 HTTP 服务器的测试。

package main

import (
        "net/http"
        "net/http/httptest"
        "testing"

        "github.com/gorilla/mux"
)

// TestGetUsers test HTTP Get to "/users" using ResponseRecorder
func TestGetUsers(t *testing.T) {
        r := mux.NewRouter()
        r.HandleFunc("/users", getUsers).Methods("GET")
        req, err := http.NewRequest("GET", "/users", nil)
        if err != nil {
                t.Error(err)
        }
        w := httptest.NewRecorder()

        r.ServeHTTP(w, req)
        if w.Code != 200 {
                t.Errorf("HTTP Status expected: 200, got: %d", w.Code)
        }
}

Listing 8-8.Testing HTTP API Server Using ResponseRecorder in main_test.go

TestGetUsers中,HTTP 多路复用器被配置用于测试"/users"上的 HTTP Get 请求。

r := mux.NewRouter()
r.HandleFunc("/users", getUsers).Methods("GET")   

使用http.NewRequest to 来调用"/users"上的 HTTP Get 的处理程序的方法ServeHTTP来创建 HTTP 请求对象。一个nil值作为 HTTP 请求主体的参数被提供给函数NewRequest,因为它是一个 HTTP Get 请求。您可以为 HTTP 请求正文提供一个值,以便在 HTTP Posts 上创建 HTTP 请求对象。

req, err := http.NewRequest("GET", "/users", nil)
if err != nil {
        t.Error(err)
}

使用httptest.NewRecorder创建一个ResponseRecorder对象,以记录返回的 HTTP 响应,供以后在测试中检查。

w := httptest.NewRecorder()

HTTP 处理程序的方法ServeHTTP通过提供ResponseRecorderRequest对象来调用"/users"上的 HTTP Get 请求。这将调用处理函数getUsers

r.ServeHTTP(w, req)

ResponseRecorder对象记录返回的 HTTP 响应(处理函数中http.ResponseWriter的变化),以便于检查。您可以看到 HTTP 响应返回了 HTTP 状态代码 200。

if w.Code != 200 {
     t.Errorf("HTTP Status expected: 200, got: %d", w.Code)
}

让我们用go test命令运行测试:

go test -v

您应该会看到类似如下的输出:

=== RUN   TestGetUsers
--- PASS: TestGetUsers (0.00s)
PASS
ok      github.com/shijuvar/go-recipes/ch08/httptest    0.353s

使用服务器测试 HTTP 应用程序

在清单 8-8 中,您使用ResponseRecorder编写了测试来检查 HTTP 响应的值。这种类型足以检查 HTTP 响应的行为。包httptest还提供了一个类型Server,允许您创建一个用于测试的 HTTP 服务器,这样您就可以通过 HTTP 管道运行您的测试,方法是使用 HTTP 客户端向测试 HTTP 服务器发送 HTTP 请求。清单 8-9 显示了一个带有测试Server的测试,用于测试清单 8-7 中创建的 HTTP API 服务器。

package main

import (
        "fmt"
        "net/http"
        "net/http/httptest"
        "testing"

        "github.com/gorilla/mux"
)

// TestGetUsersWithServer test HTTP Get to "/users" using Server
func TestGetUsersWithServer(t *testing.T) {
        r := mux.NewRouter()
        r.HandleFunc("/users", getUsers).Methods("GET")
        server := httptest.NewServer(r)
        defer server.Close()
        usersURL := fmt.Sprintf("%s/users", server.URL)
        request, err := http.NewRequest("GET", usersURL, nil)

        res, err := http.DefaultClient.Do(request)

        if err != nil {
                t.Error(err)
        }

        if res.StatusCode != 200 {
                t.Errorf("HTTP Status expected: 200, got: %d", res.StatusCode)
        }
}

Listing 8-9.Testing HTTP API Server Using Server in main_test.go

在测试函数TestGetUsersWithServer中,HTTP 多路复用器被配置用于测试"/users"上的 HTTP Get 请求。

r := mux.NewRouter()
r.HandleFunc("/users", getUsers).Methods("GET")   

通过调用函数httptest.NewServer创建测试 HTTP 服务器。函数NewServer启动并返回一个新的 HTTP 服务器。Server的方法Close被添加到延迟函数列表中,以在测试完成时关闭测试Server

server := httptest.NewServer(r)
defer server.Close()

使用函数http.NewRequest创建 HTTP 请求,并使用 HTTP 客户端对象的方法Do发送 HTTP 请求。一个nil值作为 HTTP 请求主体的参数被提供给函数NewRequest,因为它是一个 HTTP Get 请求。使用http.DefaultClient创建 HTTP 客户端,然后调用方法Do向测试服务器发送 HTTP 请求,测试服务器返回 HTTP 响应。

usersURL:= fmt.Sprintf("%s/users", server.URL)
request, err := http.NewRequest("GET", usersURL, nil)
res, err := http.DefaultClient.Do(request)

您会看到 HTTP 响应返回 HTTP 状态代码 200。

    if res.StatusCode != 200 {
        t.Errorf("HTTP Status expected: 200, got: %d", res.StatusCode)
    }

让我们用go test命令运行测试:

go test -v

您应该会看到类似如下的输出:

=== RUN   TestGetUsersWithServer
--- PASS: TestGetUsersWithServer (0.01s)
PASS
ok      github.com/shijuvar/go-recipes/ch08/httptest    0.355s

8-7.编写 BDD 风格的测试

问题

如何在 Go 中编写行为驱动开发(BDD)风格的测试?

解决办法

第三方包Ginkgo是一个 BDD 风格的 Go 测试框架,允许你编写基于 BDD 的测试。Ginkgo最好与Gomega匹配器库配对。

它是如何工作的

BDD 是从测试驱动开发(TDD)演变而来的软件开发过程。在 BDD 中,应用程序是通过描述其行为来指定和设计的。BDD 强调行为而不是测试。Ginkgo是一个 BDD 风格的测试框架,建立在标准库包之上testing. Ginkgo通常与Gomega一起使用,作为测试断言的匹配器库。

构建可测试的 HTTP API 服务器

让我们构建一个可测试的 HTTP API 服务器,为应用程序编写 BDD 风格的测试。在 BDD 中,您通常在编写产品代码之前先编写规范(BDD 风格的测试),但是为了便于演示,这里您先编写应用程序代码,然后再编写规范。当您编写测试时,最重要的事情是您的应用程序代码应该是可测试的,以便您可以独立地隔离应用程序的各个组件,并编写测试来验证其行为。

图 8-3 显示了 HTTP API 应用程序的目录结构。要运行后面显示的示例代码,您需要创建这个目录结构,并确保文件创建在正确的目录中。这个目录结构必须在GOPATH/src的子目录中。

A337881_1_En_8_Fig3_HTML.jpg

图 8-3。

Directory structure of the HTTP API application

controllers由处理函数和测试组成。包model定义了应用程序的数据模型。它还为持久化存储定义了一个接口,以便您可以为应用程序代码和测试使用不同的接口实现。包store通过实现包model.中定义的接口来提供持久存储的具体实现

清单 8-10 显示了model包中user.go的来源。

package model

import "errors"

// ErrorEmailExists is an error value for duplicate email id
var ErrorEmailExists = errors.New("Email Id is exists")

// User model
type User struct {
        FirstName string `json:"firstname"`
        LastName  string `json:"lastname"`
        Email     string `json:"email"`
}

// UserStore provides a contract for Data Store for User entity
type UserStore interface {
        GetUsers() []User
        AddUser(User) error
}

Listing 8-10.Data Model and Interface for Persistent Store in model/user.go

model声明了一个名为User的数据模型,并提供了一个名为UserStore的接口,该接口为User实体提供了持久存储的契约。包store通过将User值保存到 MongoDB 数据库中,提供了接口UserStore的实现。

清单 8-11 显示了store包中user_store.go的来源。

package store

import (
        "log"
        "time"

        "gopkg.in/mgo.v2"
        "gopkg.in/mgo.v2/bson"

        "github.com/shijuvar/go-recipes/ch08/httpbdd/model"
)

// MongoDB Session
var mgoSession *mgo.Session

// Create a MongoDB Session
func createDBSession() {
        var err error
        mgoSession, err = mgo.DialWithInfo(&mgo.DialInfo{
                Addrs:   []string{"127.0.0.1"},
                Timeout: 60 * time.Second,
        })
        if err != nil {
                log.Fatalf("[createDbSession]: %s\n", err)
        }
}

// Initializes the MongoDB Session
func init() {
        createDBSession()
}

// MongoUserStore provides persistence logic for "users" collection.
type MongoUserStore struct{}

// AddUser insert new User
func (store *MongoUserStore) AddUser(user model.User) error {
        session := mgoSession.Copy()
        defer session.Close()
        userCol := session.DB("userdb").C("users")
        // Check whether email id exists or not
        var existUser model.User
        err := userCol.Find(bson.M{"email": user.Email}).One(&existUser)
        if err != nil {
                if err == mgo.ErrNotFound { // Email is unique
                }
        }
        if (model.User{}) != existUser {
                return model.ErrorEmailExists
        }
        err = userCol.Insert(user)
        return err
}

// GetUsers returns all documents from the collection.
func (store *MongoUserStore) GetUsers() []model.User {
        session := mgoSession.Copy()
        defer session.Close()
        userCol := session.DB("userdb").C("users")
        var users []model.User
        iter := userCol.Find(nil).Iter()
        result := model.User{}
        for iter.Next(&result) {
                users = append(users, result)
        }
        return users
}

Listing 8-11.Implementation of UserStore to Persist Data into MongoDB in store/user_store.go

Struct type MongoUserStore是接口UserStore的具体实现,它将数据保存到 MongoDB 数据库中。在AddUser功能中,您可以检查新用户的电子邮件 ID 是否唯一。这是我们的应用程序的一个行为,当您为处理函数编写规范时,将会对它进行测试。

清单 8-12 显示了为 HTTP API 应用程序提供处理函数的包controllersuser_controller.go的源代码。

package controllers

import (
        "encoding/json"
        "log"
        "net/http"

        "github.com/shijuvar/go-recipes/ch08/httpbdd/model"
)

// GetUsers serves requests for Http Get to "/users"
func GetUsers(store model.UserStore) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                data := store.GetUsers()
                users, err := json.Marshal(data)
                if err != nil {
                        w.WriteHeader(http.StatusInternalServerError)
                        return
                }
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusOK)
                w.Write(users)
        })

}

// CreateUser serves requests for Http Post to "/users"
func CreateUser(store model.UserStore) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                var user model.User
                // Decode the incoming User json
                err := json.NewDecoder(r.Body).Decode(&user)
                if err != nil {
                        log.Fatalf("[Controllers.CreateUser]: %s\n", err)
                        w.WriteHeader(http.StatusInternalServerError)
                        return
                }
                // Insert User entity into User Store
                err = store.AddUser(user)
                if err != nil {
                        if err == model.ErrorEmailExists {
                                w.WriteHeader(http.StatusBadRequest)
                        } else {
                                w.WriteHeader(http.StatusInternalServerError)
                        }
                        return
                }
                w.WriteHeader(http.StatusCreated)
        })
}

Listing 8-12.Handler Functions in controllers/user_controller.go

controllers为 HTTP API 提供了处理函数。示例 HTTP API 有两个端点:HTTP Get on "/users"和 HTTP Post on "/users"。处理函数GetUsers服务于 HTTP Get on "/users"的 HTTP 请求,处理函数CreateUser服务于 HTTP Post on "/users"的 HTTP 请求。处理函数是为了更好的可测试性而编写的。它们接受接口UserStore的实现作为持久存储,但是它们不依赖于任何具体的实现。因此,您可以为您的应用程序提供持久存储,以将数据持久存储到真实世界的数据库中,并且当您编写测试时,您可以通过提供接口UserStore的实现来提供持久存储的模拟实现。因为应用程序处理程序依赖于接口UserStore,所以在应用程序代码和测试中可以有不同的实现。

清单 8-13 显示了配置 HTTP 多路复用器和创建 HTTP API 服务器的main.go的源代码。

package main

import (
        "net/http"

        "github.com/gorilla/mux"

        "github.com/shijuvar/go-recipes/ch08/httpbdd/controllers"
        "github.com/shijuvar/go-recipes/ch08/httpbdd/store"
)

func setUserRoutes() *mux.Router {
        r := mux.NewRouter()
        userStore := &store.MongoUserStore{}
        r.Handle("/users", controllers.CreateUser(userStore)).Methods("POST")
        r.Handle("/users", controllers.GetUsers(userStore)).Methods("GET")
        return r
}

func main() {
        http.ListenAndServe(":8080", setUserRoutes())
}

Listing 8-13.HTTP API Server in main.go

通过提供一个MongoUserStore实例作为处理函数的参数,应用程序处理程序被配置成一个 HTTP 多路复用器。

userStore := &store.MongoUserStore{}
r.Handle("/users", controllers.CreateUser(userStore)).Methods("POST")
r.Handle("/users", controllers.GetUsers(userStore)).Methods("GET")

为 HTTP API 服务器编写 BDD 风格的测试

第三方包 Ginkgo 及其首选匹配器库Gomega用于指定和验证测试用例中的行为。

安装银杏和戈美加

要安装GinkgoGomega,运行以下命令:

go get github.com/onsi/ginkgo/ginkgo
go get github.com/onsi/gomega  

当你安装软件包ginkgo时,它也会在你的GOBIN位置安装一个名为ginkgo的可执行程序,该程序可以用于引导测试套件文件和运行测试。GOBINgo install命令安装 Go 二进制文件的目录。GOBIN的默认位置是$GOPATH/bin。如果您想更改默认位置,可以通过配置一个名为GOBIN的环境变量来实现。

要使用GinkgoGomega,您必须将这些包添加到导入列表中:

import (

    "github.com/onsi/ginkgo"
    "github.com/onsi/gomega"
)

引导测试套件文件

要用Ginkgo为一个包编写测试,首先要创建一个测试套件文件。让我们从您编写测试套件文件和规范的controllers目录运行下面的命令。

ginkgo bootstrap

这将生成一个名为controllers_suite_test.go的文件,其中包含清单 8-14 中所示的代码。

package controllers_test

import (
        . "github.com/onsi/ginkgo"
        . "github.com/onsi/gomega"

        "testing"
)

func TestControllers(t *testing.T) {
        RegisterFailHandler(Fail)
        RunSpecs(t, "Controllers Suite")
}

Listing 8-14.Test Suite File controllers_suite_test.go in controllers_test Package

生成的名为controllers_suite_test.go的测试套件文件将被组织到一个名为controllers_test的包中,就像您从包controllers中生成测试套件文件一样。在这里,您将测试和应用程序代码组织在同一个目录中,但是在不同的包中。Go 允许你组织controllers目录下的controllerscontrollers_test包。这将把您的测试与应用程序代码隔离开来,因为您将应用程序代码和测试组织到不同的包中。如果您想将测试套件文件和测试的包名改为controllers,您可以这样做,并且Ginkgo可以使用它。

在测试套件controllers_suite_test.go中,您执行以下操作:

  • 使用点(.)导入来导入包ginkgogomega。这允许您调用导出的ginkgogomega包的标识符,而无需使用限定符。
  • 连接GinkgoGomega. GomegaRegisterFailHandler(Fail)语句被用作Ginkgo的匹配器库。
  • RunSpecs(t, "Controllers Suite")语句告诉Ginkgo启动测试套件。如果你的任何一个规格失败,Ginkgo将自动使testing.T失败。

注意,除了ginkgo bootstrap生成的代码,你不需要写任何额外的代码。这个测试套件文件足以运行您在下一步中编写的同一个包中的所有规范。

向套件添加规格

您刚刚创建了名为controllers_suite_test.go的测试套件文件。要运行您的测试套件,您需要添加一个测试文件来运行规范。您可以使用ginkgo generate命令生成一个测试文件。

ginkgo generate user_controller

这将生成一个名为user_controller_test.go的测试文件。清单 8-15 显示了由ginkgo命令行工具生成的代码。

package controllers_test

import (
    . "github.com/onsi/ginkgo"
    . "github.com/onsi/gomega"
)

var _ = Describe("UserController", func() {

})

Listing 8-15.Test File user_controller_test.go Generated by ginkgo

规范是使用银杏的Describe函数写在顶级描述容器中的。Ginkgo使用var _ =来评估顶层的Describe函数,而不需要init函数。

在测试文件中编写规格

生成的测试文件user_controller_test.go现在只包含一个顶层Describe容器。让我们在测试文件中编写规范来测试 HTTP API 服务器。让我们在编写规范之前定义基本的用户故事。

  1. 允许用户查看用户实体列表。
  2. 让用户创建新的用户实体。
  3. 用户实体的电子邮件 Id 应该是唯一的。

现在,让我们根据这些用户故事在测试文件中编写规范。清单 8-16 显示了用user_controller_test.go编写的针对 HTTP API 服务器的 BDD 风格测试的规范。

package controllers_test

import (
        "encoding/json"
        "net/http"
        "net/http/httptest"
        "strings"

        "github.com/shijuvar/go-recipes/ch08/httpbdd/controllers"
        "github.com/shijuvar/go-recipes/ch08/httpbdd/model"

        "github.com/gorilla/mux"
        . "github.com/onsi/ginkgo"
        . "github.com/onsi/gomega"
)

var _ = Describe("UserController", func() {
        var r *mux.Router
        var w *httptest.ResponseRecorder
        var store *FakeUserStore
        BeforeEach(func() {
                r = mux.NewRouter()
                store = newFakeUserStore()
        })

        // Specs for HTTP Get to "/users"
        Describe("Get list of Users", func() {
                Context("Get all Users from data store", func() {
                        It("Should get list of Users", func() {
                                r.Handle("/users", controllers.GetUsers(store)).Methods("GET")
                                req, err := http.NewRequest("GET", "/users", nil)
                                Expect(err).NotTo(HaveOccurred())
                                w = httptest.NewRecorder()
                                r.ServeHTTP(w, req)
                                Expect(w.Code).To(Equal(200))
                                var users []model.User
                                json.Unmarshal(w.Body.Bytes(), &users)
                                // Verifying mocked data of 2 users
                                Expect(len(users)).To(Equal(2))
                        })
                })
        })

        // Specs for HTTP Post to "/users"
        Describe("Post a new User", func() {
                Context("Provide a valid User data", func() {
                        It("Should create a new User and get HTTP Status: 201", func() {
                                r.Handle("/users", controllers.CreateUser(store)).Methods("POST")
                                userJson := `{"firstname": "Alex", "lastname": "John", "email": "alex@xyz.com"}`

                                req, err := http.NewRequest(
                                        "POST",
                                        "/users",
                                        strings.NewReader(userJson),
                                )
                                Expect(err).NotTo(HaveOccurred())
                                w = httptest.NewRecorder()
                                r.ServeHTTP(w, req)
                                Expect(w.Code).To(Equal(201))
                        })
                })
                Context("Provide a User data that contains duplicate email id", func() {
                        It("Should get HTTP Status: 400", func() {
                                r.Handle("/users", controllers.CreateUser(store)).Methods("POST")
                                userJson := `{"firstname": "Shiju", "lastname": "Varghese", "email": "shiju@xyz.com"}`

                                req, err := http.NewRequest(
                                        "POST",
                                        "/users",
                                        strings.NewReader(userJson),
                                )
                                Expect(err).NotTo(HaveOccurred())
                                w = httptest.NewRecorder()
                                r.ServeHTTP(w, req)
                                Expect(w.Code).To(Equal(400))
                        })
                })
        })
})

// FakeUserStore provides a mocked implementation of interface model.UserStore
type FakeUserStore struct {
        userStore []model.User
}

// GetUsers returns all users
func (store *FakeUserStore) GetUsers() []model.User {
        return store.userStore
}

// AddUser inserts a User
func (store *FakeUserStore) AddUser(user model.User) error {
        // Check whether email exists
        for _, u := range store.userStore {
                if u.Email == user.Email {
                        return model.ErrorEmailExists
                }
        }
        store.userStore = append(store.userStore, user)
        return nil
}

// newFakeUserStore provides two dummy data for Users
func newFakeUserStore() *FakeUserStore {
        store := &FakeUserStore{}
        store.AddUser(model.User{
                FirstName: "Shiju",
                LastName:  "Varghese",
                Email:     "shiju@xyz.com",
        })

        store.AddUser(model.User{
                FirstName: "Irene",
                LastName:  "Rose",
                Email:     "irene@xyz.com",
        })
        return store
}

Listing 8-16.Specs in user_controller_test.go

清单 8-16 针对 HTTP API 服务器的处理函数(参见清单 8-12 )提供了 BDD 风格的测试。处理函数依赖于接口model.UserStore,它提供了持久存储的契约。下面是在"/users"上服务 HTTP Get 请求的处理函数GetUsers:

func GetUsers(store model.UserStore) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                data := store.GetUsers()
                users, err := json.Marshal(data)
                if err != nil {
                        w.WriteHeader(http.StatusInternalServerError)
                        return
                }
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusOK)
                w.Write(users)
        })

}

为了测试处理函数,你必须提供一个接口model.UserStore的实现。应用程序代码提供了一个store.MongoUserStore值作为接口model.UserStore的实现,该接口将数据保存到 MongoDB 数据库中。当您编写测试时,您不需要将数据持久化到现实世界的数据库中;相反,您可以为持久化存储提供一个模拟实现。因为处理函数仅仅依赖于接口model.UserStore而不是任何具体的实现,所以通过提供接口model.UserStore的实现,你可以很容易地提供一个模拟实现来使用持久存储。结构类型FakeUserStore提供了接口model.UserStore的模拟实现。

type FakeUserStore struct {
        userStore []model.User
}

// GetUsers returns all users
func (store *FakeUserStore) GetUsers() []model.User {
        return store.userStore
}

// AddUser inserts a User
func (store *FakeUserStore) AddUser(user model.User) error {
        // Check whether email exists
        for _, u := range store.userStore {
                if u.Email == user.Email {
                        return model.ErrorEmailExists
                }
        }
        store.userStore = append(store.userStore, user)
        return nil
}

// newFakeUserStore provides two dummy data for Users
func newFakeUserStore() *FakeUserStore {
        store := &FakeUserStore{}
        store.AddUser(model.User{
                FirstName: "Shiju",
                LastName:  "Varghese",
                Email:     "shiju@xyz.com",
        })

        store.AddUser(model.User{
                FirstName: "Irene",
                LastName:  "Rose",
                Email:     "irene@xyz.com",
        })
        return store
}

函数newFakeUserStore提供了一个带有两个虚拟User数据的FakeUserStore实例。

当你写规范时,块DescribeContextIt被用来指定行为。一个Describe块用于描述代码的单个行为,它被用作ContextIt块的容器。Context块用于指定单个行为下的不同上下文。您可以在一个Describe块中写入多个Context块。It块用于在DescribeContext容器中写入单独的规格。

BeforeEach程序块在每个It程序块之前运行。该块用于在运行每个规范之前编写逻辑。这里它被用来创建mux.RouterFakeUserStore的实例。

var r *mux.Router
var w *httptest.ResponseRecorder
var store *FakeUserStore
BeforeEach(func() {
        r = mux.NewRouter()
        store = newFakeUserStore()
})

mux.RouterFakeUserStore的值用于配置It块中的 HTTP 请求多路复用器。

r.Handle("/users", controllers.GetUsers(store)).Methods("GET")

我们来总结一下user_controller_test.go的规格:

  • Describe块中指定了两个单独的行为:"users"上的“获取用户列表”和“发布新用户”。
  • Describe块中,Context块用于定义个体行为下的上下文。
  • 各个规格写在DescribeContext容器内的It块中。
  • 在“获取用户列表”行为中,指定了一个上下文“从数据存储中获取所有用户”。这个上下文映射了 HTTP Get 在"/users"端点上的功能。在这个上下文中,指定了一个It块“应该获取用户列表”。这将检查返回的 HTTP 响应的状态代码是否为 200。由FakeUserStore提供的持久存储提供了两个用户的虚拟数据,以便您可以检查返回的 HTTP 响应是否有两个用户。
  • 在“发布新用户”行为中,在Context块中定义了两个上下文:“提供有效的用户数据”和“提供包含重复电子邮件 id 的用户数据”。这些上下文映射了"/users"端点上 HTTP Post 的功能。如果您提供有效的User数据,这应该能够创建一个新用户。如果您用重复的电子邮件 ID 提供User数据,您应该会得到一个错误。您为一个重复的用户提供一个现有的电子邮件 id,以测试上下文“提供一个包含重复电子邮件 ID 的用户数据”的行为。为该规范提供的电子邮件 ID 已经添加到持久存储库FakeUserStore中,所以当您执行该规范时,应该会得到 HTTP 错误响应。这些规格在It模块中指定。

您可以使用go test命令或ginkgo命令运行规范。让我们使用go test命令运行规范:

go test -v

您应该会看到类似如下的输出:

=== RUN   TestControllers
Running Suite: Controllers Suite
================================
Random Seed: 1473153169
Will run 3 of 3 specs

+++
Ran 3 of 3 Specs in 0.026 seconds
SUCCESS! -- 3 Passed | 0 Failed | 0 Pending | 0 Skipped --- PASS: TestControllers (0.03s)
PASS
ok      github.com/shijuvar/go-recipes/ch08/httpbdd/controllers 0.624s

您也可以使用ginkgo命令来运行规范:

ginkgo test -v

这应该会产生如下所示的输出:

Running Suite: Controllers Suite
================================
Random Seed: 1473153225
Will run 3 of 3 specs

UserController Get list of Users Get all Users from data store
  Should get list of Users
  D:/go/src/github.com/shijuvar/go-recipes/ch08/httpbdd/controllers/user_controller_test.go:40
+
------------------------------
UserController Post a new User Provide a valid User data
  Should create a new User and get HTTP Status: 201
  D:/go/src/github.com/shijuvar/go-recipes/ch08/httpbdd/controllers/user_controller_test.go:60
+
------------------------------
UserController Post a new User Provide a User data that contains duplicate email id
  Should get HTTP Status: 400
  D:/go/src/github.com/shijuvar/go-recipes/ch08/httpbdd/controllers/user_controller_test.go:76
+
Ran 3 of 3 Specs in 0.070 seconds
SUCCESS! -- 3 Passed | 0 Failed | 0 Pending | 0 Skipped PASS

Ginkgo ran 1 suite in 4.6781235s
Test Suite Passed