用Go語言寫HTTP中介軟體

JeOam發表於2019-02-16

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

其一,一個好的中介軟體擁有單一的功能,可插拔並且是自我約束的。這就意味著你可以在介面的層次上把它放到應用中,並能很好的工作。中介軟體並不影響你的程式碼風格,它也不是一個框架,僅僅是你處理請求流程中額外一層罷了。根本不需要重寫程式碼:如果你想用一箇中介軟體,就把它加上應用中;如果你改變主意了,去掉就好了。就這麼簡單。

來看看Go,HTTP中介軟體非常流行,標準庫中也是這樣。或許咋看上去並不明顯,net/http包中的函式,如StripPrefixTimeoutHandler 正是我們上面定義的中介軟體:封裝處理過程並在處理輸入或輸出時增加額外的動作。

我最近的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型別完全取決於你。對簡單的情況而已,一個函式就足夠了。但是隨著中介軟體功能的複雜,你應該考慮定義自己的資料結構,把邏輯獨立到多個方法中。

實際上,標準庫這兩種方法都用了。StripPrefix 是一個返回HandlerFunc的函式。雖然TimeoutHandler也是一個函式,但它返回了處理請求的自定義的型別。

更復雜的情況

我們的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寫中介軟體了,為什麼不自己做一個呢?

PS,你可以在一個GitHub gist中找到這篇文章中所有的中介軟體例子。


原文連結: Writing HTTP Middleware in Go
轉載自: 伯樂線上Codefor

相關文章