01 . Go之從零實現Web框架(框架雛形, 上下文Context,路由)

設計一個框架

大部分時候,我們需要實現一個 Web 應用,第一反應是應該使用哪個框架。不同的框架設計理念和提供的功能有很大的差別。比如 Python 語言的 djangoflask,前者大而全,後者小而美。Go語言/golang 也是如此,新框架層出不窮,比如BeegoGinIris等。那為什麼不直接使用標準庫,而必須使用框架呢?在設計一個框架之前,我們需要回答框架核心為我們解決了什麼問題。只有理解了這一點,才能想明白我們需要在框架中實現什麼功能。

我們先看看標準庫 net/http 如何處理一個請求

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main()  {
	http.HandleFunc("/",handler)
	//http.HandlerFunc("/count",counter)
	log.Fatal(http.ListenAndServe("localhost:8000",nil))
}

func handler(w http.ResponseWriter, r *http.Request)  {
	fmt.Fprintf(w,"URL.Path = %q\n",r.URL.Path)
	fmt.Println("1234")
}

net/http提供了基礎的Web功能,即監聽端口,映射靜態路由,解析HTTP報文。一些Web開發中簡單的需求並不支持,需要手工實現。

  • 動態路由:例如hello/:namehello/*這類的規則。
  • 鑒權:沒有分組/統一鑒權的能力,需要在每個路由映射的handler中實現。
  • 模板:沒有統一簡化的HTML機制。

當我們離開框架,使用基礎庫時,需要頻繁手工處理的地方,就是框架的價值所在。但並不是每一個頻繁處理的地方都適合在框架中完成。Python有一個很著名的Web框架,名叫bottle,整個框架由bottle.py一個文件構成,共4400行,可以說是一個微框架。那麼理解這個微框架提供的特性,可以幫助我們理解框架的核心能力。

  • 路由(Routing):將請求映射到函數,支持動態路由。例如'/hello/:name
  • 模板(Templates):使用內置模板引擎提供模板渲染機制。
  • 工具集(Utilites):提供對 cookies,headers 等處理機制。
  • 插件(Plugin):Bottle本身功能有限,但提供了插件機制。可以選擇安裝到全局,也可以只針對某幾個路由生效。

Gee框架

這個教程將使用 Go 語言實現一個簡單的 Web 框架,起名叫做Geegeektutu.com的前三個字母。我第一次接觸的 Go 語言的 Web 框架是GinGin的代碼總共是14K,其中測試代碼9K,也就是說實際代碼量只有5KGin也是我非常喜歡的一個框架,與Python中的Flask很像,小而美。

7天實現Gee框架這個教程的很多設計,包括源碼,參考了Gin,大家可以看到很多Gin的影子。
時間關係,同時為了儘可能地簡潔明了,這個框架中的很多部分實現的功能都很簡單,但是儘可能地體現一個框架核心的設計原則。例如Router的設計,雖然支持的動態路由規則有限,但為了性能考慮匹配算法是用Trie樹實現的,Router最重要的指標之一便是性能。

標準庫啟動Web服務

Go語言內置了 net/http庫,封裝了HTTP網絡編程的基礎的接口,我們實現的Gee Web 框架便是基於net/http的。我們接下來通過一個例子,簡單介紹下這個庫的使用。

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main()  {
	http.HandleFunc("/",indexHandler)
	http.HandleFunc("/hello",helloHandler)
	log.Fatal(http.ListenAndServe(":8000",nil))
}

// handler echoes r.URL.Path
func indexHandler(w http.ResponseWriter, req *http.Request)  {
	fmt.Fprintf(w,"URL.Path = %q\n",req.URL.Path)
}

func helloHandler(w http.ResponseWriter, req *http.Request)  {
	for k,v := range req.Header {
		fmt.Fprintf(w, "Header[%q] = %q\n",k,v)
	}
}

我們設置了2個路由,//hello,分別綁定 indexHandlerhelloHandler , 根據不同的HTTP請求會調用不同的處理函數。訪問/,響應是URL.Path = /,而/hello的響應則是請求頭(header)中的鍵值對信息。

用 curl 這個工具測試一下,將會得到如下的結果

$ curl localhost:8000/
URL.Path = "/"

$ curl localhost:8000/hello
Header["User-Agent"] = ["curl/7.64.1"]
Header["Accept"] = ["*/*"]

main 函數的最後一行,是用來啟動 Web 服務的,第一個參數是地址,:8000表示在 8000 端口監聽。而第二個參數則代表處理所有的HTTP請求的實例,nil 代表使用標準庫中的實例處理。第二個參數,則是我們基於net/http標準庫實現Web框架的入口。

