Files
drouter/_dev/t-static-perf.py
liuzhuohua ed5eb4428b 1.0.9 静态资源传输层优化 + 页脚版本号 + logo 回概述
## 性能:瓶颈在传输,不在代码

先量后改。helper 冷启动 0.44s(编译 22173 行)看着像瓶颈,但
drouter-helpd 是常驻守护,请求走 Unix socket,正常路径根本不 fork;
实测各 API 已是毫秒级。app.js 语法编译 18ms、顶层执行 18ms
(393 个顶层函数 / 13401 行)—— JS 执行不是瓶颈。

真瓶颈是 app.js 690,482 字节每次重传。**决定不拆包**:JS 执行只 18ms,
拆包引入首屏 waterfall 的风险大于收益。改走三条:

1. 缓存策略按「有没有指纹」区分(不是按扩展名)
   - 带 ?v=<mtime> → public, max-age=31536000, immutable
   - 没指纹 → 仍 no-cache(否则部署后用户拿到旧文件)
   - index.html → 永远 no-store(每次都要重新注入)
2. 编码协商 br > gzip > 原文,**逐级降级**
3. 压缩结果按 (path, mtime, size, enc) 缓存

真机实测:首次 690,482 → 200,137 字节,第二次打开 0 字节(304)。

## 逐级降级不是可选项

目标机**没装 python3-brotli**(只有 C 库 libbrotli1)。最初写的
_brotli_cached 在模块缺失时 `return data, None`,而 None 是 encoding,
调用方理解为「压不动,原样输出」—— 于是 583KB 裸传,**比优化前更慢**。
改成 (None, None) 明确表示「这条路不通」,由 _encode_body 继续试 gzip。

同时修:Accept-Encoding 探测必须认 q=0(br;q=0 是「明确不要」,
子串匹配会正好发给解不开的客户端 → 满屏乱码);编码必须进缓存键
(共用键会互相覆盖);brotli 固定 q5(q11 只省 10KB 却要 1.5-3s)。

## 宽 except 把 bug 装成了特性

兜底只接 ImportError 这类**真·预期**的缺失,不接 TypeError/ValueError
(「str 传给要 bytes 的接口」也是 TypeError,接了等于没接)。
早先的 `except Exception` 吞掉了一个 NameError(漏注入 _comp_lock),
表现是压缩功能完好但从不生效,页面照常能开、只看响应头的判据完全发现不了。

## 版本号:建立单一真源 packaging/VERSION

原来版本号在 4 个构建脚本各写死一个默认值 + openapi 里硬编码。
漏改一处就产出「文件名 1.0.9、control 里 1.0.8」的包 —— apt 按
control 认版本,dpkg -l 永远显示旧版且升不上去。

index.html 的 ETag 并入版本号:它发出去的是注入后的内容,
ETag 却按原文件算,只改 VERSION 文件时 ETag 不变,客户端换到 304
继续用旧副本,页面版本号永远停在旧版。

## logo 回概述

用 <a> 而不是 <div onclick>:Tab 能聚焦、Enter 能触发、读屏会念「链接」。
已在 dash 页时不重复 go()(go 会 stopPageTimers + 重拉数据)。
CSS 显式去 text-decoration/color(UA 样式表不声明仍生效),
:focus-visible 焦点环保留(纯键盘用户唯一的路标)。

## 顺带修掉一个已发布版本的真 bug

_vpn_env 里 os.path.join('/lib/modules', ...) 被留成了 '/nonexistent'
(反向验证脚本被 SIGTERM 打断,finally 没执行,注入留在源码里),
于是 mod_file 永远为空、**need_module 整个状态从四态里消失** ——
这正是 v1.0.8 release notes 声称修好的那个 bug(PVE 的 KVM 虚机内核
明明有 wireguard.ko.xz 却报 unsupported,前端配一句写死的
「通常说明跑在精简容器里」把排查方向带偏)。

新增 _dev/t-worktree-clean.py 扫注入哨兵,放在 sync.sh 所有判据之前 ——
基线被污染时,后面每一条红都不可信。

## 判据

新增 t-static-perf.py(42 项静态)、t-encoding-runtime.py(26 项**真跑**,
覆盖 9 种 Accept-Encoding 组合、brotli 缺失降级、两种编码不互相覆盖、
压不动直出、内部错误不被吞)、t-worktree-clean.py。
三个都做了反向验证,注入后确实变红。

反向验证本身也踩了坑:判据里 len(body) 在 body 为 None 时先崩 ——
退出码非 0 但没有 FAIL 行,看着像「跑了」,实际没人知道它验出了什么。
取长度一律过 _sz() 包一层。

