理解 Go 语言 Web 编程

2016年4月9日 // Go Web 编程 网站

断断续续学 Go 语言很久了,一直没有涉及Web编程方面的东西。因为仅是凭兴趣去学习的,时间有限,每次去学,也只是弄个一知半解。不过这两天下定决心把 Go 语言 Web 编程弄懂,就查了大量资料,边学边记博客。希望我的这个学习笔记对其他人同样有帮助,由于只是业余半吊子学习,文中必然存在诸多不当之处,恳请读者留言指出,在此先道一声感谢!

本文只是从原理方面对 Go 的 Web 编程进行理解,尤其是详细地解析了 net/http 包。由于篇幅有限,假设读者已经熟悉 Writing Web Applications 这篇文章,这里所进行的工作只是对此文中只是的进一步深入学习和扩充。

Go 语言 Web 程序的实质

利用 Go 语言构建 Web应用程序,实质上是构建 HTTP 服务器。HTTP 是一个简单的请求-响应协议,通常运行在 TCP 之上。它指定了客户端可能发送给服务器什么样的消息以及得到什么样的响应。下图为最简化的 HTTP 协议处理流程。

HTTP 请求和响应流程

HTTP 请求和响应流程

从上图可知,构建在服务器端运行的Web程序的基本要素包括:

  • 如何分析和表示 HTTP 请求;
  • 如何根据 HTTP 请求以及程序逻辑生成 HTTP 响应(包括生成 HTML 网页);
  • 如何使服务器端一直正确地运行以接受请求并生成响应。

Go 语言有关 Web 程序的构建主要涉及 net/http 包,因此这里所给的各种函数、类型、变量等标识符,除了特别说明外,都是属于 net/http 包内的。

请求和响应信息的表示

HTTP 1.1 中,请求和响应信息都是由以下四个部分组成,两者之间格式的区别是开始行不同。

  1. 开始行。位于第一行。在请求信息中叫请求行,在响应信息中叫状态行
    • 请求行:构成为 请求方法 URI 协议/版本,例如 GET "http://images.chingli.com/past/logo.gif HTTP/1.1
    • 响应行:构成为 协议版本 状态代码 状态描述,例如 HTTP/1.1 200 OK
  2. 。零行或多行。包含一些额外的信息,用来说明浏览器、服务器以及后续正文的一些信息。
  3. 空行。
  4. 正文。包含客户端提交或服务器返回的一些信息。请求信息和响应信息中都可以没有此部分。

开始行和头的各行必须以 <CR><LF> 作为结尾。空行内必须只有 <CR><LF> 而无其他空格。在 HTTP/1.1 协议中,开始行和头都是以 ASCII 编码的纯文本,所有的请求头,除 Host 外,都是可选的。

HTTP 请求信息由客户端发来,Web 程序要做的首先就是分析这些请求信息,并用 Go 语言中响应的数据对象来表示。在 net/http 包中,用 Request 结构体表示 HTTP 请求信息。其定义为:

type Request struct {
    Method string
    URL *url.URL
    Proto      string // "HTTP/1.0"
    ProtoMajor int    // 1
    ProtoMinor int    // 0
    Header Header
    Body io.ReadCloser
    ContentLength int64
    TransferEncoding []string
    Close bool
    Host string
    Form url.Values
    PostForm url.Values
    MultipartForm *multipart.Form
    Trailer Header
    RemoteAddr string
    RequestURI string
    TLS *tls.ConnectionState
    Cancel <-chan struct{}
}

当收到并理解(将请求信息解析为 Request 类型变量)了请求信息之后,就需要根据相应的处理逻辑,构建响应信息。net/http 包中,用 Response 结构体表示响应信息。

type Response struct {
    Status     string // e.g. "200 OK"
    StatusCode int    // e.g. 200
    Proto      string // e.g. "HTTP/1.0"
    ProtoMajor int    // e.g. 1
    ProtoMinor int    // e.g. 0
    Header Header
    Body io.ReadCloser
    ContentLength int64
    TransferEncoding []string
    Close bool
    Trailer Header
    Request *Request
    TLS *tls.ConnectionState
}

如何构建响应信息

很显然,前面给出的 RequestResponse 结构体都相当复杂。好在客户端发来的请求信息是符合HTTP协议的,因此 net/http 包已经能够根据请求信息,自动帮我们创建 Request 结构体对象了。那么,net/http 包能不能也自动帮我们创建 Response 结构体对象呢?当然不能。因为很显然,对于每个服务器程序,其行为是不同的,也即需要根据请求构建各样的响应信息,因此我们只能自己构建这个 Response 了。不过在这个过程中,net/http 包还是竭尽所能地为我们提供帮助,从而帮我们隐去了许多复杂的信息。甚至如果不仔细想,我们都没有意识到我们是在构建 Response 结构体对象。

为了能更好地帮助我们,net/http 包首先为我们规定了一个构建 Response 的标准过程。该过程就是要求我们实现一个 Handler 接口:

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

现在,我们编写 Web 程序的主要工作就是编写各种实现该 Handler 接口的类型,并在该类型的 ServeHTTP 方法中编写服务器响应逻辑。这样一来,我们编写的 Web 服务器程序可能主要就是由各种各样的 fooHandlerbarHandler 构成;Handler 接口就成为 net/http 包中最重要的东西。可以说,每个 Handler 接口的实现就是一个小的 Web 服务器。以往由许多人将“handler”翻译为“句柄”,这里将其翻译为处理程序,或不做翻译。

该怎么实现此 Handler 接口呢?我们在这里提供多种方法。

方法 1:显式地编写一个实现 Handler 接口的类型

我们已经读过 Writing Web Applications 这篇文章了,在其中曾实现了查看 Wiki 页面的功能。现在,让我们抛开其中的实现方法,以最普通的思维逻辑,来重现该功能:

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
)

