筆記:學習go語言的網路基礎庫,並嘗試搭一個簡易Web框架

抑菌發表於2021-01-13

走你~!

在日常的 web 開發中,後端人員常基於現有的 web 框架進行開發。但單純會用框架總感覺不太踏實,所以有空的時候還是看看這些框架是怎麼實現的會比較好,萬一要排查問題也快一些。

最近在學習 go 語言,也嘗試學習一下 go 語言當前最流行的 web 框架 gin,於是有了這篇學習筆記。看完這篇文章應該能理解 gin 最基礎的操作了,但由於 gin 是基於 go 原生的 http 伺服器搭建的,所以會先從 go 網路基礎庫中的 http 伺服器開始說起。

示例程式碼地址:連結

1. 認識請求處理器

我們先啟動一個 http 伺服器,然後再分析它是怎麼跑起來的。

func hello(w http.ResponseWriter, req *http.Request) {
	w.Write([]byte("hello"))
}

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

在上面的程式碼中,我們先是定義了一個處理方法,然後在8888埠上進行監聽。當伺服器啟動後,每當收到訪問/hello的請求,就會返回一個 hello。

我們先看http.HandleFunc()的原始碼:

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

我們可以看到它實際呼叫的是一個叫DefaultServeMux的東西,它是一個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
}

這裡比較引人注目的是那個 map,我們的請求路徑和處理方法會被封裝成一個 muxEntry,然後以請求路徑為鍵值存放到這個 map 中(這個過程可以在Handle方法中看到)。

到這裡我們心裡應該有點點底了,至少知道了我們自己寫的請求路徑和處理方法是以鍵值對的形式存放在對映表中的,這樣當請求來到的時候就可以根據路徑取出相應的方法進行處理了。

那麼接下來我們的疑問是:伺服器是如何啟動的,都做了哪些事情。

2. 伺服器啟動概覽

接下來我們要分析的就是這行程式碼了:http.ListenAndServe(":8888", nil),它的原始碼如下:

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

可以看到它建立了一個 Server,並且呼叫了 Server.ListenAndServe()方法。根據原始碼的介紹,這個方法會在指定的埠上進行監聽,返回一個 Listener,並呼叫Server.Serve()方法監聽該埠上的連線請求,原始碼如下:

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(ln)    //監聽該埠上的連線請求
}

我們進一步看Server.Serve()方法,根據介紹,Serve()方法會接受 Listener 上的連線,並且為每個連線建立一個 goroutine,這些 goroutine 會根據請求選擇對應的處理方法進行處理。由於程式碼較多,我們只看核心部分:

func (srv *Server) Serve(l net.Listener) error {
    
    //······

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

    //通過一個無限迴圈不停地接收請求
	for {
		rw, err := l.Accept()

        //······

		connCtx := ctx

        //······

        //為每個請求建立一個連線     
		c := srv.newConn(rw)
		c.setState(c.rwc, StateNew) // before Serve can return
		
		//為每個連線建立一個 goroutine 進行處理
		go c.serve(connCtx)
	}
}

從原始碼中可以看到 Server 會通過一個無限迴圈不停地接收請求,為每個請求建立一個連線,併為每個連線建立一個 goroutine 進行處理。

接著我們進一步看連線物件的conn.serve()方法,看看新建立的 goroutine 是怎樣處理的。這個方法傳入了連線的上下文作為引數,同樣由於程式碼比較多,這裡只給出核心部分:

func (c *conn) serve(ctx context.Context) {
    //......
    defer func(){...}()
    
    //tls......
    
    //......
    
    for{
        //獲取一個請求,並建立一個響應物件
        w, err := c.readRequest(ctx)
        
        //......
        
        serverHandler{c.server}.ServeHTTP(w, w.req) //處理請求
        w.cancelCtx()
        
        //......
    }
}

在新的 goroutine 中會通過一個無限迴圈處理本次連線,不停地獲取請求。每讀取到一個請求就建立一個響應物件,並對這次請求進行處理。

處理請求的方法是ServeHTTP(),它的原始碼如下:

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

由此可見,預設情況下會呼叫我們開頭提到的 DefaultServeMux 的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)       //進行處理
}

這樣一來,我們整個伺服器的執行邏輯就串聯起來了!

