本篇要回答的问题:审视模块的业务边界时,应该用什么样的思维方式?以办公软件的 IO 子系统为实例,看它如何从"散落核心系统的存盘代码"经过 Visitor → IO DOM 两次迭代,并不断重新审视边界。
上一讲"少谈框架多谈业务"强调架构就是业务的正交分解、接口是核心。本讲用一个真实案例(许式伟当年做 WPS Office IO 子系统)把这套思维落地——关键是发现接口中"过度的(多余的)约束",把它提高到通用普适场景去看。
IO 子系统的需求与初始架构
IO 子系统需求:读盘/存盘 + 剪贴板拷贝(存盘)/粘贴(读盘);要支持多种格式:
| 类别 | 格式 | 特点 |
|---|---|---|
| 流式文档(读盘+存盘) | Word、RTF、HTML、纯文本 | 基于文本逻辑 |
| 分页文档(存盘) | PDF、PS | 基于分页显示 |
初始设计(反面):把 SaveWord/SaveRTF/LoadWord/LoadRTF 直接散落到 Span / Paragraph / TextPool / Document 每个类里。
关键判断:这类代码散落在核心系统各处、几乎每个类都要改——这叫全局性功能。它完全是核心系统的一部分,无法称为独立子系统。受 OOP “一切以对象为中心"思想 + MFC Serialization 机制的"毒害”,更加剧了这种倾向。
良好设计的两条要求:①核心系统功能要少(最小子集);②核心功能要能收敛。但读盘存盘需求是开放发散的(格式层出不穷,难以收敛),所以这个初始设计不好。
Visitor 模式(第一次迭代,失败)
Visitor 模式:为核心系统 Model 层提供一套遍历数据的接口,数据通过事件接收(StartDocument/StartParagraph/Characters/EndDocument…),存盘函数改为 SaveWord(stg, doc VisitableDoc)。
| 好处 | 问题 |
|---|---|
| 核心系统提供统一数据访问接口,IO 子系统得以抽离 | 本质就是 SAX 模式(数据源从磁盘换成 Model 层而已),SAX 的缺点全有 |
| Word/RTF 等模块彼此独立又能融洽配合(如 RTF→Word 转换很简单) | 有预设访问逻辑,客户未必想以相同逻辑访问数据 |
| —— | 事件模型简陋,需求方诉求不匹配,被迫缓存数据等待,大量冗余代码 |
| —— | 接口抽象难理解,事件次序需长篇文档说明 |
关键判断:这是许式伟做 WPS IO 子系统第一版的真实方案,他自评非常失败。教训——KISS 提倡的简单不是接口外观的简洁,而是业务语义表达上的准确无歧义。
IO DOM 模式(第二次迭代,成功)
第二次迭代改为基于 DOM:定义 IoSpan/IoParagraph/IoDocument 等接口族,存盘改为 SaveWord(stg, doc IoDocument)。
| 关键设计 | 说明 |
|---|---|
| 两套 DOM | IO DOM(IoDocument)+ 核心系统自己的 DOM(Document),二者几乎雷同 |
| 超集关系 | 理论上 Document 是 IoDocument 的超集;通过 Document.Io() 函数转为 IoDocument 体现 |
相比 Visitor,IO DOM 三大优势:
| 优势 | 说明 |
|---|---|
| 工程量更低 | 存盘读盘模块代码量下降 |
| 理解一致性更好 | 接口体现业务 |
| 更自然、避免惊异 | 核心 Model 本就通过 DOM 暴露,IO DOM 只是其子集,客户理解成本最低 |
洞见:在 DOM 基础上再提供 Visitor 是多余的——DOM 提供了极度灵活的数据访问接口,几乎能适应所有读取场景。Visitor 反而是核心系统为 IO 子系统提供的"专用插件机制",是额外成本。
回到最初的需求:边界还没审视完
过一遍用户故事就会发现需求并没全部解决——PDF/PS 等分页文档漏掉了。因为 IO DOM 是流式文档,没有分页信息。解法:引入排版(Render)环节产出 View DOM:
Render(doc IoDocument) → ViewDocument
SavePDF(f, doc ViewDocument)
SavePS(f, doc ViewDocument)
| 文档层次 | 来源 | 用途 |
|---|---|---|
| IO DOM | 核心 DOM 的子集 | 流式文档存盘读盘 |
| View DOM | IO DOM 经 Render 排版而来 | 屏幕绘制(onPaint)、打印(onPrint)、PDF/PS 存盘 |
关键判断:如果做需求分析时没把这些需求关联性找出来,那就不是一次合格的需求分析。
不断重新审视边界
继续审视,还会发现更多"过度约束":
| 发现的边界问题 | 原接口(过度约束) | 修正后 |
|---|---|---|
| 剪贴板:数据流不一定是文件 | Save/Load(f *os.File, …) |
Save/Load(f io.Writer/io.Reader, …) |
| LoadFile 应返回识别出的格式 | LoadFile(file, doc) error |
LoadFile(file, doc) (format string, err error) |
| 数据源不止文件(还有 IStorage 等) | SaveFile(file string, …) |
Save(src interface{}, format, doc) error(参考 Windows STGMEDIUM) |
| PDF/PS 非流式,不能用 IoDocument | Save(dest, format, doc IoDocument) |
留作思考题——需适当调整文档类型 |
洞见:剪贴板这个具体场景,本质是在提醒我们"发现模块接口中多余的约束"。把模块提高到通用普适场景看,即使没有剪贴板这个具体需求,也能及时发现
*os.File是过度约束。但用了interface{}后,必须在文档层面补清楚支持/不支持什么,避免共识麻烦。
总结
本讲通过 IO 子系统的真实迭代,剖析了思考模块边界的方法。最重要的是职责:不同模块各做什么、如何耦合、耦合方式的需求适应性如何、实现者心智负担如何。
先把"是什么"回答清楚
| 概念 | 一句话说明 |
|---|---|
| 全局性功能 | 散落核心系统、难以剥离的功能(如读盘存盘) |
| Visitor 模式 | 本质是 SAX,事件驱动;心智负担大,被判失败 |
| IO DOM | 核心 DOM 的子集接口族,自然且避免惊异 |
| View DOM | IO DOM 经 Render 排版而来,支持分页/打印/PDF |
| 过度约束 | 接口里多余的具体约束(如 *os.File),应提至通用场景 |
一句话速记
审视模块边界 = 把接口提高到通用普适场景,揪出每一个"过度约束"——IO 子系统从 SAX(Visitor) 到 IO DOM 的迭代,证明 KISS 的简单是业务语义准确而非外观简洁。
几条值得记住的判断
- 全局性功能散落核心、难剥离,要警惕 OOP "一切以对象为中心"的毒害。
- Visitor=SAX,事件模型简陋、心智负担大;DOM 更自然,DOM 之上再加 Visitor 多余。
- 合格的需求分析要找出需求关联性(如 PDF→View DOM→排版)。
- 不断揪出接口的过度约束(
*os.File→io.Writer、补返回 format)。
思考题
Save(dest interface{}, format string, doc IoDocument) 这个入口接口,在要同时支持流式文档(IoDocument)和分页文档(需 ViewDocument)时,最后一个参数该怎么调整才能既覆盖两类文档、又不引入过度约束?把你的接口签名写出来,并说明用什么机制做插件式格式扩展。
