5.动手实践整洁架构 - 实现用例

114 阅读18分钟

最后让我们看看如何在实际代码中实现我们所讨论的架构。

由于应用层、Web 层和持久层在我们的架构中耦合度非常松散,因此我们可以完全自由地按照我们认为合适的方式对域代码进行建模。我们可以做 DDD,我们可以实现丰富或贫乏的领域模型,或者发明我们自己的做事方式。

本章描述了在我们在前面的章节中介绍的六边形架构风格中实现用例的一种固执己见的方法。 为了适合以领域为中心的架构,我们将从领域实体开始,然后围绕它构建一个用例。

实现领域模型

我们想要实现从一个账户向另一个账户汇款的用例。以面向对象的方式对此进行建模的一种方法是创建一个允许取款和存款的 Account 实体,以便我们可以从源账户取款并将其存入目标账户:

package buckpal.domain;

public class Account {
    private AccountId id;
    private Money baselineBalance;
    private ActivityWindow activityWindow;

    // constructors and getters omitted
    
    public Money calculateBalance() {
        return Money.add(this.baselineBalance,this.activityWindow.calculateBalance(this.id));
    }

    public boolean withdraw(Money money, AccountId targetAccountId) {
        if (!mayWithdraw(money)) {
            return false;

        }

        Activity withdrawal = new Activity(
                this.id,
                this.id,
                targetAccountId,
                LocalDateTime.now(),
                money);

        this.activityWindow.addActivity(withdrawal);
        return true;
    }

    private boolean mayWithdraw(Money money) {
        return Money.add(this.calculateBalance(), money.negate()).isPositive();
    }

    public boolean deposit(Money money, AccountId sourceAccountId) {
        Activity deposit = new Activity(
                this.id,
                sourceAccountId,
                this.id,
                LocalDateTime.now(),
                money);

        this.activityWindow.addActivity(deposit);
        return true;
    }
}

账户实体提供实际账户的当前快照。账户的每次提款和存款都记录在 Activity 实体中。由于始终将账户的所有活动加载到内存中并不明智,因此 Account 实体仅保存在 ActivityWindow 值对象中捕获的最近几天或几周的活动窗口。

为了仍然能够计算当前账户余额,账户实体还具有属性 baselineBalance,表示账户在活动窗口的第一个活动之前的余额。总余额是基准余额加上窗口中所有活动的余额。

在这种模式下,向账户提款和存钱只需向活动窗口添加一个新的活动即可,就像在withdraw() 和deposit() 方法中所做的那样。在提款之前,我们会检查业务规则,规定我们不能透支账户。

现在我们有了一个可以取款和存钱的账户,我们可以围绕它构建一个用例。

一个简单的用例

首先,让我们讨论一下用例的实际用途。

通常,它遵循以下步骤:

  1. 接受输入

  2. 验证业务规则

  3. 操纵模型状态

  4. 返回输出

用例从输入适配器获取输入,您可能想知道为什么我没有将此步骤称为“验证输入”。答案是,我认为用例代码应该关心域逻辑,我们不应该通过输入验证来污染它。因此,我们将在其他地方进行输入验证,我们很快就会看到。

然而,用例负责验证业务规则。它与域实体分担此责任。我们将在本章后面讨论输入验证和业务规则验证之间的区别。

如果满足业务规则,用例就会根据输入以一种或另一种方式操纵模型的状态。通常,它会更改域对象的状态并将此新状态传递到持久层适配器实现的端口以进行持久保存。不过,用例也可能调用任何其他输出适配器。

最后一步是将输出适配器的返回值转换为输出对象,该对象将返回到调用适配器。 考虑到这些步骤,让我们看看如何实现“汇款”用例。

为了避免第 1 章“层有什么问题?”中讨论的广泛服务问题,我们将为每个用例创建一个单独的服务类,而不是将所有用例放入单个服务类中。

这是一个预告片:

package buckpal.application.service;

