理解Go语言Web编程

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

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

Go语言Web程序的实质

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

HTTP请求-响应流程
HTTP请求和响应流程

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

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

请求和响应信息的表示

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

  1. 开始行。位于第一行。在请求信息中叫请求行,在响应信息中叫状态行
    • 请求行:构成为请求方法 URI 协议/版本,例如GET /images/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)
}

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

需要补充说明一下。在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的捷径:

当然,如果我们不嫌麻烦,可不用这个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语言的中间件实现的要点:

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服务器程序共同需要的功能。如:

组合使用各种中间件

理解了中间件的概念以及其使用和编写方法之后,编写我们自己的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包具体该如何应用了。但由于篇幅所限,加之短短几行代码难以说明白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编程有更深刻的理解。

参考文章