这本书之前刚工作的时候略有耳闻,不过当时简单翻阅了下,并没有什么共鸣,可能是编程经验太少导致,但最近我再重读这本书的时候,感觉之前自己或所在团队确实踩了不少类似的坑,也渐渐了解到,很多复杂的项目和工程背后,其实往往是一些最朴素的道理,软件工程的发展也并没有上层框架的发展那么快。

也因此,本篇博客作为一个读书笔记性质,可能有些地方也并非足够连贯。

如何看待软件架构设计

软件架构设计的终极目标:用最小的人力成本来满足构建和维护该系统的需求。

很多时候,对于一个系统,一开始我们的开发效率接近 100%,然而伴随产品的每次发布,生产力直线下降。工程师的大部分时间都是消耗在对现有系统的修修补补上面,而不是真正完成实际的功能。拆东墙补西墙,周而往复。公司需要的人力成本也因此变多了,但是效益却没有提升。

初级工程师总是会犯的一个问题是:持续低估良好的设计,整洁的代码的重要性,并且普遍采用一种话术来欺骗自己:我们可以未来在重构代码,产品上线最重要。而实际上,产品上线之后疲于应付新需求已经很累了,就很难有重构的时机。

实际上,一般软件开发都会被设计成如下三个阶段,这可能并没有错:

  1. 先让代码工作起来
  2. 试图让它变好:通过优化和重构,让人更好地理解代码,并且适应新需求。
  3. 试着让它运行的更快

所以,我们确实需要理解整洁架构的重要性,避免我们在 1 和 2 循环往复。

软件设计的第一条原则:不管是为了可测试性还是其他什么东西——是不变的,就不要依赖于多变的东西。

软件系统

软件系统的价值维度:行为和架构。

变更的实施难度应该和变更的范畴成等比关系,而与变更的具体行为无关

有的时候,产品经理会表示,我就改一个小点,为什么需要几天时间?开发人员会找一大堆理由,通常不会提及架构的不合理性。实际上这种现象在我毕业入职的第一家公司时有发生。

编程范式

目前,我们主要有三个编程范式:结构化编程,面向对象编程,函数式编程。这些编程范式都是在 20 世纪被提出来的,而且在有限的时间中估计也不会新增编程范式了。

  • 结构化编程:if/then/else 和 do/while/util
  • 面向对象编程
  • 函数式编程:值不可变,对赋值进行了限制和规范

三个编程范式,分别限制了 goto 语句、函数指针和赋值语句的使用。

结构化编程

程序员可以用代码将一些已经证明可用的结构串联起来,只要自行证明这些额外代码是正确的,就可以推导出整个程序的正确性。

goto 语句,让我们的程序很难被分成这种小块。

关于验证:科学理论和科学定律的特点:他们可以被证伪,但是没有办法被证明,实际上现有的编程大部分也采用了这种理念,我们没用使用完整的形式化证明,而是使用测试用例,测试没有问题后,即发布到线上。

面向对象

  • 封装、继承、多态都不是面向对象创造出来的,但是确实使用起来更方便了。
  • 依赖反转也通常是面向对象的特点。
  • 独立部署:当某个组件的源代码需要修改,仅仅需要重新部署该组件即可,不需要修改其他组件。

面向对象编程就是以多态为手段来对源代码中的依赖关系进行控制的能力,这种能力让软件架构师能够构建出某种插件结构,让高层策略性的组件和底层实现的组件相分离,底层组件可以被编译成插件,实现独立于高层组件的开发和部署。

函数式编程

  • 所有的竞争问题、死锁问题、并发更新问题都是由可变变量导致的,如果变量永远不能被篡改,那就不可能产生竞争或并发更新的问题。如果锁的状态是不可变的,那就永远不会产生死锁问题。
  • 一个个架构设计良好的应用程序应该将状态修改的部分和不需要修改状态的部分隔离承担度的组件,然后通过合适的机制保护可变量。
  • 软件架构师应该着力于将大部分处理逻辑都归于不可变组件中,可变状态组件的逻辑越少越好。

一种函数式编程理念的开发方式:
事件溯源:我们只存储事务记录,不存储具体状态,当需要计算具体状态的时候,我们只需要重头开始计算所有的事务即可。同时,我们也把 CURD 变成了 CR。

SOLID 原则

我们为软件构建中层结构的主要目标:

  1. 使软件可容忍被改动。
  2. 使软件更容易被理解。
  3. 构建可在多个软件系统中复用的组件。

SOLID 原则 分为以下几点:

  • SRP:单一职责原则,任何一个软件模块都应该有且只有一个被修改的原因。避免多人为了不同的目的修改同一份原代码文件。
  • OCP:开放封闭原则,通过新增代码来修改原有的行为,而非只靠修改源代码。
  • LSP:里氏替换原则,组件方便被替换,每一处使用父类对象的地方,可以使用其子类对象进行替换,而保持其行为不变。
  • ISP:接口隔离原则,避免不必要的依赖。
  • DIP:依赖反转原则,高层代码不应该依赖底层细节。如果我们想要设计一个灵活的系统,在源代码层次的依赖关系中就应该多引用抽象类型,而非具体实现。
    • 优秀的软件架构师会花费很大力气来设计接口,以减少未来对其进行的改动。毕竟在不修改借口的情况下为软件增加新的功能是软件设计的基础常识。

