10.动手实践整洁架构 - 组装应用

131 阅读12分钟

现在我们已经实现了一些用例、Web 适配器和持久性适配器,我们需要将它们组装成一个可工作的应用程序。正如第 3 章“组织代码”中所讨论的,我们依靠依赖注入机制来实例化我们的类并在启动时将它们连接在一起。在本章中,我们将讨论如何使用普通 Java 以及 Spring 和 Spring Boot 框架来实现此目的的一些方法。

为什么还要关心装配?

为什么我们不只在需要的时间和地点实例化用例和适配器呢?因为我们希望保持代码依赖关系指向正确的方向。请记住:所有依赖项都应该向内指向我们应用的域代码,这样当外层中的某些内容发生更改时,域代码就不必更改。

如果一个用例需要调用持久性适配器并只是实例化它本身,那么我们就创建了错误方向的代码依赖项。 这就是我们创建输出端口接口的原因。用例只知道一个接口,并在运行时提供该接口的实现。

这种编程风格的一个很好的副作用是我们创建的代码具有更好的可测试性。如果我们可以将类所需的所有对象传递到其构造函数中,我们就可以选择传递模拟而不是真实对象,这使得为类创建隔离的单元测试变得容易。

那么,谁负责创建我们的对象实例?以及如何在不违反依赖规则的情况下做到这一点? 答案是,必须有一个对我们的架构中立的配置组件,并且它依赖于所有类,以便实例化它们,如图 26 所示。

在第 2 章“反转依赖关系”中介绍的“整洁架构”中,该配置组件将位于最外层,可以访问所有内层,如依赖关系规则所定义。

配置组件负责根据我们提供的部件组装一个可工作的应用程序。它必须

• 创建 Web 适配器实例,

• 确保 HTTP 请求实际路由到 Web 适配器,

• 创建用例实例

• 为 Web 适配器提供用例实例

• 创建持久性适配器实例

• 提供带有持久性适配器实例的用例

• 并确保持久性适配器实际上可以访问数据库

除此之外,配置组件应该能够访问某些配置参数源,例如配置文件或命令行参数。在应用程序组装期间,配置组件将这些参数传递给应用程序组件以控制行为,例如访问哪个数据库或使用哪个服务器发送电子邮件等。

这些是有很多责任,我们这里不是违反了单一职责原则吗?是的,但如果我们想保持应用程序的其余部分整洁,我们需要一个外部组件来处理接线。该组件必须知道所有部件才能将它们组装到工作应用程序中。

通过纯代码组装

有多种方法可以实现负责组装应用的配置组件。如果我们在没有依赖注入框架支持的情况下构建应用程序,我们可以使用纯代码创建这样的组件:

package copyeditor.configuration;

class Application {
    public static void main(String[] args) {
        AccountRepository accountRepository = new AccountRepository();
        ActivityRepository activityRepository = new ActivityRepository();

        AccountPersistenceAdapter accountPersistenceAdapter = new AccountPersistenceAdapter(accountRepository, activityRepository);

        SendMoneyUseCase sendMoneyUseCase = new SendMoneyUseService(
                    accountPersistenceAdapter, // LoadAccountPort
                    accountPersistenceAdapter); // UpdateAccountStatePort

        SendMoneyController sendMoneyController = new SendMoneyController(sendMoneyUseCase);
        startProcessingWebRequests(sendMoneyController);
    }
}

此代码片段是此类配置组件的简化示例。在Java中,应用程序是从main方法启动的。在此方法中,我们实例化所需的所有类(从 Web 控制器到持久性适配器),并将它们连接在一起。

最后,我们调用神秘方法 startProcessingWebRequests(),它通过 HTTP 公开 Web 控制器,然后应用程序就可以处理请求了。

这种纯代码方法是组装应用程序的最基本方法,然而,它也有一些缺点。

首先,上面的代码适用于只有一个 Web 控制器、用例和持久性适配器的应用程序。想象一下我们需要生成多少这样的代码才能引导一个成熟的企业应用程序!

其次,由于我们自己从包的外部实例化所有类,因此这些类都需要是公共的。例如,这意味着 Java 不会阻止用例直接访问持久性适配器,因为它是公共的。如果我们可以通过使用包私有可见性来避免不必要的依赖关系,那就太好了。