3. 搭建一個 web 框架

go 原生提供的 http 伺服器,結合其協程的特性,已經具有較高的效能。但是在真正的開發中,我們往往不會直接基於這個內建的 http 伺服器進行開發,因為它還缺少一些易用性。

接下來,我們將參考當前 go 生態中最流行的 web 框架 gin,進行一次簡單的仿寫。

在進行開發之前,我們得思考一下我們的框架得補足哪些功能。由於之前實習的時候用 java 的 spring 框架寫過業務,我的第一反應要新增這兩的功能:

  1. 分組。比如說我們得設定一個“User”組,然後我們在 UserController 中編寫和 “User” 相關的功能,比如說 login,logout等,使用者訪問時則需要通過 /User/login,/User/logout 來進行訪問。
  2. 過濾器的功能。可能這麼描述不太準確,我想說的是像 java spring 框架中 AOP 這樣的功能,在 go 語言中被叫做中介軟體。反正通俗來說就是我們可以給一組或多組方法的前後新增攔截,鑑權等功能。

下面開始邊搭邊記錄。

框架雛形

在搭框架之前,我們得把 go 原生提供的 http 伺服器呼叫封裝一下,以便我們自己後續新增功能。我們先把文章最開始那個 hello 的功能跑起來。

首先我們建立一個包,在包內建立自己的框架,我把這個框架起名叫 jun(其實叫啥都行)。然後建立一個名為 Engine 的結構體,Engine 就是我們框架的總控制了。

type Engine struct {
}

//通過 Default() 我們可以建立一個預設的 http 伺服器
func Default() *Engine {
	engine := New()
	return engine
}

func New() *Engine {
	engine := &Engine{}
	return engine
}

//為了能夠讓 Engine 作為伺服器執行起來,我們需要讓它實現 Handler 介面,也就是實現 ServeHTTP 方法
//將來我們會在這個方法中編寫框架處理請求的邏輯
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	w.Write([]byte("hello"))
}

//封裝了http.ListenAndServe,如果使用者沒有給出埠,我們就用8080作為預設埠
func (engine *Engine) Run(addr ...string) error {
	address := resolveAddress(addr)
	return http.ListenAndServe(address, engine)
}

為了能夠讓 Engine 作為伺服器執行起來,我們需要讓它實現 Handler 介面,也就是實現 ServeHTTP 方法,將來我們會在這個方法中編寫框架處理請求的邏輯。

同時我們提供了一個啟動框架的 Run 方法,裡面封裝了http.ListenAndServe,如果使用者沒有給出埠,我們就用8080作為預設埠。

下面我們完善處理請求的部分,我們要提供一些方法,讓使用者可以把請求處理器註冊到框架中。

如果你看了文章的開頭部分,應該會記得 go 內建的 http 伺服器是通過一個 map 對映表記錄這些請求處理器的。這麼做能夠完成靜態路由的功能,但是無法完成動態路由的功能。想要完成動態路由,通常都會用字首樹實現。

我本來想只是簡單地借用原生的請求處理器完成靜態路由,但是發現原生的處理器有個地方設計得比較簡潔,就是我們在註冊請求處理方法的時候不需要指定這個請求是 POST 請求還是 GET 請求。

雖然理論上來說我們只要在開發的時候明白這是一個 POST 或是 GET 請求就行,但是為了程式碼的可讀性以及規範性,我想還是對 GET / POST 請求做一下標記比較好。

所以我打算重新實現這個請求處理器,一來是新增上 GET / POST 請求標識,二來為了以後擴充套件成動態路由作準備(gin 框架也是重新實現了路由部分,而且自己實現了動態路由)。

實現原理和原生的一樣,用一個對映表儲存請求路徑和請求處理器,只不過在路徑前面增加一個 GET / POST 標識。(原生的靜態路由實現要健壯很多,不過這不是本文的重點了)

增加和修改的部分如下:

//對請求處理環節,我們使用一個對映表進行匹配,完成最簡單的靜態路由
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	key := req.Method + "-" + req.RequestURI
	if handler, ok := engine.router[key]; ok {
		handler(w, req)
	} else {
		http.NotFound(w, req)
	}
}

