← 揭开黑盒:理解系统的调查课堂

综合代码解析Ⅰ:保存、状态与消息,三个系统怎样被写出来

前三个单元分别完成了保存调查、页面状态调查和 V0/V1 消息建设。这一课从实现者的角度回看它们:需求怎样变成数据结构,函数怎样划分职责,一次操作实际经过哪些代码,以及为什么选择这套设计。

本课覆盖第一至第三单元的全部实现主线,前置是完成三个单元的操作与阅读。它是一次跨单元的技术总结,之后每完成三个单元安排一次;课内的 01–18 是讲义编号,不是十八个单元。

学完后应能独立完成三件事:沿源码讲清一条执行链;指出一个设计选择及其代价;为一项小改动列出修改位置、接口约定和验收条件。建议分四次读,每次约 30–45 分钟,另留一次动手改造时间。

一、先认识我们自己写的程序

1. 文件按职责分开,操作沿调用链流动

三个实验都采用浏览器加本机 Python 服务的结构。浏览器负责表单、显示和人的操作;HTTP 入口负责接收请求、检查入口条件和组织响应;核心模块负责数据及行为规则。

保存:app.js / saveNote
        → lab.py / Handler.do_POST
        → storage.py / Store.save
        → SQLite 提交 → HTTP 响应 → 再读列表 → 显示

状态:app.js / readSummary
        → lab.py / Handler.do_GET
        → model.py / Lab.capture → Lab.deliver
        → app.js / renderSummary

消息:app.js / act
        → lab.py / Handler.do_POST
        → model.py / World.act → _prepared → _submit → queue
        → 后台 pump → _receive → 接收者的局部视图

图中的箭头表示本次操作的先后依赖。它不表示所有步骤都在同一线程,也不表示一次 HTTP 响应会等待整个箭头链结束。消息实验的 HTTP 响应在本地提交后就能返回,后面的通道交付由调度器推进。

为什么没有把全部代码写在按钮回调中?因为 HTTP、存储和协议解决的是不同问题。将 Store.save()、Lab.capture()、protocol.unpack() 分出来,可以直接给它们输入并检查返回值;更换网页也不必重新实现数据库或解析器。页面文件中的 render 函数则只处理页面状态,不负责 SQLite 事务。

这里也没有为了“分层”而建立大量接口类。课程规模下,一个函数或一个状态类已经足够。三个实验各自保留短小的 HTTP 辅助函数,是为了能够独立下载、运行和阅读;代价是这些相似代码需要分别维护。未来若三套程序统一部署、共享认证和错误格式,才值得提取公共服务层。

2. 读源码时,先辨认值、状态和副作用

后文会反复遇到这些写法:

写法在本课中的实际含义
Python 的 dict、JavaScript 的对象按字段组织一条消息、一份快照或一张证据卡
self.memory、self.buffer属于当前对象、会跨函数调用保留的状态
return note, FalsePython 返回两个值,调用者解包为记录和“是否重复”
await fetch(...)等待这次 HTTP 请求的响应;其他异步工作仍可能推进
throw / raise当前正常执行路径停止,由外层错误处理接管
with conn:在此处管理 SQLite 事务的提交或回滚,不等于关闭连接
append、SQL INSERT、修改 DOM会改变状态或外部可见结果,需要找到发生顺序

读一个函数时,依次写出:输入从哪里来,读取了什么状态,在哪一行修改状态,返回值承诺什么。后文直接按这个顺序展开。

3. 对照教材与可运行示例

从整门课下载包解压,保留 assets/ 下全部目录。本课对应课程 1.3.0,三套实验实现版本均为 1.0.0;以当前下载包中的函数为准,不用网页行号定位。

进入 assets/review-lab/,可以分别运行:

py -3 -X utf8 -B trace_save.py
py -3 -X utf8 -B trace_state.py
py -3 -X utf8 -B trace_message.py

Python 3.10+,只需标准库;macOS/Linux 把 py -3 换成 python3。这些脚本调用相邻目录的真实实现。保存示例使用临时数据库和空闲端口,消息示例使用独立模型与可控时钟。每个脚本独立运行,结束时输出 PASS,不需要先启动三个原实验。

下文标明“源码节选”的块只用于对照,不能单独粘贴运行;“等价展开”帮助读懂紧凑写法;完整可运行入口始终是上述脚本及原实验。修改练习请在自己的解压副本进行。

二、第一单元:四种保存,核心区别是状态放在哪里

1. 外观一致,存储适配不同

定位 save-lab/app.js 的 readNotes():

async function readNotes(box) {
  if (box === "a") return state.memory;
  if (box === "b") return localNotes();
  return (await api(`/api/notes/${experiment}/${box}`)).notes;
}

调用者统一得到一组记录,不必知道读取过程是否经过网络。async 函数将返回值包装为 Promise,所以读取 A、B 时也能沿用调用处的 await。这种一致的返回形状,让四张样本卡共用 renderNotes(),同时把保存位置的差异集中在读写函数中。

样本实际保存处保存时改变什么生命周期与共享范围
A当前网页的 state.memory给 JavaScript 状态换一份数组页面重新加载后重新初始化
B浏览器 localStorage对当前键写入 JSON 字符串同源、同浏览器配置环境内可读取;可被清理
CPython Store.memory按实验编号保存到进程内字典服务进程活着时,多窗口可经 HTTP 读到
Dnotes.sqlite3向 SQLite 表插入并提交记录使用相同数据库文件重新打开后可读取

