整洁架构

本书的整体思路

  1. 首先说明架构十分重要。
  2. 为了得到良好的架构,思考软件系统最本质的组成:代码。从代码出发,一步步将代码组织成系统:代码->类->组件->系统
  3. 从代码开始,介绍编写代码的三种编程范式:结构化编程、面向对象编程、函数式编程。
  4. 接下来介绍将代码(数据+行为)组织成类的设计原则:SOLID原则。
  5. 类确定后,介绍将类聚合成组件的原则(REP、CCP、CRP)以及组件之间的耦合原则(ADP、SDP、SAP)。
  6. 软件架构的实质就是规划如何将系统切分成组件,并安排好组件之间的排列关系,以及组件之间互相通信的方式。第5步已经得到了组件,所以接下来介绍软件架构要遵循的一些方法,以便将这些组件组合成完整的系统。
  7. 最后介绍了软件架构的一些细节问题。

架构目标

软件架构设计的主要目标是支撑软件系统的全生命周期,设计良好的架构可以让系统便于理解、易于修改、方便维护,并且能轻松部署。
软件架构的终极目标就是最大化程序员的生产力,同时最小化系统的总运营成本。

在质量、效率、成本这个三角形中探索平衡点。

行为价值

软件系统的行为是其最直观的价值维度。程序员的工作就是让机器按照某种指定方式运转,给系统的使用者创造或者提高利润。程序员们为了达到这个目的,往往需要帮助系统使用者编写一个对系统功能的定义,也就是需求文档。然后,程序员们再把需求文档转化为实际的代码。

大部分程序员认为工作是且仅是:按照需求文档编写代码,并且修复任何Bug。真是大错特错。

架构价值

作为技术人员,更要重视软件系统的另一价值维度:架构价值。

软件系统必须保持灵活。软件发明的目的,就是让我们可以以一种灵活的方式来改变机器的工作行为。对机器上那些很难改变的工作行为,我们通常称之为硬件(hardware)。

为了达到软件的本来目的,软件系统必须够“软”——也就是说,软件应该容易被修改。当需求方改变需求的时候,随之所需的软件变更必须可以简单而方便地实现。变更实施的难度应该和变更的范畴(scope)成等比关系,而与变更的具体形状(shape)无关。

哪个价值更重要?

究竟是系统行为更重要,还是系统架构的灵活性更重要?哪个价值更大?系统正常工作更重要,还是系统易于修改更重要?

这两个价值并不冲突:若忽视了架构价值,行为价值也无法实现。系统迭代会变得越来越慢,问题越来越多,成本变得越来越大。只有重视架构,行为价值才能被更好的满足。

软件开发的一个核心特点:要想跑得快,先要跑得稳。

架构师的职责

软件架构师必须创建出一个可以让功能实现起来更容易、修改起来更简单、扩展起来更轻松的软件架构。

请记住:如果忽视软件架构的价值,系统将会变得越来越难以维护,终会有一天,系统将会变得再也无法修改。

如果系统变成了这个样子,那么说明软件开发团队没有和需求方做足够的抗争,没有完成自己应尽的职责。

需求是做正确的事,设计是正确的做事。
研发团队应该珍惜自己的精力,把自己看作是投资人,产品提的需求不能打动你,就不值得投入。
一旦决定投入,要按研发团队的方式来执行,因为其他人都是外行,没有人比你更专业。

编程范式

任何软件架构的实现都离不开具体的代码,对软件架构的讨论应该从第一行被写下的代码开始。

编程范式指的是程序的编写模式,与具体的编程语言关系相对较小。这些范式会告诉你应该在什么时候采用什么样的代码结构。直到今天,我们也一共只有三个编程范式,而且未来几乎不可能再出现新的。

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

结构化编程

结构化编程对程序控制权的直接转移进行了限制和规范。

  • 简单一句话:禁用goto,使用if/else,do/while/until 等来代替
  • Dijkstra于1968年最先提出并推导证明。

面向对象编程

面向对象编程对程序控制权的间接转移进行了限制和规范。

  • 比结构化编程还早提出两年。
  • 1966年由OleJohan Dahl和Kriste Nygaard在论文中总结归纳出来的。这两个程序员注意到在ALGOL语言中,函数调用堆栈(call stack frame)可以被挪到堆内存区域里,这样函数定义的本地变量就可以在函数返回之后继续存在。这个函数就成为了一个类(class)的构造函数,而它所定义的本地变量就是类的成员变量,构造函数定义的嵌套函数就成为了成员方法(method)。这样一来,我们就可以利用多态(polymorphism)来限制用户对函数指针的使用。

