Go Web 程式設計入門--深入學習用 Go 編寫 HTTP 伺服器

KevinYan發表於2020-01-28


Go是一門通用的程式語言,想要學習 Go 語言的 Web 開發,就必須知道如何用 Go 啟動一個 HTTP 伺服器用於接收和響應來自客戶端的 HTTP 請求。用 Go實現一個http server非常容易,Go 語言標準庫net/http自帶了一系列結構和方法來幫助開發者簡化 HTTP 服務開發的相關流程。因此,我們不需要依賴任何第三方元件就能構建並啟動一個高併發的 HTTP 伺服器。這篇文章會學習如何用net/http自己編寫實現一個HTTP Serve並探究其實現原理,以此來學習瞭解網路程式設計的常見正規化以及設計思路。

HTTP 服務處理流程

基於HTTP構建的服務標準模型包括兩個端,客戶端(Client)和服務端(Server)。HTTP 請求從客戶端發出,服務端接受到請求後進行處理然後將響應返回給客戶端。所以http伺服器的工作就在於如何接受來自客戶端的請求,並向客戶端返回響應。

典型的 HTTP 服務的處理流程如下圖所示:

HTTP處理流程

伺服器在接收到請求時,首先會進入路由(router),也成為服務複用器(Multiplexe),路由的工作在於請求找到對應的處理器(handler),處理器對接收到的請求進行相應處理後構建響應並返回給客戶端。Go實現的http server同樣遵循這樣的處理流程。

我們先看看Go如何實現一個簡單的返回 "Hello World"http server

package main

import (
   "fmt"
   "net/http"
)

func HelloHandler(w http.ResponseWriter, r *http.Request) {
   fmt.Fprintf(w, "Hello World")
}

func main () {
   http.HandleFunc("/", HelloHandler)
   http.ListenAndServe(":8000", nil)
}

執行程式碼之後,在瀏覽器中開啟localhost:8000就可以看到Hello World。這段程式碼先利用http.HandleFunc在根路由/上註冊了一個HelloHandler, 然後利用http.ListenAndServe啟動伺服器並監聽本地的 8000 埠。當有請求過來時,則根據路由執行對應的handler函式。

我們再看一下另外一種常見的實現方式:

package main

import (
   "fmt"
   "net/http"
)

type HelloHandlerStruct struct {
   content string
}

func (handler *HelloHandlerStruct) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   fmt.Fprintf(w, handler.content)
}

func main()  {
   http.Handle("/", &HelloHandlerStruct{content: "Hello World"})
   http.ListenAndServe(":8000", nil)
}

這段程式碼不再使用 http.HandleFunc 函式,取而代之的是直接呼叫 http.Handle 並傳入我們自定義的 http.Handler 介面的例項。

Go實現的http服務步驟非常簡單,首先註冊路由,然後建立服務並開啟監聽即可。下文我們將從註冊路由、開啟服務、處理請求,以及關閉服務這幾個步驟瞭解Go如何實現http服務。

路由註冊

http.HandleFunchttp.Handle都是用於給路由規則指定處理器,http.HandleFunc的第一個引數為路由的匹配規則(pattern)第二個引數是一個簽名為func(w http.ResponseWriter, r *http.Requests)的函式。而http.Handle的第二個引數為實現了http.Handler介面的型別的例項。

http.HandleFunchttp.Handle的原始碼如下:

func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    DefaultServeMux.HandleFunc(pattern, handler)
}

// HandleFunc registers the handler function for the given pattern.
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    if handler == nil {
        panic("http: nil handler")
    }
    mux.Handle(pattern, HandlerFunc(handler))
}

func Handle(pattern string, handler Handler) { 
    DefaultServeMux.Handle(pattern, handler)
}

可以看到這兩個函式最終都由DefaultServeMux呼叫Handle方法來完成路由處理器的註冊。
這裡我們遇到兩種型別的物件:ServeMuxHandler

Handler

http.Handlernet/http中定義的介面用來表示 HTTP 請求:

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

Handler介面中宣告瞭名為ServeHTTP的函式簽名,也就是說任何結構只要實現了這個ServeHTTP方法,那麼這個結構體就是一個Handler物件。其實go的http服務都是基於Handler進行處理,而Handler物件的ServeHTTP方法會讀取Request進行邏輯處理然後向ResponseWriter中寫入響應的頭部資訊和響應內容。