localStorage 按来源区分协议、主机与端口,实验又把 experiment 放进键名。换端口或实验编号会读到另一处位置,不等于刚才的写入消失。服务器的 Store.memory 则属于 Python 进程,和浏览器自己的内存不是同一块空间。

定位 saveNote() 中 A、B 的写入节选:

const notes = box === "a" ? state.memory : localNotes();
if (notes.length >= 100) throw new Error("此实验最多 100 条记录,请开启新实验或重置样本");
const next = [...notes, {id: request.operation, text: request.text, created_at: new Date().toISOString()}];
if (box === "a") state.memory = next;
else localStorage.setItem(key("sample-b"), JSON.stringify(next));
renderNotes(box, next);

[...notes, 新记录] 新建外层数组,保留原有记录并追加一条。它不是深拷贝,原有记录对象仍被共享;这里没有同时修改那些旧对象,因此足够。B 的 JSON.stringify() 是把数组转成可保存的文本,读取时用 JSON.parse() 还原结构。它没有提供跨设备同步,也没有提供两个标签页之间的原子合并。

设计代价也在这里:两个 B 页面都先读旧数组,再各自写回,后写的一方可能覆盖另一方的追加。这个样本用于讲存储范围,尚不是多人笔记系统。要支持并发编辑,需要重新设计写入协议或使用合适的事务存储,不能只把数组复制得更深。

2. HTTP 成功、保存确认和页面更新是三个步骤

app.js/api() 先 fetch,再读取响应 JSON,最后检查 response.ok。浏览器的 fetch 收到 HTTP 503 时通常仍会返回 Response,代码必须显式把错误状态转成异常。

保存到 C、D 后,还要发一次读取请求刷新列表。因此存在一种合法状态:服务已经确认保存,但随后的列表读取失败。 对应源码把第二次失败放在内部 try/catch 中,显示“服务已确认保存,但列表读取失败”,不会抹掉已经取得的保存确认。

这项设计保留了两个不同事实:写入请求的结果和界面刷新请求的结果。若把它们全部包在同一个含糊的“保存失败”提示中,界面就会诱导用户重新创建操作,增加重复写入的机会。

三、第一单元:为什么重试需要操作编号、事务和冲突检查

1. 操作编号由发起方创建,并在重试时保留

定位 save-lab/app.js/saveNote()。请求选择这一行是关键:

request = retry ? state.pending[box] : {text: checkText(view.input.value), operation: crypto.randomUUID()};

新操作生成新编号;重试取回上一次的整份请求。保存到 C、D 之前,代码把请求放进 state.pending[box];取得成功确认后才删除。这样“再试同一次”与“再新增一条”在接口上可以区分。

pending 当前只在页面内存中,刷新会丢失这份待确认请求。按钮禁用也只防当前页面重复点击。这两个限制都说明:客户端保护有作用,服务端仍必须自行维护编号与内容约定。

2. D 的唯一性由业务键和数据库共同表达

定位 save-lab/storage.py/Store.__init__(),表结构的核心是:

CREATE TABLE IF NOT EXISTS notes (
  experiment TEXT NOT NULL,
  id TEXT NOT NULL,
  text TEXT NOT NULL,
  created_at TEXT NOT NULL,
  PRIMARY KEY(experiment,id)
)

这是源码中 SQL 的排版展开。PRIMARY KEY(experiment,id) 表达“同一实验里,一个操作编号最多对应一条记录”。不同实验可以复用同一个编号,因为它们不是同一个保存上下文。

定位 Store.save() 的 D 分支,查找已有记录的节选:

existing = conn.execute("SELECT id,text,created_at FROM notes WHERE experiment=? AND id=?", (experiment, operation)).fetchone()
if existing:
    if existing["text"] != text:
        raise Conflict("同一操作编号不能改成另一段输入")
    return dict(existing), True

? 是参数占位符,输入作为值绑定,不拼进 SQL 语句。fetchone() 返回一行或 None;连接设置了 sqlite3.Row,所以能按 existing["text"] 取列。

同号同内容返回已经保存的记录,True 表示重复操作;同号不同内容抛出冲突。若不比较正文,用户可能用旧编号提交新内容,却收到一份与新输入无关的“成功”。只有在这两个分支都不成立时,才继续生成记录、检查数量并插入。

实际写入和返回位于这样的嵌套结构中。下面是控制结构示意,中间检查已省略:

with self.lock:
    with closing(self.connect()) as conn:
        with conn:
            # 查询已有编号、比较内容、检查数量;必要时提前返回或报错
            note = new_note(text, operation)
            conn.execute("INSERT INTO notes VALUES (?,?,?,?)",
                         (experiment, note["id"], note["text"], note["created_at"]))
        return note, False

这里有三种不同责任:self.lock 保护本进程里共享 Store 的请求;with conn 管理 SQLite 事务,正常退出提交、有异常时回滚;closing 最后关闭连接。新记录的 return 位于事务块外,因此调用者拿到正常结果之前,提交步骤已经完成。

每次操作建立自己的 SQLite 连接,也避免把一个默认具有线程使用限制的连接随意共享给 HTTP 工作线程。代价是每次都要打开连接;对本机小实验可接受。进程内锁不能协调另一进程的 Store,数据库主键可以阻止重复行,但这里没有完整处理多进程同时争抢插入时的所有返回语义。

3. 同一个 503 可以发生在提交前,也可以发生在提交后

定位 save-lab/lab.py/Handler.do_POST():

if fault == "before":
    return self.send(503, {"error": UNCONFIRMED})
