IdbI

许式伟谈架构2

四、服务治理篇

47 | 服务治理的宏观视角

服务治理的目标

很多开发人员可能会习惯地认为,把软件开发出来交付给用户是其工作的结束。但实际上对于任何一个产品或者产品里面的某项功能来说,把东西开发出来只是个开始,实际上这个产品或功能在其被取代或去除之前,都会有很长一段时间的维护期。

\t上图是很基础的产品或功能的生命周期示意图。它并不只是对软件适用,而是对所有的商品适用。我们后面在 “软件工程篇” 中还会进一步探讨它。对于这个示意图,我们核心需要理解的是两点:

其一,虽然功能开发阶段的成本是非常显性的,但是功能维护期,包括了功能迭代和售后维保,它的隐性成本往往更高。

其二,产品的功能开发期虽然有可能很短,但是它是起点,是源头。它每一分每一秒时间是怎么花的,很大程度上决定了这个产品或功能的最终维护代价。

互联网的诞生,对今天我们的生活产生了翻天覆地的影响。虽然细究起来它进入民用市场还只有短短二十多年的历史,但它的发展速度只能以 “恐怖” 来形容。以互联网为载体的软件,它不只是在功能上要满足用户需求,还要提供健康的 24 小时不间断的服务。功能开发与维护的边界变得模糊,一些公司甚至每天都在发布新的版本。要做到 24 小时不间断服务,这并不是那么容易的一件事情。

我们知道,传统意义上的操作系统,实现的是软件治理,它们的关注点是如何让众多的软件一起融洽相处,感觉上好像自己在独享着物理的硬件资源。而服务治理的核心目标,除了软件治理外,更重要的是考虑如何确保这些软件能够真正做到 24 小时不间断的服务。而这,才是服务端操作系统的使命。

服务治理系统

在上一讲,我们已经介绍了部分提供 24 小时不间断的服务所带来的挑战。但我们上一讲的侧重点在业务架构,所以我们主要关注点放在了对业务架构产生重要影响的内容,比如负载均衡和存储中间件。

从服务治理角度来说,把软件做出来只是一个开始。接下来我们面对的第一件事情,是如何把它发布出去。这就需要涉及部署、升级和版本管理等相关的话题。软件在线上成功跑了起来,为用户提供了服务,我们接着面临的挑战是怎么保证它不会挂掉。这涉及非常多层面的事情。

首先是怎么知道服务是不是挂了,这就涉及监控与报警。在发现服务挂掉后,需要考虑尽快把它重启起来,恢复到正常的状态。微观上某个软件进程挂掉不能影响到正常的服务。所以我们需要考虑各类故障域,尽可能全面地把单点故障的风险消除掉。

单点故障消除,有可能会是个运维问题,但更多时候我们也得从软件的业务架构层面去解决它。服务治理并没有那么简单纯粹。虽然在理想情况下我们应该尽可能自动化所有故障的恢复,但故障的可能性太多,很多时候是我们无法提前预知的,这意味着人工介入无可避免。所以,互联网不只是产生了服务端开发这样的工种,同时也产生了运维,或者说业务 SRE 这样的工种。

SRE 全称是 Site Reliability Engineer (网站可靠性工程师),这是 Google 引入的一个职位,后被各类公司所借鉴。区别于传统意义上的运维,SRE 也是一个特殊的工程师群体,和服务端开发一样,他们肩负着自己独特的使命。

从服务端近年来的发展来看,产业进化的方向无不与服务治理相关:如何保证服务 24 小时不间断地运行。故障基本上是难于避免的。可以导致故障的因素非常多。我们大体可以分为这么几个层面。

其一,软硬件升级与各类配置变更。变更是故障的第一大问题源头。保证系统不出问题的最简单的方法当然是不去升级。但从用户的服务体验和竞争力的角度来说,升级又是必需的。所以这是一个服务端开发与 SRE 之间做平衡的问题。

其二,软硬件环境的故障也可能引发我们的服务异常。软硬件环境的故障包括:单机故障如硬盘坏、内存坏、网卡坏、系统死机失去响应或重启等。机房或机架故障如断网、断电等。区域性故障如运营商网络故障、DNS 服务商故障、自然灾害比如地震等。对于一个规模化的服务系统,从不间断服务的角度,低概率的软硬件环境故障就会变成必然事件。比如我们考虑,假设一块硬盘的寿命是三年,也就是说每 1000 天可能会发生一次故障,但如果我们的服务集群有 1000 块硬盘,这就意味着平均每天都会坏一块盘。

其三,终端用户的请求也可能引发故障。比较典型的场景是秒杀类,短时间内大量的用户涌入,导致系统的承载能力超过规划,产生服务的过载。当然还有一些场景比如有针对性的恶意攻击、特定类型的用户请求导致的服务端资源大量消耗等,都可能引发服务故障。

所以,一个合理的服务治理系统,不只是需要能够及时反应业务系统的健康状况。更重要的是,要在发生了故障的情况下,能够提供故障跟踪与排查的有效线索,方便业务 SRE 可以快速定位跟踪的根因(Root Cause),并进行及时的止损。当然,大部分情况下服务是正常的。但这并不代表我们就不会遇到麻烦。从服务单例用户的角度来说,我们服务可能没有发生故障,但是我们的某个用户就是访问不了我们的服务,或者访问服务没有得到预期的结果。

从单例用户的支持角度,我们还需要考虑服务的可支持性。为什么我访问不了?为什么我点击某个按钮没有反应或者报错?如果我们不体系化去考虑这些问题,我们的售后支持将极其低效。

综上所述,一个服务治理系统看起来是这样的,这很不容易:

服务治理的发展历程

服务治理的发展进程涉及面非常之广。有自动化,有业务架构改造,还有人力(SRE)。最早,我们可能从最基本的脚本开始。我们可能 SSH 进入某一台机器,执行特定脚本。最初的自动化努力给我们争取了足够的时间和必不可少的经验。脚本的适用性如何?怎么才能让单个脚本不是 “任务” 的抽象,而是 “服务治理方法论” 的结果?

我们的期望,是把服务治理建立成自治系统,而不是简单的自动化系统。

基于这样的思考,人们逐渐建立了基于物理机器资源的服务治理体系。脚本成为了平台。而平台的形成,正是脚本的抽象化、产品化、普适化的结果。把一个服务实例绑定在某一台物理的服务器,虽然让服务视图看起来很直观,但是这种绑定让我们应对物理资源故障变得被动,同时也不利于服务器资源的充分利用。

所以虚拟机和容器技术的诞生,促使人们开始探索物理资源和应用服务之间的解耦。而一旦我们完成了这一步,服务的逻辑视图就完全语义化了,它与物理资源就只是一个应用的过程。物理资源环境发生任何故障,都可以迅速在新的硬件设备上重新构建。

对 SRE 来说,机器的损坏和生命周期管理基本上已经不需要任何操作了。硬件已经被池化。成千上万的机器加入系统,或者出现问题,被修复,这一切都不需要 SRE 的任何操作。这意味着,随着系统的层次结构不断上升,我们完成了从手动触发,到自动触发,到自主化。这正是今天 DCOS(数据中心操作系统)走的路。

结语

今天我们对本章服务治理篇做了概要的介绍。服务治理不是纯理论,没有简洁的抽象问题模型,我们面对的是现实世界的复杂性。这些现实的复杂性,必然带来解决方案的复杂性。直到今天为止,很多问题仍然没有被圆满解决。但是,它们的确已经在被解决的边缘。相关领域的探索与发展,日新月异。

48 | 事务与工程:什么是工程师思维?

服务治理的目标,是保障软件提供 24 小时不间断服务。服务治理没有简洁的抽象问题模型,我们需要面对的是现实世界的复杂性。保障服务的健康运行,必然有大量的事务性工作,运维或 SRE(网站可靠性工程师)这样的职业也由此诞生。

事务与工程

但是如果我们停留在事务中不能出来,那么随着我们所服务的用户数量增加,必然需要招聘大量的人员来应对繁重的事务工作。事务性的工作不会总是让人不开心,特别是工作不太多的时候。已知的、重复性的工作有一种让人平静的功效。完成这些事可以带来一种满足感和快速胜利感。事务工作可能是低风险低压力的活动,有些员工甚至喜欢做这种类型的工作。

但是我们必须清楚,在 SRE 所扮演的角色中,一定数量的事务工作是不可避免的,这其实是任何工程类工作都具有的特点。少量的事务存在不是什么大问题。但是一旦事务数量变多,就会有害了。如果事务特别繁重,那就应该非常担忧了。如果花在工程项目上的时间太少,你的职业发展会变慢,甚至停滞。我们可以鼓励那些做脏活累活的人,但仅仅限于在这些工作不可避免,并有巨大的正面影响的时候才会这样做。没有人可以通过不停地做脏活累活实现自己的职业发展。

把问题彻底解决

那么,什么是工程师思维?

在部分所谓的技术导向型公司,可能存在一些思维惯性,销售和产品经理会觉得自己没有话语权,开发工程师会觉得自己的地位高人一等。对此我其实很反感。推崇技术当然不是个问题,但是所有的健康公司都必然是业务导向的公司,所有的技术人员如果希望有好的职业发展,也必然需要去理解业务。

七牛是推崇工程师文化的,但工程师文化显然并不是去尊崇工程师这样的职业。什么才是真正的工程师文化?

从浅层的意义来说,工程师就是要实现业务的自动化。DON'T REPEAT YOURSELF! 某件重复发生的事情只干一次就好,以后也不需要再重复做。工程师的自动化思维,所体现的内在逻辑是如何把问题 Close,如何把问题彻底解决掉,而编码只是一种工具。

在我们日常生活中,很多问题不需要编码来解决,但是确实需要用 “彻底解决它” 的思维去完成。这种思维不仅限于工程师,同样适用于所有人。比如,我们开餐厅需要解决服务质量的问题,这一点可能海底捞就解决得很好,但是不一定是用编码的方式解决。同样地,假设我们办线下市场活动,要解决内容质量的问题。怎么彻底解决它,这是值得深度思考的问题。

很多人会习惯呆在自己的舒适区,习惯于做任务,每天重复相同的作业,这就不符合我们所说的 “工程师文化”。我们需要达到的状态是,今天干完一件事,明天开启新的事。怎么判断自己在做新的事情?那就要看我们问题是否解决得够彻底。

比如我在做新媒体运营,每天写着不同的公众号文章,这是否代表我在做新的事情?答案显然是不一定。要回答这个问题,我们首先需要搞清楚的是,我每天发公众号文章,是在解决一个什么样的问题。如果我们没有想清楚这一点,那么我们就不是在 Close 问题,我们只是在做任务而已。

我们的目标显然不应该是每天发一篇文章。这是在定义一件事务,而不是定义一个目标。把问题定义清楚非常非常重要。清楚了问题,就是设定清楚了我们的目标。然后才能谈得上去彻底解决掉它。

从另一个维度看,工程师这种把问题 Close,彻底解决掉的思维,看重的是自己工作内容的长期价值。如果我们只是在做事务,如果我们并没有在实质性解决一个问题,那么这件事情的长期价值就是零。

所以本质上,工程师文化也是产品文化,把问题以一种自动化的方式解决。这才是我们真正应该尊崇的工程师文化。

一个公司各个岗位是彼此协作的团队,工程师并不是特殊群体。销售、技术支持、产品、开发工程师每一个角色都是平等的。每个人都应该秉承工程师精神,把一个个问题 Close,让它不要再发生。不需要显得很忙,忙不代表成就,真正的工程师文化应该是推动整个团队往前走,每个团队成员都在成长。

系统化思维与批判精神

从更深层次来说,工程师思维是一种系统化的思维。仅仅是编码和自动化是不够的,很可能你编码也只是在实现某种事务性工作,而不是用系统性或者说结构化的方案来解决问题。真正的工程师会系统化地考虑方案的有效性。他们追求的是用最小化的编码工作来解决更大范围的问题。

少就是指数级的多!

现实中,一些工程师经常对于自己编写的代码形成一种情感依附,这是人之常情。一些人可能会在你删除多余代码时提出抗议:“如果我们以后需要这个代码怎么办?”“我们为什么只是把这些代码注释掉,这样稍后再使用它的时候会更容易吗?”“为什么不增加一个功能开关?”

这些都是糟糕的建议。源代码管理系统中的回滚其实很容易,但大量的注释代码则会造成干扰和混乱,尤其是我们还要继续演进时。那些由于功能开关没有启用而没有被执行的代码,更是像一个个定时炸弹一样等待爆炸。

极端地说,当你指望一个软件 24 小时不间断服务时,在某种程度上来说每一行代码都是负担。所以 SRE 需要推崇的实践是保证所有的代码行都有必须存在的目的。另外,从软件工程角度来说,传统意义上的工程强调的是复制性,但软件的编码却是一项不确定性很强的创新性工作,我们总在不断迭代出新的技术。所以软件工程是颇为复杂的东西,它需要在不确定性和复制性这对儿矛盾中平衡。

所以优秀的工程师还需要有批判精神。经验当然是有价值的,但过于相信惯例就会抑制创新能力。寻求本源,不迷信惯例和权威。以数据为指导,从根源出发去系统性解决问题。

结语

今天看起来我们的话题有了一次比较大的跳跃,谈起了工程师思维和工程师文化。但服务治理不是纯理论,没有简洁的抽象问题模型。我们面对的是现实世界的复杂性。这些现实的复杂性,背后是大量的事务工作,尤其是我们对问题还不够了解的时候。

这个时候,工程师思维在背后起到了关键性的支撑。正是我们坚持了批判精神,坚持了以系统化的思维来把问题彻底解决,才有今天服务治理系统的日新月异的发展。

五、架构思维篇

57 | 心性:架构师的修炼之道

今天开始,我们终于进入第五章,也就是大家常规认为的架构课的内容:架构思维篇。怎么还没有谈架构?这可能是很多人心中的疑问。这个问题我们今天后面会给出它的答案。但是我相信所有的读者最关心的一个问题无疑是:怎么成为优秀的架构师?架构师的修炼之道究竟是什么?

我的答案是:修心。心性,是架构师区别于一般软件工程师的地方。也是为什么他能够看到那么多人看不到的关键点的原因。

同理心的修炼:认同他人的能力

在前面几个章节,我们已经陆续介绍了架构的全过程:

  • 17 | 架构:需求分析 (上)
  • 18 | 架构:需求分析(下)- 实战案例
  • 32 | 架构:系统的概要设计
  • 45 | 架构:怎么做详细设计?

