聊聊什么是整洁架构

今天看文章的时候突然想起来,今年是【整洁架构】(Clean Architecture)诞生的十周年,2012 年 8 月 13 日,Clean 系列的作者 Robert C. Martin(Uncle Bob) 提出了这个概念。

相信大家在很多地方都听过 Clean Arch 的名号,朦胧之间大体也能说出来一些理念,但很多同学概念上还是区分不清。今天我们就来结合十年前这个经典的 blog 来回顾一下:到底什么是整洁架构,怎么落地。

软件架构

架构存在的意义是什么?用一个短语来概括就是 Separation of Concerns,即分离关注点。

这个可能不太好理解,什么是分离关注点呢?设想一下假设我们现在有一个电商系统,要实现一个下单的功能。

这个系统有哪些关注点(Concern)?

  • 首先,我们一定会需要实现对前端的 http 接口,定义比如单号,商品 id,用户id 等字段,支持用户在 App 上下单时前端请求一个接口即可。
  • 其次,我们肯定会需要访问底层很多服务,甚至直接修改数据库,缓存的数据,来更新余额,更新库存。
  • 我们也肯定需要一些机制来把这个【下单】动作转换为一个信号,通过一些 MQ 的形式告知相关上下游。

类似这样的 Concern 其实非常多,如果我们全都一起考虑,肯定要头大,不知道怎么下手。即使匆匆忙忙写好了,以后万一接口要加字段,底层存储要换,比如从 MySQL 切成 RocksDB,怎么办?

软件架构的意义,就是给你一套方法论,让你能否 follow 这套理论,将你的系统进行拆解,把每一个 Concern 放到预期的位置。这样我们就不用一股脑去思考这么多东西,而是一层一层来思考。

一个简单的想法,比如跟持久化相关的我放到 DAL 包下,跟接口相关的,我放到 Application 包下,这两个是独立的。接口字段变更不应该影响 DAL 的部分。

这就是通过【分层】来实现【关注点分离】,你要应对的不再是一团乱麻,有成千上万的点要去考虑。而是将这些点分散到架构中不同的位置,每一层都是相对独立的,保证他们高内聚,低耦合即可。

好的系统标准

事实上,业界我们能看到的架构基本上都会落脚到【Separation of Concerns】这一点,虽然用的术语和思考路径略有区别,最后往往殊途同归。

  • Hexagonal Architecture (a.k.a. Ports and Adapters) by Alistair Cockburn and adopted by Steve Freeman, and Nat Pryce in their wonderful book Growing Object Oriented Software
  • Onion Architecture by Jeffrey Palermo
  • Screaming Architecture from a blog of mine last year
  • DCI from James Coplien, and Trygve Reenskaug.
  • BCE by Ivar Jacobson from his book Object Oriented Software Engineering: A Use-Case Driven Approach

一个好的系统内核应该满足以下要求:

  1. 独立于任何框架

系统的架构不应该与某个框架,某个库强绑定(无论这个框架多么流行),因为一旦出现了强绑定,坦率的讲这些框架/开源库就不再是你的工具了,他们可能存在的问题,限制,bug,都会实实在在地限制你的系统。

这里的原则在于,对于框架,一定要把它们当成工具,可以取其精华去其糟粕,而不是脱离了这个框架系统就完全没法跑,这一点很重要。

  1. 可独立测试

E2E 的测试当然很好,但不能是你进行测试的唯一选择,即便我们的系统可能是给前端调用的,也可能会依赖某些数据库。但测试本身不应该依赖外部环境,你不能说必须有一个正常运行的前端来打接口,才能验证我们系统是否正常。

你的系统本身应该可以独立被测试。这一点跟我们之前测试系列相关文章是一致的。单测不能依赖外部存储或者环境,单测必须能在本地,只依赖自己的代码来快速运行。

  1. 独立于 UI

你的前台可能是个移动端 App,也可能是个网页,甚至可能就是个命令行工具,但不管是什么,都不应该影响到你的系统。不能出现 UI 层面加减元素,还要联动着整个系统变更,除非【业务逻辑】也发生变化。

  1. 独立于存储

不要写出强依赖你的存储组件的系统,底层的存储可能随着业务增长而不再使用,无论是 MySQL,Oracle 或者 Mongo 等,你的【业务逻辑】和存储是无关的。这里的本质还是强调,你是在用它们作为你的工具,而不是被工具所绑定。

  1. 独立于外界代理

你的内核,只应该和自己的【业务逻辑】相关,而不是跟某个外部系统,也许是个 RPC, 存储,MQ等等。你的内核不应该知道这些东西的存在,而是只关心和维护自己的【业务逻辑】。

整洁架构同心圆

聊聊什么是整洁架构

这时我们再来看 Clean Arch 这个经典的分层图是否就清晰一些了?

  • Entities:同心圆的最深处,一定是业务实体,业务的规则 。
  • Use Cases:往外延伸会出现当前这个 Application 的规则,我们提供了哪些用例,支持什么行为。
  • Gateways/Presenters/Controllers:再往外延伸出现适配器,基于我们要提供的能力,实现具体的适配逻辑。
  • Web/UI/DB/External Interfaces:最外层则是框架,具体的表现,如选择了什么数据库,UI 展示成什么样等。

注意箭头是往里指的,即同心圆外侧的概念,可以依赖内层的概念,而里面的实体,是完全不知道外面的世界有什么东西的。这个也很好理解,【业务逻辑】只应该和自身业务相关,具体怎么存的,怎么给用户展现的,这些是 detail,不应该因为一个 detail 来影响到同心圆最中心的【业务实体】。

  • 内层是规则:policies
  • 外层是机制:mechanisms