type Page struct {
    Title string
    Body  []byte
}

func loadPage(title string) (*Page, error) {
    filename := title + ".txt"
    body, err := ioutil.ReadFile(filename)
    if err != nil {
        return nil, err
    }
    return &Page{Title: title, Body: body}, nil
}

type viewHandler struct{}

func (viewHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/view/"):]
    p, _ := loadPage(title)
    fmt.Fprintf(w, "<h1>%s</h1><div>%s</div>", p.Title, p.Body)
}

func main() {
    http.Handle("/view/", viewHandler{})
    http.ListenAndServe(":8080", nil)
}

假设该程序的当前目录中有一个 abc.txt 的文本文件,若访问 http://localhost:8080/view/abc,则会显示该文件的内容。

在该程序 main 函数的第一行使用了 Handle 函数,其定义为:

func Handle(pattern string, handler Handler)

该函数的功能就是将我们编写的 Handler 接口的实现 viewHandler 传递给 net/http 包,并由 net/http 包来调用 viewHandlerServeHTTP 方法。至于如何生成 Response,我们可以暂时不管,net/http 包已经替我们完成这些工作了。

不过有一点还是要注意,该 viewHandler 只对 URL 的以 /view/ 开头的路径才起作用,如果我们访问 http://localhost:8080/http://localhost:8080/edit,则都会返回一个 404 page not found 页面;而如果访问 http://localhost:8080/view/xyz,则浏览器什么数据也得不到。对于后一种情况,很显然是因为我们编写的 viewHandler.ServeHTTP 方法没有对Wiki页面文件不存在时 loadPage 函数返回的错误进行处理造成的;而对前一种情况,则是 net/http 包帮我们完成的。很奇怪,为什么只是将 /view/ 字符串传递给 Handle 函数的 pattern 参量,它就会比较智能地匹配 viewHandler?而对于除了 /view/ 开头路径的其他路径,由于没有显式地进行匹配,net/http 包似乎也知道,并自动地帮我们返回 404 page not found 页面。这其实就是 net/http 包提供的简单的路由功能,我们将在以后对其进行介绍。

方法 2:将一个普通函数转换为请求处理函数

我们可能已经注意到了,方法 1 中程序的 viewHandler 结构体中没有一个字段,我们构建它主要是为了使用其 ServeHTTP 方法。很显然,这有点绕了。因为在大多数时候,我们只需要使 Handler成为一个函数就足够了。为此,http 包中提供了一个替代 Handle 函数的 HandleFunc 函数:

func HandleFunc(pattern string, handler func(ResponseWriter, *Request))

HandleFunc 函数不再像 Handle 那样接受一个 Handler 接口对象,而是接受一个具有特定签名的函数。而原来由 Handler 接口对象的 ServeHTTP 方法所实现的功能,现在需要该函数来实现。这样一来,我们就可以改写方法1中的示例程序了,这也正是 Writing Web Applications 一文所使用的方法:

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
)

type Page struct {
    Title string
    Body  []byte
}

func loadPage(title string) (*Page, error) {
    filename := title + ".txt"
    body, err := ioutil.ReadFile(filename)
    if err != nil {
        return nil, err
    }
    return &Page{Title: title, Body: body}, nil
}

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/view/"):]
    p, _ := loadPage(title)
    fmt.Fprintf(w, "<h1>%s</h1><div>%s</div>", p.Title, p.Body)
}

func main() {
    http.HandleFunc("/view/", viewHandler)
    http.ListenAndServe(":8080", nil)
}

可以看出,该示例程序中的 viewHandler 函数实际上并没有实现 Handler 接口,因此它是一个伪 Handler。不过其所实现的功能正是 Handler 接口对象需要实现的功能,我们可称像 viewHandler 这样的函数为 Handler 函数。我们会在方法3中通过类型转换轻易地将这种 Handler 函数转换为一个真正的 Handler

多数情况下,使用 HandleFunc 比使用 Handle 更加简便,这也是我们所常用的方法。

方法 3:利用闭包功能编写一个返回 Handler 的请求处理函数

在 Go 语言中,函数是一等公民,函数字面可以被赋值给一个变量或直接调用。同时函数字面(实际上就是一段代码块)也是一个闭包,它可以引用定义它的外围函数(即该代码块的作用域环境)中的变量,这些变量会在外围函数和该函数字面之间共享,并且在该函数字面可访问期间一直存在。

那么,我们可以定义一个这样的函数类型,该函数类型具有和我们在方法2中定义的 viewHandler 函数具有相同的签名,因而可以通过类型转换把 viewHandler 函数转换为此函数类型;同时该函数类型本身实现了 Handler 接口。net/http 包中的 HandlerFunc 就是这样的函数类型。

首先,HandlerFunc 是一个函数类型:

type HandlerFunc func(ResponseWriter, *Request)

其次,HandlerFunc 同时也实现了 Handler 接口:

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request)

这里 ServeHTTP 的实现很简单,即调用其自身 f(w, r)

任何签名为 func(http.ResponseWriter, *http.Request) 函数都可以被转换为 HandlerFunc。的事实上,方法 2 中的 main 函数中第一行的 HandleFunc 函数就是将 viewHandler 转换为 HandlerFunc 再针对其调用 Handle 的。即 http.HandleFunc("/view/", viewHandler) 相当于 http.Handle("/view/", http.HandlerFunc(viewHandler{}))

既然如此,能不能更直接地编写一个返回 HandlerFunc 函数的函数?借助于 Go 语言函数的灵活性,这一点是可以实现的。可对方法2中的 viewHandler 函数做如下改写:

func viewHandler() http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        title := r.URL.Path[len("/view/"):]
        p, _ := loadPage(title)
        fmt.Fprintf(w, "<h1>%s</h1><div>%s</div>", p.Title, p.Body)
    })
}

