## 性能:瓶颈在传输,不在代码
先量后改。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 项移动端适配全过。
157 lines
7.6 KiB
Python
157 lines
7.6 KiB
Python
#!/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)
|