回到上面的HandleFunc函式,它呼叫了*ServeMux.HandleFunc將處理器註冊到指定路由規則上:

func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    if handler == nil {
        panic("http: nil handler")
    }
    mux.Handle(pattern, HandlerFunc(handler))
}

注意一下這行程式碼:

mux.Handle(pattern, HandlerFunc(handler))

這裡HandlerFunc實際上是將handler函式做了一個型別轉換,將函式轉換為了http.HandlerFunc型別(注意:註冊路由時呼叫的是 http.HandleFunc,這裡型別是http.HandlerFunc)。看一下HandlerFunc的定義:

type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

HandlerFunc型別表示的是一個具有func(ResponseWriter, *Request)簽名的函式型別,並且這種型別實現了ServeHTTP方法(在其實現的ServeHTTP方法中又呼叫了被轉換的函式自身)。也就是說這個型別的函式其實就是一個Handler型別的物件。利用這種型別轉換,我們可以將將具有func(ResponseWriter, *Request)簽名的普通函式轉換為一個Handler物件,而不需要定義一個結構體,再讓這個結構實現ServeHTTP方法。

ServeMux(服務複用器)

上面的程式碼中可以看到不論是使用http.HandleFunc還是http.Handle註冊路由的處理函式時最後都會用到ServerMux結構的Handle方法去註冊路由處理函式。

我們先來看一下ServeMux的定義:

type ServeMux struct {
    mu    sync.RWMutex
    m     map[string]muxEntry
    es    []muxEntry // slice of entries sorted from longest to shortest.
    hosts bool       // whether any patterns contain hostnames
}

type muxEntry struct {
    h       Handler
    pattern string
}

ServeMux中的欄位m,是一個mapkey是路由表示式,value是一個muxEntry結構,muxEntry結構體儲存了路由表示式和對應的handler。欄位m對應的 map用於路由的精確匹配而es欄位的slice會用於路由的部分匹配,這個到了路由匹配部分再細講。

ServeMux也實現了ServeHTTP方法:

func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
    if r.RequestURI == "*" {
        if r.ProtoAtLeast(1, 1) {
            w.Header().Set("Connection", "close")
        }
        w.WriteHeader(StatusBadRequest)
        return
    }
    h, _ := mux.Handler(r)
    h.ServeHTTP(w, r)
}

也就是說ServeMux結構體也是Handler物件,只不過ServeMuxServeHTTP方法不是用來處理具體的request和構建response,而是用來透過路由查詢對應的路由處理器Handler物件,再去呼叫路由處理器的ServeHTTP 方法去處理request和構建reponse

註冊路由

搞明白HandlerServeMux之後,我們再回到之前的程式碼:

DefaultServeMux.Handle(pattern, handler)

這裡的DefaultServeMux表示一個預設的ServeMux例項,在上面的例子中我們沒有建立自定義的ServeMux,所以會自動使用DefaultServeMux

然後再看一下ServeMuxHandle方法是怎麼註冊路由的處理函式的:

func (mux *ServeMux) Handle(pattern string, handler Handler) {
    mux.mu.Lock()
    defer mux.mu.Unlock()

    if pattern == "" {
        panic("http: invalid pattern")
    }
    if handler == nil {
        panic("http: nil handler")
    }
  // 路由已經註冊過處理器函式,直接panic
    if _, exist := mux.m[pattern]; exist {
        panic("http: multiple registrations for " + pattern)
    }

    if mux.m == nil {
        mux.m = make(map[string]muxEntry)
    }
  // 用路由的pattern和處理函式建立 muxEntry 物件
    e := muxEntry{h: handler, pattern: pattern}
  // 向ServeMux的m 欄位增加新的路由匹配規則
    mux.m[pattern] = e
    if pattern[len(pattern)-1] == '/' {
  // 如果路由patterm以'/'結尾,則將對應的muxEntry物件加入到[]muxEntry中,路由長的位於切片的前面
        mux.es = appendSorted(mux.es, e)
    }

    if pattern[0] != '/' {
        mux.hosts = true
    }
}

Handle方法註冊路由時主要做了兩件事情:一個就是向ServeMuxmap[string]muxEntry增加給定的路由匹配規則;然後如果路由表示式以'/'結尾,則將對應的muxEntry物件加入到[]muxEntry中,按照路由表示式長度倒序排列。前者用於路由精確匹配,後者用於部分匹配,具體怎麼匹配的後面再看。

