7.动手实践整洁架构 - 实现持久性适配器

132 阅读12分钟

在第一章中,我对传统的分层架构进行了全面剖析,并声称它支持“数据库驱动设计”,因为最终一切都取决于持久层。在本章中,我们将了解如何使持久层成为应用层的插件以反转这种依赖性。

依赖倒置

我们将讨论为应用层服务提供持久功能的持久层适配器,而不是持久层。图 16 显示了我们如何应用依赖倒置原则来做到这一点。

我们的应用层服务调用端口接口来访问持久功能。这些端口由持久层适配器类实现,该适配器类执行实际的持久化工作并负责与数据库通信。

在六边形架构术语中,持久层适配器是“驱动”或“输出”适配器,因为它是由我们的应用层调用的,而不是相反。

这些端口实际上是应用层服务和持久层代码之间的间接层。让我们提醒自己,我们添加这一间接层是为了能够发展域代码,而不必考虑持久化问题,这意味着代码不依赖于持久层。持久化代码的重构不一定会导致核心代码的更改。

当然,在运行时我们仍然存在从应用层到持久层适配器的依赖关系。例如,如果我们修改持久层中的代码并引入错误,我们仍然可能会破坏应用层中的功能。但只要端口的合同得到履行,我们就可以在持久层适配器中自由地做我们想做的事,而不会影响核心。

持久层适配器的职责

让我们看一下持久层适配器通常做什么:

  1. 接受输入
  2. 将输入映射为数据库格式
  3. 将输入发送到数据库
  4. 将数据库输出映射为应用格式
  5. 返回输出

持久层适配器通过端口接口获取输入,输入模型可以是专用于特定数据库操作的领域实体对象,如接口所指定的。 然后,它将输入模型映射到可用于修改或查询数据库的格式。在 Java 项目中,我们通常

使用 Java Persistence API (JPA) 与数据库通信,因此我们可以将输入映射到反映数据库表结构的 JPA 实体对象。根据上下文,将输入模型映射到 JPA 实体可能会做大量工作却收效甚微,因此我们将在第 8 章“边界之间的映射”中讨论不进行映射的策略。

我们可以使用任何其他技术来与数据库对话,而不是使用 JPA 或其他对象关系映射框架。

我们可以将输入模型映射到纯 SQL 语句并将这些语句发送到数据库,或者我们可以将输入数据序列化到文件中并从那里读回它们。

重要的是持久层适配器的输入模型位于应用层核心内,而不是持久层适配器本身内,因此持久层适配器中的更改不会影响核心。

接下来,持久层适配器查询数据库并接收查询结果。

最后,它将数据库响应结果映射到端口期望的输出模型中并返回它。同样,重要的是输出模型位于应用层核心内而不是持久层适配器内。

除了输入和输出模型位于应用层核心而不是持久层适配器本身这一事实之外,其职责与传统持久层的职责并没有真正的不同。

但是,实现如上所述的持久层适配器将不可避免地提出一些我们在实现传统持久层时可能不会问的问题,因为我们已经习惯了传统的方式而不会考虑它们。

切片端口接口

实现服务时想到的一个问题是如何对定义应用层核心可用的数据库操作的端口接口进行切片。

通常的做法是创建一个单一存储库接口,为某个实体提供所有数据库操作,如图 17 所示。

每个依赖数据库操作的服务都将依赖于这个单一的“广泛”端口接口,即使它只使用该接口中的一个方法。这意味着我们的代码库中有不必要的依赖项。

对我们上下文中不需要的方法的依赖使代码更难理解和测试。想象一下,我们正在为上图中的 RegisterAccountService 编写单元测试。我们必须为 AccountRepository 接口的哪些方法创建模拟?我们必须首先找出该服务实际调用了哪些 AccountRepository 方法。仅模拟接口的一部分可能会导致其他问题,因为下一个从事该测试的人可能会期望接口被完全模拟并遇到错误。所以他或她必须再次做一些研究。

用马丁·C·罗伯特的话来说:

依赖那些承载着你不需要的包袱的东西可能会给你带来意想不到的麻烦。

接口隔离原则为这个问题提供了答案。它指出应该将广泛的接口分成特定的接口,以便客户端只知道他们需要的方法。

如果我们将其应用到输出端口,我们可能会得到如图 18 所示的结果。

现在每个服务只依赖于它实际需要的方法。更重要的是,端口的名称清楚地说明了它们的用途。在测试中,我们不再需要考虑要模拟哪些方法,因为大多数情况下每个端口只有一个方法。

像这样的非常窄的端口使得编码成为一种即插即用的体验。在开发服务时,我们只需“插入”我们需要的端口,没有附加的行李需要随身携带。

当然,“每个端口一个方法”的方法可能并不适用于所有情况。可能有一组数据库操作非常具有凝聚力并且经常一起使用,因此我们可能希望将它们捆绑在一个接口中。