但架构师面临的问题往往是错综复杂的。给你一个明确的需求说明文档,干干净净地从头开始做 “需求分析”,做 “概要设计”,做模块的 “详细设计”,最后编码实现,这是理想场景。

现实中,大多数情况并不是这样。而是:你拿到了一份长长的源代码,加上少得可怜的几份过时文档。然后被安排做一个新功能,或者改一个顽固 Bug。

你接手的代码量,比前面我们架构实战案例 “画图程序” 长得多,动辄几百万甚至上千万行的源代码。文档也要少得多,没有清晰的网络协议和接口文档,更别提详细设计文档。有句程序员界的名言:“程序员最讨厌的两件事情:一件事情是写文档,一件事情是接手的代码发现没文档”。这是很真实的对现实的写照。

我知道对于 “画图程序” 这个案例,一些读者会说:怎么一上来就给我这么复杂的实战案例,就不能循序渐进一点么?

但它真的已经是最小的架构案例了,不到一千行的代码规模。相比在现实中你不得不面对几百万甚至上千万行的代码规模的工程,这只是小菜一碟。下一章的 “软件工程篇”,我们会探讨怎么阅读别人的源代码。现在我们还是先回归到真实的场景:给一个几百万甚至上千万行的工程项目增加一个新功能,修改一个顽固 Bug,或者心一横做重构。

问题是:你真的是在改善系统,还是在破坏系统?

很多时候,你是在破坏系统。代码为什么会散发臭味?就是因为有很多很多缺乏良好架构思维能力的工程师在加功能,在做重构。

我说的不太客气。但需要认清的一点是,这就是绝大部分公司面临的现实问题。最值得研究的是重构。重构不为改善用户体验,它的目标是为了改善系统质量,清除代码中的臭味。但现实中也有不小比例的重构实际上是在让问题变得更糟糕。

为什么会这样?因为,这里有工程师发展成为架构师所需要的最重要的心性修炼:

认同他人的能力。

架构师最重要的是有同理心,要有认同他人的能力。不要在没有全面理解他人思想的情况下去调整既有代码的设计逻辑。读懂他人的思想。

这很难。所以,如果真冲着读懂他人思想的目标去,接手一个模块刚开始往往会比较慢,具体需要花多久取决于你的经验积累。

理解一个系统的架构,如果当初做这个系统的几个核心人员你还联系得上,那么不要犹豫,争取一个小时的时间和他们做沟通。这比你直接一上来就啃代码要好。纯啃代码就好比做逆向工程把机器码转回源代码,是一件非常复杂的事情。但是如果联系不到人,就只能老老实实去啃代码。结合文档看代码往往会事半功倍,但也要注意识别出文档和代码不一致的情形,把它们记录下来。

经验积累得多了,看到源代码就能很快体会别人的思想。这背后所依赖的,其实也是架构能力。架构师往往对一个需求场景会有多条实现路径的思考和评估。这样的思考和评估做多了,看到别人的代码就容易建立熟悉感,一眼看出别人的思路是什么。

架构师的同理心,也体现在需求分析上。这在某种意义上来说是更难的一件事情。因为,接管系统的时候要了解的是其他架构师的思想,它毕竟是你熟悉的场景。而需求分析则是理清用户需求的能力,你需要代入用户,理解用户的核心诉求。所以需求分析需要更强的空杯心态,去认同他人。

全局观的修炼:保持好奇心与韧性

架构师第二大能力,是全局观的修炼。没有了全貌,那就是井底之蛙,谈何架构?为什么我们的架构课不是一上来就谈架构思维?这是因为,并不是我们理解了架构思维的原则,就能够做好架构。

架构之道,是虚实结合之道。我们要理论与实践相结合。不可能只要理论,否则架构师满天飞了。如果两者只能取其一,我选实践。

从实悟虚,从虚就实,运用得当方得升华。这其实是最朴素的虚实结合的道理。对学架构来说尤其如此。架构思维的感悟并不能一步到位,永远有进步的空间,需要我们在不断实践中感悟,升华自己的认知。

架构课内容的前四章为 “基础平台”、“桌面开发”、“服务端开发”、“服务治理”。

从内容上来说,由 “基础平台(硬件架构 / 编程语言 / 操作系统)”,到 “业务开发(桌面开发 / 服务端开发)”,再到 “业务治理(服务治理 / 技术支持 / 用户增长)”,基本上覆盖了信息技术主体骨架的各个方面。

有了骨架,就有了全貌,有了全局的视角。

前面四章,我们内容体系的侧重点放在了架构演变的过程。我们研究什么东西在迭代。这样,我们就不是去学习一个 “静态的”、“不变的” 信息技术的骨架,更重要的是我们也在学信息技术的发展历史。

有了基础平台,有了前端与后端,有了过去与未来,我们就有了真真正正的全貌。我们博览群书,为的就是不拘于一隅,串联我们自身的知识体系,形成我们的认知框架。这很难。

很多人都有关于 “广度” 与 “深度” 的辩证与困惑。全局观这件事情,对于心性上的修炼,比的是好奇心与韧性。

保持对这个世界的好奇心。看到新科技与新思想,先认同它,去体会它,理解它产生的需求背景与技术脉络,以此融入自己的知识体系。持续地学习。

架构师需要有学习韧性。但并不是所有的技术都值得深耕。我们也都没有这个精力去这么做。我们要做到的是,随时想深入耕耘就能深入。

怎么深耕,更多的是结合自己的工作内容和兴趣。很多工程师会有困惑,觉得自己的工作内容平淡无奇,没法让自己进步,但实际上瓶颈不在于工作内容,在于自己心性的修炼。好的架构师,拥有化腐朽为神奇的能力。

迭代能力的修炼:学会否定自己

架构师第三大能力的修炼,是迭代,是反思,是自我批判。

不管你喜不喜欢,工程师天天都在接任务,码代码。但是差别在于,你究竟在用什么样的态度来接任务,码代码。

关于码代码,不少优秀的工程师都有这样的体会:洋洋洒洒写了好多代码,过了半年一年,自己看着怎么看怎么不爽。如果你有同感,那么恭喜你:你进步了。

但,接下来的事情更重要。你是捏着鼻子忍着,继续接老板安排下来的新任务;还是,百忙里抽出一点时间,把之前写的代码改到你满意的样子。

这个改代码的过程,它之所以重要,是因为它才是真真正正的架构能力升华的过程。通过迭代而升华。这是架构能力提升之路。你的收益不会只是你重构的那一个模块本身。通过重构,你建立了新的知识体系。它是内在根本性的变化,看不见但你自己可以体会得到。

接任务的态度也很重要。面对每一个新的开发任务,都是一次重新审视架构合理性的机会。

就算这个模块从来没有交给过他人,所有代码都是你自己写的,也不代表这个模块就不会老化,发出臭味。这里面的原因在于,很多时候你给模块加上新功能的时候,往往会出现很多当初架构没有考虑到的场景,导致不得不用打补丁的方式把需求给满足了。

一个需求捏着鼻子做,两个需求捏着鼻子做,慢慢的系统就不堪重负,变得很脆弱,到后面加功能就会变得越来越困难。

所以,发现架构无法很方便地支持某个需求,就意味着架构存在缺陷,这时要及时停下来思考以下问题:

  • 还有哪些潜在的需求,现在还没有收到,但是未来可能会需要去满足?
  • 如果这些需求当初就提出来了,架构做成什么样更合理一些?
  • 当前的架构设计,迭代到新架构设计,它的成本是什么样的?

在架构调整这件事情上,早迭代,小步迭代,比做一个大的重构版本要好。

结语

架构师成长之旅,是心性修炼之旅。我们需要记得,并不是理解了架构思维的原则,就能够做好架构。架构之道,是虚实结合之道。理论与实践相结合。

从实悟虚,从虚就实,运用得当方得升华。架构思维的感悟并不能一步到位,永远有进步的空间,需要我们在不断实践中感悟,升华自己的认知。从技能来说,我们可能把架构师能力去归结为:

  • 理需求的能力;
  • 读代码的能力;
  • 抽象系统的能力。

但是架构师修炼之道,更难的是在心性上,这包括:

  • 同理心的修炼,认同他人的能力。
  • 全局观的修炼,保持好奇心和学习的韧性。
  • 迭代能力的修炼,学会反思,学会在自我否定中不断成长。

58 | 如何判断架构设计的优劣?

想要让自己进步,我们首先得知道什么是好的。所以我们今天的话题是,如何判断架构设计的优劣?

架构设计的基本准则

架构设计会有它的一些基本准则。比如:

  • KISS:简单比复杂好;
  • Modularity:着眼于模块而不是框架;
  • Testable:保证可测试性;
  • Orthogonal Decomposition:正交分解。

KISS 全称是 Keep it Simple, Stupid,用最直白的话说,“简单就是美”。不增加无谓的复杂性。正确理解系统的需求之后才进行设计。要避免过度设计,除非有人为复杂性买单。KISS 的“简单”,强调的是易实施性。让模块容易实现,实现的时候心智负担低,比复杂的优化更重要。KISS 的“简单”,也是主张让你的代码,包括接口,符合惯例。接口语义要自然,最好让人一看方法名就知道怎么回事,避免惊异。

Modularity,强调的是模块化。从架构设计角度来说,模块的规格,也就是模块的接口,比模块的实现机制更重要。我们应着眼于模块而不是框架。框架是易变的。框架是业务流,可复用性相对更低。框架都将经历不断发展演化的过程,逐步得到完善。所以不让模块为框架买单。模块设计时应忽略框架的存在。认真审视模块的接口,发现其中“过度的(或多余的)” 约束条件,把它提高到足够通用的、普适的场景来看。

Testable,强调的是模块的可测试性。设计应该以可测试性为第一目标。可测试往往意味着低耦合。一个模块可以很方便地进行测试,那么就可以说它是一个设计优良的模块。模块测试的第一步是环境模拟。模块依赖的模块列表、模块的输入输出,这些是模块测试的需要,也是模块耦合度的表征。当然,可测试性不单单因为是耦合的需要。测试让我们能够发现模块构架调整的潜在问题。通常模块在架构调整期(代码重构)最容易引入 Bug。 只有在模块开发过程中我们就不断积累典型的测试数据,以案例的形式固化所有已知 Bug,才可能在架构调整等最容易引发问题的情形下获得最佳的效果。

Orthogonal Decomposition,中文的意思是 “正交分解”。架构就是不断地对系统进行正交分解的过程。相信大家都听过一个设计原则:“优先考虑组合,而不是继承”。如果我们用正交分解的角度来诠释这句话,它本质上是鼓励我们做乘法而不是做加法。组合是乘法,它是让我们用相互正交、完全没有相关性的模块,组合出我们要的业务场景。而继承是加法,通过叠加能力把一个模块改造成另一个模块。

核心系统的伤害值

正交分解,第一件事情就是要分出哪些是核心系统,哪些是周边子系统。核心系统构成了业务的最小功能集,而后通过不断增加新的周边功能,而演变成功能强大的复杂系统。

对于核心系统的变更要额外小心。如果某新功能早期没有规划,后期却被界定为属于核心功能,我们就需要认真评估它对既有架构的破坏性。至于周边功能,我们核心考虑的是,如何降低添加一个新的周边功能对核心系统的影响?

不论哪一种情况,如果我们不够小心,系统就会由于不断增加功能而变老化,散发出臭味。为了减少一个功能带来的负面影响,这个功能相关代码首先要做到尽可能内聚。

代码不一定要写到独立的模块(如果代码量不算大的话),但一定要写到独立的文件里面。对于周边系统,这部分独立出来的代码算是它的功能实现代码,不隶属于核心系统。

我们的关注点是某个周边功能对核心系统的影响。为了添加这个功能,它必然要求核心系统添加相关的代码以获得执行的机会。

我们根据经验可以初步判断,核心系统为这个周边功能增加的代码量越少,那么这个功能与核心系统的耦合就越低。那么,是否有可能把一个功能的添加对核心系统的影响降低到零,也就是不改一行代码?这当然是可能的,只不过这要求核心系统需要提供所谓 “插件机制”。后续我们会继续探讨这个话题,今天暂且按下不表。

我们先把话题收回到架构设计质量的评估。

上面我们谈了一些架构设计的基本准则,但还谈不上是质量评估的方法。质量判定的方法可以是定性的,也可以是定量的。定性的判断方法有一定的数据支撑,虽然这种支撑有可能是模糊而感性的。比如我们通常会说,“从 XXX 的角度看,我感觉这个更好”。这里 XXX 是某种定性分析的依据。

从科学严谨性的角度,有定量的判断方法是更理想的状态。可惜的是,到目前为止,我个人并没有听到过任何定量的判断方法来确定架构设计的优劣。但今天我会给出一些个人发明的判定公式。它们都只是经验公式,并没有经过严谨的数学证明。我们假设,某个架构设计方案将系统分成了 n 个模块,记为:[M1, M 2 , ..., M n]。其中 M1 是核心系统,其他模块是周边系统。为简化,我们不妨假设周边系统与周边系统间是正交的,相互没有耦合。

那么,我们第一个最关注的问题是:核心系统受到各周边系统的总伤害是多少?

这里有一个周边功能对核心系统总伤害的经验公式:

同一个周边功能相邻的代码行算作一处修改。不同周边功能的修改哪怕相邻也算作多处。这个公式核心想表达的含义是:修改处数越多,伤害越大。对于每一处修改,鼓励尽可能减少到只修改一行,更多代码放到周边模块自己那里去。

这个伤害值公式,当然也同样适用于度量某个周边功能对核心系统的影响面。核心系统越干净,增加新功能越容易。由于核心系统的地位,所以这个公式实际上是最重要的测量公式。

模块的耦合度测量

我们第二个关注的问题,是每个模块自身的质量。模块自身的质量具体来说,又包括模块接口的质量和模块实现的质量。我们先看模块接口的质量,这是模块级别最重要的东西。它取决于以下两个方面:

其一,接口与业务的匹配性。简单说,就是接口越自然体现业务越好。然而,从机器判定的角度来说,这一点是不可计算的,完全依赖于个人的主观判断。我们在下一讲 “少谈点框架,多谈点业务” 中将会继续探讨这个话题。

其二,接口的外部依赖,也就是模块接口对外部环境的耦合度。

下面我们要介绍的是模块的 “耦合度测量公式”。它同时适用于模块实现和模块接口的耦合度测量。

假设,我们的模块实现(或模块接口)依赖了模块 A,那么我们的模块实现(或模块接口)与所依赖的模块 A 的耦合度为:

