老刘
发布于 2026-10-16 / 1 阅读

同一本书,两台设备认不出彼此——BookKey 与归一化

同一本书,两台设备认不出彼此——BookKey 与归一化

旦阅诞生记 · 第 04 篇(第二辑 · 认出同一本书)

从这一篇开始进入系列的核心:BookKey。它是旦阅整个同步体系的根——三端靠它认出"我们说的是同一本书"。这一篇讲它最基础的一层:怎么给一本内容相同、字节不同的 txt,算出同一个身份。

先看这个身份失效时的现场。手机上《诡秘之主》读到 93%,点同步,进度上传成功;打开电脑,点同步,界面提示拉回了 0 本——服务端压根不认为电脑里那本《诡秘之主》和手机里的是同一本。文件确实是同一份下载源拷出来的,书名一致、字数一致,但服务端给两台设备记的身份不一样。

一、直觉方案与它的三个坑

第一版方案非常直觉:对 txt 文件求 MD5,MD5 就是书的身份证。方向是对的——身份证要能区分不同的书,MD5 对字节的敏感性绰绰有余。问题恰恰出在"太敏感":它把文件当成了书的本体,而同一本书在不同来源下的文件字节往往不同。

差异来源 例子 后果
编码 来源 A 是 UTF-8,来源 B 是 GBK 同一个"诡"字,两种编码下的字节完全不同
行尾 来源 A 是 CRLF(Windows 惯例),来源 B 是 LF(Unix 惯例) 每个换行处都差一个字节
BOM 有的文件开头带 EF BB BF,有的没有 开头差三个字节

任何一条命中,文件字节就不同,MD5 就不同——于是明明是同一本书,两台设备却各自认为对方是另一本。同步还没开始,身份先崩了。

二、关键转身:别对文件哈希,对内容哈希

破局的想法很简单:既然差异都发生在"怎么把这本书存成文件",那就绕开文件,直接对书的内容哈希。同一本《诡秘之主》,UTF-8 版和 GBK 版解码成文本之后,字符序列是一样的;CRLF 版和 LF 版也是。所以:

先解码成文本 → 把无关紧要的表示差异抹平(归一化)→ 再按统一编码求哈希。

"抹平表示差异"这一步就是归一化,它必须是一套三端逐字节一致的确定性流程。服务端 BookKeyUtil.cs 里的参考实现不长,直接看代码:

/// <summary>
/// 正文归一化(跨端必须一致的确定性步骤):
///   1. 去掉开头的 BOM(U+FEFF);
///   2. 行尾统一为 \n(先 \r\n → \n,再 \r → \n);
///   3. 去掉首尾的空白与换行(仅 ASCII 空格 / 制表 / 换行,不含全角空格)。
/// </summary>
public static string Normalize(string text)
{
    if (string.IsNullOrEmpty(text)) return string.Empty;

    var s = text;
    if (s.Length > 0 && s[0] == '\uFEFF') s = s[1..];        // 1) BOM
    s = s.Replace("\r\n", "\n").Replace('\r', '\n');         // 2) 行尾
    return s.Trim(' ', '\t', '\n');                          // 3) 首尾裁剪
}

/// <summary>对已归一化的文本计算 BookKey。</summary>
public static string ComputeFromNormalized(string normalized, string algo = DefaultAlgo)
{
    var bytes = Encoding.UTF8.GetBytes(normalized);          // 4) 统一 UTF-8
    using var hasher = CreateHasher(algo);                   //    md5(默认)/ sha256
    return PrefixOf(algo) + Convert.ToHexString(hasher.ComputeHash(bytes)).ToLowerInvariant();
}

产物形如 md5:8f14e45fceea167a5a36dedd4bea2543。四步里每一步都有明确的"为什么",值得逐条拆开。

三、每一步为什么是它

去 BOM:元信息不是正文。 EF BB BF 是给"读文件的程序"看的标签,声明"我是 UTF-8"。它有没有,不影响书的任何一个字,所以必须在解码后第一件事丢掉。

行尾归一:顺序敏感。 先 CRLF → LF,再孤立 CR → LF。这个顺序不能反——如果先把 \r 换成 \n,CRLF 就会变成 LFLF,一个换行凭空变两个。顺序写对,CRLF、LF、老 Mac 的孤立 CR 三种行尾最后都收敛成同一个 \n 序列。

首尾裁剪:只动边界,不动正文。 网文 txt 的开头结尾常常挂着网站水印、推荐语、一排空行。这些是"文件的开头结尾",不是"书的开头结尾"。但裁剪的边界要非常克制:只裁首尾,不碰中间——正文中间的空行是作者的排版表达,删掉它们,书就真的不一样了。

裁剪集合刻意不含全角空格。 只裁 ASCII 空格、制表符、换行符。全角空格 U+3000 不在名单里,因为它在中文文本里几乎总是正文的一部分(比如段首缩进),裁它有误伤风险。