note, duplicate = store.save(experiment, box, text, data["operation"])
if fault == "after":
    # 用相同的 503 模拟客户端没有得到成功确认;不声称模拟了真实 TCP 丢包。
    return self.send(503, {"error": UNCONFIRMED})
self.send(200 if duplicate else 201, {"note": note, "duplicate": duplicate})

before 在调用存储之前返回,after 在存储已经返回之后改变 HTTP 结果。两条路径都回 503,服务器内部状态却不同。这是有意放在接口与提交边界上的故障注入;它不会真的制造 TCP 丢包,也不会自动回滚先前成功的提交。

运行 trace_save.py,固定输入的结果应是:

故障位置首次 HTTP首次读回条数保留原编号重试最终条数
before5030201,duplicate=False1
after5031200,duplicate=True1

两种情况下,同号换内容都得到 409。示例还会在同一目录重新创建 Store 对象,得到 C=0、D=1:新对象不继承旧内存,但重新打开同一 SQLite 文件。这一步演示重新加载,不冒充真正的进程重启。

这套幂等设计的完整条件是:保留同一操作编号、限定同一实验、拒绝同号异文,并保留已经存下的操作结果。它的保证只覆盖这张表的这项写入。若以后“新增笔记”还要调用外部付款接口,当前事务不会自动让付款也只执行一次。

四、第二单元:快照、版本与并发等待怎样配合

1. 现场、数据集和快照各有自己的身份

定位 state-lab/model.py/Lab.create()。一份现场 run 持有 datasets、requests、events 和起始 scene。数据集下又区分 main 与 archive,每个有自己的 version 和 task。

字段负责区分什么不能拿来代替什么
boot_id / run_id哪次服务会话、哪次调查现场不能只凭相同任务标题复用现场
scope / task.id哪个数据范围、哪个具体任务同名任务不保证是同一对象
version同一范围内的内容版本不同范围的版本号没有统一大小关系
request_id哪一次读取请求不是数据新旧程度,也不是到达次序

Lab.update() 只有在 done 真正改变时才增加版本。版本代表本范围内容演进,因此重复设置相同值不会制造一个新的内容版本;请求事件仍然可以被记录。

2. 快照在 capture 时固定,不在 deliver 时重新查询

定位 _snapshot() 的核心节选:

data = run["datasets"][scope]
payload = {"request_id": request_id, "scope": scope, "version": data["version"],
           "task_ids": [data["task"]["id"]], "total": 1,
           "completed": int(data["task"]["done"]), "task": dict(data["task"])}
payload["capture_event"] = self._event(run, "captured", payload, provenance)
return payload

int(False) 是 0,int(True) 是 1,用来形成统计值。dict(data["task"]) 复制任务字段,避免快照里的 task 和实时任务是同一个可变字典。目前任务字段是字符串与布尔值,浅拷贝足够;若以后加入可变子列表,就需要重新考虑复制深度。

capture() 把这份 payload 存进请求记录。deliver() 交付的是保存下来的 payload 副本。这样,即使实时任务后来已经变化,旧读取仍能如实表示“读取当时的内容”。若交付时再查实时任务,就抹掉了读取时间与传输时间的区别,也无法复现旧响应晚到。

3. 等待期间释放锁,否则“放行”本身会进不来

定位 Lab.deliver() 开头,注意缩进:

with self.lock:
    request = self._run(run_id)["requests"][request_id]
    ready = request["ready"]
# 等待期间不持有状态锁,其他请求可以修改任务、读取新版本或放行旧响应。
arrived = ready.wait(timeout)
with self.lock:
    run = self._run(run_id)

threading.Event 是一个可等待的信号。挂起请求初始未设置;release() 改变请求状态并调用 ready.set();等待方随后继续。wait(timeout) 也可能超时,代码将该次读取记为 timed-out 并返回错误。

锁用于读取和修改共享字典,不应该在整个等待期间占住。如果把 ready.wait() 放在第一个锁块里,另一个线程执行 release() 时也需要这把锁,就会被阻塞;可能等到挂起请求超时以后,放行请求才获得机会。RLock 只允许同一线程重复进入,不能让另一条 HTTP 线程穿过它。

本实现确实用到了同一线程的重入:create() 持锁创建现场,末尾调用也会加锁的 inspect() 返回快照。RLock 允许这一层嵌套;若机械换成普通 Lock,就要同时重整调用关系,不能只替换锁的名字。

ThreadingHTTPServer 让不同请求有机会分别处理;“有线程”与“锁的范围正确”是两件必须同时做对的事。当前等待会占用一条工作线程,因此模型限制同时挂起的请求数。它适合少量教学请求,不是一套支持海量长连接的服务架构。

运行 trace_state.py:同一范围里,旧请求捕获 v3/未完成,新请求捕获 v4/已完成。先交付新请求,再放行旧请求,结果依次为 v4、v3;旧快照中的 task.done 仍是 False。脚本直接调用模型,页面是否采用它们要接着看前端。

4. 响应有效,不代表它适合覆盖当前显示

定位 state-lab/app.js/renderSummary()。开启保护开关后,两个判断分别处理范围和版本:

const wrongScope = payload.scope !== $("#scope").value;
const older = latestPayload && payload.scope === latestPayload.scope
  && payload.version < latestPayload.version;

这是源码表达式的换行展开。任一条件成立,函数记录 ignored 事件并提前返回。否则才把 payload 保存到 latestPayload,计算显示值并更新 DOM。

