4.动手实践整洁架构 - 如何组织代码

199 阅读10分钟

通过查看代码就可以认识架构不是很好吗?

在本章中,我们将研究组织代码的不同方式,并介绍直接反映六边形架构的富有表现力的包结构。

在新建软件项目中,我们首先要尝试正确的是包结构。我们建立了一个漂亮的结构,打算将其用于项目的其余部分。然后,在项目期间,事情变得忙碌,我们意识到在很多地方,包结构只是一堆非结构化混乱代码的漂亮外观。一个包中的类从其他包中导入不应导入的类。

我们将讨论构建前言中介绍的 BuckPal 示例应用程序代码的不同选项。更具体地说,我们将研究“汇款”用例,用户可以使用该用例将钱从他的帐户转移到另一个帐户。

按层组织

组织代码的第一种方法是按层。我们可以这样组织代码:

buckpal
├── domain
| ├── Account
| ├── Activity
| ├── AccountRepository
| └── AccountService
├── persistence
| └── AccountRepositoryImpl
└── web
  └── AccountController

对于 Web 层、域层和持久层的每一层,我们都有一个专用的包。正如第 1 章“层有什么问题?”中所讨论的,出于多种原因,简单的层可能不是我们代码的最佳结构,因此我们在这里已经应用了依赖倒置原则,只允许对域包中的域代码进行依赖。我们通过在域包中引入 AccountRepository 接口并在持久性包中实现它来做到这一点。

然而,我们至少可以找到这个包结构不理想的三个原因。

首先,我们的应用程序的功能片或功能之间没有包边界。如果我们添加管理用户的功能,我们将在 Web 包中添加 UserController,在域包中添加 UserService、UserRepository 和 User,在持久性包中添加 UserRepositoryImpl。如果没有进一步的结构,这可能很快就会变成一堆混乱的类,从而导致应用程序的假定不相关的功能之间产生不必要的副作用。

其次,我们看不到我们的应用程序提供了哪些用例。您能说出 AccountService 或 AccountController 类实现了哪些用例吗?如果我们正在寻找某个功能,我们必须猜测哪个服务实现了它,然后在该服务中搜索负责的方法。

同样,我们无法在包结构中看到我们的目标架构。我们可以猜测我们遵循了六边形架构风格,然后浏览 Web 和持久层包中的类来查找 Web 和持久层适配器。但我们无法一眼看出 Web 适配器调用了哪些功能以及持久层适配器为域层提供了哪些功能。传入和传出端口隐藏在代码中。

按特性组织

让我们尝试解决“按层组织”方法的一些问题。下一个方法是按特性组织我们的代码:

buckpal
  └── account
    ├── Account
    ├── AccountController
    ├── AccountRepository
    ├── AccountRepositoryImpl
    └── SendMoneyService

本质上,我们已经将所有与账户相关的代码都放到了账户包中。 每组新的特性都会在账户包同级创建一个新的包,我们可以通过对不应从外部访问的类使用包私有可见性来强制特性之间的包边界。

包边界与包私有可见性相结合,使我们能够避免功能之间不必要的依赖关系。

我们还将 AccountService 重命名为 SendMoneyService 以缩小其职责(实际上我们也可以通过逐层封装的方法来做到这一点)。现在,我们只需查看类名就可以看到代码实现了用例“Send Money”。制作应用程序的代码中可见的特性被罗伯特·马丁称为“尖叫架构”,因为它向我们尖叫着它的意图。然而,逐个特性包的方法使我们的架构比分层包的方法更不明显。我们没有包名称来识别我们的适配器,而且我们仍然看不到传入和传出端口。更重要的是,即使我们颠倒了域代码和持久层代码之间的依赖关系,以便 SendMoneyService 只知道 AccountRepository 接口而不知道其实现,我们也无法使用包私有可见性来保护域代码免受持久性代码的意外依赖。

那么,如何才能让我们的目标架构一目了然呢?如果我们能够将手指指向如图 9 所示的架构图中的某个框,并立即知道代码的哪一部分负责该框,那就太好了。

让我们再采取一步来创建一个具有足够表现力来支持这一点的包结构。

具有架构表现力的包结构

在六边形架构中,我们有实体、用例、传入和传出端口以及传入和传出(或“驱动”和“被驱动”)适配器作为我们的主要架构元素。让我们将它们放入表达此架构的包结构中:

buckpal
└── account
  ├── adapter
  | ├── in
  | | └── web
  | |		└── AccountController
  | ├── out
  | | └── persistence
  | |		├── AccountPersistenceAdapter
  | |   └── SpringDataAccountRepository
  ├── domain
  | ├── Account
  | └── Activity
  └── application
    └── SendMoneyService
    └── port
      ├── in
      | └── SendMoneyUseCase
      └── out
        ├── LoadAccountPort
        └── UpdateAccountStatePort

架构的每个元素都可以直接映射到其中一个包。有一个包名为 account 的包,表明这是围绕帐户实现用例的模块。

在下一个级别,我们有包含域模型的域包。应用程序包包含围绕该域模型的服务层。

SendMoneyService 实现传入端口接口 SendMoneyUseCase 并使用传出端口接口 LoadAccountPort 和 UpdateAccountStatePort,这些接口由持久层适配器实现。

