Clean architecture for the rest of us

1,538 阅读13分钟

原文:Clean architecture for the rest of us

作者:Suragch

这是一篇介绍性文章。

如果你是一名高级软件工程师,可以到此为止了,这篇文章不适合你。这篇文章是写给那些像我一样的普通程序员的,他们写着乱七八糟的代码,创建着像意大利面一样混乱的架构,但却对构建干净的、可维护的、适应性强的东西很着迷。

前言

我通常不购买计算机相关的书籍,因为它们实在是过时的太快了。还有就是,反正这些书中信息在网上都可以查得到。但在一年前,我阅读了 Robert Martin 编写的 Clean Code,这本书真实的帮我改善了开发软件的方式。所以,当我看到同一作者另外一本书《Clean Architecture》出版的时候,便毫不犹豫的买下了它。

Clean Code 一样,Clean Architecture 中讲述了大量经的起时间考验的原则,这些原则适用于任何人编写的任何代码。如果你在网上搜索书名,会发现有些人并不认同作者的观点。显然,我不是要在这里批判他。我只是知道 Robert Martin (又名 Uncle Bob)已经从事编程超过50年,而我没有。

尽管这本书理解起来有点困难,但我会尽最大努力,把书中的重要概念加以总结和解释,让普通人也能够理解。作为一名软件架构师,我仍然在不断学习成长,所以,请以批判的眼光阅读我写的东西。

什么是 clean architecture?

架构 (Architecture) 是指项目的整体设计,是代码放入类(classes)、文件(files)、组件(components)、模块(modules) 的组织结构,以及这些代码单元之间相互关联的方式。架构定义了应用程序在哪里执行核心功能,以及这些功能如何与数据库、用户界面等事物进行交互。

Clean 架构是一种易于理解、可以随着项目发展灵活改变的项目组织方式。这不是随随便便就可以办到的,需要有意识的规划。

Clean architecture 的特点

构建一个易于维护的大型项目的秘诀是:将类或文件拆分到不同的组件中,每个组件都可以独立于其他组件进行修改。让我们用几张图片来说明这一点:

clean-architecture-ex-1.jpeg

上面的图片中,如果想用刀代替剪刀,你需要做什么?你必须解开连接到笔、墨水瓶、胶带和指南针的绳子。然后你再将这些物品重新系到刀上。也许这对于刀来说是可以了。但如果钢笔和胶带说:“等等,我们需要剪刀。” 所以现在笔和胶带不能正常工作了,不得不在做改变。这个改变反过来又会影响那些连接到它们上面的相关物体。 一团糟。

对比下图:

clean-architecture-ex-2.jpeg

现在我们应该如何替换剪刀呢?我们只需要从便利贴下面拉出剪刀的绳子,然后把系在刀子上的新绳子插进去。容易多了。便利贴不在乎,因为绳子甚至没有绑在它上面。

第二张图所代表的架构显然更容易改变。只要便利贴不需要被经常的更换,这个系统就很容易维护。同样的,这种架构可以使软件更易于维护和变更。

clean-architecture-ex-3.png

内圈是应用程序的领域层 (domain layer) ,用来放置业务规则。我们所说的“业务”不一定是指公司,它只是意味着你的应用程序的本质,即代码的核心功能。例如:翻译应用程序的核心功能是翻译,线上商店的本质是要出售商品。这些业务规则通常相当的稳定,因为你不太可能经常改变应用的本质。

外层是基础设施层 (infrastructure layer),包括 UI、数据库、网络 API、以及软件框架之类的东西。相对于领域,这些更容易发生变化。举个例子,你更有可能更改 UI 按钮的外观,而不是更改贷款的计算方式。

领域和基础设施之间设置了一个明显的边界,目的是让领域对基础设施毫无感知。这意味着 UI 和数据库依赖业务规则,但业务规则不依赖 UI 和数据库。这使它成为一个插件架构 (plugin architecture),无论 UI 是一个网页、桌面应用程序、还是移动应用都没有关系;数据是存储在 SQL、NoSQL 还是云端也没有关系,领域根本就不关心。这使得更改基础设施变得十分容易。

定义术语

上图中的两个圈层可以进一步细化。

clean-architecture-ex-4.png

在这里,领域层被细分成 entitiesuse casesadapter layer 形成了领域和基础设施层之间的边界。这些术语可能有点令人困惑。让我们分别来看一下。

Entities(实体)

