11.动手实践整洁架构 - 强化架构边界

124 阅读14分钟

我们在前面的章节中讨论了很多关于架构的内容,有一个目标架构来指导我们决定如何编写代码以及将代码放在哪里,感觉很好。

然而,在每个大型软件项目中,架构往往会随着时间的推移而被侵蚀。层之间的界限减弱,代码变得更难测试,我们通常需要越来越多的时间来实现新功能。

在本章中,我们将讨论一些可以采取的措施来加强架构内的边界,从而对抗架构侵蚀。 边界和依赖性在我们讨论强化实施架构边界的不同方法之前,我们先讨论一下边界在架构中的位置以及“强化实施边界”的实际含义。

图 27 显示了我们的六边形架构的元素如何分布在四个层上,类似于第 2 章“反转依赖关系”中介绍的通用整洁架构方法。

最内层包含领域实体,应用层可以访问这些域实体以在应用服务内实现用例。适配器通过输入端口访问这些服务,或者这些服务正在通过输出端口访问这些服务。最后,配置层包含创建适配器和服务对象并将它们提供给依赖项注入机制的工厂。

在上图中,我们的架构边界变得非常清晰。每层与其下一个向内和向外的邻居之间都有边界。根据依赖关系规则,跨越此类层边界的依赖关系必须始终指向内部。

本章介绍了执行依赖规则的方法,我们希望确保不存在指向错误方向的非法依赖项(图中的红色虚线箭头)。

可见性修饰符

让我们从 Java 为我们提供的最基本的边界工具开始:可见性修饰符。

可见性修饰符几乎是我过去几年进行的每一次入门级工作面试中的一个话题。我会问面试者 Java 提供了哪些可见性修饰符以及它们的区别是什么。

大多数面试者只列出了 public、protected 和 private 修饰符。几乎没有人知道 package-private (或“默认”)修饰符。对于我来说,这始终是一个受欢迎的机会,可以提出一些问题,了解为什么这样的可见性修饰符有意义,以便了解面试者是否可以从他或她以前的知识中抽象出来。

那么,为什么 package-private 修饰符如此重要呢?因为它允许我们使用 Java 包将类分组为内聚的“模块”。此类模块内的类可以相互访问,但不能从包外部访问。然后我们可以选择公开特定的类作为模块的入口点。这降低了因引入指向错误方向的依赖关系而意外违反依赖关系规则的风险。

让我们再看一下第 3 章“组织代码”中讨论的包结构,并考虑到 visibiliy 修饰符:

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

我们可以将持久化包中的类设为package-private, package-private(在上面的树中用“o” 标记),因为它们不需要被外界访问。持久性适配器通过其实现的输出端口进行访问。出于同样的原因,我们可以将 SendMoneyService 类设为包私有,依赖注入机制通常使用反射来实例化类,因此即使它们是 package-private,它们仍然能够实例化这些类。

然而,对于 Spring,只有当我们使用第 9 章“组装应用程序”中讨论的类路径扫描方法时,这种方法才有效,因为其他方法要求我们自己创建这些对象的实例,这需要公共访问。

根据架构的定义,示例中的其余类必须是公共的(用“+”标记):域包需要可由其他层访问,应用层需要由 Web 和持久性适配器访问。

package-private 修饰符对于只有几个类的小型模块来说非常有用。然而,一旦包达到一定数量的类,同一个包中包含如此多的类就会变得令人困惑。在这种情况下,我喜欢创建子包以使代码更容易找到(并且,我承认,以满足我对美观的需求)。这是 package-private 修饰符无法实现的地方,因为 Java 将子包视为不同的包,并且我们无法访问子包的包私有成员。因此,子包中的成员必须是公开的,将它们暴露给外界,从而使我们的架构容易受到非法依赖的影响。

编译后检查

一旦我们在类上使用 public 修饰符,编译器就会让任何其他类使用它,即使依赖项的方向根据我们的架构指向错误的方向。

由于编译器在这些情况下无法帮助我们,因此我们必须找到其他方法来检查依赖关系规则是否被违反。

一种方法是引入编译后检查,即在代码已经编译时在运行时进行的检查,此类运行时检查最好在持续集成构建中的自动化测试期间运行。

支持 Java 此类检查的工具是 ArchUnit。除此之外,ArchUnit 还提供了一个 API 来检查依赖项是否指向预期的方向。如果发现违规,就会抛出异常。它最好在基于 JUnit 等单元测试框架的测试中运行,这样在依赖项冲突的情况下测试就会失败。

