分层单体架构风格是分层思想在单体架构中的应用,其关注于技术视角的职责分层。同时,基于不同层变化速率的不同,在一定程度上控制变化在系统内的传播,有助于提升系统的稳定性。但这种技术视角而非业务视角的关注点隔离,导致了问题域与工程实现之间的Gap,这种割裂会导致系统认知复杂度的提升。
作者:倪新明
1 经典单体分层架构
1.1 四层单体架构风格
经典的四层单体分层架构如下图所示,应用在逻辑上划分为展现层、业务层、持久层及数据存储层,每层的职责如下:
• 展现层:负责给最终用户展现信息,并接受用户的输入触发系统的业务逻辑。用户可以是使用系统的人,也可以是其他软件系统。
• 业务层:关注系统业务逻辑的实现
• 持久层:负责数据的存取
• 数据存储层:底层的数据存储设施
这种分层单体架构可能是大多数开发人员最早接触、最为熟悉的应用架构风格,其特点是:
• 层间的依赖关系由上到下逐层向下直接依赖,每层都是关闭状态,请求的数据流向从上到下,必须严格通过每个分层进行流转,而不能进行穿透调用。
• 关注点隔离:通过分层将系统的关注点进行垂直分配,每层只关注自身层边界内的职责,层间职责相互独立不存在交叉。比如业务层负责处理系统的核心业务逻辑,而持久层则关注于对数据的存取。
除了关注点隔离这一维度,分层也在 “变化” 的维度进行隔离。每层的变化速率不同,由下级上逐层增加,展现层的变化速率最快,数据存储层变化速率最低。通过严格层依赖关系约束,尽量降低低层变化对上层的影响。这个特点的上下文是分层之间依赖于抽象,而非依赖于具体。当实现发生变化而接口契约不变时,变更范围框定在当前层。但,如果是接口契约的变更,则可能会直接影响到上游的依赖层。
这种分层架构风格具有明显的优势:
• 分层模型比较简单,理解和实现成本低
• 开放人员接受度和熟悉程度高,认知和学习成本低
1.2 五层单体架构风格
四层架构面临的问题是:
• 层间数据效率问题: 由于层间调用关系的依赖约束,层间的数据流转需要付出额外成本
• 业务层服务能力的复用性:业务层中处于对等地位的组件或模块之间存在共享服务的诉求
从复用性的角度考虑,如下所示的五层架构中,通过引入中间层解决复用问题。将共享服务从业务层沉淀到通用服务层,以提高复用性。其特点是:
• 引入通用服务层提供通用服务,提高复用性
• 通用服务层是开放层,允许调用链路穿透,业务层可以按需直接访问更下层的持久层
相比于四层架构,五层分层架构的主要优势是:通过中间层的引入一定程度解决系统的复用性问题。但从反向角度看,正是由于中间层的引入导致了如下问题:
• 引入中间层降低了数据传输效率,提高了开发实现成本
• 有造成系统混乱度提升的风险:由于通用服务层的开放性导致业务层可以穿透调用。但这种是否需要进行穿透的场景无法形成统一的判定原则,往往依赖于实现人员的个人经验进行权衡,同一个业务场景由不同的开发人员实现可能会有不同的判定结果(在四层架构中如果放开层间调用约束也会存在该问题)。随着业务需求迭代,系统的依赖关系一定会日趋增加,最终形成复杂的调用关系,也导致系统复杂性上升,增加团队成员的认知成本。
2 单体分层架构的共性问题探讨
当然,正是由于其极高的接受度,也造成了大家对分层的认知误区,认为分层是必然的“默认选项” ,从而忽略了分层的本质。分层到底是为了解决什么问题?
分层本质上是处理复杂性的一种方式:将复杂性在不同级别进行抽象,通过分层进行职责隔离,以此降低认知成本。同时,通过分层形成的“屏障”,控制变化在系统间的传播,提升系统稳定性。
不论是四层架构还是五层架构都是分层思想在单体应用架构风格下的实践,这种分层模式存在的固有问题主要体现在以下几个方面:
• 分层对系统复杂度和效率的影响
• 变化真的能完全隔离吗?
• 问题域与解决方案的隔离
2.1 分层对系统复杂度和效率的影响
如上文所述,分层架构中各层的变化速度不同。越往上变化越快,稳定性越低,越往下变化越慢,稳定性越高。比如,展现层的用户展示逻辑可能频繁变化,对应于不同的场景诉求展示数据及形式都可能不同。
如果划分层次越多,层间依赖关系越严格,则系统的调用链路和依赖关系会更加清晰。但,请求及响应的链路越长,层间数据转换有额外成本。即使引入各种数据转换工具,比如MapStruct,实现起来依然会感觉非常繁琐和重复。
如果划分层次越多,层间依赖关系宽松,允许跨层调用(如下所示的从展现层调用持久层只是一个示意),则能在一定程度降低数据频繁转换的成本。但:
• 其一:如何判定是否要跨层调用很难形成统一的严格判定标准,只能进行粗粒度划分。因此,在实现过程中会有不同的判定结果,系统的调用关系会随着代码规模增长而日趋复杂。当然,团队可以加强代码评审的粒度,每次评审基于是否穿透调用进行讨论、判断并达成一致。但实际经验是,由于人为因素,靠严格的代码评审并不能保证决策的一致性。
• 其二:如果允许跨层调用,则意味着 “模型” 的穿透,低层的模型会直接暴露在更上层,这与我们追求的组件内聚性和模型的封装性存在冲突
注:层间的依赖约束是一种架构决策,可以考虑通过自动化单元测试机制进行保证,具体参考
《 基于ArchUnit守护系统架构 》
《 轻量级的架构决策记录机制 - ADR》
2.2 变化的隔离
我们对分层有一个普遍的、“先入为主” 的认知,分层能够隔离变化。首先会想到的例子,比如,如果底层的数据库发生了变更,又或者ORM框架发生了变更,那么,我们只需要修改DAO层的实现,而不需要更改上层的业务层代码。
• 你真的会替换数据库吗?你真的会替换ORM框架吗?有可能,但概率非常低,大部分系统并不会发生这种场景。
• 发生替换就真的能隔离吗?如果你的层间不是依赖于抽象,而是依赖于具体,那么隔离也无从谈起。
• 即使层间依赖于抽象,变化就真的隔离了吗?实现发生变化的直接结果就是依赖方需要引用新的实现,这种变化也同样会影响到上层。只不过是这种变化可能交由IOC容器了
但,这个是变化隔离的全部吗?
• 如果是展现层需要增加一个新的字段,而当前数据库模型中没有?
• 如果是数据库中需要增加一个新的字段,而展现层和业务逻辑层不关心?
• 如果是......
所以,引起系统变化的原因很多,场景各异,业务诉求亦不相同,分层对变化隔离程度也不相同:
分层可以控制变化在系统内的传播,由于变化场景的多样化,分层不能完全的隔离变化。
2.3 问题域与解决方案的割裂
重新思考下上文提到的分层单体架构的特点之一:关注点隔离,展现层、业务层、数据访问层、存储层等各层聚焦于自身的职责。这种关注点的本质是什么?
技术视角的隔离!!!
每层都是从技术视角将技术关注点进行隔离,而非业务领域视角。技术视角是研发友好的,作为开发人员,天然的可以理解和接受这种技术维度的统一语言:DAO层只负责处理数据相关逻辑,Controller层之服务处理Restful API相关,RPC层只处理与外部系统的跨进程调用等等。
而对于非常核心的业务概念,比如以订单为例,在单体分层架构下需要回答这样一个问题:“订单组件” 在哪里?
在经典的分层单体架构风格中,典型的实现如下图所示:
• OrderConroller:Spring技术栈下的系统访问的Rest接口
• OrderService/OrderServiceImpl:订单的核心业务逻辑实现服务,实现诸如下单、取消订单等逻辑
• OrderDAO/OrdeDAOImpl:订单数据的存取
订单组件并不是以一个单一的、内聚的事物存在,其组成元素OrderService以及其依赖的OrderDAO分散于不同的层,因此,这种模式下订单组件只是逻辑性、概念性的存在。作为业务域的核心抽象,订单组件没有真实的、直观的、内聚的反映在代码实现中。我们在工程代码库中寻找“订单组件”:
• 首先,在工程顶层最先看到的是技术视角的Module(Maven Module):web、service 、dao
• 然后,需要在各层导航才能一窥其全貌
在IDE的支持下,这种导航并不会很复杂。但问题的根本在于:认知成本的增加**。**
我们去了解系统,天然的是从业务域而非技术域出发,单体分层恰恰是从技术域而非业务域出发,这种不同导致业务域与实现间的割裂,增加了对系统的认知成本**。**
实现要反应抽象,组件化思维本质上一种模块化思维,通过内聚性和封装性,将问题空间进行拆分成子空间,分而治之。对外通过接口提供组件能力,屏蔽内部的复杂性。接口契约的大小粒度需要权衡,粒度越小,能力提供越约聚焦,理解和接入成本越低,但通用性越差。接口契约粒度越大,则通用性越强,但理解和接入复杂性越高。
将组件化思维应用于单体分层架构,引申出模块化单体架构风格。应用架构按照问题域进行模块化组织,而非基于技术关注点进行拆分。组件内部遵循内聚性原则,其内包含了实现组件能力所需要的各个元素及交互关系。组件之间通过统一的、合适粒度的接口契约进行交互,不直接依赖于组件的内部能力或模型。同时,组织良好的模块化单体应用架构也是进行微服务拆分的重要保证。如果你无法在单体架构中进行优雅的模块化组织,又何谈合理的微服务拆分呢?
3 结语
单体分层架构风格是分层思想在单体架构中的应用,其关注于技术视角的职责分层。同时,基于不同层变化速率的不同,在一定程度上控制变化在系统内的传播,有助于提升系统的稳定性。但这种技术视角而非业务视角的关注点隔离,导致了问题域与工程实现之间的Gap,这种割裂会导致系统认知复杂度的提升。将组件化思维应用于单体分层架构,模块化单体技术视角的分层拉回至业务域视角的模块化,一定程度上降低业务与工程实现间的隔离。良好的模块化是单体走向微服务的重要基石,如果模块化设计较差的系统,不仅会增加微服务拆分的成本,更为重要的是,会增加形成分布式单体的概率和风险。