自定義 ServeMux

透過http.NewServeMux()可以建立一個ServeMux例項取代預設的DefaultServeMux

我們把上面輸出Hello Worldhttp server再次改造一下,使用自定義的 ServeMux例項作為ListenAndServe()方法的第二個引數,並且增加一個/welcome路由(下面的程式碼主要是展示用HandleHandleFunc註冊路由,實際使用的時候不必這麼麻煩,選一種就好):

package main

import (
    "fmt"
    "net/http"
)

type WelcomeHandlerStruct struct {

}

func HelloHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello World")
}

func (*WelcomeHandlerStruct) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Welcome")
}

func main () {
    mux := http.NewServeMux()
    mux.HandleFunc("/", HelloHandler)
    mux.Handle("/welcome", &WelcomeHandlerStruct{})
    http.ListenAndServe(":8080", mux)
}

之前提到ServeMux也實現了ServeHTTP方法,因此mux也是一個Handler物件。對於ListenAndServe()方法,如果第二個引數是自定義ServeMux例項,那麼Server例項接收到的ServeMux服務複用器物件將不再是DefaultServeMux而是mux

啟動服務

路由註冊完成後,使用http.ListenAndServe方法就能啟動伺服器開始監聽指定埠過來的請求。

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

func (srv *Server) ListenAndServe() error {
    if srv.shuttingDown() {
        return ErrServerClosed
    }
    addr := srv.Addr
    if addr == "" {
        addr = ":http"
    }
    ln, err := net.Listen("tcp", addr)
    if err != nil {
        return err
    }
    return srv.Serve(tcpKeepAliveListener{ln.(*net.TCPListener)})
}

這先建立了一個Server物件,傳入了地址和handler引數(這裡的handler引數時 ServeMux例項),然後呼叫Server物件ListenAndServe()方法。

Server(伺服器物件)

先看一下Server這個結構體的定義,欄位比較多,可以先大致瞭解一下:

type Server struct {
    Addr    string  // TCP address to listen on, ":http" if empty
    Handler Handler // handler to invoke, http.DefaultServeMux if nil
    TLSConfig *tls.Config
    ReadTimeout time.Duration
    ReadHeaderTimeout time.Duration
    WriteTimeout time.Duration
    IdleTimeout time.Duration
    MaxHeaderBytes int
    TLSNextProto map[string]func(*Server, *tls.Conn, Handler)
    ConnState func(net.Conn, ConnState)
    ErrorLog *log.Logger

    disableKeepAlives int32     // accessed atomically.
    inShutdown        int32     
    nextProtoOnce     sync.Once 
    nextProtoErr      error     

    mu         sync.Mutex
    listeners  map[*net.Listener]struct{}
    activeConn map[*conn]struct{}// 活躍連線
    doneChan   chan struct{}
    onShutdown []func()
}

ServerListenAndServe方法中,會初始化監聽地址Addr,同時呼叫Listen方法設定監聽。最後將監聽的TCP物件傳入其Serve方法。Server 物件的 Serve 方法會接收 Listener 中過來的連線,為每個連線建立一個goroutine,在goroutine中會用路由處理 Handler 對請求進行處理並構建響應。

func (srv *Server) Serve(l net.Listener) error {
......
   baseCtx := context.Background() // base is always background, per Issue 16220 
   ctx := context.WithValue(baseCtx, ServerContextKey, srv)
   for {
      rw, e := l.Accept()// 接收 listener 過來的網路連線請求
      ......
      c := srv.newConn(rw)
      c.setState(c.rwc, StateNew) // 將連線放在 Server.activeConn這個 map 中
      go c.serve(ctx)// 建立協程處理請求
   }
}

這裡隱去了一些細節,以便了解Serve方法的主要邏輯。首先建立一個上下文物件,然後呼叫ListenerAccept()接收監聽到的網路連線;一旦有新的連線建立,則呼叫ServernewConn()建立新的連線物件,並將連線的狀態標誌為StateNew,然後開啟一個goroutine處理連線請求。

處理連線

在開啟的 goroutineconnserve()會進行路由匹配找到路由處理函式然後呼叫處理函式。這個方法很長,我們保留關鍵邏輯。

