Wordz – 抽象数据库

在本节中,我们将把所学知识应用到我们的 Wordz 应用程序中,并创建一个适合获取单词以呈现给用户的端口。我们将在第 14 章《驱动数据库层》中编写适配器和集成测试。

设计仓储接口

在设计我们的端口时,第一项任务是确定它应该做什么。对于数据库端口,我们需要思考领域模型应该负责什么,以及我们将把什么推给数据库处理。我们用于数据库的端口通常称为 仓库接口(Repository Interfaces)。以下三个基本原则应指导我们:

  • 思考领域模型需要什么——为什么我们需要这些数据?它将用于什么?

  • 不要简单地反映假定的数据库实现——在这个阶段不要从表和主键的角度思考。这些内容稍后在我们决定如何实现存储时再考虑。有时,数据库性能的考虑意味着我们必须重新审视我们在这里创建的抽象。如果允许数据库更好地运行,我们可能会在这里泄漏一些数据库实现细节。我们应该尽可能推迟此类决策。

  • 考虑何时应更多地利用数据库引擎。也许我们打算在数据库引擎中使用复杂的存储过程。在仓库接口中反映这种行为的划分。这可能表明仓库接口中需要更高级别的抽象。

对于我们的示例应用程序,让我们考虑为用户随机获取一个单词进行猜测的任务。我们应该如何在领域模型和数据库之间划分工作?有两个主要选项:

  • 让数据库随机选择一个单词

  • 让领域模型生成一个随机数,并让数据库提供一个编号的单词

通常,让数据库做更多的工作会加快数据处理速度;数据库代码更接近数据,并且不会通过网络连接将数据拖入我们的领域模型。但我们如何说服数据库随机选择某些内容呢?我们知道,对于关系数据库,我们可以发出一个查询,该查询将以无保证的顺序返回结果。这有点随机。但它足够随机吗?在所有可能的实现中?似乎不太可能。

另一种方法是让领域模型代码通过生成一个随机数来决定选择哪个单词。然后,我们可以发出一个查询来获取与该数字关联的单词。这也意味着每个单词都有一个关联的数字——我们可以在稍后设计数据库模式时提供这一点。

这种方法意味着我们需要领域模型从与单词关联的所有数字中随机选择一个数字。这意味着领域模型需要知道所有可供选择的数字集合。我们可以在这里做出另一个设计决策。用于标识单词的数字将从 1 开始,每个单词递增 1。我们可以在端口上提供一个方法,返回这些数字的上限。然后,我们就可以定义该仓库接口——并通过测试来验证。

测试类从所需的包声明和库导入开始:

package com.wordz.domain;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.when;

我们通过 junit-jupiter 库提供的注解启用 Mockito 集成。我们在类级别添加注解:

@ExtendWith(MockitoExtension.class)
public class WordSelectionTest {

这将确保在每次测试运行时初始化 Mockito。测试的下一部分定义了一些整数常量以提高可读性:

private static final int HIGHEST_WORD_NUMBER = 3;
private static final int WORD_NUMBER_SHINE = 2;

我们需要两个测试替身,我们希望 Mockito 生成它们。我们需要一个用于单词仓库的存根和一个用于随机数生成器的存根。我们必须为这些存根添加字段。我们将使用 Mockito@Mock 注解标记这些字段,以便 Mockito 为我们生成替身:

@Mock
private WordRepository repository;
@Mock
private RandomNumbers random;

当我们使用 @Mock 注解时,Mockito 不会区分 mockstub。它只是创建一个测试替身,可以配置为 mockstub 使用。这将在稍后的测试代码中完成。

我们将测试方法命名为 selectsWordAtRandom()。我们希望驱动出一个名为 WordSelection 的类,并使其负责从 WordRepository 中随机选择一个单词:

@Test
void selectsWordAtRandom() {
    when(repository.highestWordNumber())
        .thenReturn(HIGHEST_WORD_NUMBER);

    when(repository.fetchWordByNumber(WORD_NUMBER_SHINE))
        .thenReturn("SHINE");

    when(random.next(HIGHEST_WORD_NUMBER))
        .thenReturn(WORD_NUMBER_SHINE);

    var selector = new WordSelection(repository, random);

    String actual = selector.chooseRandomWord();

    assertThat(actual).isEqualTo("SHINE");
}

前面的测试以正常方式编写,添加行以捕获每个设计决策:

  • WordSelection 类封装了选择要猜测的单词的算法。

  • WordSelection 构造函数接受两个依赖项:

  • WordRepository 是存储单词的端口。

  • RandomNumbers 是随机数生成的端口。

  • chooseRandomWord() 方法将返回一个随机选择的单词作为 String

  • arrange 部分被移到 beforeEachTest() 方法中:

@BeforeEach
void beforeEachTest() {
    when(repository.highestWordNumber())
        .thenReturn(HIGHEST_WORD_NUMBER);

    when(repository.fetchWordByNumber(WORD_NUMBER_SHINE))
        .thenReturn("SHINE");
}

这将在每次测试开始时为我们的 WordRepository 存根设置测试数据。编号为2的单词被定义为 SHINE,因此我们可以在断言中检查这一点。

从测试代码中流出了以下两个接口方法的定义:

package com.wordz.domain;

public interface WordRepository {
    String fetchWordByNumber(int number);
    int highestWordNumber();
}

WordRepository 接口定义了我们的应用程序对数据库的看法。我们当前只需要两个功能:

  • fetchWordByNumber() 方法:根据给定的编号获取单词。

  • highestWordNumber() 方法:返回最高单词编号。

测试还驱动出了我们随机数生成器所需的接口:

package com.wordz.domain;

public interface RandomNumbers {
    int next(int upperBoundInclusive);
}

next() 方法返回一个在 1 到 upperBoundInclusive 范围内的整数。

在定义了测试和端口接口后,我们可以编写领域模型代码:

package com.wordz.domain;
public class WordSelection {
    private final WordRepository repository;
    private final RandomNumbers random;

    public WordSelection(WordRepository repository, RandomNumbers random) {
        this.repository = repository;
        this.random = random;
    }

    public String chooseRandomWord() {
        int wordNumber = random.next(repository.highestWordNumber());
        return repository.fetchWordByNumber(wordNumber);
    }
}

请注意,此代码没有从 com.wordz.domain 包之外导入任何内容。它是纯粹的应用程序逻辑,仅依赖于端口接口来访问存储的单词和随机数。至此,我们的 WordSelection 领域模型的生产代码已完成。

设计数据库和随机数适配器

接下来的任务是实现 RandomNumbers 端口和实现 WordRepository 接口的数据库访问代码。概括来说,我们将选择一个数据库产品,研究如何连接它并运行数据库查询,然后使用集成测试来测试驱动该代码。我们将推迟这些任务到本书的第三部分,即第 13 章《驱动领域层》和第 14 章《驱动数据库层》。