幸运的是,有依赖注入框架可以为我们做脏活,同时仍然维护包私有依赖关系。 Spring 框架是目前 Java 世界中最流行的框架。 Spring 还提供了 Web 和数据库支持以及许多其他功能,因此我们毕竟不必实现神秘的 startProcessingWebRequests() 方法。

通过 Spring 的类路径扫描进行组装

如果我们使用 Spring 框架来组装我们的应用程序,则结果称为“应用程序上下文”。应用程序上下文包含共同构成应用程序的所有对象(Java 术语中的“bean”)。

Spring 提供了多种组装应用程序上下文的方法,每种方法都有自己的优点和缺点。让我们首先讨论最流行(也是最方便)的方法:类路径扫描。

通过类路径扫描,Spring 会遍历类路径中可用的所有类,并搜索使用 @Component 注解进行注解的类。然后,框架从每个类创建一个对象。这些类应该有一个构造函数,它将所有必需的字段作为参数 , 就像第 6 章 “实现持久性适配器” 中的 AccountPersistenceAdapter 一样:

@Component
@RequiredArgsConstructor
class AccountPersistenceAdapter implements LoadAccountPort, UpdateAccountStatePort {
    private final AccountRepository accountRepository;
    private final ActivityRepository activityRepository;
    private final AccountMapper accountMapper;

    @Override
    public Account loadAccount(AccountId accountId, LocalDateTime baselineDate) {
        ...
    }

    @Override
    public void updateActivities(Account account) {
        ...
    }
}

在这种情况下,我们甚至没有自己编写构造函数,而是让 Lombok 库使用 @RequiredArgsConstructor 注解来为我们完成此操作,该注解创建一个将所有 final 字段作为参数的构造函数。

Spring 将找到此构造函数并搜索所需参数类型的 @Component 注解的类,并以类似的方式实例化它们以将它们添加到应用程序上下文中。一旦所有必须的对象都可用,它最终将调用 AccountPersistenceAdapter 的构造函数并将结果对象添加到应用程序上下文中。

类路径扫描是组装应用程序的一种非常方便的方法。我们只需在代码库中添加一些 @Component 注释并提供正确的构造函数即可。

我们还可以创建自己的构造型注解以供 Spring 使用。例如,我们可以创建一个 @PersistenceAdapter 注释:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface PersistenceAdapter {
@AliasFor(annotation = Component.class)
String value() default "";

}

该注解使用 @Component 进行元注解,让 Spring 知道应该在类路径扫描期间识别它。我们现在可以使用 @PersistenceAdapter 而不是 @Component 将持久适配器类标记为应用程序的一部分。通过这个注解,使我们的架构对于阅读代码的人来说更加明显。

然而,类路径扫描方法也有其缺点。首先,它是侵入性的,因为它要求我们为我们的类添加特定于框架的注释。如果您是整洁架构的强硬派,您会说这是禁止的,因为它将我们的代码绑定到特定的框架。

我想说,在通常的应用程序开发中,类上的单个注释并不是什么大问题,并且如果有必要的话可以轻松重构。

然而,在其他情况下,比如构建一个库或框架供其他开发人员使用时,这可能是不行的,因为我们不想让用户依赖 Spring 框架。

类路径扫描方法的另一个潜在缺点是可能会发生神奇的事情。我所说的“魔法”指的是那种糟糕的魔法,它会造成无法解释的影响,如果你不是 Spring 专家,可能需要几天的时间才能弄清楚。

神奇的事情之所以发生,是因为类路径扫描对于应用程序组装来说是一种非常生硬的武器。

我们只需将 Spring 指向应用程序的父包,并告诉它在该包中查找 @Component 注解的类。

您是否清楚应用程序中存在的每个类?可能不会。肯定会有一些我们实际上并不希望在应用程序上下文中拥有的类。也许这个类甚至以邪恶的方式操纵应用程序上下文,导致难以跟踪的错误。 让我们看看另一种方法,它可以给我们更多的控制权。

通过 Spring 的 Java 配置进行组装

类路径扫描是应用程序组装的大棒,而 Spring 的 Java Config 则是手术刀。这种方法类似于本章前面介绍的纯代码方法,但它不那么混乱,并且为我们提供了一个框架,这样我们就不必手动编写所有代码。