實現http.Handler接口

package http

type Handler interface {
	ServeHTPP(w ResponseWriter, r *Request)
}

func ListenAndServe(address string, h Handler) error  {
	
}

第二個參數的類型是什麼呢?通過查看net/http的源碼可以發現,Handler是一個接口,需要實現方法 ServeHTTP ,也就是說,只要傳入任何實現了 ServerHTTP 接口的實例,所有的HTTP請求,就都交給了該實例處理了。

main.go
**

package main

import (
	"fmt"
	"log"
	"net/http"
)

type Engine struct{}

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	switch req.URL.Path {
	case "/":
		fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
	case "/hello":
		for k, v := range req.Header {
			fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
		}
	default:
		fmt.Fprintf(w, "404 NOT FOUND: %s\n", req.URL)
	}
}

func main() {
	engine := new(Engine)
	log.Fatal(http.ListenAndServe(":8000", engine))
}
  • 我們定義了一個空的結構體Engine,實現了方法ServeHTTP。這個方法有2個參數,第二個參數是 Request ,該對象包含了該HTTP請求的所有的信息,比如請求地址、Header和Body等信息;第一個參數是 ResponseWriter ,利用 ResponseWriter 可以構造針對該請求的響應。

  • main 函數中,我們給 ListenAndServe 方法的第二個參數傳入了剛才創建的engine實例。至此,我們走出了實現Web框架的第一步,即,將所有的HTTP請求轉向了我們自己的處理邏輯。還記得嗎,在實現Engine之前,我們調用 http.HandleFunc 實現了路由和Handler的映射,也就是只能針對具體的路由寫處理邏輯。比如/hello。但是在實現Engine之後,我們攔截了所有的HTTP請求,擁有了統一的控制入口。在這裡我們可以自由定義路由映射的規則,也可以統一添加一些處理邏輯,例如日誌、異常處理等。

  • 代碼的運行結果與之前的是一致的。

Gee框架的雛形

代碼結構

tree gee_demo1 
gee_demo1
├── gee
│   ├── gee.go
│   └── go.mod
├── go.mod
└── main.go

1 directory, 4 files

go.mod

module gee_demo1

go 1.13

require gee v0.0.0

replace gee => ./gee
  • go.mod 中使用 replace 將 gee 指向 ./gee

從 go 1.11 版本開始,引用相對路徑的 package 需要使用上述方式。

main.go

package main

import (
	"fmt"
	"gee"
	"net/http"
)

func main()  {
	r := gee.New()
	r.GET("/", func(w http.ResponseWriter, req *http.Request) {
		fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
	})

	r.GET("/hello", func(w http.ResponseWriter, req *http.Request) {
		for k, v := range req.Header {
			fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
		}
	})

	r.Run(":8000")
}

看到這裡,如果你使用過gin框架的話,肯定會覺得無比的親切。gee框架的設計以及API均參考了gin。使用New()創建 gee 的實例,使用 GET()方法添加路由,最後使用Run()啟動Web服務。這裡的路由,只是靜態路由,不支持/hello/:name這樣的動態路由,動態路由我們將在下一次實現。

gee.go

package gee

import (
	"fmt"
	"log"
	"net/http"
)

// HandlerFunc defines the request handler used by gee
type HandlerFunc func(http.ResponseWriter, *http.Request)

// Engine implement the interface of ServeHTTP
type Engine struct {
	router map[string]HandlerFunc
}

// New is the constructor of gee.Engine
func New() *Engine {
	return &Engine{router: make(map[string]HandlerFunc)}
}

func (engine *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
	key := method + "-" + pattern
	log.Printf("Route %4s - %s", method, pattern)
	engine.router[key] = handler
}

// GET defines the method to add GET request
func (engine *Engine) GET(pattern string, handler HandlerFunc) {
	engine.addRoute("GET", pattern, handler)
}

// POST defines the method to add POST request
func (engine *Engine) POST(pattern string, handler HandlerFunc) {
	engine.addRoute("POST", pattern, handler)
}

// Run defines the method to start a http server
func (engine *Engine) Run(addr string) (err error) {
	return http.ListenAndServe(addr, engine)
}

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	key := req.Method + "-" + req.URL.Path
	if handler, ok := engine.router[key]; ok {
		handler(w, req)
	} else {
		fmt.Fprintf(w, "404 NOT FOUND: %s\n", req.URL)
	}
}