//新增路由的方法
func (engine *Engine) addRouter(method string, pattern string, handlerFunc http.HandlerFunc) {
	if engine.router == nil {
		engine.router = make(map[string]http.HandlerFunc)
	}
	key := method + "-" + pattern
	engine.router[key] = handlerFunc
}

func (engine *Engine) Get(pattern string, handlerFunc http.HandlerFunc) {
	engine.addRouter("GET", pattern, handlerFunc)
}

func (engine *Engine) Post(pattern string, handlerFunc http.HandlerFunc) {
	engine.addRouter("Post", pattern, handlerFunc)
}

完成以上修改後,我們框架的啟動程式碼就變成了這樣子:

func main() {
	engine := jun.Default()
	engine.Get("/hello", func(w http.ResponseWriter, req *http.Request) {
		w.Write([]byte("hello!"))
	})
	engine.Run()
}

到這裡,我們的框架已經能夠支援原生 http 伺服器的基本功能了,我們可以在此基礎上進行設計,讓框架變得更易用,更強大。

所以接下來我們著手實現上面提到的兩個功能:分組和中介軟體。

分組

首先是分組,在思考分組的實現時,我們很容易就想到這個分組的結構一定要有一個字首的欄位,比如像這樣:

type RouterGroup struct {
	basePath string
}

然而在我閱讀 gin 的原始碼時,我發現 RouterGroup 中有一個 Engine 的欄位,Engine 中也有一個 RouterGroup 欄位。雖然目前我說不清楚這樣設計的道理,但是這樣設計確實會讓框架變得比較好用,就暫且這樣理解吧:web框架最基本核心的功能就是路由請求,所以可以把整個框架都抽象成一個大的路由組,我們可以在這個大路由組裡面分組建立路由。

於是 RouterGroup 的設計暫時可以是這樣,(如果你體會不到這樣設計的好處,那麼一定要手動操作一下 gin 框架,使用一下分組功能):

type RouterGroup struct {
	basePath string
	engine   *Engine
}

type Engine struct {
	RouterGroup
	router map[string]http.HandlerFunc
}

既然已經對 Engine 進行了抽象,那麼我們新增路由的一系列方法也應該針對 RouterGroup,而不是 Engine,於是新增路由的方法變成了這樣:

//多了一層 RouterGroup 封裝
func (group *RouterGroup) addRouter(method string, pattern string, handlerFunc http.HandlerFunc) {
	if group.engine.router == nil {
		group.engine.router = make(map[string]http.HandlerFunc)
	}
	pattern = group.basePath + pattern
	key := method + "-" + pattern
	group.engine.router[key] = handlerFunc
}

func (group *RouterGroup) Get(pattern string, handlerFunc http.HandlerFunc) {
	group.addRouter("GET", pattern, handlerFunc)
}

func (group *RouterGroup) Post(pattern string, handlerFunc http.HandlerFunc) {
	group.addRouter("POST", pattern, handlerFunc)
}

當然我們建立 RouterGroup 的方法也要稍作調整,同時我們要提供建立 RouterGroup 的函式Group

func New() *Engine {
	engine := &Engine{
		RouterGroup: RouterGroup{
			basePath: "",
		},
	}
	engine.RouterGroup.engine = engine
	return engine
}

func (group *RouterGroup) Group(relativePath string) *RouterGroup {
	return &RouterGroup{
		basePath: relativePath,
		engine:   group.engine,
	}
}

然後我們對分組功能進行測試:

func main() {
	router := jun.Default()
	userGroup := router.Group("/user")

	userGroup.Post("/login", func(w http.ResponseWriter, req *http.Request) {
		w.Write([]byte("login successfully!"))
	})

	userGroup.Post("/logout", func(w http.ResponseWriter, req *http.Request) {
		w.Write([]byte("logout successfully!"))
	})

	router.Run()
}

分組功能初步完成!

上下文

接下來在實現中介軟體功能之前,我們先對框架進行一個小的改良,引入一個上下文的概念,這個概念能方便我們對框架進行封裝。

舉個例子:目前我們寫請求處理方法的時候,引數都是 http.ResponseWriter 和 http.Request,也就是請求和響應,這兩個東西構成了一次 http 服務最基本的要素。