一个 entity 是一组对应用程序功能至关重要的相关的业务规则。在面向对象的编程语言中,一个 entity 的所有规则会以成员方法的形式组合到一个类中。即使没有应用,这些规则仍然存在。例如,银行可能有个规则,对贷款收取 10% 的利息,无论是在纸上计算还是使用计算机,这个利息都是 10%。这是书中一个 entity 类的示例 (191页):

clean-architecture-ex-5.png

Entities 对其他层一无所知,它们不依赖任何东西。也就是说,它们不使用外层中的任何类或组件。

Use cases(用例)

Use cases 是特定应用程序的业务规则,描述如何使系统自动化运行。这决定了应用程序的行为。下面是书中关于 use cases 业务规则的一个示例 (192页,稍作了些修改):

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

Use cases 与 entities 交互,并依赖 entities,但它对更外层一无所知。它们不在乎是网页还是 iPhone 应用程序,也不关心数据是存储在云端还是本地的 SQLite 数据库。

该层定义接口或者抽象类,以供外层使用。

Adapters(适配器)

Adapters,也称为 interface adapters,是领域和基础设施之间沟通的翻译员 (translators)。例如,它们从 GUI 获取输入数据,并将其重新打包成便于 use cases 和 entities 使用的形式。然后它们从 use cases 和 entities 获取输出,并将其重新打包成便于 GUI 显示或数据库存储的形式。

Infrastructure(基础设施)

这层是所有 I/O 组件所在的地方:UI、数据库、框架、设备等。这是最不稳的的一层。由于这一层中的事物很可能发生变化,因此它们尽可能远离更稳定的领域层。因为是相互分离的,所以对其进行修改或组件替换都相对容易。

实现 clean architecture 的原则

因为下面的一些原则有着令人迷惑的名字,所以我在上面的解释中特意没有使用它们。但是,要想实现我所描述的架构设计,必须遵循这些原则。如果这部分让你头晕目眩,可以直接跳到文章最后的 最终总结 部分。

下面的前五个原则通常缩写为 SOLID,以方便记忆。它们是类级别的原则,但具有适用于组件 (相关类的集合)的类似对应物。组件级别的原则遵循 SOLID 原则。

单一职责原则 (Single Responsibility Principle - SRP)

这是 SOLID 中的 S。SRP 说的是一个类应该只有一个职责。类可能有多个方法,但这些方法一起协同工作来完成一件主要事情。对类的修改应该只有一个原因。举个例子,如果财务办公室提了一个需求需要修改这个类,同时人力资源部也有一个需求需要以不同的方式修改这个类,这时,修改这个类就存在了两个原因。那么,这个类应该被拆分为两个独立的类,以保证每个类都只有一个原因去修改。

开闭原则 (Open Closed Principle - OCP)

这是 SOLID 中的 O。Open 意味着对于扩展是开放的。Close 意味着对于修改是关闭的。因此,你应该有能力向类或组件添加功能,同时,又不需要修改现有功能。要怎么做的呢?首先需要保证每个类或组件只有一个单一职责,然后将相对稳定的类隐藏在接口的后面。这样,当不太稳定的类不得不修改的时候不会影响到相对稳定的类。

里氏替换原则 (Liskov Substitution Principle - LSP)

这是 SOLID 中的 L。我猜是需要 L 来拼成 SOLID,但“替换”才是你需要记住的。该原则意味着较低层级的类或组件可以被替换,但不能影响到较高层级的类或组件的行为。可以通过抽象类或接口来实现该原则。例如,在 Java 中,ArrayList 和 LinkedList 都实现了 List 接口,它们可以相互替换。如果这个原则应用到架构级别,MySQL 可以被 MongoDB 替换,同时不影响领域层的逻辑。

接口隔离原则 (Interface Segregation Principle - ISP)

这是 SOLID 中的 I。ISP 指的是使用接口将一个类和使用它的类分开,接口只暴露依赖类所需要的方法子集。这样,不在接口暴露的方法子集中的其他方法发生修改时,不会影响到依赖类。

依赖倒置原则 (Dependency Inversion Principle - DIP)

这是 SOLID 中的 D。这意味着相对不稳定的类和组件应该依赖于相对稳定的类和组件,而不是反过来。如果一个稳定的类依赖一个不稳定的类,那么每次不稳定的类发生变化,将会影响到稳定的类,所以需要翻转依赖的方向。要怎么做呢?通过使用抽象类,或者把稳定的类隐藏在接口的后面。

所以,像下面这样一个稳定的类使用易变的类的情况:

class StableClass {
    void myMethod(VolatileClass param) {
        param.doSomething();
    }
}

应该创建一个接口,并让易变的类实现这个接口:

class StableClass {
    interface StableClassInterface {
        void doSomething();
    }
    void myMethod(StableClassInterface param) {
        param.doSomething();
    }
}

class VolatileClass implements StableClass.StableClassInterface {
    @Override
    public void doSomething() {
    }
}

这样就翻转了依赖方向。易变得类知道稳定的类的类名,但稳定的类对易变的类一无所知。

使用抽象工厂模式 (Abstract Factory partten)是实现此目的的另一种方法。

重用/发布等效原则 (Reuse/Release Equivalence Principle - REP)

REP 是一个组件级别的原则。重用 (Reuse) 是指一组可重用的类或模块。发布 (Release) 是指以版本号发布。这个原则是说,你发布的任何东西都应该可以作为内聚的单元进行重复使用,而不应该是不相干的类的随机集合。

共同闭合原则 (Common Closure Principle -CCP)

CCP 是一个组件级别的原则。它说的是组件应该是一些类的集合,这些类在相同的时间由于同样的原因被修改。如果对这些类的修改基于不同的原因,或者修改的频率不一致,那么这个组件应该被拆分。该原则与上面提到的*单一职责原则 (Single Responsibility Principle - SRP)*基本相同。

通用复用原则 (Common Reuse Principle - CRP)

CRP 是一个组件级别的原则。它说的是不应该依赖那些包含你不需要的类的组件。这些组件应该被拆分到用户不必依赖那些他不使用的类的程度。该原则与上面提到的*接口隔离原则 (Interface Segregation Principle - ISP)*基本相同。

这三个原则 (REP, CCP, and CRP) 相互矛盾。过多的拆分或过多的分组都会导致问题。需要根据实际情况平衡这些原则。

非循环依赖原则 (Acyclic Dependency Principle - ADP)

ADP 意味着在项目中不应该出现依赖循环。例如,如果组件 A 依赖组件 B,组件 B 依赖组件 C,而组件 C 又依赖组件 A,那么就存在一个依赖循环。

clean-architecture-ex-6.png

在尝试对系统进行更改时,这样的循环会产生重大问题。打破依赖循环的一种解决方案,是使用*依赖倒置原则 (Dependency Inversion Principle - DIP)*在组件之间添加一个接口。如果不同的个人或团队对不同的组件负责,那么这些组件应该以自己的版本号单独发布。这样,一个组件的更改不会立即影响到其他团队。

稳定依赖原则 (Stable Dependency Principle - SDP)

这个原则说的是,依赖关系应该建立在稳定的方向上。也就是说,较不稳定的组件应该依赖较稳定的组件。这最大限度的降低了变更带来的影响。一些组件本身就是容易发生变化的,这没有关系,我们要做的是不要让稳定的组件依赖它们。

稳定抽象原则 (Stable Abstraction Principle - SAP)

SAP 说的是:一个组件越稳定,它就应该越抽象,也就是它应该包含的抽象类越多。抽象类更容易扩展,因此这可以防止稳定的组件变得过于僵化。

最终总结

以上内容总结了《Clean Architecture》一书的主要原则,但我还想补充一些其他的要点。

测试

创建一个插件架构的好处是使代码更具可测试性。当项目中有很多依赖的时候,代码是很难测试的。但当你拥有一个插件框架,测试会变得容易很多,仅仅需要你用 Mock 对象替换一个数据库依赖项(或者其他的任何组件)。

我总是在测试 UI 的时候感到很糟糕。我做了一个遍历 GUI 的测试,但一旦我对 UI 做了更改,测试就中断了,最终我只能删除这个测试。我意识到我应该在*适配器层 (adapter layer)*创建一个 Presenter 对象。Presenter 获取业务规则的输出,并根据 UI 视图的需要格式化所获得的所有内容。UI 视图对象除了显示 Presenter 提供的预格式化数据之外什么都不做。这样修改代码之后,就可以独立于 UI 测试 Presenter 的代码了。

创建一个特殊的测试 API 来测试业务规则。它应该与接口适配器分离,以便在应用程序结构发生变化时测试不会中断。

根据用例 (use cases) 划分组件

我在上面谈到了领域和基础设施层。如果将这些看作是水平方向的层级,则可以根据应用程序的不同用例 (user cases),将它们在垂直方向上划分为不同的组件组。就像是一个分层蛋糕,每个切片都是一个用例 (use cases),切片中的每一层构成一个组件。

