Files
inkwatch/shared/codec.js
T
PMon Dev b3db58df06 rename: 项目对外名定为「墨观 InkWatch」
<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 离线契约通过。
2026-09-16 12:09:53 +08:00

464 lines
18 KiB
JavaScript
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.
/**
* 墨观 编解码器 —— 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,
};