那麼gee.go就是重頭戲了。我們重點介紹一下這部分的實現。

  • 首先定義了類型HandlerFunc,這是提供給框架用戶的,用來定義路由映射的處理方法。我們在Engine中,添加了一張路由映射表router,key 由請求方法和靜態路由地址構成,例如GET-/GET-/helloPOST-/hello,這樣針對相同的路由,如果請求方法不同,可以映射不同的處理方法(Handler),value 是用戶映射的處理方法。

  • 當用戶調用(*Engine).GET()方法時,會將路由和處理方法註冊到映射表 router 中,(*Engine).Run()方法,是 ListenAndServe 的包裝。

  • Engine實現的 ServeHTTP 方法的作用就是,解析請求的路徑,查找路由映射表,如果查到,就執行註冊的處理方法。如果查不到,就返回 404 NOT FOUND

執行go run main.go,再用 curl 工具訪問,結果與最開始的一致

測試

$ curl localhost:8000
URL.Path = "/"

$ curl localhost:8000/hello
Header["Accept"] = ["*/*"]
Header["User-Agent"] = ["curl/7.64.1"]

至此,整個Gee框架的原型已經出來了。實現了路由映射表,提供了用戶註冊靜態路由的方法,包裝了啟動服務的函數。當然,到目前為止,我們還沒有實現比net/http標準庫更強大的能力,不用擔心,很快就可以將動態路由、中間件等功能添加上去了。

上下文Context

  • 路由(router)獨立出來,方便之後增強。
  • 設計上下文(Context),封裝 Request 和 Response ,提供對 JSON、HTML 等返回類型的支持.

使用效果

package main

import (
	"fmt"
	"gee"
	"net/http"
)

func main() {
	r := gee.New()
	r.GET("/", func(c *gee.Context) {
		c.HTML(http.StatusOK, "<h1>Hello Gee</h1>")
	})
	r.GET("/hello", func(c *gee.Context) {
		// expect /hello?name=geektutu
		c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
	})

	r.POST("/login", func(c *gee.Context) {
		c.JSON(http.StatusOK, gee.H{
			"username": c.PostForm("username"),
			"password": c.PostForm("password"),
		})
	})

	r.Run(":9999")
}
  • Handler的參數變成成了gee.Context,提供了查詢Query/PostForm參數的功能。
  • gee.Context封裝了HTML/String/JSON函數,能夠快速構造HTTP響應。

設計Context

必要性

  1. 對Web服務來說,無非是根據請求*http.Request,構造響應http.ResponseWriter。但是這兩個對象提供的接口粒度太細,比如我們要構造一個完整的響應,需要考慮消息頭(Header)和消息體(Body),而 Header 包含了狀態碼(StatusCode),消息類型(ContentType)等幾乎每次請求都需要設置的信息。因此,如果不進行有效的封裝,那麼框架的用戶將需要寫大量重複,繁雜的代碼,而且容易出錯。針對常用場景,能夠高效地構造出 HTTP 響應是一個好的框架必須考慮的點。

用返回JSON數據作比較, 感受下封裝前後的差距

封裝前