面向对象编程到底是什么?业界在这个问题上存在着很多不同的说法和意见。然而对一个软件架构师来说,其含义应该是非常明确的:面向对象编程就是以多态为手段来对源代码中的依赖关系进行控制的能力,这种能力让软件架构师可以构建出某种插件式架构,让高层策略性组件与底层实现性组件相分离,底层组件可以被编译成插件,实现独立于高层组件的开发和部署。

函数式编程

函数式编程对程序中的赋值进行了限制和规范。

  • 最早被提出
  • 函数式编程概念是基于与阿兰·图灵同时代的数学家Alonzo Church在1936年发明的λ演算的直接衍生物
  • 从理论上来说,函数式编程语言中应该是没有赋值语句的。大部分函数式编程语言只允许在非常严格的限制条件下,才可以更改某个变量的值。

设计原则

SOLID原则的主要作用就是告诉我们如何将数据和函数组织成为类,以及如何将这些类链接起来成为程序

单一职责原则

该设计原则是基于康威定律(Conway’s Law)的一个推论——一个软件系统的最佳结构高度依赖于开发这个系统的组织的内部结构。这样,每个软件模块都有且只有一个需要被改变的理由。

SRP是SOLID五大设计原则中最容易被误解的一个。很多程序员想当然地认为这个原则就是指:每个模块都应该只做一件事。(之前我也这么以为)
根据书中所讲,SRP的描述可以这样推演:
“任何一个软件模块都应该有且仅有一个被修改的原因。” ->
“任何一个软件模块都应该只对一个用户(User)或系统利益相关者(Stakeholder)负责。”->
任何一个软件模块都应该只对某一类行为者负责。

文中提到的“软件模块”究竟又是在指什么呢?大部分情况下,其最简单的定义就是指一个源代码文件。然而,有些编程语言和编程环境并不是用源代码文件来存储程序的。在这些情况下,“软件模块”指的就是一组紧密相关的函数和数据结构。

这样推演是为了更好的与真正的软件系统结合。软件系统为了满足用户和所有者的要求,必然要经常做出修改。
“用户和所有者”对应“被修改的原因”,但这样用词并不准确,书中统称为 行为者(actor)。

单一职责最重要的是职责的划分,职责的划分才是重点。职责清楚后才能划分边界,否则其他几项原则都是空中楼阁。

开闭原则

如果软件系统想要更容易被改变,那么其设计就必须允许新增代码来修改系统行为,而非只能靠修改原来的代码。

一个设计良好的计算机系统应该在不需要修改的前提下就可以轻易被扩展。

看过大量如下代码:
if(xxx) { doSth(); } else { doOther(); }

(这里只是举例这种代码是违反开闭的例子,并不是说不该存在。)

开闭原则关键是分离变与不变。对于不变的进行抽象,对于具体的、经常变化的进行扩展。

例如,软件系统中界面展示方式、内容经常变化,早期需要支持web端、客户端。界面属于具体的、经常变化的,不变的是界面依赖的数据。可以对数据进行抽象,具体的界面依赖数据。新增 移动端 展示方式时增加对数据的扩展,已存在的不需要改造。

面向对象的多态真的是个神器。

OCP是进行系统架构设计的主导原则,其主要目标是让系统易于扩展,同时限制其每次被修改所影响的范围。
实现方式是通过将系统划分为一系列组件,并且将这些组件间的依赖关系按层次结构进行组织,使得高阶组件不会因低阶组件被修改而受到影响。

里氏替换原则

如果想用可替换的组件来构建软件系统,那么这些组件就必须遵守同一个约定,以便让这些组件可以相互替换

接口隔离原则

在设计中避免不必要的依赖。

依赖倒置原则

高层策略性的代码不应该依赖实现底层细节的代码,恰恰相反,那些实现底层细节的代码应该依赖高层策略性的代码。

依赖反转原则(DIP)主要想告诉我们的是,如果想要设计一个灵活的系统,在源代码层次的依赖关系中就应该多引用抽象类型,而非具体实现。

注意,具体实现时不可能完全消失违反DIP的情况。通常只需要把它们集中于少部分的具体实现组件中,将其与系统的其他部分隔离即可,比如main组件。

组件

什么是组件

