一文讀懂Go Http Server原理

2023-01-12 21:09:37

hello大家好呀,我是小樓,這是系列文《Go底層原理剖析》的第二篇,依舊是分析 Http 模組,話不多說,開始。

從一個 Demo 入手

俗話說萬事開頭難,但用 Go 實現一個 Http Server 真不難,簡單到什麼程度?起一個 Server,並且能響應請求,算上包名、匯入的依賴,甚至空行,也就只要 15 行程式碼:

package main

import (
	"io"
	"net/http"
)

func main() {
	http.HandleFunc("/hello", hello)
	http.ListenAndServe(":81", nil)
}

func hello(response http.ResponseWriter, request *http.Request) {
	io.WriteString(response, "hello world")
}

這麼簡單,能與之一戰的恐怕只有 Python 了吧,而且 Go 還能編譯成可執行的二進位制檔案,你說牛啤不牛啤?

Http Server 如何處理連線?

我們從這一行程式碼看起

http.ListenAndServe(":81", nil)

從命名來看,這個方法幹了兩件事,監聽並且服務,從方法的單一職責上來說,我覺得不ok,一個方法怎麼能幹兩件事?但這是大佬寫的程式碼,就很合理。

第一個引數Addr是要監聽的地址和埠,第二個引數Handler一般是nil,它是真正的邏輯處理,但我們通常用第一行程式碼那樣來註冊處理器,這程式碼一看就感覺是把 path 對映到業務邏輯上,我們先大概瞭解,待會再來看它

http.HandleFunc("/hello", hello)

如果瞭解過一點網路程式設計基礎,就會知道作業系統提供了bindlistenaccept這樣的系統呼叫,我們只要按順序發起呼叫,就能組合出一個 Server。

Go 也是利用這些系統呼叫,把他們都封裝在了ListenAndServe中。

Listen 往下追究就是系統呼叫,所以我們重點看 Serve

把分支程式碼收起來,只看主幹,發現是一個 for 迴圈裡面在不停地 Accept,而這個 Accept 在沒有連線時是阻塞的,當有連線時,起一個新的協程來處理。

Http Server 如何處理請求的?

一些前置工作

處理請求的一行程式碼是,可以看出是每個連線單開了一個協程處理:

go c.serve(connCtx)

這裡的 connCtx 代入了當前的 Server 物件:

ctx := context.WithValue(baseCtx, ServerContextKey, srv)
...
connCtx := ctx

而且還提供了修改它的 hook 方法 srv.ConnContext,可以在每次 Accept 時修改原始的 context

if cc := srv.ConnContext; cc != nil {
	connCtx = cc(connCtx, rw)
	if connCtx == nil {
		panic("ConnContext returned nil")
	}
}

它的定義是:

// ConnContext optionally specifies a function that modifies
// the context used for a new connection c. The provided ctx
// is derived from the base context and has a ServerContextKey
// value.
ConnContext func(ctx context.Context, c net.Conn) context.Context

但是如果按照我開頭給的程式碼,你是沒法修改 srv.ConnContext 的,可以改成這樣來自定義:

func main() {
	http.HandleFunc("/hello", hello)
	server := http.Server{
		Addr: ":81",
		ConnContext: func(ctx context.Context, c net.Conn) context.Context {
			return context.WithValue(ctx, "hello", "roshi")
		},
	}
	server.ListenAndServe()
}

同樣的 c.setState 也提供了 hook,可採取如上的方法設定,在每次連線狀態改變時執行 hook 方法:

c.setState(c.rwc, StateNew, runHooks) // before Serve can return
// ConnState specifies an optional callback function that is
// called when a client connection changes state. See the
// ConnState type and associated constants for details.
ConnState func(net.Conn, ConnState)

開始真正幹活

為了能看清楚 Accept 後,serve 方法到底幹了什麼,我們再簡化一下:

func (c *conn) serve(ctx context.Context) {
	...
	for {
		w, err := c.readRequest(ctx)
		...
		serverHandler{c.server}.ServeHTTP(w, w.req)
		...
	}
}

serve 也是一個大回圈,迴圈裡面主要是讀取一個請求,然後將請求交給 Handler 處理。

為什麼是一個大回圈呢?因為每個 serve 處理的是一個連線,一個連線可以有多次請求。

讀請求就顯得比較枯燥乏味,按照Http協定,讀出URL,header,body等資訊。

這裡有個細節是在每次讀取了一個請求後,還開了一個協程去讀下一個請求,也算是做了優化吧。

for {
	w, err := c.readRequest(ctx)
	...

	if requestBodyRemains(req.Body) {
		registerOnHitEOF(req.Body, w.conn.r.startBackgroundRead)
	} else {
		w.conn.r.startBackgroundRead()
	}
	...
}

請求如何路由?

當讀取到一個請求後,便進入這一行程式碼:

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

ServeHTTP 找到我們註冊的 Handler 去處理,如果請求的URI 是 *或請求 Method 是 OPTIONS,則使用globalOptionsHandler,也就是說這類請求不需要我們手動處理,直接就返回了。

對於我們註冊的 Handler 也需要去尋找路由,這個路由的規則還是比較簡單,主要由如下三條:

  • 如果註冊了帶 host 的路由,則按 host + path 去尋找,如果沒註冊帶 host 的路由,則按 path 尋找
  • 路由規則匹配以完全匹配優先,如果註冊的路由規則最後一個字元是/,則除了完全匹配外,還會以字首查詢

舉幾個例子來理解一下:

  • 帶 host 的匹配規則

註冊路由為

http.HandleFunc("/hello", hello)
http.HandleFunc("127.0.0.1/hello", hello2)

此時如果執行

curl 'http://127.0.0.1:81/hello'

則會匹配到 hello2,但如果執行

curl 'http://localhost:81/hello'

就匹配的是 hello

  • 字首匹配

如果註冊路由為

http.HandleFunc("/hello", hello)
http.HandleFunc("127.0.0.1/hello/", hello2)

注意第二個最後還有個/,此時如果執行

curl 'http://127.0.0.1:81/hello/roshi'

也能匹配到 hello2,怎麼樣,是不是理解了?

找到路由之後就直接呼叫我們開頭註冊的方法,如果我們往 Response 中寫入資料,就能返回給使用者端,這樣一個請求就處理完成了。

總結

最後我們回憶下 Go Http Server 的要點:

  • 用 Go 起一個 Http Server 非常簡單
  • Go Http Server 本質是一個大回圈,每當有一個新連線時,會起一個新的協程來處理
  • 每個連線的處理也是一個大回圈,這個迴圈裡做了讀取請求、尋找路由、執行邏輯三件大事

感謝能抽空看到這裡,如果你能點贊在看分享,我會更加感激不盡~


搜尋關注微信公眾號"捉蟲大師",後端技術分享,架構設計、效能優化、原始碼閱讀、問題排查、踩坑實踐