由于 viewHandler 函数返回的 HandlerFunc 对象既实现了 Handler 接口,又具有和方法2中的 Handler 函数相同的签名。因此此例中 main 函数的第一行既可以使用 http.Handle,又可以使用 http.HandleFunc。另外,该 viewHandler 函数中的 return 可以不用 http.HandlerFunc 进行显式类型转换,而是自动地将返回的函数字面转换为 HandlerFunc 类型。

现在理解起来可能变得困难点了。为什么要这样做呢?对比方法 2 和方法 3 的 viewHandler 函数签名就可以看出来了:方法 2 中的 viewHandler 函数签名必须是固定的,而方法 3 则是任意的。这样我们可以利用方法3向 viewHandler 函数中传递任意的东西,如数据库连接、HTML 模板、请求验证、日志和追踪等东西,这些变量在闭包函数中是可访问的。而被传递的变量可以是定义在 main 函数内的局部变量;要不然,在闭包函数中能访问的外界变量就只能是全局变量了。另外,利用闭包的性质,被闭包函数引用的外部自由变量将与闭包函数一同存在,即在同样的引用环境中调用闭包函数时,其所引用的自由变量仍保持上次运行后的值,这样就达到了共享状态的目的。让我们对本例中的代码进行修改:

func viewHandler(n int) http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        title := r.URL.Path[len("/view/"):]
        p, _ := loadPage(title)
        fmt.Fprintf(w, "<h1>%s</h1><div>%s</div>", p.Title, p.Body)
        n++
        fmt.Fprintf(w, "<div>%v</div>", n)
    })
}

func main() {
    var n int
    http.HandleFunc("/view/", viewHandler(n))
    http.HandleFunc("/page/", viewHandler(n))
    http.ListenAndServe(":8080", nil)
}

现在,分别访问 http://localhost:8080/view/abchttp://localhost:8080/page/abc 两个地址,每次刷新页面,则显示的 n 值增加 1,但两个地址页面内的 n 值得变化是相互独立的。

方法 4:用封装器函数封装多个 Handler 的实现

我们就可以编写一个具有如下签名的 HandlerFunc 封装器函数:

wrapperHandler(http.HandlerFunc) http.HandlerFunc

该封装器是这样一个函数,它具有一个输入参数和一个输出参数,两者都是 HandlerFunc 类型。该函数通常按如下方式进行定义:

func wrapperHandler(f http.HandlerFunc) http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        do_something_before_calling_f()
        f(w, r)
        do_something_after_calling_f()
    })
}

与方法 3 一样,在封装器函数中,我们使用了 Go 语言闭包的功能构建了一个函数变量,并在返回时将该函数变量转换为 HandlerFunc。与方法3不一样的地方在于,我们通过一个参数将被封装的 Handler 函数传递给封装器函数,并在封装器函数中定义的闭包函数中通过通过 f(w, r) 调用被封装的 HandlerFunc 的功能。而在执行 f(w, r) 之前或之后,我们可以额外地做一些事情,甚至可以根据情况决定是否执行 f(w, r)

这样一来,可以在方法2的示例程序的基础上,添加 wrapperHandler 函数,并修改 main 函数:

func wrapperHandler(f http.HandlerFunc) http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "<div>Do something <strong>before</strong> calling a handler.</div>")
        f(w, r)
        fmt.Fprintf(w, "<div>Do something <strong>after</strong> calling a handler.</div>")
    })
}

func main() {
    http.HandleFunc("/view/", wrapperHandler(viewHandler))
    http.ListenAndServe(":8080", nil)
}

我们真是绕了一个大圈,但这样绕有其自身的好处:

  • 共享代码:将多个 Handler 函数(如 viewHandlereditHandlersaveHandler)中共同的代码放进此封装器函数中,并在封装器中实现一些公用的代码,具体请见 Writing Web Applications 一文的末尾部分。
  • 共享状态:除了本例中向 wrapperHandler 函数传递各种 Handler 函数外,我们可以增加参数个数,即传递其他自由变量给闭包(例如:func wrapperHandler(f http.HandlerFunc, n int) http.HandlerFunc),从而达到与方法 3 相同的共享状态效果。注意,这里说的共享状态实际上只是在同一个闭包函数(也即 Handler)及其运行环境中共享状态,在某一运行环境下传递到某个闭包型 Handler 的自由变量并不能自动再被传出去,这与以后将要讲得在多个 Handler 间共享状态是不同的。

需要补充说明一下。在 net/http 包中,HandleHandleFuncHandlerHandlerFunc,都是对同一问题的具体两种方法。当我们处理的东西较简单时,为求简便,一般会用带 Func 后缀的后一类方法,尤其是 HandlerFunc 给我们带来了很大的灵活性。当需要定义一个包含较多字段的 Handler 实现时,就会像方法1那样正正经经地定义一个 Handler 类型。因此,不管是方法3和方法 4,你都可以看到不同的写法,如使方法 4 封装的是 Handler 结构体变量而非这里的 HandlerFunc,但其原理都是相通的。

ResponseWriter 接口

尽管知道了Handler的多种写法,但我们还没有完全弄明白如何构建 Responsenet/http 包将构建 Response 的过程也标准化了,即通过各种 Handler 操作 ResponseWriter 接口来构建 Response

type ResponseWriter interface {
    Header() Header
    Write([]byte) (int, error)
    WriteHeader(int)
}

