理解Go語言Web編程

aaanly 8年前發布 | 63K 次閱讀 Golang Google Go/Golang開發 Web開發

斷斷續續學Go語言很久了,一直沒有涉及Web編程方面的東西。因為僅是憑興趣去學習的,時間有限,每次去學,也只是弄個一知半解。不過這兩天下定決心把Go語言Web編程弄懂,就查了大量資料,邊學邊記博客。希望我的這個學習筆記對其他人同樣有幫助,由于只是業余半吊子學習,文中必然存在諸多不當之處,懇請讀者留言指出,在此先道一聲感謝!

本文只是從原理方面對Go的Web編程進行理解,尤其是詳細地解析了net/http包。由于篇幅有限,假設讀者已經熟悉Writing Web Applications這篇文章,這里所進行的工作只是對此文中只是的進一步深入學習和擴充。

Go語言Web程序的實質

利用Go語言構建Web應用程序,實質上是構建HTTP服務器。HTTP是一個簡單的請求-響應協議,通常運行在TCP之上。它指定了客戶端可能發送給服務器什么樣的消息以及得到什么樣的響應。下圖為最簡化的HTTP協議處理流程。

理解Go語言Web編程
HTTP請求和響應流程

從上圖可知,構建在服務器端運行的Web程序的基本要素包括:

  • 如何分析和表示HTTP請求;
  • 如何根據HTTP請求以及程序邏輯生成HTTP響應(包括生成HTML網頁);
  • 如何使服務器端一直正確地運行以接受請求并生成響應。

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)
}

我們真是繞了一個大圈,但這樣繞有其自身的好處:

  • 共享代碼:將多個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編程有更深刻的理解。

參考文章

來源:http://www.chingli.com/coding/understanding-go-web-app/

 本文由用戶 aaanly 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!