依赖的符号(symbol)是指:

  • 被引用的类型,包括 typedef(type alias)、class 或 struct;
  • 被引用的全局变量、全局函数或成员函数。

单个模块清楚了,我们看模块实现(或模块接口)的所有外部依赖,也就是该模块的总耦合度公式为:

其中,耦合度 A 是该模块与依赖模块 A 的耦合程度,公式见上。不成熟度系数 A 表征依赖模块 A 的不成熟度程度。如果依赖模块 A 完全成熟,不会再发生改变则为 0;如果模块在发生非常剧烈变动,连规格都完全没法确定则为 1。

通过该耦合度测量公式可以看出,我们鼓励依赖外部成熟模块。理论上完全成熟的模块很可能就只有语言内置的数据类型如 int、string 等,其他模块多多少少还是会经受一些变化,所以还是尽量减少外部依赖。另外值得一提的是,将模块接口引用的类型 A 改为 object 或 interface{} 类型并不能降低耦合度。也就是说如果某参数为 interface,那么这个 interface 的耦合度要看功能实际使用时,它存在各种可能的类型,都会计算在依赖中。

我们应该怎么看待耦合度测量公式?

需要强调的是,它是一个经验公式,仅仅是代表了某种价值主张。在实际应用中,计算得到的具体耦合度值并无物理含义,只能用来对比两个相同功能的系统(或模块)架构设计方案。对于两个功能完全不同的 A、B 系统(或模块),其计算结果并不能用于评判彼此的好坏。

结语

今天我们探讨的话题是如何评判架构设计的优劣。首先我们谈的是架构设计的基本准则,它们虽然不足以明确说谁好或是不好,但是指明了方向。然后我们开始对架构好坏做定性甚至定量的分析。考虑到核心系统的重要性,我们单独引入了一个伤害值来评估它的纯洁度。最后,我们对模块自身的接口或实现,给出了耦合度测量公式。通过这个公式,我们明确了我们架构设计的价值主张。

但我们需要意识到的一点是,这些并不是全部。判断模块间的耦合度是复杂的。上面我们的公式某种程度上来说只考虑了静态依赖关系,而没有考虑动态依赖。比如说,我们考虑两个网络模块 A 和 B,一个显而易见的耦合度判断是:

  • A 调用 B 的网络接口数量越多,依赖越大(静态依赖,上面我们已经考虑);
  • A 调用 B 的网络接口的次数越多,依赖越大(动态依赖,上面我们未考虑)。

59 | 少谈点框架,多谈点业务

架构是共识确认的过程

对于架构这件事情,有不少让人误解的地方。前面在 “57 | 心性:架构师的修炼之道” 一讲中,我们提到过架构师需要掌握的三大技能:理需求的能力;读代码的能力;抽象系统的能力。

这里面除了 “读代码” 这件事可能允许没有什么显性的产出外(其实也应该有,去读代码通常意味着缺架构设计文档,所以按理应该去补文档),其他两类事情要做好都不容易。

就理需求的能力而言,很多架构师一不知道要做需求分析,二不知道需求分析的产出到底应该是什么样的。需求分析可以说是架构师最没有概念的一个环节,尽管它至关重要。这一块领域特征比较明显,课堂上讲师授课的方式,很难有好的成效,更适合以实训的方式来强化。

就抽象系统的能力而言,很多架构师爱画架构图。画完了架构图,他就认为架构做完了,下一步该去编码。

这有什么问题?

首先,架构过程是团队共识形成与确认的过程。共识是需要精确的、无歧义的。而架构图显然并不精确。团队没有精确的共识很可怕,它可能导致不同模块的工作牛头不对马嘴,完全无法连接起来,但是这个风险没有被暴露,直到最后一刻里程碑时间要到了,要出版本了,大家才匆匆忙忙联调,临时解决因为架构不到位产生的“锅”。

这时候人们的动作通常会走形。追求的不再是架构设计的好坏,而是打补丁,怎么把里程碑的目标实现了,别影响了团队绩效。

我们作个工程师的类比,这种不精确的架构,就好比建筑工程中,设计师画了一个效果图,没有任何尺寸和关键细节的确认,然后大家就分头开工了。最后放在一起拼接(联调),发现彼此完全没法对上,只能临时修修改改,拼接得上就谢天谢地了。是不是能够和当初效果图匹配?让老天爷决定吧。

更精确描述架构的方法是定义每个模块的接口。接口可以用代码表达,这种表达是精确的、无歧义的。架构图则是辅助模块接口,用于说明模块接口之间的关联。

为了证明接口的有效性,架构师还应该过一遍所有的用户故事,以伪代码或流程图的方式,把所有用户故事过一遍,确认模块之间的接口串起来是可以正常工作的。实际上更有效的方法是在概要设计(也叫系统设计)阶段就把框架代码写出来,真真正正用代码,而不是伪代码,把用户故事串一遍。

代码即文档。代码是理解一致性更强的文档。

这样做的好处是,我们把联调工作做到了前头,工程的最大风险就得到了管理。剩下来的就是每个模块自身的好坏,这就和组织能力无关,只取决于我们招聘的工程师个体素质了。所以模块的接口,是架构设计的核心。

别让框架绑架业务

接口代表什么?接口代表业务。架构图代表什么?架构图代表框架。

不要让框架绑架业务。

在架构的两侧,一边是用户需求,一边是技术。接口代表用户需求,代表业务。框架代表技术,是我们满足需求的方法。

框架它是重要的。但是不要让框架反客为主,溢出模块边界。在系统迭代的过程中,框架会经受变化,以适应需求的演进过程。抓住稳定的东西,比追逐变化更重要。

框架,体现的是需求泛化的能力。从架构思维角度上来说,它是通过抽象出需求模板,把多个需求场景中变化的部分抽离出来,形成相对稳定的泛化需求。

框架的抽象能力不是一蹴而就的,它既依赖我们抽象系统的能力,也依赖我们对领域需求的理解程度。所以框架会随着时间而迭代,逐步向最理想的状态逼近。

如果框架不能满足需求,但我们不迭代框架,而是硬生生去添加这样的功能需求,会发生什么?

结果是,代码逃逸出框架,把系统搅得支离破碎。这时候你可能能够嗅到一丝危险的气息。但是你可能说没办法,里程碑的截止时间就在那里,没办法。这实际上是大框架面临的最大挑战。它最好能够提前预测所有可能的需求,以此抑制潜在代码逃逸的风险。

但这很难。所以我们应该换一个角度看这个问题。在如何持续保证系统洁净的这件事情上,我个人给的建议是:

连接性的代码越少越好。

什么是连接性的代码?就是把两个子的业务系统连接,构成一个大业务场景的代码。如果有大业务场景,应该抽象出新的更大范畴的业务系统。这样我们的焦点就始终在业务上。

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

抽象出符合业务自然语义的接口,远比抽象出泛需求的框架要容易得多。因为,业务语义是稳定的。关注业务接口的定义,我们自然就把焦点转向关注业务如何由相互正交的子业务组合而来。

我们举例来说明关注业务与关注框架这两种思维方式的差异性。我们知道,在 IO 系统中,读取磁盘文件中的数据有两种常见的模型:SAX 和 DOM 模型。

SAX 模型是一种基于事件的读盘机制。在读完一个完整的数据单元时,就发送一个读到某数据单元的事件。比如在 XML 中,它的事件接口看起来是这样的:

DOM 模型则基于对象的组织模型来提供数据读取的能力。细分来说,它又有两种不同的选择。一种是基于抽象的 DOM 树,它看起来是这样的:

另一种是基于更具体的业务逻辑,与具体的领域相关,比如对于 Word/WPS 这样的字处理软件看起来是这样的:

基于 SAX 模型是非常典型的框架思维。它的确足够的通用,但它有两个问题。

一方面,基于事件模型是一个非常简陋的编程框架,与大部分 IO 子系统的需求方的诉求并不那么匹配。关于这一点我们在下一讲 “60 | 架构分解:边界,不断重新审视边界” 还会详细展开。

另一方面,它不体现业务,使用方不能在缺乏文档配合的情况下正确地使用这个接口。本来代码应该是精确的,但是这样的接口把精确性这个最佳的优点给放弃了。

基于 DOM 模型的两种模型中,看起来前者的接口很简洁,但实际上它和上面的 SAX 模型有类似的问题:不体现业务。而后者虽然看起来非常冗长,但是它可以脱离额外的接口说明文档而直接毫无心智负担地使用。毫无疑问,这才是我们该追寻的接口描述方式。

别用实现替代业务

在接口设计中,我们还看到另一种倾向,可以认为是用框架来替代业务的特例:用实现机制替代业务 (注:框架可以理解成实现)。我个人经常给架构师们说的一句话是:

比框架(架构图)更重要的是数据结构,比数据结构更重要的是接口。

为什么数据结构比框架(架构图)更重要?业务数据结构是架构实现机制的灵魂。从共识确认的角度,数据结构相比框架而言,是更重要的共识。

一些架构师能够想清楚实现,但是想不清楚业务。他们用实现替代对业务系统的抽象。

用实现机制替代业务的典型案例是定义了数据结构,但是不抽象数据的业务逻辑,直接让使用方操作成员变量,或者定义一堆成员变量的 get/set 接口。

另一个例子是当我们用 ORM 框架操作数据库时,工程师非常容易犯的错误是,直接操作数据结构,而忽略定义业务接口的重要性。

结语

今天谈的内容,核心指向一点:

  • 架构就是业务的正交分解。每个模块都有它自己的业务。
  • 这里我们说的模块是一种泛指,它包括:函数、类、接口、包、子系统、网络服务程序、桌面程序等等。

它看似简单,但是它太重要了,重要到需要单独一讲来把它谈清楚。它是一切架构动作的基础。架构行为的三步曲:“需求分析”、“概要设计”、模块的 “详细设计”,背后都直指业务的正交分解,只是逐步递进,一步步从模糊到越来越强的确定性,直至最终形成业务设计的完整的、精确无歧义的解决方案。

60 | 架构分解:边界,不断重新审视边界

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

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

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

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 方法我们可能希望知道加载的文件具体是文档格式,所以应该改为:

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),以确认我们的架构适应性。

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

61 | 全局性功能的架构设计

在上一讲 “加餐 | 实战:画图程序的整体架构” 中,我们结合前面几章的实战案例画图程序来实际探讨我们架构思维的运用。这一篇虽然以加餐名义体现,但它对理解怎么开展和评估架构工作非常关键。

在架构设计中,我们会有一些难啃的骨头。其中最为典型的,就是全局性功能。全局性功能的特征是很难被剥离出来成为独立模块的。我们仍然以大家熟悉的 Office 软件作为例子:

  • 读盘 / 存盘:每增加一个功能,都需要考虑这个功能的数据如何存储到磁盘,如何从磁盘中恢复。
  • Undo/Redo:每增加一个功能,都需要考虑这个功能如何回滚 / 重做,很难剥离。
  • 宏录制:每增加一个功能,都需要考虑这个功能执行的结果如何用 API 表达,并且得支持将界面操作翻译成 API 语句。……

也有一些功能看似比较全局,但实际上很容易做正交分解,比如服务端的所有 API 都需要鉴权,都需要记录日志。它们似乎有全局性的影响,但一方面,通常可以在 API 入口统一处理,另一方面就算只是提供辅助函数,具体的鉴权和记录日志都由每个 API 自行处理,心智负担不算太高。所以对于这类功能,我们可以不把它归为全局性功能。正因为需求交织在一起,全局性功能往往难以彻底进行正交分解。但对于架构师来说,难不代表应该轻易就放弃对正交分解的追求。不能放过任何一块难啃的骨头。

读盘 / 存盘功能

不能很好分解往往还是需求的分析没有到位所致。前面在 “60 | 架构分解:边界,不断重新审视边界” 这一讲中我们已经拿 “读盘 / 存盘” 作为案例进行过分析。最终我们选择了引入 IO DOM 来进行正交分解。这里面的关键点在于:

其一,“读盘 / 存盘” 本身需求是发散的,因为要支持的文档格式只会越来越多。所以我们必须把它独立成一个子系统,比如叫它 IO 子系统。

其二,既然要独立子系统,就需要抽象出它对核心系统的稳定依赖。为什么这个稳定依赖最后设计为 IO DOM,是因为 DOM 是核心系统的常规界面。

引入只与数据有关的 IO DOM,相当于给 DOM 规范了一个接口子集,用于和 IO 子系统交互。这样的好处是,虽然 IO DOM 是 IO 子系统对核心系统的侵入,但这是没办法的,因为读盘存盘是全局功能,我们没法消除这种全局性,但是可以尽可能削弱到最低。

如果 IO DOM 的确是 DOM 的子集,我们相当于已经找到了尽可能削弱的方法,因为 IO DOM 名称上虽然带了 IO,但是它只是一个归类,实际上这些接口都是核心系统的常规接口,并非为 IO 子系统定制。这样一来,读盘与存盘带来的全局性影响就近乎被消除了。这是一种很好的思考方式。

全局性功能往往容易带来某种复杂的框架。这不难理解,毕竟它是全局性的,所以常规的思路是为这个功能实现一个库,并建立一套使用它的机制,也就是框架,以应用到核心系统中去。上面 IO DOM 则是反其道而行之,通过抽象核心系统的接口,让全局性功能反向依赖这些接口来完成。这不容易,但是这样做核心系统受到的伤害值最低。

Undo/Redo 功能

我们再看一个例子,比如 Undo/Redo 功能。读过设计模式的小伙伴们可能都知道,在设计模式中有一个模式叫 Command 模式,专门用于解决 Undo/Redo 这个功能场景的。它的基本思路是,每个用户操作都实现为一个 Command,每个 Command 需要实现反操作,以便做到 Undo 的能力。

这是一个典型的 Undo/Redo 框架。实际上这个框架本身做的事情并不多,基本上就是维护一个 Command 队列,并基于这个队列提供 Undo 和 Redo 功能。看起来不错的样子,但实际上框架只节省了 1% 的工作量。其余 99% 的工作量在实现一个个 Command 身上,框架使用方的心智负担不是一点点的大。

那么有可能让 Undo/Redo 与核心系统解耦么?这当然是可能的。

我第一次对 Undo/Redo 实现机制反思的灵感,来自于做 IO 子系统的经历。前面某一讲中我也提过,在我实习的时候,做的第一份工作任务是读盘与存盘。在做需求分析的时候,我发现微软 Office 支持一个很有意思的功能,叫快速存盘。在编辑一份 Word 文档,打几个字存盘时,Word 很快就可以保存完毕,而 WPS 当时则会导致交互界面停顿,存盘没有完成时用户无法编辑。

