Files
HomeAgent/internal/plugins/webui/gzip_test.go
JianFeeeee ae87f4b8b2 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>
2026-09-27 00:02:28 +08:00

450 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
}
}