ResponseWriter 实现了 io.Writer 接口,因此,该接口可被用于各种打印函数,如 fmt.FprintfWriteHeader 方法用于向HTTP响应信息写入状态码(一般是错误代码),它必须先于 Write 调用。若不调用 WriteHeader,使用 Write 方法会自动写入状态码 http.StatusOKHeader 方法返回一个 Header 结构体对象,可以通过该结构体的方法对HTTP响应消息的头进行操作。但这种操作必须在 WriteHeaderWrite 执行之前进行,除非所操作的 Header 字段在执行 WriteHeaderWrite 之前已经被标记为 "Trailer"。有点复杂,这里就不再多讲了。其实对于大部分人只要调用 WriteHeaderWrite 就够了。

ListenAndServe 函数

前面所有示例程序中,都在 main 函数中调用了 ListenAndServe 函数。下面对此函数所做的工作进行分析。该函数的实现为:

func ListenAndServe(addr string, handler Handler) error {
    server := &Server{Addr: addr, Handler: handler}
    return server.ListenAndServe()
}

该函数新建了一个 Server 对象,然后调用该 ServerListenAndServe 方法并返回执行错误。

Server 这个幕后大佬终于浮出水面了,基于 net/http 包建立的服务器程序都是它在操控的。让我们先看看该结构体的定义:

type Server struct {
    Addr           string        // TCP address to listen on, ":http" if empty
    Handler        Handler       // handler to invoke, http.DefaultServeMux if nil
    ReadTimeout    time.Duration // maximum duration before timing out read of the request
    WriteTimeout   time.Duration // maximum duration before timing out write of the response
    MaxHeaderBytes int           // maximum size of request headers, DefaultMaxHeaderBytes if 0
    TLSConfig      *tls.Config   // optional TLS config, used by ListenAndServeTLS
    TLSNextProto map[string]func(*Server, *tls.Conn, Handler)
    ConnState func(net.Conn, ConnState)
    ErrorLog *log.Logger
    disableKeepAlives int32     // accessed atomically.
    nextProtoOnce     sync.Once // guards initialization of TLSNextProto in Serve
    nextProtoErr      error
}

这里我们主要关心该结构体的 AddrHandler 字段以及如下方法:

func (srv *Server) ListenAndServe() error
func (srv *Server) Serve(l net.Listener) error
func (srv *Server) SetKeepAlivesEnabled(v bool)

ListenAndServe 在 TCP 网络地址 srv.Addr 上监听接入连接,并通过 Serve 方法处理连接。连接被接受后,则使TCP保持连接。如果 srv.Addr 为空,则默认使用 ":http"ListenAndServe 返回的 error 始终不为 nil

Servenet.Listener 类型的l上接受接入连接,为每个连接创建一个新的服务goroutine。该goroutine读请求并调用 srv.Handler 以进行响应。同 ListenAndServe 一样,Serve 返回的 error 也一直不为 nil

至此我们已经涉及到了涉及更底层网络 I/O 的 net 包了,就不再继续深究了。

最简单的Web程序:

package main

import (
    "net/http"
)

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

这时访问 http://localhost:8080/ 或其他任何路径并不是无法访问,而是得到前面提到的 404 page not found。之所以能返回内容,正因为我们的服务器已经开始运行了,并且默认使用了 DefaultServeMux 这个 Handler 类型的变量。

路由

net/http 包默认的路由功能

ServeMuxnet/http 包自带的HTTP请求多路复用器(路由器)。其定义为:

type ServeMux struct {
    mu    sync.RWMutex
    m     map[string]muxEntry
    hosts bool // whether any patterns contain hostnames
}

ServeMux 的方法都是我们前面见过的函数或类型:

func (mux *ServeMux) Handle(pattern string, handler Handler)
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request))
func (mux *ServeMux) Handler(r *Request) (h Handler, pattern string)
func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request)

每个 ServeMux 都包含一个映射列表,每个列表项主要将特定的URL模式与特定的 Handler 对应。为了方便,net/http 包已经为我们定义了一个可导出的 ServeMux 类型的变量 DefaultServeMux

var DefaultServeMux = NewServeMux()

如果我们决定使用 ServeMux 进行路由,则在大部分情况下,使用 DefaultServeMux 已经够了。net/http 包包括一些使用 DefaultServeMux 的捷径:

  • 调用 http.Handlehttp.HandleFunc 实际上就是在往 DefaultServeMux 的映射列表中添加项目;
  • ListenAndServe 的第二个参数为 nil,它也默认使用 DefaultServeMux

当然,如果我们不嫌麻烦,可不用这个 DefaultServeMux,而是自己定义一个。前面方法1中的 main 函数实现的功能与以下代码是相同的:

func main() {
    mux := http.NewServeMux()
    mux.Handle("/view/", viewHandler{})
    http.ListenAndServe(":8080", mux)
}

当我们往 ServeMux 对象中填充足够的列表项后,并在 ListenAndServe 函数中指定使用该路由器,则一旦 HTTP 请求进入,就会对该请求的一些部分(主要是 URL)进行检查,找出最匹配的 Handler 对象以供调用,该对象可由 Handler 方法获得。如果 ServeMux 中已注册的任何 URL 模式都与接入的请求不匹配,Handler 方法的第一个返回值也非 nil,而是返回一个 NotFoundHandler,其正文正是 404 page not found,我们在前面已经见过它了。

ServeMux 同时也实现了 Handler 接口。其 ServeHTTP 方法完成了 ServeMux 的主要功能,即根据HTTP请求找出最佳匹配的 Handler 并执行之,它本身就是一个多 Handler 封装器,是各个 Handler 执行的总入口。这使我们可以像使用其他 Handler 一样使用 ServeMux 对象,如将其传入 ListenAndServe 函数,真正地使我们的服务器按照 ServeMux 给定的规则运行起来。

自定义路由实现

