在冰镇云开发过程中,遇到了一个关于 Golang 中 http.ResponseWriter 写入顺序的问题,解决后决定记录一下,以后忘了还能回来翻一翻,也算是踩坑日记之一了。
http.ResponseWriter 使用
既然我们要聊 http.ResponseWriter,那么首先让我们看看这个到底是个什么?http.ResponseWriter 是 Golang 中的一个接口,它的定义如下。
1
2
3
4
5
| type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}
|
从接口的定义可以发现 http.ResponseWriter 只做三件事:写入 Header、写入状态码、写入响应体 Body 数据。
一般而言,我们编写 Web 服务都要完成这三个动作。在 Golang 原生 http 库中通过用户定义的 Handler 传入 http.Request 和 http.ResponseWriter,而在一些第三方库比如 gin 中则对请求和响应进行了封装。
在服务的开发过程中,我们存在一个这样的需求:我们需要为每一个请求响应一个 Server-Timing 的 Header,这个 Header 用于记录服务端整体或者一些特定模块的耗时。而在记录服务端整体的耗时中,我们期望它能更全面的记录所有操作的耗时。于是,它的实现大概长这样子。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| func middleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next(w, r)
headerValue := fmt.Sprintf("total;dur=%d", time.Now().Sub(start).Milliseconds())
w.Header().Set("Server-Timing", headerValue)
}
}
func process(w http.ResponseWriter, r *http.Request) {
time.Sleep(time.Second) // 模拟处理耗时
w.WriteHeader(http.StatusOK)
w.Write([]byte("hello, world"))
}
func main() {
handler := middleware(process)
http.ListenAndServe(":8080", handler)
}
|
我们通过中间件的方式,将实际处理的 Handler 封装一层,开始的时候记录开始时间,结束的时候计算耗时并写入 Header。
但是当我们运行程序的时候,就会发现行不通,Header 压根没写入到响应中。经过查看 ResponseWriter 的注释后,才发现原来 HTTP 的状态码和 Header 都是通过 WriteHeader 方法写入的,在执行 WriteHeader 之前,就需要将 Header 设置好。执行 WriteHeader 之后,数据已经写入,之后再通过 Header().Set 也无法修改响应头数据了。
但平时我们 WriteHeader 都需要在业务 Handler 中执行,因为需要根据业务的处理结果决定返回 200、400 或其他状态码。因此为了实现 Header 写入,且逻辑不侵入业务代码,可能需要对 ResponseWriter 进行一些封装。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| type WriterWrapper struct {
http.ResponseWriter
start time.Time
}
func (w *WriterWrapper) WriteHeader(statusCode int) {
headerValue := fmt.Sprintf("total;dur=%d", time.Now().Sub(w.start).Milliseconds())
w.Header().Set("Server-Timing", headerValue)
w.ResponseWriter.WriteHeader(statusCode)
}
func middleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writerWrapper := &WriterWrapper{ResponseWriter: w, start: time.Now()}
next(writerWrapper, r)
}
}
func process(w http.ResponseWriter, r *http.Request) {
time.Sleep(time.Second) // 模拟处理耗时
w.WriteHeader(http.StatusOK)
w.Write([]byte("hello, world"))
}
func main() {
handler := middleware(process)
http.ListenAndServe(":8080", handler)
}
|
我们封装了一个 WriteWrapper 结构体,该结构体记录了处理请求的开始时间,并重写了 WriteHeader 函数,在执行 WriteHeader 的时候先设置和修改我们需要的 Header,再调用真实 ResponseWriter 的 WriteHeader 函数,写入 Header 和状态。
这样的方法使得我们能更优雅地设置通用 Header,将业务逻辑和通用逻辑解耦开。不过需要注意的是,如果是用于统计处理时长,那么 WriteHeader 之后就不应该再存在任何耗时的业务逻辑,否则这种方式统计的时延并准确。
状态码和响应体写入顺序
既然设置 Header 和 WriteHeader 有先后顺序要求,那么写入 Header 和写入响应体是否有先后顺序要求呢?答案是肯定的。
1
2
| w.Write([]byte("hello, world"))
w.WriteHeader(http.StatusOK)
|
上面的案例代码,如果我们将 Write 和 WriteHeader 的顺序调换,那么将会发现 Server-Timing Header 没有出现在最终的请求响应中。不仅如此,我们还发现 Stdout 输出了一些异常日志。
http: superfluous response.WriteHeader call from main.(*WriterWrapper).WriteHeader
字面意思,我们第二行 WriteHeader 是多余调用。
翻阅标准库 ResponseWriter 的注释和实现,我们发现 Write 的实现是当执行的时候发现未曾调用 WriteHeader,就会主动调用一遍 WriteHeader 并设置状态码为 200 OK。
1
2
3
4
5
6
7
8
9
10
11
12
13
| func (w *response) Write(data []byte) (n int, err error) {
return w.write(len(data), data, "")
}
func (w *response) write(lenData int, dataB []byte, dataS string) (n int, err error) {
/* more code */
if !w.wroteHeader {
w.WriteHeader(StatusOK)
}
/* more code */
}
|
而在 WriteHeader 的实现中,则是先判断有没有曾经调用过,如果已经调用过则直接输出异常日志并返回。
1
2
3
4
5
6
7
8
9
10
11
12
| func (w *response) WriteHeader(code int) {
/* more code */
if w.wroteHeader {
caller := relevantCaller()
w.conn.server.logf("http: superfluous response.WriteHeader call from %s (%s:%d)", caller.Function, path.Base(caller.File), caller.Line)
return
}
w.wroteHeader = true
/* more code */
}
|
因此,我们调整 WriteHeader 和 Write 顺序后,Write 将隐式写入 Header 并写入状态码。当我们显式调用封装好的 WriteHeader 时,由于已经被判定为执行过,不再写入 Server-Timing Header。
其他一些设想
上面我们通过一些例子和源码分析了 http.ResponseWriter 写入顺序上的一些细节点,也算是踩坑了。那么有没有一种方法可以使得这些动作顺序不作任何要求呢?我理解应该还是有的,比如我们做一个 Writer 封装,所有写入动作都变成缓存动作,直到最后再统一写入。但是这样业务逻辑层就丢弃了实际响应写入的错误信息,个人还是不太推荐这么搞。