1. 程式人生 > >golang 實現HTTP代理和反向代理

golang 實現HTTP代理和反向代理

代理的核心功能可以用一句話概括:接受客戶端的請求,轉發到後端伺服器,獲得應答之後返回給客戶端。下圖是 《HTTP 權威指南》一書中給出的圖例,可以很清晰地說明這一流程:

代理的功能有很多,事實上整個網際網路到處都充斥著代理伺服器。如果所有的 HTTP 訪問都是客戶端和伺服器端直接進行的話,我們的網路不僅會變得緩慢,而且效能會大打折扣。

代理伺服器根據不同的配置和使用,可能會有不同的功能,這些功能主要包括:

  • 內容過濾:代理可以根據一定的規則限制某些請求的連線。比如有些公司會設定內部網路無法訪問某些購物、遊戲網站,或者學校的網路不讓學生訪問色情暴力的網站等
  • 節省成本:代理伺服器可以作為快取使用,對於某些資源只需要第一次訪問的時候去下載,以後代理直接把快取的結果返回給客戶端,節約網路頻寬的開銷
  • 提高效能:通過代理伺服器的快取(比如 CDN)和負載均衡(比如 nginx lb)功能,伺服器端可以加速請求的訪問,在更快的時間內返回結果)
  • 增加安全性:公司可以在內網和外網之間通過代理進行轉發,這樣不僅對外隱藏了實現的細節,而且可以在代理層對爬蟲、病毒性請求進行過濾,保護內部服務

所有的這些功能的實現都依賴於代理的特性,它可以在客戶端和伺服器端做一些事情,根據代理做的事情不同,它的角色和功能也就不同。那麼,代理具體可以做哪些事情呢?比如:

  • 修改 HTTP 請求:url、header、body
  • 過濾請求:根據一定的規則丟棄、過濾請求
  • 決定轉發到哪個後端(可以是靜態定義的,也可以是動態決定)
  • 儲存伺服器的應答,後續的請求可以直接使用儲存的應答
  • 修改應答:對應答做一些格式的轉換,修改資料,甚至返回完全不一樣的應答資料
  • 重試機制,如果後端伺服器暫時無法響應,隔一段時間重試
  • ……

正向代理和反向代理

代理可以分為正向代理和反向代理兩種。

正向代理需要客戶端來配置,一般來說我們會通過瀏覽器或者作業系統提供的工具或者介面來配置。這個時候,代理對客戶端不是透明的,客戶端需要知道代理的地址並且手動配置。配置了代理,瀏覽器在傳送請求的時候會對報文做特殊的修改。

反向代理對客戶端是透明的,也就是說客戶端一般不知道代理的存在,認為自己是直接和伺服器通訊。我們大部分訪問的網站就是反向代理伺服器,反向代理伺服器會轉發到真正的伺服器,一般在反向代理這一層實現負載均衡和高可用的功能。而且這裡也可以看到,客戶端是不會知道真正伺服器端的 ip 地址和埠的,這在一定程度上起到了安全保護的作用。

代理伺服器怎麼知道目的伺服器的地址?

在反向代理中,代理伺服器要轉發的伺服器地址都是事先知道的(包括靜態配置和動態配置)。比如 使用 nginx 來配置負載均衡 。

而對於正向代理來說,客戶端可能訪問的伺服器地址是無法事先知道的。因為HTTP 協議活動在應用層,它無法獲取網路層(IP層)資訊,那麼該協議要有一個地方可以拿到這個資訊。HTTP 中可能儲存這個資訊的地方有兩個:URL 和 header。預設情況下,HTTP 請求的 status line 有三部分組成:方法、uri 和協議版本,比如:

GET /index.html HTTP/1.0
User-Agent: gohttp 1.0

如果客戶端(比如瀏覽器)知道自己在通過正向代理進行報文傳輸,那麼它會在 status line 加上要訪問伺服器的真實地址。這個時候傳送的報文是:

GET http://www.marys-antiques.com/index.html HTTP/1.0
User-Agent: gohttp 1.0