ServeMux 的路由功能是非常简单的,其只支持路径匹配,且匹配能力不强。许多时候 Request.Method 字段是要重点检查的;有时我们还要检查 Request.HostRequest.Header 等字段。总之,在这些时候,ServeMux 已经变得不够用了,这时我们可以自己编写一个路由器。由于前面讲的 HandleHandleFunc 函数默认都使用 DefaultServeMux,既然我们不再准备使用默认的路由器了,就不再使用这两个函数了。那么,只有向 ListenAndServe 函数传入我们的路由器了。根据 ListenAndServe 函数的签名,我们的路由器应首先是一个 Handler,现在的问题变成该如何编写此 Handler。很显然,此路由器 Handler 不仅自身是一个 Handler ,还需要能方便地将任务分配给其他 Handler,为此,它必须有类似 HandleHandleFunc 这样的函数,只不过这样的函数变得更强大、更通用,或更适合我们的业务。

我们已经知道 Handler 的实现有多种方法,现在我们需要考虑的是,我们的路由器应该是一个结构体还是一个函数。很显然,由于结构体具有额外的字段来存储其他信息,通常我们会希望我们的路由器是一个结构体,这样更利于功能的封装。以下程序实现了一个自定义的路由器 myRouter,该路由器的功能就是对请求的域名(主机名称)进行检查,必须是已经注册的域名(可以有多个)才能访问网站功能。这样如果不借助像 Nginx 这样的反向代理,也可以限定我们的网站只为特定域名服务,而当其他不相关的域名也指向本服务器 IP 地址后,通过该域名访问此服务器将返回一个 404 site not found 页面。myRouter.Add 方法的功能其实与 HandleHandleFunc 类似。

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
    "strings"
)

type Page struct {
    Title string
    Body  []byte
}

func loadPage(title string) (*Page, error) {
    filename := title + ".txt"
    body, err := ioutil.ReadFile(filename)
    if err != nil {
        return nil, err
    }
    return &Page{Title: title, Body: body}, nil
}

func viewHandler() http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !strings.HasPrefix(r.URL.Path, "/view/") {
            fmt.Fprint(w, "404 page not found")
            return
        }
        title := r.URL.Path[len("/view/"):]
        p, _ := loadPage(title)
        fmt.Fprintf(w, "<h1>%s</h1><div>%s</div>", p.Title, p.Body)
    })
}

type myRouter struct {
    m map[string]http.HandlerFunc
}

func NewRouter() *myRouter {
    router := new(myRouter)
    router.m = make(map[string]http.HandlerFunc)
    return router
}

func (router *myRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    host := strings.Split(r.Host, ":")[0]
    if f, ok := router.m[host]; ok {
        f(w, r)
    } else {
        fmt.Fprint(w, "404 site not found")
    }
}

func (router *myRouter) Add(host string, f http.HandlerFunc) {
    router.m[host] = f
}

func main() {
    router := NewRouter()
    router.Add("localhost", viewHandler())
    router.Add("127.0.0.1", viewHandler())
    http.ListenAndServe(":8080", router)
}

使用第三方路由包

以上自定义实现的 myRouter 实在是太简陋了,它主要适用于一些简单的 Web 服务器程序(如当下比较流行的单页面 Web 程序)。当网站程序较复杂时,我们就需要一个功能强大的路由器了。在 GitHub 上已经有许多这样的路由器包了。如 gorilla/mux 就是其中一例。该包的使用与 http.ServeMux 以及上面我们自己编写的 myRouter 基本相同,不过功能要强大好多。

另外还有一些路由实现包,其使用方法 http.ServeMux 稍有不同,如 HttpRouter。该包重新定义了 HandlerHandleHandlerFunc等类型或函数签名,因此要依照新的定义编写各种处理程序,所幸的是能有简单的方法继续使用原来的 http.Handlerhttp.HandlerFunc。这里就不详细讲了。

中间件

什么是中间件

在前面路由器的实现中,我们已经意识到,通常只有尽量使用各种现成的包提供的功能,才能使我们编写 Web 服务器程序更加轻松。为了方便我们使用,这些现成的包通常以中间件的形式提供。所谓中间件,是指程序的一部分,它可以封装已有的程序功能,并且添加额外的功能。对于 Go 语言的 Web 编程来说,中间件就是在 HTTP 请求-响应处理链上的函数,他们是独立于我们的 Web 程序而编写,并能够访问我们的请求、响应以及其他需要共享的变量。在 GitHub 能找到许多 Go 语言写的 HTTP 中间件,这些中间件都以独立的包提供,这意味着他们是独立的,可以方便地添加到程序,或从中移除。

在上面的方法 4 中,我们在不经意间写出了一个中间件。这里的 wrapperHandler 就是一个中间件,它就像一个喇叭外面的盒子,不仅将喇叭包起来成为一个音箱,还为音箱添加了电源开关、调节音量大小等功能。只要这个盒子的大小合适,它还可以用来包装其他的喇叭而构成不同的音箱。进一步地,我们甚至可以认为各种路由器(如我们前面写的 myRouter)其实也是中间件。

Go 语言的中间件实现的要点:

  • 中间件自身是一个 Handler 类型;或者是一个返回 Handler 类型的函数;或是一个返回 HandlerFunc 的函数;或者是返回一个函数,该函数的返回值为 Handler 类型(真够绕的)。
  • 中间件一般封装一个(或多个)Handler,并在适当的位置调用该 Handler,如通过调用 f(w, r)w http.ResponseWriter, r *http.Request 两参数传递给被封装的 Handler 并执行之。
  • 在调用 Handler 之前或之后,可以实现自身的一些功能。
  • 通过一定的机制在多个 Handler 之间共享状态。

gorilla/handlers 包就提供了许多的中间件,他们的定义与上面的 wrapperHandler 不太相同,让我们来随便看看其中一些中间件的函数签名:

