第1部分:概述
软件架构的终极目标时,用最小的人力成本来满足构建和维护该系统的需求
本书的主题:描述什么是优秀的、整洁的软件架构与设计
两个价值维度
行为价值:让机器按照某种指定方式运转
架构价值:软件系统的灵活性,即是否容易被修改
如果忽略软件架构的价值,系统将会变得越来越难以维护,终会有一天,系统将会变得再也无法修改。
第2部分:从基础构建开始:编程范式
结构化编程
- 结构化编程对程序控制权的直接转移进行了限制和规范
- 用顺序结构、分支结构、循环结构可构造出任何程序
- 结构化编程范式可将模块递归拆分为可推导的单元
- goto有害,某些用法会导致某个模块无法被递归拆分成更小、可证明的单元
面向对象编程
- 面向对象编程对程序控制权的间接转移进行了限制和规范
- 面向对象编程就是以多态为手段来对源代码中的依赖关系进行控制的能力,这种能力让软件架构师可以构建出某种插件式架构,让高层策略性组件与底层实现性组件相分离,底层组件可以被编译成插件,实现独立于高层组件的开发和部署
函数式编程
函数式编程对程序中的赋值进行了限制和规范
可变变量导致竞争问题、死锁问题、并发更新问题
不可变性方案:
一个架构设计良好的应用程序应该将状态修改的部分和不需要修改状态的部分隔离成单独的组件,然后用合适的机制来保护可变量。软件架构师应该致力于将大部分处理逻辑都归于不可变组件中,可变状态组件的逻辑应该越少越好。
事件溯源:只存储事务记录,不存储具体状态。当需要具体状态时,从头开始计算所有的事务。当只有CR,没有UD,自然也就不存在并发问题。
第3部分:设计原则
SRP:单一职责原则
- Single Responsibility Principle
- There should never be more than one reason for a class to change
- 单一职责原则主要讨论的是函数和类之间的关系--但是它在两个讨论层面上会以不同的形式出现。在组件层面,我们可以将其称为共同必包原则(Common Closure Principle),在软件架构层面,它则是用于奠定架构边界的变更轴心(Axis of Change)。
OCP:开闭原则
- Open-Closed Principle
- 设计良好的计算机软件应该易于扩展,同时抗拒修改
- 其主要目标是让系统易于扩展,同时限制器每次被修改所影响的范围。实现方式是通过将系统划分为一系列组件,并且将这些组件间的依赖关系按层次结构进行组织,使得高阶组件不会因低阶组件被修改而受到影响。
LSP:里氏替换原则
- Liskov Substitution Principle
- 对于每个类型是S的对象o1,都存在一个类型为T的对象o2,能使操作T类型的程序P在用o2替换o1时行为保持不变,我们就可以将S称为T的子类型。
- 正方形/长方形问题:正方形不是长方形的子类,它们具有不同的修改边长的逻辑
ISP:接口隔离原则
- Interface Segregation Principle
- 避免不必要的依赖
DIP:依赖反转原则
Dependency Inversion Principle
具体的编码守则
应在代码中多使用抽象接口,尽量避免使用那些多变的具体实现类
不要在具体实现类上创建衍生类
不要覆盖包含具体实现的函数
应避免在代码中写入与任何具体实现相关的名字,或者是其他容易变动的事物的名字
对易变对象的创建过程做一些处理,通常会选择用抽象工厂模式来解决源代码的依赖问题
依赖方向与控制流方向相反
第4部分:组件构建原则
组件
- 组件是软件的部署单元,是整个软件系统在部署过程中可以独立完成部署的最小实体。例如,对于Java来说,它的组件是jar文件。
组件聚合
哪些类应该被组合成一个组件呢?三个与构建组件相关的基本原则:
REP:复用/发布等同原则(为复用性而组合)
软件复用的最小粒度应等同于其发布的最小粒度
组件中的类和模块必须是彼此紧密相关的
CCP:共同闭包原则(为维护性而组合)
我们应该将那些会同时修改,并且为相同目的而修改的类放到同一个组件中,而将不会同时修改,并且不会为了相同目的而修改的那些类放到不同的组件中。
SRP原则在组件层面上的再度阐述,可共同概括为:将由于相同原因而修改,并且需要同时修改的东西放在一起;将由于不同原因而修改,并且不同时修改的东西分开。
CRP:共同复用原则(为避免不必要的发布而切分)
不要强迫一个组件的用户依赖他们不需要的东西
和ISP可共同概括为:不要依赖不需要用到的东西
组件聚合张力图
- REP和CCP是黏合性原则, 它们会让组件变得更大,而CRP原则是排除性原则, 它会尽量让组件变小。
- 一般来说,一个软件项目的重心会从该三角区域的右侧开始,先期主要牺牲的是复用性。然后,随着项目逐渐成熟,其他项目会逐渐开始对其产生依赖,项目重心就会逐渐向该三角区域的左侧滑动。换句话说,一个项目在组件结构设计上的重心是根据该项目的开发时间和成熟度不断变动的,我们对组件结构的安排主要与项目开发的进度和它被使用的方式有关,与项目本身功能的关系其实很小。
组件耦合
三条主要关注组件之间关系的原则:
ADP:无依赖环原则
组件依赖关系图中不应该出现环
循环依赖会使得组件的独立维护、单元测试和发布流程变得困难
打破依赖循环的两种方式:
应用依赖反转原则(DIP),依赖于接口而不是实现
创建一个新的组件,将现有组件互相依赖的类全部放入新组件
SDP:稳定依赖原则
依赖关系必须要指向更稳定的方向
任何一个我们预期会经常变更的组件都不应该被一个难于修改的组件所依赖,否则这个多变的组件也将会变得非常难以修改。(通过遵守稳定依赖原则解决)
当组件不依赖于任何组件,则不会有任何原因导致它需要被变更,我们称它为“独立”组件
一种计算组件稳定性的方式,I指标:
I = Fan-out / (Fan-in + Fan-out)
Fan-in: 入向依赖,组件外部类依赖于组件内部类的数量
Fan-out: 出向依赖,组件内部类依赖于组件外部类的数量
I 不稳定性,指标范围是[0, 1],值越小越稳定
每个组件的I指标都必须大于其所依赖组件的I指标,即组件结构依赖图中的I指标必须要按其依赖关系方向递减。
可使用DIP来解决违反稳定依赖原则的组件依赖
SAP:稳定抽象原则
一个组件的抽象化程度应该与其稳定性保持一致
该原则要求稳定的组件同时应该是抽象的,这样它的稳定性就不会影响到扩展性;另一方面,该原则也要求一个不稳定的组件应该包含具体的实现代码,这样它的不稳定性就可以通过具体的代码被轻易修改。
对组件抽象化程度的一个衡量方式,A指标:
A = Na / Nc
Nc: 组件中类的数量
Na: 组件中抽象类和接口的数量
A:抽象程度
稳定程度与抽象化程度的区间分析
- 痛苦区:非常稳定但也非常具体,难以被修改
- 无用区:抽象但没有被其他组件依赖
- 最优的位置是主序列线的两端,实际项目中贴近线即可
- 衡量一个组件距离最佳位置的指标,D指标:
D=|A+I-1|
第5部分:软件架构
什么是软件架构
- 软件架构这项工作的实质就是规划如何将系统切分成组件,并安排好组件之间的排列关系,以及组件之间互相通信的方式。
- 设计软件架构的目的是为了在工作中更好地对这些组件进行研发、部署运行以及运维;最大化程序员的生产力,同时最小化系统的总运营成本。
- 将软件的高层策略与其底层实现隔离开
- 一个优秀的软件架构师应该致力于最大化可选项数量
独立性
康威定律:任何一个组织在设计系统时,往往都会复制出一个与该组织内部沟通结构相同的系统。
将系统正确地划分为一些隔离良好的组件,以便尽可能长时间地为我们的未来保留尽可能多的可选项
通过采用单一职责原则(SRP)和共同闭包原则(CCP),以及既定的系统设计意图来隔离那些变更原因不同的部分,集成变更原因原因相同的部分
按层解耦:UI界面、应用独有的业务逻辑、领域普适的业务逻辑、数据库等。
用例的解耦:根据用例进行垂直切片(比如添加订单和删除订单)
解耦模式
源码层次:源代码模块之间的依赖;系统所有的组件都会在同一个地址空间内执行,通过简单的函数调用来进行彼此的交互;通常叫做单体结构。
部署层次:部署单元(譬如jar文件、DLL、共享库等)之间的依赖;大部分组件可能还是依然运行在同一个地址空间内,通过彼此的函数调用通信,部分吗可能会运行在同一个处理器下的其他进程内;可以产生出许多可独立部署的单元。
服务层次:通过网络数据包进行通信;每个执行单元在源码层和二进制层都会是一个独立的个体。
一个设计良好的架构应该允许一个系统从单体结构开始,以单一文件的形式部署,然后逐渐成长为一组相互同理的可部署单元,甚至是独立的服务或者微服务。最后还能随着情况的变化,允许系统逐渐回退到单体结构。
划分边界
- 边界的作用是将软件分割成各种元素,以便约束边界两侧之间的依赖关系
- 尽可能地推迟细节性的决策(采用的框架、数据库、web服务器、工具库、依赖注入等),并将这种推迟所产生的影响降低到最低
- 过早的细节性决策会浪费大量人力
- 边界线应该画在那些不相关的事情中间,比如GUI、业务逻辑、数据库
- 数据库可以采用多种实现,而业务逻辑并不需要关心这件事。这意味着我们可以将与数据库相关的决策延后,先专注编写业务逻辑的代码,进行测试,直到不得不选择数据库为止。
- 插件式架构
- 为了在软件架构中画边界线,我们需要先将系统分割成组件,其中一部分是系统的核心业务逻辑组件,而另一部分则是与核心业务逻辑无关但负责提供必要功能的插件。然后通过对源代码的修改,让这些非核心组件依赖于系统的核心业务逻辑组件。这也是一种对依赖反转原则(DIP)和稳定抽象原则(SAP)的具体应用,依赖箭头应该由底层具体实现细节指向高层抽象的方向。
边界剖析
边界形式
单体结构:低层客户端调用高层服务函数
部署层次的组件:动态链接库
线程?
本地进程
服务
策略和层次
- 策略:描述计算部分的业务逻辑、描述计算报告的格式、描述如何校验输入数据等
- 层次:一条策略具体系统的输入/输出越远,它所属的层次就越高
- 在一个设计良好的架构中,依赖关系的方向通常取决于它们所关联的组件层次。一般来说,低层组件被设计为依赖于高层组件。
错误:
function encrypt() {
while(true)
writeChar(translate(readChar()))
}
高层组件encrypt()依赖于低层组件中的函数readChar()和writeChar()
正确:
Encrypt类依赖于Char Reader接口和Char Writer接口,由具体类实现接口方法。这样低层组件(实现类)就变成依赖于高层组件(Encrypt类加两个接口)
业务逻辑
- 关键业务逻辑和关键业务数据是紧密相关的,所以它们很适合被放在同一个对象中处理,这种对象也被叫做“业务实体(Entity)”
- 业务实体独自代表了整个业务逻辑,它与数据库、用户界面、第三方框架等内容无关
- 用例更靠近系统的输入和输出,属于低层概念;而业务实体是一个可以适用于多个应用情景的一般化概念,相对地离系统的输入和输出更远。所以用例依赖于业务实体,而业务实体并不依赖于用例。
- 不要选择直接在数据结构中使用对业务实体对象的引用!这两个对象存在的意义是非常不一样的,而且这两个对象会以不同的原因、不同的速率发生变更。整合在一起是对共同闭包原则(CCP)和单一职责原则(SRP)的违反。
尖叫的软件架构
- 软件的系统架构应该为该系统的用例提供支持。 -- 《Object Oriented Software Engineering, A Use Case Driven Approach》
- 一个良好的架构设计应该围绕着用例来展开,这样的架构设计可以在脱离框架、工具以及使用环境的情况下完整地描述用例。
- 避免让框架主导我们的架构设计
- 在不依赖任何框架的情况下针对用例进行单元测试。另外,我们运行测试的时候不应该运行web服务,也不应该需要连接数据库。
整洁架构
各类架构(六边形架构、DCI架构、BCE架构等)都具有同一个设计目标:按照不同关注点对软件进行切割。也就是说,这些架构都会将软件切割成不同的层,至少有一层是只包含该软件的业务逻辑的,而用户接口、系统接口则属于其他层。
按照这些架构设计出来的系统,通常都具有以下特点:
独立于框架
可被测试
独立于UI
独立于数据集
独立于任何外部机构
将上述所有架构的设计理念综合称为一个独立的理念:
依赖关系规则
通常越靠近中心,其所在的软件层次就越高。基本上,外层圆代表的是机制,内层圆代表的是策略。
贯穿整个架构设计的规则:源码中的依赖关系必须只指向同心圆的内层,即由低层机制指向高层策略。
业务实体:封装了该应用中最通用、最高层的业务逻辑,它们应该属于系统中最不容易受外界影响而变动的部分。
用例:通常包含的是特定应用场景下的业务逻辑,这里面封装并实现了整个系统的所有用例。这些用例引导了数据在业务实体之间的流入/流出,并指挥着业务实体利用其中的关键业务逻辑来实现用例的设计目标。
接口适配器:
通常是一组数据转换器,它们负责将数据从对用例和业务实体而言最方便操作的格式,转换为外部系统(譬如数据库以及web)和持久层框架最方便操作的格式。
如果采用的是SQL数据库,那么所有的SQL语句都应该被限制在这一层的代码中。
这层也会负责将来自外部服务的数据转换成系统内用例和业务实体所需的格式
框架和驱动程序:最外层的模型层一般是由工具、数据库、web框架等组成。在这一层中,我们通常只需要编写一些于内层沟通的黏合性代码。
真正的架构可能会超过四层,但是源码层面的依赖关系一定要指向同心圆的内层。
通常采用依赖反转原则(DIP)来解决控制流和依赖方向的相反性。
展示器和谦卑对象
- 谦卑对象模式:将容易测试的行为和难以测试的行为拆分成两组模块或类,并将它们隔离。包含难以测试的行为的模块被称为谦卑(Humble)组。 (具体可参考 xUnit Test Patterns: Refractoring Test Code)
- 谦卑组的代码通常应该越简单越好
- 强大的可测试性是一个架构设计是否优秀的显著衡量标准之一
- 因为跨边界通信肯定需要用到某种简单的数据结构,而边界会自然而然地将系统分割成难以测试的部分与容易测试的部分,所以通过在系统的边界处运用对象模式,我们可以大幅地提高整个系统的可测试性。
不完全边界
为了解决YAGNI原则(You aren't going to need it, 不要预测未来的需要)和架构师工作本身(预见性设计)之间的矛盾,需要引入不完全边界的概念。
几种不完全边界策略,除此外还有许多其他实现方式,根据不同的场景进行选择
方式一:将系统分割成一系列可以独立编译、独立部署的组件之后,再把它们构建成一个组件。这样可以省去多组件管理这部分的工作,比如版本号管理和发布管理等等。
方式二:单向边界。单侧组件提供接口进行隔离,而不是采用双向反向接口。
方式三:门户模式
架构师的职责之一就是预判未来哪里有可能需要设置架构边界,并决定应该以完全形式还是不完全形式来实现它们。
层次与边界
- 架构边界可以存在于任何地方,必须要小心审视究竟在什么地方才需要设计架构边界。另外,必须弄清楚完全实现这些边界将会带来多大的成本。
- 权衡利弊,反复进行
Main组件
- Main组件是整个系统中的一个低层模块,它处于整洁架构的最外圈,主要负责为系统加载所有必要的信息,然后再将控制权转交回系统的高层组件。
服务:宏观与微观
面向服务的“架构”以及微服务“架构”本身只是一种比函数调用方式成本稍高,分割应用程序行为的一种形式,与系统架构无关。
服务的一些谬论
解耦合的谬论:服务强耦合于数据结构
独立开发的谬论:大型系统一样可以采用单体模式,或者组件模式来构建,不一定非得服务化。
系统的架构是由系统内部的架构边界,以及边界之间的依赖关系所定义的,与系统中各组件之间的调用和通信方式无关。
测试边界
- 测试代码也是系统的一部分
- 测试代码始终都是向内依赖于被测试部分的代码,而且系统中没有其他组件依赖于它们。
- 如果测试代码与系统是强耦合的,它就得随着系统变更而变更。修改以嘎通用的系统组件可能会导致成千上百个测试出现问题,我们通常将这类问题称为脆弱的测试问题(fragile tests problem)。
- 软件设计的第一条原则:不要依赖于多变的东西
整洁的嵌入式架构
- 在软件和固件之间添加硬件抽象层(HAL),使得硬件发生变化时,软件代码仍能使用
第6部分:实现细节
数据库只是实现细节
- 数据的组织结构,数据的模型,都是系统架构中的重要部分,但是从磁盘上存储/读取数据的机制和手段却没那么重要。
web是实现细节
- GUI只是一个实现细节。而Web则是GUI的一种,所以也是一种实现细节。作为一名软件架构师,我们需要将这类细节与核心业务逻辑隔离开来。
应用程序框架是实现细节
使用框架的风险
框架自身的架构设计很多时候并不是特别正确
随着产品的成熟,功能要求很可能超出框架所能提供的范围
框架本身可能朝着我们不需要的方向演进
未来我们可能会想要切换到一个更新、更好的框架上
框架作为架构最外圈的一个实现细节来使用,不要让它们进入内圈
拾遗
- 本章由Simon Brown撰写
- 四种封装方式的访问限制(从左到右分别是按层封装、按功能封装、端口和适配器、按组件封装)。
- Simon Brown对组件的定义是
在一个执行环境(应用程序)中的、一个干净、良好的接口背后的一系列相关功能的结合。
与Bob大叔的定义稍有不同
组件是部署单元。组件是系统中能够部署的最小单位,对应在java里就是jar文件。