用 native 项目读懂《A Philosophy of Software Design》:从 Markdown 阅读笔记看深度模块与 TEA 架构实践
用 native 项目读懂《A Philosophy of Software Design》:从 Markdown 阅读笔记看深度模块与 TEA 架构实践
本文以 open source 仓库
gh_mirrors/ze/native中 markdown-viewer 示例项目自带的一篇读书笔记 notes.md 为主线,把 Ousterhout《A Philosophy of Software Design》里"复杂度是增量积累的""模块应该深""通用模块更深"等核心论点,落地到该示例应用的Model/Msg/update架构、effects 通道、<markdown>渲染器与容量上限设计中,帮助读者同时理解软件设计思想与一套可运行、可测试的 native 桌面应用工程实践。
笔记说了什么:复杂度在评审中失守
notes.md 开篇就点出全书的核心论断:复杂度是增量积累的(complexity is incremental),与复杂度的斗争胜负取决于代码评审而非架构评审。这个判断之所以成立,是因为复杂度很少来自某一次大决策,而是来自一次次"顺手"的小改动——每处都看似局部、看似合理,累积起来却让整个系统难以修改。
仓库中的 markdown-viewer 示例(examples/markdown-viewer/)是一个很好的对照样本:它刻意用大量**固定容量(Fixed capacities)**把"增量复杂度"挡在门外——main.zig 中明确声明文档上限 max_document_bytes = 16 * 1024、路径上限 max_path_bytes = 512、最近文件列表上限 max_recent = 6、<details> 展开标志上限 max_details = 16,以及预览图片上限 max_preview_images。这些常量不是随意的,而是由底层运行时预算推导出来的(详见下文"容量预算"一节)。这正体现了笔记里"在评审中守住防线"的工程化做法:把边界写进代码,让超限行为可预测、可测试,而不是靠人肉评审时"记得别加东西"。
深度模块:Unix write() 与 textarea 的一体两面
笔记第二章"模块应当深"给出了经典的抽象定义:模块 = 接口(成本)/ 实现(收益)。深度模块拥有"小接口 + 背后大量功能",浅模块则相反——只增加表面积,却让代码库"看起来井井有条"。
笔记中的两个例证在仓库里都能找到直接对应:
write()一个调用隐藏缓冲、调度与设备:markdown-viewer 中,fx.writeFile一个 effect 调用就完成了"把编辑器全文写回磁盘 + 更新路径状态 + 加入最近列表 + 持久化最近列表"等一系列工作(main.zig 中savePath与.file_done的.write分支)。- 反例:每字段一个类 + getter/setter:对应到本应用,就是如果把
current_path、pending_path、recent_storage等字段各自暴露为可独立读写的方法,状态流转会立刻散落。而这里的状态封装是**Elm 风格(TEA)**的:所有状态集中在Model,所有变更必须经过Msg到达update。
update 是唯一会修改状态的地方(main.zig 中 pub fn update),视图 viewer.native 只做绑定与派发、从不直接改状态。这正是笔记"接口远简单于实现"在架构层面的体现:对视图层暴露的"接口"只是一组 Msg 标签(edit、open_doc、save_doc、toggle_details……),而背后是文件 I/O、最近列表、图片加载、浏览器打开等一整套逻辑。
通用模块更深:从 backspace() 到 insert(text)
笔记第六章的观点略带反直觉:把模块做得通用,通常反而让它更小。评判标准是那个经典问题——"这个 API 会不会因为 UI 变化而改变?"如果会,说明 API 提到了调用方的领域,就是浅的。
笔记里给出的例子是编辑器 API 设计:
insert(text)和delete(range)优于backspace()、deleteSelection()、pasteFromClipboard()。
这条设计原则在仓库里被完整践行。Model 中的编辑器是一个 canvas.TextBuffer(max_document_bytes)(main.zig),文本变更统一走 model.editor.apply(edit)——edit 是 canvas.TextInputEvent,包含 insert_text、move_caret、.clear 等通用原语。update 只认 edit 一个消息,由文本输入事件驱动,而不是为"退格""粘贴""删除选区"各造一个 Msg。测试 tests.zig 中的 "editing the textarea updates the preview and derived counts through dispatch" 也直接通过 msgForTextEdit(editor.id, .{ .insert_text = "curious words" }) 这一通用插入原语驱动真实派发路径,验证预览与派生计数同步更新。
同理,工具栏的 Open / Save / Save As 三个按钮在 viewer.native 中最终都落到两个通用函数 openPath 与 savePath,而非三个重复的专用流程;打开与"另存为"在 update 里共享同一个 .file_done 分支,只是 Save 写 currentPath、Save As 写路径字段内容。UI 将来无论把按钮改成快捷键还是菜单,这些 Msg 与 effect 通道都不需要变——接口没有提到"按钮"这个调用方领域。
周四要讨论的问题,仓库里的答案
笔记末尾列了三个待讨论问题,恰好都可以在仓库里找到对应实现作为讨论素材。
问题 1:深与臃肿的界线在哪里?
仓库的答案藏在容量常量里。main.zig 顶部的一串 pub const 定义了每个模块的边界:
| 常量 | 值 | 语义 |
|---|---|---|
max_document_bytes |
16 KiB | 文档上限:预览同时保留编辑器与渲染两份文本 |
max_path_bytes |
512 B | 路径字段与当前路径上限 |
max_recent |
6 | 侧栏最近文件条数 |
max_details |
16 | <details> 展开标志数组长度 |
max_preview_images |
min(16, 16-4) = 12 |
预览远程图片上限 |
max_image_source_bytes |
2 KiB | 图片 URL 上限,与 fx.loadImage 通道一致 |
max_note_bytes |
192 B | 状态栏提示文字上限 |
这些上限绝大多数能追溯到底层运行时预算(见下节)。也就是说,"深模块"在工程上意味着:接口小、边界明确、超限行为被显式定义(如 editor.truncated 标志、.truncated 结果),而不是无限膨胀的"万能接口"。
问题 2:effects 通道是深模块吗?一个调用、五个结果枚举?
笔记提到"一个调用,五个结果枚举"。对应到仓库:文件 effect 的结果类型 EffectFileResult 有 ok、truncated、not_found 等多种 outcome,而 update 中的 .file_done 分支对每个 outcome 都有明确处理——读取成功则复制字节、重置详情/滚动、采纳路径、加入最近列表;truncated 则载入截断副本并提示"超过 16 KiB 文档上限";失败则仅更新状态栏提示,不触碰当前文档(tests.zig 的 "open and save round-trip through the fake executor" 验证了失败后文档保持原样)。从笔记的评判标准看:API 没有提到 UI 领域、调用方只需理解结果枚举,是深度模块的合格候选。
问题 3:第 9 章"better together vs. better apart"如何应用于 sync RFC?
侧栏的第三份样本文档 spec.md 正是一份"会话同步协议 RFC",它本身就是一次"合/分"权衡练习:增量同步复用"现有快照 diff——不引入新线上格式",同时把负载限制在 256 KiB 以保证不阻塞 UI 线程。这份文档同时是 <markdown> 渲染器各项能力的展示(任务清单、表格、<details> 折叠块等),详见下文。
渲染链路:笔记如何在 <markdown> 元素里变成原生控件
notes.md 作为侧栏四份内置样本之一(Model.samples 中 id=4、标题 "Reading notes",main.zig),通过 @embedFile("samples/notes.md") 编译进二进制。它的渲染由 viewer.native 中唯一一个 <markdown> 元素完成:
<scroll grow="1" value="{doc_scroll}" on-scroll="doc_scrolled" label="Preview">
<column padding="24">
<markdown source="{document}" on-link="open_url" on-details="toggle_details" details-expanded="{details_expanded}" images="{markdownImages}" />
</column>
</scroll>
左栏 textarea 的每一次输入都通过 on-input="edit" 镜像进 Model.editor,右栏 <markdown> 绑定的是同一份字节,因此预览"逐键跟踪、无防抖、无缓存、无漂移"(README 原话)。底层渲染器在 markdown.zig:没有 DOM、没有 CSS、没有脚本执行,标题按 heading_scales = .{2.0, 1.5, 1.25} 相对正文 token 缩放;内联样式(粗体、斜体、代码、删除线、链接、GFM 自动链接且裁剪尾部标点)与 GitHub 风格的安全展示型 HTML 子集(<details>/<summary>、<a>、表格 align 等)被降级为普通原生控件。
笔记中使用的元素——## 标题、列表、> 引用块、<strike>、链接——全部在渲染器的支持清单内,且这份笔记还示范了渲染器文档注释中"Malformed input degrades to literal text"的行为:即使未来有人向笔记里加入渲染器不支持的语法(如脚注、setext 标题),也会退化为纯文本,而不会构建失败。
<details> 折叠块:状态归模型,不归渲染器
notes.md 没有用到折叠块,但同目录的 spec.md 与 tour.md 大量使用,而底层协议(markdown.zig 文档注释)明确:<details> 块的展开状态由调用方的模型持有——渲染器通过 details_expanded(按文档内块序索引的布尔数组)与 on_details(携带块序的 Msg 构造器)协作,应用在 update 中切换标志。
main.zig 中 Model.details_expanded: [max_details]bool 由视图直接绑定,update 的 .toggle_details => |index| 分支负责翻转;渲染器在 markdown.zig 中按 max_markdown_details_per_document = 16 截断超出部分,与应用的 max_details = 16 精确对齐。测试 tests.zig 的 "preview links open through fx.spawn and details expand through the model" 通过自动化 widget-click 驱动真实点击,验证折叠/展开、两个块互不干扰、以及切换样本后标志重置。
远程图片:应用 effect 与模型自有映射
tour.md 里有一行 。Markdown 渲染器不隐式加载任何远程图片——图片发现(canvas.markdown.collectImageSources)与应用模型中的 PreviewImage 记录配合:应用在 refreshPreviewImages 中收集源、校验 http(s):// 前缀与 2 KiB 长度上限、通过 fx.loadImage 发起有界加载,成功后把 source -> ImageId + 尺寸 的映射放回模型,视图经 images="{markdownImages}" 拿到解析结果。未解析的图片保持 alt 文本回退。tests.zig 的 "remote images flow from markdown discovery through the resolved preview mapping" 完整走通了"发现 → 发起 effect → 成功映射 → 图片控件出现 → 编辑后回收"链路。
真实文件 I/O:无对话框的诚实路径字段
笔记没有讲文件对话框,但承载它的 markdown-viewer 应用有一个重要设计事实:Native SDK 没有文件对话框服务。因此应用的 Open / Save / Save As 使用"诚实的模式"——工具栏里一个可编辑的路径字段就是文件选择器(main.zig 文档注释原话)。每次操作的结果是一个带显式 outcome 的类型化 Msg;失败落在状态栏,绝不弹对话框。
- Open:读取路径字段内容(
fx.readFile),成功后复制字节进编辑器、清空样本归属、重置折叠/滚动、采纳路径、pushRecent加入最近列表并持久化; - Save:把编辑器写回当前文档(
fx.writeFile,key 为save_key); - Save As:写入路径字段内容并采纳为新当前路径。
最近列表同样通过文件 effect 持久化:main 里用 app_dirs.resolveOne(.{ .name = "markdown-viewer" }, …, .data, …) 解析到每应用数据目录(macOS 即 ~/Library/Application Support/markdown-viewer),拼出 recent.txt 路径;boot(TEA 的 init)在首帧前用 fx.readFile 恢复列表,persistRecent 在每次打开/保存后用 fx.writeFile 写回。解析失败只是禁用持久化,绝不构成启动错误。
容量预算:16 KiB 文档上限为什么是 16 KiB
main.zig 注释解释了这些数字的来源:视图同时保留编辑器的文本副本与预览渲染后的文本,而运行时的每视图 widget 文本预算为 64 KiB(max_canvas_widget_inline_text_bytes_per_view,canvas_limits.zig),因此 16 KiB 文档保留 2 倍余量给界面文字与 span 负载。同理:
- 路径 512 B 对应 effect 通道自身的 1 KiB 上限(
max_effect_file_path_bytes,effects.zig); - 预览图片上限
min(max_registered_canvas_images, max_effects - 4)保证在图片密集的文档中,仍为 open/save/recent/link 四个共享 effect 槽保留位置——max_effects = 16(effects.zig)、max_registered_canvas_images = 16(canvas_limits.zig); - 图片 URL 上限 2 KiB 与
fx.loadImage的 URL 通道一致,超长源直接停留在 alt 文本回退,不发起注定失败的 effect; - 渲染器侧还有一批独立的确定截断上限:
max_markdown_blocks_per_container = 64、max_markdown_list_items_per_list = 64、max_markdown_list_depth = 4、max_markdown_table_columns = 8、max_markdown_table_rows = 64、max_markdown_paragraph_bytes = 8192(markdown.zig)。
超限从不静默:打开超大文件时结果带 .truncated 标志,编辑器 truncated 时状态栏提示"Document is full (16 KiB cap)"。这套"预算即契约"的设计,正是笔记主题"把复杂度挡在评审之外"的可执行版本。
如何运行与验证
markdown-viewer 是仓库中"零配置"示例之一:一个 app.zon 清单 + src/ 目录,无构建文件,可直接从该目录运行(README.md):
cd examples/markdown-viewer
native dev # 开发运行,viewer.native 支持热重载
native test # 驱动真实派发路径的测试
根目录等价测试命令为 zig build test-example-markdown-viewer。示例仅声明 macOS 平台(app.zon 中 .platforms = .{"macos"},GPU 后端为 Metal),在 macOS 上以 native dev 打开后:左栏编辑 notes.md 的任意字符,右栏预览逐键跟随;点击笔记中的链接会通过 fx.spawn 调用系统浏览器命令(macOS 为 open,Windows 为 cmd /c start,其余为 xdg-open,见 openInBrowser);切换系统外观,窗口会经 on_appearance 实时重新推导 stone/indigo 明暗 token(main.zig 的 viewerTokens),窗口内不提供主题开关。
测试覆盖(tests.zig)包括:open/save/save-as 往返与最近列表持久化(fake effect 执行器)、链接点击 spawn 浏览器命令、details 折叠的自动化点击、编辑器编辑驱动预览与派生计数(word/line/byte 在状态栏实时计算、从不存储)、系统外观双向实时改 token、受控预览滚动往返、编译视图与热重载解释器构建出完全相同的控件树,以及布局/无障碍审计扫描。
Follow-ups 的仓库落地版
笔记最后列出三项后续动作,其中两项可以直接在本仓库里找到"以代码作答"的位置:
- 重读第 5 章 information leakage:对应泄漏与否的判据就是
Model/Msg/update边界是否被遵守——viewer.native 只绑定与派发、main.zig 独占状态变更、markdown.zig 不持有任何应用状态(连<details>展开标志都由调用方模型提供),三层之间没有信息渗透通道。 - 对照 Parnas《On the Criteria》:Parnas 的信息隐藏原则在仓库中体现为
Model的"私有存储 + 视图绑定函数"模式——current_path_storage/pending_path_storage/recent_storage等原始缓冲区不直接暴露,视图通过document()、path()、recentDocs()、statusLine()等派生函数读取,未来改存储布局不影响视图层。 - 起草 depth review 清单:可直接从仓库提炼条目——"接口是否提到调用方领域(UI/按钮/文件对话框)?""是否有不受控的容量增长?""超限行为是否被显式定义?""状态是否只有一条变更路径?"——以
notes.md的判断题形式应用到下一次评审。
小结
一篇只有三十余行的阅读笔记,之所以值得展开成文,是因为它恰好描述了这个仓库反复实践的原则:深度模块(<markdown> 一个元素隐藏整个渲染器)、通用原语(insert/delete 而非专用命令)、状态单一变更路径(TEA 的 Model/Msg/update)、以及用显式容量预算把增量复杂度挡在代码评审之外。读这份笔记,再看 main.zig、viewer.native 与 tests.zig,软件设计的抽象原则就变成了可以运行、可以测试、可以逐行引用的工程事实。