微软怎么做到的呢?它背后的机制就是快速存盘。所谓快速存盘,就是存盘的时候并不是把完整的文档写到磁盘文件中,而是将上一次存盘到这一次存盘的增量部分,追加到文档的尾部。这样一个 Word 文件就有多个版本的文档,每次读盘的时候只需要读出一个最新版本即可。

当然要想避免系统无法响应用户编辑的另一个思路是异步存盘。也就是在存盘命令执行之初,我对整个文档的 DOM 建立一份镜像(Snapshot),存盘的时候基于镜像进行后台存盘,就不会影响到用户交互。

虽然镜像的实现代价不低,但这个思路有它的独特好处,比如支持异步打印。打印机是比磁盘更慢的 IO 设备。如果在打印的时候用户就没法编辑,也是不太好的用户体验。而打印显然也无法通过类似快速存盘这种机制来实现加速,但镜像功能则可以很好地提升打印的体验。这些对 IO 子系统的思考,为什么会对我思考 Undo/Redo 机制设计有帮助?因为它们有一个共同点,就是都和数据本身密切相关。

比如 Word 文档支持存储多个版本,我们很容易就想到,其实这个机制可以用来做 Undo/Redo。想象一下,如果用户每进行一次编辑,我就自动执行一次快速存盘,这样就在磁盘中形成多个版本。这样在做 Undo 的时候,我们只需要回退到上一个版本的文档即可。

事实上,只要支持了多版本,就有了镜像能力,也有了 Undo/Redo 能力。

这些思考,就促进了后来数据层(DataLayer)的诞生。怎么理解这个数据层?你可以把它类比为服务端的数据库。它是一个存储中间件,负责托管所有的数据。中肯地说,数据层的引入有好有坏。

好处不必多言,有了数据层,所有异步操作不是问题,Undo/Redo 不是问题,也还有更多想象空间。

不好的地方是,它是 Model 层的基础,对我们实现 Model 层的业务逻辑是有侵入的。基于内存数据结构写程序,和基于数据库写程序,体验上会有很大的差异。从避免绑定的角度,我们会尽可能将这种差异隐藏起来,把基于数据层与不基于数据层的差异消除。

当然,随着今天软件服务化(SaaS)大行其道,基于某种存储中间件来写业务逻辑,越来越多人意识到它已经是一种必然的趋势。回顾我们解决 Undo/Redo 的思路,你会发现,它并不是在问题发生的地方解决。这也是需求分析的复杂性所在。

宏录制功能

我们再看 “宏录制” 功能。这个功能使用的人应该不太多,不少人甚至可能并不知道它的存在。要理解 “宏录制”,首先需要知道什么是宏(Macro)。简单来说,所谓宏(Macro),是指二次开发的代码。微软几乎所有的产品都有二次开发接口,也就是 API 层,典型代表是 Office 和 Visual Studio。

有了二次开发接口,就可以有生态,有围绕着 Office 和 Visual Studio 的生态厂商,来增强产品的能力,也可以让 Office 和 Visual Studio 更容易地融入到企业的业务流中。可以说,支持宏是微软做得最牛的地方。那么什么是 “宏录制”?简单说,就是把用户的界面操作用 API 调用的方式记录下来,把它变成一段二次开发代码。

这有几点好处。

其一,被录制下来的 “宏”,可以被反复重放,如果某件事情经常发生,它就可以改善我们的工作效率。你甚至可以为 “宏” 指定一个快捷键,这相当于你作为用户,竟然可以给系统添加新功能。

其二,被录制下来的 “宏”,可以进行修改迭代,进行功能的增强。这有助于二次开发的新手学习 Office 或 Visual Studio 的 API 接口,大幅降低二次开发的入门难度。

那么怎么支持 “宏录制”?这个功能和它比较像的是服务端的日志,只是略有不同。比较像的地方是,宏录制也像日志一样,会去记录一段文本。我们想象一下,如果我们的 Model 层 DOM API 也基于 RESTful API 接口,那么我们就可以在 API 入口的地方去实现 “宏录制”。

不同的地方是,“宏录制” 需要考虑 API 嵌套,我们实现某个 API 可能会调用另外某个 API,但是录制的时候,肯定只能录最外层的 API,而不是所有 API 调用都被录制下来。这些都比较好解决。所以 “宏录制” 相比前面的 “存盘 / 读盘”、“Undo/Redo” 而言,是一个侵略性相对小的功能,心智负担比较低。

架构师的信仰

通过这些例子,我们需要坚定的一个信念是,任何功能都是可以正交分解的,即使我目前还没有找到方法,那也是因为我还没有透彻理解需求。这是架构师的信仰。

换句话说,怎么做业务分解?业务分解就是最小化的核心系统,加上多个正交分解的周边系统。核心系统一定要最小化,要稳定。坚持不要往核心系统中增加新功能,这样你的业务架构就不可能有臭味。

这是我们的信仰。重要的话要说三遍。

在模块演化的过程中,随着功能的增加,复杂模块的演化可能会经历剧烈的调整期。通常这种剧烈调整起源于需求理解的进一步深化,引发对原模块接口的反思。无论如何,记住最重要的一点:保持核心系统的纯洁性比什么都重要。

结语

架构分解中有两大难题。

其一,需求的交织。不同需求混杂在一起,也就是我们今天说的全局性功能。其二,需求的易变。不同客户,不同场景下需求看起来很不一样,场景呈发散趋势。

但无论如何,我们需要坚持作为一名架构师的信仰:

任何功能都是可以正交分解的,即使我目前还没有找到方法。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是大家很熟悉的 “开闭原则(Open Closed Principle,OCP)”。

62 | 重新认识开闭原则 (OCP)

架构的本质是业务的正交分解。

在上一讲 “61 | 全局性功能的架构设计” 中我们提到,架构分解中有两大难题:其一,需求的交织。不同需求混杂在一起,也就是存在所谓的全局性功能。其二,需求的易变。不同客户,不同场景下需求看起来很不一样,场景呈发散趋势。

我们可能经常会听到各种架构思维的原则或模式。但,为什么我们开始谈到架构思维了,也不是从那些耳熟能详的原则或模式谈起?

因为,万变不离其宗。

就架构的本质而言,我们核心要掌握的架构设计的工具其实就只有两个:

  • 组合。用小业务组装出大业务,组装出越来越复杂的系统。
  • 如何应对变化(开闭原则)。

开闭原则(OCP)

今天我们就聊聊怎么应对需求的变化。

谈应对变化,就不能不提著名的 “开闭原则(Open Closed Principle,OCP)”。一般认为,最早提出开闭原则这一术语的是勃兰特·梅耶(Bertrand Meyer)。他在 1988 年在 《面向对象软件构造》 中首次提出了开闭原则。

什么是开闭原则(OCP)?

软件实体(模块,类,函数等)应该对于功能扩展是开放的,但对于修改是封闭的。

一个软件产品只要在其生命周期内,都会不断发生变化。变化是一个事实,所以我们需要让软件去适应变化。我们应该在设计时尽量适应这些变化,以提高项目的稳定性和灵活性,真正实现 “拥抱变化”。

开闭原则告诉我们,应尽量通过扩展软件实体的行为来应对变化,满足新的需求,而不是通过修改现有代码来完成变化,它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。

为什么会有这样的架构设计原则?它背后体现的架构哲学是什么?

本质上,开闭原则的背后,是推崇模块业务的确定性。我们可以修改模块代码的缺陷(Bug),但不要去随意调整模块的业务范畴,增加功能或减少功能都并不鼓励。这意味着,它认为模块的业务变更是需要极其谨慎的,需要经得起推敲的。

我个人非常推崇 “开闭原则”。它背后隐含的架构哲学,和我说的 “架构的本质是业务的正交分解” 一脉相承。

与其修改模块的业务,不如实现一个新业务。只要业务的分解一直被正确执行的话,实现一个新的业务模块来完成新的业务范畴,是一件极其轻松的事情。从这个角度来说,开闭原则鼓励写 “只读” 的业务模块,一经设计就不可修改,如果要修改业务就直接废弃它,转而实现新的业务模块。

这种 “只读” 思想,大家可能很熟悉。比如基于 Git 的源代码版本管理、基于容器的服务治理都是通过 “只读” 设计来改善系统的治理难度。

对于架构设计来说同样如此。“只读” 的架构分解让我们逐步沉淀下越来越多可复用的业务模块。如此,我们不断坚持下去,随着时间沉淀,我们的组织就会变得很强大,组装复杂业务系统也将变得越来越简单。

所以开闭原则,是架构治理的根本哲学。

CPU 背后的架构思维

一种广泛的误解认为,开闭原则是一种 “面向对象编程(OOP)” 领域提出来的编程思想。但这种理解显然太过狭隘。虽然开闭原则的正式提出可能较晚,但是在信息科技的发展历程中,开闭原则思想的应用就太多了,它是信息技术架构的基本原则。注意我这里没有用 “软件架构” 而是用 “信息技术架构”,因为它并不只适用于软件设计的范畴。

我们在 “02 | 大厦基石:无生有,有生万物” 一讲介绍冯·诺依曼体系的规格时就讲过:从需求分析角度来说,关键要抓住需求的稳定点和变化点。需求的稳定点,往往是系统的核心价值点;而需求的变化点,则往往需要相应去做开放性设计。

冯·诺依曼体系的中央处理器(CPU)的设计完美体现了 “开闭原则” 的架构思想。它表现在:

  • 指令是稳定的,但指令序列是变化的,只有这样计算机才能够实现 “解决一切可以用 ‘计算’ 来解决的问题” 这个目标。
  • 计算是稳定的,但数据交换是多变的,只有这样才能够让计算机不必修改基础架构却可以适应不断发展变化的交互技术革命。

体会一下:我们怎么做到支持多变的指令序列的?我们由此发明了软件。我们怎么做到支持多变的输入输出设备的?我们定义了输入输出规范。

我们不必去修改 CPU,但是我们却支持了如此多姿多彩的信息世界。

多么优雅的设计。它与面向对象无关,完全是开闭原则带来的威力。

CPU 的优雅设计远不止于此。在 “07 | 软件运行机制及内存管理” 这一讲中,我们介绍了 CPU 对虚拟内存的支持。通过引入缺页中断,CPU 将自身与多变的外置存储设备,以及多变的文件系统格式进行了解耦。

中断机制,我们可以简单把它理解为 CPU 引入的回调函数。通过中断,CPU 把对计算机外设的演进能力交给了操作系统。这是开闭原则的鲜活案例。

插件机制

一些人对开闭原则的错误解读,认为开闭原则不鼓励修改软件的源代码来响应新需求。这个说法当然有点极端化。开闭原则关注的焦点是模块,并不是最终形成的软件。模块应该坚持自己的业务不变,这是开闭原则所鼓励的。

当然软件也是一个业务系统,但对软件系统这个大模块来说,如果我们坚持它的业务范畴不变,就意味着我们放弃进步。让软件的代码不变,但业务范畴却能够适应需求变化,有没有可能?

有这个可能性,这就是插件机制。

常规我们理解的插件,通常以动态库(dll/so)形式存在,这种插件机制是操作系统引入的,可以做到跨语言。当然部分语言,比如 Java,它有自己的插件机制,以 jar 包的形式存在。

在上一讲 “61 | 全局性功能的架构设计” 中我们提到,微软的大部分软件,以 Office 和 Visual Studio 为代表,都提供了二次开发能力。这些二次开发接口构成了软件的插件机制,并最终让它成为一个生态型软件。

一般来说,提供插件机制的二次开发接口需要包含以下三个部分。

其一,软件自身能力的暴露,也就是我们经常说的 DOM API。插件以此来调用软件已经实现的功能,这是最基础的部分,我们这里不进一步展开。

其二,插件加载机制。通常,这基于文件系统,比如我们规定把所有插件放到某个目录下。在 Windows 平台下会多一个选择,把插件信息写到注册表。

其三,事件监听。这是关键,也是难点所在。没有事件,插件没有机会介入到业务中去。但是应该提供什么样的事件,提供多少个事件,这非常依赖架构能力。

原则来说,在提供的能力相同的情况下,事件当然越少越好。但是怎么做到少而精,这非常有讲究。一般来说,事件分以下三类:

其一,界面操作类。最原始的是鼠标和键盘操作,但它们太过于底层,提供出去会是双刃剑,一般对二次开发接口来说会选择不提供。更多的时候会选择暴露更高级的界面事件,比如菜单项或按钮的点击。

其二,数据变更类。在数据发生变化的时候,允许捕获它并做点什么。最为典型的是 onSelectionChanged 这个事件,基本上所有的软件二次开发接口都会提供。当然它属于界面数据变更,只能说是数据变更的特例。如果我们回忆一下 MVC 框架(参见 “22 | 桌面程序的架构建议”),就能够记得 Model 层会发出数据变更通知,也就是 onDataChanged 类的事件出来给 View 或 Controller。

其三,业务流程类。它通常发生在某个业务流的中间某个环节,或者业务流完成之后。比如对 Office 软件来说,打开文件之初或之后,都可能发出相应的事件,以便插件做些什么。

通过以上分析可以看出,完整的插件机制还是比较庞大的。但实际应用中插件机制未必要做得如此之重。比如,Go 语言中的 image 包,它提供的 Decode 和 DecodeConfig 等功能都支持插件,我们可以增加一种格式支持,而无需修改 image 包。这里面最大的简化,是放弃了插件加载机制。我们自己手工来加载插件,比如:

这段代码为 image 包加载了两个插件,一个支持 jpeg,一个支持 png 格式。

如果大家仔细研究过我们实战案例 “画图程序” 的代码(参见 “加餐 | 实战:画图程序的整体架构”)就会发现,类似的插件机制的运用有很多。我们说的架构分解,把复杂系统分解为一个最小化的核心系统,加上多个相互正交的周边系统,它背后的机制往往就是我们这里提的插件机制。

插件机制的确让核心系统与周边系统耦合度大大降低。但插件机制并非没有成本。插件机制本身也是核心系统的一个功能,它本身也需要考虑与核心系统其他功能的耦合度。

如果某插件机制没有多少客户,也就是说,没有几个功能基于它开发,而它本身代码又散落在核心系统的各个角落,那么投入产出就显然不成比例。所以维持足够的通用性,是提供插件机制的重大前提。

单一职责原则

到此为止,相信大家已经对开闭原则(OCP)非常了解了。总结来说就两点:

第一,模块的业务要稳定。模块的业务遵循 “只读” 设计,如果需要变化不如把它归档,放弃掉。这种模块业务只读的思想,是架构治理的基础哲学。

第二,模块的业务变化点,简单一点的,通过回调函数或者接口开放出去,交给其他的业务模块。复杂一点的,通过引入插件机制把系统分解为 “最小化的核心系统 + 多个彼此正交的周边系统”。事实上回调函数或者接口本质上就是一种事件监听机制,所以它是插件机制的特例。

平常,我们大家也经常会听到 “单一职责原则(Single Responsibility Principle,SRP)”,它强调的是每个模块只负责一个业务,而不是同时干多个业务。而开闭原则强调的是把模块业务的变化点抽离出来,包给其他的模块。它们谈的本质上是同一个问题的两个面。

结语

从来没有人这样去谈架构的本质,也没有人这样解读开闭原则(OCP),对吧?其实对于这部 “架构课” 的革命性,我自己从没怀疑过。它的内容是精心设计的,为此我准备了十几年。我们用了四章内容来谈信息科技的需求与架构的演进,然后才进入正题。

用写文章的角度来说,这个伏笔的确够深的。

当然这不完全是伏笔。如果我们把整个信息科技看作最大的一个业务系统,我们有无数人在为之努力奋进,迭代它的架构。大家在竟合中形成自然的分工。学习信息科技的演进史,是学习架构的必要组成部分。我们一方面从中学习怎么做需求分析,另一方面也从中体悟做架构的思维哲学。

当然,还有最重要的一点是,我们要知道演进的结果,也就是信息科技最终形成的基础架构。

作为架构师,我们除了做业务架构,还有一个同等难度的大事,就是选择合适的基础架构。基础架构 + 业务架构,才是你设计的软件的全部。作为架构师,千万不要一叶障目,不见泰山,忘记基础架构选择的重要性。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是 “接口设计的准则”。

63 | 接口设计的准则

上一讲 “62 | 重新认识开闭原则 (OCP)” 我们介绍了开闭原则。这一讲的内容非常非常重要,可以说是整个架构课的灵魂。总结来说,开闭原则包含以下两层含义:

第一,模块的业务要稳定。模块的业务遵循 “只读” 设计,如果需要变化不如把它归档,放弃掉。这种模块业务只读的思想,是架构治理的基础哲学。我平常和小伙伴们探讨模块边界的时候,经常会说这样一句话:每一个模块都应该是可完成的。

第二,模块业务的变化点,简单一点的,通过回调函数或者接口开放出去,交给其他的业务模块。复杂一点的,通过引入插件机制把系统分解为 “最小化的核心系统 + 多个彼此正交的周边系统”。事实上回调函数或者接口本质上就是一种事件监听机制,所以它是插件机制的特例。

今天,我们想聊聊怎么做接口设计。

不过在探讨这个问题前,我想和大家探讨的第一个问题是:什么是接口?你可能会觉得这个问题挺愚蠢的。毕竟这几乎是我们嘴巴里天天会提及的术语,会不知道?但让我们用科学家的严谨作风来看待这个问题。接口在不同的语义环境下,主要有两个不同含义。

一种是模块的使用界面,也就是规格,比如公开的类或函数的原型。我们前面在这个架构课中一直强调,模块的接口应该自然体现业务需求。这里的接口,指的就是模块的使用界面。

另一种是模块对依赖环境的抽象。这种情况下,接口是模块与模块之间的契约。在架构设计中我们经常也会听到 “契约式设计(Design by Contract)” 这样的说法,它鼓励模块与模块的交互基于接口作为契约,而不是依赖于具体实现。

对于这两类的接口语义,我们分别进行讨论。

模块的使用界面

对于模块的使用界面,最重要的是 KISS 原则,让人一眼就明白这个模块在做什么样的业务。

KISS 的全称是 Keep it Simple, Stupid,直译是简单化与傻瓜化。用土话来说,就是要 “让傻子也能够看得懂”,追求简单自然,符合惯例。

这样说比较抽象,我们拿七牛开源的 mockhttp 项目作为例子进行说明。这个项目早期的项目地址为:

  • 代码主页:https://github.com/qiniu/mockhttp.v1
  • 文档主页:https://godoc.org/github.com/qiniu/mockhttp.v1

最新的项目地址变更为:

  • 代码主页:https://github.com/qiniu/x/tree/master/mockhttp
  • 文档主页:https://godoc.org/github.com/qiniu/x/mockhttp

mockhttp 是做什么的呢?它用于启动 HTTP 服务作为测试用途。当然 Go 的标准库 net/http/httptest 已经有自己的 HTTP 服务启动方法,如下:

httptest.NewServer 分配一个空闲可用的 TCP 端口,并将它与传入的 HTTP 服务器关联起来。最后我们得到的 ts.URL 就是服务器的访问地址。使用样例如下:

mockhttp 有所不同,它并不真的启动 HTTP 服务,没有端口占用。这里我们不谈具体的原理,我们看接口。mockhttp.v1 版本的使用界面如下:

这里比较古怪的是 service,它并不是 http.Handler 类型。它背后做了一件事情,就是帮 service 这个 HTTP 服务器自动实现请求的路由分派能力。这有一定的好处,使用上比较便捷:

但是它有两个问题。一个问题是关于模块边界上的。严谨来说 mockhttp.v1 并不符合 “单一职责原则(SRP)”。它干了两个业务:

  • 启动 HTTP 测试服务;
  • 实现 HTTP 服务器请求的路由分派。

另一个是关于接口的 KISS 原则。mockhttp.Bind 虽然听起来不错,也很简单,但实际上并不符合 Go 语言的惯例语义。另外就是 mockhttp.Client 变量。按 Go 语义的惯例它可能叫 DefaultClient 会更好一些,另外它的类型是 rpc.Client,而不是 http.Client,这样方便是方便了,但却产生了多余的依赖。

mockhttp.v1 这种业务边界和接口的随意性,一定程度上是因为它是测试用途,所以有点怎么简单怎么来的意思。但是后来的发展表明,所有的偷懒总会还回来的。于是就有了 mockhttp.v2 版本。这个版本在我们做小型的 package 合并时,把它放到了 https://github.com/qiniu/x 这个 package 中。接口如下:

这里暴露的方法和变量,一方面 Go 程序员一看即明其义,另一方面语义上和 Go 标准库既有的 HTTP package 可自然融合。它的使用方式如下:

从上面的例子可以看出,我们说接口要 KISS,要简单自然,这里很重要的一点是符合语言和社区的惯例。如果某类业务在语言中已经有约定俗成的接口,我们尽可能沿用相同的接口语义。

模块的环境依赖

接口的另一种含义是模块对依赖环境的抽象,也就是模块与模块之间的契约。我们大部分情况下提到的接口,指的是这一点。

模块的环境依赖,也分两种,一种是使用界面依赖,一种是实现依赖。所谓使用界面依赖是指用户在使用该模块的使用界面时自然涉及的。所谓实现依赖则是指模块当前实现方案中涉及到的组件,它带来的依赖条件。如果我换一种实现方案,这类依赖可能就不再存在,或者变成另外的依赖。

在环境依赖上,我们遵循的是 “最小依赖原则”,或者叫 “最少知识原则(Least Knowledge Principle,LKP)”,去尽可能发现模块中多余的依赖。具体到细节,使用界面依赖与实现依赖到处置方式往往还是有所不同。

从使用界面依赖来说,我们接口定义更多考虑的往往是对参数的泛化与抽象,以便让我们可以适应更广泛的场景。

比如,我们前面谈到 IO 系统的时候,把存盘与读盘的接口从 *.os.File 换成 io.Reader、io.Writer,以获得更强的通用性,比如对剪贴板的支持。

类似的情况还有很多,一个接口的参数类型稍加变化,就会获得更大的通用性。再比如,对于上面 mockhttp.v1 中 rpc.Client 这个接口就存在多余的依赖,改为 http.Client 会更好一些。不过有的时候,我们看起来从接口定义似乎更加泛化,但是实际上却是场景的收紧,这需要特别注意避免的。比如上面 mockhttp.v1 的接口: func Bind(host string, service interface{})

与 mockhttp.v2 的接口:func ListenAndServe(host string, service http.Handler)

看似 v1 版本类型用的是 interface{},形式上更加泛化,但实际上 v1 版本有更强的假设,它内部通过反射机制实现了 HTTP 服务器请求的路由分派。而 v2 版本对 service 则用的是 HTTP 服务器的通用接口,是更加恰如其分的描述方式。

当然,在接口参数的抽象上,也不适合过度。如果某种泛化它不会发生,那就是过度设计。不要一开始就把系统设计得非常复杂,而陷入“过度设计”的深渊。应该让系统足够的简单,而却又不失扩展性,这其中的平衡完全依赖你对业务的理解,它是一个难点。

聊完使用界面依赖,我们接着聊实现依赖。

从模块实现的角度,我们环境依赖有两个选择:一个是直接依赖所基于的组件,一个是将所依赖的组件所有被引用的方法抽象成一个接口,让模块依赖接口而不是具体的组件。那么,这两种方式应该怎么选择?

我的建议是,大部分情况下应该选择直接依赖组件,而不必去抽象它。如无必要,勿增实体。

如果我们大量抽象所依赖的基础组件,意味着我们系统的可配置性(Configurable)更好,但学习成本也更高。

什么时候该当考虑把依赖抽象化?

其一,在需要提供多种选择的时候。比较典型的是日志的 Logger 组件。对于绝大部分的业务模块,都并不希望绑定 Logger 的选择,把决策权交给使用方。但是有的时候,在这一点上过度设计也会比较常见。比如,不少业务模块会选择抽象对数据库的依赖,以便于在 MySQL 和 MongoDB 之间自由切换。但这种灵活性绝大部分情况下是一种过度设计。选择数据库应该是非常谨慎严谨的行为。

其二,在需要解除一个庞大的外部系统的依赖时。有时候我们并不是需要多个选择,而是某个外部依赖过重,我们测试或其他场景可能会选择 mock 一个外部依赖,以便降低测试系统的依赖。

其三,在依赖的外部系统为可选组件时。这个时候模块会实现一个 mock 的组件,并在初始化时将接口设置为 mock 组件。这样的好处是,除非用户关心,否则客户可以当模块不存在这个可选的配置项,这降低了学习门槛。

整体来说,对模块的实现依赖进行接口抽象,本质是对模块进行配置化,增加很多配置选项,这样的配置化需要谨慎,适可而止。

结语

一个老生常谈的话题。接口有分模块的使用界面和模块的环境依赖这两种理解。对于模块的使用界面,我们推崇 KISS 原则,简单自然,符合业务表达的惯例。对于模块的环境依赖,我们遵循的是 “最小依赖原则”,或者叫 “最少知识原则(Least Knowledge Principle,LKP)”,尽可能发现模块中多余的依赖。如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是 “不断完善的架构范式”。

64 | 不断完善的架构范式

我们在 “62 | 重新认识开闭原则 (OCP)” 这一讲中介绍了开闭原则。这篇内容非常非常重要,可以说是整个架构课的灵魂。总结来说,开闭原则包含以下两层含义:

第一,模块的业务要稳定。模块的业务遵循 “只读” 设计,如果需要变化不如把它归档,放弃掉。这种模块业务只读的思想,是架构治理的基础哲学。我平常和小伙伴们探讨模块边界的时候,经常会说这样一句话:

每一个模块都应该是可完成的。

这实际上是开闭原则的业务范畴 “只读” 的架构治理思想的另一种表述方式。第二,模块业务的变化点,简单一点的,通过回调函数或者接口开放出去,交给其他的业务模块。复杂一点的,通过引入插件机制把系统分解为 “最小化的核心系统 + 多个彼此正交的周边系统”。事实上回调函数或者接口本质上就是一种事件监听机制,所以它是插件机制的特例。

上一讲我们介绍了接口设计。到此为止,我们的架构思维篇也已经基本接近尾声。可能有人会越来越奇怪,为什么我还是没有去聊那些大家耳熟能详的架构设计原则?实际上,并不是这些架构设计原则不好,它们之中不乏精彩绝伦、振聋发聩的总结。比如:

  • 接口隔离原则(Interface Segregation Principle,ISP):一个模块与另一个模块之间的依赖性,应该依赖于尽可能小的接口。
  • 依赖倒置原则(Dependence Inversion Principle,DIP):高层模块不应该依赖于低层模块,它们应该依赖于抽象接口。
  • 无环依赖原则(Acyclic Dependencies Principle,ADP):不要让两个模块之间出现循环依赖。怎么解除循环依赖?见上一条。
  • 组合 / 聚合复用原则(Composition/Aggregation Reuse Principle,CARP):当要扩展功能时,优先考虑使用组合,而不是继承。
  • 高内聚与低耦合(High Cohesion and Low Coupling,HCLC):模块内部需要做到内聚度高,模块之间需要做到耦合度低。这是判断一个模块是在做一个业务还是多个业务的依据。如果是在做同一个业务,那么它所有的代码都是内聚的,相互有较强的依赖。
  • 惯例优于配置(Convention over Configuration,COC):灵活性会增加复杂性,所以除非这个灵活性是必须的,否则应尽量让惯例来减少配置,这样才能提高开发效率。如有可能,尽量做到 “零配置”。
  • 命令查询分离(Command Query Separation,CQS):读写操作要分离。在定义接口方法时,要区分哪些是命令(写操作),哪些是查询(读操作),要将它们分离,而不要揉到一起。
  • 关注点分离(Separation of Concerns,SOC):将一个复杂的问题分离为多个简单的问题,然后逐个解决这些简单的问题,那么这个复杂的问题就解决了。当然这条说了等于没说,难在如何进行分离,最终还是归结到对业务的理解上。

这些都是很好很好的。但是,我们需要意识到的一点是,熟读架构思维并不足以让我们成为优秀的架构师。要始终记住的一点是,我们做的是软件工程。软件工程的复杂性它自然存在,不会因为好的架构思维而消除。

所以虽然理解架构思维是非常重要的,但是架构师真正的武器库并不是它们。那么架构师的武器库是什么?这就要从 “架构治理” 开始谈起。

前面我们说过,“开闭原则” 推崇模块业务 “只读” 的思想,是很好的架构治理哲学。它告诉我们,软件是可以以 “搭积木” 的方式搭出来的。核心的一点是,我们如何形成更多的 “积木”,即一个个业务只读、接口稳定、易于组合的模块。

