Skip to content

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.ResponseWriterWriteHeader 方法设置状态码,但调用一次后就不能再改。要记录状态码,需要包装 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.SplitHostPortRemoteAddr(格式是 IP:port)拆成 IP 和端口。如果请求已经经过了上游代理(X-Forwarded-For 已存在),把当前代理的 IP 追加到链尾。

四、请求体读取的坑

http.RequestBodyio.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

下一讲会把后端地址从硬编码改成可配置的路由表,让网关能根据请求路径转发到不同的后端服务。