背景
當我們談到反向代理時,可以将其比喻為一個“中間人”。想象一下,你是一個使用者,你想要通路某個網站。但是,這個網站并不直接向你提供服務,而是委托了一個代理來處理你的請求。這個代理就是反向代理。
你可以把反向代理想象成一個非常聰明的助手,它可以幫助你與網站進行交流。當你發送請求時,它會接收到你的請求,并将其轉發給網站。然後,網站會将響應發送給反向代理,反向代理再将響應發送給你。這樣,你就可以與網站進行互動,而不需要直接與網站通信。
net/http 包裡面已經幫我們内置了具有反向代理能力 ReverseProxy 對象, 但是它的能力有限, 從工程能力上面還有很多自行實作.
本文包含了講述官方代碼内部實作, 同時結合自身需求講述改造後代碼邏輯
因包含了大段代碼, 不免閱讀起來第一感覺較為繁瑣複雜, 但大部分代碼都進行了詳細的注釋标注, 可以業務中用到時再回來詳讀代碼部分.
大家也可閱讀底部參考連結部分, 引用的文章内容相對精簡, 相信大家能有所收獲.
官方代碼分析
簡單使用
首先我們看下入口實作, 隻需要幾行代碼, 就将所有流量代理到了 http://www.domain.com 上
// 設定要轉發的位址
target, err := url.Parse("http://www.domain.com")
if err != nil {
panic(err)
}
// 執行個體化 ReverseProxy 包
proxy := httputil.NewSingleHostReverseProxy(target)
//http.HandleFunc("/", proxy.ServeHTTP)
// 啟動服務
log.Fatal(http.ListenAndServe(":8082", proxy))
本地啟動 127.0.0.1:8082 後會攜帶相關用戶端相關請求資訊到 http://www.domain.com 域下.
但是通常上述是無法滿足我們需求的, 比如有鑒權、逾時控制、鍊路傳遞、請求日志記錄等常見需求, 這樣我們怎麼來實作呢? 在開始之前, 我們先了解下官方内置了哪些能力, 具體是怎麼工作的.
底層結構
官方的 ReverseProxy 提供的結構:
type ReverseProxy struct {
// 對請求内容進行修改 (對象是業務傳入req的一個副本)
Director func(*http.Request)
// 連接配接池複用連接配接,用于執行請求, 預設為http.DefaultTransport
Transport http.RoundTripper
// 定時重新整理内容到用戶端的時間間隔(流式/無内容此參數忽略)
FlushInterval time.Duration
// 預設為std.err,用于記錄内部錯誤日志
ErrorLog *log.Logger
// 用于執行 copyBuffer 複制響應體時,利用的bytes記憶體池化
BufferPool BufferPool
// 如果配置後, 可修改目标代理的響應結果(響應頭和内容)
// 如果此方法傳回error, 将調用 ErrorHandler 方法
ModifyResponse func(*http.Response) error
// 配置後代理執行過程中, 發生錯誤均會回調此方法
// 預設邏輯不響應任務内容, 狀态碼傳回502
ErrorHandler func(http.ResponseWriter, *http.Request, error)
}
在開始的demo裡, 我們第一步執行個體化了 ReverseProxy 對象, 首先我們分析下NewSingleHostReverseProxy 方法做了什麼
// 執行個體化 ReverseProxy 包
proxy := httputil.NewSingleHostReverseProxy(target)
初始化部分
初始化對象, 設定代理請求的request結構值
// 執行個體化 ReverseProxy 對象
// 初始化 Director 對象, 将請求位址轉換為代理目标位址.
// 對請求header頭進行處理
func NewSingleHostReverseProxy(target *url.URL) *ReverseProxy {
targetQuery := target.RawQuery
director := func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.URL.Path, req.URL.RawPath = joinURLPath(target, req.URL)
if targetQuery == "" || req.URL.RawQuery == "" {
req.URL.RawQuery = targetQuery + req.URL.RawQuery
} else {
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
}
if _, ok := req.Header["User-Agent"]; !ok {
// explicitly disable User-Agent so it's not set to default value
req.Header.Set("User-Agent", "")
}
}
return &ReverseProxy{Director: director}
}
小貼士:
大家可能對 User-Agent 處理比較奇怪, 為什麼不存在後要設定一個空字元串呢?
這塊代碼源自于的 issues 為: https://github.com/golang/go/issues/15524
目的是為了避免請求頭User-Agent被污染, 在http底層包發起請求時, 如果未設定 User-Agent 将會使用 Go-http-client/1.1 代替
具體代碼位址:
https://github.com/golang/go/blob/457721cd52008146561c80d686ce1bb18285fe99/src/net/http/request.go#L646
發起請求部分
http.ListenAndServe(":8082", proxy) 啟動服務時, 處理請求的工作主要是 Handler 接口ServeHTTP 方法.
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
ReverseProxy 中預設已實作此接口, 以下是處理請求的核心邏輯
我們來看下代碼是怎麼處理的
// 服務請求處理方法
func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// 檢測是否設定http.Transport對象
// 如果未設定則使用預設對象
transport := p.Transport
if transport == nil {
transport = http.DefaultTransport
}
// 檢測請求是否被終止
// 終止請求或是正常結束請求等 notifyChan 都會收到請求結束通知, 之後進行cancel
ctx := req.Context()
if cn, ok := rw.(http.CloseNotifier); ok {
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
defer cancel()
notifyChan := cn.CloseNotify()
go func() {
select {
case <-notifyChan:
cancel()
case <-ctx.Done():
}
}()
}
// 對外部傳入的http.Request對象進行克隆
// outreq 是給代理伺服器傳入的請求對象
outreq := req.Clone(ctx)
if req.ContentLength == 0 {
// 主要修複 ReverseProxy 與 http.Transport 重試不相容性問題
// 如果請求方法為 GET、HEAD、OPTIONS、TRACE, 同時body為nil情況下, 将會發生重試
// 避免因為複制傳入的request建立傳入代理的請求内容, 導緻無法發生重試.
// https://github.com/golang/go/issues/16036
outreq.Body = nil
}
if outreq.Body != nil {
// 避免因panic問題導緻請求未正确關閉, 其他協程繼續從中讀取
// https://github.com/golang/go/issues/46866
defer outreq.Body.Close()
}
if outreq.Header == nil {
// Issue 33142: historical behavior was to always allocate
outreq.Header = make(http.Header)
}
// 調用實作的 Director 方法修改請求代理的request對象
p.Director(outreq)
if outreq.Form != nil {
outreq.URL.RawQuery = cleanQueryParams(outreq.URL.RawQuery)
}
outreq.Close = false
// 更新http協定,HTTP Upgrade
// 判斷header Connection 中是否有Upgrade
reqUpType := upgradeType(outreq.Header)
// 根據《網絡交換的 ASCII 格式》規範, 更新協定中是否包含禁止使用的字元
// https://datatracker.ietf.org/doc/html/rfc20#section-4.2
if !ascii.IsPrint(reqUpType) {
// 調用 ReverseProxy 對象的 ErrorHandler 方法
p.getErrorHandler()(
rw,
req,
fmt.Errorf("client tried to switch to invalid protocol %q", reqUpType))
return
}
// 請求下遊移除Connetion頭
// https://datatracker.ietf.org/doc/html/rfc7230#section-6.1
removeConnectionHeaders(outreq.Header)
// 請求下遊根據RFC規範移除協定頭
for _, h := range hopHeaders {
outreq.Header.Del(h)
}
// Transfer-Encoding: chunked 分塊傳輸編碼
if httpguts.HeaderValuesContainsToken(req.Header["Te"], "trailers") {
outreq.Header.Set("Te", "trailers")
}
// 請求下遊指定協定更新, 例如 websockeet
if reqUpType != "" {
outreq.Header.Set("Connection", "Upgrade")
outreq.Header.Set("Upgrade", reqUpType)
}
// 添加 X-Forwarded-For 頭
// 最開始的是離服務端最遠的裝置 IP,然後是每一級代理裝置的 IP
// 類似于 X-Forwarded-For: client, proxy1, proxy2
if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
prior, ok := outreq.Header["X-Forwarded-For"]
// 如果header頭 X-Forwarded-For 設定為nil, 則不再 X-Forwarded-For
// 這個參數下面我們将詳細說明
omit := ok && prior == nil
if len(prior) > 0 {
clientIP = strings.Join(prior, ", ") + ", " + clientIP
}
if !omit {
outreq.Header.Set("X-Forwarded-For", clientIP)
}
}
// 使用transport對象中維護的連結池, 向下遊發起請求
res, err := transport.RoundTrip(outreq)
if err != nil {
p.getErrorHandler()(rw, outreq, err)
return
}
// 處理下遊響應的更新協定請求
// Deal with 101 Switching Protocols responses: (WebSocket, h2c, etc)
if res.StatusCode == http.StatusSwitchingProtocols {
if !p.modifyResponse(rw, res, outreq) {
return
}
p.handleUpgradeResponse(rw, outreq, res)
return
}
// 根據協定規範删除響應 Connection 頭
removeConnectionHeaders(res.Header)
// 下遊響應根據RFC規範移除協定頭
for _, h := range hopHeaders {
res.Header.Del(h)
}
// 如有設定 modifyResponse, 則修改響應内容
// 調用 ReverseProxy 對象 modifyResponse 方法
if !p.modifyResponse(rw, res, outreq) {
return
}
// 拷貝響應Header到上遊response對象
copyHeader(rw.Header(), res.Header)
// 分塊傳輸部分協定 header 頭設定, 已跳過
// 寫入響應碼到上遊response對象
rw.WriteHeader(res.StatusCode)
// 拷貝結果到上遊
// flushInterval将響應定時重新整理到緩沖區
err = p.copyResponse(rw, res.Body, p.flushInterval(res))
if err != nil {
defer res.Body.Close()
// ... 調用errorHandler
panic(http.ErrAbortHandler)
}
// 關閉響應body
res.Body.Close()
// chunked 分塊傳輸編碼調用flush重新整理到用戶端
if len(res.Trailer) > 0 {
// Force chunking if we saw a response trailer.
// This prevents net/http from calculating the length for short
// bodies and adding a Content-Length.
if fl, ok := rw.(http.Flusher); ok {
fl.Flush()
}
}
// 以下為分塊傳輸編碼相關header設定
if len(res.Trailer) == announcedTrailers {
copyHeader(rw.Header(), res.Trailer)
return
}
for k, vv := range res.Trailer {
k = http.TrailerPrefix + k
for _, v := range vv {
rw.Header().Add(k, v)
}
}
}
以上是代理請求的核心處理流程, 我們可以看到主要是對傳入 request 對象轉成下遊代理請求對象, 請求後傳回響應頭和内容, 進行處理.
内容補充
1. 為什麼請求下遊移除Connetion頭
Connection 通用标頭控制網絡連接配接在目前會話完成後是否仍然保持打開狀态。如果發送的值是 keep-alive,則連接配接是持久的,不會關閉,允許對同一伺服器進行後續請求。
這個頭設定解決的是用戶端和服務端連結方式, 而不應該透傳給代理的下遊服務.
是以再RFC中有以下明确規定:
“Connection”頭字段允許發送者訓示所需的連接配接 目前連接配接的控制選項。為了避免混淆下遊接收者,代理或網關必須删除或在轉發之前替換任何收到的連接配接選項資訊。
RFC: https://datatracker.ietf.org/doc/html/rfc7230#section-6.1
2. X-Forwarded-For 作用
X-Forwarded-For(XFF)請求标頭是一個事實上的用于辨別通過代理伺服器連接配接到 web 伺服器的用戶端的原始 IP 位址的标頭(很容易被篡改)。
當用戶端直接連接配接到伺服器時,其 IP 位址被發送給伺服器(并且經常被記錄在伺服器的通路日志中)。但是如果用戶端通過正向或反向代理伺服器進行連接配接,伺服器就隻能看到最後一個代理伺服器的 IP 位址,這個 IP 通常沒什麼用。如果最後一個代理伺服器是與伺服器安裝在同一台主機上的負載均衡伺服器,則更是如此。X-Forwarded-For 的出現,就是為了向伺服器提供更有用的用戶端 IP 位址。
X-Forwarded-For: <client>, <proxy1>, <proxy2>
<client>
用戶端的 IP 位址。
<proxy1>, <proxy2>
如果請求經過多個代理伺服器,每個代理伺服器的 IP 位址會依次出現在清單中。
這意味着,如果用戶端和代理伺服器行為良好,最右邊的 IP 位址會是最近的代理伺服器的 IP 位址,
最左邊的 IP 位址會是原始用戶端的 IP 位址。
引用:
https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/X-Forwarded-For
實際應用落地
實際落地過程中, 我們不僅要考慮轉發能力, 還要有相對應的日志、逾時、優雅錯誤處理等能力,
下面将講解怎麼基于官方内置的 ReverseProxy 對象的代理能力來實作這些功能.
設計思路: 對外實作 Proxy ServerHttp版的接口, 在内部利用 ReverseProxy 對象代理能力基礎上設計.
1. 定義proxy ServeHTTP對象
type ServeHTTP struct {
// 代理連結位址
targetUrl string
// net/http 内置的 ReverseProxy 對象
reverseProxy *httputil.ReverseProxy
// 代理錯誤處理
proxyErrorHandler ProxyErrorHandler
// 日志對象
logger log.Logger
}
下面我們執行個體化對象
// NewServeHTTP 初始化代理對象
func NewServeHTTP(targetUrl string, logger log.Logger) *ServeHTTP {
target, err := url.Parse(targetUrl)
if err != nil {
panic(err)
}
// 重新設定 Director 複制請求處理
proxy := &httputil.ReverseProxy{Director: func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.Host = target.Host
if _, ok := req.Header["User-Agent"]; !ok {
req.Header.Set("User-Agent", "")
}
if req.Header.Get("Content-Length") == "0" {
req.Header.Del("Content-Length")
}
req.Header["X-Forwarded-For"] = nil
for _, name := range removeRequestHeaders {
req.Header.Del(name)
}
}}
serveHttp := &ServeHTTP{
targetUrl: targetUrl,
logger: logger,
reverseProxy: proxy,
proxyErrorHandler: DefaultProxyErrorHandler,
}
// 設定trasport處理對象(主要調配連結池大小和逾時時間)
serveHttp.reverseProxy.Transport = HttpTransportDefault()
// 定義錯誤處理
serveHttp.reverseProxy.ErrorHandler = serveHttp.getErrorHandler(logger)
// 定義響應處理
serveHttp.reverseProxy.ModifyResponse = serveHttp.getResponseHandler(logger)
return serveHttp
}
// SetProxyErrorFunc 設定錯誤處理函數
func (s *ServeHTTP) SetProxyErrorFunc(handler ProxyErrorHandler) *ServeHTTP {
s.proxyErrorHandler = handler
return s
}
2. 我們重寫了 reverseProxy 的 Director方法
1)我們不希望轉發 X-Forwarded-For 到代理層, 通過手動指派為nil方式解決
原因是網絡防火牆對源IP進行了驗證, X-Forwarded-For是可選項之一, 但通常 X-Forwarded-For 不安全且存在本地聯通調試問題, 不建議通過此參數進行驗證, 故将此移除.
2)移除指定的 removeRequestHeaders 頭
常見的鑒權類頭等不應該原樣轉發給下遊伺服器, 需要移除
3. 覆寫官方預設的 HttpTransportDefault
在 http.Transport 對象中, MaxIdleConnsPerHost、MaxIdleConns 參數在 http1.1 下非常影響性能
官方預設同host 建立的連結池内連接配接數隻有2個, 在http2長連結情況下基本沒有問題, 在http1.1場景下會嚴重影響連結性能問題, 我們統一修改為200
netHttp.Transport{
Proxy: proxyURL,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 200,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
4. 定義請求處理部分
考慮到在請求 reverseProxy 對象轉發邏輯時,需要攔截請求進行前置參數處理, 不能直接使用 reverseProxy 對象, 是以就由自定義 proxy 實作 handler 接口的 ServeHTTP 方法, 對 reverseProxy 連結處理進行一層包裝.
邏輯如下:
// ServeHTTP 服務轉發
func (s *ServeHTTP) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
var (
reqBody []byte
err error
// 生成traceId
traceId = s.getTraceId(request)
)
// 前置擷取請求頭, 放入context中
// 調用結束後請求 body 将會被關閉, 後面将無法再擷取
if request.Body != nil {
reqBody, err = io.ReadAll(request.Body)
if err == nil {
request.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
}
// header 設定 traceId和逾時時間傳遞
request.Header.Set(utils.TraceKey, traceId)
request.Header.Set(utils.Timeoutkey, cast.ToString(s.getTimeout(request)))
// 計算擷取逾時時間, 發起轉發請求
ctx, cancel := context.WithTimeout(
request.Context(),
time.Duration(s.getTimeout(request))*time.Millisecond,
)
defer cancel()
// 設定請求體
ctx = context.WithValue(ctx, ctxReqBody, string(reqBody))
// 設定請求時間, 用于響應結束後計算請求耗時
ctx = context.WithValue(ctx, ctxReqTime, time.Now())
// context 設定traceId, 用于鍊路日志列印
ctx = context.WithValue(ctx, utils.TraceKey, traceId)
request = request.WithContext(ctx)
// 調用 reverseProxy ServeHTTP, 處理轉發邏輯
s.reverseProxy.ServeHTTP(writer, request)
}
以上代碼均有詳細注釋, 下面我們看下 traceId和請求耗時函數邏輯, 比較簡單.
// getTraceId 擷取traceId
// header頭中不存在則生成
func (s *ServeHTTP) getTraceId(request *http.Request) string {
traceId := request.Header.Get(utils.TraceKey)
if traceId != "" {
return traceId
}
return uuid.NewV4().String()
}
// getTimeout 擷取逾時時間
// header中不存在timeoutKey, 傳回預設逾時時間
// header頭存在, 則判斷是否大于預設逾時時間, 大于則使用預設逾時時間
// 否則傳回header設定的逾時時間
func (s *ServeHTTP) getTimeout(request *http.Request) uint32 {
timeout := request.Header.Get(utils.Timeoutkey)
if timeout == "" {
return DefaultTimeoutMs
}
headerTimeoutMs := cast.ToUint32(timeout)
if headerTimeoutMs > DefaultTimeoutMs {
return DefaultTimeoutMs
}
return headerTimeoutMs
}
5. 定義響應部分和錯誤處理部分
從一開始我們就了解 ReverseProxy 功能, 可以設定 ModifyResponse、ErrorHandler, 下面我們看下具體是怎麼實作的.
ErrorHandler
// getErrorHandler 記錄錯誤記錄
func (s *ServeHTTP) getErrorHandler(logger log.Logger) ErrorHandler {
return func(writer http.ResponseWriter, request *http.Request, e error) {
var (
reqBody []byte
err error
)
if request.Body != nil {
reqBody, err = io.ReadAll(request.Body)
if err == nil {
request.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
}
// 初始化時确認proxyErrorHandler具體處理方法
// 調用 proxyErrorHandler,處理響應部分
s.proxyErrorHandler(writer, e)
// 擷取必要資訊, 記錄錯誤日志
scheme := s.getSchemeDataByRequest(request)
_ = log.WithContext(request.Context(), logger).Log(log.LevelError,
"x_module", "proxy/server/error",
"x_component", scheme.kind,
"x_error", e,
"x_header", request.Header,
"x_action", scheme.operation,
"x_param", string(reqBody),
"x_trace_id", request.Context().Value(utils.TraceKey),
)
}
}
// 具體代理業務錯誤處理
// 包含預設錯誤響應和具體代理業務錯誤響應.
// 以下為某個業務響應
func XXXProxyErrorHandler(writer http.ResponseWriter, err error) {
resp := HttpXXXResponse{
ErrCode: 1,
ErrMsg: err.Error(),
Data: struct{}{},
}
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.Header().Set("Connection", "keep-alive")
writer.Header().Set("Cache-Control", "no-cache")
// 設定狀态碼為200
writer.WriteHeader(http.StatusOK)
// 将響應值序列化
respByte, _ := json.Marshal(resp)
// 将response資料寫入writer, 重新整理到Flush
// 關于Flush部分, 一般是不需要主動重新整理的, 請求結束後會自動Flush
_, _ = fmt.Fprintf(writer, string(respByte))
if f, ok := writer.(http.Flusher); ok {
f.Flush()
}
}
以上有一個值的關注的地方, 設定響應頭一定要在設定響應碼之前, 否則将無效
設定響應内容一定在最後, 否則将設定失敗并傳回錯誤.
ModifyResponse 處理邏輯
// getResponseHandler 擷取響應資料
func (s *ServeHTTP) getResponseHandler(logger log.Logger) func(response *http.Response) error {
return func(response *http.Response) error {
var (
duration float64
logLevel = log.LevelInfo
header http.Header
)
// 擷取請求體
reqBody := response.Request.Context().Value(ctxReqBody)
// 擷取開始請求時間, 計算請求耗時
startTime := response.Request.Context().Value(ctxReqBody)
if startTime != nil {
_, ok := startTime.(time.Time)
if ok {
duration = time.Since(startTime.(time.Time)).Seconds()
}
}
// 擷取響應資料
// 如果響應碼非200, 調整日志等級
scheme := s.getSchemeDataByResponse(response)
if response.StatusCode != http.StatusOK {
logLevel = log.LevelError
header = scheme.header
}
// 記錄日志
_ = log.WithContext(response.Request.Context(), logger).Log(logLevel,
"x_module", "proxy/server/resp",
"x_component", "http",
"x_code", scheme.code,
"x_header", header,
"x_action", scheme.operation,
"x_params", reqBody,
"x_response", scheme.responseData,
"x_duration", duration,
"x_trace_id", response.Request.Context().Value(utils.TraceKey),
)
// 設定響應頭
response.Header.Set("Content-Type", "application/json; charset=utf-8")
return nil
}
}
預設代理伺服器是不設定響應頭的, 則為預設的響應頭。
響應頭必須手動設定
6. 使用自定義的 proxy 代理請求
urlStr := "https://" + targetHost
proxy := utilsProxy.NewServeHTTP(urlStr, logger).SetProxyErrorFunc(utilsProxy.XXXProxyErrorHandler)
log.Fatal(http.ListenAndServe(":8082", proxy))
參考連結
【golang簡單而強大的反向代理】
https://h1z3y3.me/posts/simple-and-powerful-reverse-proxy-in-golang/
【Golang ReverseProxy 如何實作反向代理?】
https://juejin.cn/post/6973306900440055815
【golang反向代理源碼解析】
https://www.cnblogs.com/FengZeng666/p/15645634.html
【golang x-forwared-for issues】
https://github.com/golang/go/issues/53423
【golang User-Agent issues】
https://github.com/golang/go/issues/15524
【golang req body issuses】
https://github.com/golang/go/issues/16036
【golang header頭設定的坑】
https://blog.alovn.cn/2020/01/20/golang-http-set-response-header/
【http協定】
https://developer.mozilla.org/zh-CN/docs/Web/HTTP
【rfc Connection協定頭規範】
https://datatracker.ietf.org/doc/html/rfc7230#section-6.1
【rfc 協定網絡字元規範】
https://datatracker.ietf.org/doc/html/rfc20#section-4.2
作者:王宇
來源:微信公衆号:好未來技術
出處:https://mp.weixin.qq.com/s/DiKRbINIigXuKxPj6a4pZQ