func (c *conn) serve(ctx context.Context) {

    ...

    for {
        w, err := c.readRequest(ctx)
        if c.r.remain != c.server.initialReadLimitSize() {
            // If we read any bytes off the wire, we're active.
            c.setState(c.rwc, StateActive)
        }

        ...
        serverHandler{c.server}.ServeHTTP(w, w.req)
        w.cancelCtx()
        if c.hijacked() {
            return
        }
        w.finishRequest()
        if !w.shouldReuseConnection() {
            if w.requestBodyLimitHit || w.closedRequestBodyEarly() {
                c.closeWriteAndWait()
            }
            return
        }
        c.setState(c.rwc, StateIdle)
        c.curReq.Store((*response)(nil))

        ...
    }
}

當一個連線建立之後,該連線中所有的請求都將在這個協程中進行處理,直到連線被關閉。在serve()方法中會迴圈呼叫readRequest()方法讀取下一個請求進行處理,其中最關鍵的邏輯是下面行程式碼:

serverHandler{c.server}.ServeHTTP(w, w.req)

serverHandler是一個結構體型別,它會代理Server物件:

type serverHandler struct {
   srv *Server
}

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
    handler := sh.srv.Handler
    if handler == nil {
        handler = DefaultServeMux
    }
    if req.RequestURI == "*" && req.Method == "OPTIONS" {
        handler = globalOptionsHandler{}
    }
    handler.ServeHTTP(rw, req)
}

serverHandler實現的ServeHTTP()方法裡的sh.srv.Handler就是我們最初在http.ListenAndServe()中傳入的Handler引數,也就是我們自定義的ServeMux物件。如果該Handler物件為nil,則會使用預設的DefaultServeMux。最後呼叫ServeMuxServeHTTP()方法匹配當前路由對應的handler方法。

func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
   if r.RequestURI == "*" {
      if r.ProtoAtLeast(1, 1) {
         w.Header().Set("Connection", "close")
      }
      w.WriteHeader(StatusBadRequest)
      return
   }
   h, _ := mux.Handler(r)
   h.ServeHTTP(w, r)
}

func (mux *ServeMux) Handler(r *Request) (h Handler, pattern string) {

    if r.Method == "CONNECT" {
        if u, ok := mux.redirectToPathSlash(r.URL.Host, r.URL.Path, r.URL); ok {
            return RedirectHandler(u.String(), StatusMovedPermanently), u.Path
        }

        return mux.handler(r.Host, r.URL.Path)
    }

    // All other requests have any port stripped and path cleaned
    // before passing to mux.handler.
    host := stripHostPort(r.Host)
    path := cleanPath(r.URL.Path)

    // If the given path is /tree and its handler is not registered,
    // redirect for /tree/.
    if u, ok := mux.redirectToPathSlash(host, path, r.URL); ok {
        return RedirectHandler(u.String(), StatusMovedPermanently), u.Path
    }

    if path != r.URL.Path {
        _, pattern = mux.handler(host, path)
        url := *r.URL
        url.Path = path
        return RedirectHandler(url.String(), StatusMovedPermanently), pattern
    }

    return mux.handler(host, r.URL.Path)
}

// handler is the main implementation of Handler.
// The path is known to be in canonical form, except for CONNECT methods.
func (mux *ServeMux) handler(host, path string) (h Handler, pattern string) {
    mux.mu.RLock()
    defer mux.mu.RUnlock()

    // Host-specific pattern takes precedence over generic ones
    if mux.hosts {
        h, pattern = mux.match(host + path)
    }
    if h == nil {
        h, pattern = mux.match(path)
    }
    if h == nil {
        h, pattern = NotFoundHandler(), ""
    }
    return
}

// Find a handler on a handler map given a path string.
// Most-specific (longest) pattern wins.
func (mux *ServeMux) match(path string) (h Handler, pattern string) {
    // Check for exact match first.
    v, ok := mux.m[path]
    if ok {
        return v.h, v.pattern
    }

    // Check for longest valid match.  mux.es contains all patterns
    // that end in / sorted from longest to shortest.
    for _, e := range mux.es {
        if strings.HasPrefix(path, e.pattern) {
            return e.h, e.pattern
        }
    }
    return nil, ""
}

match方法裡我們看到之前提到的mux的m欄位(型別為map[string]muxEntry)和es(型別為[]muxEntry)。這個方法裡首先會利用進行精確匹配,在map[string]muxEntry中查詢是否有對應的路由規則存在;如果沒有匹配的路由規則,則會利用es進行近似匹配。