使用 ArchUnit,我们现在可以检查层之间的依赖关系,假设每个层都有自己的包,如上一节讨论的包结构中所定义。例如,我们可以检查域层到外部应用层是否不存在依赖关系:

class DependencyRuleTests {
    @Test
    void domainLayerDoesNotDependOnApplicationLayer() {
        noClasses().that()
            .resideInAPackage("buckpal.domain..")
            .should()
            .dependOnClassesThat()
            .resideInAnyPackage("buckpal.application..")
            .check(new ClassFileImporter().importPackages("buckpal.."));
    }
}

通过一些工作,我们甚至可以在 ArchUnit API 之上创建一种 DSL(领域特定语言),它允许我们在六边形架构中指定所有相关的包,然后自动检查这些包之间的所有依赖关系是否都指向正确的方向:

class DependencyRuleTests {
    @Test
    void validateRegistrationContextArchitecture() {
        HexagonalArchitecture.boundedContext("account")
                .withDomainLayer("domain")
                .withAdaptersLayer("adapter")
                .incoming("web")
                .outgoing("persistence")
                .and()
                .withApplicationLayer("application")
                .services("service")
                .incomingPorts("port.in")
                .outgoingPorts("port.out")
                .and()
                .withConfiguration("configuration")
                .check(new ClassFileImporter().importPackages("buckpal.."));
    }
}

在上面的代码示例中,我们首先指定有界上下文的父包(如果它仅跨越单个有界上下文,那么它也可能是完整的应用程序)。然后我们继续指定域、适配器、应用层和配置层的子包。最后调用 check() 将执行一组检查,根据依赖关系规则验证包依赖关系是否有效。

如果您想尝试一下的话,该六边形架构 DSL 的代码可在开源代码库中找到。

虽然像上面这样的编译后检查对于对抗非法依赖关系很有帮助,但它们并不是万无一失的。

例如,如果我们在上面的代码示例中拼错了包名称 Buckpal,则测试将找不到类,因此不会发现依赖项冲突。一个拼写错误,或者更重要的是,一次重构重命名包,可能会使整个测试变得毫无用处。我们可以通过添加一个检查来解决这个问题,如果没有找到类,该检查就会失败,但它仍然容易受到重构的影响,编译后检查始终必须与代码库并行维护。

构建制品

到目前为止,我们在代码库中划分架构边界的唯一工具是包。我们所有的代码都是同一个整体构建制品的一部分。

构建制品是(希望是自动化的)构建过程的结果。目前Java世界中最流行的构建工具是 Maven 和 Gradle。因此,到目前为止,假设我们有一个 Maven 或 Gradle 构建脚本,我们可以调用 Maven 或 Gradle 来编译、测试应用程序的代码并将其打包到单个 JAR 文件中。

构建工具的一个主要功能是依赖解析。为了将某个代码库转换为构建制品,构建工具首先检查该代码库依赖的所有制品是否可用。如果没有,它会尝试从制品库加载它们。如果失败,在尝试编译代码之前构建就会失败并出现错误。

我们可以利用它来强制我们架构的模块和层之间的依赖关系(从而强化边界)。对于每个这样的模块或层,我们创建一个单独的构建模块,具有自己的代码库和自己的构建制品(JAR 文件)。在每个模块的构建脚本中,我们仅指定根据我们的架构允许的对其他模块的依赖关系。开发人员不能再无意中创建非法依赖项,因为这些类甚至在类路径上不可用,并且会遇到编译错误。

图 28 显示了一组不完整的选项,用于将我们的架构划分为单独的构建制品。

从左侧开始,我们看到一个基本的三模块构建,其中包含用于配置、适配器和应用层的单独构建制品。配置模块可以访问适配器模块,适配器模块又可以访问应用模块。配置模块还可以访问应用模块,因为它们之间存在隐式的、可传递的依赖关系。

请注意,适配器模块包含 Web 适配器和持久性适配器。这意味着构建工具不会禁止这些适配器之间的依赖关系。虽然依赖关系规则并未严格禁止这些适配器之间的依赖关系(因为两个适配器位于同一外层内),但在大多数情况下,保持适配器彼此隔离是明智的。

毕竟,我们通常不希望持久层中的更改泄漏到 Web 层中,反之亦然(记住单一责任原则!)。对于其他类型的适配器也是如此,例如将我们的应用程序连接到某个第三方 API 的适配器。我们不希望通过在适配器之间添加意外的依赖关系来泄漏该 API 的详细信息到其他适配器中。