比较过程要先看范围。main/v4 与 archive/v99 不是同一数据集的先后版本,直接取数字最大的会显示错误对象。相同范围内,v4 已显示后到来的 v3 才可以据此判为旧响应。这个保护也只知道“当前已经显示的版本”,没有证明它就是服务器此刻最新的版本。

displayedCount() 是另一层:现场 beta 故意把 completed 减一并限制不低于零;开启直接显示开关才采用响应原值。这段故障用于教学,不是推荐的统计写法。把查询、响应采用和显示转换分开,才能定位“服务器值正确但页面算错”的问题。修复版本保护不会顺便修复错误算式。

还应注意当前设计只记住一份 latestPayload。如果产品要求来回切换多个范围后,各范围都保持已见的最高版本,就应改成按 scope 保存的状态表,并定义切换时的显示策略。这是新的需求,不是把现有一个 < 换成 <= 就能解决。

五、第二单元:合并证据为什么分成“预览”和“应用”

1. 纯规则模块让合并可以脱离页面运行

定位 state-lab/journal.js。这里没有操作 DOM 或 localStorage;输入是记录册、导入报告和人的选择,输出是导入计划或新的记录册。页面负责读文件和收集选择,规则模块负责判定哪些内容能够合并。

一张卡片含有 id、line、观察与解释等正文、evidence_ids 引用。原始证据放在单独的 evidence 数组中,卡片通过编号引用它。这样,多张卡可以引用同一条响应,合并时也能发现同一证据编号的内容被改动。

2. Map 负责快速定位,规则负责决定怎样处理

定位 planImport() 的卡片分类节选:

const knownCards = new Map(book.cards.map(card => [card.id, card]));
const additions = [], conflicts = []; let duplicates = 0;
for (const card of incoming.cards) {
  const previous = knownCards.get(card.id);
  if (!previous) additions.push(card);
  else if (cardValue(previous) === cardValue(card)) duplicates++;
  else conflicts.push({local: previous, incoming: card});
}

map 先把每张卡变成 [编号, 卡片],再交给 Map 建立查找表。它的用途是按 id 定位旧卡,避免对每张导入卡都重新遍历全部本地卡。随后才有三条业务分支:新编号加入;相同编号且内容相同跳过;相同编号但内容不同交给人比较。

不能只写 knownCards.set(card.id, card),那会把“同号有分歧”偷偷变成“后导入者覆盖前者”。本课堂需要保留独立解释,因此冲突是要呈现的学习材料。

分类之前还有两层校验:validateReport() 检查报告结构、数量与引用;contextKey() 检查现场、服务会话、起始场景、来源和版本一致。服务端证据必须已经存在于当前核对过的现场中,同号异文也会被拒绝。文件通过这些检查,仍不等于作者的观察和推理已经获认证。

3. 应用时还要确认:人刚才比较的仍是这份内容

定位 applyImport() 的开头节选:

if (basis(book) !== plan.base) throw new Error("预览导入后本地卡片已变化,请重新比较");
if (choices.length !== plan.conflicts.length || choices.some(choice => !["keep", "compare"].includes(choice))) throw new Error("请逐项选择如何保留冲突版本");
const next = structuredClone(book);

预览记录了一份基线 plan.base。如果人在比较过程中又编辑了本地卡片,之前的判断可能已经不适用,所以应用前重新比较基线。这是乐观并发检查:允许先阅读与编辑,到真正提交时再核对依据。

structuredClone() 复制完整记录册,后续向新册追加数据,原册保持原状。选择 compare 时,为导入版本分配新 id,并用 conflict_of 指回原卡;选择 keep 时保留本地卡,导入版本继续留在原报告文件中。它没有自动判断哪位作者正确。

basis() 使用排序后的卡片内容形成比较字符串,并排除只表示编辑时间的 changed_at。这里需要的是“相关内容是否相同”,不是最新的电脑时钟。这个 canonical 字符串用于内部比较,不是通用签名或防篡改认证格式。

已有 Node.js 22+ 时,在 review-lab 运行 node trace_journal.mjs,依次看到相同卡去重、同号异文冲突、并列保留两张卡、旧计划被拒绝。它实际加载原 journal.js,没有另外写一套合并算法。

最后别扩大这项保证:浏览器 persist() 的“先读存储、比较、再写入”并不是跨标签页原子事务。当前主要依靠独立记录册加显式文件合并工作;若要实时多人编辑,应把版本比较与提交放进能提供原子条件写入的服务或事务中。

六、第三单元:协议把“双方觉得差不多”变成可执行的约定

1. 先规定数据结构,再讨论如何变成字节

定位 message-lab/protocol.py/validate()。V1 消息共有 protocol、id、from、to、kind;data 增加 body,ack 增加 reply_to 与 stage。例如跟踪脚本使用:

MESSAGE = {"protocol": 1, "id": "a-001", "from": "A", "to": "B",
           "kind": "data", "body": "你好👋"}

这是 Python 字典形式的样例输入。线上格式是 JSON,但 json.loads() 能成功并不代表满足 V1:JSON 允许数组、数字、任意对象,而协议只接受指定字段和取值。

validate() 的字段集合检查要求 data 恰好是基础字段加 body,避免读者误以为多传一个字段就会自动得到新能力。版本严格检查 type(message.get("protocol")) is int;因为 Python 中 isinstance(True, int) 为真,而本协议不接受 true 充当版本 1。

这是一种严格的小版本协议。优点是含义明确、错误尽早暴露;代价是直接新增可选字段也会被老实现拒绝。若以后要兼容扩展,需要明确版本协商或未知字段策略,不能只在一端增加字段后假定对方会理解。