func CanonicalHost(domain string, code int) func(h http.Handler) http.Handler
func CombinedLoggingHandler(out io.Writer, h http.Handler) http.Handler
func CompressHandler(h http.Handler) http.Handler

通常中间件实现的功能都是大多数 Web 服务器程序共同需要的功能。如:

  • 日志记录和追踪,显示调试信息;
  • 连接或断开数据库连接;
  • 提供静态文件 HTTP 服务;
  • 验证请求信息,阻止恶意的或其他不想要的访问,限制访问频次;
  • 写响应头,压缩 HTTP 响应,添加 HSTS 头;
  • 从异常中恢复运行;
  • 等等……

组合使用各种中间件

理解了中间件的概念以及其使用和编写方法之后,编写我们自己的 Web 服务器程序就不那么复杂了:无非就是编写各种各样的 Handler,并仔细设计将这些 Handler 层层组合起来。当然这其中必然会涉及更多的知识,但那些都是细节了,我们这里并不进行讨论。

进一步的学习或应用可以结合已有的一些第三方中间件库来编写自己的程序,如 Gorilla Web 工具箱或 codegangsta/negroni。这两者的共同特点就是遵照 net/http 包的惯用法进行编程,只要理解了前面讲的知识,就能较轻易地理解这两者的原理和用法。这两者之中,codegangsta/negroni 的聚合度要更高一点,它主动帮我们实现了一些常用功能。

当有人在社区中问究竟该使用哪个 Go 语言 Web 框架时,总会有人回答说使用 net/http 包自身的功能就是不错的选择,这种回答实际上就是自己按照以上讲述的方法编写各种具体功能的 Handler,并使用网上已有的各种中间件,从而实现程序功能。现在看来,由于 net/http 包以及 Go 语言的出色设计,这样的确能编写出灵活的且具有较大扩展性的程序,这种方法的确是一种不错的选择。但尽管如此,有时我们还是希望能有别人帮我们做更多的事情,甚至已经为我们规划好了程序的结构,这个时候,我们就要使用到框架。

在多个 Handler(或中间件)间共享状态

当我们的 Web 服务器程序的体量越来越大时,就必然有许许多多的 Handler(中间件也是 Handler);对于同一个请求,可能需要多个 Handler 进行处理;多个 Handler 被并列地或嵌套地调用。因此,这时就会涉及到多个 Handler 之间共享状态(即共享变量)的问题。在前面我们已经见识过中间件的编写方式,就是提供各种方法将 w http.ResponseWriterr *http.Request 参数先传递给中间件(封装器),然后再进一步传递给被封装的 HandlerHandlerFunc,这里传递的 wr 变量实际上就是被共享的状态。

通常,有两类变量需要在多个 Handler 间共享。第一类是在服务器运行期间一直存在,且被多个 Handler 共同使用的变量,如一个数据库连接,存储 session 所用的仓库,甚至前面讲的 ServeMux 中存储 patternHandler 间对应关系的列表等,我们将第一类变量称作“与应用程序同生存周期的变量”。第二类是只在单个请求的处理期间存在的变量,如从 Request 信息中得出的用户 ID 和授权码等,我们将第二类变量称作“与请求同生存周期变量”,对于不同的请求,需要的这种变量的类型、个数都不固定。

另外,在 Go 语言中,每次请求处理都需要启动一个独立的 goroutine,这时在 Handler 间共享状态还不涉及线程安全问题;但有些请求的处理过程中可能会启动更多的 goroutine,如某个处理请求的 goroutine 中,再启动一个 goroutine 进行 RPC,这时在多个 Handler 间共享状态时,要确保该变量是线程安全的,即不能在某个 goroutine 修改某个变量的同时,另外一个 goroutine 在读此变量。如果将同一个变量传递给多个 goroutine,一旦该变量被修改或设为不可用,这种改变对所有 goroutine 应该是一致的。当编写 Web程序时,常常遇到与请求同生存周期变量,我们往往无法精确预料需要保存的变量类型和变量个数,这时最方便的是使用映射类型进行保存,而映射又不是线程安全的。因此,必须采取措施保证被传递的变量是线程安全的。

在多个 Handler 间传递变量的方法可归结为两种:

方法 a:使用全局变量共享状态

如在包的开头定义一个全局变量

var db *sql.DB

前面讲到的在 http 包中定义的 http.DefaultServeMux 就是这样的全局变量。

这样我们自己编写的各个 Handler 就可以直接访问此全局变量了。对于第一类的与应用程序同生存周期的变量,这是一个好办法。但当我们的程序中有太多的 Handler 时,每个 Handler 可能都需要一些特别的全局变量,这时程序中可能有很多的全局变量,就会增加程序的耦合度,使维护变得困难。这时可以用结构体类型进一步封装这些全局变量,甚至把 Handler 定义为这种结构体的方法。

对于与请求同生存周期变量,也可以使用全局变量的方法在多个 Handler 之间共享状态。gorilla/context 包就提供了这样一种功能。该包提供一种方法在一个全局变量中存储很多很多的东西,且可以线程安全地读写。该包中的一个全局变量可用来存储在一个请求生命周期内需要共享的东西。每次的请求是不同的,每次请求所要共享的状态也是不同的,为了实现最大限度的灵活性,该包差不多定义了一个具有以下类型的全局变量:

map[*http.Request]map[string]interface{}

该全局变量针对每次请求存储一组状态的列表,在请求结束将该请求对应的状态映射列表清空。由于是用映射实现的,而映射并非线程安全的,因此在每次数据项改写操作过程中需要将其锁起来。

方法 b:修改 Handler 的定义通过传递参数共享状态

既然 w http.ResponseWriterr *http.Request 就是在各个 Handler 之间共享的两个状态变量,那能不能修改 http 包,以同样的方法共享更多的状态变量?当然能,并且还有多种方法:

示例 1:修改 Handler 接口的 ServeHTTP 函数签名,使其接受一个额外的参数。如使其变为 ServeHTTP(http.ResponseWriter, *http.Request, int),从而可额外将一个 int 类型变量(如用户 ID)传递给 Handler

示例 2:修改 Request,使其包含需要共享的额外的字段。

示例 3:设计一个类型,使它既包含 Request 的内容,又实现了 ResponseWriter 接口,同时又可包含额外的变量。

还有更多种方法,既然不再必须遵守 http 包中关于 Handler 实现的约定,我们可以随心所欲地编写我们的 Handler。这种方法对于与请求同生存周期变量的共享非常有用。已经存在着许许多多的 Go 语言 Web 框架,往往每种框架都规定了一种编写 Handler 的方法,都能更方便地在各个 Handler 之间共享状态。我们似乎获得了更大的自由,但请注意,这样一来,我们往往需要修改 http 包中的许多东西,并且不使用惯用的方法来编写 Handler 或中间件,使得各个 Handler 或中间件对不同的框架是不通用的。因此,这些为了更好地实现在多个 Handler 间共享状态的方法,反倒使 Go 语言的 Web 编程世界变得支离破碎。

还需要说明一点。我们提倡编写标准的 Handler 来使我们的代码更容易调用第三方中间件或被第三方中间件调用,但并不意味着在编程时,所有的处理函数或类型都要编写成 Handler 形式,因为这样反而会限制了我们的自由。只要我们的函数或类型不是可导出的,并且不与其他中间件交互,我们就可以随意地编写他们。这样一来,函数或方法就可以随意地定义,共享状态并不是那么难。

通过上下文(context)共享状态

Context 通常被译作上下文或语境,它是一个比较抽象的概念,可以将其理解为程序单元的一个运行状态(或快照)。这里的程序单元可以为一个 goroutine,或为一个 Handler。如每个 goroutine 在执行之前,都要先知道整个程序当前的执行状态,通常将这些执行状态封装在一个 ctx(context 的缩写)结构体变量中,传递给要执行的 goroutine 中。上下文的概念几乎已经成为传递与请求同生存周期变量的标准方法,这时 ctx 不光要在多个 Handler 之间传递,同时也可能在多个 goroutine 之间传递,因此我们必须保证所传递的 ctx 变量是类型安全的。

所幸的是,已经存在一种成熟的机制在多个 goroutine 间线程安全地传递变量了,具体请参见 Go Concurrency Patterns: Contextgolang.org/x/net/context 包就是这种机制的实现。context 包不仅实现了在程序单元(goroutine、API 边界等)之间共享状态变量的方法,同时能通过简单的方法,使我们在被调用程序单元的外部,通过设置 ctx 变量值,将过期或撤销这些信号传递给被调用的程序单元。

在 Go 1.7 中,context 可能作为最顶层的包进入标准库。context 包能被应用于多种场合,但最主要的场合应该是在多个 goroutine 间(其实也是在多个 Handler 间)方便、安全地共享状态。为此,在 Go 1.7 中,随着 context 包的引入,将会在 http.Request 结构体中添加一个新的字段 Context。这种方法正是前面方法b中的示例2所做的,这样一来,我们就定义了一种在多个 Handler 间共享状态的标准方法,有可能使 Go 语言已经开始变得破碎的Web编程世界得以弥合。

既然 context 包这么重要,让我们来了解一下它吧。context 包的核心就是 Context 接口,其定义如下:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key interface{}) interface{}
}

该接口的 Value 方法返回与一个 key(不存在 key 时就用 nil)对应的值,该值就是 ctx 要传递的具体变量值。除此之外,我们定义了专门的方法来额外地标明某个 Context 是否已关闭(超过截止时间或被主动撤销)、关闭的时间及原因:Done 方法返回一个信道(channel),当 Context 被撤销或过期时,该信道是关闭的,即它是一个表示 Context 是否已关闭的信号;当 Done 信道关闭后,Err 方法表明 Context 被撤的原因;当 Context 将要被撤销时,Deadline 返回撤销执行的时间。在Web编程时,Context 对象总是与一个请求对应的,若 Context 已关闭,则与该请求相关联的所有 goroutine 应立即释放资源并退出。

似乎 Context 接口没有提供方法来设置其值和过期时间,也没有提供方法直接将其自身撤销。也就是说,Context 不能改变和撤销其自身。那么该怎么通过 Context 传递改变后的状态呢?请继续读下去吧。

无论是 goroutine,他们的创建和调用关系总是像一棵树的根系一样层层进行的,更靠根部的 goroutine 应有办法主动关闭其下属的 goroutine 的执行(不然程序可能就失控了)。为了实现这种关系,我们的 Context 结构也应该像一棵树的根系,根须总是由根部衍生出来的。要创建 Context 树,第一步就是要得到树根,context.Background 函数的返回值就是树根:

func Background() Context

该函数返回一个非 nil 但值为空的 Context,该 Context 一般由 main 函数创建,是与进入请求对应的 Context 树的树根,它不能被取消、没有值、也没有过期时间。

有了树根,又该怎么创建根须呢?context 包为我们提供了多个函数来创建根须:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key interface{}, val interface{}) Context

看见没有?这些函数都接收一个 Context 类型的参数 parent,并返回一个 Context 类型的值,即表示是从接收的根部得到返回的须部。这些函数都是树形结构上创建根部的须部,须部是从复制根部得到的,并且根据接收参数设定须部的一些状态值,接着就可以将根须传递给下层的 goroutine 了。

让我们先来看看最后面的 WithValue 函数,它返回 parent 的一个副本,调用该副本的 Value(key) 方法将得到 val。这样我们不光将根部原有的值保留了,还在须部中加入了新的值(若须部新加入值的 key 在根部已存在,则会覆盖根部的值)。