@RequiredArgsConstructor
@Transactional
public class SendMoneyService implements SendMoneyUseCase {
    private final LoadAccountPort loadAccountPort;
    private final AccountLock accountLock;
    private final UpdateAccountStatePort updateAccountStatePort;

    @Override
    public boolean sendMoney(SendMoneyCommand command) {
        // TODO: validate business rules

        // TODO: manipulate model state

        // TODO: return output
    }
}

该服务实现输入端口接口 SendMoneyUseCase 并调用输出端口接口 LoadAccountPort 来加载账户,并调用端口 UpdateAccountStatePort 将更新的账户状态保留在数据库中。

图 11 给出了相关组件的图形概述。

让我们处理上面代码中留下的 // TODO。

验证输入

现在我们正在讨论验证输入,尽管我刚刚声称这不是用例类的责任。但我仍然认为它属于应用层,所以这里是讨论它的地方。

为什么不让调用适配器在将输入发送到用例之前验证输入?那么,我们是否希望相信调用者已经验证了用例所需的所有内容?此外,用例可能由多个适配器调用,因此验证必须由每个适配器实现,并且可能会出错或完全忘记。

应用层应该关心输入验证,否则它可能会从应用层核心外部获得无效输入。这可能会对我们模型的状态造成损害。

但是,如果不在用例类中,则应将输入验证放在哪里?

我们会让输入模型来处理它。对于“Send Money”用例,输入模型是我们在前面的代码示例中已经看到的 SendMoneyCommand 类。更准确地说,我们将在构造函数中完成它:

package buckpal.application.port.in;

@Getter
public class SendMoneyCommand {
    private final AccountId sourceAccountId;
    private final AccountId targetAccountId;
    private final Money money;

    public SendMoneyCommand(AccountId sourceAccountId,AccountId targetAccountId,Money money) {
        this.sourceAccountId = sourceAccountId;
        this.targetAccountId = targetAccountId;
        this.money = money;

        requireNonNull(sourceAccountId);
        requireNonNull(targetAccountId);
        requireNonNull(money);
        requireGreaterThan(money, 0);
    }
}

为了汇款,我们需要源账户和目标账户的 ID 以及要转账的金额。所有参数都不能为空,并且金额必须大于零。

如果违反了这些条件中的任何一个,我们只需在构造过程中抛出异常来拒绝对象创建。 通过将 SendMoneyCommand 的字段设置为最终字段,我们可以有效地使其不可变。因此,一旦构造成功,我们就可以确定状态是有效的并且不能更改为无效的状态。

由于 SendMoneyCommand 是用例 API 的一部分,因此它位于输入端口包中。因此,验证保留在应用程序的核心(在我们的六边形架构的六边形内),但不会污染神圣的用例代码。

但是,当有工具可以为我们做肮脏的工作时,我们真的想手动实现每个验证检查吗?在 Java 世界中,此类工作的事实上的标准是 Bean Validation API。它允许我们将所需的验证规则表达为类字段上的注解:

package buckpal.application.port.in;

@Getter
public class SendMoneyCommand extends SelfValidating<SendMoneyCommand> {
    @NotNull
    private final AccountId sourceAccountId;

    @NotNull
    private final AccountId targetAccountId;

    @NotNull
    private final Money money;

    public SendMoneyCommand(AccountId sourceAccountId, AccountId targetAccountId, Money money) {
        this.sourceAccountId = sourceAccountId;
        this.targetAccountId = targetAccountId;
        this.money = money;
        requireGreaterThan(money, 0);
        this.validateSelf();
    }
}

抽象类 SelfValidating 提供了 validateSelf() 方法,我们只需将其作为构造函数中的最后一条语句进行调用即可。这将评估字段上的 Bean Validation 注解(在本例中为 @NotNull),并在发生违规时抛出异常。如果 Bean 验证对于某种验证来说表达力不够,我们仍然可以手动实现它,就像我们检查金额是否大于零所做的那样。

SelfValidating 类的实现可能如下所示:

package shared;

public abstract class SelfValidating<T> {
    private Validator validator;