切片持久性适配器

在上图中,我们看到了一个实现所有持久性端口的持久性适配器类。然而,只要实现了所有持久性端口,就没有规则禁止我们创建多个类。

例如,我们可以选择为每个需要持久性操作(或 DDD 术语中的“聚合”)的域类实现一个

持久性适配器,如图 19 所示。

这样,我们的持久性适配器就会自动沿着我们支持持久性功能的域的接缝进行切片。

我们可能会将持久性适配器拆分为更多类,例如,当我们想要使用 JPA 或另一个 OR-Mapper 实现几个持久性端口以及使用纯 SQL 实现一些其他端口以获得更好的性能时。然后,我们可以创建一个 JPA 适配器和一个普通 SQL 适配器,每个适配器都实现持久性端口的子集。

请记住,我们的域代码并不关心哪个类最终履行持久性端口定义的契约。只要实现所有端口,我们就可以在持久层中自由地做我们认为合适的事情。

“每个聚合一个持久性适配器”方法也是未来分离多个有界上下文的持久性需求的良好基础。比如说,一段时间后,我们确定了一个负责计费用例的有界上下文。图 20 给出了此场景的概述。

每个有界上下文都有自己的持久性适配器(或者可能不止一个,如上所述)。术语“有界上下文”意味着边界,这意味着账户上下文的服务不能访问计费上下文的持久性适配器,反之亦然。如果一个上下文需要另一个上下文的某些内容,它可以通过专用输入端口访问它。

Spring Data JPA 示例

让我们看一下实现上图中的 AccountPersistenceAdapter 的代码示例。该适配器必须在数据库中保存和加载账户。我们已经在第 4 章“实现用例”中看到了 Account 实体,但这里再次提供其骨架以供参考:

package buckpal.domain;

@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Account {
    @Getter private final AccountId id;
    @Getter private final ActivityWindow activityWindow;
    private final Money baselineBalance;
    
    public static Account withoutId(Money baselineBalance,ActivityWindow activityWindow) {
        return new Account(null, baselineBalance, activityWindow);
    }
    
    public static Account withId(AccountId accountId,Money baselineBalance,ActivityWindow activityWindow) {
        return new Account(accountId, baselineBalance, activityWindow);
    }
    
    public Money calculateBalance() {
        // ...
    }
    
    public boolean withdraw(Money money, AccountId targetAccountId) {
        // ...
    }
    
    public boolean deposit(Money money, AccountId sourceAccountId) {
        // ...
    }
}

请注意,Account 类不是一个具有 getter 和 setter 的简单数据类,而是尝试尽可能不可变。它只提供了在有效状态下创建账户的工厂方法,并且所有变异方法都会进行一些验证,例如在提款之前检查账户余额,以便我们无法创建无效的域模型。

我们将使用 Spring Data JPA 与数据库对话,因此我们还需要 @Entity 注解的类来表示账户的数据库状态:

package buckpal.adapter.persistence;

@Entity
@Table(name = "account")
@Data
@AllArgsConstructor
@NoArgsConstructor
class AccountJpaEntity {
    @Id
    @GeneratedValue
    private Long id;
}
package buckpal.adapter.persistence;

@Entity
@Table(name = "activity")
@Data
@AllArgsConstructor
@NoArgsConstructor
class ActivityJpaEntity {
    @Id
    @GeneratedValue
    private Long id;
    
    @Column private LocalDateTime timestamp;
    @Column private Long ownerAccountId;
    @Column private Long sourceAccountId;
    @Column private Long targetAccountId;
    @Column private Long amount;
}

在此阶段,账户的状态仅由 id 组成。稍后,可能会添加其他字段,例如用户 ID。更有趣的是 ActivityJpaEntity , 它包含特定账户的所有活动 。 我们可以通过 JPA @ManyToOne 或 @OneToMany 注释将 ActivitiyJpaEntity 与 AccountJpaEntity 连接起来,以标记它们之间的关系,但我们选择暂时忽略这一点,因为它会给数据库查询带来副作用。事实上,在这个阶段, 使用比 JPA 更简单的对象关系映射器来实现持久性适配器可能更容易,但我们无论如何都使用它,因为我们认为将来可能需要它。

接下来,我们使用 Spring Data 创建存储库接口,该接口提供开箱即用的基本 CRUD 功能以及自定义查询以从数据库加载某些活动:

interface AccountRepository extends JpaRepository<AccountJpaEntity, Long> {
}
interface ActivityRepository extends JpaRepository<ActivityJpaEntity, Long> {
    @Query("select a from ActivityJpaEntity a " +
    "where a.ownerAccountId = :ownerAccountId " +
    "and a.timestamp >= :since")
    List<ActivityJpaEntity> findByOwnerSince(
    @Param("ownerAccountId") Long ownerAccountId,
    @Param("since") LocalDateTime since);
    