clean-architecture-ex-7.jpeg

例如,在视频网站上,一个用例 (use case) 是观众 (viewer) 观看视频。所以有一个 ViewerUseCase 组件、一个 ViewerPresenter 组件、一个 ViewerView 组件,等等。另一个用例 (use case) 是针对上传视频到网站的发布者 (publisher)。对于他们,应该有一个 PublisherUseCase 组件、一个 PublisherPresenter 组件、一个 PublisherView 组件,等等。还有一个用例 (use case) 可能是针对站点的管理员。以这种方式,通过对水平层进行垂直方向的切片来创建单个组件。

部署应用程序的时候,可以以最有意义的任何方式对组件进行分组。

强制分层

你可能拥有世界上最好的架构,但如果新来的开发人员添加了一个绕过边界的依赖项,这将完全违背了架构设计的初衷。防止这种情况发生的最佳方法是:使用编译器来保护架构。例如,在Java中,可以将类打包为 private,以便在那些不应该知道它们的模块面前隐藏起来。另一种选择是使用第三方软件,它可以帮助你检查是否有东西在使用它不应该使用的东西。

只在需要时增加复杂性

不要从一开始就过度设计你的系统,只有在需要的时候才使用更多的架构。但是在体系结构中维护一些边界,会使组件在未来更容易爆发。举个例子:首选,你可能会部署一个外部的单体应用程序,但在内部,类保持着适当的边界。稍后,你可能将它们分解为单独的模块。在后来,你可以将它们部署为服务。只要沿着保持分层和边界的路走,你就可以自由调整它们的部署方式。通过这种方式,你不会创造可能永远也用不到的不必要的复杂性。

细节

在开始一个项目时,应该首先处理业务规则,其他的都是细枝末节。数据库是一个细节,UI 是一个细节,操作系统是一个细节,Web API 是一个细节,框架也是一个细节。对这些细节的决定应该尽可能的延后。这样,当你需要它们的时候,你将站在一个绝佳的位置帮助你作出明智的选择。这对你初始的开发工作没有影响,因为领域层对基础设施层一无所知。当你准备好选择数据库时,填写数据库适配器代码然后将其插入到架构中。当你准备好 UI 时,填写 UI 适配器代码,然后将其插入到架构中。

最后一点建议

  • 不要把 Entity 对象用作在外层传递的数据结构,应该使用独立的数据模型对象。
  • 项目的顶级组织架构应该清楚地告诉人们这个项目是关于什么的。这叫做 screaming architecture。
  • 走出去,开始将这些课程付诸实践。只有使用这些原则,你才能真正学会它们。

练习:制作依赖图

打开你当前的一个项目,并在一张纸上画出依赖关系图。为项目中的每一个组件或类画一个方框,然后遍历每个类,看看这些类的依赖。任何命名的类都是依赖项。从正在检查的类的方框画一个箭头指向命名的类或组件的方框。

当你遍历完所有的类,请考虑下面的问题:

  • 业务规则在哪里 (entities and use cases) ?
  • 业​​务规则是否依赖其他东西?
  • 如果你不得不使用不同的数据库、UI 平台、或代码框架,有多少个类或组件将受到影响?
  • 是否有依赖循环?
  • 为了创建插件架构,您需要进行哪些重构?

结论

Clean Architecture》这本书的精髓是你需要创建一个插件架构 (plugin architecture)。出于相同的原因在同一时间需要同时修改的类应该组合在一起成为组件。业务规则组件是相对更加稳定的,它们应该对相对易变的基础设施组件一无所知,这些基础设施组件处理 UI、数据库、网络、代码框架和其他的细节功能。组件层级之间的边界,是通过接口适配器来维护的。这些接口适配器在层级之间传输数据,并沿着指向更稳定的内部组件的方向保持依赖关系。

我学到了很多东西。我希望你也是。如果我在哪里歪曲了这本书,请告知我。您可以在我的 GitHub 个人资料 中找到我的联系信息。

进一步学习

我尽我最大的努力全面的总结了 Clean Architecture,但是你会在书中找到更多信息。值得花时间读一下这本书。事实上,我推荐阅读 Robet Martin 写的以下这三本书。我给出了这些书在亚马逊上的链接,但如果你购买二手副本,你可能会发现它们更便宜。 我按照推荐阅读的顺序列出了它们。 这些书都不会很快的过时。