之前提到在註冊路由時會把以'/'結尾的路由(可稱為節點路由)加入到es欄位的[]muxEntry中。對於類似/path1/path2/path3這樣的路由,如果不能找到精確匹配的路由規則,那麼則會去匹配和當前路由最接近的已註冊的父節點路由,所以如果路由/path1/path2/已註冊,那麼該路由會被匹配,否則繼續匹配下一個父節點路由,直到根路由/

由於[]muxEntry中的muxEntry按照路由表示式從長到短排序,所以進行近似匹配時匹配到的節點路由一定是已註冊父節點路由中最相近的。

查詢到路由實際的處理器Handler物件返回給呼叫者ServerMux.ServeHTTP方法後,最後在方法裡就會呼叫處理器HandlerServeHTTP方法處理請求、構建寫入響應:

   h.ServeHTTP(w, r)

實際上如果根據路由查詢不到處理器Handler那麼也會返回NotFoundHandler:

func NotFound(w ResponseWriter, r *Request) { Error(w, "404 page not found", StatusNotFound) }

func NotFoundHandler() Handler { return HandlerFunc(NotFound) }

這樣標準統一,在呼叫h.ServeHTTP(w, r)後則會想響應中寫入 404 的錯誤資訊。

停止服務

我們寫的http server已經能監聽網路連線、把請求路由到處理器函式處理請求並返回響應了,但是還需要能優雅的關停服務,在生產環境中,當需要更新服務端程式時需要重啟服務,但此時可能有一部分請求進行到一半,如果強行中斷這些請求可能會導致意外的結果。

從 Go 1.8 版本開始,net/http原生支援使用http.ShutDown來優雅的關停HTTP 服務。這種方案同樣要求使用者建立自定義的 http.Server 物件,因為Shutdown方法無法透過其它途徑呼叫。

我們來看下面的程式碼,這段程式碼透過結合捕捉系統訊號(Signal)、goroutine 和管道(Channel)來實現伺服器的優雅停止:

package main

import (
   "context"
   "fmt"
   "log"
   "net/http"
   "os"
   "os/signal"
   "syscall"
)

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

   server := &http.Server{
      Addr:    ":8081",
      Handler: mux,
   }

   // 建立系統訊號接收器
   done := make(chan os.Signal)
   signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
   go func() {
      <-done

      if err := server.Shutdown(context.Background()); err != nil {
         log.Fatal("Shutdown server:", err)
      }
   }()

   log.Println("Starting HTTP server...")
   err := server.ListenAndServe()
   if err != nil {
      if err == http.ErrServerClosed {
         log.Print("Server closed under request")
      } else {
         log.Fatal("Server closed unexpected")
      }
   }
}

type helloHandler struct{}

func (*helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   fmt.Fprintf(w, "Hello World")
}

這段程式碼透過捕捉 os.Interrupt 訊號(Ctrl+C)和syscall,SIGTERM訊號(kill 程式時傳遞給程式的訊號)然後呼叫 server.Shutdown 方法告知伺服器應停止接受新的請求並在處理完當前已接受的請求後關閉伺服器。為了與普通錯誤相區別,標準庫提供了一個特定的錯誤型別 http.ErrServerClosed,我們可以在程式碼中透過判斷是否為該錯誤型別來確定伺服器是正常關閉的還是意外關閉的。

用Go 編寫http server的流程就大致學習完了,當然要寫出一個高效能的伺服器還有很多要學習的地方,net/http標準庫裡還有很多結構和方法來完善http server,學會這些最基本的方法後再看其他Web 框架的程式碼時就清晰很多。甚至熟練了覺得框架用著太複雜也能自己封裝一個HTTP 服務的腳手架(我用echo 和 gin 覺得還挺簡單的,跟PHP 的Laravel框架比起來他們也就算個腳手架吧,沒黑 PHP,關注我的用 Laravel 的小夥伴可別取關【哈哈哈…嗝】)。

參考文章:

https://juejin.im/post/5dd11baff265da0c0c1...

https://github.com/unknwon/building-web-ap...

https://medium.com/honestbee-tw-engineer/g...

本作品採用《CC 協議》,轉載必須註明作者和本文連結
公眾號:網管叨bi叨 | Golang、Laravel、Docker、K8s等學習經驗分享

相關文章