我们还不知道该怎么设置 Context 的过期时间,或直接撤销 Context 呢,答案就在前三个函数。先看第一个 WithCancel 函数,它只是将根部复制到须部,并且还返回一个额外的 cancel CancelFunc 函数类型变量,该函数类型的定义为:

type CancelFunc func()

调用 CancelFunc 对象将撤销对应的 Context 对象,这就是主动撤销 Context 的方法。也就是说,在根部 Context 所对应的环境中,通过 WithCancel 函数不仅可创建须部的 Context,同时也获得了该须部 Context 的一个命门机关,只要一触发该机关,该须部 Context(以及须部的须部)都将一命呜呼。

WithDeadline 函数的作用也差不多,它返回的 Context 类型值同样是 parent 的副本,但其过期时间由 deadlineparent 的过期时间共同决定。当 parent 的过期时间早于传入的 deadline 时间时,返回的根须过期时间应与 parent 相同(根部过期时,其所有的根须必须同时关闭);反之,返回的根须的过期时间则为 deadlineWithTimeout 函数又和 WithDeadline 类似,只不过它传入的是从现在开始 Context 剩余的生命时长。WithDeadlineWithTimeout 同样也都返回了所创建的子 Context 的命门机关:一个 CancelFunc 类型的函数变量。

context 包实现的功能使得根部 Context 所处的环境总是对须部 Context 有生杀予夺的大权。这样一来,我们的根部 goroutine 对须部的 goroutine 也就有了控制权。

概括来说,在请求处理时,上下文具有如下特点:

  • Context 对象(ctx 变量)的生存周期一般仅为一个请求的处理周期。即针对一个请求创建一个 ctx 变量(它为 Context 树结构的树根);在请求处理结束后,撤销此 ctx 变量,释放资源。
  • 每次创建一个 goroutine 或调用一个 Handler,要么将原有的 ctx 传递给 goroutine,要么创建 ctx 的一个子 Context 并传递给 goroutine。
  • 为了使多个中间件相互链式调用,必须以标准的方法在多个 Handler 之间传递 ctx 变量。如重新规定 Handler 接口中 ServeHTTP 方法的签名为 ServeHTTP(context.Context, http.ResponseWriter, *http.Request),或将 Context 作为 Request 结构体的一个字段。
  • ctx 对象能灵活地存储不同类型、不同数目的值,并且使多个 goroutine 安全地读写其中的值。
  • 当通过父 Context 对象创建子 Context 对象时,可同时获得子 Context 的一个撤销函数,这样父 Context 对象的创建环境就获得了对子 Context 将要被传递到的 goroutine 的撤销权。
  • 在子 Context 被传递到的 goroutine 中,应该对该子 ContextDone 信道(channel)进行监控,一旦该信道被关闭(即上层运行环境撤销了本 goroutine 的执行),应主动终止对当前请求信息的处理,释放资源并返回。

现在,是时候给出点示例代码来看看 context 包具体该如何应用了。但由于篇幅所限,加之短短几行代码难以说明白 context 包的用法,这里并不准备进行举例。Go Concurrency Patterns: Context 一文中所列举的“Google Web Search”示例则是一个极好的学习示例,请自行移步去看吧。

框架

我们在前面已经费劲口舌地说明了当用 Go 写 Web 服务器程序时,该如何实现路由功能,以及该如何用规范的方式编写 Handler(或中间件)。但一个 Web 程序的编写往往要涉及更多的方面,我们在前面介绍中间件时已经说过,各种各样的中间件能够帮助我们完成这些任务。但许多时候,我们总是希望他人帮我们完成更多的事情,从而使我们自己的工作更加省力。应运这种需求,就产生了许许多多的Web框架。根据架构的不同,这些框架大致可分为两大类:

第一类是微架构型框架。其核心框架只提供很少的功能,而更多的功能则需要组合各种中间件来提供,因此这种框架也可称为混搭型框架。它相当灵活,但相对来说需要使用者在组合使用各种中间件时花费更大的力气。像 EchoGojiGin 等都属于微架构型框架。

第二类是全能型架构。它基本上提供了你编写Web应用时需要的所有功能,因此更加重型,多数使用MVC架构模式设计。在使用这类框架时你可能感觉更轻省,但其做事风格一般不同于 Go 语言惯用的风格,你也较难弄明白这些框架是如何工作的。像 BeegoRevel 等就属于全能型架构。

对于究竟该选择微架构还是全能型架构,仍有较多的争议。像 The Case for Go Web Frameworks 一文就力挺全能型架构,并且其副标题就是“Idiomatic Go is not a religion”,但该文也收到了较多的反对意见,见这里这里。总体上来说,Go 语言社区已越来越偏向使用微架构型框架,当将来 context 包进入标准库后,http.Handler 本身就定义了较完善的中间件编写规范,这种使用微架构的趋势可能更加明显,并且各种微架构的实现方式有望进一步走向统一,这样其实 http 包就是一个具有庞大生态系统的微架构框架。

更加自我

在此之前,我们一直在谈论 net/http 包,但实际上我们甚至可以完全不用此包而编写 Web 服务器程序。如有人编写了 fasthttp 包,并声称它比 net/http 包快 10 倍,并且前面提到的 Echo 框架也可以在底层使用此包。听起来或许很好,但这样一来,我们编写 Handler 和中间件的方式就会大变了,最终可能置我们于孤独的境地。

这里之所以介绍 fasthttp 包,只是为了告诉大家,我们总有更多的选择,千万不要把思维局限在某种方法或某个框架。随着我们对自身需求把握得更加准确,以及对程序质量要求的提高,我们可能真的会去考虑这些选择,而到那时,则必须对 Go 语言 Web 编程有更深刻的理解。

参考文章