规则定了,大家就统一了目标,知道要达到什么能力。

而具体怎么落地,这件事情并不需要每个人都知道。这就是机制,机制是实现目标的手段,发现一个不行可以换另一个,但只要你的 policies 不变,其他人就可以依赖这个 policies 来做他们的事情。

这里是不是感觉似曾相识?

对应到我们的代码层面,其实 policies 就是我们的接口 interface。

大家各自定好 interface,至于具体各自怎么实现,那是自己的事,其他人不用关心。你只需要实现你的 interface 承诺的能力即可。

下面我们细化一下各个部分的理解。

依赖原则

source code dependencies can only point inwards.

还是按照我们的同心圆来看,外层可以依赖内层,但内层不能依赖外层。

内层不应该知道任何外层的概念,流程,能力,不管是函数,类,变量还是任何定义的实体。

不要因为结构类似,就把外层某个框架,或者 driver 定义的结构体,直接一路透传,让内层 entity 也依赖,这样就破坏了我们的原则。这时应该定义两个独立的结构,entity 只维护自己的结构即可,必要时去从一个 struct copy 数据到外层 struct 即可,但不应该让内层感知到这一点。

外层无论发生什么,都不会影响到内层逻辑。即便业务逻辑修改,本质也是最内层的 policies 发生了变化,也一定是从里往外变的,不要倒着来。

Entities

业务的实体。代表着 Enterprise wide business rules,这是很不容易发生变化的。你能想象电商 app 三天两头给你换概念,本来感知到购物车,订单就ok,结果没过多长时间,购物车没了,只能直接下单?

任何发生在 Entities 层面的变动都是你的最底层逻辑发生的改变,是大动干戈的。

它代表了最基础的逻辑,不会轻易的被一些简单的优化需求影响。

Use Cases

也就是我们常说的【用例】。它代表着 application specific business rules,概念上比 Entities 又小了一圈。只关心当前这个系统提供的能力。

这一层需要和 Entities 进行交互,需要依赖 Entities 使用它们的底层能力(Enterprise wide business rules)来实现用例。

当然,我们不指望 Use Cases 层影响到 Entities,因为人家才是最底层,最本质的能力。你不可能提供超过 Entities 逻辑的能力出去。

当应用层面的逻辑有变更,自然会影响到 Use Cases 层。

Interface Adapters

接口适配器,干了什么事呢?

其实很简单,entities 和 use cases 有自己的定义,这个定义跟任何外部框架没关系,只关乎与他们各自的逻辑。而我们的数据,总要存储到某个组件中,总要最终展示给用户,总要以各种形式抛出去。

接口适配器就是来弥补二者之间的 diff,完成数据转换。

MVC 架构中的 Presenters, Views 以及 Controllers 就属于这个层级。

适配器需要将 entities, use cases 的数据格式转换成外层能理解的格式。

举个例子,我们有一个 User 实体,要存到 MySQL 中,这个时候势必也需要在 MySQL 中定义一张 user 表来存放用户信息。Entities 层是不感知这一点的,真正从 Entities, Use Cases 中获取到用户信息,并转换为 ORM 能理解的结构,最终调用存储组件的能力写入数据到 MySQL,这个流程,就需要一个适配器来完成。

Frameworks And Drivers

这里就是最外层,具体的框架和其他存储组件等 detail。通常这一层我们不会自己去写,大多数是有现成的实现。比如你不可能写一个 ORM 或者 MySQL 出来。

我们只需要写一些胶水代码(glue) 来完成 adapter 层和这一层的交互即可。

你的 web 框架,数据库,本质都是细节,都是 detail,应该作为我们的 tool,而不是我们强依赖的东西。即便它们有问题,也不影响整体架构,我们还可以更换。

层级限制

其实不一定最后只有四层,这里只是粗线条的理一下。有些场景下可能会更多。

但一定记住,无论多少层,最后一定是外层依赖内层,内层不感知外层,这个依赖原则要保持。

  • 越往内层走,抽象程度越高,越能看到 policies,最内层的是最通用的概念;
  • 越往外层走,细节越多,看到的全是怎样实现 policies 的机制 mechanisms。

依赖倒置

依赖接口编程,而不是实现。我们为了实现每一层都依赖自己内层的想法,需要借助 Dependency Inversion 的力量。通过一些注入依赖的能力,保证我们依赖的接口有我们想要的实现。

比如 use case 需要调用某个组件,本质是需要调用对应的能力,而不是具体实现,我们也不能直接依赖外层。所以我们对 这个组件 的能力封装成一个 interface,然后直接依赖。

具体的实现通过依赖注入框架来写入,从 use case 的角度看,并没有往外层依赖。

跨越同心圆边界的数据

很多时候我们有一些数据是每一层都需要的,这个时候可以定义一些 Data Transfer Object 来完成,或者直接在函数中加参数,原则在于,我们希望这些数据,是隔离的。

不要直接图省事,用 DB 返回的结构,或者 Entieis 来满天飞。这样很容易出现要违反依赖原则的情况。

当我们需要跨界来传递数据时,一定要保证数据的格式是内层最需要的格式。

小结

整洁架构其实概念并不多,记住同心圆的四个圈分别代表什么,我们希望通过这四个层次,完成分层,实现关注点分离。这样每一层只在乎自己的逻辑,依赖更内层的逻辑,而不知道外面发生了什么

区分好什么是 policies,什么是 mechanisims,用依赖倒置来实现,这样的代码不仅仅可读性更高,也更容易测试,因为耦合更小。我们可以随时替换外层的 detail 而不影响内层逻辑。