在这种方法中,我们创建配置类,每个配置类负责构造一组要添加到应用程序上下文的 bean。

例如,我们可以创建一个配置类来负责实例化所有持久性适配器:

@Configuration
@EnableJpaRepositories
class PersistenceAdapterConfiguration {
    @Bean
    AccountPersistenceAdapter accountPersistenceAdapter(AccountRepository accountRepository,
            ActivityRepository activityRepository, AccountMapper accountMapper) {
        return new AccountPersistenceAdapter(accountRepository, activityRepository, accountMapper);
    }

    @Bean
    AccountMapper accountMapper() {
        return new AccountMapper();
    }
}

@Configuration 注解将该类标记为配置类,由 Spring 的类路径扫描获取。因此,在这种情况下,我们仍然使用类路径扫描,但我们只选取配置类而不是每个 bean,这减少了邪恶魔法发生的机会。

bean 本身是在配置类的 @Bean 注释工厂方法中创建的。在上面的例子中,我们向应用程序上下文添加了一个持久性适配器。它需要两个存储库和一个映射器作为其构造函数的输入。 Spring 自动提供这些对象作为工厂方法的输入。

但是 Spring 从哪里获取存储库对象呢?如果它们是在另一个配置类的工厂方法中手动创建的,那么 Spring 会自动将它们作为参数提供给上面代码示例的工厂方法。然而,在本例中,它们是由 Spring 本身创建的,由 @EnableJpaRepositories 注释触发。如果Spring Boot找到这个注解,它会自动为我们定义的所有Spring Data存储库接口提供实现。

如果您熟悉 Spring Boot,您可能知道我们可以将注释 @EnableJpaRepositories 添加到主应用程序类,而不是我们的自定义配置类。是的,这是可能的,但每次应用程序启动时都会激活 JPA 存储库。即使我们在测试中启动应用程序,实际上也不需要持久性。因此,通过将此类“功能注释”移动到单独的配置“模块”,我们变得更加灵活,可以启动应用程序的部分内容,而不必总是启动整个应用程序。

通过 PersistenceAdapterConfiguration 类,我们创建了一个范围严格的持久性模块,用于实例化持久层中所需的所有对象。它会被 Spring 的类路径扫描自动拾取,同时我们仍然可以完全控制哪些 bean 实际添加到应用程序上下文中。

同样,我们可以为 Web 适配器或应用层中的某些模块创建配置类。我们现在可以创建一个包含某些模块的应用程序上下文,但模拟其他模块的 bean,这为我们的测试提供了极大的灵活性。我们甚至可以将每个模块的代码推送到自己的代码库、自己的包或自己的 JAR 文件中,而无需进行太多重构。

此外,这种方法不会强迫我们像类路径扫描方法那样在整个代码库中撒上 @Component 注释。因此,我们可以保持应用层干净,而不依赖于 Spring 框架(或任何其他框架)。

然而,这个解决方案有一个问题。如果配置类与其创建的 Bean 类(在本例中为持久性适配器类)不在同一包中,则这些类必须是公共的。为了限制可见性,我们可以使用包作为模块边界,并在每个包内创建专用的配置类。这样,我们就不能使用子包,正如第 10 章“强制架构边界”中将讨论的那样。

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

Spring 和 Spring Boot(以及类似的框架)提供了许多使我们的生活更轻松的功能。主要功能之一是将我们作为应用程序开发人员提供的部分(类)组装成应用程序。

类路径扫描是一个非常方便的功能。我们只需将 Spring 指向一个包,它就会根据它找到的类组装一个应用程序。这允许快速开发,我们不必考虑整个应用程序。

然而,一旦代码库增长,很快就会导致缺乏透明度。我们不知道到底哪些 bean 被加载到应用程序上下文中。此外,我们无法轻松启动应用程序上下文的隔离部分以在测试中使用。

通过创建一个负责组装应用程序的专用配置组件,我们可以将应用程序代码从这个责任中解放出来。我们获得了高度内聚的模块,我们可以彼此隔离地启动这些模块,并且可以在代码库中轻松移动。与往常一样,这是以花费一些额外时间来维护此配置组件为代价的。