组件是软件的部署单元,是整个软件系统在部署过程中可以独立完成部署的最小实体。例如,对于Java来说,它的组件是jar文件。而在Ruby中,它们是gem文件。在.Net中,它们则是DLL文件。总而言之,在编译运行语言中,组件是一组二进制文件的集合。而在解释运行语言中,组件则是一组源代码文件的集合。无论采用什么编程语言来开发软件,组件都是该软件在部署过程中的最小单元。

设计良好的组件都应该永远保持可被独立部署的特性,这同时也意味着这些组件应该可以被单独开发。
软件系统通过组件的方式构建,每个组件具备独立部署的特性,意味着通过可插拔组件的方式提升系统的扩展性。

组件如何聚合

  1. 组件的构成安排不会一成不变,应随着项目重心的不同,以及研发性与复用性的不同而不断演化。
  2. 个人理解,这三个原则指导如何构建出“高内聚”的组件:
    • 有相同的修改原因
    • 同时修改
    • 同时被复用
    • 组件内的类都是紧密关联的

REP: The Reuse/Release Equivalence Principle(复用/发布等同原则)

软件复用的最小粒度应等同于其发布的最小粒度。
复用组件时,要求被复用的组件可以独立发布并有明确版本号。
这项原则看起来是废话,因为复用的前提就是把相关的逻辑分离到独立的组件中。
但难点在于,如何确定哪些类组合成组件。
这个问题由CCP和CRP解答。

CCP: The Common Closure Principle (共同闭包原则)

CCP的主要作用就是提示我们要将所有可能会被一起修改的类集中在一处。

将会同时修改,并且为相同目的而修改的类放到同一个组件中,而将不会同时修改,并且不会为了相同目的而修改的那些类放到不同的组件中。

这其实是SRP原则在组件层面上的再度阐述。正如SRP原则中提到的“一个类不应该同时存在着多个变更原因”一样,CCP原则也认为一个组件不应该同时存在着多个变更原因。

SRP与CCP概括为:将由于相同原因而修改,并且需要同时修改的东西放在一起。将由于不同原因而修改,并且不同时修改的东西分开。

CRP: The Common Reuse Principle (共同复用原则)

该原则建议将经常共同复用的类和模块放在同一个组件中。
因为类很少被单独复用,更常见的是多个类同时作为某个可复用的抽象定义被共同复。
这个原则简答了我当年学Java的一个困惑。
当年想被遍历一个容器对象(比如HashMap),以为需要其他类来使用遍历器,实际是可直接通过容器对象获取遍历器。

但CRP更重要的是告诉将哪些类分开。
每当引用一个组件,就增加了依赖关系。被引用组件发生变更时,引用它的组件也要变更。
如果把关联不紧密的类放在了同一个组件,就会造成上述问题。导致给被人提供了个需要经常更新、测试、部署的组件,用的人就开骂了。

所以,CRP原则实际上是在指导我们:不是紧密相连的类不应该被放在同一个组件里。

CRP实际是ISP原则的普世版,两个原则可以用一句话概括:不要依赖不需要用到的东西

组件聚合张力图

  • REP和CCP是粘合性原则,会让组件变得更大
  • CRP是排除性原则,会让组件变小
    架构师就是在三个原则中取舍,可以用组件聚合张力图描述三者关系:

在项目早期,CCP原则会比REP原则更重要,因为这一阶段研发速度比复用性更重要。

一般来说,一个软件项目的重心会从该三角区域的右侧开始,先期主要牺牲的是复用性。然后,随着项目逐渐成熟,其他项目会逐渐开始对其产生依赖,项目重心就会逐渐向该三角区域的左侧滑动。

很好理解,因为项目一开始也不知道哪些能复用,所以不会拆分组件,通常逻辑都放在了一个组件中。随着迭代,功能越来越多,对业务理解更深刻,就会剥离出可复用的组件。

不知道是不是Bob大叔创造的名词。ccp和crp可以简单映射为单体架构和微服务架构。造出来这些名词,让人难以理解。

组件如何耦合

组件依赖结构图并不是用来描述应用程序功能的,它更像是应用程序在构建性与维护性方面的一张地图。这就是组件的依赖结构图不能在项目的开始阶段被设计出来的原因——当时该项目还没有任何被构建和维护的需要,自然也就不需要一张地图来指引。

组件结构图的重要目标是如何隔离频繁的变更。软件架构师需要设计一套组件关系依赖图,以便将稳定的高价值组件与常变的组件隔离开。