2. 编码需要经过对象、文本和字节三个层次

定位 payload_bytes() 与 pack(),源码如下:

def payload_bytes(message):
    message = validate(message)
    payload = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
    if len(payload) > MAX_PAYLOAD:
        raise ProtocolError("消息超过本版长度上限")
    return payload


def pack(message):
    payload = payload_bytes(message)
    return len(payload).to_bytes(4, "big") + payload

json.dumps() 得到 Python 字符串;ensure_ascii=False 保留中文字符而非全部转为 ASCII 转义;separators 选择紧凑排版。随后 encode("utf-8") 才产生真正要发送的 bytes。最后按编码后的实际字节数生成四字节大端头,并与正文拼接。

上述固定消息在当前参考编码器下得到:

阶段值
JSON 字符串长度74 个 Unicode 码位
UTF-8 正文长度81 字节
四字节长度头十六进制 00 00 00 51,0x51 即十进制 81
整帧长度4 + 81 = 85 字节

这里统计的是整个 JSON,不只是 body。同一对象用带缩进或 ASCII 转义的 JSON 也可能合法,但字节数会改变。因此协议规定的是编码及边界规则,不是这条消息必须永远等于 85 字节。跟踪脚本的固定数值只适用于所列输入与当前参考排版。

长度头让接收端能明确切出下一条消息;代价是必须先读到头,并对宣称的大小做检查。当前正文上限 2048 字节,批次上限 8192 字节。选择有限上限是为了让等待、内存占用和错误处理都有边界。

3. 接收端用三类结果表达三种状态

定位 unpack(),这是完整函数:

def unpack(buffer):
    if len(buffer) < 4:
        return None, buffer
    size = int.from_bytes(buffer[:4], "big")
    if not 1 <= size <= MAX_PAYLOAD:
        raise ProtocolError("长度头不在 1–2048 字节范围内")
    if len(buffer) < 4 + size:
        return None, buffer
    try:
        message = json.loads(buffer[4:4 + size].decode("utf-8"))
    except (UnicodeError, ValueError) as error:
        raise ProtocolError("完整帧的正文不是有效 UTF-8 JSON") from error
    return validate(message), buffer[4 + size:]

前两个长度检查解决“现在能读到哪一层”。不足四字节时连正文大小都不知道;读到合法头但正文未齐时,应继续等。两者都返回 None, 原buffer,不消耗已经取得的数据。

声明大小不合法,或完整正文无法解释为有效消息,是错误;程序抛出异常。完成一条消息则返回字典和尾部字节,尾部可能为空,也可能含下一帧。buffer[4:4 + size] 只切正文,buffer[4 + size:] 留给下一轮。

这个返回约定让外层不用猜测:None 表示等待,字典表示完成一条,异常表示当前输入不符合约定。不能把三者都压成空字典 {},否则外层会分不清“没有数据”和“有错误”。

为什么这里没有每次喂一点就先解码一点 UTF-8?因为一次分块可能截在多字节字符中间。当前设计先积累完整正文,再一次解码,降低接收器复杂度。代价是需要保留整条正文;它适合本版的小消息,不适合直接搬来接收无限大的媒体流。

七、第三单元:无状态解析函数怎样组成有状态接收器

1. unpack 处理一次边界,Decoder 管理多次输入

unpack(buffer) 不在内部保存“上次已经读到哪里”。真正跨调用保留状态的是 Decoder 对象中的 buffer 与 messages。

定位 Decoder.feed() 的主要循环,以下为源码节选:

while self.buffer:
    message, remaining = self.unpacker(self.buffer)
    if message is None:
        if remaining != self.buffer:
            raise ProtocolError("不完整消息不能提前消耗字节")
        break
    if not isinstance(remaining, bytes) or len(remaining) >= len(self.buffer):
        raise ProtocolError("解码一条消息后必须前进,并返回剩余字节")
    accepted = validate(message)
    decoded.append(accepted)
    self.messages.append(accepted)
    self.buffer = remaining

进入循环前,feed() 已把新块接到旧缓冲后面,并检查缓冲上限。循环每轮只问一次 unpacker:当前能否取出一条?取出后缩短 buffer,继续看剩余部分;遇到不完整帧就停止,等待下一次 feed()。

decoded 只保存本次 feed 新解析出的消息,作为返回值;self.messages 保存这个 Decoder 已经成功解析的全部消息。两份列表的用途不同,不应让调用方每次都误处理全部历史消息。

循环中两个检查约束了可替换解析器的行为:等待时不能偷吃字节,成功时必须推进位置。否则一个学生实现可能丢掉半个长度头,也可能每轮返回同一消息、却不缩短缓冲,导致死循环。这些检查保护接口契约,并不能替代对 unpacker 全部正确性的测试。

2. 一次输入块可能结束半条消息,也可能包含多条

trace_message.py 将两帧相连,总计 169 字节,再按下面的方式喂给真实 Decoder:

次数本次输入字节本次解析出的 id余下缓冲
12无2
22无4
3164a-00183
41a-0020

第二次已经读完长度头,但还没有正文。第三次既补齐第一条,又带来了第二条的大部分,所以循环先取出 a-001,再停在第二条不足的位置。第四次补齐最后一个字节后,才得到 a-002。

finish() 用来检查“调用方宣布这批输入已结束”时是否还留有不完整数据。它不会替你等待未来网络输入,也不会回滚先前解析出的消息。

因此把这批字节截去最后一个字节,会得到“已解析 1 条,仍有 83 字节不完整”。调用方必须保留这两个事实。若产品希望一批要么全成功、要么全不处理,就要在外层增加暂存、完整性验收和统一提交;当前解析器没有批次事务。