但在我們實際開發的時候,我們有可能響應給客戶端 json 資料,也可能響應一個 html 頁面,響應這兩種不同的東西是要設定不同的響應頭的。

在沒有上下文概念時,我們想要響應 json 資料需要這樣做:

func func1(w http.ResponseWriter, req *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Header().Set("Content-Type", "application/json")
		encoder := json.NewEncoder(w)

		message := map[string]interface{}{
			"message": "pong",
		}
		if err := encoder.Encode(message); err != nil {
			http.Error(w, err.Error(), 500)
		}
}

也就是說我們每次都要手動設定一些和 json 相關的響應內容,如果我們用一個結構把響應與請求封裝起來,那麼我們就可以基於這個結構對一些常用的操作進行封裝,我們可以看一下對於同樣的內容,用 gin 框架會怎麼寫:

func func2(c *gin.Context) {
	c.JSON(200, gin.H{
		"message": "pong",
	})
}

(⊙ˍ⊙)沒錯,就是這麼簡單。

所以下面我們仿照 gin 引入這個上下文的結構,並編寫響應 JSON 的方法:

//用 H 來封裝這個用於轉換為 json 的 map,減少程式碼量
type H map[string]interface{}

type Context struct {
	Writer  http.ResponseWriter
	Request *http.Request
	engine  *Engine
}

//新建一個 Context
func newContext(w http.ResponseWriter, req *http.Request) *Context {
	return &Context{
		Writer:  w,
		Request: req,
	}
}

//封裝的響應 JSON 的方法
func (c *Context) JSON(code int, obj interface{}) {
	c.Writer.WriteHeader(code)
	c.Writer.Header().Set("Content-Type", "application/json")

	encoder := json.NewEncoder(c.Writer)
	if err := encoder.Encode(obj); err != nil {
		http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
	}
}

//我們還可以封裝一系列方法,比如獲取引數,響應 html 等等······

當然我們的主框架也要做一些相應的修改:

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	context := newContext(w, req)
	context.engine = engine

	key := req.Method + "-" + req.RequestURI
	if handler, ok := engine.router[key]; ok {
		handler(context)
	} else {
		http.NotFound(w, req)
	}
}

並把一系列註冊路由方法的引數改為 Context,為此我們還要重新封裝一下處理方法,不能再用 http.HandlerFunc了。

//自己定義一個 HandlerFunc
type HandlerFunc func(*Context)

func (group *RouterGroup) Get(pattern string, handlerFunc HandlerFunc) {
	group.addRouter("GET", pattern, handlerFunc)
}

//······

最後我們進行一個簡單的測試:

func main() {
	router := jun.Default()

	router.Get("/ping", func(c *jun.Context) {
		c.JSON(http.StatusOK, jun.H{
			"message": "pong",
		})
	})

	router.Run()
}

上下文功能初步完成!

中介軟體

這裡廢話可能有點多

接下來我們考慮中介軟體的功能,也就是攔截器,這應該是整個框架最精彩的部分了。它的使用場景很廣,比如限制一些介面登入後才能訪問。

既然是對一些介面做定製,中介軟體功能就應該作用於分組之上,所以我們要考慮對已經實現的分組功能做一些改進。

