【深入理解Go】從0到1實現一個filter(middleware)

NoSay發表於2021-09-30

filter(也稱middleware)是我們平時業務中用的非常廣泛的框架元件,很多web框架、微服務框架都有整合。通常在一些請求的前後,我們會把比較通用的邏輯都會放到filter元件來實現。如打請求日誌、耗時、許可權、介面限流等通用邏輯。那麼接下來我會和你一起實現一個filter元件,同時讓你瞭解到,它是如何從0到1搭建起來的,具體在演進過程中遇到了哪些問題,是如何解決的。

從一個簡單的server說起

我們看這樣一段程式碼。首先我們在服務端開啟了一個http server,配置了/這個路由,hello函式處理這個路由的請求,並往body中寫入hello字串響應給客戶端。我們通過訪問127.0.0.1:8080就可以看到響應結果。具體的實現如下:

// 模擬業務程式碼
func hello(wr http.ResponseWriter, r *http.Request) {
    wr.Write([]byte("hello"))
}

func main() {
    http.HandleFunc("/", hello)
    if err := http.ListenAndServe(":8080", nil); err != nil {
        panic(err)
    }
}

列印請求耗時v1.0

接下來有一個需求,需要列印這個請求執行的時間,這個也是我們業務中比較常見的場景。我們可能會這樣實現,在hello這個handler方法中加入時間計算邏輯,主函式不變:

// 模擬業務程式碼
func hello(wr http.ResponseWriter, r *http.Request) {
    // 增加計算執行時間邏輯
    start := time.Now()
    wr.Write([]byte("hello"))
    timeElapsed := time.Since(start)
    // 列印請求耗時
    fmt.Println(timeElapsed)
}

func main() {
    http.HandleFunc("/", hello)
    if err := http.ListenAndServe(":8080", nil); err != nil {
        panic(err)
    }
}

但是這樣實現仍然有一定問題。假設我們有一萬個請求路徑定義、所以有一萬個handler和它對應,我們在這一萬個handler中,如果都要加上請求執行時間的計算,那必然代價是相當大的。

為了提升程式碼複用率,我們使用filter元件來解決此類問題。大多數web框架或微服務框架都提供了這個元件,在有些框架中也叫做middleware。

filter登場

filter的基本思路,是把功能性(業務程式碼)與非功能性(非業務程式碼)分離,保證對業務程式碼無侵入,同時提高程式碼複用性。在講解2.0的需求實現之前,我們先回顧一下1.0中比較重要的函式呼叫http.HandleFunc("/", hello)

這個函式會接收一個路由規則pattern,以及這個路由對應的處理函式handler。我們一般的業務邏輯都會寫在handler裡面,在這裡就是hello函式。我們接下來看一下http.HandleFunc()函式的詳細定義:

func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) 

這裡要注意一下,標準庫中又把func(ResponseWriter, *Request)這個func重新定義成一個型別別名HandlerFunc:

type HandlerFunc func(ResponseWriter, *Request)

所以我們一開始用的http.HandleFunc()函式定義,可以直接簡化成這樣:

func HandleFunc(pattern string, handler HandlerFunc) 

我們只要把「HandlerFunc型別」與「HandleFunc函式」區分開就可以一目瞭然了。因為hello這個使用者函式也符合HandlerFunc這個型別的定義,所以自然可以直接傳給http.HandlerFunc函式。而HandlerFunc型別其實是Handler介面的一個實現,Handler介面的實現如下,它只有ServeHTTP這一個方法:

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

HandlerFunc就是標準庫中提供的預設的Handler介面實現,所以它要實現ServeHTTP方法。它在ServeHTTP中只做了一件事,那就是呼叫使用者傳入的handler,執行具體的業務邏輯,在我們這裡就是執行hello(),列印字串,整個請求響應流程結束

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

列印請求耗時v2.0

所以我們能想到的比較容易的辦法,就是把傳入的使用者業務函式hello在外面包一層,而非在hello裡面去加列印時間的程式碼。我們可以單獨定義一個timeFilter函式,他接收一個引數f,也是http.HandlerFunc型別,然後在我們傳入的f前後加上的time.Now、time.Since程式碼。

這裡注意,timeFilter最終返回值也是一個http.HandlerFunc函式型別,因為畢竟最終還是要傳給http.HandleFunc函式的,所以filter必須也要返回這個型別,這樣就可以實現最終業務程式碼與非業務程式碼分離的同時,實現列印請求時間。詳細實現如下:

// 列印請求時間filter,和具體的業務邏輯hello解耦
func timeFilter(f http.HandlerFunc) http.HandlerFunc {
    return func(wr http.ResponseWriter, r *http.Request) {
        start := time.Now()
        // 這裡就是上面我們看過HandlerFun型別中ServeHTTP的預設實現,會直接呼叫f()執行業務邏輯,這裡就是我們的hello,最終會列印出字串
        f.ServeHTTP(wr, r)
        timeElapsed := time.Since(start)
        // 列印請求耗時
        fmt.Println(timeElapsed)
    }
}

func hello(wr http.ResponseWriter, r *http.Request) {
    wr.Write([]byte("hello\n"))
}

