天天看點

用Go語言寫HTTP中間件

在web開發過程中,中間件一般是指應用程式中封裝原始資訊,添加額外功能的元件。不知道為什麼,中間件通常是一種不太受歡迎的概念。但我認為它棒極了。

其一,一個好的中間件擁有單一的功能,可插拔并且是自我限制的。這就意味着你可以在接口的層次上把它放到應用中,并能很好的工作。中間件并不影響你的代碼風格,它也不是一個架構,僅僅是你處理請求流程中額外一層罷了。根本不需要重寫代碼:如果你想用一個中間件,就把它加上應用中;如果你改變主意了,去掉就好了。就這麼簡單。

來看看Go,HTTP中間件非常流行,标準庫中也是這樣。或許咋看上去并不明顯,net/http包中的函數,如

StripPrefix TimeoutHandler

正是我們上面定義的中間件:封裝處理過程并在處理輸入或輸出時增加額外的動作。

我最近的Go包

nosurf

也是一個中間件。我從一開始就有意的這樣設計。大多數情況下,你根本不必在應用層關心CSRF檢查。nosurf,和其他中間件一樣,非常獨立,可以和實作标準庫net/http接口的工具配合使用。

你也可以使用中間件做這些:

* 通過隐藏長度緩解BREACH攻擊

* 頻率限制

* 屏蔽惡意自動程式

* 提供調試資訊

* 添加HSTS, X-Frame-Options頭

* 從異常中優雅恢複

* 以及其他等等。

寫一個簡單的中間件

第一個例子中,我寫了一個中間件,隻允許使用者從特定的域(在HTTP的Host頭中有域資訊)來通路伺服器。這樣的中間件可以保護應用程式不受“

主機欺騙攻擊

定義類型

為了友善,讓我們為這個中間件定義一種類型,叫做SingleHost。

type SingleHost struct {

    handler     http.Handler

    allowedHost string

}

隻包含兩個字段:

* 封裝的Handler。如果是有效的Host通路,我們就調用這個Handler。

* 允許的主機值。

由于我們把字段名小寫了,使得該字段隻對我們自己的包可見。我們還應該寫一個初始化函數。

