60 | 架构分解:边界,不断重新审视边界
下载APP
关闭
渠道合作
推荐作者
60 | 架构分解:边界,不断重新审视边界
2019-11-26 许式伟 来自北京
《许式伟的架构课》
课程介绍
讲述:丁伟
时长15:36大小14.25M
你好,我是七牛云许式伟。
架构就是业务的正交分解。每个模块都有它自己的业务。
这里我们说的模块是一种泛指,它包括:函数、类、接口、包、子系统、网络服务程序、桌面程序等等。
接口是业务的抽象,同时也是它与使用方的耦合方式。在业务分解的过程中,我们需要认真审视模块的接口,发现其中 “过度的(或多余的)” 约束条件,把它提高到足够通用的、普适的场景来看。
IO 子系统的需求与初始架构
这样说太抽象了,今天我们拿一个实际的例子来说明我们在审视模块的业务边界时,需要用什么样的思维方式来思考。
我们选的例子,是办公软件的 IO 子系统。从需求来说,我们首先考虑支持的是:
读盘、存盘;
剪贴板的拷贝(存盘)、粘贴(读盘)。
读盘功能不只是要能够加载自定义格式的文件,也要支持业界主流的文件格式,如:
Word 文档、RTF 文档;
HTML 文档、纯文本文档。
存盘功能更复杂一些,它不只是要支持保存为以上基于文本逻辑的流式文档,还要支持基于分页显示的文档格式,如:
PDF 文档;
PS 文档。
对于这样的业务需求,我们应该怎么做架构设计?
我第一次看到的设计大概是这样的:
从上面的设计可以看出,读盘存盘的代码散落在核心系统的各处,几乎每个类都需要进行相关的修改。这类功能我们把它叫做 “全局性功能”。我们下一讲将专门讨论全局性功能怎么做设计。
全局性功能的架构设计要非常小心。如果按上面这种设计,我们无法称之为一个独立的子系统,它完完全全是核心系统的一部分。
某种程度上来说,这个架构是受了 OOP 思想的毒害,以为一切都应该以对象为中心,况且在微软的 MFC 框架里面有 Serialization 机制支持,进一步加剧了写这类存盘读盘代码的倾向。
这当然是不太好的。在良好的设计中,一方面核心系统功能要少,少到只有最小子集;另一方面核心功能要能够收敛,不能越加越多。
但读盘存盘的需求是开放的,今天支持 Word 和 RTF 文档,明天支持 HTML,后天微软又出来新的 docx 格式。文件格式总是层出不穷,难以收敛。
Visitor 模式
所以,以上读盘存盘的架构设计不是一个好的架构设计。那么应该怎么办呢?可能有人会想到设计模式中的 Visitor 模式。
什么是 Visitor 模式?简单来说,它的目的是为核心系统的 Model 层提供一套遍历数据的接口,数据最终是通过事件的方式接收。如下:
这样做的好处是显然的。
一方面,核心系统为 IO 系统提供了统一的数据访问接口。这样 IO 子系统就从核心系统中抽离出来了。
另一方面,Word 文档的支持、RTF 文档的支持这些模块在 IO 子系统中也彼此完全独立,却又相互可以非常融洽地进行配合。比如我们可以很方便将 RTF 文件转为 Word 文件,代码如下:
类似地,加载一个 Word 文件的代码如下:
那么这个设计有什么问题?
如果你对比上一讲 “59 | 少谈点框架,多谈点业务” 提到的 SAX 和 DOM 模式,很容易看出这里的 Visitor 模式本质上就是 SAX 模式,只不过数据源不再是磁盘中的文件,而是换成了核心系统的 Model 层而已。
所以我前面讲的 SAX 模式的缺点它一样有。它最大的问题是有预设的数据访问逻辑,其客户未必期望以相同的逻辑访问数据。
基于事件模型是一个非常简陋的编程框架,与大部分 IO 子系统的需求方,比如我们这里的 Word 文档存盘、RTF 文档存盘的诉求并不那么匹配。解决这种不匹配的常规做法是把数据先缓存下来,等到我当前步骤所有需要的数据都已经发送过来了,再进行处理。
这个设计并不是假想的,实际上我当年在做 WPS Office IO 子系统第一版本的架构设计时,就采用了这个架构。但最终实践下来,我自己总结的时候认为它是一个非常失败的设计。
一方面,虽然 Visitor 或者 SAX 模式看起来是 “简洁而高效” 的,但是实际编码中程序员的心智负担比较大,有大量的冗余代码纯粹就是为了缓存数据,等待更多有效的数据。
另一方面,这个接口仍然是抽象而难以理解的。比如,不同事件的次序是什么样的,需要较长的文档说明。
这也是给架构师们提了个醒,我们架构设计的 KISS 原则提倡的简单,并不是接口外观上的简洁,而是业务语义表达上的准确无歧义。
IO DOM 模式
所以第二次的架构迭代,我们调整为基于 DOM 模式,如下:
在这个架构,我们认为有两套 DOM,一套是 IO DOM,即 IoDocument 接口及其相关的接口。一套是核心系统自己的 DOM,也就是 Document 类及其相关的接口。这两套接口几乎是雷同的,理论上 Document 只是 IoDocument 这个 DOM 的超集。
那么为什么不是直接在接口上体现出超集关系?从语法表达上很难,毕竟这是一个接口族,而不是一个接口。这里我们通过在 Document 类引入 Io() 函数来将其转为 IoDocument 接口,以体现双方的超集关系。
在这个方案下,将 RTF 文件转为 Word 文件的代码如下:
类似地,加载一个 Word 文件的代码如下:
相比前面的 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 文档的存盘接口是这样的:
那么意味着这些存盘模块的实现者需要对 IO DOM 进行排版(Render),得到具备分页信息的数据结构,然后以此进行存盘。
这意味着 IO 子系统在特定的场景下,其实与排版与绘制子系统相关,包括:
屏幕绘制(onPaint);
打印(onPrint)。
可能有些人能够回忆起来,前面在 “22 | 桌面程序的架构建议” 一讲介绍 Model 和 ViewModel 之间的关系时,我也是拿 Office 文档举例。核心系统的 DOM,或者 IO 子系统的 IO DOM,通过排版(Render)功能,可以渲染出 View 层所需的显示数据,我们不妨称之为 View DOM。
而有了 View DOM,我们就不只是可以进行屏幕绘制和打印,也可以支持 PDF/PS 文档的存盘了。代码如下:
如果你做需求分析的时候,没有把这些需求关联性找到,那就不是一次合格的需求分析过程。
不断重新审视边界
到此为止,我们的分析是否已经足够细致,把所有关键细节都想得足够清楚?
其实并没有,我们在理需求时,我们首先要考虑支持的是:
剪贴板的拷贝(存盘)、粘贴(读盘)。
但是我们在整理用户故事(User Story)的时候仍然把它给漏了。当然,剪贴板带来的影响没有 PDF/PS 文档大,它只是意味着我们的数据流不再是 *os.File 可以表达,而是需要用更抽象的 io.Reader/Writer 来表示。也就是说,以下接口:
要改为:
这其实就是我前面强调的 “发现模块接口中多余的约束”的一种典型表现。在我们模块提高到足够通用的、普适的场景来看时,实际上并不需要剪贴板这样具体的用户场景,也能够及时地发现这种过度约束。
另外,我们的 IO 子系统的入口级的接口:
我们且不说这里面怎么实现插件机制,以便于我们非常方便就能够不修改任何代码,就增加一种新的文件格式的读写支持。我们单就它的边界来看,也需要进一步探讨。
其一,LoadFile 方法我们可能希望知道加载的文件具体是文档格式,所以应该改为:
其二,考虑到剪贴板的支持,我们输入的数据源不一定是文件,还可能是 io.Reader、IStorage 等,在 Windows 平台下有 STGMEDIUM 结构体来表达通用的介质类型,可以参考。从跨平台的角度,也可以考虑直接用 Go 语言中的任意类型。如下:
既然用了 interface{} 这样的任意类型,就意味着我们需要在文档层面上补充清楚我们都支持些什么,不支持些什么,避免在团队共识上遇到麻烦。
其三,考虑 PDF/PS 这类非流式文档的支持,我们不能用 IoDocument 作为输入文档的类型。也就是说,以下接口:
需要作出适当的调整。具体应该怎么调?欢迎留言发表你的观点。
结语
这一讲我们通过一个实际的例子,来剖析架构设计过程中我们如何在思考模块边界。
最重要的,当然是职责。不同的业务模块,分别做什么,它们之间通过什么样的方式耦合在一起。这种耦合方式的需求适应性如何,开发人员实现上的心智负担如何,是我们决策的影响因素。
为了避免留下难以调整的架构缺陷,我们强烈建议你认真细致做好需求分析,并且在架构设计时,认真细致地过一遍所有的用户故事(User Story),以确认我们的架构适应性。
最后,我们在具体接口的每个输入输出参数的类型选择上,一样要非常考究,尽可能去发现其中 “过度的(或多余的)” 约束。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题按照大纲是 “全局性功能的架构设计”,但我计划做一篇加餐,内容是架构思维实战,把前面我们的实战案例 “画图程序” 和这几讲的理论知识结合起来。
大家可以提前思考以下内容:对画图程序进行子系统的划分,我们的哪些代码是核心系统,哪些是周边系统?从判断架构设计的优劣的角度,我们如何评判它好还是不好?
如果你自己也实现了一个 “画图程序”,可以根据这几讲的内容,对比一下我们给出的样例,和自己写的有哪些架构思想上的不同,怎么评价它们的好坏?
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
分享给需要的人,Ta购买本课程,你将得20元
生成海报并分享
赞 8
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
59 | 少谈点框架,多谈点业务
下一篇
加餐 | 实战:“画图程序” 的整体架构
精选留言(17)
- 丁丁历险记2019-11-26这下好了,满脑子架构就是业务的正交分解了。。。。。9
- Sam2019-11-27许大,请教您一个问题。文中提到的如下代码片段: func Save(src interface{}, format string, doc IoDocument) error func Load(src interface{}, doc IoDocument) (format string, err error) 其中format参数有何用意,麻烦指点下。 第二个: func Save(dest interface{}, format string, doc IoDocument) error 我没想到改造方法,只想到了增加了 func Export(dest interface{}, format string, doc ViewDocument) error,这种方式,感觉好Low展开
作者回复: 1、format就是要保存的文件格式; 2、其实你说的是一个好方法,我也用的是这个方法。
5 - 许式伟-七牛云(已满...2019-11-26其实,这里面有一个隐含的决策没有交代,为什么有引入 IO DOM,直接拿核心系统的 DOM 来作为 IO 系统依赖行不行?欢迎留言探讨。共 4 条评论6
- 吴2019-11-26越来越有味,这系列文章需要反复研究5
- 木瓜7772021-04-16项目经常在开始前,根本不知道具体业务需求,而是不断迭代的,请问怎么进行 良好的架构设计呢?
作者回复: 1、走进用户;2、整理/梳理需求,做需求分析,千万别跳过需求分析这个关键步骤就设计
2 - Bachue Zhou2019-12-07感觉什么约束都没有的 interface{} 不该出现在重要的接口里 不可能真的什么约束都没有 肯定会要求实现某些接口或者约定了某种反射方式 还是应该在接口里体现出具体的约束细节
作者回复: 是这样。大部分情况下不应该用 any 类型,除非有什么让人信服的特殊理由。
1 - 吴2019-11-26老大,go语言的入门书介绍一下,go语言擅长做啥了
作者回复: 网上随意买一本都可以,Go语法确定性较强,掌握门槛比较低,关键在用起来。Go能够用的领域可以很广泛,主要做后端开发,单我甚至也用它做过游戏。
1 - 哲2021-11-13软件架构设计中有一个很大的痛点是老逻辑加新功能,发现越来越难,最后像屎山一样改不动。而好的业务代码应该像插件一样即插即用的。怎么达到这种效果呢,就是本章所讲的,要求每一个细分的模块儿足够简单且脱离耦合性,那么就需要在架构或者小步重构的过程中,做到定义好模块的边界了。
- Run2021-03-19术语啊
- will2020-05-29介绍IO DOM 模式时提到有两套DOM,一套是 IO DOM我可以看到在代码中反映,但是说另外一套是Document 类及其相关的接口,实在理解不来... 相关接口是什么接口,好像示例代码省略了很多?
作者回复: 一般来说,业务dom和io dom是超集关系
- 亢(知行合一的路上)2020-04-28架构设计时,认真细致地过一遍所有的用户故事,这一点太重要了。实际设计过程中,经常遗留一些重要的点,导致改动模块接口,一定要多下功夫,减少开发的成本。
- 不在调上2020-04-26许大,想请教一个问题,文章中的代码,怎么界定使用方和提供方的功能呢?
- Bravery1682020-02-24看这章的体会:架构师要把握道(心法),也要深入术(武器,或者说解决问题的具体方式方法),手中能拿出的武器很多,心里形成运用的法则,做到道术一体,最后的境界就是达到心中有法,手中无剑胜有剑的境界。
- 丁丁历险记2019-11-26最近买了打印机,pdf 打出来模糊,wps pdf 转word 想的挺好,实施起来各种坑。 思来想去,还是github 上找找pdf 转html的代码,毕竟ai 很成熟了。 然后html 打印。 毕竟对开发来说,调html样式 比编写word 容易太多。
作者回复: pdf 转 word 的确比较复杂,这里没有讨论。
- 落石2019-11-26func Save(dest interface{}, format string, func () interface{} docmentLoader) error 由调用方决定 document 的类型。 1. 将document也调整为父子类的形式。但隐约感觉到老师好像不太赞同继承? 2. 或者在调用时强转为 SaveWord 或 SavePDF 中的 IoDocument 和 ViewDocument
作者回复: 仔细想想,是否可以解决问题
- Aaron Cheung2019-11-26模块边界 受教了 期待架构思维实战篇👍
- leslie2019-11-26期待老师的《架构思维设计》:希望老师可以在阳历年之前分享出来,这样可以更好的用在自己的将来新项目上。 一路跟着老师学习还是觉得受益匪浅,许多思路梳理清楚了。看到很多扩展性的坑、边界有时需要自己不断的调整到更高的高度,完成了一个类似看到了、审清还要做清,例如:架构的分级乱、接口乱、代码的效率/规范化乱、执行乱。 整体架构的思维设计确实可以梳理出许多:老师讲《中间件存储》时,根据老师的内容梳理清楚了数据系统这块,总体的架构思维还是期待老师的加课能够提供不一样的思维方式吧,这样自己可以做的更好。期待老师的《架构思维设计》,谢谢老师的分享。展开共 1 条评论1