60 架构分解:边界,不断重新审视边界 60 | 架构分解:边界,不断重新审视边界你好,我是七牛云许式伟。

在上一讲 “59 | 少谈点框架,多谈点业务” 中,我们强调: 架构就是业务的正交分解。每个模块都有它自己的业务。

这里我们说的模块是一种泛指,它包括:函数、类、接口、包、子系统、网络服务程序、桌面程序等等。

接口是业务的抽象,同时也是它与使用方的耦合方式。在业务分解的过程中,我们需要认真审视模块的接口,发现其中 “过度的(或多余的)” 约束条件,把它提高到足够通用的、普适的场景来看。

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://learn.lianglianglee.com/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),以确认我们的架构适应性。

最后,我们在具体接口的每个输入输出参数的类型选择上,一样要非常考究,尽可能去发现其中 “过度的(或多余的)” 约束。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题按照大纲是 “全局性功能的架构设计”,但我计划做一篇加餐,内容是架构思维实战,把前面我们的实战案例 “画图程序” 和这几讲的理论知识结合起来。

大家可以提前思考以下内容:对画图程序进行子系统的划分,我们的哪些代码是核心系统,哪些是周边系统?从判断架构设计的优劣的角度,我们如何评判它好还是不好?

如果你自己也实现了一个 “画图程序”,可以根据这几讲的内容,对比一下我们给出的样例,和自己写的有哪些架构思想上的不同,怎么评价它们的好坏?

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e8%ae%b8%e5%bc%8f%e4%bc%9f%e7%9a%84%e6%9e%b6%e6%9e%84%e8%af%be/60%c2%a0%e6%9e%b6%e6%9e%84%e5%88%86%e8%a7%a3%ef%bc%9a%e8%be%b9%e7%95%8c%ef%bc%8c%e4%b8%8d%e6%96%ad%e9%87%8d%e6%96%b0%e5%ae%a1%e8%a7%86%e8%be%b9%e7%95%8c.md