obj = map[string]interface{}{
    "name": "geektutu",
    "password": "1234",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
if err := encoder.Encode(obj); err != nil {
    http.Error(w, err.Error(), 500)
}

封裝後

c.JSON(http.StatusOK, gee.H{
    "username": c.PostForm("username"),
    "password": c.PostForm("password"),
})
  1. 針對使用場景,封裝*http.Requesthttp.ResponseWriter的方法,簡化相關接口的調用,只是設計 Context 的原因之一。對於框架來說,還需要支撐額外的功能。例如,將來解析動態路由/hello/:name,參數:name的值放在哪呢?再比如,框架需要支持中間件,那中間件產生的信息放在哪呢?Context 隨着每一個請求的出現而產生,請求的結束而銷毀,和當前請求強相關的信息都應由 Context 承載。因此,設計 Context 結構,擴展性和複雜性留在了內部,而對外簡化了接口。路由的處理函數,以及將要實現的中間件,參數都統一使用 Context 實例, Context 就像一次會話的百寶箱,可以找到任何東西。

Context具體實現

gee/context.go

type H map[string]interface{}

type Context struct {
	// origin objects
	Writer http.ResponseWriter
	Req    *http.Request
	// request info
	Path   string
	Method string
	// response info
	StatusCode int
}

func newContext(w http.ResponseWriter, req *http.Request) *Context {
	return &Context{
		Writer: w,
		Req:    req,
		Path:   req.URL.Path,
		Method: req.Method,
	}
}

func (c *Context) PostForm(key string) string {
	return c.Req.FormValue(key)
}

func (c *Context) Query(key string) string {
	return c.Req.URL.Query().Get(key)
}

func (c *Context) Status(code int) {
	c.StatusCode = code
	c.Writer.WriteHeader(code)
}

func (c *Context) SetHeader(key string, value string) {
	c.Writer.Header().Set(key, value)
}

func (c *Context) String(code int, format string, values ...interface{}) {
	c.SetHeader("Content-Type", "text/plain")
	c.Status(code)
	c.Writer.Write([]byte(fmt.Sprintf(format, values...)))
}

func (c *Context) JSON(code int, obj interface{}) {
	c.SetHeader("Content-Type", "application/json")
	c.Status(code)
	encoder := json.NewEncoder(c.Writer)
	if err := encoder.Encode(obj); err != nil {
		http.Error(c.Writer, err.Error(), 500)
	}
}

func (c *Context) Data(code int, data []byte) {
	c.Status(code)
	c.Writer.Write(data)
}

func (c *Context) HTML(code int, html string) {
	c.SetHeader("Content-Type", "text/html")
	c.Status(code)
	c.Writer.Write([]byte(html))
}
  • 代碼最開頭,給map[string]interface{}起了一個別名gee.H,構建JSON數據時,顯得更簡潔。
  • Context目前只包含了http.ResponseWriter*http.Request,另外提供了對 Method 和 Path 這兩個常用屬性的直接訪問。
  • 提供了訪問Query和PostForm參數的方法。
  • 提供了快速構造String/Data/JSON/HTML響應的方法。

路由 (Router)

我們將和路由相關的方法和結構提取了出來,放到了一個新的文件中router.go,方便我們下一次對 router 的功能進行增強,例如提供動態路由的支持。 router 的 handle 方法作了一個細微的調整,即 handler 的參數,變成了 Context。

gee/router.go

type router struct {
	handlers map[string]HandlerFunc
}

func newRouter() *router {
	return &router{handlers: make(map[string]HandlerFunc)}
}

func (r *router) addRoute(method string, pattern string, handler HandlerFunc) {
	log.Printf("Route %4s - %s", method, pattern)
	key := method + "-" + pattern
	r.handlers[key] = handler
}

func (r *router) handle(c *Context) {
	key := c.Method + "-" + c.Path
	if handler, ok := r.handlers[key]; ok {
		handler(c)
	} else {
		c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
	}
}

框架入口

gee/gee.go

// HandlerFunc defines the request handler used by gee
type HandlerFunc func(*Context)

// Engine implement the interface of ServeHTTP
type Engine struct {
	router *router
}

// New is the constructor of gee.Engine
func New() *Engine {
	return &Engine{router: newRouter()}
}

func (engine *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
	engine.router.addRoute(method, pattern, handler)
}

// GET defines the method to add GET request
func (engine *Engine) GET(pattern string, handler HandlerFunc) {
	engine.addRoute("GET", pattern, handler)
}

// POST defines the method to add POST request
func (engine *Engine) POST(pattern string, handler HandlerFunc) {
	engine.addRoute("POST", pattern, handler)
}

// Run defines the method to start a http server
func (engine *Engine) Run(addr string) (err error) {
	return http.ListenAndServe(addr, engine)
}

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	c := newContext(w, req)
	engine.router.handle(c)
}

router相關的代碼獨立後,gee.go簡單了不少。最重要的還是通過實現了 ServeHTTP 接口,接管了所有的 HTTP 請求。相比第一天的代碼,這個方法也有細微的調整,在調用 router.handle 之前,構造了一個 Context 對象。這個對象目前還非常簡單,僅僅是包裝了原來的兩個參數,之後我們會慢慢地給Context插上翅膀。
如何使用,main.go一開始就已經亮相了。運行go run main.go,藉助 curl ,一起看一看今天的成果吧。

curl -i //localhost:9999/
HTTP/1.1 200 OK
Date: Mon, 12 Aug 2019 16:52:52 GMT
Content-Length: 18
Content-Type: text/html; charset=utf-8
<h1>Hello Gee</h1>

$ curl "//localhost:9999/hello?name=geektutu"
hello geektutu, you're at /hello

$ curl "//localhost:9999/login" -X POST -d 'username=geektutu&password=1234'
{"password":"1234","username":"geektutu"}

$ curl "//localhost:9999/xxx"
404 NOT FOUND: /xxx

感謝大佬 geektutu.com 分享