    @Query("select sum(a.amount) from ActivityJpaEntity a " +
    "where a.targetAccountId = :accountId " +
    "and a.ownerAccountId = :accountId " +
    "and a.timestamp < :until")
    Long getDepositBalanceUntil(
    @Param("accountId") Long accountId,
    @Param("until") LocalDateTime until);
    
    @Query("select sum(a.amount) from ActivityJpaEntity a " +
    "where a.sourceAccountId = :accountId " +
    "and a.ownerAccountId = :accountId " +
    "and a.timestamp < :until")
    Long getWithdrawalBalanceUntil(
    @Param("accountId") Long accountId,
    @Param("until") LocalDateTime until);
}

Spring Boot 将自动找到这些存储库,而 Spring Data 将发挥其魔力,在存储库接口后面提供一个实际与数据库通信的实现。

有了 JPA 实体和存储库,我们就可以实现持久化适配器,为我们的应用程序提供持久化功能:

@RequiredArgsConstructor
@Component
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) {
        AccountJpaEntity account = accountRepository.findById(accountId.getValue()).orElseThrow(EntityNotFoundException::new);
        List<ActivityJpaEntity> activities = activityRepository.findByOwnerSince(accountId.getValue(),baselineDate);
        Long withdrawalBalance = orZero(activityRepository.getWithdrawalBalanceUntil(accountId.getValue(),baselineDate));
        Long depositBalance = orZero(activityRepository.getDepositBalanceUntil(accountId.getValue(),baselineDate));

        return accountMapper.mapToDomainEntity(
        account,
        activities,
        withdrawalBalance,
        depositBalance);
    }

    private Long orZero(Long value){
        return value == null ? L : value;
    }

    @Override
    public void updateActivities(Account account) {
        for (Activity activity : account.getActivityWindow().getActivities()) {
            if (activity.getId() == null) {
                activityRepository.save(accountMapper.mapToJpaEntity(activity));
            }
        }
    }
}

持久性适配器实现了应用程序所需的两个端口:LoadAccountPort 和 UpdateAccountStatePort。

要从数据库加载账户,我们从 AccountRepository 加载它,然后通过 ActivityRepository 加载该账户在某个时间窗口内的活动。

为了创建有效的 Account 域实体,我们还需要此活动窗口开始之前账户的余额,因此我们从数据库中获取该账户的所有取款和存款的总和。

最后,我们将所有这些数据映射到 Account 域实体并将其返回给调用者。 为了更新账户的状态,我们迭代账户实体的所有活动并检查它们是否有 ID。如果没有,它们就是新活动,我们通过 ActivityRepository 保留它们。

在上述场景中,我们在 Account 和 Activity 域模型与 AccountJpaEntity 和 ActivityJpaEntity 数据库模型之间有双向映射。为什么要费力来回映射呢?难道我们不能将 JPA 注释移至 Account 和 Activity 类并将它们直接存储在数据库中吗?

这种“无映射”策略可能是一个有效的选择,正如我们将在第 8 章“边界之间的映射”中讨论映射策略时看到的那样。然而,JPA 迫使我们在领域模型中做出妥协。例如,JPA 要求实体具有无参数构造函数。或者,在持久层中,从性能角度来看,@ManyToOne 关系可能是有意义的,但在域模型中,我们希望这种关系是相反的,因为我们总是只加载部分数据。

因此,如果我们想创建一个丰富的域模型而不影响底层持久性,我们就必须在域模型和持久性模型之间进行映射。

数据库事务怎么样?

我们还没有触及数据库事务的主题。我们把交易边界放在哪里?

事务应该涵盖在特定用例中执行的所有数据库写入操作,以便在其中一个操作失败时可以将所有这些操作一起回滚。

由于持久性适配器不知道哪些其他数据库操作是同一用例的一部分,因此它无法决定何时打开和关闭事务。我们必须将此责任委托给协调对持久性适配器的调用的服务。

使用 Java 和 Spring 执行此操作的最简单方法是将 @Transactional 注释添加到应用程序服务类,以便 Spring 将使用事务包装所有公共方法:

package buckpal.application.service;

@Transactional
public class SendMoneyService implements SendMoneyUseCase {
    ...
}

如果我们希望我们的服务保持纯粹并且不被 @Transactional 注释玷污,我们可以使用面向方面的编程(例如使用 AspectJ),以便将事务边界编织到我们的代码库中。

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

构建一个充当域代码插件的持久性适配器可以将域代码从持久性细节中解放出来,以便我们可以构建丰富的域模型。

使用窄端口接口,我们可以灵活地以这种方式实现一个端口,以这种方式实现另一个端口,甚至可能使用不同的持久性技术,而应用不会注意到。只要遵守端口契约,我们甚至可以切换整个持久层。