全量回归 54 个判据 0 失败;真机验收 22 项全通过;
前端三查 47 视图 0 异常、26 项移动端适配全过。
2026-10-04 10:27:02 +08:00

157 lines
7.6 KiB
Python
Raw Permalink 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""静态资源传输层契约:长缓存 + brotli 协商 + 版本号注入 + logo 回概述。
1.0.9 新增。这一批改动要挡住的具体回归:
1. **指纹资源没走长缓存** —— app.js 583KB(gzip 198KB / br 160KB)。
早先一律 no-cache,指纹明明注入了却每次重传。局域网无感,
走外网(cloudflared / 远程打开)就是每开一次面板多传 200KB。
2. **brotli 不可用时没回退 gzip** —— 目标机实测**没装 python3-brotli**
(只有 C 库 libbrotli1)。如果「要 br 就只发 br,失败吐原文」,
优化后在真机上退化成 583KB 裸传,比优化前更慢。
3. **两种编码共用一个缓存键** —— 后写进去的会把先写的覆盖掉,
拿到 br 的客户端可能收到 gzip 版,浏览器解不开就是满屏乱码。
4. **`br;q=0` 被当支持** —— 子串匹配会正好发给「明确不要 br」的客户端。
5. **版本号没有单一真源** —— 早先 4 个构建脚本各写死一个默认值。
6. **index.html 的 ETag 与实际发出的内容不一致** —— 它发的是注入后的
产物,ETag 却按原文件算;只改 VERSION 时 ETag 不变,客户端换到 304
就继续用旧副本,页面版本号永远停在旧版。
"""
import ast
import io
import os
import re
import sys
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
fails = 0
def chk(label, cond, extra=''):
global fails
if not cond:
fails += 1
print('[%s] %s %s' % ('OK' if cond else 'FAIL', label, extra))
WEB = io.open(os.path.join(ROOT, 'backend/drouter-web.py'),
encoding='utf-8').read()
INDEX = io.open(os.path.join(ROOT, 'web/index.html'),
encoding='utf-8').read()
APPJS = io.open(os.path.join(ROOT, 'web/app.js'),
encoding='utf-8').read()
CSS = io.open(os.path.join(ROOT, 'web/app.css'), encoding='utf-8').read()
# ---- 1. 长缓存按「有没有指纹」区分 ----
chk('按有没有指纹区分缓存策略(不是按扩展名)',
"fingerprinted = 'v=' in (self.path or '')" in WEB)
chk('带指纹的静态资源走 immutable 长缓存',
'max-age=31536000, immutable' in WEB)
chk('没指纹的仍是 no-cache(否则部署后用户拿到旧文件)',
"if fingerprinted else 'no-cache'" in WEB)
chk('HTML 入口页仍是 no-store(它每次都要重新注入)',
"no-store, no-cache, must-revalidate" in WEB)
# ---- 2. brotli 逐级降级 ----
chk('有 _want_brotli 协商判断', 'def _want_brotli' in WEB)
chk('brotli 不可用时返回 (None, None) 而不是原文',
re.search(r'def _brotli_cached.*?return None, None', WEB, re.S) is not None)
chk('brotli 失败后**继续试 gzip**(逐级降级,不能吐原文)',
re.search(r'if _want_brotli.*?if enc:\s*\n\s*return body, enc.*?if _want_gzip',
WEB, re.S) is not None)
chk('编码探测集中在一个函数里(避免两处实现漂移)',
'def _accepts(headers, token)' in WEB)
chk('Accept-Encoding 识别 q=0(br;q=0 是「不要 br」)',
"q.startswith('q=')" in WEB)
chk('brotli 质量档有定值(不是默认 q11,首屏等不起)',
'BROTLI_QUALITY = ' in WEB)
# ---- 3. 两种编码不能共用缓存键 ----
chk('编码进缓存键', re.search(r'key = \(path, int\(st\.st_mtime\), st\.st_size, enc\)',
WEB) is not None)
chk('只有一个压缩缓存表', WEB.count('_comp_cache = {}') == 1
and '_gz_cache' not in WEB and '_br_cache' not in WEB)
# ---- 4. 压缩协商的其余契约 ----
chk('压缩后必须发 Vary: Accept-Encoding',
WEB.count("send_header('Vary', 'Accept-Encoding')") >= 2)
chk('有体积下限(小响应压了反而亏)', 'GZIP_MIN = ' in WEB)
chk('压缩比不划算时不压', 'if len(z) >= len(data)' in WEB)
# ---- 5. 版本号单一真源 ----
chk('有 packaging/VERSION 这个唯一真源',
os.path.isfile(os.path.join(ROOT, 'packaging/VERSION')))
chk('后端从 /opt/drouter/VERSION 读版本',
"os.path.join(BASE, 'VERSION')" in WEB)
chk('openapi 的 version 用 app_version() 而非硬编码',
"'version': app_version()" in WEB)
chk('版本号读取只做一次(运行期不变,缓存住)', '_VERSION_READ' in WEB)
chk('版本号格式受校验(半截写入不会显示到页面上)',
'VERSION_FALLBACK' in WEB and re.search(r"re\.match\(r'\^", WEB) is not None)
for p in ('packaging/build-deb.sh', 'packaging/build-docker.sh',
'packaging/build-offline-bundle.sh', '_dev/build-image-remote.sh'):
s = io.open(os.path.join(ROOT, p), encoding='utf-8').read()
chk('%s 从 VERSION 文件取版本' % os.path.basename(p),
'packaging/VERSION' in s and not re.search(r'\$\{1:-1\.\d', s))
# 每一份「装到运行目录」的清单都要带上 VERSION ——
# build-deb.sh(通配 backend/*.py) 不会自动带上它,漏了不报错只是版本号不更新。
chk('build-deb.sh 把 VERSION 装进包',
'$STAGE/opt/drouter/VERSION' in
io.open(os.path.join(ROOT, 'packaging/build-deb.sh'),
encoding='utf-8').read())
chk('deploy.sh 把 VERSION 装到 /opt/drouter',
'$OPT/VERSION' in io.open(os.path.join(ROOT, 'scripts/deploy.sh'),
encoding='utf-8').read())
chk('Dockerfile 把 VERSION 拷进镜像',
'packaging/VERSION /opt/drouter/VERSION' in
io.open(os.path.join(ROOT, 'packaging/docker/Dockerfile'),
encoding='utf-8').read())
chk('sync.sh 打包时带上 packaging/VERSION',
'packaging/VERSION' in io.open(os.path.join(ROOT, 'devtools/sync.sh'),
encoding='utf-8').read())
# ---- 6. index.html 的 ETag 必须覆盖注入后的内容 ----
chk('index.html 的 ETag 并入版本号',
re.search(r"endswith\('index\.html'\):\s*\n\s*etag = .*app_version\(\)",
WEB) is not None)
chk('页脚有版本号占位符', '__APP_VERSION__' in INDEX and 'id="app-ver"' in INDEX)
chk('注入函数替换掉占位符', "'__APP_VERSION__', app_version()" in WEB)
# ---- 7. logo 回概述页 ----
chk('左上角 logo 是 <a>(键盘/读屏可用,不是 div onclick)',
re.search(r'<a class="brand[^"]*"[^>]*id="brand-home"', INDEX) is not None)
chk('logo 有可访问名称', 'title="返回系统概览"' in INDEX)
chk('logo 点击已绑定', "brandHome.addEventListener('click'" in APPJS)
chk('点击时 preventDefault(否则 #dash 会跳到地址栏)',
re.search(r"addEventListener\('click', e => \{\s*\n\s*e\.preventDefault\(\)",
APPJS) is not None)
chk('已在概述页时不重复 go()(go 会 stopPageTimers 再重拉数据)',
"if (S.page === 'dash') return;" in APPJS)
chk('logo 点击会收起手机端抽屉', re.search(
r"addEventListener\('click', e => \{.*?closeDrawer\(\)", APPJS, re.S) is not None)
chk('CSS 显式去掉 <a> 默认下划线/颜色',
'.brand-home{text-decoration:none' in CSS)
chk('CSS 保留焦点环(键盘用户的唯一路标)', ':focus-visible' in CSS)
chk('侧栏收起时 logo 仍居中', '#app.side-collapsed .brand-home' in CSS)
# ---- 8. 前端语法没被改坏 ----
try:
import subprocess
node = subprocess.run(['node', '--check', os.path.join(ROOT, 'web/app.js')],
capture_output=True)
chk('app.js 语法正确', node.returncode == 0,
node.stderr.decode('utf-8', 'replace')[:200])
except Exception as e:
chk('app.js 语法正确(跳过:%s)' % e, True)
try:
ast.parse(WEB)
chk('drouter-web.py 语法正确', True)
except SyntaxError as e:
chk('drouter-web.py 语法正确', False, str(e))
print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails))
sys.exit(1 if fails else 0)