func NewSingleHost(handler http.Handler, allowedHost string) *SingleHost {

    return &SingleHost{handler: handler, allowedHost: allowedHost}

處理請求

現在才是實際的邏輯。為了實作http.Handler,我們的類型秩序實作一個方法:

type Handler interface {

        ServeHTTP(ResponseWriter, *Request)

這就是我們實作的方法:

func (s *SingleHost) ServeHTTP(w http.ResponseWriter, r *http.Request) {

   host := r.Host

   if host == s.allowedHost {

       s.handler.ServeHTTP(w, r)

   } else {

       w.WriteHeader(403)

   }

ServeHTTP 函數僅僅檢查請求中的Host頭:

  • 如果Host頭比對初始化函數設定的allowedHost ,就調用封裝handler的ServeHTTP方法。
  • 如果Host頭不比對,就傳回403狀态碼(禁止通路)。

在後一種情況中,封裝handler的ServeHTTP方法根本就不會被調用。是以封裝的handler根本不會有任何輸出,實際上它根本就不知道有這樣一個請求到來。

現在我們已經完成了自己的中間件,來把它放到應用中。這次我們不把Handler直接放到net/http服務中,而是先把Handler封裝到中間件中。

singleHosted = NewSingleHost(myHandler, "example.com")​

http.ListenAndServe(":8080", singleHosted)

另外一種方法

我們剛才寫的中間件實在是太簡單了,隻有僅僅15行代碼。為了寫這樣的中間件,引入了一個不太通用的方法。由于Go支援函數第一型和閉包,并且擁有簡潔的http.HandlerFunc包裝器,我們可以将其實作為一個簡單的函數,而不是寫一個單獨的類型。下面是基于函數的中間件版本。

func SingleHost(handler http.Handler, allowedHost string) http.Handler {​

   ourFunc := func(w http.ResponseWriter, r *http.Request) {​

       host := r.Host​

       if host == allowedHost {​

           handler.ServeHTTP(w, r)​

       } else {​

           w.WriteHeader(403)​

       }​

   }​

   return http.HandlerFunc(ourFunc)​

這裡我們聲明了一個叫做SingleHost的簡單函數,接受一個Handler和允許的主機名。在函數内部,我們建立了一個類似之前版本ServeHTTP的函數。這個内部函數其實是一個閉包,是以它可以從SingleHost外部通路。最終,我們通過

HandlerFunc

把這個函數用作http.Handler。

使用Handler還是定義一個http.Handler類型完全取決于你。對簡單的情況而已,一個函數就足夠了。但是随着中間件功能的複雜,你應該考慮定義自己的資料結構,把邏輯獨立到多個方法中。

實際上,标準庫這兩種方法都用了。

是一個傳回HandlerFunc的函數。雖然

也是一個函數,但它傳回了處理請求的自定義的類型。

更複雜的情況

我們的SingleHost中間件非常簡單:先檢查請求的一個屬性,然後要麼什麼也不管,把請求直接傳給封裝的Handler;要麼自己傳回一個響應,根本不讓封裝的Handler處理這次請求。然而,有些情況是這樣的,不但基于請求觸發一些動作,還要在封裝的Handler處理後做一些掃尾工作,比如修改響應内容等。

添加資料比較容易

如果我們想在封裝的handler輸出的内容後添加一些資料,我們隻需要在handler結束後繼續調用Write()即可:

type AppendMiddleware struct {

    handler http.Handler

}​

func (a *AppendMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {

    a.handler.ServeHTTP(w, r)

    w.Write([]byte("Middleware says hello."))

響應内容現在就應該包含封裝的handler的内容,再加上Middleware says hello.

問題是

做其他的響應内容操作比較麻煩。比如,如果我們想在響應内容前寫入一些資料。如果我們在封裝的handler前調用Write(),那麼封裝的handler就好失去對HTTP狀态碼和HTTP頭的控制。因為第一次調用Write()會直接将頭輸出。

想要修改原有輸出(比如,替換其中的某些字元串),改變特定的HTTP頭,設定不同的狀态碼也都因為同樣的原因而不可行:當封裝的handler傳回時,上述資料早已被發送給用戶端了。

為了處理這樣的需求,我們需要一種特殊的可以用做buffer的ResponseWriter,它能夠收集、暫存輸出以用于修改等操作,最後再發送給用戶端。我們可以将這個帶buffer的ResponseWriter傳給封裝的handler,而不是真實的RW,這樣就避免直接發送資料給用戶端。

幸運的是,在Go标準庫中确實存在這樣一個工具。net/http/httptest中的

ResponseRecorder

就是這樣的:它儲存狀态碼,一個儲存響應頭的字典,将輸出累計在buffer中。盡管是用于測試(這個包名暗示了這一點),它還是很好的滿足了我們的需求。

讓我們看一個使用ResponseRecorder的例子,這裡修改了響應内容的所有東西,是為了更完整的示範。

type ModifierMiddleware struct {​

    handler http.Handler​

func (m *ModifierMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {​

    rec := httptest.NewRecorder()​

    // passing a ResponseRecorder instead of the original RW​

    m.handler.ServeHTTP(rec, r)​

    // after this finishes, we have the response recorded​

    // and can modify it before copying it to the original RW​

    // we copy the original headers first​

    for k, v := range rec.Header() {​

        w.Header()[k] = v​

    }​

    // and set an additional one​

    w.Header().Set("X-We-Modified-This", "Yup")​

    // only then the status code, as this call writes out the headers ​

    w.WriteHeader(418)​

    // the body hasn't been written (to the real RW) yet,​

    // so we can prepend some data.​

    w.Write([]byte("Middleware says hello again. "))​

  // then write out the original body​

    w.Write(rec.Body.Bytes())​

下面是我們包裝的handler的輸出。如果不用我們的中間件封裝,原來的handler僅僅會輸出Success!。

HTTP/1.1 418 I'm a teapot​

X-We-Modified-This: Yup​

Content-Type: text/plain; charset=utf-8​

Content-Length: 37​

Date: Tue, 03 Sep 2013 18:41:39 GMT​

Middleware says hello again. Success!​

這種方式提供了非常大的便利。被封裝的handler現在完全在我們的控制之下:即使在其傳回之後,我們也可以以任意方式操作輸出。

和其他handlers共享資料

在不同的情況下,中間件可以需要給其他的中間件或者應用程式暴露特定的資訊。比如,nosurf需要給使用者提供一種擷取CSRF 密鑰的方式以及錯誤原因(如果有錯誤的話)。

對這種需求,一個合适的模型就是使用一個隐藏的map,将http.Request指針指向需要的資料,然後暴露一個包級别(handler級别)的函數來通路這些資料。

我在nosurf中也使用了這種模型。這裡,我建立了一個全局的上下文map。注意到,由于預設情況下Go的map并不是[并發通路安全](

http://blog.golang.org/go-maps-in-action#TOC_6

.)的,需要一個mutex。

type csrfContext struct {​

    token string

    reason error​

var (​

    contextMap = make(map[*http.Request]*csrfContext)​

    cmMutex    = new(sync.RWMutex)​

)​

使用handler設定資料,然後通過暴露的函數Token()來擷取資料。

func Token(req *http.Request) string {​

    cmMutex.RLock()​

    defer cmMutex.RUnlock()​

    ctx, ok := contextMap[req]​

    if !ok {​

            return ""​

    return ctx.token​

你可以在nosurf的代碼庫

context.go

中找到完整的實作。

雖然我選擇在nosurf中自己實作這種需求,但實際上存在一個友善的

gorilla/context

包,它實作了一個通用的儲存請求資訊的map。在大多數情況下,這個包足以滿足你的需求,避免你在自己實作一個共享存儲時踩坑。它甚至還有一個

自己的中間件

能在請求處理結束之後清除請求資訊。

總結

這篇文章的目的是吸引Go使用者對中間件概念的注意以及展示使用Go寫中間件的一些基本元件。盡管Go是一個相對年輕的開發語言,Go擁有非常

漂亮的标準HTTP接口

。這也是用Go寫中間件是個非常簡單甚至快樂的過程的原因之一。

然而,目前Go仍然缺乏高品質的HTTP工具。我之前提到的

Go中間件想法

,大多都還沒實作。現在你已經知道如何用Go寫中間件了,為什麼不自己做一個呢?

繼續閱讀