所以,真正提高我们工程效率的,是我们的业务分解能力和历史积累的成果。前面我们说过,架构分解中有两大难题:其一,需求的交织。不同需求混杂在一起,也就是存在所谓的全局性功能。其二,需求的易变。不同客户,不同场景下需求看起来很不一样,场景呈发散趋势。

在 “61 | 全局性功能的架构设计” 这一讲我们重点聊的是第一点。对于全局性功能怎么去拆解,把它从我们的业务中剥离出来,并无统一的解决思路。但好的一点是,绝大部分全局性功能都会有很多人去拆解,并最终会被基础设施化。所以具体业务中我们会碰到的全局性功能并不会非常多。

比如,怎么做用户的鉴权?怎么保障软件 24 小时持续服务?怎么保障快速定位用户反馈的问题?这些需求和所有业务需求是交织在一起的,也足够普适,所以就会有很多人去思考对应的解决方案。作为架构师,心性非常重要。

架构师需要有自己的信仰。我们需要坚持对业务进行正交分解的信念,要坚持不断地探索各类需求的架构分解方法。这样的思考多了,我们就逐步形成了各种各样的架构范式。这些架构范式,并不仅仅是一些架构思维,而是 “一个个业务只读、接口稳定、易于组合的模块 + 组合的方法论”,它们才是架构师真正的武器库。

这个武器库包含哪些内容?首先,它应该包括信息科技形成的基础架构。努力把前辈们的心血,变成我们自己真正的积累。光会用还不够,以深刻理解它们背后的架构逻辑,确保自己与基础架构最大程度上的 “同频共振”。

只有让基础架构完全融入自己的思维体系,同频共振,我们才有可能在架构设计需要的时候 “想到它们”。这一点很有趣。有些人看起来博学多才,头头是道,但是真做架构时完全想不到他的 “博学”。从体系结构来说,这个基础架构包含哪些内容?

其一,基础平台。包括:冯·诺依曼体系、编程语言、操作系统。

其二,桌面开发平台。包括:窗口系统、GDI 系统、浏览器与小程序。当然我们也要理解桌面开发背后的架构逻辑,MVC 架构。

其三,服务端开发平台。包括:负载均衡、各类存储中间件。服务端业务开发的业务逻辑比桌面要简单得多。服务端难在如何形成有效的基础架构,其中大部分是存储中间件。

其四,服务治理平台。主要是以容器技术为核心的 DCOS(数据中心操作系统),以及围绕它形成的整个服务治理生态。这一块还在高速发展过程中,最终它将让服务端开发变得极其简单。

理解了这些基础架构,再加上你自己所处行业的领域知识,基本上设计出一个优秀业务系统,让它健康运行,持续不间断地向用户提供服务就不是问题。读到这里,你可能终于明白,为什么这个架构课的内容结构是目前这个样子组织的。因为消化基础架构成为架构师自身的本领,远比消化架构设计原则,架构思维逻辑要难得多。消化基础架构的过程,同时也是消化架构思维的过程。

把虚的事情往实里做,才有可能真正做好。理解了基础架构,剩下的就是如何沉淀业务架构所需的武器库。这一般来说没有太统一的体系可以参考,如果有,大部分都会被基础设施化了。

所以,业务只能靠你自己的架构设计能力去构建它。而这,其实也是架构师的乐趣所在。还没有被基础设施化但比较通用的,有一个大门类是数据相关的体系。数据是软件的灵魂。它可能包括以下这些内容:

存盘与读盘(IO);文本处理;存储与数据结构;Undo/Redo;……

我们在下一讲,会专门聊聊其中的 “文本处理” 这个子课题。从完整性讲,我们的架构课并没有包括所有的基础架构。我们把话题收敛到了 “如何把软件跑起来,并保证它持续健康运行” 这件事情上。

但从企业的业务运营角度来说,这还远不是全部。“54 | 业务的可支持性与持续运营” 我们稍稍展开了一下这个话题。但要谈透这个话题,它会是另一本书,内容主题将会是 “数据治理与业务运营体系构建”。我希望有一天能够完成它,但这可能要很久之后的事情了。它是我除架构课外的另一个心愿。

结语

我们在 “56 | 服务治理篇:回顾与总结” 这一讲,也就是第四章结束的时候,谈到我们下一章的内容时提到:我个人不太喜欢常规意义上的 “设计模式”。或者说,我们对设计模式常规的打开方式是有问题的。理解每一个设计模式,都应该放到它想要解决的问题域来看。所以,我个人更喜欢的架构范式更多的是 “设计场景” 的总结。“设计场景” 和设计模式的区别在于它有自己清晰的问题域定义,是一个实实在在的通用子系统。

是的,这些 “通用的设计场景”,才是架构师真正的武器库。如果我们架构师总能把自己所要解决的业务场景分解为多个 “通用的设计场景” 的组合,这就代表架构师有了极强的架构范式的抽象能力。而这一点,正是架构师成熟度的核心标志。

结合今天这一讲我们聊的内容,相信你对这段话会有新的理解。“开闭原则” 推崇模块业务 “只读” 的思想,是很好的架构治理哲学。它告诉我们,软件是可以以 “搭积木” 的方式搭出来的。核心的一点是,我们如何形成更多的 “积木”,即一个个业务只读、接口稳定、易于组合的模块。

结合今天这一讲的内容,相信你终于完全能理解我们这个架构课的内容组织为什么是现在你看到的样子了。

65 | 架构范式:文本处理

上一讲 “64 | 不断完善的架构范式” 我们提到架构师的武器库是不断完善的架构范式。今天我们围绕一个具体的问题域,看看我们日常能够积累什么样的经验和成果,来完善作为一个架构师的知识体系。我们选择的问题是 “文本处理”。

计算机之所以叫计算机,是因为计算机的能力基本上就是“计算 +I/O”两部分。I/O 只是为了让计算机与物理世界打交道,它也是为计算服务的。所以数据是软件的灵魂,数据处理是软件的能力。

今天我们聊的文本处理,不是通用的数据处理能力,而是收敛在数据的 I/O 上。这里说的文本,是指写入到磁盘的非结构化数据。它可能真的是文本内容,比如 HTML 文档、CSS 文档;也可能是二进制内容,比如 Word 文档、Excel 文档。文本处理则是指对这类非结构化数据的处理过程,常见文本处理的需求场景有:

  • 数据验证(Data Validation)。比如判断用户输入的文本是否合法,值的范围是否符合期望。
  • 数据抽取(Data Extraction)。比如从某 HTML 页面中抽取出结构化的机票信息(什么时间,从哪里出发,到哪里去,价格几何等等)。
  • 编译器(Compiler)。特殊地,在文本格式是某种语言的代码时,我们可以将文本编译成可执行的机器码,或虚拟机解释执行的字节码。当然我们也可以边解释文本的语义边执行。
  • ……

从用户需求的角度来说,文本处理的需求场景是不可穷尽的。网络爬虫与搜索引擎需要文本处理,Office 软件需要文本处理,编程语言的编译器需要文本处理,网络协议解析需要文本处理,等等。那么,怎么才能从这些多变的需求场景中,抽出正交分解后可复用的架构范式?我们今天聊聊文本处理的通用思路。

我的文本处理技术栈演进

文本处理,很多人都会遇到,只不过大家各自遇到的场景不同。我这里先回顾下我个人遇到的文本处理场景。我总结了一个图,如下:

在 2000 年初,我作为实习生拿到的第一个任务,是金山电子表格自身的文件格式设计和 Excel 文件的读写。此后,我参与了多个版本的 Word 文件读写工作。为了便于分析 Excel 和 Word 文件的格式,我实现了 ExcelViewer 和 DocViewer 这两个文件格式查看器。

实际上这两个 Viewer 非常重要,因为它第一次让文件格式的理解过程用程序固化了下来。这非常利于知识传承。大家可以设想一下,假如没有 Viewer,那么后面接手的人基本上只能靠阅读 ExcelReader 和 DocReader 模块的代码来理解文件格式。

但是这有两个问题。其一,Reader 模块有大量的业务逻辑,对我们理解 Excel 和 Word 文件格式本身会造成干扰。其二,Reader 模块增加功能会比较慢,对于那些我们本身不支持的功能,或者我们还暂时来不及兼容的功能,是没有对应的解析代码的。

但是 Viewer 就不一样。我们会尽可能地把我们对 Excel 和 Word 的理解记录下来,成为稳定可传承的知识,而无需关心是否已经支持该功能。另外,从时间的维度来说,应该先有 Viewer,在理解了文件格式之后,再设计出 Reader 才比较合理。

这个时期的 ExcelViewer 和 DocViewer,它主要抽象出来的是界面呈现部分。具体 ExcelViewer 和 DocViewer 的代码不需要有一行涉及到界面。这有诸多好处。实际上可视化界面只是 ExcelViewer 和 DocViewer 的一种输出终端,它们同时也生成了一个纯文本结果到磁盘文件中。这有助于我们用常规的 diff 工具来对比两个文件的差异,从而加速我们对未知数据格式的了解。但,此时的 ExcelViewer 和 DocViewer 并没有将文件格式的处理过程抽象出通用的模块。也可以说,还没有抽象出文本处理范式。

这个时期同期还有一个探索性的 WPS for Linux 项目。为了支持跨平台编译,我实现了一个简单的 mk 程序。这个程序区别于 Linux 标准化的 make 程序,没有那么复杂的逻辑需要理解。它的输入是一个类 Windows 平台的 ini 文件,里面只需要指定选择的编译器、相关的编译选项、源代码文件列表等,就可以进行编译。甚至源代码列表可以直接指定为从 Visual C++ 的项目配置 dsp 文件中抽取,极易使用。

这个 mk 程序除了要解析一个类 ini 的配置文件外,也会解析 C/C++ 源代码文件形成源代码文件的依赖表,以更好地支持增量编译。不只是源代码文件本身的修改会触发重新编译,任何依赖文件的修改也会触发重新编译。同样地,这个时期的 mk 程序同样没有引入任何通用的文本处理范式。

此后大约在 2004 年,我开始在金山办公软件内部推 KSDN。KSDN 这个名字承自 MSDN,我们希望打造一个全局的文档系统,它自动从项目的源代码中提取并生成。每天日构建完毕后得到最新版本的 KSDN。KSDN 处理的输入主要是 C++ 和 Delphi 源代码文件(当时的界面是 Delphi 写的),是纯文本的。这和 ExcelViewer、DocViewer 不同,他们的输入是二进制文件。

KSDN 第一次引入了一个通用的脚本,来表达我们想从源代码中抽取什么内容。整个 KSDN 处理单个源代码文件的工作原理可以描述为:

  • 通过文件后缀选择源代码文件的解析脚本,通过该脚本解析 C++ 或 Dephi 的源代码,并输出 XML 格式的文件;
  • 通过 XSLT 脚本,将 XML 文件渲染为一个或多个 HTML 文件。XSLT 全称是 Extensible Stylesheet Language Transformations(可扩展样式表转换),是 XML 生态中的一项技术。

在 2006 年的时候,我决定实现 KSDN 2.0 版本。这个版本主要想解决第一个版本的脚本语法表达能力比较局限的问题。于是 C++ 版本的 TPL(Text Processing Language)诞生了。它非常类似于 Boost Spirit,但功能要强大很多。它的项目主页为:

https://github.com/xushiwei/tpl

它依赖基础库 stdext,项目主页为:

https://github.com/xushiwei/stdext

C++ 版本的 TPL 支持的表达能力,已经完全不弱于 UNIX 经典的 LEX + YACC 组合,使用上却轻量很多。KSDN 2.0 的工作原理变成了:

  • 基于 TPL 将 C++ 或 Delphi 文件转为 json 格式;
  • 与 XSLT 类似地,我们引入了 JSPT,即以 json 为输入,PHP 为 formatter,将内容转为一个或多个 HTML 文件。

这个过程非常通用,可以用于实现任意文件格式之间的变换。包括我们前面的 mk 程序,它本质上也是类 ini 文件格式变换到 Makefile 的过程,我们基于 TPL 很轻松就改造了一个 mk 2.0 版本。2009 年的时候,我们基于 C++ 实现一个名为 CERL 的网络库,它和 Go 语言的 goroutine 类似,也是基于协程来实现高并发。在这个网络库中,我们定义了一个名为 SDL(Server Description Language)的语言来描述服务器的网络协议。很自然地,我们基于 TPL + JSPT 来实现了 SDL 文件的解析过程。

2011 年,七牛云成立,我们选择了 Go 语言作为技术栈的核心。在转 Go 语言后,除了 TPL,我个人沉淀的大部分 C++ 基础库都不再需要,因为它们往往已经被 Go 语言的标准库解决得很好。在 2015 年的时候,出于某种原因我实现了一个网络爬虫,这个爬虫会在收到网页内容后,抽取网页中的结构化信息并存储起来。这个抽取信息的过程,最终导致 Go 语言版本 TPL 的诞生。它的项目主页为:https://github.com/qiniu/text

为了验证 Go 语言版本 TPL 的有效性,我在实现了经典的 “计算器(Calculator)” 之余,顺手实现了一门语言,这就是 qlang。它的项目主页为:https://github.com/qiniu/qlang

由于 Go 语言中实现泛型数据结构的需要,我给 qlang 实现了一个 embedded 版本,简称 eql。它是类似 erubis/erb 的东西。结合 go generate,它可以很方便地让 Go 支持模板(不是 html template,是指泛型)。在 2017 年,出于 rtmp 网络协议理解的需要,我创建了 BPL(Binary Processing Language),它的项目主页为:https://github.com/qiniu/bpl

