Appearance
17|请求与响应
最小代理能转发请求,但生产网关需要记录每次转发的详细信息:客户端 IP、请求方法、路径、响应状态码、耗时。这些信息用于排查故障、分析流量和监控健康度。本章在最小代理的基础上,增加请求日志和响应记录。
一、请求信息提取
HTTP 请求包含方法、路径、协议版本、请求头和请求体。网关需要提取其中一部分用于路由匹配和日志记录:
go
package main
import (
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"time"
)
func main() {
target, _ := url.Parse("http://127.0.0.1:8081")
proxy := httputil.NewSingleHostReverseProxy(target)
// 包装代理,添加日志
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 提取请求信息
clientIP := r.RemoteAddr
method := r.Method
path := r.URL.Path
userAgent := r.UserAgent()
fmt.Printf("[%s] %s %s (UA: %s)\n", clientIP, method, path, userAgent)
proxy.ServeHTTP(w, r)
cost := time.Since(start)
fmt.Printf("completed in %v\n", cost)
})
http.ListenAndServe(":8080", handler)
}http.HandlerFunc 是一个函数类型,它满足 http.Handler 接口。上面的代码创建了一个自定义 handler,在调用代理前后打印日志。
二、ResponseWriter 包装
上面的代码有一个问题:proxy.ServeHTTP(w, r) 完成后,不知道后端返回了什么状态码。http.ResponseWriter 的 WriteHeader 方法设置状态码,但调用一次后就不能再改。要记录状态码,需要包装 ResponseWriter,拦截 WriteHeader 调用:
go
type responseRecorder struct {
http.ResponseWriter
statusCode int
}
func newResponseRecorder(w http.ResponseWriter) *responseRecorder {
return &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK}
}
func (rr *responseRecorder) WriteHeader(code int) {
rr.statusCode = code
rr.ResponseWriter.WriteHeader(code)
}responseRecorder 嵌入了 http.ResponseWriter,继承了所有方法。它重写了 WriteHeader,在设置状态码前先记录到 statusCode 字段。
使用:
go handler
start := time.Now()
rr := newResponseRecorder(w)
proxy.ServeHTTP(rr, r)
cost := time.Since(start)
fmt.Printf("%s %s %d %v\n", r.Method, r.URL.Path, rr.statusCode, cost)
})现在日志格式是:GET /hello 200 2.345ms,包含方法、路径、状态码和耗时。这种格式和 Nginx 的访问日志类似,可以直接接入日志分析工具。
三、X-Forwarded 头
代理场景下,后端服务看到的客户端 IP 是代理的地址,不是真实客户端地址。标准做法是在转发请求时添加 X-Forwarded 系列头:
| 头 | 含义 |
|---|---|
X-Forwarded-For | 原始客户端 IP,多个代理用逗号分隔 |
X-Forwarded-Host | 原始请求的 Host |
X-Forwarded-Proto | 原始请求的协议(http/https) |
go
proxy.Director = func(req *http.Request) {
originalDirector(req)
// 追加客户端 IP 到 X-Forwarded-For
clientIP, _, _ := net.SplitHostPort(req.RemoteAddr)
if prior := req.Header.Get("X-Forwarded-For"); prior != "" {
clientIP = prior + ", " + clientIP
}
req.Header.Set("X-Forwarded-For", clientIP)
req.Header.Set("X-Forwarded-Host", req.Host)
req.Header.Set("X-Forwarded-Proto", "http")
req.Host = target.Host
}net.SplitHostPort 把 RemoteAddr(格式是 IP:port)拆成 IP 和端口。如果请求已经经过了上游代理(X-Forwarded-For 已存在),把当前代理的 IP 追加到链尾。
四、请求体读取的坑
http.Request 的 Body 是 io.ReadCloser,只能读一次。如果在代理前读取了 Body(比如用于日志或校验),代理转发时 Body 已经是空的。需要先把 Body 内容读到内存,再重新赋值给请求:
go body,
r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(body))这个操作会占用和请求体大小相等的内存。大文件上传场景下,读取 Body 可能导致内存耗尽。网关处理大文件时,通常不读取 Body,直接透传。
五、当前完整代码
go
package main
import (
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"time"
)
type responseRecorder struct {
http.ResponseWriter
statusCode int
}
func newResponseRecorder(w http.ResponseWriter) *responseRecorder {
return &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK}
}
func (rr *responseRecorder) WriteHeader(code int) {
rr.statusCode = code
rr.ResponseWriter.WriteHeader(code)
}
func main() {
target, _ := url.Parse("http://127.0.0.1:8081")
proxy := httputil.NewSingleHostReverseProxy(target)
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
clientIP, _, _ := net.SplitHostPort(req.RemoteAddr)
if prior := req.Header.Get("X-Forwarded-For"); prior != "" {
clientIP = prior + ", " + clientIP
}
req.Header.Set("X-Forwarded-For", clientIP)
req.Host = target.Host
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rr := newResponseRecorder(w)
proxy.ServeHTTP(rr, r)
cost := time.Since(start)
fmt.Printf("%s %s %d %v\n", r.Method, r.URL.Path, rr.statusCode, cost)
})
fmt.Println("proxy listening on :8080")
http.ListenAndServe(":8080", handler)
}运行后,每次请求都会输出类似:
GET /hello 200 2.456ms
POST /api/users 201 15.234ms
GET /notfound 404 1.123ms下一讲会把后端地址从硬编码改成可配置的路由表,让网关能根据请求路径转发到不同的后端服务。