因此,我们可以将单个适配器模块拆分为多个构建模块,每个适配器对应一个构建模块,如图 28 第二列所示。 接下来,我们可以决定进一步拆分应用程序模块。它当前包含应用程序的传入和传出端口、

实现或使用这些端口的服务以及应包含大部分域逻辑的域实体。

如果我们决定不将域实体用作端口内的传输对象(即我们希望禁止第 8 章“边界之间的映射”中的“无映射”策略),我们可以应用依赖倒置原则并退出一个单独的“api”模块,仅包含端口接口(图 28 中的第三列)。适配器模块和应用程序模块可以访问 api 模块,但反之则不行。 api 模块无权访问域实体,也不能在端口接口内使用它们。此外,适配器不再能够直接访问实体和服务,因此它们必须通过端口。

我们甚至可以更进一步,将 api 模块一分为二,一部分仅包含输入端口,另一部分仅包含输出端口(图 27 中的第四列)。这样我们就可以通过声明仅对输入或输出端口的依赖来非常清楚地确定某个适配器是输入适配器还是输出适配器。

此外,我们还可以进一步拆分应用模块,创建一个仅包含服务的模块和另一个仅包含域实体的模块。这确保了实体不会访问服务,并且只需声明对域构建制品的依赖关系,就允许其他应用(具有不同的用例,因此具有不同的服务)使用相同的域实体。

图 28 说明了将应用划分为构建模块的不同方法有很多,当然不仅仅是图中描述的四种方法。要点是,我们将模块切割得越精细,我们就越能控制它们之间的依赖关系。然而,我们切割得越精细,我们在这些模块之间要做的映射就越多,从而强制执行第 8 章“边界之间的映射”中介绍的映射策略之一。

除此之外,与使用简单包作为边界相比,使用构建模块来划分架构边界具有许多优点。

首先,构建工具绝对讨厌循环依赖。循环依赖是不好的,因为循环内一个模块的更改可能意味着循环内所有其他模块的更改,这违反了单一职责原则。构建工具不允许循环依赖,因为它们在尝试解决它们时会陷入无限循环。因此,我们可以确定构建模块之间不存在循环依赖关系。 另一方面,Java 编译器根本不关心两个或多个包之间是否存在循环依赖关系。

其次,构建模块允许在某些模块内进行独立的代码更改,而无需考虑其他模块。想象一下,我们必须在应用程序层进行重大重构,这会导致某个适配器出现临时编译错误。如果适配器和应用层位于同一构建模块中,则大多数 IDE 会坚持必须先修复适配器中的所有编译错误,然后才能在应用程序层中运行测试,即使测试不需要适配器来编译。但是,如果应用层位于自己的构建模块中,那么 IDE 目前不会关心适配器,我们可以随意运行应用层测试。使用 Maven 或 Gradle 运行构建过程也是如此:如果两个层位于同一构建模块中,则构建将由于任一层中的编译错误而失败。

因此,多个构建模块允许每个模块中的独立更改。我们甚至可以选择将每个模块放入自己的代码库中,允许不同的团队维护不同的模块。

最后,通过在构建脚本中显式声明每个模块间依赖关系,添加新的依赖关系成为有意识的行为,而不是意外。需要访问当前无法访问的某个类的开发人员希望在将依赖项添加到构建脚本之前考虑一下依赖项是否确实合理。

不过,这些优势伴随着维护构建脚本的额外成本,因此在将架构拆分为不同的构建模块之前,架构应该保持一定程度的稳定。

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

软件架构基本上都是关于管理架构元素之间的依赖关系。如果依赖关系变成了一个大泥球,那么架构就变成了一个大泥球。

因此,架构随着时间的推移,我们需要不断确保依赖项指向正确的方向。 在生成新代码或重构现有代码时,我们应该牢记包结构,并尽可能使用包私有可见性,以

避免依赖于不应从包外部访问的类。

如果我们需要在单个构建模块中强制实施架构边界,并且包私有修饰符不起作用,因为包结构不允许这样做,我们可以使用像 ArchUnit 这样的编译后工具。

只要我们觉得架构足够稳定,我们就应该将架构元素提取到它们自己的构建模块中,因为这可以对依赖关系进行明确的控制。

所有三种方法都可以组合起来以强化实施架构边界,从而保持代码库随着时间的推移可维护。