ADP: THE ACYCLIC DEPENDENCIES PRINCIPLE(无依赖环原则)

这个原则很好理解。
如何打破循环依赖?

  1. 使用DIP原则
  2. 引入新的组件,使两个组件都依赖新的组件

SDP:THE STABLE DEPENDENCIES PRINCIPLE(稳定依赖原则)

依赖关系必须指向更稳定的方向。
那如何评估组件的稳定性?

稳定性指标

Fan-in:入向依赖,这个指标指代了组件外部类依赖于组件内部类的数量。
Fan-out:出向依赖,这个指标指代了组件内部类依赖于组件外部类的数量。

I:不稳定性,I=Fan-out/(Fan-in+Fan-out)。该指标的范围是[0,1],I=0意味着组件是最稳定的,I=1意味着组件是最不稳定的.

稳定依赖原则(SDP)的要求是组件结构依赖图中各组件的I指标必须要按其依赖关系方向递减。

注意,并不是所有的组件都是稳定的,都是稳定的就意味着不能改了,失去了扩展性。
关键是保障核心组建的稳定性,分离变与不变。

经常违反SDP原则的例子就是业务逻辑依赖了界面展示:
-w425

如何修复?
DIP是个神器。
-w405

SAP:THE STABLE ABSTRACTIONS PRINCIPLE(稳定抽象原则)

一个组件的抽象化程度应该与其稳定性保持一致。

如果我们将高阶策略放入稳定组件中,那么用于描述那些策略的源代码就很难被修改了。这可能会导致整个系统的架构设计难于被修改。

如何才能让一个无限稳定的组件(I=0)接受变更呢?开闭原则(OCP)为我们提供了答案。

这个原则告诉我们:创造一个足够灵活、能够被扩展,而且不需要修改的类是可能的,而这正是我们所需要的。哪一种类符合这个原则呢?答案是抽象类。

稳定抽象原则(SAP)为组件的稳定性与它的抽象化程度建立了一种关联。一方面,该原则要求稳定的组件同时应该是抽象的,这样它的稳定性就不会影响到扩展性。另一方面,该原则也要求一个不稳定的组件应该包含具体的实现代码,这样它的不稳定性就可以通过具体的代码被轻易修改。

将SAP与SDP这两个原则结合起来,就等于组件层次上的DIP。

然而,DIP毕竟是与类这个层次有关的原则——对类来说,设计是没有灰色地带的。一个类要么是抽象类,要么就不是。SDP与SAP这对原则是应用在组件层面上的,我们要允许一个组件部分抽象,部分稳定。

衡量抽象化程度

Nc:组件中类的数量。
Na:组件中抽象类和接口的数量。
A:抽象程度,A=Na÷Nc。

A指标的取值范围是从0到1,值为0代表组件中没有任何抽象类,值为1就意味着组件中只有抽象类。

主序列图

组件的稳定性I与其抽象化程度A之间的关系:
纵轴为A值,横轴为I值。
-w325

最稳定的、包含了无限抽象类的组件应该位于左上角(0,1),最不稳定的、最具体的组件应该位于右下角(1,0)。

不可能所有的组件都能处于这两个位置上,因为组件通常都有各自的稳定程度和抽象化程度。

-w414
enter image description here

痛苦区

一个不抽象的组件,但却很稳定,稳定的原因是被其他组件所依赖。
这样的组件在设计上是不佳的,因为它很难被修改,这意味着该组件不能被扩展。
这样一来,因为这个组件不是抽象的,而且它又由于稳定性的原因变得特别难以被修改,我们并不希望一个设计良好的组件贴近这个区域,因此(0,0)周围的这个区域被我们称为痛苦区(zone of pain)。

最好的例子就是数据库中的表了。表结构十分具体,被其他组件依赖,每次修改都痛苦万分。

另一个会处于这个区域的典型软件组件是工具型类库。例如String组件,虽然其中所有的类都是具体的,但由于它被使用得太过普遍,任何修改都会造成大范围的混乱,因此String组件只能是不可变的。

无用区

因为这些组件通常是无限抽象的,但是没有被其他组件依赖,这样的组件往往无法使用。这类组件也不是我们想要的

避开这两个区域

坐落于主序列线上的组件不会为了追求稳定性而被设计得“太过抽象”,也不会为了避免抽象化而被设计得“太过不稳定”。