    public SelfValidating() {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        validator = factory.getValidator();

    }

    protected void validateSelf() {
        Set<ConstraintViolation<T>> violations = validator.validate((T) this);

        if (!violations.isEmpty()) {
            throw new ConstraintViolationException(violations);
        }
    }

}

通过输入模型中的验证,我们有效地围绕用例实现创建了一个反腐败层。这不是分层架构意义上的一层,调用下面的下一层,而是我们的用例周围的一个薄薄的保护屏障,它将错误的输入返回给调用者。

构建者的力量

我们上面的输入模型 SendMoneyCommand 将很多责任交给了它的构造函数。由于类是不可变的,构造函数的参数列表包含类的每个属性的参数。由于构造函数还会验证参数,因此不可能创建具有无效状态的对象。

在我们的例子中,构造函数只有三个参数。如果我们更多参数怎么办?难道我们不能使用Builder模式来让它使用起来更方便吗?我们可以将具有长参数列表的构造函数设为私有,并在构建器的 build() 方法中隐藏对其的调用。然后,我们可以构建一个像这样的对象,而不必调用具有 20 个参数的构造函数:

new SendMoneyCommandBuilder()
.sourceAccountId(new AccountId(41L))
.targetAccountId(new AccountId(42L))
// ... initialize many other fields
.build();

我们仍然可以让构造函数进行验证,以便构造函数无法构造具有无效状态的对象。 听起来不错?想想如果我们必须向 SendMoneyCommandBuilder 添加另一个字段会发生什么(这在软件项目的生命周期中会发生很多次)。我们将新字段添加到构造函数和构建器中。然后,一位同事(或者一个电话、一封电子邮件、一只蝴蝶……)打断了我们的思路。休息后,我们返回编码,忘记将新字段添加到调用构建器的代码中。

我们没有收到编译器关于尝试创建处于无效状态的不可变对象的任何警告!当然,在运行时 - 希望在单元测试中 - 我们的验证逻辑仍然会启动并抛出错误,因为我们错过了一个参数。

但是,如果我们直接使用构造函数而不是将其隐藏在构建器后面,则每次添加新字段或删除现有字段时,我们只需跟踪编译错误的踪迹即可反映代码库其余部分的更改。

长参数列表甚至可以很好地格式化,并且好的 IDE 可以帮助提供参数名称提示:

那么为什么不让编译器来指导我们呢?

不同用例使用不同输入模型

我们可能会想对不同的用例使用相同的输入模型。让我们考虑用例“注册账户”和“更新账户详细信息”。两者最初都需要几乎相同的输入,即一些账户详细信息,例如账户描述。

不同之处在于,“更新账户详细信息”用例还需要账户 ID 才能更新该特定账户。 “注册账户”用例可能需要 ID 所有者,以便它可以将其分配给他或她。因此,如果我们在两个用例之间共享相同的输入模型,则必须允许将空账户 ID 传递到“更新账户详细信息”用例,并将空所有者 ID 传递到“注册账户”用例。

允许 null 作为不可变命令对象中字段的有效状态本身就是一种代码味道。但更重要的是,我们现在如何处理输入验证?对于注册和更新用例,验证必须有所不同,因为每个用例都需要一个 id,而另一个则不需要。我们必须将自定义验证逻辑构建到用例本身中,从而因输入验证问题而污染我们神圣的业务代码。

另外,如果在“注册账户”用例中账户 ID 字段意外出现非空值,我们该怎么办?我们会抛出错误吗?我们就简单地忽略它吗?这些是维护工程师——包括未来的我们——在看到代码时会问的问题。

每个用例的专用输入模型使用例更加清晰,并将其与其他用例解耦,防止不必要的副作用。

然而,它是有代价的,因为我们必须将传入数据映射到不同用例的不同输入模型中。我们将在第 8 章“边界之间的映射”中讨论此映射策略以及其他映射策略。

验证业务规则