3. 替换学生实现,用的是函数接口

Decoder(unpacker=unpack) 把解析函数作为参数保存。World(codec=...) 则要求模块提供 pack 与 unpack。运行 --codec student 时,服务加载学生模块,工作台与真实发送接收路径都使用它。

这属于依赖注入:调用方声明自己需要什么能力,把具体实现从外部传进来。它让测试和课堂都能切换实现,而不用在每个业务分支里写一遍“如果学生模式……”。

代价是接口必须准确。双方要约定 bytes 类型、长度含义、等待返回值、异常类型和剩余数据;仅仅函数同名还不够。practice/check.py 因此分别做“学生发送→参考接收”和“参考发送→学生接收”,避免两端相同错误在自发自收中相互掩盖。

八、第三单元:队列、回执与角色视图为什么分开

1. 按钮发的是一次 HTTP 操作,角色传的是一封协议消息

定位 message-lab/app.js 的 envelope()、act() 和 api()。输入框文字先组成带编号的 V1 对象,然后作为 action.message 放进发给本机服务的 JSON 请求。服务解析 HTTP 正文后,再用 codec.pack() 生成角色通道中的 V1 帧。

这里有两层编码:浏览器和服务之间的 HTTP JSON,以及教学通道内部的“长度头+UTF-8 JSON”。不能把 HTTP 请求的一段字节直接当作这里讲的 TCP 包,也不能把 HTTP 200 当作另一角色收到 V1 的回执。

Handler.do_POST() 在 /act 入口取得 X-Role-Key,调用 World.act()。角色来自该密钥匹配到的服务端身份,随后 _prepared() 检查信封 from/to 是否与当前固定角色一致。只在 JSON 中写 from: "B",不会让甲变成乙。

2. act 返回本地状态,pump 推进通道

World.act() 的顺序是:找到房间与角色 → 检查截止和可见版本 → 准备动作 → 提交动作 → 返回自己的视图。

_prepared() 验证动作并构造字节;_submit() 记录发件、创建队列项。定位 _submit() 的节选:

packet = {"ticket": secrets.token_hex(5), "from": name, "to": "B" if name == "A" else "A",
          "wire": wire, "local_id": local_id, "due": self.clock() + choice["delay"], **choice}
room["queue"].append(packet)

队列项里有帧、目标、到期时刻及本次模拟故障选择。lab.py 的后台线程周期调用 world.pump();pump 找出已经到期的项,按 drop/copies 决定丢弃或调用 _receive()。这套代码实际执行传输模拟,页面不会自己凭一个计时动画添加对端来信。

self.clock 默认是 time.monotonic,用于比较经过的时长,避免把电脑日历时间的校准当成实验时间突然倒退。用于展示的时间戳是另一种用途。测试传入可控时钟,能直接推进到所需时刻,不必靠真实 sleep 猜何时送达。

RLock 保护房间、发件、队列等共享状态;操作和调度以短步骤进行。这里两个角色和通道都在同一个 Python 服务内,内存中的锁可以协调它们。将来如果拆成两台独立服务,就不能继续假定一把进程内锁能协调双方,需要新的持久化和通信协议。

3. 来信与有效回执是两个集合

定位 _receive():收到合法消息先追加到本角色 inbox。如果它是 ack,再尝试把 reply_to 和本地发件关联。实际查找语句是:

original = next((item for sent in actor["outbox"] for item in sent["messages"] if item["id"] == message["reply_to"]), None)

逐层解释就是:遍历每次本地发件 sent,再遍历该次发件解析出的每条消息 item,找到 id 等于 reply_to 的第一条;没有则得到 None。一批发件可能包含多条帧,所以需要两层遍历。

接下来的条件是:

if original and (message["stage"] != "accepted" or original["kind"] == "data"):
    actor["receipts"].append({"receipt_id": message["id"], "reply_to": message["reply_to"], "stage": message["stage"], "at": entry["at"]})

received 可以确认 data,也可以确认一封 ack;accepted 只适用于 data。找不到本地原消息,或确认阶段不适用时,来信仍留在 inbox,但只记一条 unmatched-receipt 事件,不加入有效回执集合。

这种分开保存的设计有利于排错:我们确实收到了一封东西,与它能证明哪条本地发件,是两个需要分别保留的信息。删掉无法关联的来信会丢失排查依据;把所有来信都当有效确认又会错误扩大承诺。

4. 生成回执和收到回执之间,还有一整条通道

乙点击“确认收到”时,_prepared() 从自己的 inbox 找到那条来信,创建一封新的 ack:新的 id 标识这封回执,reply_to 指向原消息,stage 说明声明层次。随后它照常被 pack、排队、调度和接收,并不直接修改甲的 receipts。

跟踪脚本在独立 World 中固定两种调度,得到:

步骤甲发件数乙收件数甲有效回执数
甲提交原信100
原信交付110
乙提交 received110
回执正常交付111
若固定丢掉该回执110

脚本是同时查看两侧的观察者演绎,用来核对代码,不是让角色取得全局知识。故障由测试构造器注入,HTTP 创建房间的接口不提供“指定第几封丢失”的入口。

手工重投原帧之后,乙收件数变成 2。源码 _receive() 目前没有按应用 id 阻止重复追加。_submit() 只拒绝“同一 id 换内容”,并不阻止“同一 id 同内容再次传输”。所以 V1 的编号负责关联,并未像保存实验那样构成接收侧幂等处理。