顺带一个此时埋下、后面兑现的设计:裁剪集合全是 ASCII(< 0x80),而 UTF-8 多字节字符的续字节均 ≥ 0x80——所以将来在字节流上做裁剪永远切不坏汉字。这是下一篇讲大文件流式实现的伏笔。

四、怎么保证三端真的算出同一个 key

归一化最大的工程风险不是算法难,而是三份实现悄悄长歪:桌面端 C#、手机端 UTS、服务端 C#,任何一份在某个分支上行为差一点点,key 就分叉,而且分叉不会报错——只是某天某本书忽然同步不上。

所以旦阅的一致性不靠"大家仔细点",靠三道独立的验证:

第一道:服务端自检接口。 服务端把自己的参考实现直接暴露出来,客户端把正文 POST 过去比对:

app.MapPost("/api/progress/fingerprint", (FingerprintRequest req) =>
{
    var normalized = BookKeyUtil.Normalize(req.Text ?? string.Empty);
    var algo = string.IsNullOrWhiteSpace(req.Algo) ? BookKeyUtil.DefaultAlgo : req.Algo!.Trim();
    var key = BookKeyUtil.ComputeFromNormalized(normalized, algo);
    return Results.Ok(new FingerprintResponse(key, normalized.Length, algo));
})
.WithSummary("对给定正文计算 BookKey(仅用于自检,正文不会被保存)");

注意接口语义:只返回指纹和归一化后的长度,正文不落盘——一个自检接口不能反过来变成"上传正文"的口子。桌面端的自检工具把它包成了一条命令:

# 把本地 txt 的正文 POST 给服务端,校验两端 BookKey 是否一致
dotnet run --project tools/EggBook.Verify -- fingerprint <文件> <服务端>

第二道:一份故意的"第三方实现"。 服务端仓库里有一个 Node 版参考脚本 bookkey_ref.mjs——用另一种语言、另一个人再实现一遍同一算法:

node scripts/bookkey_ref.mjs --selftest              # 先自检脚本本身
node scripts/bookkey_ref.mjs D:/books/某本书.txt      # 与手机端日志里的 key 比对

它的价值在于打破"两个实现互相抄错"的死循环:如果 C# 实现和 UTS 实现一致但都错了,一份独立写出来的 Node 实现有很大概率不一致——错就被暴露,而不是被复制。

第三道:真实文件交叉校验 + 单测。 拿 42 MB 的真实小说让三端各算一遍,三个 key 必须逐字符一致;BookKeyLineAlgorithmTests 把手机端"按行归一化"路线(手机端不把整本书读成字符串,而是逐行读、逐行归一,结果与整串算法等价)与服务端口径的等价性锁进单元测试。全站 126 项测试里,BookKey 相关的等价性占了相当大比重。

三道验证共同守护的其实是一句话:

归一化是契约,不是算法。 算法写错可以修,契约分歧只能靠约定——所以验证的目标是"约定被三端同时遵守",而不是"代码看起来对"。

五、几个顺手的细节决策

hashAlgo 字段是留的后路。 BookKey 带算法前缀(md5: / sha256:),进度记录里也存了这个字段。将来哪天想换算法,改前缀和实现就行,表结构不用动——身份体系设计时就要假设"算法会过时"。

MD5 在这里是指纹,不是门锁。 它防的是"字节不同",不防"有人伪造内容"。个人书库里偶然碰撞的概率可以忽略;真介意,hashAlgo 换成 sha256 就好,这也是留后路的另一半意义。

算一次,永不重算。 BookKey 在导入时计算一次,写进书籍记录缓存;之后打开书直接用缓存。这个时机选得很省:导入流程本来就要把整个文件读一遍做编码探测、转存 UTF-8(手机端把源文件统一转存成 UTF-8 放进私有目录,从此打开书不再需要探测编码),BookKey 只是顺路多喂了一遍已经转好的数据。

六、归一化不解决的场景

公平起见,也要说清楚它不管什么:归一化抹平的是"表示差异",不是"内容差异"。同一本书的精校版和原版,字数就是不一样——归一化之后 key 依然不同,服务端会诚实地把它们当成两本书。

我认为这是特性而不是缺陷:精校版的第三章和原版的第三章,字符数不同,"读到 93%"这个进度在两本书之间本来就不该互换。身份系统宁可严格,不可暧昧。

七、小结

这一篇解决的是"同一本书"的定义问题:

  • 文件 MD5 把"文件的偶然"当成了"书的本质",三个来源差异就足以击穿;
  • 正确的锚点是归一化后的正文:去 BOM、行尾归 LF、首尾裁剪、UTF-8 MD5,四步全部确定性、三端逐字节一致;
  • 一致性靠三道独立验证(自检接口、第三方实现、真实文件交叉校验)守护,而不是靠细心。

下一篇解决它带出的第一个性能问题:归一化要把整本书读进来,100 MB 的 txt 在手机上怎么算?答案是把 60 秒压到 424 毫秒——流式归一化与哈希。