虽然验证输入不是用例逻辑的一部分,但验证业务规则肯定是。业务规则是应用程序的核心,应谨慎处理。但是我们什么时候处理输入验证以及什么时候处理业务规则呢?

两者之间的一个非常务实的区别是,验证业务规则需要访问域模型的当前状态,而验证输入则不需要。输入验证可以以声明方式实现,就像我们对上面的 @NotNull 注解所做的那样,而业务规则需要更多上下文。

我们也可以说输入验证是语法验证,而业务规则是用例上下文中的语义验证。

我们以“源账户不得透支”为规则。根据上面的定义,这是一个业务规则,因为它需要访问模型的当前状态来检查源账户和目标账户是否确实存在。

相反,规则“转账金额必须大于零”可以在不访问模型的情况下进行验证,因此可以作为输入验证的一部分来实现。

我知道这种区别可能会引起争议。您可能会争辩说,转账金额非常重要,因此在任何情况下都应将其验证视为业务规则。

然而,上述区别有助于我们在代码库中放置某些验证,并在以后轻松地再次找到它们。这就像回答验证是否需要访问当前模型状态的问题一样简单。这不仅有助于我们首先执行该规则,而且还有助于将来的维护工程师再次找到它。

那么,我们如何实施业务规则呢?

最好的方法是将业务规则放入域实体中,就像我们对规则“源账户不得透支”所做的那样:

package buckpal.domain;

public class Account {
    // ...
    public boolean withdraw(Money money, AccountId targetAccountId) {
        if (!mayWithdraw(money)) {
        return false;
        }
        // ...
    }
}

这样,业务规则就很容易定位和推理,因为它就位于需要遵守该规则的业务逻辑旁边。 如果在域实体中验证业务规则不可行,我们可以在开始处理域实体之前在用例代码中简单地执行此操作:

package buckpal.application.service;

@RequiredArgsConstructor
@Transactional
public class SendMoneyService implements SendMoneyUseCase {
    // ...
    @Override
    public boolean sendMoney(SendMoneyCommand command) {
        requireAccountExists(command.getSourceAccountId());
        requireAccountExists(command.getTargetAccountId());
        ...
    }
}

我们只需调用一个方法来进行实际验证,并在验证失败时抛出专用异常。然后,与用户交互的适配器可以将此异常作为错误消息显示给用户,或者以任何其他看起来合适的方式处理它。 在上面的情况下,验证只是检查源账户和目标账户是否确实存在于数据库中。更复杂的业务规则可能需要我们首先从数据库加载域模型,然后对其状态进行一些检查。如果我们无论如何都必须加载域模型,我们应该在域实体本身中实现业务规则,就像我们对上面的“源账户不得透支”规则所做的那样。

“充血”域模型与“贫血”域模型

我们的架构风格对于如何实现我们的领域模型留有余地。这是一种祝福,因为我们可以做在我们的背景下看起来正确的事情,也是一种诅咒,因为我们没有任何指导方针来帮助我们。

一个经常讨论的问题是是否要实现遵循 DDD 哲学的“充血”域模型或“贫血”域模型。我不会偏爱两者中的任何一个,但让我们讨论一下它们如何适合我们的架构。

在“充血”域模型中,尽可能多的域逻辑是在应用程序核心的实体中实现的。这些实体提供了更改状态的方法,并且仅允许根据业务规则进行有效的更改。这就是我们对上面的账户实体所追求的方式。在这个场景中我们的用例实现在哪里?

在这种情况下,我们的用例充当领域模型的入口点。用例仅代表用户的意图,并将其转换为对执行实际工作的域实体的编排方法调用。许多业务规则位于实体中而不是用例实现中。

“汇款”用例服务将加载源账户实体和目标账户实体,调用它们的 withdraw() 和 Deposit() 方法,并将它们发送回数据库。

在“贫血”域模型中,实体本身非常薄弱。它们通常只提供保存状态的字段以及读取和更改状态的 getter 和 setter 方法。它们不包含任何领域逻辑。

