# 60 | 架构分解:边界,不断重新审视边界 你好,我是七牛云许式伟。 在上一讲 “[59 | 少谈点框架,多谈点业务](https://time.geekbang.org/column/article/169113)” 中,我们强调: > 架构就是业务的正交分解。每个模块都有它自己的业务。 这里我们说的模块是一种泛指,它包括:函数、类、接口、包、子系统、网络服务程序、桌面程序等等。 接口是业务的抽象,同时也是它与使用方的耦合方式。在业务分解的过程中,我们需要认真审视模块的接口,发现其中 “过度的(或多余的)” 约束条件,把它提高到足够通用的、普适的场景来看。 ## IO 子系统的需求与初始架构 这样说太抽象了,今天我们拿一个实际的例子来说明我们在审视模块的业务边界时,需要用什么样的思维方式来思考。 我们选的例子,是办公软件的 IO 子系统。从需求来说,我们首先考虑支持的是: * 读盘、存盘; * 剪贴板的拷贝(存盘)、粘贴(读盘)。 读盘功能不只是要能够加载自定义格式的文件,也要支持业界主流的文件格式,如: * Word 文档、RTF 文档; * HTML 文档、纯文本文档。 存盘功能更复杂一些,它不只是要支持保存为以上基于文本逻辑的流式文档,还要支持基于分页显示的文档格式,如: * PDF 文档; * PS 文档。 对于这样的业务需求,我们应该怎么做架构设计? 我第一次看到的设计大概是这样的: ``` type Span struct { ... SaveWord(ctx *SaveWordContext) error SaveRTF(ctx *SaveRTFContext) error LoadWord(ctx *LoadWordContext) error LoadRTF(ctx *LoadRTFContext) error } type Paragraph struct { ... SpanCount() int GetSpan(i int) *Span SaveWord(ctx *SaveWordContext) error SaveRTF(ctx *SaveRTFContext) error LoadWord(ctx *LoadWordContext) error LoadRTF(ctx *LoadRTFContext) error } type TextPool struct { ... ParagraphCount() int GetParagraph(i int) *Paragraph SaveWord(ctx *SaveWordContext) error SaveRTF(ctx *SaveRTFContext) error LoadWord(ctx *LoadWordContext) error LoadRTF(ctx *LoadRTFContext) error } type Document struct { ... TextPool() *TextPool SaveWord(stg IStorage) error SaveRTF(f *os.File) error SaveFile(file string, format string) error LoadWord(stg IStorage) error LoadRTF(f *os.File) error LoadFile(file string) error } ``` 从上面的设计可以看出,读盘存盘的代码散落在核心系统的各处,几乎每个类都需要进行相关的修改。这类功能我们把它叫做 “全局性功能”。我们下一讲将专门讨论全局性功能怎么做设计。 全局性功能的架构设计要非常小心。如果按上面这种设计,我们无法称之为一个独立的子系统,它完完全全是核心系统的一部分。 某种程度上来说,这个架构是受了 OOP 思想的毒害,以为一切都应该以对象为中心,况且在微软的 MFC 框架里面有 Serialization 机制支持,进一步加剧了写这类存盘读盘代码的倾向。 这当然是不太好的。在良好的设计中,一方面核心系统功能要少,少到只有最小子集;另一方面核心功能要能够收敛,不能越加越多。 但读盘存盘的需求是开放的,今天支持 Word 和 RTF 文档,明天支持 HTML,后天微软又出来新的 docx 格式。文件格式总是层出不穷,难以收敛。 ## Visitor 模式 所以,以上读盘存盘的架构设计不是一个好的架构设计。那么应该怎么办呢?可能有人会想到设计模式中的 Visitor 模式。 什么是 Visitor 模式?简单来说,它的目的是为核心系统的 Model 层提供一套遍历数据的接口,数据最终是通过事件的方式接收。如下: ``` type Visitor interface { StartDocument(attrs *DocumentAttrs) error StartParagraph(attrs *ParagraphAttrs) error StartSpan(attrs *SpanAttrs) error Characters(chars []byte) error EndSpan() error EndParagraph() error EndDocument() error } type VisitableDoc interface { Visit(visitor Visitor) error } type Document struct { ... Visit(visitor Visitor) error } func NewDocument() *Document func LoadDocument(doc VisitableDoc) (*Document, error) func SaveWord(stg IStorage, doc VisitableDoc) error func SaveRTF(f *os.File, doc VisitableDoc) error func SaveFile(file string, format string, doc VisitableDoc) error func LoadWord(stg IStorage) (VisitableDoc, error) func LoadRTF(f *os.File) (VisitableDoc, error) func LoadFile(file string) (VisitableDoc, error) ``` 这样做的好处是显然的。 一方面,核心系统为 IO 系统提供了统一的数据访问接口。这样 IO 子系统就从核心系统中抽离出来了。 另一方面,Word 文档的支持、RTF 文档的支持这些模块在 IO 子系统中也彼此完全独立,却又相互可以非常融洽地进行配合。比如我们可以很方便将 RTF 文件转为 Word 文件,代码如下: ``` func ConvRTF2Word(rtf *os.File, word IStorage) error { doc, err := LoadRTF(rtf) if err != nil { return err } return SaveWord(word, doc) } ``` 类似地,加载一个 Word 文件的代码如下: ``` func LoadWordDocument(stg IStorage) (*Document, error) { vdoc, err := LoadWord(stg) if err != nil { return nil, err } return LoadDocument(vdoc) } ``` 那么这个设计有什么问题? 如果你对比上一讲 “[59 | 少谈点框架,多谈点业务](/https://time.geekbang.org/column/article/169113)” 提到的 SAX 和 DOM 模式,很容易看出这里的 Visitor 模式本质上就是 SAX 模式,只不过数据源不再是磁盘中的文件,而是换成了核心系统的 Model 层而已。 所以我前面讲的 SAX 模式的缺点它一样有。它最大的问题是有预设的数据访问逻辑,其客户未必期望以相同的逻辑访问数据。 基于事件模型是一个非常简陋的编程框架,与大部分 IO 子系统的需求方,比如我们这里的 Word 文档存盘、RTF 文档存盘的诉求并不那么匹配。解决这种不匹配的常规做法是把数据先缓存下来,等到我当前步骤所有需要的数据都已经发送过来了,再进行处理。 这个设计并不是假想的,实际上我当年在做 WPS Office IO 子系统第一版本的架构设计时,就采用了这个架构。但最终实践下来,我自己总结的时候认为它是一个非常失败的设计。 一方面,虽然 Visitor 或者 SAX 模式看起来是 “简洁而高效” 的,但是实际编码中程序员的心智负担比较大,有大量的冗余代码纯粹就是为了缓存数据,等待更多有效的数据。 另一方面,这个接口仍然是抽象而难以理解的。比如,不同事件的次序是什么样的,需要较长的文档说明。 这也是给架构师们提了个醒,我们架构设计的 KISS 原则提倡的简单,并不是接口外观上的简洁,而是业务语义表达上的准确无歧义。 ## IO DOM 模式 所以第二次的架构迭代,我们调整为基于 DOM 模式,如下: ``` type IoSpan interface { Text() []byte Attributes() IoSpanAttrs } type IoSpans interface { Len() int Elem(i int) IoSpan } type IoParagraph interface { Spans() IoSpans Attributes() IoParagraphAttrs } type IoParagraphs interface { Len() int Elem(i int) IoParagraph } type IoDocument interface { Paragraphs() IoParagraphs Attributes() IoDocumentAttrs } func NewIoDocument() IoDocument type Document struct { ... Io() IoDocument } func NewDocument() *Document func SaveWord(stg IStorage, doc IoDocument) error func SaveRTF(f *os.File, doc IoDocument) error func SaveFile(file string, format string, doc IoDocument) error func LoadWord(stg IStorage, doc IoDocument) error func LoadRTF(f *os.File, doc IoDocument) error func LoadFile(file string, doc IoDocument) error ``` 在这个架构,我们认为有两套 DOM,一套是 IO DOM,即 IoDocument 接口及其相关的接口。一套是核心系统自己的 DOM,也就是 Document 类及其相关的接口。这两套接口几乎是雷同的,理论上 Document 只是 IoDocument 这个 DOM 的超集。 那么为什么不是直接在接口上体现出超集关系?从语法表达上很难,毕竟这是一个接口族,而不是一个接口。这里我们通过在 Document 类引入 Io() 函数来将其转为 IoDocument 接口,以体现双方的超集关系。 在这个方案下,将 RTF 文件转为 Word 文件的代码如下: ``` func ConvRTF2Word(rtf *os.File, word IStorage) error { doc := NewIoDocument() err := LoadRTF(rtf, doc) if err != nil { return err } return SaveWord(word, doc) } ``` 类似地,加载一个 Word 文件的代码如下: ``` func LoadWordDocument(stg IStorage) (*Document, error) { doc := NewDocument() err := LoadWord(stg, doc.Io()) if err != nil { return nil, err } return doc, nil } ``` 相比前面的 Visitor 模式,采用 IO DOM 除了让所有存盘读盘的模块代码工程量变低,接口的理解一致性更好外,还有一个额外的好处,是 IO DOM 更自然,避免了惊异。因为核心系统的 Model 层通常就是通过 DOM 接口暴露的,而 IO DOM 从概念上只是一个子集关系,显然对客户的理解成本来说是最低的。而 Visitor 模式你可以理解为它是核心系统 Model 层为 IO 子系统提供的专用插件机制,它对核心系统来说是额外的成本。 事实上,在 DOM 模式基础上提供 Visitor 模式是有点多余的。DOM 模式通常提供了极度灵活的数据访问接口,可以适应几乎所有的数据读取场景。 ## 回到最初的需求 我们是否解决了最初 IO 子系统的所有需求? 我们简单分析下各类用户故事(User Story)就能够发现其实并没有。我们解决了所有流式文档的存盘读盘,但是没有解决基于分页显示的文档格式支持,如: * PDF 文档; * PS 文档。 因为从核心系统 DOM 得到的文档,或者我们抽象的 IO DOM,都是流式文档,并没有分页信息。如果我们 PDF、PS 文档的存盘接口是这样的: ``` func SavePDF(f *os.File, doc IoDocument) error func SavePS(f *os.File, doc IoDocument) error ``` 那么意味着这些存盘模块的实现者需要对 IO DOM 进行排版(Render),得到具备分页信息的数据结构,然后以此进行存盘。 这意味着 IO 子系统在特定的场景下,其实与排版与绘制子系统相关,包括: * 屏幕绘制(onPaint); * 打印(onPrint)。 可能有些人能够回忆起来,前面在 “[22 | 桌面程序的架构建议](https://time.geekbang.org/column/article/105356)” 一讲介绍 Model 和 ViewModel 之间的关系时,我也是拿 Office 文档举例。核心系统的 DOM,或者 IO 子系统的 IO DOM,通过排版(Render)功能,可以渲染出 View 层所需的显示数据,我们不妨称之为 View DOM。 而有了 View DOM,我们就不只是可以进行屏幕绘制和打印,也可以支持 PDF/PS 文档的存盘了。代码如下: ``` func Render(doc IoDocument) (ViewDocument, error) func SavePDF(f *os.File, doc ViewDocument) error func SavePS(f *os.File, doc ViewDocument) error ``` 如果你做需求分析的时候,没有把这些需求关联性找到,那就不是一次合格的需求分析过程。 ## 不断重新审视边界 到此为止,我们的分析是否已经足够细致,把所有关键细节都想得足够清楚? 其实并没有,我们在理需求时,我们首先要考虑支持的是: * 剪贴板的拷贝(存盘)、粘贴(读盘)。 但是我们在整理用户故事(User Story)的时候仍然把它给漏了。当然,剪贴板带来的影响没有 PDF/PS 文档大,它只是意味着我们的数据流不再是 \*os.File 可以表达,而是需要用更抽象的 io.Reader/Writer 来表示。也就是说,以下接口: ``` func SaveRTF(f *os.File, doc IoDocument) error func LoadRTF(f *os.File, doc IoDocument) error func SavePDF(f *os.File, doc ViewDocument) error func SavePS(f *os.File, doc ViewDocument) error ``` 要改为: ``` func SaveRTF(f io.Writer, doc IoDocument) error func LoadRTF(f io.Reader, doc IoDocument) error func SavePDF(f io.Writer, doc ViewDocument) error func SavePS(f io.Writer, doc ViewDocument) error ``` 这其实就是我前面强调的 “发现模块接口中多余的约束”的一种典型表现。在我们模块提高到足够通用的、普适的场景来看时,实际上并不需要剪贴板这样具体的用户场景,也能够及时地发现这种过度约束。 另外,我们的 IO 子系统的入口级的接口: ``` func SaveFile(file string, format string, doc IoDocument) error func LoadFile(file string, doc IoDocument) error ``` 我们且不说这里面怎么实现插件机制,以便于我们非常方便就能够不修改任何代码,就增加一种新的文件格式的读写支持。我们单就它的边界来看,也需要进一步探讨。 其一,LoadFile 方法我们可能希望知道加载的文件具体是文档格式,所以应该改为: ``` func LoadFile(file string, doc IoDocument) (format string, err error) ``` 其二,考虑到剪贴板的支持,我们输入的数据源不一定是文件,还可能是 io.Reader、IStorage 等,在 Windows 平台下有 STGMEDIUM 结构体来表达通用的介质类型,可以参考。从跨平台的角度,也可以考虑直接用 Go 语言中的任意类型。如下: ``` func Save(src interface{}, format string, doc IoDocument) error func Load(src interface{}, doc IoDocument) (format string, err error) ``` 既然用了 interface{} 这样的任意类型,就意味着我们需要在文档层面上补充清楚我们都支持些什么,不支持些什么,避免在团队共识上遇到麻烦。 其三,考虑 PDF/PS 这类非流式文档的支持,我们不能用 IoDocument 作为输入文档的类型。也就是说,以下接口: ``` func Save(dest interface{}, format string, doc IoDocument) error ``` 需要作出适当的调整。具体应该怎么调?欢迎留言发表你的观点。 ## 结语 这一讲我们通过一个实际的例子,来剖析架构设计过程中我们如何在思考模块边界。 最重要的,当然是职责。不同的业务模块,分别做什么,它们之间通过什么样的方式耦合在一起。这种耦合方式的需求适应性如何,开发人员实现上的心智负担如何,是我们决策的影响因素。 为了避免留下难以调整的架构缺陷,我们强烈建议你认真细致做好需求分析,并且在架构设计时,认真细致地过一遍所有的用户故事(User Story),以确认我们的架构适应性。 最后,我们在具体接口的每个输入输出参数的类型选择上,一样要非常考究,尽可能去发现其中 “过度的(或多余的)” 约束。 如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题按照大纲是 “全局性功能的架构设计”,但我计划做一篇加餐,内容是架构思维实战,把前面我们的实战案例 “画图程序” 和这几讲的理论知识结合起来。 大家可以提前思考以下内容:对画图程序进行子系统的划分,我们的哪些代码是核心系统,哪些是周边系统?从判断架构设计的优劣的角度,我们如何评判它好还是不好? 如果你自己也实现了一个 “画图程序”,可以根据这几讲的内容,对比一下我们给出的样例,和自己写的有哪些架构思想上的不同,怎么评价它们的好坏? 如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。