mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
perf(webui): 服务端 gzip —— 首屏 wire 字节 -70%
生产实测:首屏 API 合计 792,933 B,而服务端此前**完全没有** Content-Encoding
(直连 127.0.0.1:8080 与经 nginx 的公网入口两条路径都验过:响应头里没有
该字段,wire 尺寸 == 原始尺寸)。同一份数据 gzip 后:
/api/v1/kernel 152,667 → 37,697 (-75%)
/api/v1/chat/history?limit=40 554,764 → 174,594 (-69%)
这些是高度重复的 JSON(同批 key 名反复出现、中文实体名、时间戳),
压缩比自然地高。真实实例上实测首屏 wire 字节 77,943 → 23,339(-70%)。
位置:链改为 proxyDispatch → gzip → logged → mux。夹在 proxyDispatch
与 logged 之间,是因为 proxyDispatch 命中时直接 return、响应来自上游
(其 Content-Encoding 由 httputil 处理),我们不插手;门户自身的全部
响应(requireAPI 的 401/503、requireWeb 的 302、HTML/CSS/JS、全部
JSON API)都压。
### 三个必须显式处理的坑
1. **SSE 不能压。** text/event-stream 进 gzip 缓冲后 flush 语义就废了
(前端收不到流式,要等缓冲攒够)。对 SSE 请求直接透传。
2. **必须透传 http.Flusher。** handleChatEvents / streamOpenAI 里是
`w.(http.Flusher)` 类型断言;包装 ResponseWriter 会让断言失败 ⇒
flusher 为 nil ⇒ 走降级分支 ⇒ SSE **静默**坏掉(不报错,只是收不到
流式)。这不是「顺手加一下」能过的改动,有专门的判据守着。
3. **204/304/HEAD 没有 body**,压它们只浪费 CPU 并加坏头。
另外 webp/png/zip/gzip 等已压缩类型也跳过(mascot.webp 133KB 就在内)。
### 小于 1KB 的响应不压
gzip 头 23 字节,几百字节的 JSON 压完反而更大。与 nginx 的
gzip_min_length 1000 对齐。实测 /api/v1/status(197B)不带
Content-Encoding。
### 状态机写成枚举而非多个 bool
第一版用 passthrough/decided/buffering/allowBuf 四个 bool 交叉表示,
结果出两个 bug:小响应内容被写成空、已压缩类型仍被压。根因是
「该不该压」在 Write / WriteHeader / 收尾三处各判一次且判据不一致。
改成单一 mode 枚举(undecided/passThrough/buffering/streaming)、
判据只在 WriteHeader 与 Write 各求值一次后,两个 bug 同时消失。
### ★ 一条判据我自己写错了,值得记下来
TestGzipDropsContentLength 初版断言「压缩响应不应带 Content-Length」,
实测失败。追查后证明**判据错了、代码是对的**:
Go 在 Del("Content-Length") 之后,若响应体小到能被一次性缓冲(<2048B),
net/http 会**自动重算**并补上压缩后的真实长度(实测 14000B → 119B →
响应头 Content-Length: 119,正确)。真正要防的是**陈旧长度**:留着
14000 而实发 119 时,客户端按 Content-Length 读满会先拿到 119 字节再吃
unexpected EOF(已用对照探针实测复现)。判据改成两条:①声明长度 ==
实际读到字节数 ②该值 == 压缩后长度而非压缩前长度。另加一条对照判据
TestGzipStaleContentLengthWouldBreak,把危害钉成可执行断言。
### 验证(不是「应该能跑」)
- go vet 干净;全量测试通过;新增 12 条 gzip 判据;覆盖率 66.5% → 67.0%
- 真实实例(独立数据目录 + 18081 端口)实测:
· SSE:无 Content-Encoding,2 次独立 TCP 读(逐帧下发,未被缓冲)
· /api/v1/status(197B):不带 Content-Encoding
· /api/v1/kernel -69%、/api/v1/settings -73%、/api/v1/plugins -63%
· 首屏 wire 字节 77,943 → 23,339(-70%)
· 内容完整性:gzip 解压后与明文逐字段相等(plugins/tools/build/
channels 名称集合与顺序均一致)
- ★ 途中被一个「MISMATCH」误导过一轮:/api/v1/kernel 两次请求字节不同。
追查发现是 IOManager.ListChannels 遍历 **map**(Go 每次迭代随机化),
**在本次改动之前就已不确定**,与 gzip 无关。差点被我误报成压缩 bug。
附:dashboard.js 被自动格式化器整体重排(6783 增 / 6293 删,纯空白与
引号风格)。已用 prettier 归一化后逐字节比对确认**零语义差异**。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
301
internal/plugins/webui/gzip.go
Normal file
301
internal/plugins/webui/gzip.go
Normal file
@ -0,0 +1,301 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// gzip 中间件:给可压缩的响应加 Content-Encoding: gzip。
|
||||
//
|
||||
// ★ 为什么必须有(生产实例实测,非估算):
|
||||
//
|
||||
// 首屏 API 合计 792,933 B,而服务端此前**完全没有** Content-Encoding
|
||||
// (直连与经 nginx 两条路径都验过:头里没有该字段,wire 尺寸 == 原始
|
||||
// 尺寸)。实测同一份数据 gzip -9 后:
|
||||
//
|
||||
// /api/v1/chat/history?limit=40 554,764 → 174,594 (-69%)
|
||||
// /api/v1/kernel 152,667 → 37,697 (-75%)
|
||||
//
|
||||
// 这些响应是**高度重复的 JSON**(同一批 key 名反复出现、中文实体名、
|
||||
// 时间戳),压缩比自然地高。经公网入口(frp + 移动网络)时,793KB 的
|
||||
// 首屏与 53MB/h 的空闲轮询都是实打实的流量钱。
|
||||
//
|
||||
// 放在哪一层:
|
||||
//
|
||||
// 包在 logged **外面**(链:proxyDispatch → gzip → logged → mux)。
|
||||
// 理由:proxyDispatch 命中时直接 return,响应来自上游(上游自己的
|
||||
// Content-Encoding 由 httputil 处理),我们不该插手;而门户自身的
|
||||
// 全部响应(含 requireAPI 的 401/503、requireWeb 的 302 跳转、
|
||||
// HTML/CSS/JS、全部 JSON API)都该压。
|
||||
//
|
||||
// ★ 三个必须显式处理的坑:
|
||||
//
|
||||
// 1. **SSE / 流式不能压。** text/event-stream 一旦进了 gzip 缓冲,
|
||||
// flush 语义就废了(表现为「前端收不到流式,要等缓冲攒够」)。
|
||||
// 2. **必须透传 http.Flusher。** handler 里有 `w.(http.Flusher)`
|
||||
// 的类型断言(handleChatEvents / streamOpenAI)。包装 ResponseWriter
|
||||
// 会让断言失败 ⇒ flusher 为 nil ⇒ 代码走降级分支,SSE 直接坏掉。
|
||||
// 这不是「顺手加一下」能过的改动。
|
||||
// 3. **HEAD / 204 / 304 没有 body**,压缩它们只会浪费 CPU 和加坏头。
|
||||
const gzipMinLength = 1024 // 与 nginx 的 gzip_min_length 对齐
|
||||
|
||||
// gzipCompressibleContentType 判定是否值得压。
|
||||
//
|
||||
// 压「已经压缩过」的类型是纯浪费:webp/png/jpeg/gzip/zip 再压一遍
|
||||
// 几乎不缩小,却要付 CPU + 掉帧。webui 自带 mascot.webp(133KB)就是这类。
|
||||
//
|
||||
// ★ text/event-stream 明确**不**列(虽然它在通用规则里可压):
|
||||
// 压它会毁掉 flush 语义。宁可漏压也不要压坏流。
|
||||
func gzipCompressibleContentType(ct string) bool {
|
||||
if ct == "" {
|
||||
return false
|
||||
}
|
||||
// 取分号前的主类型(content-type 可能带 charset)
|
||||
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||
ct = ct[:i]
|
||||
}
|
||||
ct = strings.TrimSpace(strings.ToLower(ct))
|
||||
switch ct {
|
||||
case "application/json", "application/javascript", "text/javascript",
|
||||
"text/html", "text/css", "text/plain",
|
||||
"application/xml", "text/xml", "image/svg+xml":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// acceptsGzip 判断客户端是否要 gzip。
|
||||
func acceptsGzip(r *http.Request) bool {
|
||||
for _, v := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
|
||||
if i := strings.IndexByte(v, ';'); i >= 0 {
|
||||
v = v[:i]
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(v), "gzip") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// gzipWriter 池:gzip.NewWriter 每次都要分配窗口/哈希状态,
|
||||
// 而 webui 的 API 响应极频繁,不复用会让 GC 压力反噬我们要省的目的。
|
||||
var gzipPool = sync.Pool{
|
||||
New: func() any { return gzip.NewWriter(io.Discard) },
|
||||
}
|
||||
|
||||
// gzip 响应的三个状态。写成枚举而不是几个 bool —— 上一版用
|
||||
// passthrough/decided/buffering/allowBuf 四个 bool 交叉表示,
|
||||
// 出现了「小响应内容被写成空」和「已压缩类型仍被压」两个 bug,
|
||||
// 根因就是「到底该不该压」在 Write / WriteHeader / 收尾三处各判一次、
|
||||
// 判据还不一致。**单一判据在单一处求值**是这里的硬要求。
|
||||
type gzipMode int
|
||||
|
||||
const (
|
||||
// gzipUndecided:还没看过 Content-Type,不知道该不该压。
|
||||
gzipUndecided gzipMode = iota
|
||||
// gzipPassThrough:不该压(或不能压),原样透传。
|
||||
gzipPassThrough
|
||||
// gzipBuffering:可压且已决定压,但还没写够阈值,先攒着。
|
||||
gzipBuffering
|
||||
// gzipStreaming:正在边收边压(已越过阈值)。
|
||||
gzipStreaming
|
||||
)
|
||||
|
||||
// gzipResponseWriter 包装 ResponseWriter,边写边压。
|
||||
type gzipResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
gz *gzip.Writer
|
||||
mode gzipMode
|
||||
wroteHeader bool
|
||||
status int
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (g *gzipResponseWriter) WriteHeader(code int) {
|
||||
if g.wroteHeader {
|
||||
return
|
||||
}
|
||||
g.status = code
|
||||
// 无 body 的状态码不压,也不加 Content-Encoding。
|
||||
if code == http.StatusNoContent || code == http.StatusNotModified {
|
||||
g.commit(gzipPassThrough)
|
||||
return
|
||||
}
|
||||
// 内容类型不可压(如 image/webp):透传,头照常发。
|
||||
if !gzipCompressibleContentType(g.Header().Get("Content-Type")) {
|
||||
g.commit(gzipPassThrough)
|
||||
return
|
||||
}
|
||||
// 可压,但**先不发头**:Content-Length 一旦发出就不能改,
|
||||
// 得先知道最终写多少字节才能决定压不压(小响应压了反而变大)。
|
||||
// 真正的 commit 发生在首次 Write 越过阈值、或 handler 返回时。
|
||||
}
|
||||
|
||||
func (g *gzipResponseWriter) Write(p []byte) (int, error) {
|
||||
switch g.mode {
|
||||
case gzipPassThrough:
|
||||
g.commit(gzipPassThrough)
|
||||
return g.ResponseWriter.Write(p)
|
||||
|
||||
case gzipStreaming:
|
||||
// 已开压:直接喂进 gzip 流。注意此时若下游还没 WriteHeader 过,
|
||||
// startCompress 已经替我们发过了(见 commit)。
|
||||
if g.gz == nil {
|
||||
return g.ResponseWriter.Write(p)
|
||||
}
|
||||
return g.gz.Write(p)
|
||||
|
||||
case gzipBuffering:
|
||||
g.buf = append(g.buf, p...)
|
||||
if len(g.buf) >= gzipMinLength {
|
||||
g.startCompress()
|
||||
g.writeBufToStream()
|
||||
}
|
||||
return len(p), nil
|
||||
|
||||
default: // gzipUndecided
|
||||
// WriteHeader 没被显式调用(handler 直接 Write)也走这里。
|
||||
if !gzipCompressibleContentType(g.Header().Get("Content-Type")) {
|
||||
g.commit(gzipPassThrough)
|
||||
return g.ResponseWriter.Write(p)
|
||||
}
|
||||
g.buf = append(g.buf, p...)
|
||||
if len(g.buf) >= gzipMinLength {
|
||||
g.startCompress()
|
||||
g.writeBufToStream()
|
||||
} else {
|
||||
g.mode = gzipBuffering
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
}
|
||||
|
||||
// writeBufToStream 把缓冲内容送进 gzip 流。写失败(客户端已断开)在
|
||||
// 响应收尾阶段无法处置,与 close/Flush 中的处理一致地忽略。
|
||||
func (g *gzipResponseWriter) writeBufToStream() {
|
||||
if g.gz != nil && len(g.buf) > 0 {
|
||||
_, _ = g.gz.Write(g.buf)
|
||||
}
|
||||
g.buf = nil
|
||||
}
|
||||
|
||||
// startCompress 真正开始压缩:剥掉 Content-Length、补 Content-Encoding
|
||||
// 与 Vary,然后才发头。
|
||||
func (g *gzipResponseWriter) startCompress() {
|
||||
h := g.Header()
|
||||
h.Del("Content-Length") // 压缩后长度未知,留着就是错的
|
||||
h.Set("Content-Encoding", "gzip")
|
||||
// Vary:同一 URL 会因 Accept-Encoding 不同而返回不同编码。中间缓存
|
||||
// (nginx/CDN/浏览器)必须据此区分,否则会把 gzip 版发给不支持
|
||||
// 压缩的客户端。
|
||||
h.Add("Vary", "Accept-Encoding")
|
||||
if g.gz == nil {
|
||||
g.gz = gzipPool.Get().(*gzip.Writer)
|
||||
g.gz.Reset(g.ResponseWriter)
|
||||
}
|
||||
g.commit(gzipStreaming)
|
||||
}
|
||||
|
||||
// commit 定模式并发头(幂等)。
|
||||
func (g *gzipResponseWriter) commit(mode gzipMode) {
|
||||
if g.wroteHeader {
|
||||
g.mode = mode
|
||||
return
|
||||
}
|
||||
g.mode = mode
|
||||
g.wroteHeader = true
|
||||
if g.status == 0 {
|
||||
g.status = http.StatusOK
|
||||
}
|
||||
g.ResponseWriter.WriteHeader(g.status)
|
||||
}
|
||||
|
||||
// Flush 透传:SSE 依赖它逐帧下发。
|
||||
//
|
||||
// ★ 存在即必须正确:handler 里是 `w.(http.Flusher)` 断言,
|
||||
// 拿不到就等于没有 Flush,SSE 会卡到缓冲满。
|
||||
//
|
||||
// 若此刻仍在缓冲(可压但没写够阈值),必须先把已攒的内容发出去,
|
||||
// 否则 Flush 形同虚设、且数据永远滞留缓冲。
|
||||
func (g *gzipResponseWriter) Flush() {
|
||||
switch g.mode {
|
||||
case gzipUndecided:
|
||||
// 没写过任何东西就 Flush(少见):先把头发出去。
|
||||
g.commit(gzipPassThrough)
|
||||
case gzipBuffering:
|
||||
// 有内容但没到阈值:SSE 场景不该走到这;真走到了就直接定夺——
|
||||
// 有内容就压(已经攒了半天,收益远大于 23 字节的头开销)。
|
||||
if len(g.buf) > 0 {
|
||||
g.startCompress()
|
||||
g.writeBufToStream()
|
||||
} else {
|
||||
g.commit(gzipPassThrough)
|
||||
}
|
||||
case gzipStreaming:
|
||||
if g.gz != nil {
|
||||
_ = g.gz.Flush()
|
||||
}
|
||||
}
|
||||
if f, ok := g.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// finish 在 handler 返回后收尾:把「攒着没发」的内容定夺掉。
|
||||
func (g *gzipResponseWriter) finish() {
|
||||
switch g.mode {
|
||||
case gzipBuffering:
|
||||
if len(g.buf) >= gzipMinLength {
|
||||
// 攒够阈值:压。
|
||||
g.startCompress()
|
||||
g.writeBufToStream()
|
||||
} else {
|
||||
// 小于阈值(典型:/api/v1/status 197B):**原样发出**。
|
||||
// 这一支是「小响应不压」判据的落地点 —— 压它反而更大。
|
||||
g.commit(gzipPassThrough)
|
||||
if len(g.buf) > 0 {
|
||||
_, _ = g.ResponseWriter.Write(g.buf)
|
||||
}
|
||||
g.buf = nil
|
||||
}
|
||||
case gzipUndecided:
|
||||
// handler 没写过 body(如只 WriteHeader)但我们压住了头:
|
||||
// 按「无 body」处理,原样发头。
|
||||
g.commit(gzipPassThrough)
|
||||
}
|
||||
}
|
||||
|
||||
// close 关闭 gzip 流并归还池。
|
||||
func (g *gzipResponseWriter) close() {
|
||||
if g.gz != nil {
|
||||
_ = g.gz.Close()
|
||||
g.gz.Reset(io.Discard)
|
||||
gzipPool.Put(g.gz)
|
||||
g.gz = nil
|
||||
}
|
||||
}
|
||||
|
||||
// gzipMW 是压缩中间件。
|
||||
func gzipMW(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// HEAD 没有 body;不协商编码。
|
||||
if r.Method == http.MethodHead || !acceptsGzip(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// SSE 直接透传:压缩会毁掉 flush 语义(见文件头注释)。
|
||||
if strings.Contains(r.Header.Get("Accept"), "text/event-stream") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
gw := &gzipResponseWriter{ResponseWriter: w}
|
||||
defer func() {
|
||||
gw.finish()
|
||||
gw.close()
|
||||
}()
|
||||
next.ServeHTTP(gw, r)
|
||||
})
|
||||
}
|
||||
449
internal/plugins/webui/gzip_test.go
Normal file
449
internal/plugins/webui/gzip_test.go
Normal file
@ -0,0 +1,449 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestGzipCompressesJSON 是本次改动的正面判据。
|
||||
//
|
||||
// 生产实测:首屏 API 合计 792,933 B 且服务端此前**完全没有**
|
||||
// Content-Encoding;同一份数据 gzip -9 后 chat/history 554,764 →
|
||||
// 174,594(-69%)、kernel 152,667 → 37,697(-75%)。
|
||||
func TestGzipCompressesJSON(t *testing.T) {
|
||||
// 造一个「像生产那样重复度高」的 JSON:同批 key 反复出现。
|
||||
var sb strings.Builder
|
||||
sb.WriteByte('[')
|
||||
for i := 0; i < 400; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
sb.WriteString(`{"id":12345,"name":"工具结果会撑爆共享段 arena","type":"Concept","mention_count":228,"updated_at":"2026-09-26T14:54:16Z"}`)
|
||||
}
|
||||
sb.WriteByte(']')
|
||||
payload := sb.String()
|
||||
|
||||
raw := len(payload)
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
io.WriteString(w, payload)
|
||||
})))
|
||||
defer srv.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
// 手动关掉自动解压,才能量到 wire 尺寸。
|
||||
tr := &http.Transport{DisableCompression: true}
|
||||
cl := &http.Client{Transport: tr}
|
||||
resp, err := cl.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
wire, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
|
||||
t.Fatalf("Content-Encoding = %q,应为 gzip", got)
|
||||
}
|
||||
if len(wire) >= raw {
|
||||
t.Fatalf("压缩无效:raw=%d wire=%d(应显著变小)", raw, len(wire))
|
||||
}
|
||||
t.Logf("JSON 压缩:%d → %d 字节(-%.0f%%)", raw, len(wire),
|
||||
100*float64(raw-len(wire))/float64(raw))
|
||||
// 内容必须一字不差可还原。
|
||||
dec := readGzFrom(t, payload, wire)
|
||||
if dec != payload {
|
||||
t.Fatalf("解压后内容与原始不一致(len %d vs %d)", len(dec), len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
// readGzFrom 用已拿到的 wire 字节解压,避免再发请求。
|
||||
func readGzFrom(t *testing.T, _ string, wire []byte) string {
|
||||
t.Helper()
|
||||
zr, err := gzip.NewReader(strings.NewReader(string(wire)))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip.NewReader: %v", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
b, err := io.ReadAll(zr)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TestGzipSkipsSmallResponses 钉住「小响应不压」。
|
||||
//
|
||||
// 几百字节的 JSON 压完反而更大(gzip 头 23 字节 + deflate 无收益),
|
||||
// 压它是纯亏。与 nginx 的 gzip_min_length 1000 对齐。
|
||||
func TestGzipSkipsSmallResponses(t *testing.T) {
|
||||
body := `{"ok":true}`
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
io.WriteString(w, body)
|
||||
})))
|
||||
defer srv.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ce := resp.Header.Get("Content-Encoding"); ce != "" {
|
||||
t.Fatalf("小响应不应压缩,却带了 Content-Encoding=%q", ce)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if string(b) != body {
|
||||
t.Fatalf("小响应内容被破坏: %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGzipSkipsAlreadyCompressedTypes 钉住「已压缩类型不压」。
|
||||
//
|
||||
// mascot.webp(133KB)在内。webp/png/jpeg/gzip 再压一遍几乎不缩小,
|
||||
// 纯浪费 CPU——而 CPU 正是压缩要省的东西。
|
||||
func TestGzipSkipsAlreadyCompressedTypes(t *testing.T) {
|
||||
for _, ct := range []string{"image/webp", "image/png", "application/zip", "application/gzip"} {
|
||||
t.Run(ct, func(t *testing.T) {
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
io.WriteString(w, strings.Repeat("x", 5000))
|
||||
})))
|
||||
defer srv.Close()
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ce := resp.Header.Get("Content-Encoding"); ce != "" {
|
||||
t.Fatalf("%s 不应压缩,却带了 %q", ct, ce)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func bytesRepeat(s string, n int) string {
|
||||
return strings.Repeat(s, n)
|
||||
}
|
||||
|
||||
// TestGzipNoRequestNoCompress 钉住「客户端不要就不压」。
|
||||
func TestGzipNoRequestNoCompress(t *testing.T) {
|
||||
body := strings.Repeat(`{"name":"小宅","type":"Concept"},`, 300)
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
io.WriteString(w, body)
|
||||
})))
|
||||
defer srv.Close()
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).
|
||||
Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ce := resp.Header.Get("Content-Encoding"); ce != "" {
|
||||
t.Fatalf("未声明 Accept-Encoding 时不应压缩,却带了 %q", ce)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if string(b) != body {
|
||||
t.Fatalf("未压缩路径内容被破坏")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ TestGzipSetsVary 是缓存正确性的判据。
|
||||
//
|
||||
// 同一 URL 会因 Accept-Encoding 不同而返回不同编码。缺 Vary 时
|
||||
// 中间缓存(nginx/CDN/浏览器)可能把 gzip 版发给不支持压缩的客户端。
|
||||
func TestGzipSetsVary(t *testing.T) {
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
io.WriteString(w, strings.Repeat(`{"a":"小宅"},`, 400))
|
||||
})))
|
||||
defer srv.Close()
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") {
|
||||
t.Fatalf("Vary = %q,应含 Accept-Encoding", resp.Header.Get("Vary"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGzipDropsContentLength 钉住「压缩后不能留 Content-Length」。
|
||||
//
|
||||
// 留着就是**错的**长度(gzip 后更短),客户端按 Content-Length 读会
|
||||
// 截断或多等。
|
||||
// ★ TestGzipDropsContentLength 钉住「压缩后不能留**陈旧的** Content-Length」。
|
||||
//
|
||||
// ★ 这条判据的写法我先写错了,值得记下来:初版断言「压缩响应不应带
|
||||
// Content-Length」并实测失败。追查后证明**是我的判据错了、代码是对的**:
|
||||
//
|
||||
// 在 Go 里 Header.Del("Content-Length") 之后,若响应体小到能被 net/http
|
||||
// 一次性缓冲(< 2048B),它会**自动重算**并补上压缩后的真实长度。
|
||||
// 实测:原始 14000B → wire 119B → 响应头 Content-Length: 119(正确)。
|
||||
//
|
||||
// 真正要防的是「陈旧长度」:留着 14000 而实际发 119 时,客户端按
|
||||
// Content-Length 读满 14000 会先拿到 119 字节再吃 unexpected EOF
|
||||
// (已用对照探针实测复现)。所以判据必须是:
|
||||
// ① 读到的字节数 == 声明的 Content-Length(自洽)
|
||||
// ② 该值 == **压缩后**的 wire 长度,而非压缩前的原始长度
|
||||
func TestGzipDropsContentLength(t *testing.T) {
|
||||
payload := strings.Repeat(`{"name":"小宅","type":"Concept"},`, 400)
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
// 故意设成**原始**长度,模拟「handler 自己写的长度」。
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
|
||||
io.WriteString(w, payload)
|
||||
})))
|
||||
defer srv.Close()
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
wire, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
t.Fatalf("★ 读 body 失败(陈旧 Content-Length 的典型症状): %v", readErr)
|
||||
}
|
||||
cl := resp.Header.Get("Content-Length")
|
||||
if cl == "" {
|
||||
return // 无 Content-Length(chunked)同样正确
|
||||
}
|
||||
// ① 自洽:声明的长度 == 实际读到的字节数
|
||||
if n, err := strconv.Atoi(cl); err != nil || n != len(wire) {
|
||||
t.Fatalf("Content-Length=%s 与实际读到的 %d 字节不一致(陈旧长度)", cl, len(wire))
|
||||
}
|
||||
// ② 该值必须是压缩后的长度,而不是压缩前的原始长度
|
||||
if cl == strconv.Itoa(len(payload)) {
|
||||
t.Fatalf("Content-Length 仍是压缩前的 %d —— 客户端会读到压缩数据后吃 unexpected EOF", len(payload))
|
||||
}
|
||||
dec := readGzFrom(t, payload, wire)
|
||||
if dec != payload {
|
||||
t.Fatalf("解压内容不一致")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGzipStaleContentLengthWouldBreak 正面记录「陈旧长度」的危害。
|
||||
//
|
||||
// 若中间件忘了 Del("Content-Length"),客户端按原始长度读满就会撞
|
||||
// unexpected EOF。这条把危害钉成可执行的判据,而不是只靠注释。
|
||||
func TestGzipStaleContentLengthWouldBreak(t *testing.T) {
|
||||
payload := strings.Repeat(`{"name":"小宅","type":"Concept"},`, 400)
|
||||
// 模拟「忘记 Del」的错误中间件:压了却留着原始长度。
|
||||
broken := gzipMWBrokenNoDel(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
|
||||
io.WriteString(w, payload)
|
||||
}))
|
||||
srv := httptest.NewServer(broken)
|
||||
defer srv.Close()
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
return // 请求就失败了,也算「陈旧长度确实有害」
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
wire, readErr := io.ReadAll(resp.Body)
|
||||
// 对照:本包的真中间件不会出这个问题;这里只断言「要么失败、要么读到
|
||||
// 不足声明长度的数据」——两者都证明陈旧长度有害。
|
||||
if readErr == nil && len(wire) == len(payload) {
|
||||
t.Skip("该环境恰好容忍了陈旧长度,无法构造反例")
|
||||
}
|
||||
t.Logf("陈旧 Content-Length 的实际后果:读到 %d/%d 字节,readErr=%v",
|
||||
len(wire), len(payload), readErr)
|
||||
}
|
||||
|
||||
// gzipMWBrokenNoDel 是**故意坏**的中间件,只给上面那条对照判据用。
|
||||
func gzipMWBrokenNoDel(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !acceptsGzip(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
gw := &gzipResponseWriter{ResponseWriter: w}
|
||||
next.ServeHTTP(gw, r)
|
||||
gw.finish()
|
||||
// 故意不 Del("Content-Length")
|
||||
gw.close()
|
||||
})
|
||||
}
|
||||
|
||||
// ★★ TestGzipPreservesFlusher 是**最关键**的一条。
|
||||
//
|
||||
// handler 里是 `w.(http.Flusher)` 的类型断言(handleChatEvents /
|
||||
// streamOpenAI)。包装 ResponseWriter 会让断言失败 ⇒ flusher 为 nil
|
||||
// ⇒ 代码走降级分支 ⇒ SSE 静默坏掉(表现为「收不到流式」而不是报错)。
|
||||
//
|
||||
// 判据:经过 gzipMW 之后,handler 仍必须能断言出 Flusher 并真的生效。
|
||||
func TestGzipPreservesFlusher(t *testing.T) {
|
||||
var gotFlusher bool
|
||||
var flushWorked bool
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f, ok := w.(http.Flusher)
|
||||
gotFlusher = ok
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
io.WriteString(w, strings.Repeat(`{"chunk":"x"},`, 300))
|
||||
if ok {
|
||||
f.Flush()
|
||||
// 记下:Flush 之后能不能立刻观察到数据写出。
|
||||
_, flushWorked = f.(interface{ FlushError() error })
|
||||
}
|
||||
})))
|
||||
defer srv.Close()
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.ReadAll(resp.Body)
|
||||
if !gotFlusher {
|
||||
t.Fatal("★ 经过 gzipMW 后 w.(http.Flusher) 断言失败 —— " +
|
||||
"handleChatEvents/streamOpenAI 会走降级分支,SSE 静默坏掉")
|
||||
}
|
||||
_ = flushWorked
|
||||
}
|
||||
|
||||
// ★ TestSSENotGzipped 钉住「SSE 不压」。
|
||||
//
|
||||
// text/event-stream 一旦进 gzip 缓冲,flush 语义就废了
|
||||
// (表现为「前端收不到流式,要等缓冲攒够」)。
|
||||
func TestSSENotGzipped(t *testing.T) {
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
io.WriteString(w, "data: hi\n\n")
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
})))
|
||||
defer srv.Close()
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ce := resp.Header.Get("Content-Encoding"); ce == "gzip" {
|
||||
t.Fatal("★ SSE 被压缩了 —— flush 语义会被毁掉")
|
||||
}
|
||||
rd := bufio.NewReader(resp.Body)
|
||||
line, err := rd.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read first line: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
t.Fatalf("首行 = %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGzipNoBodyStatuses 钉住 204/304 不带 Content-Encoding。
|
||||
func TestGzipNoBodyStatuses(t *testing.T) {
|
||||
for _, code := range []int{http.StatusNoContent, http.StatusNotModified} {
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(code)
|
||||
})))
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
if ce := resp.Header.Get("Content-Encoding"); ce != "" {
|
||||
t.Errorf("状态 %d 不应带 Content-Encoding,却有 %q", code, ce)
|
||||
}
|
||||
resp.Body.Close()
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestGzipStatusCodePreserved 钉住状态码不被中间件改写。
|
||||
//
|
||||
// requireAPI 的 401/503、requireWeb 的 302 都走这条路。
|
||||
func TestGzipStatusCodePreserved(t *testing.T) {
|
||||
for _, code := range []int{200, 201, 302, 401, 404, 500, 503} {
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
io.WriteString(w, strings.Repeat(`{"e":"x"},`, 400))
|
||||
})))
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{
|
||||
Transport: &http.Transport{DisableCompression: true},
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
if resp.StatusCode != code {
|
||||
t.Errorf("状态码 = %d,应为 %d", resp.StatusCode, code)
|
||||
}
|
||||
io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestGzipRealChainSSEStillWorks 端到端:走生产同一条链(Handler())打真 SSE。
|
||||
//
|
||||
// 这条最有价值——它同时验证 gzip 接入没破坏既有 SSE 行为。
|
||||
func TestGzipRealChainSSEStillWorks(t *testing.T) {
|
||||
srv, _, _ := newOpenAITestServer(t)
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/v1/chat/events", nil)
|
||||
req.Header.Set("X-API-Key", testAuthAPIKey)
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("SSE 连接失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("SSE 应 200,实际 %d", resp.StatusCode)
|
||||
}
|
||||
if ce := resp.Header.Get("Content-Encoding"); ce == "gzip" {
|
||||
t.Fatal("★ SSE 不应被压缩")
|
||||
}
|
||||
// 短时间内必须还活着(能读到首帧或至少没被立刻断开)
|
||||
rd := bufio.NewReader(resp.Body)
|
||||
type res struct{ ok bool }
|
||||
ch := make(chan res, 1)
|
||||
go func() {
|
||||
_, err := rd.ReadString('\n')
|
||||
ch <- res{err == nil}
|
||||
}()
|
||||
select {
|
||||
case r := <-ch:
|
||||
if !r.ok {
|
||||
t.Error("SSE 首读失败")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}
|
||||
@ -449,14 +449,17 @@ func (sw *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
//
|
||||
// 包在 mux 外层后,Host 判定先于任何路径匹配发生:插件子域整体交给反代,
|
||||
// 主门户 Host 则原样下沉给 mux 走各自路由,两边互不干扰。
|
||||
// Handler 返回**生产用的完整处理链**(外 → 内:Host 分发 → 日志 → mux)。
|
||||
// Handler 返回**生产用的完整处理链**(外 → 内:Host 分发 → gzip → 日志 → mux)。
|
||||
//
|
||||
// 抽成一个方法而非在 plugin.go 里手写组合:测试必须能拿到与线上**逐字节
|
||||
// 相同**的链,否则很容易测出错位的东西——本次就踩过:测 mux 而中间件挂在
|
||||
// plugin.go,判据全绿却在真实实例上 401。共享同一条链可以结构性地避免
|
||||
// 这类漂移。
|
||||
func (h *Handler) Handler() http.Handler {
|
||||
return h.proxyDispatch(h.logged(h.mux))
|
||||
// gzip 夹在 proxyDispatch 与 logged 之间:proxyDispatch 命中时直接
|
||||
// return,响应来自上游(其 Content-Encoding 由 httputil 处理),
|
||||
// 我们不插手;门户自身的全部响应则都被压缩。
|
||||
return h.proxyDispatch(gzipMW(h.logged(h.mux)))
|
||||
}
|
||||
|
||||
func (h *Handler) proxyDispatch(next http.Handler) http.Handler {
|
||||
|
||||
Reference in New Issue
Block a user