这意味着域逻辑是在用例类中实现的。它们负责验证业务规则、更改实体的状态并将它们传递到负责将它们存储在数据库中的输出端口。 “充血性”包含在用例中而不是实体中。

这两种风格以及任意数量的其他风格都可以使用本书中讨论的架构方法来实现。请随意选择适合您需求的一款。

针对不同用例的不同输出模型

一旦用例完成其工作,它应该返回给调用者什么?

与输入类似,如果输出尽可能特定于用例,那么它会带来好处。输出应该只包含调用者工作真正需要的数据。

在上面“汇款”用例的示例代码中,我们返回一个布尔值。这是我们在这种情况下可能返回的最小且最具体的值。

我们可能会想将包含更新实体的完整账户返回给调用者。也许调用者对账户的新余额感兴趣?

但我们真的想让“汇款”用例返回这些数据吗?调用者真的需要它吗?如果是这样,我们是否应该创建一个专用用例来供不同调用者使用的数据?

这些问题没有正确答案。但我们应该要求他们尽量让我们的用例尽可能具体。如有疑问,请尽可能少地返回。

在用例之间共享相同的输出模型也往往会紧密耦合这些用例。如果其中一个用例需要在输出模型中添加一个新字段,则其他用例也必须处理该字段,即使它与它们无关。从长远来看,由于多种原因,共享模型往往会像肿瘤一样生长。应用单一职责原则并保持模型分离有助于解耦用例。

出于同样的原因,我们可能希望抵制使用领域实体作为输出模型的诱惑。我们不希望我们的域实体因不必要的原因而发生更改。然而,我们将在第 11 章“有意识地走捷径”中更多地讨论使用实体作为输入或输出模型。

只读用例怎么样?

上面,我们讨论了如何实现修改模型状态的用例。我们如何实施只读用例? 假设 UI 需要显示账户的余额。我们是否为此创建一个特定的用例实现?

谈论像这样的只读操作的用例是很尴尬的。当然,在 UI 中,需要请求的数据来实现我们称为“查看账户余额”的特定用例。如果这被认为是项目上下文中的用例,那么我们无论如何应该像其他用例一样实现它。

然而,从应用程序核心的角度来看,这是一个简单的数据查询。因此,如果它在项目上下文中不被视为用例,我们可以将其实现为查询,以将其与真实用例区分开来。

在我们的架构风格中执行此操作的一种方法是为查询创建专用输入端口并在“query service”中实现它:

package buckpal.application.service;

@RequiredArgsConstructor
class GetAccountBalanceService implements GetAccountBalanceQuery {
    private final LoadAccountPort loadAccountPort;
    
    @Override
    public Money getAccountBalance(AccountId accountId) {
        return loadAccountPort.loadAccount(accountId, LocalDateTime.now()).calculateBalance();
    }
}

查询服务的行为就像我们的用例服务一样。它实现了一个名为 GetAccountBalanceQuery 的输入端口,并调用输出端口 LoadAccountPort 来实际从数据库加载数据。

这样,只读查询就可以与代码库中修改用例(或“命令”)清楚地区分开来。这与命令-查询分离 (CQS) 和命令-查询责任分离 (CQRS) 等概念很好地配合。

在上面的代码中,除了将查询传递到输出端口之外,服务实际上并不执行任何工作。如果我们跨层使用相同的模型,我们可以走捷径,让客户端直接调用输出端口。我们将在第 11 章“有意识地走捷径”中讨论这条捷径。

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

我们的架构允许我们按照我们认为合适的方式实现领域逻辑,但是如果我们独立地对用例的输入和输出进行建模,我们就可以避免不必要的副作用。

是的,这不仅仅是在用例之间共享模型需要更多的工作。我们必须为每个用例引入一个单独的模型,并在该模型和我们的实体之间进行映射。

但特定于用例的模型可以让您清晰地理解用例,从长远来看更容易维护。此外,它们还允许多个开发人员并行处理不同的用例,而不会互相干扰。

与严格的输入验证相结合,特定于用例的输入和输出模型对实现可维护的代码库大有帮助。