<audio id="audio" title="60 | 架构分解：边界，不断重新审视边界" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/72/a9/72450321yy4b7aa2a3778c13326290a9.mp3"></audio>

你好，我是七牛云许式伟。

在上一讲 “[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），以确认我们的架构适应性。

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

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

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

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

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