代理路徑

客戶端不管是通過代理伺服器,還是直接訪問後端伺服器對於最終的結果是沒有區別的,也就是說大多數情況下客戶端根本不關心它訪問的到底是什麼,只需要(準確快速地)拿到想要的資訊就夠了。但是有時候,我們還是希望知道請求到底在中間經歷了哪些代理,比如用來除錯網路異常,或者做資料統計,而 HTTP 協議也提供了響應的功能。

雖然 RFC 2616 定義了 Via 頭部欄位來跟蹤 HTTP 請求經過的代理路徑,但在實際中用的更多的還是 X-Forwarded-For 欄位, X-Forwarded-For 是 Squid 快取代理服務軟體引入的,目前已經在規範化在 RFC 7239 文件。

X-Forwarded-For 頭部格式也比較簡單,比如某個伺服器接受到請求的對應頭部可能是:

X-Forwarded-For: client, proxy1, proxy2

對應的值有多個欄位,每個欄位代表中間的一個節點,它們之間由逗號和空格隔開,從左到右距離當前節點越來越近。

每個代理伺服器會在 X-Forwarded-For 頭部填上前一個節點的 ip 地址,這個地址可以通過 TCP 請求的 remote address 獲取。為什麼每個代理伺服器不填寫自己的 ip 地址呢?有兩個原因,如果由代理伺服器填寫自己的 ip 地址,那麼代理可以很簡單地偽造這個地址,而上一個節點的 remote address 是根據 TCP 連接獲取的(如果不建立正確的 TCP 連線是無法進行 HTTP 通訊的);另外一個原因是如果由當前節點填寫 X-Forwarded-For ,那麼很多情況客戶端無法判斷自己是否會通過代理的。

NOTE:

  1. 最終客戶端或者伺服器端接受的請求, X-Forwarded-For 是沒有最鄰近節點的 ip 地址的,而這個地址可以通過 remote address 獲取
  2. 每個節點(不管是客戶端、代理伺服器、真實伺服器)都可以隨便更改 X-Forwarded-For 的值,因此這個欄位只能作為參考

代理伺服器實現

這個部分我們會介紹如何用 golang 來實現 HTTP 代理伺服器,需要讀者瞭解一些 HTTP 伺服器端程式設計的知識,可以參考我之前的文章:go http 伺服器程式設計。

正向代理

按照我們之前介紹的代理原理,我們可以編寫出這樣的程式碼:

package main

import (
	"fmt"
	"io"
	"net"
	"net/http"
	"strings"
)

type Pxy struct {}

func (p *Pxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
	fmt.Printf("Received request %s %s %s\n", req.Method, req.Host, req.RemoteAddr)

	transport :=  http.DefaultTransport

	// step 1
	outReq := new(http.Request)
	*outReq = *req // this only does shallow copies of maps

	if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
		if prior, ok := outReq.Header["X-Forwarded-For"]; ok {
			clientIP = strings.Join(prior, ", ") + ", " + clientIP
		}
		outReq.Header.Set("X-Forwarded-For", clientIP)
	}

	// step 2
	res, err := transport.RoundTrip(outReq)
	if err != nil {
		rw.WriteHeader(http.StatusBadGateway)
		return
	}

	// step 3
	for key, value := range res.Header {
		for _, v := range value {
			rw.Header().Add(key, v)
		}
	}

	rw.WriteHeader(res.StatusCode)
	io.Copy(rw, res.Body)
	res.Body.Close()
}

func main() {
	fmt.Println("Serve on :8080")
	http.Handle("/", &Pxy{})
	http.ListenAndServe("0.0.0.0:8080", nil)
}

這段程式碼比較直觀,只包含了最核心的程式碼邏輯,完全按照最上面的代理圖例進行組織。一共分成幾個步驟:

  1. 代理接收到客戶端的請求,複製了原來的請求物件,並根據資料配置新請求的各種引數(新增上 X-Forward-For 頭部等)
  2. 把新請求傳送到伺服器端,並接收到伺服器端返回的響應
  3. 代理伺服器對響應做一些處理,然後返回給客戶端