5. 视图白名单将内部调度与角色可知事实分开

定位 World._view()。它显式选取本角色 outbox、inbox、receipts、events、decision 等字段,复制后返回。隐藏队列、随机故障选择、另一角色决定不会顺手跟着整个 room 一起返回。

前端隐藏一块 HTML 不能达到这个效果:如果完整 room 已经送给浏览器,参与者仍能通过响应内容读到。这里的边界在服务器形成视图时建立。邀请使用临时能力密钥,限制本机实验中能读取哪一份视图;它尚不等于跨设备的完整账户、密钥更新与加密体系。

视图还带有 revision。AI 动作预览记下该版本,真正执行时再次核对;如果期间新来信改变了本地可见状态,就要求重新预览。剩余秒数在计算 revision 之后才加入,避免每过一秒就使未发生其他变化的建议失效。

revision 在这里由内容哈希生成,用于比较视图基线。普通哈希不是带密钥的消息认证码,不能独立证明消息是谁签发的。把版本比较和来源认证当成同一件事,会给后续信任设计留下缺口。

6. 页面层做展示和交互,不代替业务规则

三个实验的 HTML 提供表单和固定容器,CSS 负责布局与状态样式,JavaScript 绑定事件并更新内容。以消息实验的 el() 为例,显示用户文字时使用 textContent,因此正文里的 <标签> 会作为文字,而不会变成网页元素。

busy、禁用按钮和 try/finally 帮助避免当前页面重复操作并恢复交互状态。服务端仍要检查格式、角色和阶段,因为请求可以来自另一个窗口,甚至没有经过这个页面。界面检查改善体验,服务端检查维护真正的行为约定。

原文件中若干 JavaScript 函数写得较紧凑,阅读时可以在自己的副本中只做格式化。是否分行不应改变事件顺序、Promise 的等待关系或异常处理范围;不能在“整理代码”时顺手删除那些看起来重复的检查。

九、横向看设计:相似写法不一定提供相同保证

1. 给编号命名之前,先写清它标识什么

系统编号/基线实际用法尚未提供的能力
保存experiment + operation唯一行、同内容重试、异文冲突跨外部副作用的“恰好一次”
状态读取scope + version判断已显示的同范围新旧跨范围的全局最新状态
状态读取request_id关联捕获、发送、收到与显示仅凭编号推算发生顺序
证据合并card.id + 内容基线重复跳过、异文比较、旧计划拒绝自动判断论证正确或实时协同
消息message.id / reply_to把收到的回执关联到原消息接收侧去重、可靠送达
角色建议revision检查动作依据是否仍有效密码学身份认证

UUID、递增整数或哈希只是编号的生成方式。系统的保证来自使用这个值的判断、存储和事务安排。要评审一个“有编号所以不会重复”的设计,必须继续找到拒绝或合并重复项的实际代码。

2. 我们反复使用的五个实现原则

把变化的原因分开。 HTTP 入口处理协议交互,Store 管存储,journal 管合并,protocol 管字节约定。一个函数的名称与参数应能说明它负责哪种变化。这样改页面文案不会迫使解析器跟着改,换编解码实现也不必重写调度器。

先定义返回约定,再连接两端。 保存返回记录与 duplicate;解析返回消息与剩余字节;合并预览返回新增、重复和冲突;角色操作返回本地视图。清楚的返回形状能让调用者采取明确动作,减少靠字符串猜测状态。

在边界检查,在状态改变处维护约束。 浏览器检查空输入让人尽早得到提示;服务端再次检查维护真实入口;数据库主键、锁内比较和解析器推进检查负责各自状态约束。不要以为最外层检查过一次就可以删除所有内部约束。

复制是为了固定视角,锁是为了协调同时修改。 dict/structuredClone/deepcopy 主要解决“后来的修改是否影响这份结果”;RLock/事务主要解决“多个执行者如何接触共享状态”。深拷贝不能代替锁,锁也不能替代对外返回时的恰当复制。

把可替换部分作为依赖传入。 codec、clock、policy 让同一套业务规则可以接入学生实现、确定时间与指定故障。测试不必另写一份“理论上会这样”的模型。但这些测试入口的权限和语义不能随意扩大为课堂角色的运行时能力。

3. 为什么没有现在就提取一个“大而全底座”

三个实验确实共享 HTTP、状态与记录这些词,但核心约束不同:保存需要数据库事务;状态调查需要冻结旧读取;消息实验需要局部角色视图和不可靠队列。把它们先塞进一套通用配置,可能把关键机制藏到通用框架里。

当前选择是各自完整、核心模块可单独调用、课程目录统一组织。下一次提取公共代码应有具体触发条件,例如同一错误格式已经在多个入口反复修改,或多套服务真的需要共同的身份体系。提取时先固定接口与测试,再迁移调用者,保留每个实验独有的行为。

十、测试怎样成为设计的一部分

1. 每个层次验证它自己能够负责的承诺

层次本课程的检查方式能证明到哪里
数据与协议函数给 pack/unpack、合并函数输入边界样例这些输入下的返回、异常与状态符合约定
状态模型注入时钟、调度与独立对象指定执行顺序下的快照、回执、冲突与视图符合设计
HTTP 入口真正启动本机服务并请求请求格式、状态码、入口限制与模型接线正确
浏览器操作真实表单、观察 DOM 与网络结果页面把实际结果显示为预期状态
下载包在独立目录解压并执行教材没有依赖作者机器的隐含文件与已有数据

