什么是简洁架构?
架构意为项目的整体设计。它是代码形成类、文件、组件或模块的组织架构。架构决定了应用在哪里完成它的主要功能和这些功能是怎么与数据库、用户界面交互的。
简洁架构指的是怎么组织项目结构,以让其容易理解并且当项目增长时容易改变和扩展。这些不是随便做到的,需要有意为之。
简洁架构的特征
构建一个易于维护的大型项目的秘诀是:把文件或类分离到可以独立修改的组件中。让我们用一组图片释义。
在上图中,如果你想用一把刀替换剪刀,要把连接到钢笔、墨水瓶、胶带和圆规的绳子都解下来,然后重新绑到刀上。也许这样对刀来说可以,但是如果钢笔和胶带需要剪刀怎么办。因为刀替换了剪刀,现在钢笔和剪刀不工作了,需要改变它们才可以继续工作,但是这样又会影响其他绑到它们身上的物体。
对比起来:
现在要怎么替换剪刀?只需要把绑到剪刀的绳子从Post-it便利贴下面拽出来并且连接一个新的绳子到刀上就可以了。Post-it便利贴不会在意,因为绳子甚至都没有绑到它的身上。
第二张图片表示的架构明显更容易改变。只要Post-it便利贴不经常变更,这个系统将非常容易维护。同样的,这种架构也会让你软件容易维护和变更。
内圈是你的应用的域层,指业务逻辑。 外圈是基础架构,包括UI,数据库,HTTP APIs。比起业务逻辑,这部分会经常发生变化。比如,可能会经常修改一个UI按钮的样式而不是按钮的功能。
建立应用域层和基础架构之间的界限,这样应用域层就不用知道基础架构。比如UI和数据库依赖应用逻辑,但是应用逻辑并不依赖UI和数据库,这样就变成了一个插件结构。
应用域层不在乎UI和数据的存储方式,这样更容易修改这些由UI和数据库等组成的基础架构。
定义术语
上图中的两个圈可以被更详细的定义。
应用域层又被细分为实体、用例和一个分隔域层和基础架构层的中间适配层。这些术语可能会有些难以理解,让我们分别看一下。
实体(Entities)
实体:对应用功能至关重要的一组相关的逻辑规则。在面向对象编程语言中,一个实体的规则就是类中的一系列方法。即使没有应用,这些规则仍然会存在。例如,贷款收取10%的利息是一个银行可能有的规则,不管是在纸上还是计算机上计算,都不会改变这一规则。下面是书中解释实体类的一个示例:
这个实体对其他层一无所知,也不依赖它们。也就是说,它们不使用外层的其他类或组件的名字。 简单地解释entity:某个应用功能的一组相关的逻辑规则,通常是一个抽象类;
用例(Use cases)
用例是特定应用的逻辑规则。它们决定了应用的行为。下面是书中的关于用例的一个例子:
Gather Info for New Loan
Input: Name, Address, Birthdate, etc.
Output: Same info + credit score
Rules:
1. Validate name
2. Validate address, etc.
3. Get credit score
4. If credit score < 500 activate Denial
5. Else create Customer (entity) and activate Loan Estimation
用例与实体交互并依赖实体,但是它们对外层一无所知,不在乎外层是网页还是app,也不在乎数据存在云中还是local SQLite数据库。
适配器(Adapters)
适配器,也称作接口适配器,是应用域层和基础架构的翻译官。例如,它们从GUI获取数据,处理成方便用例和实体理解的形式。然后从用例和实体获取输出,再处理成方便GUI展示或者数据库存储的形式。
基础架构(Infrastructure)
这一层包括所有的I/O组件:UI,数据库,框架,设备等。它是最常变化的层。就是因为这个层容易改变,所以它们要尽可能远离稳定的域层。从而保证可以容易的改变或替换组件。
成就简洁架构的理论
因为下述的理论的名字很迷惑,所以我故意的没有在上面的描述和释义中使用它们。但是,上面的架构设计需要遵从这些理论来实现。如果这一小节让你困惑,可以直接跳到最终节。
前五个理论经常被缩写为SOLID方便记忆。它们是类级别的理论但是与组件(一组类)级别的理论类似。组件级别的理论由SOLID理论发展而来。
1.OCP(开闭原则)
当我们为类、函数去增加功能时,可以去扩展,但不要去修改。
如果要修改代码,尽量用继承或组合的方式来扩展类的功能,而不是直接修改类的代码。当然,如果能保证对整个架构不会产生任何影响,那就没必要搞的那么复杂,直接改这个类吧。
设计良好的软件应该易于扩展,同时抗拒修改。这是我们进行架构设计的主导原则,其他的原则都为这条原则服务。
2.SRP(单一职责原则)
不同的类具备不同的职责,各司其职。做系统设计时候,如果发现有一个类拥有了两种职责,那么就要问一个问题:可以将这个类分成两个类吗?一个人类只承担一个职责,如果真的有必要,那就分开,千万不要让一个类干的事情太多。
任何一个软件模块,都应该有且只有一个被修改的原因,“被修改的原因“指系统的用户或所有者,翻译一下就是,任何模块只对一个用户的价值负责。该原则指导我们如何拆分组件。
举个例子,CTO 和 COO 都要统计员工的工时,当前他们要求的统计方式可能是相同的,我们复用一套代码,这时 COO 说周末的工时统计要乘以二,按照这个需求修改完代码,CTO 可能就要过来骂街了。当然这是个非常浅显的例子,实际项目中也有很多代码服务于多个价值主体,这带来很大的探秘成本和修改风险。
另外当一份代码有多个所有者时,就会产生代码合并冲突的问题。
3.LSP(里氏替换原则) 一个对象在其出现的任何地方,都可以用子类实例做替换,并且不会导致程序的错误。换句话说,当子类可以在任意地方替换父类且软件功能不受影响时,这种继承关系的建模才是合理的。
总结:子类可以扩展父类的方法,但不应该复写父类的方法。
当用同一接口的不同实现互相替换时,系统的行为应该保持不变。该原则指导的是接口与其实现方式。
你一定很疑惑,实现了同一个接口,他们的行为也肯定是一致的呀,还真不一定。假设认为矩形的系统行为是:面积=宽*高,让正方形实现矩形的接口,在调用 setW 和 setH 时,正方形做的其实是同一个事情,设置它的边长。这时下边的单元测试用矩形能通过,用正方形就不行,实现同样的接口,但是系统行为变了,这是违反 LSP 的经典案例。换句话说,当子类可以在任意地方替换父类且软件功能不受影响时,这种继承关系的建模才是合理的。
4.ISP(接口隔离原则) 一个类实现的接口中,不应该包含它不需要的方法。
如果一个类的接口包含了它不需要的方法,则应该将接口拆分成更小和更具体的接口,有助于解耦,从而更容易重构、更改。
接口隔离原则要求我们,建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。
不依赖任何不需要的方法、类或组件。该原则指导我们的接口设计。
当我们依赖一个接口但只用到了其中的部分方法时,其实我们已经依赖了不需要的方法或类,当这些方法或类有变更时,会引起我们类的重新编译,或者引起我们组件的重新部署,这些都是不必要的。所以我们最好定义个小接口,把用到的方法拆出来。一个类实现的接口中,不应该包含它不需要的方法。
5.DIP(依赖反转原则) 依赖抽象而不是依赖实现。 抽象不应该依赖细节,细节应该依赖抽象。 高层模块不能依赖低层模块,二者都应该依赖抽象。
依赖倒置原则在程序编码中经常运用,其核心思想就是面向接口编程,高层模块不应该依赖低层模块(原子操作的模块),两者都应该依赖于抽象。
我们平时常说的“针对接口编程,不要针对实现编程”就是依赖倒转原则的最好体现:接口(也可以是抽象类)就是一种抽象,只要不修改接口声明,大家可以放心大胆调用,至于接口的内部实现则无需关心,可以随便重构。这里接口就是抽象,而接口的实现就是细节。
如果不管高层模块还是底层模块,它们都依赖于抽象,具体一点就是接口或者抽象类,只要接口是稳定的,那么任何一个的更改都不用担心其他受到影响,这就使得无论高层模块还是低层模块都可以很容易地被复用。
依赖倒转原则其实可以说是面向对象设计的标志,用哪种语言来编写程序不重要,如果编写时考虑的都是如何针对抽象编程而不是针对细节编程,即程序中所有的依赖关系都是终止于抽象类或者接口,那就是面向对象的设计,反之那就是过程化的设计。
再举一个生活中的例子,电脑中内存或者显卡插槽,其实是一种接口,而这就是抽象;只要符合这个接口的要求,无论是用金士顿的内存,还是其它的内存,无论是4G的,还是8G的,都可以很方便、轻松的插到电脑上使用。而这些内存条就是具体实现,就是细节。
跨越组件边界的依赖方向永远与控制流的方向相反。该原则指导我们设计组件间依赖的方向。
依赖反转原则是个可操作性非常强的原则,当你要修改组件间的依赖方向时,将需要进行组件间通信的类抽象为接口,接口放在边界的哪边,依赖就指向哪边。也就是说不稳定的类或组件应该依赖更稳定的类或组件。如果一个稳定类依赖不稳定的类,每次不稳定类改变,都会影响到这个稳定类。所以依赖的方向需要倒置过来。怎么做?通过使用抽象类或把稳定类隐藏在接口下面。
6.REP(复用、发布等同原则)
软件复用的最小粒度应等同于其发布的最小粒度。直白地说,就是要复用一段代码就把它抽成组件。该原则指导我们组件拆分的粒度。
7.CCP(共同闭包原则)
为了相同目的而同时修改的类,应该放在同一个组件中。CCP 原则是 SRP 原则在组件层面的描述。该原则指导我们组件拆分的粒度。
对大部分应用程序而言,可维护性的重要性远远大于可复用性,由同一个原因引起的代码修改,最好在同一个组件中,如果分散在多个组件中,那么开发、提交、部署的成本都会上升。
8.CRP(共同复用原则)
不要强迫一个组件依赖它不需要的东西。CRP 原则是 ISP 原则在组件层面的描述。该原则指导我们组件拆分的粒度。
相信你一定有这种经历,集成了组件A,但组件A依赖了组件B、C。即使组件B、C 你完全用不到,也不得不集成进来。这是因为你只用到了组件A的部分能力,组件A中额外的能力带来了额外的依赖。如果遵循共同复用原则,你需要把A拆分,只保留你要用的部分。
REP、CCP、CRP 三个原则之间存在彼此竞争的关系,REP 和 CCP 是黏合性原则,它们会让组件变得更大,而 CRP 原则是排除性原则,它会让组件变小。遵守REP、CCP 而忽略 CRP ,就会依赖了太多没有用到的组件和类,而这些组件或类的变动会导致你自己的组件进行太多不必要的发布;遵守 REP 、CRP 而忽略 CCP,因为组件拆分的太细了,一个需求变更可能要改n个组件,带来的成本也是巨大的。
Acyclic Dependency Principle (ADP)
ADP意为在你的项目中不应该有依赖圈。例如,组件A依赖组件B,组件B依赖组件C,组件C依赖组件A,就形成了依赖圈。
这样的依赖圈会在你试图对系统作出更改的时候引起重大的问题。打破这样循环圈的一种解决方案是使用Dependency Inversion Principle,在组件之间添加一层接口。如果不同个体或团队负责不同的组件,那么这些组件应该用他们自己的版本号单独发布。这样一个组件的修改不会立即影响其他团队。
Stable Dependency Principle (SDP)
这个理论意为依赖的方向应该与稳定性保持一致。也就是不稳定的组件应该依赖于更稳定的组件。这样减少了改变带来的影响。一些组件被设计为不稳定的,that's OK,但是不应该让稳定组件依赖于它们。
Stable Abstraction Principle (SAP)
意为一个组件越是稳定,它就应该越是抽象,也就是它应该包含更多的抽象类。抽象类更容易扩展,这样稳定组件就不会太死板。
最终章
上面的内容总结了Clean Architecture这本书的主要理论,但是我还想要加一些其他的重要的观点。
Testing
创建一个插件架构会让你的代码更可测。如果有太多依赖会很难测试你的代码。但是如果是插件结构,用一个mock对象替换一个数据库依赖(或其他组件)会容易的多。
我经常会难以测试UI。当我开始测试GUI流程时,一旦对UI作出改变,测试就会奔溃。结果只好删除测试。 虽然我明白,应该在适配层创建一个Presenter对象。这个Presenter对象会将逻辑规则的输出格式化为任何UI需要的格式。然后UI对象除了展示Presenter格式化后的数据,不做任何处理。这样就可以独立于UI测试Presenter代码。
创建一个专门的测试API来测试逻辑规则。跟界面适配器分开,这样当改变应用结构时,测试也不会崩溃。
Dividing components by use cases
上面我讨论过了域层和基础架构层。假设这些是横向分层,那么它们也可以根据app中的不同的用例被纵向切分为组件的组合。就像一个分层的蛋糕,每个切分是一个用例,切分中的每一层作为一个组件。
例如,在一个视频网站上,一个用例是用户观看视频。所以你要有ViewerUseCase组件,ViewerPresenter组件,ViewerView组件等。另一个用例是用户上传视频。对他们来说要有PublisherUserCase组件,PublisherPresenter组件,PublisherView组件等。另一个用例也许是网站管理者。这样,单独的组件被纵向切分的横向层级创建。
当应用部署时,这些组件可以被任意组合,只要有意义即可。
总结
Clean Architecture这本书的本质是创建一个插件架构。有可能在同一时间因为相同原因改变的类应该组合成组件。 应用逻辑组件更稳定,对不稳定的基础架构组件(UI,database,web,framework和其他细节)应该一无所知。组件层之间的界限应该由接口适配器维护,接口适配器翻译层级之间的数据,保持依赖的方向指向更稳定的内层组件。
Further study
Clean Code / Agile Sofeware Development / Clean Architecture # 一文读懂架构整洁之道