实际上我们在实现 TS 库代码的时候经常用到 DIP,比如我们底层代码需要用到上层的一个功能,我们通常定义一个抽象类或者抽象函数,然后由上层来实现,这种场景即是 DIP。

组件构建原则

组件是软件的部署单元。

  • REP:复用/发布原则:软件复用的最小粒度应等同于其发布的最小粒度。
  • CCP:共同闭包原则:我们应该将那些会同时修改,并且为相同目的而修改的类放到同一个组件中,而将不会同时修改,并且不会为了相同目的而修改的那些类放到不同的组件中。
    • 对于大部分项目来说,可维护性的重要性远远大于复用性(精髓所在,以前一直觉得复用重要,但回头想想,对于一个项目而言,维护成本才是最直观的指标,这里设计修改所需要的人力成本,最终还是利益如何最大化的问题)
  • CRP:共同复用原则:实际上是 LSP 原则的一个普适版,LSP 原则是建议我们不要依赖带有不需要函数的类,CRP 原则则是建议我们不要依赖带有不需要的类的组件。

REP 和 CCP 是粘合性原则,他们会让组件变得更大,而 CRP 是排除性原则,它会尽量让组件更小。

组件耦合原则:

  • 无依赖环原则:整体依赖应该是一个有向无环图。
  • 稳定依赖原则:依赖关系必须要指向更稳定的方向。我们可以通过组件的依赖和被依赖的关系计算它的位置稳定性。
  • 稳定抽象原则:一个组件的抽象化程度应该与其稳定性保持一致。稳定的组件应该是抽象的,那么它的稳定性就不会影响到扩展性。

软件架构流程

整体包括:运行、维护、开发、部署

什么是软件架构师?软件架构师实际上应该是能力最强的一群程序员,他们通常会在自身承接编程任务的同时,逐渐引导整个团队向一个能够最大化生产力的系统设计方向演进。所以我们有时候误以为架构师就不写代码了,这当然是错的。

软件架构设计的三个工作:组件的切分,组件的组合,以及组件的相互通信。

软件架构设计的终极目标:最大化程序员的生产力,最小化系统的总运营成本。

关于部署:一般一个系统的部署成本越高,它的可用性就越低

例如在系统早期开发中,我们可能会决定采用某种微服务架构,但当我们实际部署这个系统的时候,我们就会发现微服务的数量已经庞大到令人生畏,这也就是笔者之前所在公司遇到的问题:一开始通过 golang 微服务实现整个系统,后面决定私有化部署后,迁移成本巨大,不得不进行了微服务的合并。

运行:对于一个因架构设计糟糕而效率低下的系统,我们通常只需要增加更多的存储器与服务器,就能够让它圆满的完成任务。另外,硬件也远远比人力便宜,这也是软件架构对系统运行的影响远远没有它对开发、部署、维护的影响那么深刻的原因
笔者现在确实应该意识到这个问题。

基于以上设计的架构:UI 界面 - 系统独有的业务能力 - 领域普适的业务能力 - 数据库

重复:架构师经常会钻进一个牛角尖:害怕重复。虽然软件代码编写的原则是 don't repeat yourself,但是有的时候,对于两个后期发展偏差很大的组件,如果只是存在一些暂时的重复,是我们完全可以容忍的。我们应该根据实际情况来决定是否要重复。

划分边界

软件开发技术发展史,就是一个如何想法设法方便增加插件,从而构建一个可扩展,可维护的系统架构的故事,系统的核心业务逻辑必须和其他组件隔离,保持独立,而这些其他组件要么是可以去掉的,要么是有多重实现的。
同时,插件部分的变更实际上不应该影响系统核心逻辑的变更。

这里举例:比如说当我们设计一个多节点 server,它依赖一个分布式存储系统,我们不应该在 server 中把这个分布式存储系统默认为 redis, 而应该定义接口能力即可。

如何分层:本质上,所有的软件系统都是一组策略语句的集合。我们需要将这些策略彼此分离,并且将它们按照变更的方式进行重新分组。其中变更的原因,时间和层次相同的策略应该分到一个组件中。反之,变更原因、时间和层次不同的策略应该分属不同的组件。最终它们是一个有向无环图。

整洁架构

  • 六边形架构
  • DCI 架构
  • BCE 架构

这些架构通常有以下特点:

  1. 独立于框架
  2. 可被测试:这些系统的业务逻辑可以脱离 UI、数据库、Web服务以及其他外部元素来进行测试。
  3. 独立于 UI,并且比较方便地在不改动业务逻辑的情况下改动 UI
  4. 独立于数据库,以及独立于其他外部机构

谦卑对象模式:

谦卑对象的解读:我们可以将软件模块分为两组,一组是谦卑组,另外一组不是。谦卑组的模块通常比较难写代码进行测试,比如 GUI,这部分代码应该越简单越好。

门户模式:Facade Pattern
外部只能看到 Facade,然后 Facade 内部的 implement 可以有一个或者多个。

这种模式在我们重构项目的时候挺有用的,我们可以实现一个 Facade 类,然后默默把里面的实现灰度或者直接换掉。

服务和架构

实际上,服务本身只是一种比函数调用成本稍微高的,分割应用程序的一种形式,与数据库无关。

服务真的解耦了么?因为通常服务不能彼此访问变量,我们会认为这种设计自然就解耦了。但实际上,任何形式的共享数据都会导致强耦合,比如它们依赖同一种数据结构、同一个 schema。而且在这种情况下,它们的 dev ops 也并不是独立的。


<完>