层次不能互相代替。模型返回 completed=1,不证明 DOM 真画了 1;页面显示绿色,也不证明数据库已经提交。测试的输入、触发点和观察点都应明确。

2. 用失败情形检验接口,而不是只验证一次成功

保存检查同号同文、同号异文,以及提交前后失去确认;快照检查旧读取晚到和范围不同;解析器检查每个截断位置、连续帧、错误长度和无效 UTF-8;消息模型检查回执真的走回程、局部视图不含隐藏信息。

trace_message.py 中把码位数 74 当作正文长度,真实 unpack 会拒绝该样例;这把“字节长度不能换成字符数”落实到具体失败。截断批次又检验另一种边界:第一条可以成功,第二条仍然不完整。

而 practice/check.py 的参考模式只证明参考实现通过。学生模式故意从未完成的函数开始;不能把修改测试条件、跳过失败检查或仅运行参考模式写成自己的实现已经完成。

3. 设计评审应能指出失败发生在哪一层

尝试给下面三类失败各标一个位置:HTTP 收到 503、Decoder 抛 ProtocolError、合并预览基线过期。它们分别需要查询保存事实、检查输入字节、重新比较本地内容;一个统一的“稍后重试”按钮无法代替这三种恢复策略。

这也是代码拆分的价值:错误发生后,能缩小检查范围,并保留已经成立的事实。课程的技术目标是形成这种可追踪的实现结构。

十一、动手改造:任选一个需求,提交一份小设计与可运行改动

先复制下载课程作为练习目录。不要直接改掉自己刚刚用来核对的参考副本。下面每个任务都给出修改点与行为验收,任选一个做完整,比只画三张没有实现的设计图更有价值。

任务 A:刷新后仍能继续“重试同一次保存”

目标:D 在提交后失去确认时,刷新页面仍保留原 operation 与原 text,并提供重试入口。

修改点主要在 save-lab/app.js:pending 的保存、载入与清除。存储键应包含实验和样本;服务重启后须先取得新的会话 token,再使用恢复的操作请求。已确认后清除待确认项,不能把已成功的历史操作永远当作未确认。

验收:使用 after 故障 → 刷新 → 恢复同一操作号 → 重试 → D 仍只有一条;换实验编号不能看见上一实验的待确认请求;同号改内容仍应由服务端拒绝。若浏览器存储写入失败,页面应说明恢复能力不可用,不能显示“已保留待确认请求”。

设计说明应回答:pending 保存在哪里,什么时候写入,什么时候删除;数据库被用户另行重置时,旧 pending 再提交会意味着什么。仅仅多加一行 localStorage.setItem 还没有完成这些约定。

任务 B:把状态页的“已见最新版本”按范围分别保存

目标:浏览器在 main 与 archive 之间切换后,仍能按各自已见版本过滤旧响应。

修改点在 state-lab/app.js/renderSummary() 和现场切换/恢复逻辑。用 Map 或对象保存每个 scope 的最新 payload;先检查当前选择范围,再与该范围的已存版本比较。新现场必须重置这份映射,避免把旧现场 v9 当成新现场 v1 的前置历史。

验收:main 已显示 v4,切到 archive 后再切回,main/v3 晚到时被忽略;archive/v1 可以作为 archive 的有效响应,不能因 main/v4 而被拒;相同范围相同版本应遵守明确的显示策略;新建现场后不继承旧现场版本。

除了运行模型跟踪,还要在浏览器中测试选择框、显示值和 ignored 记录,因为真正采用响应的逻辑就在页面层。

任务 C:给 V1 增加“当前房间内重复来信只入箱一次”

目标:同一房间中,来自同一发送方的同 id、同内容消息再次到达时,不重复加入 inbox;同 id、不同内容则留下明确拒绝记录。

修改点在 message-lab/model.py 的角色初始状态与 _receive()。保存已处理编号及对应的规范化消息内容,并与首次追加 inbox 放在同一受锁保护的处理步骤中。去重依据是消息含义,而不是 JSON 空格或字节排版;规范化方案也要纳入设计说明。

验收:正常首封入箱一次;手工重投原帧后仍为一次;同号异文明确拒绝;另建房间不错误复用旧房间记录;同一消息采用另一种合法 JSON 排版编码,仍能识别为重复。

同号异文可以在一个原始字节批次内放入两条同编号、不同正文的帧来构造,也可以在模型测试中构造入站包。普通发送路径已经会拒绝与既有发件同号异文的操作,不要为了测试接收端而删除那项发送侧检查。

还要明确重复回执如何处理,以及重复原信是否需要再次回应。若本次只实现“不重复入箱”,就按这个范围交付;它尚未实现重启后去重,也不能因为过滤重复就声称必然送达。记录索引也有内存成本,需要跟随房间结束清理。

提交内容

交付四样东西:修改的代码、一个从输入到结果的执行跟踪、正常与失败路径的运行结果、一份短设计说明。说明至少包括这四项:

  1. 新需求与保持成立的旧行为。
  2. 新状态/字段的含义、归属与生命周期。
  3. 选择这套实现的理由、考虑过的另一种方案及代价。
  4. 还未覆盖的场景,以及需要在哪一层继续扩展。

完成标准是能用自己的改动说明一项具体的技术设计。之后的新单元会继续扩展系统;每完成三个单元,再回到这样的综合解析,把新增加的代码、接口和取舍重新连成一条可读的实现主线。

需要回查概念时,可读函数与数据流、文件与 JSON、数据层与 SQLite、HTTP 服务器。返回第三单元交付与下一版需求,或回到课程目录。