《整洁架构之道》,大作,力荐。原著大概可以分为原则、策略、细节三部分,本博文总结前两部分,第三部分多为对第二部分中论点的进一步阐述,详见原著。
第一部分见 《整洁架构之道》读书笔记(一)原则
Part5 软件架构
1.架构师与架构设计原则
1.1 架构师
架构师首先应当是程序员,然后应当是一线程序员,而且是他们当中最优秀的那一批人。这样才能第一时间感知到架构中的问题并解决它。如果仅设计却不敲代码,往往会令设计与实际情况脱节,无法指引、方便其他人。
1.2 架构
软件架构实质是规划如何将系统切分成组件,并安排好组件之间关系,以及组件通信方式。而对此进行设计,就是为了在工作中更好地对这些组件进行研发、部署、运行以及维护。
如果想设计一个便于推进各项工作的系统,其策略是:
在设计中尽可能长时间地保留尽可能多的可选项
进行架构设计的最终目的和最高优先级都是为了让系统能正确地工作。质量低劣的系统虽然也能正常工作,但与设计优良系统相比,其开发、部署、维护、补充开发中会遇到接二连三的麻烦,最终无法解决而导致项目失败。
软件架构设计应当支撑软件系统的全生命周期,设计良好的架构可以让系统便于理解、易于修改、方便维护,并且能轻松部署。软件架构的终极目标就是最大化程序员的生产力,同时最小化系统的总运营成本。
2.系统生命周期与架构设计
2.1开发
小型团队的早期开发中,架构设计反而是一种障碍;但如果团队规模与系统规模变大,那么有必要将团队分工负责不同组件,然后在边界处规定协作方式如API等。
2.2部署
好的系统部署应当简单,因此一键式部署应当成为架构设计的目标之一。(反例:过度的微服务化)
2.3运行
架构对运行阶段的影响要小一些,良好设计的系统在运行期间的问题应当都能通过加硬件的方式解决,以避免架构的重新设计。
另外,架构也应当帮助开发者理解系统在运行期间所需的功能。
2.4维护
实际上软件的维护成本才是软件生命周期中成本最高的。一方面在于修复或补充开发时确定新增代码最佳位置和最佳方式的成本,另一方面在于修改时引入新问题的风险成本。
可以通过系统边界、分层、策略模式等方式将降低在修改过程中对系统其他部分造成伤害的可能性。
2.5 保持可选项
参考两种价值维度,让软件“软”的主要方法就算尽可能长时间地保留尽可能多的可选项。
关键在于将软件降解为策略与细节。
策略:业务逻辑与操作过程
细节:I/O,DB, 页面,服务器,框架,协议等。
软件架构师目标在于创建一种系统形态,以策略为核心,让细节与策略解耦,并允许推迟与细节相关的决策如使用何种数据库,何种I/O设备,是否引入微服务等等。因为越到项目后期,我们就有越多的信息供我们做出更合理的决策,并能允许通过实验比较的方式找出最佳选项。
3.独立性
用例:架构设计应当使用例在架构层面清晰可见,浏览结构时,看到的应当是“支付”、“预订”、“物流”,而不是“spring”、“redis”、“Mysql”.
运行:主要是性能需求,例如qps之类,如果需求中有明确要求,那么就应当支持这种操作。这有时需要程序能够根据不同性能指标选择不同策略例如单体、多线程、微服务等模式。
开发:设计系统时,往往会复制出一个与该组织内沟通结构相同的系统。由于并行开发的需要,在设计时需要划好边界与交互方式,以便各团队独立开发。
部署:应当在开发完成后能够立刻部署无需依赖其他组件
3.1解耦
3.1.1 按层解耦
我们无法在一开始了解系统的全部需求从而保证系统后续无需变更,但可以通过分层将变更原因与变更速率不同的部分隔离开,例如UI与业务逻辑,以保证各层之间独立变更互不影响。
3.1.2 用例解耦
用例的变更原因几乎都是不同的,因此我们可以很自然地按用例解耦系统,而每个用例其实相当于水平分层后的一个个垂直切面。当我们需要新增或修改用例时,这样可以令不相关的用例互不干扰。
3.1.3 解耦模式
解耦可以在源码层次、部署层次或者服务层次进行,三者并无绝对的优劣之分。可以尝试让应用尽可能久地停留在单体应用阶段,但提取设计以便需要时能够快速按部署或服务层次解耦,这样既可以减少前期的部署成本,又能应对不断增长的数据量。另外这样做还能随着使用情况选择退回到单体应用阶段。
3.2重复
重复的代码未必全部都适合复用。不同的用例往往会有各自的演进方向,倘若两个不同用例的重复代码被提取到一起,那么其实相当于某种程度上把两个用例耦合到一起了。因此往往要在重构之前仔细斟酌一下:这里的重复是真正重复,还是表面性的假象。
举例:DTO与entity与DO
4.边界划分
软件架构设计本身就是一门划分边界的艺术。
4.1插件式架构
插件式架构类似于同心圆架构或者六边形架构等,不依照数据流向设计依赖关系,而是用依赖反转等手段让细节依赖于核心的业务逻辑,形成可插拔的插件式架构,好处:
- 细节可以被延后且能随时被替换掉
- 使得核心业务逻辑能够免疫细节变动带来的影响
4.2 when & where
由上述可插拔的插件式架构可以得出,这条线应当划在业务逻辑与其他(数据库、UI、I/O等)组件之间,因为它们变更的原因与速率不同。换句话说,这条线应当沿着系统的变更轴来划,而依赖方向应当指向更稳定的那个组件。
以数据库操作为例,可以结合稳定抽象原则,用DIP等方式在业务逻辑中保存一组数据库操作interface,然后在具体的数据库操作组件中实现它。
实际上,边界划分就是稳定抽象原则和SAP和依赖倒置DIP的具体应用。
4.3 边界类型
方式 | 产物 | 通信方式 | 代价 | 频繁程度 |
源码层次 | 单个可执行文件 | 函数调用 | 低廉 | 高+ |
部署层次 | 单个可执行文件或目录 | 函数调用 | 低廉 | 高 |
本地进程 | 进程 | Socket,消息队列等 | 较高 | 谨慎控制 |
服务 | 服务 | 网络 | 很高 | 尽可能控制到最低 |
4.3.1源码层次
最常见的划分方式,仅对同一进程、同一地址空间的函数、数据进行组织。最后生成的是部署阶段中的一个单独可执行文件,如.jar, .exe等。这种方式的跨边界调用通常是一次简单迅速低成本的函数调用,这意味着其跨边界调用会很频繁。
4.3.2部署层次
常用动态链接库来组织各组件,这种方式的组件在部署时不需要重新编译(都已经编译号可直接执行了)。最后产物是一个便于操作的文件如.war,或者干脆就一个文件夹。其解耦与跨边界调用的方式跟源码层次是一样的。虽然动态链接本身会有一次调用成本,但其跨边界调用仍然很频繁。
4.3.3本地进程
本地进程方式组织各组件,其边界会更加明显。每个进程可视为一个独立的单体应用,个进程之间常用如socket通信、消息队列等方式进行跨边界调用。依赖关系应当让低级进程成为高级(核心)进程的一个可插拔插件。这种方式的跨边界调用涉及到系统调用、数据编码解码,以及进程上下文切换,成本相对更高,应当谨慎控制次数。
4.3.4服务
最强的边界划分形式。由命令行等方式开启,部署时可以在不同机器上,通信方式始终假设会用网络进行通信。这种方式的跨边界调用十分缓慢,需要对高延时进行处理,尽可能地控制通信次数。
5.业务逻辑
5.1 业务实体
业务逻辑就是程序中那些真正用于赚钱或省钱的业务逻辑与过程,无论该过程是机器执行还是手工操作。这些逻辑又可以进一步被划分为关键逻辑与核心数据。按照面向对象,我们可以将其组织成业务实体(并非一定要用面向对象语言,只要把这些相关的东西组织到同一个独立模块中即可)。
5.2 用例
用例定义了需提供的输入、应得到的输出以及产生输出所需的处理步骤。用例所描述的是某种特定应用情景下的业务逻辑,不可等价于关键业务逻辑。业务实体、用例、细节三者关系由内而外应当是:业务实体-用例-细节。
6.框架
6.1框架与架构
框架应当用于方便我们开发而不是成为我们系统的主导。因为作为使用者,我们必须无条件接受框架的一切,但作为框架的开发者,他们却不需要考虑我们开发时的具体情况。因此使用者和开发者的关系是不对等的,开发者应当把更多精力集中在用例上而非框架上。
举例:业务逻辑不要派生自框架所定义的类
6.2可测试性与框架
我们应该通过用例对象来调度业务实体对象,确保所有的测试都不需要依赖框架。
如果系统架构所有设计都围绕用例来展开,我们应该可以在不依赖框架的情况下针对这些用例进行单元测试。例如测试时不应运行 Web 服务,无需连接数据库。
我们测试的应该只是一个简单的业务实体对象,没有任何与框架、数据库相关的依赖关系。
7.整洁架构
无论六边形架构、DCI架构还是BCE架构等,其总体上是相似的。这些架构设计目标都是按关注点对软件进行切割,而且至少有一层是包含核心业务逻辑的,其他细节被放在其他层中。
7.1特点
这些架构设计出的系统其共同特点:
- 独立于框架:框架是工具,不要让系统去适应框架
- 可测试:核心业务逻辑的测试不需要其他部分配合
- 独立于UI:可以在不动业务逻辑的情况下自由修改UI
- 独立于DB:如上述插件式架构所述,DB应当被延后决策且易于替换
- 独立于任何外部机构:核心业务逻辑无需依赖其他任何外部接口
7.2依赖关系规则
源码中的依赖关系必须只指向同心圆的内层,即由低层机制指向高层策略。任何内层代码都不应牵涉外层代码,不应该引用外层代码所声明的名字:函数、类、变量以及一切其他有命名的软件实体;也不该引用外层的数据格式,尤其是外层框架所生成的数据格式。
业务实体:最不容易受外界影响而变动的部分,例如导航方式,安全问题这种。并且应该能够被复用。
用例:包含各种特定场景下的业务逻辑,这一层变动不应影响内层业务实体,也不应受外层细节影响而变动,内外都需要进行隔离。应用的行为发生变动时,应当尽量能在这一层进行修改,而不影响内层业务实体。
接口适配器:例如controller, DAO等,接收外部请求将外部数据转换为内部能够处理的形式,或者将处理结果转换后返回外部。这层相当于建立起内部业务逻辑与外部细节的屏障。
外部细节:作者推荐把utils,DB,UI,web框架等统统放到外层。
7.3跨越边界的数据
跨越上述各层传输数据的方式很多,如函数调用、hash表存取等。但重点在于这些用于跨越边界传输数据的对象应当有一个独立、简单的数据结构。不提倡直接统一传递业务实体或表结构对象(会造成内层依赖外层)。
总之,传递时应当采用内层最方便使用的结构或方式。
8.谦卑对象(humble object)
谦卑对象模式最初的设计目的是帮助单元测试的编写者区分容易测试的行为与难以测试的行为,并将它们隔离。
8.1 UI
例如UI,测试屏幕上某个位置是否正确展示某个控件是困难的,但测试该控件的行为是简单的。于是可以把UI分为难以测试的view(谦卑对象)和易于测试的presenter,同时令view尽可能地简单且不包括数据处理等其他逻辑,而将诸如Date转字符串、展示隐藏等控制view、处理数据逻辑的行为放到易于测试的presenter中进行管理。
View除了展示它需要展示的内容(String,Boolean,int等)以外,不能拥有其他任何行为,这时我们可以把view叫做谦卑对象。
8.2数据库Gateway
同理,数据库行为涉及到各种数据库相关的class,也是难以测试的,但我们可以轻松地测试核心业务操纵数据库时所用的interface. 这时候,我们就可以用测试桩等方法替代实际数据库操作。这里数据库行为的实现类属于谦卑对象,而gateway-interface则是可测试的非谦卑对象。
9.不完全边界
构建完整的组件边界是一件费时费力的事情,而且往往会导致过度设计。这种现象可以用不完全边界暂时替代,这样若有需要即可迅速划出边界分离组件,若无需要也可以很容易地降解回一个组件。几种解决方案如下。
9.1省掉最后一步
即边界都分好了,但仍然选择在编译时统一编译部署成一个组件。虽然设计与开发阶段成本一样的,但可以省去部署时的巨大额外成本(版本号、发布等)。
9.2单向边界
组件之间的隔离往往会需要长期地维护一套反向接口以保持两边组件的双向隔离。为了避免一不小心过度设计,可以通过placeholder的方式,留出一个interface实现反向依赖,这样编码时工作量并未增加太多,但确定需要进行组件隔离时,很容易就能将虚线处的依赖斩断然后将Service-Implement从client身边划走。
9.3 facade
门面模式。即提供一个facade去聚合各种service,需要隔离组件时,可以很容易地将facade当作边界去建立反向依赖,然后划分边界。
需要注意的是,在静态语言中,facade会传递性地依赖所有service类,这时候service的变动会导致client重新编译。
10.Main组件
Main组件实际上也应当被看作一个插件:设置起始状态、配置信息、加载外部资源,最后将控制权转交给应用程序的其他高层组件。
这样,我们就可以把main组件视为“配置加载器”之类的东西,然后为不同环境、不同地区、不同客户设置不同的main组件。
11.服务化的误区
服务化只是一种成本更高、分割程序更明显的调用方式,并不能直接等同于组件边界或者系统架构,设计糟糕的服务化程序实际上无法解耦各个服务模块。实际上,设计糟糕的所谓微服务架构除了增加调用成本以外啥也没干。
11.1 是否强行解耦
微服务并未强行解耦。虽然服务化之后各组件通过API调用来通信,但显而易见,如果其中一方需要添加一个参数字段,那么另一方也不得不做出相应的修改,这跟函数调用其实差不多。
11.2 是否并行开发独立部署
同理,如果完全倚仗服务化而不精心设计,各个服务之间相互耦合,那么单体结构中遇到的同步等待、协作等问题微服务中也一样会遇到。
11.3 服务化与组件
因此,架构中的边界并非一定位于服务与服务之间,其本质仍然是穿透各个服务,在服务化背后各个组件之间存在。
所以,服务化并非银弹,就算使用了服务化仍然需要依照SOLID等组件设计原则进行规划。例如,在服务边界处根据OCP原则,例如采用策略模式,就可以应对将来潜在的新增功能需求:仅需对新增的策略再加一组实现组件就好。
12 测试
12.1 现象
测试代码绑定class,甚至绑定函数,使得测试变得“脆弱”,例如当需求变动时,成百上千测试用例报告失败,久而久之甚至会让我们抵制变化。
12.2 作者建议
测试脆弱的根本原因是我们依赖了容易变化的东西:GUI,DB等。因此我们要想让测试变得稳定,就应该避开这种多变的依赖,转而想办法让业务逻辑可以直接单测。
如果有必要,甚至可以在业务逻辑中专门为测试准备一套API,但前提是做好安全性保护工作。
12.3程序员讨论记录:作为开发者,关于解耦测试我们能做些什么?
(本节)为笔者讨论内容记录,与原著无关
问题根源:没有版本的概念,产品和技术都无长远规划,无法向前兼容的功能应当放到大版本中,单个版本不该存在这种测试用例不向前兼容的问题。
建议:1.以质疑眼光看待需求变更,有讨论啥的
2.(小场景:写了无数单测用例,突然说要加个条件,满足条件禁止登录,于是全部单测需要修改)引入策略模式,把策略当接口参数以减少修改,将测试也搞成随时可插拔的模样
3.稳定和灵活是相对的,找平衡点。
4.在需求模糊,功能不稳定的时期应当尽快多方合作出一个相对稳定的版本,然后才能对这些需求进行控制
5.是否需要对测试单独在业务逻辑层提供一套专用API方便测试?确实可能会方便一些,但后期的维护、重构,以及安全性之类都需要考虑,看上去回报率不高,搞之前根据实际情况衡量。
Part6 细节
详见《架构整洁之道》原著,基本是一些案例说明以证明Part5中的论点。