适配器包包含调用应用程序层传入端口的传入适配器和为应用程序层传出端口提供实现的传出适配器。在我们的例子中,我们正在构建一个简单的 Web 应用程序,其中包含适配器 Web 和持久层,每个适配器都有自己的子包。 唷,这是很多听起来技术性的包。这不是很令人困惑吗?

想象一下,我们的六边形架构的高级视图挂在办公室墙上,并且我们正在与一位同事讨论将客户端修改为我们正在使用的第三方 API。讨论时,我们可以指出海报上对应的输出适配器,以便更好地相互理解。然后,当我们说完后,我们坐在我们的IDE前面,可以立即开始处理客户端,因为我们所说的API客户端的代码可以在adapter/out/ 包。

相当有帮助而不是令人困惑,你不觉得吗?

这种包结构是对抗所谓的“架构/代码差距”或“模型/代码差距”的有力元素。这些术语描述了这样一个事实:在大多数软件开发项目中,架构只是一个抽象概念,无法直接映射到代码。随着时间的推移,如果包结构(除其他外)不能反映架构,代码通常会越来越偏离目标架构。

此外,这种富有表现力的包结构促进了对架构的积极思考。我们有很多包,必须考虑将我们当前正在处理的代码放入哪个包中。

但是,如此多的包难道不是意味着所有内容都必须公开才能允许跨包访问吗?

至少对于适配器包来说,情况并非如此。它们包含的所有类都可以是包私有的,因为除了通过位于应用程序包内的端口接口之外,外部世界不会调用它们。因此,应用程序层不会意外依赖于适配器类。

然而,在应用程序和域包中,某些类确实必须是公共的。这些端口必须是公共的,因为在设计上它们必须可供适配器访问。域类必须是公共的,才能被服务以及可能的适配器访问。这些服务不需要是公共的,因为它们可以隐藏在传入端口接口后面。

将适配器代码移至其自己的包中还有一个额外的好处,即如果需要,我们可以非常轻松地将一个适配器替换为另一种实现。想象一下,我们已经开始针对一个简单的键值数据库实施,因为我们不确定最终哪个数据库最好,现在我们需要切换到 SQL 数据库。我们只需在新的适配器包中实现所有相关的传出端口,然后删除旧的包。

这种包结构的另一个非常吸引人的优点是它直接映射到 DDD 概念。在我们的例子中,包 account 是一个有界上下文,它具有专用的入口和出口点(端口)来与其他有界上下文进行通信。在域包中,我们可以使用 DDD 提供的所有工具来构建我们想要的任何域模型。

与每个结构一样,在软件项目的整个生命周期中维护这种包结构需要遵守纪律。此外,在某些情况下,包结构不适合,我们除了扩大架构/代码差距并创建一个不反映架构的包之外别无选择。

没有完美的事情。但通过富有表现力的包结构,我们至少可以缩小代码和架构之间的差距。

依赖注入的作用

上面描述的包结构对于整洁架构有很大的帮助,但是这种架构的一个基本要求是应用层不依赖于传入和传出适配器,正如我们在第 2 章“反向依赖关系”中学到的那样。

对于传入适配器(例如我们的 Web 适配器),这很容易,因为控制流指向与适配器和域代码之间的依赖关系相同的方向。适配器只是调用应用层内的服务。为了清楚地划分应用程序的入口点,我们可能仍然希望隐藏端口接口之间的实际服务。

对于传出适配器,例如我们的持久层适配器,我们必须利用依赖关系倒置原则将依赖关系转向控制流的方向。

我们已经看到了它是如何运作的。我们在应用层中创建一个接口,该接口由适配器中的类实现。在我们的六边形架构中,这个接口是一个端口。然后应用层调用该端口接口来调用适配器的功能,如图10所示。

但是谁向应用层提供实现端口接口的实际对象呢?我们不想在应用层中手动实例化端口,因为我们不想引入对适配器的依赖关系。

这就是依赖注入发挥作用的地方,我们引入了一个对所有层都有依赖性的中立组件,该组件负责实例化构成我们架构的大部分类。

在上面的示例图中,中性依赖项注入组件将创建 AccountController、SendMoneyService 和 AccountPersistenceAdapter 类的实例 。 由 于 AccountController 需要 SendMoneyUseCase,因此依赖项注入将在构造期间为其提供 SendMoneyService 类的实例。控制器不知道它实际上获得了 SendMoneyService 实例,因为它只需要知道接口。

类似地,在构造 SendMoneyService 实例时,依赖项注入机制将以 LoadAccountPort 接口的形式注入 AccountPersistenceAdapter 类的实例。服务永远不知道接口背后的实际类。

我们将在第 9 章“组装应用”中以 Spring 框架为例详细讨论如何初始化应用。

这如何帮助我构建可维护的软件?

我们研究了六边形架构的包结构,它使实际代码结构尽可能接近目标架构。现在,在代码中查找架构元素只需沿着架构图中某些框的名称向下导航包结构即可,这有助于通信、开发和维护。

在接下来的章节中,我们将看到这个包结构和依赖注入的实际应用,因为我们将在应用层、Web 适配器和持久层适配器中实现一个用例。