上面的程式碼執行之後,會在本地的 8080 埠啟動代理服務。修改瀏覽器的代理為 127.0.0.1::8080 再訪問網站,可以驗證代理正常工作,也能看到它在終端打印出所有的請求資訊。

雖然這段程式碼非常簡短,但是你可以新增更多的邏輯實現非常有用的功能。比如在請求傳送之前進行過濾,根據一定的規則直接阻止某些請求的訪問;或者對請求進行限流,某個客戶端在一定的時間裡執行的請求有最大限額;統計請求的資料進行分析等等。

這個代理目前不支援 HTTPS 協議,因為它只提供了 HTTP 請求的轉發功能,並沒有處理證書和認證有關的內容。如果瞭解 HTTPS 協議的話,你會明白這種模式下是無法完成 HTTPS 握手的,雖然代理可以和真正的伺服器建立連線(知道了對方的公鑰和證書),但是代理無法代表伺服器和客戶端建立連線,因為代理伺服器無法知道真正伺服器的私鑰。

反向代理

編寫反向代理按照上面的思路當然沒有問題,只需要在第二步的時候,根據之前的配置修改 outReq 的 URL Host 地址可以了。不過 Golang 已經給我們提供了編寫代理的框架: httputil.ReverseProxy 。我們可以用非常簡短的程式碼來實現自己的代理,而且內部的細節問題都已經被很好地處理了。

這部分我們會實現一個簡單的反向代理,它能夠對請求實現負載均衡,隨機地把請求傳送給某些配置好的後端伺服器。使用 httputil.ReverseProxy 編寫反向代理最重要的就是實現自己的 Director 物件,這是 GoDoc 對它的介紹:

Director must be a function which modifies the request into a new request to be sent using Transport. Its response is then copied back to the original client unmodified. Director must not access the provided Request after returning.

簡單翻譯的話, Director 是一個函式,它接受一個請求作為引數,然後對其進行修改。修改後的請求會實際傳送給伺服器端,因此我們編寫自己的 Director 函式,每次把請求的 Scheme 和 Host 修改成某個後端伺服器的地址,就能實現負載均衡的效果(其實上面的正向代理也可以通過相同的方法實現)。看程式碼:

package main

import (
        "log"
        "math/rand"
        "net/http"
        "net/http/httputil"
        "net/url"
)

func NewMultipleHostsReverseProxy(targets []*url.URL) *httputil.ReverseProxy {
        director := func(req *http.Request) {
                target := targets[rand.Int()%len(targets)]
                req.URL.Scheme = target.Scheme
                req.URL.Host = target.Host
                req.URL.Path = target.Path
        }
        return &httputil.ReverseProxy{Director: director}
}

func main() {
        proxy := NewMultipleHostsReverseProxy([]*url.URL{
                {
                        Scheme: "http",
                        Host:   "localhost:9091",
                },
                {
                        Scheme: "http",
                        Host:   "localhost:9092",
                },
        })
        log.Fatal(http.ListenAndServe(":9090", proxy))
}

NOTE:這段程式碼來自 http://blog.charmes.net/2015/07/reverse-proxy-in-go.html。

我們讓代理監聽在 9090 埠,在後端啟動兩個返回不同響應的伺服器分別監聽在 9091 和 9092 埠,通過 curl 訪問,可以看到多次請求會返回不同的結果。

➜  curl http://127.0.0.1:9090
116064a9eb83
➜  curl http://127.0.0.1:9090
8f7ccc11718f

同樣的,這段程式碼也只是一個 demo,存在著很多問題,比如沒有錯誤處理機制,如果後端某個伺服器掛了,代理會返回 502 錯誤,更好的做法是把請求轉發到另外的可用伺服器。當然也可以新增更多的特性讓它更好用,比如動態地新增後端伺服器列表;根據後端伺服器的負載情況進行負載轉發等等。