区别于 TPL 的是,BPL 主要用于处理二进制文档。前面我们谈到 ExcelViewer 和 DocViewer 时说过,我们并没有建立任何通用的架构范式。这一直是我引以为憾的事情,所以 2006 年 C++ 版本的 TPL 诞生后就有过 BPL 相关的尝试。这里是尝试残留的痕迹:tpl/binary/*

但是二进制文档的确很难,它的格式描述中通常有一定的条件判断逻辑,所以 BPL 背后需要依赖一门语言。在 qlang 诞生后,这个条件就得到了满足,这是最终 BPL 得以能够诞生的原因。BPL 非常强大,它可以处理任意的二进制文件,也可以用于处理任意的 TCP 网络协议数据流。有了 BPL,我们最初的 ExcelViewer 和 DocViewer 可以轻松得以实现。关于 BPL 更详细的介绍,请参阅 https://github.com/qiniu/bpl 中的文档说明。

文本内容的处理范式

介绍了我个人文本处理的技术栈演进过程后,我们把话题重新回到架构范式。首先,让我们把焦点放在文本内容的处理上。文本内容的处理,有非常标准的方式。它通常分词法分析(Lex)和语法分析(Parser)两个阶段。UNIX 系的操作系统还提供了 lex 和 yacc 两个经典的程序来协助我们做文本文件的分析处理。

词法分析(Lex)通常由一个 Scanner 来完成,它负责将文本内容从字节流(Byte Stream)转为 Token 流(Token Stream)。我们以解析 Go 源代码的 Scanner 为例(参见 https://godoc.org/go/scanner),其 Scan 函数的原型如下:

其使用范式如下:

Scanner 有时候也叫 Tokenizer。例如 Go 语言中 HTML 的 Tokenizer 类(参阅 https://godoc.org/golang.org/x/net/html)的原型如下:

其使用范式如下:

词法分析(Lex)过程非常基础,大部分情况下我们不会直接和它打交道。我们打交道的基本都是语法分析器,通常叫 Parser。而从 Parser 的使用方式来说,分为 SAX 和 DOM 两种模型。SAX 模型基于事件机制,DOM 模型则基于结构化的数据访问接口。前面我们已经多次分析过 SAX 与 DOM 的优劣,这里不再展开。通常来说,我们会倾向于采用 DOM 模型。这里我们还是以 Go 文法和 HTML 文法的解析为例。

先看 Go 文法的 Parser(参阅 https://godoc.org/go/parser),它的原型如下:

这里看起来有点复杂的是 ParseFile,它输入的字节流(Byte Stream)可以是:

  • scr != nil,且为 io.Reader 类型;
  • src != nil,且为 string 或 []byte 类型;
  • src == nil,filename 非空,字节流从 filename 对应的文件中读取。

而 Parser 的输出则统一是一个抽象语法树(Abstract Syntax Tree,AST)。显然,它基于的是 DOM 模型。我们再看 HTML 文法的 Parser(参阅 https://godoc.org/golang.org/x/net/html),它的原型如下:func Parse(r io.Reader) (*Node, error)

超级简单的基于 DOM 模型的使用接口,任何解释都是多余的。那么,我前面提的 TPL(Text Processing Language)是做什么的呢?它实现了一套通用的 Scanner + Parser 的机制。首先是词法分析,也就是 Scanner,它负责将文本流转换为 Token 序列。简单来说,就是一个从 text []byte 到 tokens []Token 的过程。

尽管世上语言多样,但是词法非常接近,所以在词法分析这块 ,TPL 抽象了一个 Tokenizer 接口,方便用户自定义。TPL 也内置了一个与 Go 语言词法类似的 Scanner,只是做了非常细微的调整,增加了 ?、~、@ 等操作符。TPL 的 Parser 通过类 EBNF 文法表达。比如一个浮点运算的计算器(Calculator),支持加减乘除、函数调用、常量(如 pi 等)的类 EBNF 文法如下:

关于这个类 EBNF 文法,有以下补充说明:

  • 我们用 *G 和 +G 来表示重复,而不是用 {G}。要记住这条规则其实比较简单。在编译原理的图书中,我们看到往往是 G* 和 G+。但语言文法中除了 ++ 和 -- 运算符,很少是后缀形式,所以我们选择改为前缀。
  • 我们用 ?G 来表示可选,而不是用 [G]。同上,只要能够回忆起编译原理中我们用 G? 表示可选,我们就很容易理解这里为什么可选是用 ?G 表示。
  • 我们直接用 G1 G2 来表示串接,而不是 G1, G2。
  • 我们用 G1 % G2 和 G1 %= G2 表示 G1 G2 G1 G2 ... G1 这样的列表。其中 G1 % G2 和 G1 %= G2 的区别是前者不能为空列表,后者可以。在上面的例子中,我们用 doc %= , 表示函数的参数列表。
  • 我们用 G/action 表示在 G 匹配成功后执行 action 这个动作。action 最终是调用到 Go 语言中的回调函数。在上面这个计算器中大量使用了 G/action 文法。

与 UNIX 实用程序 yacc 不同的是,TPL 中文法描述的脚本,与执行代码尽可能分离,以加业务语义的可读性。从模型的归属来说,TPL 属于 SAX 模型。但 G/action 不一定真的是动作。在 extractor 模式下,G/action 被视为 G/marker,TPL 变成 DOM 模型。也就是说,此时 action 只是一个标记,用于形成输出的 DOM 树。关于 TPL 更详细的介绍需要很长的篇幅,你可以参考 TPL Doc。在文本内容处理的技术栈中,还有一个分支是正则表达式(Regular Expression)。在简单场景下,正则表达式是比较方便的,但是它的缺点也比较明显,可伸缩性和可读性都不强。

二进制内容的处理范式

接下来我们讨论二进制内容的通用处理范式。二进制内容的处理过程整体来说,似乎比较 “容易”。如果要说出一点问题的话,那就是 “有点繁琐”。还记得序列化机制吧?它基本上算得上二进制内容的 I/O 框架了。它看起来是这样的:

在 C++ 的操作符重载的支持下,这段代码看起来会更简洁一些:

当然,上面只是最基础的情形,所以看起来还比较简洁。但在考虑可选、重复、数组等场景,实际上并不会那么简单。比如对于数组,理想情况下代码是下面这样的:

对于 Go 语言来说,这里我们想要的 readArray 并不存在。而在 C++ 则可以通过泛型来做到,我们示意如下:

呼唤一下 Go 语言的泛型吧。不过泛型大概率需要破坏 Go 的一些基础假设,比如不支持重载。所以 Go 的泛型之路不会那么容易。回到序列化机制。常规意义的序列化,通常还提供了 Object 动态序列化与反序列化的能力。但是实际上这个机制属于过度设计。为什么这么说?

因为 Object 动态序列化的确带来了一定的便捷性,但是这个便捷性的背后是让使用者放弃了对磁盘文件格式设计的思考。这是非常不正确的指导思想。数据是软件的灵魂,文件是软件最重要的资产。文件 I/O 的序列化机制,最重要的是定义严谨的数据格式,而非提供任何出于便捷性考虑的智能。所以我们只需要保留序列化的形式就好了,任何额外的 “智能” 都是多余的。

基于这样的基本原则,稍作探究你就会发现,在数据结构清晰的情况下,其实整个序列化的代码是非常平庸的。假如我们参考 TPL 的类 EBNF 文法,定义以下这样一条规则:

这样,我们就可以自动帮这里的 Foo 类型实现它的序列化代码了。而这正是 BPL 诞生的灵感来源。BPL 设计的核心思想是,不破坏 TPL 的 EBNF 文法的任何语义,把自己作为 TPL 的扩展。这就好比,如果我们把 TPL 看作 C 的话,BPL 就是 C++。所有 TPL 的功能,BPL 都应该具备而且行为一致。我们以 MongoDB 的网络协议为例,看看 BPL 文法是什么样的:

我们对比 MongoDB 官方的协议文档(参考 https://docs.mongodb.com/manual/reference/mongodb-wire-protocol/),你会发现很有趣的一点是,我们 BPL 文法几乎和 MongoDB 官方采用的伪代码完全一致,除了一个小细节:在 BPL 中,我们用 {...} 表示采用 Go 语言结构体的文法,而 {/C ... } 表示采用 C 语言结构体的文法。当前 BPL 还只支持解释执行,但这只是暂时的。就像在 TPL 中我们除了动态解释执行外,也已经提供 tpl generator 来生成 Go 代码以静态编译执行。要进一步了解 BPL 的功能,请参阅 https://github.com/qiniu/bpl。我们也还提供了不少具体 BPL 的样例,详细可参考:https://github.com/qiniu/bpl/tree/master/formats

结语

文本处理是一个非常庞大的课题,本文详细解剖了我个人在这个领域下的经验总结。相信这些经验对你面对相关场景时会有帮助。但是更重要的一点是,我们平常需要有意识去分析我们工作中遇到的业务场景,从中提炼通用的需求场景形成架构范式的积累。如此,架构的正交分解思想方能得到贯彻。而我们的业务迭代,也就越来越容易。

66 | 架构老化与重构

架构的老化

架构的功夫全在平常。无论是在我们架构范式的不断完善上,还是应对架构老化的经验积累上,都是在日常工作过程中见功夫。我们不能指望有一天架构水平会突飞猛进。架构能力提升全靠平常一点一滴地不断反思与打磨得来。今天我们要聊的话题是架构老化与重构。

架构老化源于什么?在我们不断给系统添加各种新功能的时候,往往会遇到功能需求的实现方式不在当初框架设定的范围之内,于是很多功能代码逸出框架的范围之外。这些散落在各处的代码,把系统绞得支离破碎。久而久之,代码就出现老化,散发出臭味。

代码老化的标志,是添加功能越来越难,迭代效率降低,问题却是持续不断,解决了一个问题却又由此生出好几个新问题。在理想的情况下,如果我们坚持以 “最小化的核心系统 + 多个相互正交的周边系统” 这个指导思想来构建应用,那么代码就很难出现老化。当然,这毕竟是理想情况。现实情况下,有很多原因会导致架构老化难以避免,比如:

  • 软件工程师的技术能力不行,以功能完成为先,不考虑项目的长期维护成本;
  • 公司缺乏架构评审环节,系统的代码质量缺乏持续有效的关注;
  • 需求理解不深刻,最初架构设计无法满足迭代发展的需要;
  • 架构迭代不及时,大量因为赶时间而诞生的补丁式代码;
  • ……

那么,怎么应对架构老化?这个问题可以从两个视角来看:

  • 该怎么重构系统,才能让我们的软件重新恢复活力?
  • 在重构系统之前,我们应该如何进行局部改善,如果增加新功能又应该如何考虑?

我们先聊后者,毕竟重构系统听起来是一件系统性的工程。而添加新功能与局部调整则在日常经常发生。

老系统怎么添加新功能

先说说添加新功能。正常来说,我们添加功能的时候,尤其是自己加入项目组比较晚,已经有大量的历史代码沉淀在那里的时候,通常我们应该把自己要添加的功能定位为周边功能。对于周边功能,往往考虑最多的点是如何少给核心系统添加麻烦,能够少改就少改。

但是,这其实还不够。实际上当我们视角放在周边系统的时候,其实它本身也应该被看作独立业务系统。这样看的时候,我们自然而然会有新的要求:如何让新功能的代码与既有系统解耦,能够不依赖尽量不依赖。这个不依赖是有讲究的。不依赖核心的含义是业务不依赖。新功能的绝大部分代码独立于既有业务系统,只有少量桥接的代码是耦合的。

实际上对于任何被正交分解的周边系统 B 与核心系统 A,理想情况我们最终得到的应该是三个模块:A、B(与 A 无关部分)、A 与 B 桥接代码(与 A 相关的部分)。虽然从归属来说,A 与 B 桥接代码我们通常也会放到 B 模块,但是它应该尽可能小,且尽可能独立于与核心系统无关的代码。理解这一点至关重要。只有这样我们才能保护自己的投资,今天开发新功能的投入产出可以最大程度得以保留。未来,万一需要做重构,我们的重构成本也能够尽可能最小化。不依赖的另一个重要话题是要不要依赖公司内部的基础库。这一点需要辩证来看,不能简单回答依赖或不依赖。完全不依赖意味着放弃生产力。

这里基本的判断标准是,成熟度越高的基础库越值得依赖。成熟度的评估依赖于个人经验,首先应该评估的是模块规格的成熟度,因为实现上的问题让时间来解决就行。模块规格是否符合你的预期,以及经过了多少用户使用的打磨,这些是评估成熟度的依据。还是以我做办公软件时期的经历为例。从重构角度来说它很典型,既有的代码有几百万行。我第一个做读盘与存盘之外的新功能是电子表格的智能填充。这个功能比较常用,用户可以选择一个区域,然后移动鼠标到被选区域右下角,在鼠标变成十字时,按下鼠标左键不放并移动鼠标以进行单元格内容的自动填充。填充方向是上下左右都可以。

我怎么做这个功能?首先是实现一个基本纯算法的模块,输入一个值矩阵(可以是数值、日期,也可以是字符串等),要预测的序列个数,输出对应预测的值矩阵。为什么自动填充的方向在算法这里消失了?因为我们按填充方向构建值矩阵,而不是用户屏幕上直观看到的矩阵。然后抽象了核心系统的两个接口,一个是取一个区域的单元格数据,包括值和格式,一个是设置一个单元格的值和格式。基于这个抽象接口,我们实现了完整的自动填充逻辑。

最后,是对接这个自动填充模块与既有的业务系统。从 Model 层来说,只需要在既有的业务系统包装对应要求的接口即可。而且取区域单元格、设置一个单元格的值,这些是非常通用的接口,无论既有系统长什么样,我们都可以轻松去实现所需接口。这就是做新功能的思路,尽可能与既有系统剥离,从独立业务视角去实现业务,抽象对环境的依赖。最后,用最少量的对接代码把整个系统串起来。

架构的局部优化

聊完添加新功能,我们谈谈局部调整。它的目标是优化某个功能与核心系统的耦合关系。局部调整看似收效甚微,但是它的好处是可以快速推动。而且,日拱一卒,如果我们能够坚持下来,最后的效果远比你想象得好。它有两种常见做法。

一种是重写,或者叫局部重构。它相当于从系统中彻底移除掉与该功能相关的代码,重新写一份新的。这和开发一个新功能没什么两样,最多看看被移除的代码里面,有哪些函数设计比较合理,可以直接拿过来用,或者稍微重新包装一下能够让规格更合理的。但是我们不能太热衷于做局部重构。局部重构一定要发生在你对这块代码的业务比较了解的情形,比如你已经维护过它一阵子了。另外,局部重构一定要把老代码清理干净,不要残留一些不必要的代码在系统里面。剩下来的事情,完全可以参考我上面提的实现新功能的方法论来执行。

另一种是依赖优化。它关注的重心不是某项功能本身的实现,而是它与系统之间的关系。依赖优化整体上做的是代码的搬运工。怎么搬代码?和删除代码类似,我们要找到和该功能相关的所有代码。但是我们做的不是删除,而是将散落在系统中的代码集中起来。我们把对系统的每处修改变成一个函数,比如叫 doXXX_yyyy。这里 XXX 是功能代号,yyyy 则依据这段搬走的代码语义命个名。你可能觉得这个名字太丑了。但是某种程度来说这是故意的。它可以作为团队的约定俗成,代表此处待重新考虑边界。

不要理解错了,它不是说我们需要重新思考我们现在正在做代码优化的功能边界。它是说我们要重新考虑核心系统的边界。尤其是如果某个地方有好几个功能都加了 doXXX_yyyy 这样的调用,这就意味着这里需要提供一个事件机制,以便这些功能能够进行监听。而一旦我们做了这件事,你就发现核心系统变得更稳定了,不再需要因为添加功能而修改代码。而这不正是 “开闭原则(OCP)” 所追求的么?回到我们要进行依赖优化的功能。集中了这个功能所有代码后,这个功能与系统的耦合也就清楚了。有多少个 doXXX_yyyy,就有多少对系统的伤害(参阅 “58 | 如何判断架构设计的优劣?” 中的伤害值计算)。

如果伤害值不大,代表耦合在合理范围,做到这一步暂时不再往下走是可接受的。如果耦合过多,那就意味着我们需要站在这个功能本身的业务视角看依赖的合理性了。如果不合理,可以考虑推动局部重构。所以,局部重构不应该很盲目,而应依赖于基于 “伤害值” 的客观判断。习惯于在不理解的情况下就重构,这实在不太好。认同他人是很重要的能力修炼。况且作为架构师,事情优先级的排列是第一位的,有太多重要的事情值得去做。

依赖优化的好处比较明显。其一,工作量小,做的是代码搬运,不改变任何业务逻辑。其二,可以不必深入功能的细节,只需要找到该功能的所有相关代码,这是难点,然后把它们集中起来。尽可能把我们认为非核心系统的功能,都基于依赖优化的方式独立出去。这样核心系统与周边系统的耦合就理清楚了。依赖优化,可以把周边系统对核心系统的代码注入,整理得清清楚楚。这是事件机制的需求来源。依赖优化也能够及时发现糟糕的模块,和核心系统藕断丝连,斩不断理还乱,这时我们就需要对这个功能进行局部重构。

核心系统的重构

完成这些,我们下一步,就要进入重构的关键阶段,进行核心系统重构。对于一个积弊已久的系统,要想成功完成整体的重构是非常艰难的。如果我们一上来就去重构核心系统,风险太高。一方面,牵一发而动全身,我们无法保证工程的交付周期。另一方面,没有谁对全局有足够的了解,重构会过于盲目,项目的执行风险难以把控。

确定要对核心系统进行重构,那么最高优先级是确定它的边界,也就是使用界面(接口)。能够在不修改实现的情况下调整核心系统的使用界面到我们期望的样子是最好的。周边系统对核心系统的依赖无非两类:一是核心系统的功能,表现为它提供的 DOM 接口;二是核心系统提供的事件,让周边系统能够介入它的业务流程。

对所有周边模块进行依赖优化的整理,细加分析后可以初步确定核心系统需要暴露的事件集合。进一步要做的事情是把核心系统的 DOM 接口也抽象出来。这一步比较复杂。它包含两件事情:

  • 让周边系统对它的依赖,变成依赖接口,而非依赖实现;
  • 审视核心系统功能的 DOM 接口的合理性,明确出我们期望的接口设计。

我们可以分步骤做。可以先做实现依赖到接口依赖的转变。这有点像前面依赖优化的工作。只不过它不是搬代码,而是把周边模块独立出去,将它与核心系统的依赖关系全部调整为接口。这样,不管抽离出来的 DOM 接口是否合理,至少它代表了当前系统的模块边界。这一步做完,理论上 mock 一个核心系统出来和周边系统对接也是可行的。只不过可能这个 DOM 模型太大,要 mock 不那么容易。

接下来,就是最重要的时刻。我们需要对核心系统的接口进行重新设计。这一步的难点在于:

第一,我们对业务的理解的确有了长足的进步。我们抽象的业务接口有了更加精炼符合业务本质的表达方式,而不是换汤不换药,否则我们就需要质疑这次重构的必要性。

第二,对周边系统切换到新接口的成本有充足的预计。对周边系统来说,这是从老接口过度到新接口的过程。虽然理论上让核心系统维护两套 DOM 接口同时存在,在技术上是可行的,但是这个过渡期不能太长,否则容易让人困惑,不清楚我们倡导的是什么。

口改造,剩下来就简单了。核心系统,每一个周边系统,彼此完全独立,可以单独调整和优化。嫌当前的核心系统太糟糕?那就搞搞。为什么可以这么轻松决策?因为就算我们要重新写核心系统,要做的事情也很收敛,不会影响到大局。这不像那些系统边界分解得不清不楚的业务系统。要改核心系统的代码?不要命了么?

结语

重构工作是很有技巧性的,很能培养一个人的架构能力。做多了,我们可以建立对代码耦合的条件反射,看一眼就知道架构是否合理。但重构不是技巧性那么简单。实际上从难度来说,重构比一个全新业务的架构过程要难得多。重构,不只是一个架构的合理性问题。它包含了架构合理性的考量,因为我们需要知道未来在哪里,我们迭代方向在哪里。

但重构的挑战远不只是这些。这是一个集架构设计(未来架构应该是什么样的)、资源规划与调度(与新功能开发的优先级怎么排)、阶段规划(如何把大任务变小,降低内部的抵触情绪和项目风险)以及持久战的韧性与毅力的庞大工程。如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是 “架构思维篇:回顾与总结”。

67| 架构思维篇:回顾与总结

到今天为止,我们第五章 “架构思维篇” 就要结束了。今天这篇文章我们对整章的内容做一个回顾与总结。

架构之道

架构思维篇的内容大体如下图所示。

在前面几个章节,我们已经陆续介绍了架构的全过程:17 | 架构:需求分析 (上)18 | 架构:需求分析(下)- 实战案例32 | 架构:系统的概要设计45 | 架构:怎么做详细设计?

但架构师面临的问题往往是错综复杂的。给你一个明确的需求说明文档,干干净净地从头开始做 “需求分析”,做 “概要设计”,做模块的 “详细设计”,最后编码实现,这是理想场景。现实中,大多数情况并不是这样。而是:你拿到了一份长长的源代码,加上少得可怜的几份过时的文档。然后被安排做一个新功能,或者改一个顽固缺陷(Bug)。

我们应该怎么做架构设计?架构设计架构设计,设计为先,架构为魂。用架构的系统化和全局性思维来做设计。整体来说,我们这个架构课的知识密度比较高。这在某种程度来说,也是一种必然结果,这是因为架构师需要 “掌控全局” 带来的。

所以这个架构课对大多数人而言,多多少少都会有一些盲点。如果遇到不能理解的地方,从构建完整知识体系的角度,建议通过其他的相关资料补上。当然也欢迎在专栏中提问。相比一般的架构书籍来说,我们这一章架构思维篇的内容写得并不长。原因是架构思维的本源比架构规则重要。规则可能会因为环境变化而发生变化,会过时。但是架构思维的内核不会过时。

所以我们把关注的焦点放到了不变的思维内核上。架构之道,是虚实结合之道。

我们要理论与实践相结合。架构设计不可能只需要熟读某些架构思维的理论,否则架构师早就满天飞了。如果两者只能取其一,我选实践。从实悟虚,从虚就实,运用得当方得升华。这其实是最朴素的虚实结合的道理。对学架构这件事来说尤其如此。架构思维的感悟并不能一步到位,永远有进步的空间,需要我们在不断实践中感悟,升华自己的认知。

这个架构课内容的前四章为 “基础平台”、“桌面开发”、“服务端开发”、“服务治理”。

从内容上来说,由 “基础平台(硬件架构 / 编程语言 / 操作系统)”,到 “业务开发(桌面开发 / 服务端开发)”,再到 “业务治理(服务治理 / 技术支持 / 用户增长)”,基本上覆盖了信息技术主体骨架的各个方面。有了骨架,就有了全貌,有了全局的视角。

前面四章,我们内容体系的侧重点放在了架构演变的过程。我们研究什么东西在迭代。这样,我们就不是去学习一个 “静态的”、“不变的” 信息技术的骨架,更重要的是我们也在学信息技术的发展历史。有了基础平台,有了前端与后端,有了过去与未来,我们就有了真真正正的全貌。我们博览群书,为的就是不拘于一隅,串联我们自身的知识体系,形成我们的认知框架。

信息科技的整体架构,与我们的应用软件架构息息相关。架构分基础架构和应用架构。选择基础架构也是构建业务竞争优势的重要组成部分。从技能来说,我们可能把架构师能力去归结为:

  • 理需求的能力;
  • 读代码的能力;
  • 抽象系统的能力。

但架构师的成长之旅,首先是心性修炼之旅。这包括:

  • 同理心的修炼,认同他人的能力;
  • 全局观的修炼,保持好奇心和学习的韧性;
  • 迭代能力的修炼,学会反思,学会在自我否定中不断成长。

业务的正交分解

架构就是业务的正交分解。每个模块都有它自己的业务。这里我们说的模块是一种泛指,它包括:函数、类、接口、包、子系统、网络服务程序、桌面程序等等。

这句话看似很简单,但是它太重要了,它是一切架构动作的基础。架构行为的三步曲:“需求分析”、“概要设计”、模块的 “详细设计”,背后都直指业务的正交分解,只是逐步递进,一步步从模糊到越来越强的确定性,直至最终形成业务设计的完整的、精确无歧义的解决方案。

对业务进行分解得到的每一个模块来说,最重要的是模块边界,我们通常称之为 “接口”。接口是业务的抽象,同时也是它与使用方的耦合方式。在业务分解的过程中,我们需要反复地审视模块的接口,发现其中 “过度的(或多余的)” 约束条件,把它提高到足够通用的、普适的场景来看。

在架构分解过程中有两大难题。

其一,需求的交织,不同需求混杂在一起。这是因为存在我们说的全局性功能。其二,需求的易变。不同客户,不同场景下需求看起来很不一样,场景呈发散趋势。但无论如何,我们需要坚持作为一名架构师的信仰:任何功能都是可以正交分解的,即使我目前还没有找到方法,那也是因为我还没有透彻理解需求。怎么做业务分解?
\t业务分解就是最小化的核心系统,加上多个正交分解的周边系统。核心系统一定要最小化,要稳定。坚持不要往核心系统中增加新功能,这样你的业务架构就不可能有臭味。所以业务做正交分解的第一件事情,就是要分出哪些是核心系统,哪些是周边子系统。核心系统构成了业务的最小功能集,而后通过不断增加新的周边功能,而演变成功能强大的复杂系统。这里有一个周边功能对核心系统总伤害的经验公式:

同一个周边功能相邻的代码行算作一处修改。不同周边功能的修改哪怕相邻也算作多处。这个公式核心想表达的含义是:修改处数越多,伤害越大。对于每一处修改,鼓励尽可能减少到只修改一行,更多代码放到周边模块自己那里去。

在 “62 | 重新认识开闭原则 (OCP)” 这一讲我们介绍了开闭原则。它非常非常重要,可以说是整个架构课的灵魂。总结来说,开闭原则包含以下两层含义:第一,模块的业务要稳定。模块的业务遵循 “只读” 设计,如果需要变化不如把它归档,放弃掉。这种模块业务只读的思想,是很好的架构治理的基础哲学。

这告诉我们,软件是可以以 “搭积木” 的方式搭出来的。核心的一点是,我们如何形成更多的 “积木”,即一个个业务只读、接口稳定、易于组合的模块。我平常和小伙伴们探讨架构时,也经常说这样一句话:每一个模块都应该是可完成的。

这实际上是开闭原则业务范畴 “只读” 的架构治理思想的另一种表述方式。要坚持不断地探索各类需求的架构分解方法。这样的思考多了,我们就逐步形成了各种各样的架构范式。这些架构范式,并不仅仅是一些架构思维,而是 “一个个业务只读、接口稳定、易于组合的模块 + 组合的方法论”,它们才是架构师真正的武器库。

第二,模块的业务变化点,简单一点的,通过回调函数或者接口开放出去,交给其他的业务模块。复杂一点的,通过引入插件机制把系统分解为 “最小化的核心系统 + 多个彼此正交的周边系统”。回调函数或者接口本质上就是一种事件监听机制,所以它是插件机制的特例。

领域理解

应对业务需求的变化,最好的结构就是: 最小化的核心系统 + 多个彼此正交的周边系统。

但是光理解了这一点,并不足以根本性地改变你的架构能力,因为这里面最难的是领域理解。所以需求分析很关键。怎么做需求分析?这一点要讲透真的很难。

我们用的是笨方法。把整个信息科技的演进史讲了一遍。我们用穷举的方式来讲信息科技的半部演进史。为什么我说是半部?整个信息科技的发展,我们把它分为程序驱动和数据驱动两个阶段。

程序驱动的本质,是自动化的极致。以前,自动化是非常机械的,要完成自动化需要极大的难度。但是,软件的出现让自动化成为一种普惠价值,这是信息科技的上半部演进史带来的核心收益。

但到了数据驱动,事情就变了。我们甚至有了新的专有名词,比如 “智能时代”,或者 “DT 时代”。很多人想到智能,想到的是深度学习,想到的是机器视觉。但其实这非常片面。马云把上半场叫 IT,下半场叫 DT(数据科技),非常形象而且深刻。

我们的架构课,把话题收敛到了 “如何把软件跑起来,并保证它持续健康运行” 这件事情上。但从企业的业务运营角度来说,这还远不是全部。“54 | 业务的可支持性与持续运营” 我们稍稍展开了一下这个话题。但要谈透这个话题,它会是另一本书,内容主题将会是 “数据治理与业务运营体系构建”。

我希望有一天能够完成它,但这可能要很久之后的事情了。

结语

今天我们对本章内容做了概要的回顾,“架构思维篇” 到此就结束了。理解了本章的内容,对于如何构建一个高度可扩展的软件架构你就有了基本的认知。但不要让自己仅仅停留在认知上,需要多多实践。架构的功夫全在平常。

无论是在我们架构范式的不断完善上,还是应对架构老化的经验积累上,都是在日常工作过程中见功夫。我们不能指望有一天架构水平会突飞猛进。架构能力提升全靠平常一点一滴地不断反思与打磨得来。在应对架构老化这件事情上,不要轻率地选择进行全局性的重构。要把功夫花在平常,让重构在润物细无声中发生。

从难度来说,全局性的重构比一个全新业务的架构过程要难得多。重构,不只是一个架构的合理性问题。它包含了架构合理性的考量,因为我们需要知道未来在哪里,我们迭代方向在哪里。但重构的挑战远不只是这些。这是一个集架构设计(未来架构应该是什么样的)、资源规划与调度(与新功能开发的优先级怎么排)、阶段规划(如何把大任务变小,降低内部的抵触情绪和项目风险)以及持久战所需的韧性与毅力的庞大工程。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们正式开始进入第六章:软件工程篇。

返回上级