什么是整洁的架构
看完了clean code -- 代码整洁之道,那么接下来就应该读读其姊妹篇:clean architecture -- 架构整洁之道。不过对我而言,代码是实实在在的,看得见,摸得着;而架构虽然散发着光芒,但好像有点虚,似乎认知、思考还比较少。本文主要记录《clean architecture》的主要内容以及自己的一点思考。
本文地址:https://www.cnblogs.com/xybaby/p/11729354.html
架构的存在意义
clean architecture的作者是一位从事软件行业几十年的架构大师,参与开发了各种不同类型的软件,在职业生涯中发现了一个规律:那就是,尽管几十年来硬件、编程语言、编程范式发生了翻天覆地的变化,但架构规则并没有发生变化。
The architecture rules are the same!
我想读过clean code之后,应该都达成了以下共识
getting it work is easy
getting it right is hard
right make software easy to maintain、change
上升到架构层面来说,问题同样存在,而且更加明显,因为架构的影响面远大于代码。作者举了一个例子,展示了随着代码量增加、团队人员增加、release版本增加,导致的新增代码代价的激增以及程序员生产力的下降。
从可以看到,随着时间的推移,每一行代码的代价(成本)都在逐渐上升。
从另一个角度来看
单个程序员的产出随着 release急剧 下降,即使为了一个小小的feature,也不得不到处修修改改,容易牵一发而动全身
moving the mess from one place to the next
这样的经历,我想大家都有或多或少的同感,尤其在项目后期,或者团队人员几次轮换之后,代码就变得难以维护,以至于没有人敢轻易改动。出现这样的问题,不能仅仅归咎于code -- code这个层面关注的是更为细微具体的东西(比如命名、函数、注释),更多的应该是设计出了问题,或者说架构出了问题。
因此说,软件架构的目标是为了减少构造、维护特定系统的人力成本
The goal of software architecture is to minimize the human resources required to build and maintain the required system.
behavior vs architecture
行为和架构是软件系统的两个价值维度,行为是指软件开发出来要解决的问题,即功能性需求;而架构则算非功能性需求,比如可维护性、扩展性。很多程序员迫于各种压力,可能觉得只要实现功能就行了;殊不知,非功能性需求也是技术债务,出来混,迟早是要还的。
怎么看待二者的关系呢,这里祭出放之四海而皆准的艾森豪威尔矩阵:
behavior: 紧急,但不总是特别重要
architecture:重要,但从来不紧急
了解过时间管理或者目标管理的话,都知道重要但不紧急的事情反而是需要我们特别花时间去处理的。
而架构设计就是让我们在支撑功能的同时,保证系统的可维护性、可扩展性。
design level
软件开发和修房子一样,在实施角度来看都是从low-level到high-level的过程,比如房子是由砖块(brick)到房间(room),再由房间到房子(house)。作者的类比如下
software | building |
---|---|
programming paradigms | brick |
module rule(solid) | room |
component rule | house |
在我看来,clean code中强调的变量名、函数、排版更像是软件开发中最基础的单位,不同的programming paradigms遵循的思想是不同的,但代码质量(整洁代码)是独立于编程语言的。
module rule(solid)
module(模块)一般的定义即单个源文件,更广义来说,是一堆相关联的方法和数据结构的集合。
关于这部分,在clean architecture中讲得并不是很详细,于是我结合了《敏捷软件开发》(Agile Software Development: Principles, Patterns, and Practices)一书一起学习。
SOLID是一下几个术语的首字母缩写
- SRP(Single responsibility principle):单一职责原则,一个module只有一个原因修改
- OCP(Open/closed principle):开放-关闭原则,开放扩展,关闭修改
- LSP(Liskov substitution principle):里氏替换原则,子类型必须能够替换它们的基类型
- ISP(Interface segregation principle):接口隔离原则,你所依赖的必须是真正使用到的
- DIP(Dependency inversion principle):依赖导致原则,依赖接口而不是实现(高层不需要知道底层的实现)
SRP
module级别的SRP很容易和函数的单一职责相混淆。函数的单一职责是一个函数只做一件事 -- 这件事通过函数名就可以看出来。而SRP则是指一个module仅仅对一个利益相关者(actor)负责,只有这个利益相关者有理由修改这个module。
违背SRP,会导致不相关的逻辑的意外耦合,如下面这个例子
Employee这个类里面包含了太多的功能:
save
是给CTO调用CalculatePay
是给CFO使用- 而COO则关心
reportHours
。
问题在于,CalculatePay
也依赖ReportHours
,如果CFO因为某些原因修改了ReportHours
,那么就会影响到COO。
这个例子也表明,一个类是对什么东西的抽象并不是最重要的,而在于谁使用这个类,如何使用这个类。
解决方法之一是使用Facade模式,如下所示
Facade模式保证了对外暴露同样的三个接口,但其职责都委托给了三个独立的module,互不影响。
LSP
对于继承而言,子类的实例理论上是满足基类的所有约束的,比如Bird extend Animal,那么Animal的所有行为bird都应该满足。
但上面也描述过,类的有效性取决于类的使用方式,并不能用人类的认识去判断。比如正方形是否应该继承自长方形(square is a rectangle?),按照正常人的认知来说肯定是的,但对于某些使用方式就会存在问题, 比如下面这个函数
def g(Rectangle &r)
{
r.setW(5);
r.setH(2);
assert(r.area() == 10);
}
上述的代码表明,g
函数的编写者认为存在一种约束:修改rectangle的长不会影响宽。但 这个对于squre是不成立的,因此square违背了某种(隐式的)契约,这个契约是关于如何使用rectangle这个类的。
如何传达这个契约呢,有两种方式,第一是单元测试;第二是DBC(design by contract)。
详见讨论: 你会怎样设计长方形类和正方形类?
ISP
接口隔离原则解决的是“胖”接口问题,如下图所示:
OPS
所提供的三个接口是给三个不同的actor使用的,但与SRP要解决的问题不同,在这里并不存在因公用代码导致的耦合。真正的问题是 Use1对op1
的使用导致OPS的修改,导致User2 User3也要重新编译。
解决方法是引入中间层,如下所示
当然,静态语言之间的源码依赖才会导致 recompilation and redeployment; 而对于动态语言(如python)则不会有这个问题。
ISP is a language issue, rather than an architecture issue.
不过,不要依赖你不需要的东西,这个原则总是好的。
DIP
DIP(Dependency inversion principle)是架构设计中处理依赖关系的核心原则,其反转的是依赖关系。比如一个应用可能会使用到数据库,那么很自然的写法就是
Business rule依赖Database的问题在于,database的选择是一个细节问题,是易变的,今天是mysql,明天就可能会换成Nosql,这就导致Business rule也会收到影响。所以需要依赖反转,就是让database去依赖Business rule
Business rule依赖抽象接口,而database实现了这个抽象接口,接口一般是稳定的,因此即使替换DB的实现,也不会影响到Business rule。
这也提供了某种暗示:对于java C++等静态类型语言,import include应该只refer to 接口、抽象类,而不是concrete class。
OCP
OCP是下面两个短语的缩写
- open for exrension: 当应用的需求变更时,我们可以对模块进行扩展,使其满足新需求
- close for mofifacation: 对模块进行扩展时,无需改动模块的源代码或者二进制文件
很容易想到,有两种常见的设计模式能实现这样的效果,就是Strategy与Template Method。
要实现OCP,至少依赖于SRP与DIP,前者保证因为不同原因修改的逻辑不会耦合在一起,后者则保证是逻辑上的被使用者依赖使用者,从Strategy模式的实现也可以看出。
其实我觉得OCP应该是比其他几个module rule抽象层级更高的原则,甚至高于后面会提到的component rule,软件要可维护性、可扩展性强,那么就最好不要去修改(影响)已有的功能,而是添加(扩展)出新的功能。这是不证自明的。
component rule
什么是component呢,component是独立开发、独立部署的基本单元,比如一个.jar、.dll,或者python的一个wheel或者egg。
component rule主要解决两个问题,第一是哪些module可以形成一个component,即component cohesion,组件的内聚问题;另一个则是不同的component之间如何协作的问题,即component coupling
component cohesion
哪些module或者类应该放在一起作为独立部署的最小实体呢,取决于以下几个规则
REP:THE REUSE/RELEASE EQUIVALENCE PRINCIPLE
The granule of reuse is the granule of release.
复用/发布等同原则:即软件复用的最小粒度等同于其发布的最小粒度。
这是从版本管理的角度来思考软件复用的问题,通过版本追踪系统发布的组件包含了每个版本修改的bug、新增的feature,才能让软件的使用者能够放心的选择对应的版本,达到软件复用的效果。
CCP:THE COMMON CLOSURE PRINCIPLE
共同闭包原则:如果一些module因为同样的原因做修改,并且改变次数大致相同,那么就应该放在一个component里面。这个是其实就是将单一职责原则(SRP)应用到component这个level
This minimizes the workload related to releasing, revalidating, and redeploying the software
可见,CCP的目标是较少发布、验证、部署的次数,那么是倾向于让一个component更大一些。
CEP:THE COMMON REUSE PRINCIPLE
共同复用原则: 总是被一起复用的类才应该放在一个component里面。这个是接口隔离原则(ISP)在component level的应用
Thus when we depend on a component, we want to make sure we depend on every class in that component
与CCP的目标不同,CEP要求总是一起复用的类才放在一起,那么是倾向于让一个component更小一些。
component coupling
组件之间要相互协作才能产生作用,协作就会导致依赖。
比如组件A使用到组件B(组件A中的某个类使用到了组件B中的某个类),那么组件A就依赖于组件B。在这样的依赖关系里面,被依赖者(组件B)的变更会影响到依赖者(组件A),在Java,C++这样的静态类型语言里面,就体现为组件A需要重现编译、发布、部署。
架构设计的一个重要原则,就是减少由于组件之间的依赖导致的rebuild、redeploy,这样才能减低开发、维护成本,最大化程序员的生产力。
ADP: Acyclic Dependencies Principle
无环依赖原则:就是在组件依赖关系图中不应该存在环。
上图中右下角Interactors
,Authorizer
,Entities
三个组件之间就形成了环装依赖。环装依赖的问题是,环中的任何一个组件的修改都会影响到环中的任何组件,导致很难独立开发部署。另外,Database
组件本身是依赖Entities
的,现在Entities
在一个环中,那就相当于Database
依赖整个环。也就是说,对外而言一个环中的所有组件事实上形成了一个更大的组件。
如何解环呢?
一种方法是使用依赖倒置原则DIP,改变依赖顺序
另一种方法是抽象出新的通用component
SDP: Stable Dependencies Principle
稳定依赖原则
Any component that we expect to be volatile should not be depended on by a component that is difficult to change. Otherwise, the volatile component will lso be difficult to change
其实就是说,让易变(不稳定)的组件去依赖稳定的组件。这里的稳定性指变更的成本,如果一个组件被大量依赖,那么这个组件就没法频繁变更,事实上也就变得稳定(或者说僵化)了。
比如在逻辑上,应用层相对UI是可稳定的,UI发生修改的变大大得多,但如果应用层依赖UI,那么为了稳定,UI的修改也得非常小心谨慎。
解决的方案也是依赖反转原则
SAP: Stable Abstractions Principle
稳定抽象原则
A component should be as abstract as it is stable.
越稳定应该越抽象,稳定意味着会被依赖,如果不抽象,那么一旦修改,影响巨大。这个时候就可以考虑OCP,对于稳定的模块,要关闭修改,开放扩展,而抽象保证了便于扩展。
按照component cohesion规则形成的组件,再加上组件之间的耦合、依赖关系,就形成了一个架构,接下来就讨论什么是整洁的架构。
architecture
一个好的架构需要支持一些功能
- The use cases and operation of the system.
- The maintenance of the system.
- The development of the system.
- The deployment of the system.
但很多时候,很难搞清用户要怎么使用系统,要怎么运维、如何部署。而且,随着时间推移,这一切都在变化中,说不定今天是集中式部署,明天就要服务化,后天还要上云。如何应对这些可能的变化,同时又不过度设计,有两条可遵循的原则:
- well-isolated components
- dependency rule
上一章节已经提到,应该让不稳定的组件去依赖稳定的组件,那么什么组件稳定,什么组件不稳定呢。
稳定的应该是业务逻辑,policy、business rule、use case。不稳定的应该是业务逻辑的周边系统,detail、UI、db、framework
keep option open with boundary
理清楚组件之间的依赖关系,可以帮助我们推迟有关detail的决定
The longer you leave options open, the more experiments you can run, the more things you can try, and the more information you will have when you reach the point at which those decisions can no longer be deferred.
书中作者列举了自己开发Fitnesse的例子。
项目开始之初,作者就知道需要一个持久化的功能,可能就是一个DB。
遵循依赖倒置原则,DB应该依赖于business rule,所以作者在这二者之间引入了一个interface,如下所示
上图中红色的boundary line其实就是两个组件的分割,可以看到Database Interface和Business Rules在同一个组件中。通过依赖翻转,database事实上成为了business rule的一个插件(plug-in),既然是插件,那么就很方便替换。
在Fitnesse中,作者将这个DatabaseInterface命令为WikiPage
, 如之前所述,DB是一个detail,是不稳定组件,而且直接使用一个DB会引入许多工作量,对测试也不够友好。于是作者在开发期用了一个MockWikiPage
,直接返回预定义数据给business rule使用;过了一年之后,业务功能不满足mock的数据,使用了基于内存的InMemoryPage
;最终发现基于文件存储的FileSystemWikiPage
是比MySqlWikiPage
更好的选择。
clean architecture
回到架构这个话题上来,作者认为什么样的架构是整洁的呢,尽在下图:
这是一个分层架构,从外环到内环,软件的层级逐渐升高,也如之前所说
- high level policy
- low level detail
那么clean architecture的dependency rule就是:外环(low level)依赖内环(high level)
Source code dependencies must point only inward, toward higher-level policies.
entity vs rule
在上图中,出现了Entities和Use case这两个并没有怎么强调的概念,二者都属于Business rule的范畴
Entity:An Entity is an object within our computer system that embodies a small set of critical busin
比如说在一个银行借贷系统中,Loan就是一个entity,包含一系列属性如principle、rate以及相关操作applyInterest等等,这是业务逻辑的核心,也称之为Critical Business Rules
Use case:A use case is a description of the way that an automated system is used
比如说贷款前的风控系统,如何做风控,跟具体实现有较大关系,因此也称之为 application-specific business rules
不难看出,Use cases依赖于Entities, 相比而言,Entities更加稳定,所以处在环的最中间。
一个典型场景
重点在于上图的右下角, Controller、 Presenter都是第三层的实体,依赖第二层的Use case,上图展示了数据的流向,且没有违背依赖关系。
下面这个Java web系统更加详细、清楚
这个系统架构值得仔细揣摩、学习,在这里值得注意的是:
- controller、presenter 与use case的依赖、交互关系
- use case实现Input接口,声明output接口(Presenter实现)
- 交互使用的data structure,并没有在各个layer之间传递Data对象