考慮到我們有可能有多箇中介軟體,各個中介軟體和業務邏輯之間會按照一定的順序執行,我們可以利用責任鏈模式的思想進行開發。(以前入門學習責任鏈的時候寫過一篇文章,雖然當時寫的有點幼稚,但是如果你之前沒接觸過的話,說不定會有點幫助,文章連結

得補充一點,中介軟體這個名字聽起來很高階,其實可以理解成一個函式,這個函式在請求處理器之前或之後執行,所以給它取個名字叫中介軟體(●'◡'●)

但不得不承認,這部分內容對我來說有點難,花了好大功夫才看懂,所以我打算先捋一下我的思路:

  1. 由於我們的 Context 上下文是用來存放一次請求的相關資訊的,所以我們要在 Context 中新增一個集合,用來儲存這次請求需要被哪些中介軟體處理。
  2. 我們的中介軟體是作用在一個分組上的,所以 RouterGroup 中也要有一個集合存放中介軟體。
  3. 分組要提供一個 Use 方法,把中介軟體存放到對應的 RouterGroup 裡。
  4. 在我們處理一次請求的時候,我們先判斷這個請求屬於哪個分組,然後把對應分組的中介軟體新增到上下文中,然後在上下文中以責任鏈的形式依次執行所有中介軟體。

下面按照這個思路開始動手:

  1. 在 Context 中新增一個集合,用來儲存這次請求需要被哪些中介軟體處理。
type Context struct {
	Writer  http.ResponseWriter
	Request *http.Request
	engine  *Engine

    //這次請求需要被哪些中介軟體處理
	handlers []HandlerFunc
	//這是一個用於依次呼叫中介軟體的值
	index    int
}

//這個是整個責任鏈的核心方法,每當這個方法被呼叫,就執行下一個中介軟體函式
func (c *Context) Next() {
	c.index++
	for c.index < len(c.handlers) {
	    //執行下一個中介軟體函式
		c.handlers[c.index](c)
		c.index++
	}
}
  1. 中介軟體是作用在一個分組上的,所以 RouterGroup 中也要有一個集合存放中介軟體。
type RouterGroup struct {
	basePath string
	engine   *Engine
	//存放分組需要執行的中介軟體
	Handlers []HandlerFunc
}
  1. 分組要提供一個 Use 方法,把中介軟體存放到對應的 RouterGroup 裡。
func (group *RouterGroup) Use(middlewares ...HandlerFunc) *RouterGroup {
	group.Handlers = append(group.Handlers, middlewares...)
	return group
}
  1. 在我們處理一次請求的時候,我們先判斷這個請求屬於哪個分組,然後把對應分組的中介軟體新增到上下文中,然後在上下文中以責任鏈的形式依次執行所有中介軟體。由於 gin 框架實現了動態路由,這部分實現的程式碼看起來會比較多;我的程式碼比較簡陋,封裝得沒那麼好,但思路是相似的。
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	context := newContext(w, req)
	context.engine = engine

	// 根據路徑判斷這個請求屬於哪個組,然後獲取這個組上安裝的中介軟體,把這些中介軟體放到上下文中
	for _, group := range engine.groups {
		if strings.HasPrefix(req.URL.Path, group.basePath) {
			context.handlers = append(context.handlers, group.Handlers...)
		}
	}

	// 把請求處理器也作為“中介軟體”放到上下文中
	key := req.Method + "-" + req.RequestURI
	if handler, ok := engine.router[key]; ok {
		context.handlers = append(context.handlers, handler)
	} else {
		context.handlers = append(context.handlers, func(context *Context) {
			http.Error(context.Writer, "404 page not found", http.StatusNotFound)
		})
	}

	//處理請求!
	context.Next()
}

我個人覺得最難懂的地方就是把請求處理器也作為“中介軟體”放到上下文中,如果比較懵逼的話可以在處理請求的context.Next()這裡打個斷點,跟進看看整個處理流程。

下面是我簡單的測試用例,給整體的大分組新增一個計時器中介軟體,計算處理一個請求需要花多少時間。

func main() {
	router := jun.Default()
	
	//新增中介軟體
	router.Use(Timer)

	router.Get("/ping", func(c *jun.Context) {
		c.JSON(http.StatusOK, jun.H{
			"message": "pong",
		})
	})

	router.Run()
}

func Timer(c *jun.Context) {
	t := time.Now()
	c.Next()
	log.Printf("use time: %v\n", time.Since(t))
}

(ง •_•)ง能正常跑起來!

另外,在 gin 原始碼中,用預設情況啟動 gin 框架的話會自動加上兩個中介軟體:

// Default returns an Engine instance with the Logger and Recovery middleware already attached.
func Default() *Engine {
	debugPrintWARNINGDefault()
	engine := New()
	engine.Use(Logger(), Recovery())
	return engine
}

其中一個是記錄日誌的,一個是錯誤恢復的,這提醒了我在設計 web 服務的時候我們還得考慮它的錯誤恢復機制。

4. 結束

雖然只是對 gin 框架進行了簡單的仿寫,但是對整個框架的設計思想清晰了很多。當然 gin 框架絕不僅僅只有這點內容,它封裝了很多功能讓我們開發 web 服務更加方便,我們用到啥再去查文件就好啦!

相關文章