同一本书,两台设备认不出彼此——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 毫秒——流式归一化与哈希。