http.ResponseController 刷新长连接响应的使用边界
在 SSE、进度推送或长轮询接口里,服务端已经写入了内容,但客户端迟迟收不到,常见原因不是循环没有执行,而是响应仍停留在缓冲区。Go 的 http.ResponseController 可以把刷新动作从具体的 ResponseWriter 实现中抽出来:写入一段内容后调用 Flush(),并检查它返回的错误。
官方资料:https://pkg.go.dev/net/http#ResponseController
ResponseController.Flush 适合“分段写入、需要尽快提交当前缓冲”的 HTTP handler;它不承诺绕过反向代理,也不能在 ServeHTTP 返回后继续使用。
Flush 刷新的到底是哪一层
Flush 的职责是把服务端已经写入的缓冲数据交给 HTTP 传输层。它不是“通知浏览器立即渲染”的 API:客户端连接经过代理、网关或其他缓冲层时,数据仍可能等到更多内容到达或响应结束才被转发。
当前标准库的默认 HTTP/1.x 和 HTTP/2 响应实现支持 Flusher,但响应包装器不一定保留这个能力。因此长连接响应至少要同时考虑三个对象:handler 正在写入的 ResponseWriter、能够执行刷新的底层 writer,以及链路中可能继续缓冲的代理。
用 ResponseController 写出可处理错误的分段响应
响应头和内容类型应在第一次写入之前设置。每次写入后调用 Flush,一旦返回错误就停止继续发送;如果能力不支持,不要把它当成普通业务失败反复重试。
package main
import (
"errors"
"fmt"
"net/http"
"time"
)
func stream(w http.ResponseWriter, r *http.Request) {
// 先设置响应头,避免第一次 Write 后再修改失效。
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
controller := http.NewResponseController(w)
for i := 1; i <= 3; i++ {
// 每段只写当前进度,适合长连接或进度推送场景。
if _, err := fmt.Fprintf(w, "progress: %d/3\\n", i); err != nil {
return // 客户端断开时,停止继续写入。
}
if err := controller.Flush(); err != nil {
if errors.Is(err, http.ErrNotSupported) {
// 包装器或底层 writer 没有刷新能力,不能假设分段可见。
return
}
return // 其他写入链路错误也应结束当前响应。
}
time.Sleep(500 * time.Millisecond) // 仅模拟分段之间的工作间隔。
}
}
这里的关键不是延时,而是“写入一段、刷新一次、检查一次错误”。如果业务允许降级,可以在第一次刷新返回 ErrNotSupported 时改为聚合输出,但不要继续把“实时到达”作为对客户端的承诺。
包装器为什么会让刷新失效
NewResponseController 最适合接收 handler 原始拿到的 ResponseWriter,或者接收提供 Unwrap() http.ResponseWriter 的包装器。控制器会尝试调用 FlushError() error、Flusher.Flush(),找不到时再沿 Unwrap 向下寻找;如果仍没有能力,就返回匹配 http.ErrNotSupported 的错误。
因此,中间件不要只实现 Header、Write、WriteHeader 就结束。如果它包住了底层 writer,却不提供 Unwrap,上层控制器可能无法发现原本存在的刷新能力。包装器还应避免在自己内部把所有数据攒到 handler 返回后才一次性写出,否则即使底层执行了 Flush,实时效果也已经被上层缓冲抵消。
type writerWrapper struct {
http.ResponseWriter
}
func (w writerWrapper) Unwrap() http.ResponseWriter {
// 暴露原始 writer,让 ResponseController 继续寻找底层能力。
return w.ResponseWriter
}
上线前要划清的四个边界
- 协议边界:默认 HTTP/1.x 和 HTTP/2 writer 通常支持刷新,但实际响应仍受客户端和中间代理影响;不能只在本地直连测试通过后就承诺实时到达。
- 请求体边界:刷新响应和继续读取请求体不是同一件事。HTTP/1.x 默认会在开始写响应前处理未读请求体;需要交错读写时,应单独评估
EnableFullDuplex,不要把Flush当成替代方案。 - 生命周期边界:
ResponseController与ResponseWriter都不能在ServeHTTP返回后继续使用。异步 goroutine 不能拿它们做后台推送。 - 语义边界:
Flush只提交当前缓冲,不负责重试、断线重连、事件编号或消息幂等。SSE 等协议还需要在业务层设计事件格式和重连策略。
| 现象 | 优先判断 | 处理方向 |
|---|---|---|
| Flush 返回 ErrNotSupported | writer 或包装器没有暴露刷新能力 | 保留 Unwrap,或采用聚合响应降级 |
| 服务端已 Flush,客户端仍成批收到 | 代理、网关或客户端继续缓冲 | 检查链路缓冲策略,不把 Flush 当端到端保证 |
| 响应返回后仍尝试写入 | 跨越 handler 生命周期 | 把推送任务改为仍在请求生命周期内的设计,或换用合适的长连接架构 |
相关问题
ResponseController.Flush 和直接断言 Flusher 有什么区别?
直接断言只能看到当前值是否实现 Flusher;ResponseController 还支持返回错误的 FlushError,并能沿实现了 Unwrap 的包装器寻找底层能力,调用方可以统一处理“不支持”和传输错误。
调用 Flush 后能保证浏览器立刻显示吗?
不能。它只完成服务端到 HTTP 传输层的刷新;代理、客户端和渲染策略仍可能缓冲。需要端到端实时性时,必须把链路中的每层都纳入设计。
资料依据:https://go.dev/src/net/http/responsecontroller.go;https://pkg.go.dev/net/http#Flusher