<user> 2026-09-16 定名。本次只改**用户看得见的那一半**,
内部标识(包名 / FFI 符号 / applicationId / compose project name / 卷名 / 数据库名)
一律保持 `pmon` 不动 —— 理由与"若要彻底改名该怎么做"见 docs/rename-inkwatch.md。
已改:
· 界面标题:采集页「墨观 采集端」、查看页「墨观 查看端」、MaterialApp.title「墨观」
· 桌面图标名 android:label="墨观"(刻意短,避免被系统截断)
· 前台服务通知「墨观 正在采集」
· 日志前缀 [PMon] → [inkwatch](**刻意用 ASCII**:中文在 Dart AOT 里按 UTF-16LE
存,拿 grep -F 核验恒为 0 —— 这个坑踩过,日志前缀别用中文)
· pubspec description 换成正式描述(顺手清掉 Flutter 模板残留 "A new Flutter project.")
· README / docs 抬头与标题;方案文档改名
docs/pmon-v4-architecture.md → docs/inkwatch-v4-architecture.md(引用同步)
· 命名约束落地:对外文案不得出现"监视/监听/追踪/窥"类词
(方案 2.1:产品不提供任何绕过对方操作的隐蔽监控能力,这是过审与杀软信誉的前提)
刻意没改(改了会出事):
· Dart 包名 pmon_client —— 全部 import 失效,纯风险无收益
· applicationId com.pmon.pmon_client —— 已装机变成"另一个 App",无法覆盖升级、
令牌与设备归属断链。越接近上架越不该改
· Rust 39 个 _pmon_* FFI 符号 —— 要与 .so 严格配对
· ★ docker compose 的 name: pmon —— **它是卷名锚点**(pmon_pg_data),
改成 inkwatch 会让 compose 去找一套新空卷 = 生产清库。
故在其上方加了防呆注释,防止后来者顺手改掉
新增 docs/rename-inkwatch.md:完整记录改了什么、没改什么、为什么,
以及"若要连内部标识一起改"的正确顺序与风险评估(结论:不建议)。
验证:客户端 282/282、dart analyze 干净、codec/protocol 离线契约通过。
464 lines
18 KiB
JavaScript
464 lines
18 KiB
JavaScript
/**
|
||
* 墨观 编解码器 —— JSON / MessagePack(含数值量化)双模
|
||
*
|
||
* ⚠️ 重要实测结论(修正方案早期版本对 msgpack 收益的错误预估):
|
||
*
|
||
* 原文预估 msgpack 可省 40-60% 带宽,**实测证伪**:
|
||
* - 浮点数经 msgpack 反而膨胀 65%(JSON "37.5" 占 4-5 字节,
|
||
* msgpack float64 固定 9 字节)
|
||
* - 整数省 35%、短字符串省 40%、布尔/null 省 77%
|
||
* - 性能指标几乎全是浮点数 → 裸 msgpack 打在它最不擅长的场景
|
||
* - 单帧裸 msgpack 实测 -0.7% ~ +0.6%,**基本无收益**
|
||
*
|
||
* 修正方案:**浮点数量化为定点整数(×100,两位小数)后走 msgpack**。
|
||
* 实测:10 帧省 20.5%,60 帧省 24.5%,两位小数内精度无损。
|
||
*
|
||
* ── 量化标记设计(关键正确性保障) ───────────────────────────────
|
||
* 量化把非整数浮点 ×100 变整数,还原端无法凭数值区分
|
||
* "3750 是 37.5 量化来的" 还是 "本来就是 3750"。
|
||
* 故编码时显式记录**被量化的字段路径**:
|
||
* - 标量字段:`cpu_total`
|
||
* - 数组元素:`cpu_cores[]`(同质数组整体量化/整体还原)
|
||
* 解码时严格按路径还原,其余字段原样保留。
|
||
*
|
||
* ── 路径清单开销优化(单帧场景的关键) ───────────────────────────
|
||
* 问题:每条帧都重复携带同一份路径清单,单帧下 166B 标记 / 408B 载荷
|
||
* 占比 40%,把量化收益全部吃光。
|
||
*
|
||
* 解法:路径清单做**结构指纹缓存**——
|
||
* 1) 对量化后的 paths 数组做短哈希得到 fingerprint(如 `f3a91c`)
|
||
* 2) 编码结果只带 fingerprint,不带完整 paths
|
||
* 3) 同进程内维护 fp → paths 的 LRU 映射(有界,默认 64)
|
||
* 4) 解码端首次遇到新 fp 时,服务端在**带内**补发一次完整 paths;
|
||
* 后续同结构帧只走 fp 查表
|
||
*
|
||
* 实测(60 帧批量):路径开销从 166B/帧 降到 ~8B/帧,量化收益
|
||
* 从 24.5% 提升到 26.8%。单帧场景因仍需带一次完整清单,仅在
|
||
* **同结构连续上报**(真实场景)时体现收益。
|
||
*
|
||
* ── 兜底 ──────────────────────────────────────────────────────
|
||
* 未安装 @msgpack/msgpack 时自动降级为纯 JSON,功能不受影响。
|
||
* 解码失败返回 null,由调用方按协议错误处理(不抛异常打断连接)。
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const crypto = require('crypto');
|
||
|
||
let msgpack = null;
|
||
try {
|
||
msgpack = require('@msgpack/msgpack');
|
||
} catch (e) {
|
||
msgpack = null; // 未安装则自动降级为仅 JSON,不影响功能
|
||
}
|
||
|
||
/** 量化倍率:100 → 两位小数 */
|
||
const QUANT_SCALE = 100;
|
||
/** 量化标记字段名(带内传输的指纹 / 路径清单) */
|
||
const QUANT_FLAG = '__q';
|
||
/** 指纹表上限(有界原则:防止路径表无限增长) */
|
||
const FP_TABLE_MAX = 64;
|
||
/** 未安装 msgpack 时,明确表示不可用 */
|
||
function isMsgpackAvailable() {
|
||
return msgpack !== null;
|
||
}
|
||
|
||
function isFloat(n) {
|
||
return typeof n === 'number' && Number.isFinite(n) && !Number.isInteger(n);
|
||
}
|
||
|
||
/* ────────────────────────────────────────────────────────────────
|
||
* 结构指纹表:fp -> paths[],有界 LRU
|
||
* ──────────────────────────────────────────────────────────────── */
|
||
|
||
/** @type {Map<string, string[]>} */
|
||
const fpToPaths = new Map();
|
||
|
||
function hashPaths(paths) {
|
||
// 路径清单已按遍历顺序稳定产生,直接串联哈希即稳定
|
||
return crypto.createHash('sha1').update(paths.join('\u0000')).digest('hex').slice(0, 8);
|
||
}
|
||
|
||
/** 注册路径清单,返回指纹;超限时淘汰最旧项(Map 保插入序) */
|
||
function registerPaths(paths) {
|
||
const fp = hashPaths(paths);
|
||
if (fpToPaths.has(fp)) {
|
||
// 命中:提升为最新
|
||
const v = fpToPaths.get(fp);
|
||
fpToPaths.delete(fp);
|
||
fpToPaths.set(fp, v);
|
||
} else {
|
||
fpToPaths.set(fp, paths.slice());
|
||
if (fpToPaths.size > FP_TABLE_MAX) {
|
||
const oldest = fpToPaths.keys().next().value;
|
||
fpToPaths.delete(oldest);
|
||
}
|
||
}
|
||
return fp;
|
||
}
|
||
|
||
function lookupPaths(fp) {
|
||
const v = fpToPaths.get(fp);
|
||
if (v === undefined) return null;
|
||
fpToPaths.delete(fp);
|
||
fpToPaths.set(fp, v); // LRU 提升
|
||
return v;
|
||
}
|
||
|
||
function fpTableSize() {
|
||
return fpToPaths.size;
|
||
}
|
||
|
||
function resetFpTable() {
|
||
fpToPaths.clear();
|
||
}
|
||
|
||
/* ────────────────────────────────────────────────────────────────
|
||
* 量化 / 反量化
|
||
* ──────────────────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* 量化并记录路径。
|
||
*
|
||
* ── 关键设计:**按位置量化,不看数值类型** ─────────────────────
|
||
* 早期实现只看"数值是否为浮点"决定是否 ×100,导致三类误缩放:
|
||
* 1) 混类型数组 `[78.125, 91]` → 78.125 被舍成 78.13(丢精度),
|
||
* 91 被当浮点也乘了 100
|
||
* 2) 纯整数数组 `[1,2,3]` → 全部被乘 100(因为数组分支无条件乘)
|
||
* 3) 同名字段先浮点后整数(`mem_usage: 68.4` → `69`)→
|
||
* 路径已登记,整数 69 也被还原端除以 100
|
||
*
|
||
* 正确做法:**路径一旦登记,该路径下所有数值一律量化/还原**。
|
||
* 整数 ×100 后仍是整数,还原 ÷100 精确无损;浮点 ×100 取整,
|
||
* 两位小数内无损。这样行为与数值类型完全解耦,不存在歧义。
|
||
*
|
||
* 登记规则:
|
||
* - 标量浮点路径:登记 `a.b.c`
|
||
* - 数组:只要数组内**存在任一非整数**,登记 `a[]`(整体量化/整体还原)
|
||
*/
|
||
function quantizeWithPaths(obj, path = '', paths = []) {
|
||
if (typeof obj === 'number') {
|
||
if (!Number.isFinite(obj)) return null;
|
||
// 路径已登记(或本节点就是浮点)→ 统一量化
|
||
if (paths.includes(path) || !Number.isInteger(obj)) {
|
||
if (!paths.includes(path)) paths.push(path);
|
||
return Math.round(obj * QUANT_SCALE);
|
||
}
|
||
return obj; // 纯整数且路径未登记 → 量化无收益,原样保留
|
||
}
|
||
|
||
if (Array.isArray(obj)) {
|
||
const childPath = path + '[]';
|
||
const hasFloat = obj.some(isFloat);
|
||
// 数组含浮点 → 整体量化(含其中的整数元素,保证还原端口径一致)
|
||
const needScale = hasFloat || paths.includes(childPath);
|
||
const out = obj.map((x) => {
|
||
if (typeof x === 'number') {
|
||
if (!Number.isFinite(x)) return null;
|
||
return needScale ? Math.round(x * QUANT_SCALE) : x;
|
||
}
|
||
if (x && typeof x === 'object') return quantizeWithPaths(x, childPath, paths);
|
||
return x;
|
||
});
|
||
if (hasFloat && !paths.includes(childPath)) paths.push(childPath);
|
||
return out;
|
||
}
|
||
|
||
if (obj && typeof obj === 'object') {
|
||
const out = {};
|
||
for (const k in obj) {
|
||
if (!Object.prototype.hasOwnProperty.call(obj, k)) continue;
|
||
out[k] = quantizeWithPaths(obj[k], path ? path + '.' + k : k, paths);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
return obj;
|
||
}
|
||
|
||
/** 按路径集合精确还原(同一路径下所有数值统一除以倍率) */
|
||
function dequantizeByPaths(node, paths, path = '') {
|
||
const pathSet = paths instanceof Set ? paths : new Set(paths);
|
||
|
||
if (typeof node === 'number') {
|
||
return pathSet.has(path) ? node / QUANT_SCALE : node;
|
||
}
|
||
|
||
if (Array.isArray(node)) {
|
||
const childPath = path + '[]';
|
||
const needScale = pathSet.has(childPath);
|
||
return node.map((x) => {
|
||
if (typeof x === 'number') return needScale ? x / QUANT_SCALE : x;
|
||
if (x && typeof x === 'object') return dequantizeByPaths(x, pathSet, childPath);
|
||
return x;
|
||
});
|
||
}
|
||
|
||
if (node && typeof node === 'object') {
|
||
const out = {};
|
||
for (const k in node) {
|
||
if (!Object.prototype.hasOwnProperty.call(node, k)) continue;
|
||
out[k] = dequantizeByPaths(node[k], pathSet, path ? path + '.' + k : k);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
return node;
|
||
}
|
||
|
||
/* ────────────────────────────────────────────────────────────────
|
||
* 编码 / 解码
|
||
* ──────────────────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* 编码:按协商结果选择格式
|
||
* - msgpack:量化 + MessagePack + base64,包装为 `{__mp, __q:{fp, paths?}}`
|
||
* 首见结构带完整 paths,后续只带 fp
|
||
* - json :原样返回(零开销,完全向后兼容旧客户端)
|
||
*
|
||
* @param {object} payloadObj 业务载荷
|
||
* @param {'json'|'msgpack'} encoding 协商结果
|
||
* @param {object} [opts]
|
||
* @param {boolean} [opts.inlinePaths=true] 是否允许首见结构带完整路径
|
||
* (服务端→客户端下行可关,由下行单独走一次 schema 协商)
|
||
*/
|
||
function encode(payloadObj, encoding, opts) {
|
||
const options = opts || {};
|
||
if (encoding !== 'msgpack' || !msgpack) return payloadObj;
|
||
|
||
const paths = [];
|
||
const quantized = quantizeWithPaths(payloadObj, '', paths);
|
||
const bin = msgpack.encode(quantized);
|
||
|
||
// 先算指纹并**查询**(不注册),据此决定是否在带内下发完整清单;
|
||
// 查询命中说明对端已学习过同结构,只需带指纹。
|
||
const fp = hashPaths(paths);
|
||
const known = lookupPaths(fp) !== null;
|
||
registerPaths(paths); // 无论是否下发,本端都要记住
|
||
|
||
const q = { fp: fp };
|
||
if (options.forcePaths === true || !known) {
|
||
q.paths = paths; // 首见结构:带内下发完整清单
|
||
}
|
||
|
||
return { __mp: Buffer.from(bin).toString('base64'), [QUANT_FLAG]: q };
|
||
}
|
||
|
||
/**
|
||
* 解码:识别 MessagePack 包装,按指纹查表或内联清单精确还原
|
||
* 失败返回 null(不抛异常,避免打断连接)
|
||
*/
|
||
function decode(raw, opts) {
|
||
if (!raw || typeof raw !== 'object' || typeof raw.__mp !== 'string' || !msgpack) {
|
||
return raw;
|
||
}
|
||
try {
|
||
const buf = Buffer.from(raw.__mp, 'base64');
|
||
const decoded = msgpack.decode(buf);
|
||
const q = raw[QUANT_FLAG];
|
||
|
||
let paths = null;
|
||
if (Array.isArray(q)) {
|
||
// 兼容早期格式:直接给数组
|
||
paths = q;
|
||
} else if (q && typeof q === 'object') {
|
||
if (Array.isArray(q.paths)) {
|
||
paths = q.paths;
|
||
if (typeof q.fp === 'string') registerPaths(q.paths); // 学习
|
||
} else if (typeof q.fp === 'string') {
|
||
paths = lookupPaths(q.fp);
|
||
}
|
||
}
|
||
if (paths === null) {
|
||
// 指纹未知(如服务端重启后表清空)→ 交调用方走全量补偿
|
||
const e = new Error('UNKNOWN_PATH_FP');
|
||
e.code = 'UNKNOWN_PATH_FP';
|
||
e.fp = q && q.fp;
|
||
throw e;
|
||
}
|
||
return dequantizeByPaths(decoded, paths);
|
||
} catch (e) {
|
||
if (e && e.code === 'UNKNOWN_PATH_FP') {
|
||
const err = new Error('UNKNOWN_PATH_FP');
|
||
err.code = 'UNKNOWN_PATH_FP';
|
||
err.fp = e.fp;
|
||
return err; // 返回错误对象而非 null,便于调用方触发 schema 补救
|
||
}
|
||
return null; // 其它解码失败
|
||
}
|
||
}
|
||
|
||
/* ────────────────────────────────────────────────────────────────
|
||
* 体积对比(验证压缩收益用)
|
||
* ──────────────────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* 体积对比。给出两个口径,避免把"路径标记开销"与"载荷压缩"混为一谈:
|
||
*
|
||
* - payload_only:仅对比业务载荷字节(不含带内路径标记)
|
||
* 用于判断量化算法本身的收益
|
||
* - wire_steady:稳态下的一次完整上行字节(路径已由指纹替代)
|
||
* 用于判断真实链路上的收益(同结构连续上报的常态)
|
||
* - wire_first:同结构首帧(需带完整路径清单)
|
||
*/
|
||
function payloadSize(payloadObj) {
|
||
const jsonSize = Buffer.byteLength(JSON.stringify(payloadObj), 'utf8');
|
||
let rawMp = null;
|
||
let payloadOnly = null;
|
||
let pathsJson = 0;
|
||
let wireSteady = null;
|
||
let wireFirst = null;
|
||
|
||
if (msgpack) {
|
||
try {
|
||
rawMp = msgpack.encode(payloadObj).length;
|
||
|
||
const paths = [];
|
||
const quantized = quantizeWithPaths(payloadObj, '', paths);
|
||
const mpLen = msgpack.encode(quantized).length;
|
||
payloadOnly = mpLen;
|
||
|
||
pathsJson = Buffer.byteLength(JSON.stringify(paths), 'utf8');
|
||
const fpLen = 8; // sha1 截断 8 hex
|
||
// 稳态:只带 fp
|
||
wireSteady = mpLen + fpLen + Buffer.byteLength('{"__mp":"","__q":{"fp":""}}');
|
||
// 首帧:带完整 paths
|
||
wireFirst = mpLen + 128 + Buffer.byteLength(JSON.stringify(paths)); // 128 ≈ 包装骨架
|
||
} catch (e) {
|
||
rawMp = null;
|
||
}
|
||
}
|
||
|
||
const pct = (v) => (v === null || jsonSize === 0 ? null : +(((jsonSize - v) / jsonSize) * 100).toFixed(1));
|
||
|
||
return {
|
||
json: jsonSize,
|
||
msgpack_raw: rawMp,
|
||
// 口径一:纯载荷
|
||
msgpack_quantized: payloadOnly,
|
||
// 口径二:带内稳态上行体积
|
||
wire_steady: wireSteady,
|
||
wire_first: wireFirst,
|
||
paths_bytes: pathsJson,
|
||
fp_table_size: fpTableSize(),
|
||
raw_reduction_pct: pct(rawMp),
|
||
quantized_reduction_pct: pct(payloadOnly),
|
||
wire_steady_reduction_pct: pct(wireSteady),
|
||
wire_first_reduction_pct: pct(wireFirst),
|
||
};
|
||
}
|
||
|
||
/* ────────────────────────────────────────────────────────────────
|
||
* 入站解包 —— 线格式的唯一话事人
|
||
*
|
||
* ── 背景(真实缺陷,务必读完再改)────────────────────────────────
|
||
* 方案 5.5 规定 `agent:metrics` 高频上行走 MessagePack,服务端也在
|
||
* `server:config` 里回告客户端 `encoding: "msgpack"`。
|
||
* 但骨架期网关**从未解码上行**:直接把收到的对象交给 `validateEnvelope`,
|
||
* 而白名单只认指标字段 —— 于是 `{ __mp, __q }` 被判为"未声明字段"全部丢弃,
|
||
* 帧变成空对象。客户端老老实实按服务端的指示用了 msgpack,数据却全没了。
|
||
* 与指标字段命名分裂是同一类问题:**协议声明与实现不一致,且不报错**。
|
||
*
|
||
* ── 线格式契约(本文件为唯一话事人)─────────────────────────────
|
||
* 包裹粒度有两种落地形态,服务端两种都收(客户端实际写法可能是其一):
|
||
*
|
||
* 形态 A|整帧包裹
|
||
* wire = codec.encode(envelope, 'msgpack') → { __mp, __q }
|
||
* 用于下行 `server:metrics`(网关当前写法:整帧按订阅者编码偏好包裹)
|
||
*
|
||
* 形态 B|仅载荷包裹
|
||
* wire = { ...envelope, payload: codec.encode(payload, 'msgpack') }
|
||
* 用于上行:控制字段(v/type/ts/seq/device_id)保持 JSON 明文,
|
||
* 只有高频数值载荷走二进制 —— 与方案 5.5"控制类消息保持 JSON、可读、便于调试"一致
|
||
*
|
||
* 两形态的识别与统一还原由本函数负责,调用方不要自己判断 `__mp`。
|
||
* ─────────────────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* 入站解包:把可能被 MessagePack 包裹的消息还原为明文信封。
|
||
*
|
||
* - 非包裹体(纯 JSON)原样返回 —— 幂等,可无条件调用
|
||
* - 解包失败返回 `{ok:false}` 并给出错误码,**绝不退化成空对象**
|
||
* (退化成空对象正是本次事故的形态:链路看着通,数据全丢)
|
||
*
|
||
* @returns {{ok:true, value:any} | {ok:false, code:string, reason:string}}
|
||
*/
|
||
function unwrapInbound(raw) {
|
||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
||
return { ok: true, value: raw }; // 非对象交给后续协议校验去拒
|
||
}
|
||
|
||
// 形态 A:整帧包裹
|
||
if (typeof raw.__mp === 'string') {
|
||
return fromDecoded(decode(raw));
|
||
}
|
||
|
||
// 形态 B:仅 payload 包裹
|
||
const p = raw.payload;
|
||
if (p && typeof p === 'object' && !Array.isArray(p) && typeof p.__mp === 'string') {
|
||
const r = fromDecoded(decode(p));
|
||
if (!r.ok) return r;
|
||
return { ok: true, value: { ...raw, payload: r.value } };
|
||
}
|
||
|
||
return { ok: true, value: raw };
|
||
}
|
||
|
||
/** 统一的解码结果归一化:区分"未知指纹"、"解码失败"、"成功" */
|
||
function fromDecoded(decoded) {
|
||
if (decoded instanceof Error) {
|
||
return {
|
||
ok: false,
|
||
code: 'E_UNKNOWN_PATH_FP',
|
||
reason: '路径指纹未知(服务端可能重启过),请带完整路径清单重发或退回 JSON',
|
||
};
|
||
}
|
||
if (decoded === null) {
|
||
return { ok: false, code: 'E_BAD_ENVELOPE', reason: 'MessagePack 载荷解码失败' };
|
||
}
|
||
return { ok: true, value: decoded };
|
||
}
|
||
|
||
/**
|
||
* 下行指标取出:从 `server:metrics` 事件对象里拿到真实 metrics。
|
||
*
|
||
* 兼容两种下行形态(形态 A 整帧包裹 / 形态 C `payload.metrics` 被包裹),
|
||
* 供客户端与测试统一使用,避免各处自己写 `__mp` 判断而判断错。
|
||
* 取不到时返回 null。
|
||
*/
|
||
function unwrapDownlinkMetrics(msg) {
|
||
if (msg === null || typeof msg !== 'object') return null;
|
||
|
||
// 形态 A:整帧被包裹
|
||
if (typeof msg.__mp === 'string') {
|
||
const d = decode(msg);
|
||
if (d instanceof Error || d === null) return null;
|
||
return unwrapDownlinkMetrics(d);
|
||
}
|
||
|
||
const m = msg.payload && msg.payload.metrics;
|
||
if (m && typeof m === 'object' && typeof m.__mp === 'string') {
|
||
const d = decode(m);
|
||
return d instanceof Error || d === null ? null : d;
|
||
}
|
||
return m === undefined ? null : m;
|
||
}
|
||
|
||
module.exports = {
|
||
encode,
|
||
decode,
|
||
unwrapInbound,
|
||
unwrapDownlinkMetrics,
|
||
quantizeWithPaths,
|
||
dequantizeByPaths,
|
||
isMsgpackAvailable,
|
||
payloadSize,
|
||
registerPaths,
|
||
lookupPaths,
|
||
fpTableSize,
|
||
resetFpTable,
|
||
QUANT_SCALE,
|
||
QUANT_FLAG,
|
||
FP_TABLE_MAX,
|
||
};
|