第 19 章:架构复盘——从 Zed 提炼出的十条方法
最值得借鉴的不是“也用 Rust”或“也写一个 GPUI”,而是每一类变化在哪里拥有身份、如何跨异步边界、 怎样投影到 UI,以及失败后从哪一层恢复。
1. 用 Entity 建模有身份的长期状态
Buffer、Project、Workspace、Thread 等对象需要被多方引用、订阅和异步更新。Entity 把 identity、context、 event 与生命周期绑在一起;普通不可变值仍使用 struct/snapshot,不把一切都塞进响应式对象。
判断标准:如果对象需要跨帧存在、被多个组件观察、在 App context 中原子更新,它适合 Entity;一次纯计算的 输入输出不适合。
2. 把可变真相和只读投影分开
Buffer 是文本真相;MultiBuffer 是多个 Buffer 的组合视图;DisplayMap 是 fold/inlay/wrap 的视觉投影; Scene 是本帧绘制命令。上层不反向修改投影内部,而是把操作翻译回底层坐标。
Mutation: UI intent → visual range → Anchor/Buffer range → Buffer transaction
Projection: Buffer snapshot → MultiBuffer → DisplayMap → layout → Scene清晰的单向投影比双向绑定更容易处理 undo、异步结果与局部失效。
3. 跨异步边界只携带稳定身份
裸 offset、数组 index、指针位置在一次 await 后可能已过期。Zed 使用 Entity id、ProjectPath、BufferId、Anchor、 operation/version 与 generation 表达稳定引用;恢复时解析、验证或放弃。
这条规则同时适用于 LSP response、search match、Agent edit、Git hunk、collaboration message 和 remote request。
4. 让数据结构直接回答产品查询
Rope/SumTree 不只是储存文本,它们通过 Summary/Dimension 回答 offset、point、UTF-16、display row、height 等 查询。DisplayMap 每层也维护可组合 summary。
如果核心交互总在做 O(n) 扫描,不要先加缓存;先问领域数据结构能否把该查询变为其不变量的一部分。
5. Local/Remote 分叉要晚,合流要早
文件、LSP、Git、terminal 的 local/remote 差异集中在 Project stores;返回值尽快转换成统一 Buffer、diagnostic、 task output、search result。Editor、Panel 和 Agent tools 只依赖 Project API。
差异扩散到 UI 会产生两套功能和测试;过早抹平 transport/权限差异又会隐藏不可重试的副作用。正确位置是领域 服务边界。
6. 实时增量之外永远设计校准通道
Buffer operation、worktree entries、LSP diagnostics、extension index 都不能假定事件流永不丢失。snapshot + incremental delta + version/generation + resync 构成完整协议。
快速路径:持续应用小增量
检测:版本/心跳/索引不一致
恢复路径:交换已知状态,补差或替换快照
重新进入快速路径只设计正常 stream,系统第一次断线就只能“重启试试”。
7. 副作用必须有所有者与事务边界
Buffer edit 属于 transaction;跨文件改动属于 ProjectTransaction;Agent 变更关联 action log/checkpoint;异步工作 由保存 Task handle 的 Entity 拥有;remote process 属于 workspace session。
所有权回答谁能取消、谁负责清理、结果还能否回写;事务回答失败后能恢复到哪里。两者缺一都会产生幽灵 task 或半完成状态。
8. 先批处理事件,再触发响应式更新
GPUI update/notify/redraw 很方便,也容易把 provider chunk、search result、filesystem event 的每一小项放大为一轮 布局绘制。Zed 在模型流、Scene、LSP change 和 scan 边界反复使用 batching/coalescing。
响应式架构的核心性能指标是 invalidation fan-out。减少一次纯计算的微秒数,往往不如把一千次 notify 合成十次。
9. 不可信能力通过窄接口进入
Extension 经过 versioned WIT/capability;Agent tool 经过 schema/permission/sandbox/Project API;collaboration RPC 经过 typed envelope/role/host authority。外部代码或输入不直接拿内部 Entity 与任意系统权限。
窄接口既是安全边界,也是兼容边界和测试 seam。
10. 把失败状态提升为领域状态
ConnectionLost、Reconnecting、HeartbeatMissed、UpgradeRequired、permission denied、stale response 都不是一条 log 能解决的异常。显式 enum/state machine 让 UI、retry 和 cleanup 对失败类型作出正确决定。
“发生错误就返回字符串”会把暂时故障、权限拒绝、版本不兼容和用户取消混成一个不可恢复终点。
11. 一次按键穿过了哪些层
sequenceDiagram
participant OS as OS Event
participant G as GPUI Window
participant E as Editor
participant B as Buffer
participant D as DisplayMap
participant R as Renderer
participant X as Remote/Peers
OS->>G: keyDown
G->>E: dispatch action / text input
E->>B: transaction + edit
B->>B: Rope/CRDT operation + Anchor update
B-->>E: BufferEvent
E->>D: map patch through folds/inlays/wrap
D->>R: visible layout → Scene batches
B->>X: operation(如共享/远端)任何一步都不需要全量复制文本或等待网络,这正是架构组合后的用户体验。
12. 一次 Agent 修改又穿过哪些层
用户提交消息
→ Thread 构造 LanguageModelRequest
→ provider 流出 ToolUse
→ schema + permission + sandbox
→ edit tool 通过 Project 打开 Buffer
→ Buffer/Project transaction 应用 Anchored edit
→ Editor 收到同一 BufferEvent 并增量绘制
→ collaboration/remote 复用 operation/proto 同步
→ ToolResult 回到 Thread,模型解释结果
→ action log/checkpoint 支持 reject/restoreAgent 没有另造一套文件编辑器;它成为现有架构的一个新调用者。这是功能能快速覆盖 local、remote、协作和 undo 的原因。
13. 不应照抄的部分
13.1 Crate 数量不是目标
241 个 crate 是长期演化与编译/所有权边界的结果。小团队照抄会增加导航、feature flag 和 build 复杂度。先按 变化原因划模块,只有当依赖、编译或复用压力真实出现时再拆 crate。
13.2 自研 UI 框架成本极高
GPUI 给 Zed 精确控制、统一异步 context 和 GPU rendering,但也意味着 accessibility、IME、平台窗口、文本 shaping 都要自己承担。若产品差异不依赖这些能力,成熟 UI toolkit 可能是更好的商业选择。
13.3 CRDT 只在确有多副本编辑时值得
Fragment/operation/Anchor 带来复杂坐标和大量属性测试。单用户工具不应为了“未来可能协作”先引入完整 CRDT; 但至少应避免把裸 offset 长期持久化,让未来迁移有空间。
13.4 通用抽象也有认知税
Entity/Context、SumTree Dimension、Store、Item、proto traits 都很强,但新贡献者需同时理解多层。文档、统一命名、 源码入口和小型端到端示例是架构的一部分,不是可选装饰。
14. 如果从零构建类似产品
推荐演进顺序:
- 明确 Buffer transaction、snapshot 与 stable position;
- 建 Project/Worktree/LSP 领域层,UI 不直接碰 FS/process;
- 建统一 Workspace item/panel/action/focus 模型;
- 测量后选择 Rope/SumTree 与增量 DisplayMap;
- 为异步 query 加 generation/cancellation;
- 在 Project 边界引入 remote proxy;
- 多人需求成立后引入 operation-based collaboration;
- 最后让 Agent/extension 通过已有 capability 接入。
顺序的重点是先建立真相、身份、事务和领域边界,再增加新的执行者与网络副本。
15. 阅读 Zed 源码的下一步
- 想理解响应式运行时:从
gpui::App::update、Entity、Context追一个 notify; - 想理解文本内核:从
Buffer::edit追到 operation、Rope、Anchor resolve; - 想理解编辑器:选一次 click/insert,记录 BufferPoint 到 DisplayPoint 的每次转换;
- 想理解工程层:从
Project::open_buffer对比 local 与 remote store; - 想理解 AI:从
Thread::send单步到 tool result 回填; - 想理解分布式:模拟一次连接丢失,追 reconnect 和 SynchronizeBuffers。
配合源码索引按符号定位,比从 workspace 根目录顺序阅读更高效。
16. 最终检查表
设计一个 Zed 规模的交互系统时,对每项能力追问:
- 真相存在哪个对象?
- 位置/对象跨 await 后用什么稳定身份?
- 哪个 snapshot 可以交给后台?
- 结果过期如何检测?
- invalidation 会扩散到多大?
- task 和副作用归谁拥有?
- local/remote 在哪处分叉?
- 断线或事件丢失如何重同步?
- 不可信输入在哪个窄接口验证?
- 哪一层测试能最小成本证明不变量?
能具体回答这十个问题,架构通常已经从“模块列表”进入“可演化的系统”。