在整条主序列线上,组件所能处于最优的位置是线的两端。一个优秀的软件架构师应该争取将自己设计的大部分组件尽可能地推向这两个位置。然而,大型系统中的组件不可能做到完全抽象,也不可能做到完全稳定。所以我们只要追求让这些组件位于主序列线上,或者贴近这条线即可。

软件架构

软件架构这项工作的实质就是规划如何将系统切分成组件,并安排好组件之间的排列关系,以及组件之间互相通信的方式。

软件架构设计的主要目标是支撑软件系统的全生命周期,设计良好的架构可以让系统便于理解、易于修改、方便维护,并且能轻松部署。

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

保留可选项

优秀的架构师所设计的策略应该允许系统尽可能地推迟与实现细节相关的决策,越晚做决策越好。

所有的软件系统都可以降解为策略与细节这两种主要元素。策略体现的是软件中所有的业务规则与操作过程,因此它是系统真正的价值所在。

而细节则是指那些让操作该系统的人、其他系统以及程序员们与策略进行交互,但是又不会影响到策略本身的行为。它们包括I/O设备、数据库、Web系统、服务器、框架、交互协议等。

如果在开发高层策略时有意地让自己摆脱具体细节的纠缠,我们就可以将与具体实现相关的细节决策推迟或延后,因为越到项目的后期,我们就拥有越多的信息来做出合理的决策。

独立性

一个设计良好的架构应该能允许一个系统从单体结构开始,以单一文件的形式部署,然后逐渐成长为一组相互独立的可部署单元,甚至是独立的服务或者微服务。最后还能随着情况的变化,允许系统逐渐回退到单体结构。

划分边界

通过划清边界,可以推迟和延后一些细节性的决策,可以节省大量的时间、避免大量的问题。

为了在软件架构中画边界线,我们需要先将系统分割成组件,其中一部分是系统的核心业务逻辑组件,而另一部分则是与核心业务逻辑无关但负责提供必要功能的插件。然后通过对源代码的修改,让这些非核心组件依赖于系统的核心业务逻辑组件。其实,这也是一种对依赖反转原则(DIP)和稳定抽象原则(SAP)的具体应用,依赖箭头应该由底层具体实现细节指向高层抽象的方向。

这一节讲的有点啰嗦,重点在于如何划分边界?
原则就是让核心业务逻辑组件成为高层组件,界面、数据库、输入/输出等都与核心组件都应划分边界,让具体的组件依赖核心组件。

这样做的好处是可以构建插件式架构。核心业务逻辑不变,其他的全可以插拔式替换。
-w521

业务逻辑

业务逻辑是一个软件系统存在的意义,它们属于核心功能,是系统用来赚钱或省钱的那部分代码,是整个系统中的皇冠明珠。这些业务逻辑应该保持纯净,不要掺杂用户界面或者所使用的数据库相关的东西。
在理想情况下,这部分代表业务逻辑的代码应该是整个系统的核心,其他低层概念的实现应该以插件形式接入系统中。业务逻辑应该是系统中最独立、复用性最高的代码。

整洁架构

有一条贯穿整个架构设计的依赖关系规则:源码中的依赖关系必须只指向同心圆的内层,即由低层机制指向高层策略。
-w539

  • 真正的架构很可能会超过四层。并没有某个规则约定一个系统的架构有且只能有四层。
  • 然而,这其中的依赖关系原则是不变的。码层面的依赖关系一定要指向同心圆的内侧。
  • 层次越往内,其抽象和策略的层次越高,同时软件的抽象程度就越高,其包含的高层策略就越多。
  • 最内层的圆中包含的是最通用、最高层的策略,最外层的圆包含的是最具体的实现细节。

业务实体

业务实体这一层中封装的是整个系统的关键业务逻辑,一个业务实体既可以是一个带有方法的对象,也可以是一组数据结构和函数的集合。

用例

软件的用例层中通常包含的是特定应用场景下的业务逻辑,这里面封装并实现了整个系统的所有用例。
这些用例引导了数据在业务实体之间的流入/流出,并指挥着业务实体利用其中的关键业务逻辑来实现用例的设计目标。

接口适配器

软件的接口适配器层中通常是一组数据转换器,它们负责将数据从对用例和业务实体而言最方便操作的格式,转化成外部系统(譬如数据库以及Web)最方便操作的格式。

示例

-w545

打钱! 打钱! 打钱😡😡😡