func main() {
    // 在hello的外面包上一層timeFilter
    http.HandleFunc("/", timeFilter(hello))
    if err := http.ListenAndServe(":8080", nil); err != nil {
        panic(err)
    }
}

然而這樣還是有兩個問題:

  • 如果有十萬個路由,那我要在這十萬個路由上,每個都去加上相同的包裹程式碼嗎?
  • 如果有十萬個filter,那我們要包裹十萬層嗎,程式碼可讀性會非常差

目前的實現很可能造成以下後果:

http.HandleFunc("/123", filter3(filter2(filter1(hello))))
http.HandleFunc("/456", filter3(filter2(filter1(hello))))
http.HandleFunc("/789", filter3(filter2(filter1(hello))))
http.HandleFunc("/135", filter3(filter2(filter1(hello))))
...

那麼如何更優雅的去管理filter與路由之間的關係,能夠讓filter3(filter2(filter1(hello)))只寫一次就能作用到所有路由上呢?

列印請求耗時v3.0

我們可以想到,我們先把filter的定義抽出來單獨定義為Filter型別,然後可以定義一個結構體Frame,裡面的filters欄位用來專門管理所有的filter。這裡可以從main函式看起。我們新增了timeFilter、路由、最終開啟服務,大體上和1.0版本的流程是一樣的:

// Filter型別定義
type Filter func(f http.HandlerFunc) http.HandlerFunc

type Frame struct {
    // 儲存所有註冊的過濾器
    filters []Filter
}

// AddFilter 註冊filter
func  (r *Frame) AddFilter(filter Filter) {
    r.filters = append(r.filters, filter)
}

// AddRoute 註冊路由,並把handler按filter新增順序包起來。這裡採用了遞迴實現比較好理解,後面會講迭代實現
func (r *Frame) AddRoute(pattern string, f http.HandlerFunc) {
    r.process(pattern, f, len(r.filters) - 1)
}

func (r *Frame) process(pattern string, f http.HandlerFunc, index int) {
    if index == -1 {
        http.HandleFunc(pattern, f)
        return
    }
    fWrap := r.filters[index](f)
    index--
    r.process(pattern, fWrap, index)
}

// Start 框架啟動
func (r *Frame) Start() {
    if err := http.ListenAndServe(":8080", nil); err != nil {
        panic(err)
    }
}

func main() {
    r := &Frame{}
    r.AddFilter(timeFilter)
    r.AddFilter(logFilter)
    r.AddRoute("/", hello)
    r.Start()
}

r.AddRoute之前都很好理解,初始化主結構,並把我們定義好的filter放到主結構中的切片統一管理。接下來AddRoute這裡是核心邏輯,接下來我們詳細講解一下

AddRoute

r.AddRoute("/", hello) 其實和v1.0裡的 http.HandleFunc("/", hello) 其實一摸一樣,只不過內部增加了filter的邏輯。在r.AddRoute內部會呼叫process函式,我將引數全部替換成具體的值:

r.process("/", hello, 1)

那麼在process內部,首先index不等於-1,往下執行到

fWrap := r.filters[index](f)

他的含義就是,取出第index個filter,當前是r.filters[1],r.filters[1]就是我們的logFilter,logFilter接收一個f(這裡就是hello),logFilter裡的f.ServerHTTP可以直接看成執行f(),即hello,相當於直接用hello裡的邏輯替換掉了logFilter裡的f.ServerHTTP這一行,在下圖裡用箭頭表示。最後將logFilter的返回值賦值給fWrap,將包裹後的fWrap繼續往下遞迴,index--:

同理,接下來的遞迴引數為:

r.process("/", hello, 0)

這裡就輪到r.filters[0]了,即timeFilter,過程同上:

最後一輪遞迴,index = -1,即所有filter都處理完了,我們就可以最終和v1.0一樣,呼叫http.HandleFunc(pattern, f)將最終我們層層包裹後的f,最終註冊上去,整個流程結束:

AddRoute的遞迴版本相對容易理解,我也同樣用迭代實現了一個版本。每次迴圈會在本層filter將f包裹後重新賦值給f,這樣就可以將之前包裹後的f沿用到下一輪迭代,基於上一輪的f繼續包裹剩餘的filter。在gin框架中就用了迭代這種方式來實現:

// AddRouteIter AddRoute的迭代實現
func (r *Frame) AddRouteIter(pattern string, f http.HandlerFunc) {
    filtersLen := len(r.filters)
    for i := filtersLen; i >= 0; i-- {
        f = r.filters[i](f)
    }
    http.HandleFunc(pattern, f)
}

這種filter的實現也叫做洋蔥模式,最裡層是我們的業務邏輯helllo,然後外面是logFilter、在外面是timeFilter,很像這個洋蔥,相信到這裡你已經可以體會到了:

小結

我們從最開始1.0版本業務邏輯和非業務邏輯耦合嚴重,到2.0版本引入filter但實現仍不優雅,到3.0版本解決2.0版本的遺留問題,最終實現了一個簡易的filter管理框架

關注我們

歡迎對本系列文章感興趣的讀者訂閱我們的公眾號,關注博主下次不迷路~
image.png

相關文章