真实世界的软件开发-二-

90 阅读1小时+

真实世界的软件开发(二)

原文:zh.annas-archive.org/md5/9fe6488c1d46ccf6de3ab02ce7d234fc

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:业务规则引擎

挑战

您的业务现在非常顺利。事实上,您现在已经扩展到一个拥有成千上万名员工的组织。这意味着您已经雇佣了许多人来从事不同的业务职能:市场营销、销售、运营、管理、会计等等。您意识到所有业务职能都需要创建根据某些条件触发操作的规则;例如,“如果潜在客户的职位是'CEO',则通知销售团队”。您可以要求技术团队为每个新需求实现定制软件,但是您的开发人员正在忙于其他产品。为了鼓励业务团队和技术团队之间的协作,您决定开发一个业务规则引擎,这将使开发人员和业务团队能够共同编写代码。这将使您能够提高生产力并减少实施新规则所需的时间,因为您的业务团队将能够直接做出贡献。

目标

在本章中,您将首先学习如何使用测试驱动开发方法来解决新设计问题。您将了解到一个称为模拟的技术概述,这将有助于指定单元测试。然后,您将学习一些 Java 中的现代特性:局部变量类型推断和 switch 表达式。最后,您将学习如何使用建造者模式和接口隔离原则开发友好的 API。

注意

如果您想随时查看本章的源代码,可以查看书籍代码库中的com.iteratrlearning.shu_book.chapter_05包。

业务规则引擎需求

在开始之前,让我们考虑一下您想要实现的目标。您希望使非程序员能够在其自己的工作流程中添加或更改业务逻辑。例如,市场营销执行人员可能希望在潜在客户询问您的产品并符合某些条件时提供特别折扣。会计主管可能希望在支出异常高时创建警报。这些都是业务规则引擎可以实现的例子。它实质上是执行一个或多个业务规则的软件,通常使用简单的定制语言声明这些规则。业务规则引擎可以支持多个不同的组件:

事实

规则可以访问的可用信息

行动

您要执行的操作

条件

这些指定何时触发操作

规则

这些指定您希望执行的业务逻辑,实质上是将事实、条件和操作分组在一起

业务规则引擎的主要生产力优势在于它使规则能够在一个地方进行维护、执行和测试,而无需与主应用程序集成。

注意

有许多成熟的 Java 业务规则引擎,比如Drools。通常这样的引擎符合诸如决策建模和标记(DMN)的标准,并配备一个集中的规则库,一个使用图形用户界面(GUI)的编辑器和可视化工具,以帮助维护复杂的规则。在本章中,您将开发一个业务规则引擎的最小可行产品,并对其进行迭代,以改进其功能和可访问性。

测试驱动开发

从哪里开始?需求并不是一成不变的,预计会不断演变,因此您开始时只需列出用户需要完成的基本功能即可:

  • 添加一个动作

  • 运行动作

  • 基本报告

这在示例 5-1 中的基本 API 中有所体现。每个方法抛出UnsupportedOperationException,表示它尚未实现。

示例 5-1. 业务规则引擎的基本 API
public class BusinessRuleEngine {

    public void addAction(final Action action) {
        throw new UnsupportedOperationException();
    }

    public int count() {
        throw new UnsupportedOperationException();
    }

    public void run() {
        throw new UnsupportedOperationException();
    }

}

动作简单地是将要执行的代码片段。我们可以使用Runnable接口,但引入一个单独的接口Action更能代表手头的领域。Action接口将允许业务规则引擎与具体动作解耦。由于Action接口只声明了一个抽象方法,我们可以将其注释为功能接口,如示例 5-2 所示。

示例 5-2. 动作接口
@FunctionalInterface
public interface Action {
   void execute();
}

接下来怎么办?现在是时候真正写些代码了——实现在哪里?你将使用一种称为测试驱动开发(TDD)的方法。TDD 的哲学是首先编写一些测试,这些测试将指导你编写代码的实现。换句话说,你先写测试,再写实现。这有点像迄今为止你所做的相反:你先为一个需求写了完整的代码,然后测试它。现在你会更多地关注测试。

为什么使用 TDD?

为什么要采用这种方法?有几个好处:

  • 逐个编写测试将帮助您专注并完善需求,通过逐个正确实现一件事情来实现。

  • 这是确保代码有关联组织的一种方式。例如,通过先写测试,你需要仔细考虑代码的公共接口。

  • 随着你按需求迭代,构建全面的测试套件,这既增加了你符合需求的信心,也减少了 bug 的范围。

  • 你不会写不需要的代码(过度工程),因为你只是写通过测试的代码。

TDD 循环

TDD 方法大致包括以下循环步骤,如图 5-1 所示:

  1. 写一个失败的测试

  2. 运行所有测试

  3. 使实现生效

  4. 运行所有测试

TDD 循环

图 5-1. TDD 循环

在实践中,作为这个过程的一部分,你必须持续重构你的代码,否则它将变得难以维护。此时,当你引入变更时,你知道你有一套可以依赖的测试套件。图 5-2 展示了这一改进的 TDD 过程。

TDD 循环与重构

图 5-2. TDD 与重构

在 TDD 的精神下,让我们首先编写我们的第一个测试来验证addActionscount的行为是否正确,如示例 5-3 所示。

示例 5-3. 业务规则引擎的基本测试
@Test
void shouldHaveNoRulesInitially() {
    final BusinessRuleEngine businessRuleEngine = new BusinessRuleEngine();

    assertEquals(0, businessRuleEngine.count());
}

@Test
void shouldAddTwoActions() {
    final BusinessRuleEngine businessRuleEngine = new BusinessRuleEngine();

    businessRuleEngine.addAction(() -> {});
    businessRuleEngine.addAction(() -> {});

    assertEquals(2, businessRuleEngine.count());
}

在运行测试时,你会看到它们失败,并显示UnsupportedOperationException,如图 5-3 所示。

失败的测试

图 5-3. 失败的测试

所有测试都失败了,但没关系。这给了我们一个可重现的测试套件,将指导代码的实现。现在可以添加一些实现代码,如示例 5-4 所示。

示例 5-4. 业务规则引擎的基本实现
public class BusinessRuleEngine {

    private final List<Action> actions;

    public BusinessRuleEngine() {
        this.actions = new ArrayList<>();
    }

    public void addAction(final Action action) {
        this.actions.add(action);
    }

    public int count() {
        return this.actions.size();
    }

    public void run(){
        throw new UnsupportedOperationException();
    }
}

现在你可以重新运行测试,它们通过了!但是,还有一个关键操作缺失。我们如何为run方法编写测试?不幸的是,run()不返回任何结果。我们将需要一种称为模拟的新技术,以验证run()方法的正确操作。

模拟

模拟是一种技术,它允许你验证当执行run()方法时,业务规则引擎中添加的每个动作是否确实被执行。目前很难做到这一点,因为BusinessRuleEngine中的run()方法和Action中的perform()方法都返回void。我们无法编写断言!模拟在第六章中有详细介绍,但现在你将会得到一个简要概述,这样你就能继续编写测试了。你将使用 Mockito,这是一个流行的 Java 模拟库。在其最简单的形式下,你可以做两件事情:

  1. 创建一个模拟对象。

  2. 验证方法是否被调用。

那么,如何开始呢?你需要首先导入这个库:

import static org.mockito.Mockito.*;

这个导入允许你使用mock()verify()方法。静态方法mock()允许你创建一个模拟对象,然后你可以验证某些行为是否发生。方法verify()允许你设置断言,即特定方法是否被调用。示例 5-5 展示了一个例子。

示例 5-5. 模拟并验证与Action对象的交互
@Test
void shouldExecuteOneAction() {
        final BusinessRuleEngine businessRuleEngine = new BusinessRuleEngine();
        final Action mockAction = mock(Action.class);

        businessRuleEngine.addAction(mockAction);
        businessRuleEngine.run();

        verify(mockAction).perform();
}

单元测试为Action创建了一个模拟对象。这通过将类作为参数传递给mock方法来实现。接下来是测试的when部分,在这里你调用行为。这里我们添加了动作并执行了run()方法。最后,单元测试的then部分设置了断言。在这种情况下,我们验证了Action对象上的perform()方法是否被调用。

如果你运行这个测试,正如预期的那样会失败,并显示 UnsupportedOperationException。如果 run() 方法体为空会发生什么?你将收到新的异常跟踪:

Wanted but not invoked:
action.perform();
-> at BusinessRuleEngineTest.shouldExecuteOneAction(BusinessRuleEngineTest.java:35)
Actually, there were zero interactions with this mock.

这个错误来自 Mockito,并告诉你 perform() 方法从未被调用。现在是时候为 run() 方法编写正确的实现了,如 示例 5-6 所示。

示例 5-6. run() 方法的实现
public void run() {
    this.actions.forEach(Action::perform);
}

重新运行测试,你会看到测试通过了。Mockito 能够验证当业务规则引擎运行时,Action 对象的 perform() 方法是否被调用。Mockito 允许你指定复杂的验证逻辑,比如方法应该被调用多少次,带有特定参数等。你将在 第六章 中了解更多相关信息。

添加条件

你必须承认,到目前为止,业务规则引擎的功能相当有限。你只能声明简单的动作。然而,在实践中,业务规则引擎的使用者需要根据某些条件执行动作。这些条件将依赖于一些事实。例如,仅当潜在客户的职位是 CEO 时,通知销售团队。

建模状态

你可以先编写代码,添加一个动作,并使用匿名类引用本地变量,如 示例 5-7 所示,或者使用 lambda 表达式,如 示例 5-8 所示。

示例 5-7. 使用匿名类添加一个动作
// this object could be created from a form
final Customer customer = new Customer("Mark", "CEO");

businessRuleEngine.addAction(new Action() {

    @Override
    public void perform() {
        if ("CEO".equals(customer.getJobTitle())) {
            Mailer.sendEmail("sales@company.com", "Relevant customer: " + customer);
        }
    }
});
示例 5-8. 使用 lambda 表达式添加一个动作
// this object could be created from a form
final Customer customer = new Customer("Mark", "CEO");

businessRuleEngine.addAction(() -> {
    if ("CEO".equals(customer.getJobTitle())) {
        Mailer.sendEmail("sales@company.com", "Relevant customer: " + customer);
    }
});

然而,出于几个原因,这种方法并不方便:

  1. 如何测试这个动作?它不是一个独立的功能模块;它对 customer 对象有硬编码的依赖。

  2. customer 对象没有与动作分组。它是一种外部状态,被共享使用,导致责任混淆。

那么我们需要什么?我们需要封装状态,使其可供业务规则引擎中的动作使用。让我们通过引入一个名为 Facts 的新类来建模这些需求,Facts 将代表业务规则引擎中可用的状态,并且更新 Action 接口,使其能够操作 Facts。一个更新后的单元测试显示在 示例 5-9 中。该单元测试检查当业务规则引擎运行时,指定的动作是否确实被调用,并且传递了 Facts 对象作为参数。

示例 5-9. 使用事实测试一个动作
@Test
public void shouldPerformAnActionWithFacts() {
    final Action mockAction = mock(Action.class);
    final Facts mockFacts = mock(Facts.class);
    final BusinessRuleEngine businessRuleEngine = new BusinessRuleEngine(mockedFacts);

    businessRuleEngine.addAction(mockAction);
    businessRuleEngine.run();

    verify(mockAction).perform(mockFacts);
}

为了遵循 TDD 哲学,此测试最初将失败。您始终需要运行测试以确保它们失败,否则可能会意外通过一个测试。要使测试通过,您需要更新 API 和实现代码。首先,您将引入Facts类,它允许您存储以键和值表示的事实。引入一个单独的Facts类来建模状态的好处是,您可以通过提供公共 API 控制用户可用的操作,并对类的行为进行单元测试。目前,Facts类仅支持String键和String值。Facts类的代码显示在示例 5-10 中。我们选择名称getFactaddFact,因为它们更好地表示手头的领域(处理事实),而不是getValuesetValue

示例 5-10. Facts 类
public class Facts {

    private final Map<String, String> facts = new HashMap<>();

    public String getFact(final String name) {
        return this.facts.get(name);
    }

    public void addFact(final String name, final String value) {
        this.facts.put(name, value);
    }
}

现在,您需要重构Action接口,以便perform()方法可以使用作为参数传递的Facts对象。这样一来,清楚地表明了在单个Action的上下文中可用的事实(示例 5-11)。

示例 5-11. 接受事实的行动接口
@FunctionalInterface
public interface Action {
    void perform(Facts facts);
}

最后,您现在可以更新BusinessRuleEngine类,以利用事实和更新的Actionperform()方法,如示例 5-12 所示。

示例 5-12. 带有事实的 BusinessRuleEngine
public class BusinessRuleEngine {

    private final List<Action> actions;
    private final Facts facts;

    public BusinessRuleEngine(final Facts facts) {
        this.facts = facts;
        this.actions = new ArrayList<>();
    }

    public void addAction(final Action action) {
        this.actions.add(action);
    }

    public int count() {
        return this.actions.size();
    }

    public void run() {
        this.actions.forEach(action -> action.perform(facts));
    }
}

现在Facts对象可用于行动,您可以在代码中指定查找Facts对象的任意逻辑,如示例 5-13 所示。

示例 5-13. 利用事实的行动
businessRuleEngine.addAction(facts -> {
    final String jobTitle = facts.getFact("jobTitle");
    if ("CEO".equals(jobTitle)) {
        final String name = facts.getFact("name");
        Mailer.sendEmail("sales@company.com", "Relevant customer: " + name);
    }
});

让我们看一些更多的示例。这也是介绍 Java 中两个最近功能的好机会,我们按顺序探索:

  • 局部变量类型推断

  • Switch 表达式

局部变量类型推断

Java 10 引入了局部变量类型推断。类型推断是编译器可以为您确定静态类型,因此您无需输入它们的想法。在示例 5-10 中,您在之前看到了类型推断的示例,当您编写时

Map<String, String> facts = new HashMap<>();

而不是

Map<String, String> facts = new HashMap<String, String>();

这是 Java 7 中引入的一个特性,称为 菱形操作符。基本上,当其上下文确定类型参数(在本例中为 String, String)时,您可以省略泛型的类型参数。在前面的代码中,赋值的左侧指示Map的键和值应为 String

自 Java 10 起,类型推断已扩展到局部变量上。例如,示例 5-14 中的代码可以使用var关键字和局部变量类型推断进行重写,如示例 5-15 所示。

示例 5-14. 显式类型声明的局部变量声明
Facts env = new Facts();
BusinessRuleEngine businessRuleEngine = new BusinessRuleEngine(env);
示例 5-15. 局部变量类型推断
var env = new Facts();
var businessRuleEngine = new BusinessRuleEngine(env);

通过在 Example 5-15 中显示的代码中使用var关键字,变量env仍具有静态类型Facts,变量businessRuleEngine仍具有静态类型BusinessRuleEngine

注意

使用var关键字声明的变量不是final。例如,以下代码:

final Facts env = new Facts();

不严格等同于:

var env = new Facts();

在使用var声明后,仍然可以为变量env分配另一个值。您必须在变量env前显式添加final关键字,如下所示:

final var env = new Facts()

在其余章节中,出于简洁性考虑,我们简单使用var关键字,不使用final。当我们显式声明变量类型时,我们使用final关键字。

类型推断有助于减少编写 Java 代码所需的时间。然而,您应该始终使用这个特性吗?值得记住的是,开发人员花费的时间更多是在阅读代码而不是编写代码。换句话说,您应该考虑优化阅读的便利性而不是编写的便利性。var改善这一点的程度总是主观的。您应该始终专注于帮助您的团队成员阅读您的代码,因此,如果他们乐意使用var阅读代码,那么您应该使用它,否则不要使用。例如,我们可以重构 Example 5-13 中的代码,使用本地变量类型推断来整理代码,如 Example 5-16 所示。

Example 5-16. 使用事实和本地变量类型推断的操作
businessRuleEngine.addAction(facts -> {
    var jobTitle = facts.getFact("jobTitle");
    if ("CEO".equals(jobTitle)) {
        var name = facts.getFact("name");
        Mailer.sendEmail("sales@company.com", "Relevant customer: " + name);
    }
});

Switch 表达式

到目前为止,您只设置了处理一个条件的操作。这相当受限制。例如,假设您与销售团队合作。他们可能在他们的客户关系管理(CRM)系统中记录具有不同金额和不同阶段的不同交易。交易阶段可以表示为枚举Stage,其值包括LEADINTERESTEDEVALUATINGCLOSED,如 Example 5-17 所示。

Example 5-17. 枚举表示不同的交易阶段
public enum Stage {
    LEAD, INTERESTED, EVALUATING, CLOSED
}

根据交易阶段,您可以分配一个规则,给出您赢得交易的概率。因此,您可以帮助销售团队生成预测。例如,对于特定团队,LEAD 阶段的转化概率为 20%,那么一个金额为 1000 美元的LEAD阶段的交易将有一个预测金额为 200 美元。让我们创建一个操作来建模这些规则,并返回一个特定交易的预测金额,如 Example 5-18 所示。

Example 5-18. 计算特定交易预测金额的规则
businessRuleEngine.addAction(facts -> {
    var forecastedAmount = 0.0;
    var dealStage = Stage.valueOf(facts.getFact("stage"));
    var amount = Double.parseDouble(facts.getFact("amount"));
    if(dealStage == Stage.LEAD){
        forecastedAmount = amount * 0.2;
    } else if (dealStage == Stage.EVALUATING) {
        forecastedAmount = amount * 0.5;
    } else if(dealStage == Stage.INTERESTED) {
        forecastedAmount = amount * 0.8;
    } else if(dealStage == Stage.CLOSED) {
        forecastedAmount = amount;
    }
    facts.addFact("forecastedAmount", String.valueOf(forecastedAmount));
});

Example 5-18 中显示的代码基本上为每个枚举值提供一个值。更优选的语言构造是switch语句,因为它更简洁。这在 Example 5-19 中展示。

示例 5-19. 使用switch语句计算特定交易预测金额的规则
switch (dealStage) {
    case LEAD:
        forecastedAmount = amount * 0.2;
        break;
    case EVALUATING:
        forecastedAmount = amount * 0.5;
        break;
    case INTERESTED:
        forecastedAmount = amount * 0.8;
        break;
    case CLOSED:
        forecastedAmount = amount;
        break;
}

注意示例 5-19 中代码中的所有break语句。break语句确保不执行switch语句中的下一个块。如果您不小心忘记了break,则代码仍然会编译,并且会出现所谓的穿透行为。换句话说,将执行下一个块,这可能导致微妙的错误。自 Java 12 起(使用语言功能预览模式),您可以通过使用不同的语法来重写此以避免穿透行为和多个break,来使用switch作为表达式,如示例 5-20 所示。

示例 5-20. 没有穿透行为的switch表达式
var forecastedAmount = amount * switch (dealStage) {
    case LEAD -> 0.2;
    case EVALUATING -> 0.5;
    case INTERESTED -> 0.8;
    case CLOSED -> 1;
}

除了增加的可读性外,这种增强的switch形式还有一个好处是穷尽性。这意味着当您使用switch与枚举时,Java 编译器会检查所有枚举值是否有对应的switch标签。例如,如果您忘记处理CLOSED情况,Java 编译器将产生以下错误:

error: the switch expression does not cover all possible input values.

可以像在示例 5-21 中展示的那样,使用switch表达式重新编写整体操作。

示例 5-21. 用于计算特定交易预测金额的规则
businessRuleEngine.addAction(facts -> {
    var dealStage = Stage.valueOf(facts.getFact("stage"));
    var amount = Double.parseDouble(facts.getFact("amount"));
    var forecastedAmount = amount * switch (dealStage) {
        case LEAD -> 0.2;
        case EVALUATING -> 0.5;
        case INTERESTED -> 0.8;
        case CLOSED -> 1;
    }
    facts.addFact("forecastedAmount", String.valueOf(forecastedAmount));
});

接口隔离原则

现在我们想开发一个检查工具,允许业务规则引擎的用户检查可能的动作和条件的状态。例如,我们希望评估每个动作和相关条件,以便记录它们而不实际执行动作。我们该如何做呢?当前的Action接口不够用,因为它没有区分执行的代码与触发该代码的条件。目前没有办法将条件与动作代码分开。为了弥补这一点,我们可以引入一个增强的Action接口,其中内置了评估条件的功能。例如,我们可以创建一个名为ConditionalAction的接口,其中包括一个新方法evaluate(),如示例 5-22 所示。

示例 5-22. ConditionalAction 接口
public interface ConditionalAction {
    boolean evaluate(Facts facts);
    void perform(Facts facts);
}

现在我们可以实现一个基本的Inspector类,它接受一组ConditionalAction对象并根据某些事实对它们进行评估,如示例 5-23 所示。Inspector返回一个报告列表,其中包含事实、条件动作和结果。Report类的实现如示例 5-24 所示。

示例 5-23. 条件检查器
public class Inspector {

    private final List<ConditionalAction> conditionalActionList;

    public Inspector(final ConditionalAction...conditionalActions) {
        this.conditionalActionList = Arrays.asList(conditionalActions);
    }

    public List<Report> inspect(final Facts facts) {
        final List<Report> reportList = new ArrayList<>();
        for (ConditionalAction conditionalAction : conditionalActionList) {
            final boolean conditionResult = conditionalAction.evaluate(facts);
            reportList.add(new Report(facts, conditionalAction, conditionResult));
        }
        return reportList;
    }
}
示例 5-24. 报告类
public class Report {

    private final ConditionalAction conditionalAction;
    private final Facts facts;
    private final boolean isPositive;

    public Report(final Facts facts,
                     final ConditionalAction conditionalAction,
                     final boolean isPositive) {
        this.facts = facts;
        this.conditionalAction = conditionalAction;
        this.isPositive = isPositive;
    }

    public ConditionalAction getConditionalAction() {
        return conditionalAction;
    }

    public Facts getFacts() {
        return facts;
    }

    public boolean isPositive() {
        return isPositive;
    }

    @Override
    public String toString() {
        return "Report{" +
                "conditionalAction=" + conditionalAction +
                ", facts=" + facts +
                ", result=" + isPositive +
                '}';
    }
}

我们如何测试Inspector?您可以通过编写一个简单的单元测试开始,如示例 5-25 所示。这个测试突显了我们当前设计的一个基本问题。事实上,ConditionalAction接口违反了接口隔离原则(ISP)。

示例 5-25. 强调 ISP 违规
public class InspectorTest {

    @Test
    public void inspectOneConditionEvaluatesTrue() {

        final Facts facts = new Facts();
        facts.setFact("jobTitle", "CEO");
        final ConditionalAction conditionalAction = new JobTitleCondition();
        final Inspector inspector = new Inspector(conditionalAction);

        final List<Report> reportList = inspector.inspect(facts);

        assertEquals(1, reportList.size());
        assertEquals(true, reportList.get(0).isPositive());
    }

    private static class JobTitleCondition implements ConditionalAction {

        @Override
        public void perform(Facts facts) {
            throw new UnsupportedOperationException();
        }

        @Override
        public boolean evaluate(Facts facts) {
            return "CEO".equals(facts.getFact("jobTitle"));
        }
    }
}

什么是接口隔离原则?您可能注意到perform方法的实现是空的。事实上,它抛出了一个UnsupportedOperationException异常。这是一个情况,您依赖于一个接口(ConditionalAction),它提供了比您实际需要的更多内容。在这种情况下,我们只是想要建模一个条件——一个求值为真或假的东西。尽管如此,我们还是被迫依赖于perform()方法,因为它是接口的一部分。

这个通用想法是接口隔离原则的基础。它主张,任何类都不应该被迫依赖它不使用的方法,因为这会引入不必要的耦合。在第二章中,您学习了另一个原则,即单一责任原则(SRP),它促进了高内聚性。SRP 是一个通用的设计指导原则,一个类应该负责一个功能,并且只有一个改变的原因。尽管 ISP 听起来可能像是相同的想法,但它采取了不同的视角。ISP 关注的是接口的使用者而不是其设计。换句话说,如果一个接口最终非常庞大,可能是因为接口的使用者看到了一些它不关心的行为,这会导致不必要的耦合。

为了符合接口隔离原则,我们鼓励将概念分离到更小的接口中,这些接口可以独立演化。这个想法实质上促进了更高的内聚性。分离接口还为引入更接近所需领域的命名提供了机会,比如ConditionAction,我们将在下一节中探讨这些内容。

设计流畅的 API

到目前为止,我们为用户提供了一种添加具有复杂条件的操作的方式。这些条件是使用增强的开关语句创建的。然而,对于业务用户来说,语法并不像他们希望的那样友好,以指定简单条件。我们希望允许他们以符合其领域并且更简单的方式添加规则(条件和动作)。在本节中,您将了解建造者模式以及如何开发自己的流畅 API 来解决这个问题。

什么是流畅 API?

流畅 API 是专门为特定领域量身定制的 API,以便您可以更直观地解决特定问题。它还支持链式方法调用的概念,用于指定更复杂的操作。您可能已经熟悉几个知名的流畅 API:

  • Java Streams API 允许您以更符合解决问题需求的方式指定数据处理查询。

  • Spring Integration 提供了一个 Java API,用于使用与企业集成模式领域接近的词汇指定企业集成模式。

  • jOOQ 提供了一个库,使用直观的 API 与不同的数据库进行交互。

领域建模

那么我们希望为我们的业务用户简化什么?我们希望帮助他们指定一个简单的“当某个条件成立时”,“然后执行某事”的组合作为规则。在此领域中有三个概念:

条件

应用于某些事实的条件,将评估为真或假。

动作

一组特定的操作或要执行的代码。

规则

这是一个条件和一个动作在一起。只有在条件为真时动作才会执行。

现在我们已经定义了领域中的概念,我们将其转换为 Java!让我们首先定义Condition接口,并重用我们现有的Action接口,如示例 5-26 所示。请注意,我们也可以使用自 Java 8 起可用的java.util.function.Predicate接口,但是Condition名称更能代表我们的领域。

注意

在编程中名称非常重要,因为良好的名称有助于理解代码解决的问题。在许多情况下,名称比接口的“形状”(其参数和返回类型)更重要,因为名称向阅读代码的人传达上下文信息。

示例 5-26. Condition 接口
@FunctionalInterface
public interface Condition {
    boolean evaluate(Facts facts);
}

现在剩下的问题是如何建模规则的概念?我们可以定义一个带有perform()操作的接口Rule。这将允许您提供Rule的不同实现。这个接口的一个合适的默认实现是一个名为DefaultRule的类,它将与执行规则相关的适当逻辑一起持有ConditionAction对象,如示例 5-27 所示。

示例 5-27. 建模规则的概念
@FunctionalInterface
interface Rule {
    void perform(Facts facts);
}

public class DefaultRule implements Rule {

    private final Condition condition;
    private final Action action;

    public Rule(final Condition condition, final Action action) {
        this.condition = condition;
        this.action = action;
    }

    public void perform(final Facts facts) {
        if(condition.evaluate(facts)){
            action.execute(facts);
        }
    }
}

我们如何使用所有这些不同的元素创建新规则?您可以在示例 5-28 中看到一个示例。

示例 5-28. 构建一个规则
final Condition condition = (Facts facts) -> "CEO".equals(facts.getFact("jobTitle"));
final Action action = (Facts facts) -> {
      var name = facts.getFact("name");
      Mailer.sendEmail("sales@company.com", "Relevant customer!!!: " + name);
};

final Rule rule = new DefaultRule(condition, action);

构建器模式

然而,即使代码使用了接近我们域的名称(ConditionActionRule),这段代码仍然相当手动。用户必须实例化单独的对象并将它们组合在一起。让我们引入所谓的构建器模式来改进使用适当条件和操作创建 Rule 对象的过程。这种模式的目的是以更简单的方式创建对象。构建器模式基本上会解构构造函数的参数,并提供方法来提供每个参数。这种方法的好处在于它允许您声明与手头域相适应的方法名称。例如,在我们的案例中,我们想要使用 whenthen 的词汇。示例 5-29 中的代码展示了如何设置构建器模式以构建 DefaultRule 对象。我们引入了一个方法 when(),它提供了条件。方法 when() 返回 this(即当前实例),这将允许我们进一步链接其他方法。我们还引入了一个方法 then(),它提供了动作。方法 then() 也返回 this,这允许我们进一步链接方法。最后,方法 createRule() 负责创建 DefaultRule 对象。

示例 5-29. 用于 Rule 的构建器模式
public class RuleBuilder {
    private Condition condition;
    private Action action;

    public RuleBuilder when(final Condition condition) {
        this.condition = condition;
        return this;
    }

    public RuleBuilder then(final Action action) {
        this.action = action;
        return this;
    }

    public Rule createRule() {
        return new DefaultRule(condition, action);
    }
}

使用这个新类,您可以创建 RuleBuilder 并使用 when()then()createRule() 方法配置 Rule,如 示例 5-30 所示。方法链的概念是设计流畅 API 的关键方面之一。

示例 5-30. 使用 RuleBuilder
Rule rule = new RuleBuilder()
        .when(facts -> "CEO".equals(facts.getFact("jobTitle")))
        .then(facts -> {
            var name = facts.getFact("name");
            Mailer.sendEmail("sales@company.com", "Relevant customer: " + name);
        })
        .createRule();

此代码看起来更像一个查询,并利用了所涉领域:规则的概念、when()then() 作为内置结构。但它并不完全令人满意,因为用户还是会遇到两个笨拙的构造体。

  • 实例化一个“空的”RuleBuilder

  • 调用方法createRule()

我们可以通过提出稍微改进的 API 来改进这一点。有三种可能的改进方法:

  • 我们将构造函数设置为私有,以防止用户显式调用。这意味着我们需要为我们的 API 设计一个不同的入口点。

  • 我们可以将方法when()改为静态方法,这样就可以直接调用并且实际上会将调用转发到旧构造函数。此外,静态工厂方法提高了发现正确方法以设置Rule对象的可读性。

  • 方法then()将负责最终创建我们的DefaultRule对象。

示例 5-31 展示了改进的RuleBuilder

示例 5-31. 改进的 RuleBuilder
public class RuleBuilder {
    private final Condition condition;

    private RuleBuilder(final Condition condition) {
        this.condition = condition;
    }

    public static RuleBuilder when(final Condition condition) {
        return new RuleBuilder(condition);
    }

    public Rule then(final Action action) {
        return new DefaultRule(condition, action);
    }
}

现在,您可以通过从 RuleBuilder.when() 方法开始,然后使用 then() 方法简单地创建规则,如 示例 5-32 所示。

示例 5-32. 使用改进的 RuleBuilder
final Rule ruleSendEmailToSalesWhenCEO = RuleBuilder
        .when(facts -> "CEO".equals(facts.getFact("jobTitle")))
        .then(facts -> {
            var name = facts.getFact("name");
            Mailer.sendEmail("sales@company.com", "Relevant customer!!!: " + name);
        });

现在我们已经重构了RuleBuilder,我们可以重构业务规则引擎以支持规则而不仅仅是动作,如示例 5-33 所示。

示例 5-33. 更新的业务规则引擎
public class BusinessRuleEngine {

    private final List<Rule> rules;
    private final Facts facts;

    public BusinessRuleEngine(final Facts facts) {
        this.facts = facts;
        this.rules = new ArrayList<>();
    }

    public void addRule(final Rule rule) {
        this.rules.add(rule);
    }

    public void run() {
        this.rules.forEach(rule -> rule.perform(facts));
    }

}

收获

  • 测试驱动开发(Test-Driven Development,TDD)的哲学是从编写一些测试开始,这些测试将指导您实现代码。

  • 模拟允许您编写单元测试,以确保触发某些行为。

  • Java 支持局部变量类型推断和 switch 表达式。

  • 建造者模式有助于为实例化复杂对象设计用户友好的 API。

  • 接口隔离原则通过减少对不必要方法的依赖来促进高内聚。通过将大型接口分解为更小的内聚接口,使用户只看到他们需要的内容,从而实现这一目标。

在你身上循环

如果您想扩展并巩固本章的知识,您可以尝试以下活动之一:

  • 增强RuleRuleBuilder以支持名称和描述。

  • 增强Facts类,以便可以从 JSON 文件加载事实。

  • 增强业务规则引擎以支持具有多个条件的规则。

  • 增强业务规则引擎以支持具有不同优先级的规则。

完成挑战

您的业务蒸蒸日上,您的公司已将业务规则引擎作为工作流程的一部分采纳!现在您正在寻找下一个创意,并希望将您的软件开发技能应用到能够帮助世界而不仅仅是公司的新事物上。是时候迈向下一章——Twootr 了!

第六章:Twootr

挑战

Joe 是一个兴奋的年轻小伙子,热衷于向我讲述他的新创业想法。他的使命是帮助人们更好更快地沟通。他喜欢博客,但他在思考如何让人们更频繁地以更少量的内容进行博客。他称之为微博客。大胆的想法是,如果你将消息大小限制在 140 个字符,人们会频繁地发布少量的消息,而不是大段的消息。

我们问 Joe,他觉得这种限制是否会鼓励人们发表毫无意义的简短言论。他说:“Yolo!”我们问 Joe 如何赚钱。他说:“Yolo!”我们问 Joe 打算给产品取什么名字。他说:“Twootr!”我们觉得这听起来是一个很酷和原创的想法,所以我们决定帮助他建立他的产品。

目标

在本章中,你将了解如何将软件应用程序整合成一个大局。本书中以往的应用程序示例大多是小型示例——在命令行上运行的批处理作业。Twootr 是一个服务器端的 Java 应用程序,类似于大多数 Java 开发人员编写的应用程序类型。

在本章中,你将有机会学习到许多不同的技能:

  • 如何将一个大局描述拆分成不同的架构关注点

  • 如何使用测试替身(test doubles)来隔离和测试代码库中不同组件之间的交互。

  • 如何从需求出发,思考到应用程序领域的核心。

在本章的几个地方,我们不仅会谈论软件的最终设计,还会谈论我们如何达到这一设计的过程。有一些地方我们展示了某些方法是如何随着项目的开发和功能列表的扩展而迭代演变的。这将让你了解到软件项目在现实中如何演变,而不只是呈现一个理想化的最终设计抽象的思考过程。

Twootr 需求

本书中你看到的以前的应用程序都是处理数据和文档的业务应用程序。而 Twootr 是一个面向用户的应用程序。当我们和 Joe 谈论他系统需求的时候,显而易见地,他已经对自己的想法进行了一些精炼。每个用户的微博称为 twoot,用户会有一个持续的 twoot 流。为了看到其他用户的 twoot,你可以 follow 这些用户。

Joe 想出了一些不同的使用案例——他的用户使用服务的场景。这些是我们需要使之正常工作以帮助 Joe 实现帮助人们更好更快地沟通的目标的功能:

  • 用户使用唯一的用户 ID 和密码登录到 Twootr。

  • 每个用户都有一组其他用户,他们在系统中关注这些用户。

  • 用户可以发送一个 twoot,任何已登录的跟随者都应该立即看到这个 twoot。

  • 用户登录时应该看到自上次登录以来关注者的所有 Twoots。

  • 用户应该能够删除 Twoots。已删除的 Twoots 不应再对关注者可见。

  • 用户应该能够从手机或网站登录。

解释如何实现适合 Joe 需求的解决方案的第一步是概述并概述我们面临的宏观设计选择。

设计概述

注意

如果您想要查看本章的源代码,您可以查看书籍代码存储库中的com.iteratrlearning.shu_book.chapter_06包。

如果您想看到项目的实际操作,您应该从您的 IDE 中运行TwootrServer类,然后浏览到*http://localhost:8000*。

如果我们先挑出最后一个需求并首先考虑它,那么与本书中许多其他系统相比,我们需要构建一个以某种方式多台计算机进行通信的系统。这是因为我们的用户可能在不同的计算机上运行软件——例如,一个用户可能在家里的桌面电脑上加载 Twootr 网站,另一个用户可能在手机上运行 Twootr。这些不同的用户界面将如何相互通信?

软件开发人员尝试解决这类问题时采取的最常见方法是使用客户端-服务器模型。在开发分布式应用程序的这种方法中,我们将计算机分为两个主要组。我们有客户端请求某种服务的使用和服务器提供所需的服务。所以在我们的情况下,我们的客户端可能是像网站或移动电话应用程序这样的东西,通过它们,我们可以与 Twootr 服务器通信。服务器将处理大部分业务逻辑并将 Twoots 发送和接收到不同的客户端。这在图 6-1 中显示。

客户端-服务器模型

图 6-1. 客户端-服务器模型

从需求和与 Joe 的交谈中明显地可以看出,使该系统正常运行的关键部分之一是能够立即查看您关注的用户的 Twoots 的能力。这意味着用户界面必须具有从服务器接收 Twoots 以及发送它们的能力。从宏观上来说,有两种不同的通信风格可以实现这个目标:拉取式或推送式。

拉取式

拉取式通信风格中,客户端向服务器发出请求并查询信息。这种通信风格通常被称为点对点风格或请求-响应风格的通信。这是一种特别常见的通信方式,被大多数网站使用。当你加载一个网页时,它会向某个服务器发出 HTTP 请求,以获取页面的数据。拉取式通信风格在客户端控制要加载的内容时非常有用。例如,当你浏览维基百科时,你可以控制你有兴趣阅读或查看的页面,内容响应将被发送回给你。这在图 6-2 中有所体现。

拉取通信

图 6-2. 拉取通信

推送式

另一种方法是推送式通信风格。这可以称为一种反应式或事件驱动的通信方法。在这种模型中,由发布者发出一系列事件,许多订阅者监听这些事件。因此,每次通信不再是一对一的,而是一对多的。这是一个对于需要在多个事件的持续通信模式中进行不同组件交流的系统非常有用的模型。例如,如果你正在设计一个股票市场交易所,不同的公司希望看到不断更新的价格或交易信息,而不是每次想看新的信息时都需要发出新的请求。这在图 6-3 中有所体现。

推送通信

图 6-3. 推送通信

对于 Twootr 来说,事件驱动的通信风格似乎最适合该应用,因为它主要由持续的“twoots”流组成。在这种模型中,事件将是“twoots”本身。我们当然仍然可以设计应用程序,采用请求-响应通信风格。然而,如果我们选择这条路线,客户端将不得不定期轮询服务器,并使用请求询问:“自从上次请求以来有人发了‘twoot’吗?”在事件驱动风格中,你只需订阅你感兴趣的事件——即关注另一个用户——服务器就会将你感兴趣的“twoots”推送给客户端。

这种选择的事件驱动通信风格将从现在开始影响应用程序的其余设计。当我们编写实现应用程序主类的代码时,我们将接收并发送事件。如何接收和发送事件决定了我们代码中的模式,也决定了我们如何为代码编写测试。

从事件到设计

话虽如此,我们正在构建一个客户端-服务器应用程序——本章将专注于服务器端组件而非客户端组件。在“用户界面”中,你将看到如何为这个代码库开发客户端,以及与本书配套的代码示例中实现的示例客户端。我们之所以专注于服务器端组件,有两个原因。首先,这是一本关于如何在 Java 中编写软件的书,Java 在服务器端广泛使用,但在客户端使用并不广泛。其次,服务器端是应用程序的大脑所在:业务逻辑的核心。客户端只是一个非常简单的代码库,只需将 UI 绑定到发布和订阅事件即可。

通信

我们已经确定我们想要发送和接收事件,我们设计中的一个常见的下一步将是选择某种技术来发送这些消息,或者从我们的客户端到我们的服务器。在这个领域有很多选择,以下是我们可以选择的几种途径:

  • WebSockets 是一种现代、轻量级的通信协议,提供在 TCP 流上进行双向事件通信。它们经常用于 web 浏览器和 web 服务器之间的事件驱动通信,并且受到最新浏览器版本的支持。

  • 托管的基于云的消息队列,例如亚马逊简单队列服务(Amazon Simple Queue Service),是广播和接收事件的越来越受欢迎的选择。消息队列是通过发送消息来执行进程间通信的一种方式,这些消息可以由单个进程或一组进程接收。作为托管服务的好处是,你的公司不必费力确保它们可靠地托管。

  • 有许多优秀的开源消息传输或消息队列,例如 Aeron、ZeroMQ 和 AMPQ 实现。这些开源项目中的许多都避免了供应商锁定,尽管它们可能会限制你选择可以与消息队列交互的客户端类型。例如,如果你的客户端是一个 web 浏览器,它们就不太适合。

这远非详尽的列表,正如你所看到的,不同的技术有不同的权衡和用例。也许在你自己的程序中,你会选择其中一种技术。在以后的某个时候,你可能会决定它不是正确的选择,想要选择另一种技术。也可能是,你希望为不同类型的连接客户端选择不同类型的通信技术。无论哪种方式,最好在项目开始时做出决定,并避免被迫永远地接受它,这不是一个很好的架构决策。在本章的后面,我们将看到如何将这个架构选择抽象化,以避免一个大错误的前期架构决策。

甚至可能出现这样的情况,您可能希望结合不同的通信方法;例如,通过使用不同的通信方法为不同类型的客户端使用不同的通信方法。图 6-4 可视化使用 WebSockets 与网站通信以及为 Android 移动应用程序推送 Android 推送通知。

不同的通信方法

图 6-4. 不同的通信方法

GUI

将 UI 通信技术或您的 UI 与核心服务器端业务逻辑耦合也具有其他几个缺点:

  • 这是测试困难且缓慢的。每个测试都必须通过与主服务器并行运行的发布和订阅事件来测试系统。

  • 它违反了我们在第二章讨论的单一责任原则。

  • 它假设我们的客户端将有一个 UI。起初,这对 Twootr 可能是一个坚实的假设,但在辉煌的未来,我们可能希望有交互式的人工智能聊天机器人帮助解决用户问题。或者至少发送 twooting 猫的 GIF!

从中得出的结论是,我们应该明智地引入某种抽象来解耦 UI 的消息传递与核心业务逻辑。我们需要一个接口,通过它可以向客户端发送消息,并且需要一个接口,通过它可以从客户端接收消息。

持久性

在应用程序的另一侧也存在类似的问题。我们应该如何存储 Twootr 的数据?我们可以从以下多种选择中进行选择:

  • 我们可以自己索引和搜索的纯文本文件。很容易看出已经记录了什么,并避免依赖于另一个应用程序。

  • 传统的 SQL 数据库。它经过充分测试和理解,具有强大的查询支持。

  • 一个 NoSQL 数据库。这里有多种不同的数据库,具有不同的用例、查询语言和数据存储模型。

在软件项目开始时,我们真的不知道该选择什么,而且随着时间的推移,我们的需求可能会发生变化。我们确实希望将存储后端的选择与应用程序的其余部分解耦。这些不同问题之间存在相似之处——都是关于希望避免与特定技术耦合。

六边形架构

实际上,这里有一个更一般的架构风格的名称,帮助我们解决这个问题。它被称为端口和适配器六边形架构,并由Alister Cockburn 最初介绍。这个想法如图 6-5 所示,您的应用程序的核心是您正在编写的业务逻辑,您希望将不同的实现选择与此核心逻辑分开。

每当你有一个想要与业务逻辑核心解耦的技术特定关注点时,你引入一个端口。来自外部世界的事件通过端口到达和离开你的业务逻辑核心。适配器是插入端口的技术特定实现代码。例如,我们可能会有一个用于发布和订阅 UI 事件的端口,以及一个与 Web 浏览器通信的 WebSocket 适配器。

六边形架构

图 6-5. 六边形架构

系统中可能有其他组件,你可能希望为其创建端口和适配器抽象。一个可能与扩展的 Twootr 实现相关的组件是通知系统。通知用户有很多可能感兴趣的 Twoots 可以登录查看,这就是一个端口。你可能希望使用电子邮件或短信的适配器来实现这一功能。

另一个例子是身份验证服务端口。你可能希望先用一个仅存储用户名和密码的适配器开始,稍后将其替换为 OAuth 后端或将其绑定到其他系统。在本章描述的 Twootr 实现中,我们并没有像这样抽象出身份验证。这是因为我们的需求和最初的头脑风暴会议尚未提出我们可能需要不同身份验证适配器的充分理由。

或许你会想知道如何区分什么应该是端口,什么应该是核心域的一部分。在一个极端情况下,你的应用程序中可能会有数百甚至数千个端口,几乎所有内容都可以从核心域中抽象出来。在另一个极端情况下,可能根本不需要端口。在这个滑动尺度上决定应用程序应该处于的位置,是个人判断和具体情况的问题:没有硬性规定。

一个很好的决策原则可能是,把解决业务问题中至关重要的内容视为应用核心的一部分,把技术特定的或涉及与外部世界通信的内容视为应用核心外的内容。这就是我们在这个应用程序中使用的原则。因此,业务逻辑是我们核心域的一部分,但负责持久化和与 UI 的事件驱动通信的部分隐藏在端口后面。

如何入门

我们可以在这个阶段继续以更详细的方式概述设计,设计更复杂的图表,并决定哪个功能应该存在于哪个类中。我们从未发现这是一种非常有效的编写软件的方法。它往往会导致大量的假设和设计决策被推送到架构图中的小框中,结果证明这些小框并不那么小。毫无关于整体设计的思考直接潜入编码中,也不太可能产生最好的软件。软件开发需要足够的前期设计来避免其陷入混乱,但没有对代码进行足够的编写部分的架构很快就会变得枯燥和不切实际。

注意

在开始编写代码之前推动所有设计工作的方法被称为大设计上前,或BDUF。 BDUF 通常与过去 10-20 年变得更受欢迎的敏捷或迭代式开发方法相对比。由于我们发现迭代方法更有效,我们将在接下来的几节中以迭代方式描述设计过程。

在上一章节中,您已经看到了 TDD——测试驱动开发——的介绍,所以现在您应该熟悉了这样的事实,即从一个名为TwootrTest的测试类开始编写项目是个好主意。因此,让我们从一个测试开始,我们的用户可以登录:shouldBeAbleToAuthenticateUser()。在这个测试中,用户将登录并正确认证。此方法的骨架可以在示例 6-1 中看到。

示例 6-1. shouldBeAbleToAuthenticateUser() 的骨架
@Test
public void shouldBeAbleToAuthenticateUser()
{
    // receive logon message for valid user

    // logon method returns new endpoint.

    // assert that endpoint is valid
}

为了实现这个测试,我们需要创建一个Twootr类,并有一种对登录事件进行建模的方式。作为惯例,在本模块中,任何与事件发生相对应的方法都将具有前缀on。因此,例如,在这里我们将创建一个名为onLogon的方法。但是这个方法的签名是什么——它需要以什么参数,以及应该回复什么?

我们已经作出了将 UI 通信层与端口分离的架构决策。因此,我们需要决定如何定义 API。我们需要一种向用户发出事件的方式——例如,用户正在关注的另一个用户已经发了两推。我们还需要一种接收来自特定用户的事件的方式。在 Java 中,我们可以使用方法调用来代表事件。因此,每当 UI 适配器想向Twootr发布事件时,它将在系统核心拥有的对象上调用一个方法。每当Twootr想要发布事件时,它将在适配器拥有的对象上调用一个方法。

但是端口和适配器的目标是将核心与特定适配器实现解耦。这意味着我们需要某种方式来抽象不同的适配器——一个接口。在这一点上,我们本可以选择使用抽象类。虽然这样也可以运行,但接口更加灵活,因为适配器类可以实现多个接口。而且通过使用接口,我们在一定程度上避免了未来添加一些状态到 API 的邪恶诱惑。在 API 中引入状态是不好的,因为不同的适配器实现可能希望以不同的方式表示其内部状态,因此将状态放入 API 可能导致耦合。

对于发布用户事件的对象,我们不需要使用接口,因为核心中只会有一个实现——我们可以只使用常规类。您可以在图 6-6 中直观地看到我们的方法。当然,为了表示发送和接收事件的 API,我们需要一个名称,或者实际上是一对名称。在这里有很多选择;实际上,任何能清楚表明这些是用于发送和接收事件的 API 的东西都会做得很好。

我们选择了SenderEndPoint作为发送事件到核心的类,以及ReceiverEndPoint作为从核心接收事件的接口。实际上,我们可以反转发送和接收的设计,以从用户或适配器的角度工作。这种排序的优势在于我们首先考虑核心,其次考虑适配器。

事件到代码

图 6-6. 事件到代码

现在我们知道我们要走的路线,我们可以编写shouldBeAbleToAuthenticateUser()测试。这个测试只需测试当我们使用有效的用户名登录系统时,用户是否成功登录即可。这里的登录意味着什么?我们希望返回一个有效的SenderEndPoint对象,因为这是返回给 UI 以表示刚刚登录的用户的对象。然后,我们需要在我们的Twootr类中添加一个方法来表示登录事件的发生,并允许测试通过。我们的实现签名显示在示例 6-2 中。由于 TDD 鼓励我们进行最小实现工作以使测试通过,然后演化实现,我们将仅实例化SenderEndPoint对象并从我们的方法中返回它。

示例 6-2. 第一个 onLogon 签名
SenderEndPoint onLogon(String userId, ReceiverEndPoint receiver);

现在我们已经有了一个漂亮的绿色条,我们需要编写另一个测试——shouldNotAuthenticateUnknownUser()。这将确保我们不允许一个我们不了解的用户登录系统。在编写此测试时,会出现一个有趣的问题。我们如何在这里建模失败情景?我们不希望在这里返回SenderEndPoint,但我们确实需要一种方式来指示我们的 UI 登录失败了。一种方法是使用异常,我们在第三章中描述了这种方法。

异常在这里可能有用,但可以说这有点滥用概念。登录失败并不是一个异常情况——这是经常发生的事情。人们可能会拼错用户名,拼错密码,有时甚至会进入错误的网站!另一种替代且常见的方法是,如果登录成功,则返回SenderEndPoint,如果失败,则返回null。这种方法有几个缺点:

  • 如果另一个开发人员在不检查它是否为null的情况下使用该值,则会获得NullPointerException。这种错误是 Java 开发人员非常常见的错误。

  • 没有编译时支持可以帮助避免这种类型的问题。它们会在运行时出现。

  • 从方法的签名中无法判断它是故意返回null值来模拟失败,还是代码中有 bug。

这里可以帮助的更好的方法是使用Optional数据类型。这是在 Java 8 中引入的,用于建模可能存在或不存在的值。它是一个通用类型,可以将其视为一个箱子,里面可能有值也可能没有值——一个只有一个或没有值的集合。使用Optional作为返回类型使得当方法无法返回其值时发生什么变得明确——它返回空的Optional。我们将在本章中讨论如何创建和使用Optional类型。因此,我们现在重构我们的onLogon方法,使其签名为示例 6-3 中的签名。

示例 6-3. 第二个 onLogon 签名
Optional<SenderEndPoint> onLogon(String userId, ReceiverEndPoint receiver);

我们还需要修改shouldBeAbleToAuthenticateUser()测试,以确保它检查Optional值是否存在。我们接下来的测试是shouldNotAuthenticateUserWithWrongPassword(),如示例 6-4 所示。这个测试确保正在登录的用户拥有正确的密码,以使其登录工作。这意味着我们的onLogon()方法不仅需要存储用户的名称,还需要存储他们的密码在一个Map中。

示例 6-4. 应该不会因为密码错误而认证用户
    @Test
    public void shouldNotAuthenticateUserWithWrongPassword()
    {
        final Optional<SenderEndPoint> endPoint = twootr.onLogon(
            TestData.USER_ID, "bad password", receiverEndPoint);

        assertFalse(endPoint.isPresent());
    }

在这种情况下,存储数据的简单方法是使用一个Map<String, String>,其中键是用户 ID,值是密码。然而,实际上,用户的概念对我们的领域很重要。我们有涉及用户的故事,并且系统的许多功能与用户之间的交流相关。现在是向我们的实现中添加一个User领域类的时候了。我们的数据结构将被修改为一个Map<String, User>,其中键是用户的 ID,值是所讨论用户的User对象。

TDD 的一个常见批评是它抑制了软件设计。它只会让你编写测试,最终导致贫血的领域模型,你最终不得不在某个时候重写你的实现。所谓贫血的领域模型指的是领域对象没有太多的业务逻辑,而是散布在不同的方法中,以过程化的方式。这确实是对 TDD 有时候可能被实践的一种公平批评。然而,识别在何时添加一个领域类或在代码中实现某个概念是一个微妙的事情。如果这个概念是你的用户故事经常提到的内容,那么你的问题域中确实应该有代表它的东西。

然而,你可以看到一些明显的反模式。例如,如果你建立了不同的查找结构,使用相同的键,同时添加但涉及不同的值,那么你可能缺少一个领域类。因此,如果我们跟踪用户的关注者集合和密码,并且我们有两个Map对象,一个是用户 ID 对应关注者,一个是用户 ID 对应密码,那么在问题域中缺少一个概念。我们在这里引入了我们的User类,只关注了一个我们关心的值—密码—但对问题域的理解告诉我们,用户是重要的,因此我们并没有过早行事。

注意

从本章开始,我们将使用“用户”一词来代表用户的一般概念,并使用User来表示领域类。同样地,我们使用 Twootr 来指代整个系统,使用Twootr来指代我们正在开发的类。

密码和安全性

到目前为止,我们完全避免讨论安全性。事实上,不谈论安全问题并希望它们会自行消失,是技术行业最喜欢的安全策略。解释如何编写安全代码不是本书的主要目标,甚至不是次要目标;然而,Twootr 确实使用和存储密码进行身份验证,因此值得稍微考虑一下这个话题。

存储密码的最简单方法是将其视为任何其他String,称为存储它们的明文。一般来说,这是一个坏习惯,因为这意味着任何可以访问您的数据库的人都可以访问所有用户的密码。一个恶意人或组织可以,并且在许多情况下已经使用明文密码来登录您的系统并假装是用户。此外,许多人将相同的密码用于多个不同的服务。如果你不相信我们,问问你的年长亲戚!

为了避免任何人都能访问您的数据库并读取密码,您可以对密码应用加密哈希函数。这是一个函数,它接受一些任意大小的输入字符串并将其转换为一些输出,称为摘要。加密哈希函数是确定性的,因此如果您想再次对相同的输入进行哈希,您可以得到相同的结果。这对于以后检查哈希密码至关重要。另一个关键属性是,虽然从输入到摘要的转换应该很快,但反向函数应该需要很长时间或使用很多内存,以至于攻击者无法反转摘要是不切实际的。

加密哈希函数的设计是一个活跃的研究课题,政府和公司花费了大量资金在上面。它们很难正确实现,因此您永远不应该自己编写——Twootr 使用了一个名为Bouncy Castle的成熟的 Java 库。这是开源的,并经过了大量的同行评审。Twootr 使用了Scrypt哈希函数,这是一种专门用于存储密码的现代算法。示例 6-5 展示了代码示例。

示例 6-5. 密钥生成器
class KeyGenerator {
    private static final int SCRYPT_COST = 16384;
    private static final int SCRYPT_BLOCK_SIZE = 8;
    private static final int SCRYPT_PARALLELISM = 1;
    private static final int KEY_LENGTH = 20;

    private static final int SALT_LENGTH = 16;

    private static final SecureRandom secureRandom = new SecureRandom();

    static byte[] hash(final String password, final byte[] salt) {
        final byte[] passwordBytes = password.getBytes(UTF_16);
        return SCrypt.generate(
            passwordBytes,
            salt,
            SCRYPT_COST,
            SCRYPT_BLOCK_SIZE,
            SCRYPT_PARALLELISM,
            KEY_LENGTH);
    }

    static byte[] newSalt() {
        final byte[] salt = new byte[SALT_LENGTH];
        secureRandom.nextBytes(salt);
        return salt;
    }
}

许多散列方案存在的一个问题是,即使它们计算起来非常昂贵,计算出散列函数的逆转可能也是可行的,通过对所有密钥进行暴力破解直到某个长度或通过彩虹表。为了防范这种可能性,我们使用盐。是添加到加密哈希函数中的额外随机生成的输入。通过为每个用户的密码添加一些用户不会输入但是随机生成的额外输入,我们阻止了有人能够创建散列函数的反向查找。他们需要知道哈希函数和盐。

现在我们已经在这里提到了一些围绕密码存储的基本安全概念。实际上,保持系统安全是一个持续不断的工作。你不仅需要担心静态数据的安全性,还需要担心传输中的数据。当有人从客户端连接到您的服务器时,它需要通过网络连接传输用户的密码。如果一个恶意攻击者拦截了这个连接,他们可能会复制密码并用它做 140 个字符中最邪恶的事情!

对于 Twootr 来说,我们通过 WebSockets 收到登录消息。这意味着为了保证我们的应用程序安全,WebSocket 连接需要防止中间人攻击。有几种方法可以做到这一点;最常见和最简单的方法是使用传输层安全性(TLS),这是一种旨在为其连接发送的数据提供隐私和数据完整性的加密协议。

具有成熟安全理解的组织在软件设计中建立定期审查和分析。例如,他们可能定期引入外部顾问或内部团队来尝试渗透系统的安全防御,扮演攻击者的角色。

关注者和 Twoots

我们需要解决的下一个要求是关注用户。您可以考虑以两种不同的方式设计软件。其中一种方法称为自下而上,从设计应用程序的核心开始——数据存储模型或核心领域对象之间的关系——逐步构建系统的功能。在用户之间关注的自下而上方法中,首先需要决定如何建模关注之间的关系。显然这是一种多对多的关系,因为每个用户都可以有多个关注者,一个用户可以关注多个其他用户。然后,您将继续在此数据模型上添加所需的业务功能,以保持用户满意。

另一种方法是软件开发的自上而下方法。这从用户需求或用户故事开始,尝试开发实现这些故事所需的行为或功能,逐步向存储或数据建模的关注点发展。例如,我们将从接收关注另一个用户事件的 API 开始,并设计所需的任何存储机制来实现此行为,逐步从 API 到业务逻辑再到持久化。

很难说在所有情况下哪种方法更好,以及另一种方法总是应该避免;然而,对于 Java 非常流行的企业应用程序来说,我们的经验是自上而下的方法效果最佳。这是因为当你开始进行数据建模或设计软件的核心领域时,你可能会花费不必要的时间在软件正常运行所不必要的功能上。自上而下方法的缺点是,有时随着您构建更多的需求和故事,您的初始设计可能不尽如人意。这意味着您需要对软件设计采取警惕和迭代的方法,不断改进它。

在本书的这一章中,我们将向您展示自上而下的方法。这意味着我们从一个测试开始,以验证关注用户的功能,如示例 6-6 所示。在这种情况下,我们的 UI 将向我们发送一个事件,指示用户想要关注另一个用户,因此我们的测试将调用我们端点的onFollow方法,并将要关注的用户的唯一 ID 作为参数传递。当然,这个方法还不存在——所以我们需要在Twootr类中声明它,以便使代码编译通过。

建模错误

在示例 6-6 中的测试仅涵盖了关注操作的黄金路径,因此我们需要确保操作已成功执行。

示例 6-6. 应该关注有效用户
    @Test
    public void shouldFollowValidUser()
    {
        logon();

        final FollowStatus followStatus = endPoint.onFollow(TestData.OTHER_USER_ID);

        assertEquals(SUCCESS, followStatus);
    }

目前我们只有一个成功的场景,但还有其他潜在的场景需要考虑。如果作为参数传递的用户 ID 不对应于实际用户会怎样?如果用户已经在关注他们所请求关注的用户会怎样?我们需要一种方法来建模此方法可以返回的不同结果或状态。生活中有很多不同的选择可供我们选择。决策,决策,决策……

一种方法是在操作返回时抛出异常并在成功时返回void。这可能是一个完全合理的选择。它可能不会违反我们的想法,即异常应仅用于异常控制流,因为一个设计良好的 UI 会在正常情况下避免这些场景的出现。不过,让我们考虑一些替代方案,它们将状态视为一个值,而不是根本不使用异常。

一个简单的方法是使用boolean值——true表示成功,false表示失败。在操作只能成功或失败的情况下,这是一个公平的选择,而且只会因为一个原因而失败。在具有多个失败场景的情况下,boolean方法的问题在于你不知道为什么它失败了。

或者,我们可以使用简单的int常量值来表示每种不同的失败场景,但正如在第三章中讨论异常概念时所述,这种方法容易出错、类型不安全,并且可读性和可维护性较差。这里有一个替代方案适用于类型安全并提供更好文档的情况:枚举类型。enum是一组预定义的常量替代品,构成一个有效的类型。因此,任何可以使用interfaceclass的地方都可以使用enum

但是枚举比基于int的状态码更好。如果一个方法返回一个int,你不一定知道int可能包含哪些值。可以添加 javadoc 来描述它可以取哪些值,也可以定义常量(静态 final 字段),但这些只是徒劳的举动。枚举只能包含由enum声明定义的值列表。在 Java 中,枚举还可以在其上定义实例字段和方法,以添加有用的功能,尽管在这种情况下我们不会使用该功能。您可以在 示例 6-7 中看到我们关注者状态的声明。

示例 6-7. FollowStatus
public enum FollowStatus {
    SUCCESS,
    INVALID_USER,
    ALREADY_FOLLOWING
}

由于 TDD 驱使我们编写最简单的实现来通过测试,所以在这一点上onFollow方法应该简单地返回SUCCESS值。

我们有一些不同的场景需要考虑我们的following()操作。 示例 6-8 展示了驱动我们思考重复用户的测试。为了实现它,我们需要向我们的User类添加一组用户 ID 来表示此用户正在关注的用户集,并确保添加另一个用户不是重复的。这在 Java 集合 API 中非常容易。已经有了一个定义了唯一元素的Set接口,如果您要添加的元素已经是Set的成员,则add方法将返回false

示例 6-8. shouldNotDuplicateFollowValidUser
    @Test
    public void shouldNotDuplicateFollowValidUser()
    {
        logon();

        endPoint.onFollow(TestData.OTHER_USER_ID);

        final FollowStatus followStatus = endPoint.onFollow(TestData.OTHER_USER_ID);
        assertEquals(ALREADY_FOLLOWING, followStatus);
    }

测试shouldNotFollowInValidUser()断言如果用户无效,则结果状态将指示。它遵循与shouldNotDuplicateFollowValidUser()类似的格式。

Twooting

现在我们已经奠定了基础,让我们来看产品的激动人心的部分—twooting!我们的用户故事描述了任何用户都可以发送一个 twoot,并且在那个时刻已经登录的任何关注者应该立即看到这个 twoot。现实情况下,我们不能保证用户会立即看到这个 twoot。也许他们已经登录到他们的计算机,但在喝咖啡,盯着其他社交网络,或者,天佑,做一些工作。

现在你可能已经熟悉了总体方法。我们想要为已登录用户收到来自另一用户发送的 twoot 场景编写一个测试—shouldReceiveTwootsFromFollowedUser()。除了登录和关注外,这个测试需要一些其他概念。首先,我们需要模拟发送 twoot,并向SenderEndPoint添加一个onSendTwoot()方法。这个方法有两个参数,用于 twoot 的id,这样我们以后就可以引用它,以及它的内容。

第二,我们需要一种方法通知跟随者用户已经发送了一条推文-这是我们可以检查的测试发生的事情。我们之前介绍了ReceiverEndPoint作为向用户发布消息的一种方式,现在是时候开始使用它了。我们将添加一个onTwoot方法,导致示例 6-9。

示例 6-9. ReceiverEndPoint
public interface ReceiverEndPoint {
    void onTwoot(Twoot twoot);
}

无论我们的 UI 适配器是什么,都必须向 UI 发送消息以告知其发生了推文。但问题是如何编写一个检查此onTwoot方法是否已被调用的测试呢?

创建模拟对象

这就是模拟对象概念派上用场的地方。模拟对象是一种假装是另一个对象的类型。它具有与被模拟对象相同的方法和公共 API,并且在 Java 类型系统中看起来像另一个对象,但实际上不是。它的目的是记录任何交互,例如方法调用,并能够验证某些方法调用是否发生。例如,这里我们希望能够验证ReceiverEndPointonTwoot()方法是否已被调用。

注意

对于具有计算机科学学位的人来说,阅读本书时听到“验证”这个词被用于这种方式可能有些混淆。数学和形式化方法的社区倾向于将其用于指所有输入的系统属性已被证明的情况。而在模拟中,“验证”一词的意思完全不同。它只是检查某个方法是否已以特定参数调用。当不同的人群使用同一个词具有多重含义时,有时会令人沮丧,但通常我们只需要意识到术语存在的不同上下文即可。

可以通过多种方式创建模拟对象。最早的模拟对象往往是手工编写的;实际上,我们可以在这里手工编写一个ReceiverEndPoint的模拟实现,示例 6-10 就是其中一个示例。每当调用onTwoot方法时,我们通过将Twoot参数存储在List中记录其调用,并且可以通过断言List包含Twoot对象来验证它已被调用以特定参数。

示例 6-10. MockReceiverEndPoint
public class MockReceiverEndPoint implements ReceiverEndPoint
{
    private final List<Twoot> receivedTwoots = new ArrayList<>();

    @Override
    public void onTwoot(final Twoot twoot)
    {
        receivedTwoots.add(twoot);
    }

    public void verifyOnTwoot(final Twoot twoot)
    {
        assertThat(
            receivedTwoots,
            contains(twoot));
    }
}

在实践中,手动编写模拟可能变得繁琐且容易出错。优秀的软件工程师如何应对繁琐且容易出错的事情呢?没错,他们自动化了这些过程。有许多库可以帮助我们通过提供创建模拟对象的方式来解决这个问题。我们在这个项目中将使用的库称为Mockito,它是免费的、开源的,并且被广泛使用。大多数与Mockito相关的操作可以通过Mockito类的静态方法来调用,我们在此处使用静态导入。为了创建模拟对象,您需要使用mock方法,如示例 6-11 所示。

示例 6-11. mockReceiverEndPoint
    private final ReceiverEndPoint receiverEndPoint = mock(ReceiverEndPoint.class);

使用模拟对象进行验证

在这里创建的模拟对象可以在正常的 ReceiverEndPoint 实现被使用的任何地方使用。例如,我们可以将它作为参数传递给 onLogon() 方法,以连接 UI 适配器。一旦测试的行为——测试的“when”——发生了,我们的测试需要实际验证 onTwoot 方法是否被调用——“then”。为了做到这一点,我们使用 Mockito.verify() 方法包装模拟对象。这是一个通用方法,返回与其传入类型相同的对象;我们只需调用所期望的方法,并传入我们期望的参数,以描述与模拟对象的预期交互,如示例 6-12 所示。

示例 6-12. 验证接收端点
verify(receiverEndPoint).onTwoot(aTwootObject);

在上一节中你可能注意到的一件事是我们引入了 Twoot 类,它在 onTwoot 方法的签名中使用。这是一个值对象,用于封装值并表示 Twoot。由于它将被发送到 UI 适配器,因此它应该只包含简单值的字段,而不是从核心领域中过度暴露。例如,为了表示 Twoot 的发送者,它包含发送者的 id,而不是它们的 User 对象的引用。Twoot 还包含一个 contentStringTwoot 对象本身的 id

在这个系统中,Twoot 对象是不可变的。如前所述,这种风格减少了错误的可能性。在像传递给 UI 适配器的值对象中,这尤为重要。你确实只想让你的 UI 适配器显示 Twoot,而不是改变另一个用户的 Twoot 的状态。值得注意的是,我们继续遵循领域语言,将类命名为 Twoot

模拟库

我们在本书中使用 Mockito 是因为它有良好的语法,并且符合我们的首选编写模拟的方式,但它并不是唯一的 Java 模拟框架。Powermock 和 EasyMock 也很流行。

Powermock 可以模拟 Mockito 的语法,但允许您模拟 Mockito 不支持的事物,例如最终类或静态方法。关于是否应该模拟最终类等事物存在一些争论——如果你不能在生产中提供一个不同的实现,那么在测试中确实需要这样做吗?一般来说,不鼓励使用 Powermock,但在偶尔的特殊情况下,它确实是有用的。

EasyMock 在编写模拟时采用了不同的方法。这是一种风格选择,可能会被一些开发人员所青睐。最大的概念上的差异在于,EasyMock 鼓励严格模拟。严格模拟的理念是,如果你没有明确声明一个调用应该发生,那么如果它确实发生了,那就是一个错误。这导致测试对类执行的行为更加具体,但有时可能会与无关的交互耦合在一起。

SenderEndPoint

现在像 onFollowonSendTwoot 这样的方法都声明在 SenderEndPoint 类上。每个 SenderEndPoint 实例代表了一个用户将事件发送到核心领域的终点。我们的 Twoot 设计保持了 SenderEndPoint 的简单性 —— 它只是将主 Twootr 类包装起来,并委托调用这些方法,传入系统中表示的用户的 User 对象。 示例 6-13 显示了类的整体声明和一个方法对应一个事件的示例 —— onFollow

示例 6-13. SenderEndPoint
public class SenderEndPoint {
    private final User user;
    private final Twootr twootr;

    SenderEndPoint(final User user, final Twootr twootr) {
        Objects.requireNonNull(user, "user");
        Objects.requireNonNull(twootr, "twootr");

        this.user = user;
        this.twootr = twootr;
    }

    public FollowStatus onFollow(final String userIdToFollow) {
        Objects.requireNonNull(userIdToFollow, "userIdToFollow");

        return twootr.onFollow(user, userIdToFollow);
    }

你可能已经注意到 示例 6-13 中的 java.util.Objects 类。这是 JDK 自带的实用类,提供了用于检查 null 引用和实现 hashCode()equals() 方法的便捷方法。

有一些替代设计我们可以考虑,而不是引入 SenderEndPoint。我们可以通过直接在 Twootr 对象上公开方法来接收与用户相关的事件,并期望任何 UI 适配器直接调用这些方法。这是一个主观问题,就像软件开发的许多部分一样。有些人会认为创建 SenderEndPoint 增加了不必要的复杂性。

这里最大的动机是,如前所述,我们不想将 User 核心域对象暴露给 UI 适配器 —— 只需简单事件来与他们交流。可能可以在所有 Twootr 事件方法中将用户 ID 作为参数,但然后每个事件的第一步都需要查找该 ID 的 User 对象,而在这里我们已经在 SenderEndPoint 的上下文中有它了。那种设计会去除 SenderEndPoint 的概念,但以交换更多的工作和复杂性。

为了实际发送 Twoot,我们需要稍微完善我们的核心领域。User 对象需要添加一组关注者,当 Twoot 到达时可以通知他们。你可以在 示例 6-14 中看到我们 onSendTwoot 方法的实现代码。在设计的这个阶段,它找到已登录的用户并告诉他们接收 Twoot。如果你对 filterforEach 方法,以及 ::-> 语法不熟悉,不用担心 —— 这些内容将在 “函数式编程” 中介绍。

示例 6-14. onSendTwoot
void onSendTwoot(final String id, final User user, final String content)
{
    final String userId = user.getId();
    final Twoot twoot = new Twoot(id, userId, content);
    user.followers()
        .filter(User::isLoggedOn)
        .forEach(follower -> follower.receiveTwoot(twoot));
}

User 对象还需要实现 receiveTwoot() 方法。用户如何接收 Twoot?好吧,它应该通过发出事件通知用户界面,表明有一个 Twoot 准备好显示,即调用 receiverEndPoint.onTwoot(twoot)。这是我们使用模拟代码验证调用的方法,并在这里调用它使测试通过。

您可以在 Example 6-15 中看到我们测试的最终迭代,如果您从 GitHub 下载示例项目,则可以看到这段代码。您可能会注意到它看起来与我们到目前为止描述的有些不同。首先,由于已编写接收 twoots 的测试,一些操作已经重构到了公共方法中。其中一个示例是logon(),它将我们的第一个用户登录到系统中——这是许多测试给定部分的一部分。其次,该测试还创建了一个Position对象,并将其传递给Twoot,并验证了与twootRepository的交互。仓库是什么鬼?这两者到目前为止我们还没有需要,但它们是系统设计演变的一部分,将在接下来的两个部分中解释。

Example 6-15. shouldReceiveTwootsFromFollowedUser
    @Test
    public void shouldReceiveTwootsFromFollowedUser()
    {
        final String id = "1";

        logon();

        endPoint.onFollow(TestData.OTHER_USER_ID);

        final SenderEndPoint otherEndPoint = otherLogon();
        otherEndPoint.onSendTwoot(id, TWOOT);

        verify(twootRepository).add(id, TestData.OTHER_USER_ID, TWOOT);
        verify(receiverEndPoint).onTwoot(new Twoot(id, TestData.OTHER_USER_ID, TWOOT, new Position(0)));
    }

位置

你很快就会了解Position对象,但在展示它们的定义之前,我们应该了解它们的动机。我们需要解决的下一个要求是,当用户登录时,他们应该看到自上次登录以来他们关注者的所有 twoots。这需要能够对不同的 twoots 执行某种重播,并知道用户登录时还未看到的 twoots。Example 6-16 展示了该功能的一个测试。

Example 6-16. shouldReceiveReplayOfTwootsAfterLogoff
    @Test
    public void shouldReceiveReplayOfTwootsAfterLogoff()
    {
        final String id = "1";

        userFollowsOtherUser();

        final SenderEndPoint otherEndPoint = otherLogon();
        otherEndPoint.onSendTwoot(id, TWOOT);

        logon();

        verify(receiverEndPoint).onTwoot(twootAt(id, POSITION_1));
    }

为了实现这个功能,我们的系统需要知道用户注销时发送了哪些 twoots。我们可以考虑设计这一功能的许多不同方法。不同的方法在实现复杂性、正确性和性能/可扩展性方面可能有不同的权衡。由于我们刚刚开始构建 Twootr,并不指望有很多用户,所以我们的重点不在可扩展性问题上:

  • 我们可以追踪每个 twoot 的时间以及用户注销的时间,并在这些时间之间搜索 twoot。

  • 我们可以将 twoots 视为一个连续的流,其中每个 twoot 在流中有一个位置,并在用户注销时记录该位置。

  • 我们可以使用位置并记录上次查看的 twoot 的位置。

在考虑不同的设计时,我们会避免按时间排序消息。这似乎是一个好主意。假设我们以毫秒为单位存储时间单元——如果我们在同一时间间隔内接收到两个 twoot 会发生什么?我们将不知道这两个 twoot 之间的顺序。如果一个 twoot 在用户注销的同一毫秒接收到呢?

记录用户注销时间是另一个问题事件。如果用户仅通过显式点击按钮来注销,那可能还好。然而,在实际操作中,这只是他们停止使用我们用户界面的几种方式之一。也许他们会关闭网页浏览器而没有显式注销,或者他们的浏览器会崩溃。如果他们从两个网页浏览器连接,然后从其中一个注销会发生什么?如果他们的手机电量耗尽或关闭应用程序会发生什么?

我们决定最安全的方法来确定从哪里重播 twoots 是给 twoots 分配位置并存储每个用户已看到的位置。为了定义位置,我们引入了一个小的值对象称为 Position,如示例 6-17 所示。这个 class 还有一个常量值,用于流开始前流的初始位置。由于我们所有的位置值都是正数,我们可以使用任何负整数作为初始位置:这里选择了 -1

示例 6-17. 位置
public class Position {
    /**
 * Position before any tweets have been seen
 */
    public static final Position INITIAL_POSITION = new Position(-1);

    private final int value;

    public Position(final int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }

    @Override
    public String toString() {
        return "Position{" +
            "value=" + value +
            '}';
    }

    @Override
    public boolean equals(final Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        final Position position = (Position) o;

        return value == position.value;
    }

    @Override
    public int hashCode() {
        return value;
    }

    public Position next() {
        return new Position(value + 1);
    }
}

这个类看起来有点复杂,不是吗?在你的编程过程中,你可能会问自己:为什么我在这里定义了 equals()hashCode() 方法,而不是让 Java 自己处理?什么是值对象?为什么我问了这么多问题?别担心,我们刚刚介绍了一个新主题,很快会回答你的问题。引入一些代表由字段组成的值或为某些数值赋予相关领域名称的小对象通常非常方便。我们的 Position 类就是一个例子;另一个例子可能是你在示例 6-18 中看到的 Point 类。

示例 6-18. Point
class Point {
    private final int x;
    private final int y;

    Point(final int x, final int y) {
        this.x = x;
        this.y = y;
    }

    int getX() {
        return x;
    }

    int getY() {
        return y;
    }

一个 Point 有一个 x 坐标和一个 y 坐标,而 Position 只有一个值。我们已经在类上定义了字段并为这些字段定义了 getters。

equals 和 hashcode 方法

如果我们想要比较两个像这样定义的对象,具有相同的值,那么我们发现当我们希望它们相等时它们却不等。示例 6-19 展示了一个例子;默认情况下,从 java.lang.Object 继承的 equals()hashCode() 方法被定义为使用引用相等的概念。这意味着,如果你在计算机内存中的不同位置有两个不同的对象,那么它们不相等——即使所有字段的值都相等。这可能导致程序中出现许多微妙的错误。

示例 6-19. 当应该相等时,Point 对象却不相等
final Point p1 = new Point(1, 2);
final Point p2 = new Point(1, 2);
System.out.println(p1 == p2); // prints false

通常有助于根据两种不同类型的对象——引用对象值对象——来思考它们的相等性概念。在 Java 中,我们可以重写 equals() 方法以定义我们自己的实现,该实现使用被视为与值相等相关的字段。例如,示例 6-20 中展示了 Point 类的示例实现。我们检查给定的对象是否与此对象的类型相同,然后检查每个字段是否相等。

示例 6-20. 点相等性定义
    @Override
    public boolean equals(final Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        final Point point = (Point) o;

        if (x != point.x) return false;
        return y == point.y;
    }

    @Override
    public int hashCode() {
        int result = x;
        result = 31 * result + y;
        return result;
    }

final Point p1 = new Point(1, 2);
final Point p2 = new Point(1, 2);
System.out.println(p1.equals(p2)); // prints true

equals 方法与 hashCode 方法之间的合同

在 示例 6-20 中,我们不仅重写了 equals() 方法,还重写了 hashCode() 方法。这是由于 Java 的 equals/hashcode 合同。这个合同规定,如果根据它们的 equals() 方法两个对象相等,则它们的 hashCode() 结果也必须相同。许多核心 Java API 使用 hashCode() 方法——特别是像 HashMapHashSet 这样的集合实现。它们依赖于此合同的正确性,如果不正确,则它们的行为可能与您的期望不符。那么,如何正确地实现 hashCode() 呢?

良好的 hashCode 实现不仅遵循合同,而且生成的哈希码值在整数中均匀分布。这有助于提高 HashMapHashSet 实现的效率。为了实现这两个目标,以下是一系列简单的规则,如果您遵循将会得到一个良好的 hashCode() 实现:

  1. 创建一个 result 变量并将其赋值为一个素数。

  2. 对于每个在 equals() 方法中使用的字段,取一个 int 值来表示字段的哈希码。

  3. 将字段的哈希码与现有结果结合起来,方法是将前一个结果乘以一个素数,例如,result = 41 * result + hashcodeOfField;

为了计算每个字段的哈希码,您需要根据字段类型的不同进行区分:

  • 如果字段是原始值,使用其伴随类提供的 hashCode() 方法。例如,如果是 double 类型,则使用 Double.hashCode()

  • 如果它是非空对象,只需调用其 hashCode() 方法,否则使用 0。这可以用 java.lang.Objects.hashCode() 方法进行缩写。

  • 如果是数组,则需要使用与我们在此处描述的相同规则来组合每个元素的 hashCode() 值。您可以使用 java.util.Arrays.hashCode() 方法来执行此操作。

在大多数情况下,您不需要实际编写equals()hashCode()方法。现代 Java IDE 将为您生成它们。尽管如此,了解生成的代码背后的原理和原因仍然是有帮助的。特别重要的是能够审查您在代码中看到的一对equals()hashCode()方法,以及知道它们是否实现良好或糟糕。

注释

我们在这一节中稍微提到了价值对象,但是未来的 Java 版本计划包含内联类。这些正在 Valhalla 项目 中原型化。内联类的想法是提供一种非常高效的方式来实现看起来像值的数据结构。你仍然可以像对待普通类一样编写代码,但它们将生成正确的hashCode()equals()方法,占用更少的内存,并且在许多使用情况下编程速度更快。

在实现此功能时,我们需要将Position与每个Twoot关联起来,因此我们向Twoot类添加一个字段。我们还需要记录每个用户的上次查看的Position,因此我们向User添加一个lastSeenPosition。当用户接收到Twoot时,他们会更新他们的位置,当用户登录时,他们会发送用户尚未看到的Twoot。因此,SenderEndPointReceiverEndPoint不需要添加新事件。重放Twoot还要求我们将Twoot对象存储在某个地方——最初我们只使用 JDK 的List。现在我们的用户不必一直登录到系统中才能享受到 Twootr,这真是太棒了。

要点

  • 您了解了像通信风格这样的更大范围的架构思想。

  • 您开发了将领域逻辑与库和框架选择分离的能力。

  • 您通过测试驱动开发了本章中的代码,从外到内。

  • 您将面向对象的领域建模技能应用于一个更大的项目中。

在您的开发中

如果您想要扩展和巩固本节的知识,可以尝试以下活动之一:

  • 尝试文字包裹 Kata

  • 在没有阅读下一章之前,请写下一份需要实现的 Twootr 完整清单。

完成挑战

我们与您的客户乔进行了跟进会议,并讨论了项目取得的巨大进展。已经涵盖了许多核心领域要求,并且我们已经描述了系统的设计方式。当然,此时 Twootr 还不完整。到目前为止,您尚未听说过如何将应用程序的各个组件连接起来,以便它们可以彼此通信。您也尚未了解到我们如何将 Twootr 的状态持久化到某种存储系统中,这样当 Twootr 重新启动时不会丢失。

Joe 对所取得的进展感到非常兴奋,他非常期待看到完成的 Twootr 实现。最后一章将完成 Twootr 的设计,并涵盖剩余的主题。

第七章:扩展 Twootr

挑战

之前在 Twootr 上,Joe 想要实现一个现代化的在线通信系统。上一章介绍了 Twootr 的潜在设计和核心业务域的实现,包括通过测试驱动设计的方法来实现该设计。您了解了涉及的一些设计和数据建模决策,以及如何分解初始问题并构建解决方案。这并没有涵盖整个 Twootr 项目,因此本章需要完成这个叙述。

目标

本章通过帮助您了解以下主题,扩展并完善了前一章中取得的进展:

  • 避免依赖倒置原则和依赖注入的耦合

  • 使用 Repository 模式和查询对象模式进行持久化。

  • 简要介绍函数式编程,将向您展示如何在 Java 特定的上下文和实际应用程序中使用这些想法。

总结

由于我们从前一章继续进行 Twootr 项目,因此现在可能值得回顾我们设计中的关键概念。如果您正在一场马拉松式的阅读中继续前一章节,那么我们很高兴您喜欢这本书,但请随意跳过本节。

  • Twootr 是实例化业务逻辑并编排系统的父类。

  • Twoot 是我们系统中用户广播的消息的单个实例。

  • ReceiverEndPoint 是一个由 UI 适配器实现的接口,用于向 UI 推送 Twoot 对象。

  • SenderEndPoint 具有与用户从系统中发送的事件相对应的方法。

  • 密码管理和哈希由 KeyGenerator 类执行。

持久化和 Repository 模式

现在我们有了一个能够支持大部分核心 Twootr 操作的系统。不幸的是,如果我们以任何方式重新启动 Java 进程,所有的 Twoots 和用户信息都会丢失。我们需要一种持久化信息的方法,以便在重新启动后能够存活下来。在讨论软件架构的早期,我们谈到了端口和适配器以及如何希望使我们应用程序的核心与存储后端无关。事实上,有一种常用的模式可以帮助我们做到这一点:Repository 模式。

Repository 模式定义了领域逻辑和存储后端之间的接口。除了允许我们随着应用程序的演进在不同的存储后端之间切换外,这种方法还提供了几个优点:

  • 将数据从存储后端映射到领域模型的逻辑集中化。

  • 使核心业务逻辑的单元测试成为可能,而不必启动数据库。这可以加快测试的执行速度。

  • 通过保持每个类单一职责,提高了可维护性和可读性。

您可以将存储库视为类似于对象集合,但存储库不仅仅将对象存储在内存中,还会将它们持久化在某个地方。在演化我们应用程序设计时,我们通过测试推动了存储库的设计;但是,为了节省时间,我们只会描述最终实现。由于存储库是对象的集合,我们在 Twootr 中需要两个存储库:一个用于存储User对象,另一个用于存储Twoot对象。大多数存储库具有一系列常见的操作,这些操作已经实现:

add()

将对象的新实例存储到存储库中。

get()

根据标识符查找单个对象。

delete()

从持久性后端删除实例。

update()

确保为此对象保存的值等于实例字段。

有些人使用 CRUD 缩写来描述这些操作。这代表 Create,Read,Update 和 Delete。我们使用addget而不是createread,因为命名更符合常见的 Java 用法,例如,在集合框架中。

设计存储库

在我们的情况下,我们从顶向下设计事物,并通过测试驱动存储库的开发。这意味着,并非所有操作都在两个存储库上定义。如示例 7-1 所示的UserRepository,没有一个操作来删除一个User。这是因为实际上没有要求执行删除用户操作。我们问了我们的客户,乔,关于这个问题,他说“一旦你开始 Twoot,你就停不下来了!”

当独自工作时,您可能会想要添加功能,只是为了使存储库中有“正常”的操作,但我们强烈警告不要走这条路。未使用的代码,或者通常被称为死代码,是一种负担。从某种意义上说,所有的代码都是一种负担,但是如果代码实际上是有用的,那么它对您的系统有益处,而如果它没有被使用,那么它只是一种负担。随着您的需求的演变,您需要重构和改进您的代码库,而如果您有很多未使用的代码,这个任务就会变得更加困难。

这里有一个指导原则,我们在整章中一直在暗示,但直到现在才提到:YAGNI。这代表You ain’t gonna need it。这并不意味着不要引入抽象和不同的概念,比如存储库。它只是表示在实际需要时才编写代码,而不是认为将来会需要它时编写代码。

示例 7-1. UserRepository
public interface UserRepository extends AutoCloseable {
    boolean add(User user);

    Optional<User> get(String userId);

    void update(User user);

    void clear();

    FollowStatus follow(User follower, User userToFollow);
}

由于它们存储的对象的性质不同,我们的两个存储库的设计也有所不同。我们的Twoot对象是不可变的,因此示例 7-2 中显示的TwootRepository不需要实现update()操作。

示例 7-2. TwootRepository
public interface TwootRepository {
    Twoot add(String id, String userId, String content);

    Optional<Twoot> get(String id);

    void delete(Twoot twoot);

    void query(TwootQuery twootQuery, Consumer<Twoot> callback);

    void clear();
}

通常,存储库中的 add() 方法只是将相关对象持久化到数据库中。在 TwootRepository 的情况下,我们采取了不同的方法。这个方法接受一些特定的参数,并且实际上创建了相关对象。采用这种方法的动机是数据源将负责为 Twoot 分配下一个 Position 对象。我们将确保唯一和有序对象的责任委托给将具有创建此类序列的适当工具的数据层。

另一种选择可能是接受一个没有分配 positionTwoot 对象,然后在添加时设置 position 字段。现在,对象构造函数的一个关键目标应该是确保所有的内部状态都被完全初始化,理想情况下应该使用 final 字段进行检查。通过在对象创建时不分配位置,我们将创建一个未完全实例化的对象,违反了我们围绕创建对象的原则之一。

Repository 模式的一些实现引入了一个通用接口,例如,类似 示例 7-3 的内容。在我们的情况下,这并不合适,因为 TwootRepository 没有 update() 方法,而 UserRepository 没有 delete() 方法。如果你想编写能够抽象不同存储库的代码,那么这可能会很有用。为了设计一个良好的抽象,避免仅仅为了这个目的而强行将不同的实现合并到同一个接口中是很重要的。

示例 7-3. 抽象存储库
public interface AbstractRepository<T>
{
    void add(T value);

    Optional<T> get(String id);

    void update(T value);

    void delete(T value);
}

查询对象

不同存储库之间的另一个关键区别是它们如何支持查询。在 Twootr 的情况下,我们的 UserRepository 不需要任何查询功能,但是当涉及到 Twoot 对象时,我们需要能够查找 twoots 以便在用户登录时重放它们。实现这个功能的最佳方式是什么?

嗯,我们在这里可以做几种不同的选择。最简单的方法是,我们可以简单地将我们的存储库视为一个纯粹的 Java Collection,并且有一种方法可以迭代不同的 Twoot 对象。然后,查询/过滤的逻辑可以以正常的 Java 代码编写。这很好,但是可能会相当慢,因为它要求我们从数据存储中检索所有行到我们的 Java 应用程序中以便进行查询,而实际上我们可能只想要其中的一些行。通常,像 SQL 数据库这样的数据存储后端具有高度优化和高效的数据查询和排序实现,最好将查询交给它们来处理。

决定仓库实现需要负责查询数据存储后,我们需要决定如何最好地通过TwootRepository接口公开这一功能。一种选择是添加一个与我们的业务逻辑耦合的方法来执行查询操作。例如,我们可以从示例 7-4 编写像twootsForLogon()方法来获取与用户关联的 twoots。这样做的缺点是,我们现在将特定的业务逻辑功能耦合到了我们的仓库实现中——这与引入仓库抽象的初衷相悖。这将使我们难以根据需求演化我们的实现,因为我们不得不修改仓库以及核心域逻辑,并且违反了单一职责原则。

示例 7-4. twootsForLogon
List<Twoot> twootsForLogon(User user);

我们想要设计的是一种能够利用数据存储的查询能力,而不将业务逻辑与所讨论的数据存储耦合在一起的东西。我们可以为给定的业务条件向仓库添加一个特定的查询方法,正如示例 7-5 所示。这种方法比前两种方法要好得多,但仍然可以稍作调整。将每个查询硬编码到特定方法中的问题在于,随着应用程序随时间的推移演变并增加更多查询功能,我们将不得不添加越来越多的方法到 Repository 接口中,这会使其变得臃肿并且难以理解。

示例 7-5. twootsFromUsersAfterPosition
List<Twoot> twootsFromUsersAfterPosition(Set<String> inUsers, Position lastSeenPosition);

这引导我们进入下一个查询迭代,显示在示例 7-6 中。在这里,我们将我们的TwootRepository查询条件抽象成了自己的对象。现在,我们可以添加额外的属性到这个条件中进行查询,而无需将查询方法的数量变成关于不同属性的组合爆炸。我们的TwootQuery对象的定义如示例 7-7 所示。

示例 7-6. query
List<Twoot> query(TwootQuery query);
示例 7-7. TwootQuery
public class TwootQuery {
    private Set<String> inUsers;
    private Position lastSeenPosition;

    public Set<String> getInUsers() {
        return inUsers;
    }

    public Position getLastSeenPosition() {
        return lastSeenPosition;
    }

    public TwootQuery inUsers(final Set<String> inUsers) {
        this.inUsers = inUsers;

        return this;
    }

    public TwootQuery inUsers(String... inUsers) {
        return inUsers(new HashSet<>(Arrays.asList(inUsers)));
    }

    public TwootQuery lastSeenPosition(final Position lastSeenPosition) {
        this.lastSeenPosition = lastSeenPosition;

        return this;
    }

    public boolean hasUsers() {
        return inUsers != null && !inUsers.isEmpty();
    }
}

这并不是查询 twoots 的最终设计方法。通过返回一个List对象,意味着我们需要一次性将要返回的所有Twoot对象加载到内存中。当这个List可能变得非常大时,这并不是一个好主意。我们也许不想一次性查询所有对象。在这种情况下——我们想要将每个Twoot对象推送到我们的 UI 中,而不需要一次性将它们全部保存在内存中。一些仓库实现会创建一个对象来模拟返回的结果集。这些对象让你可以分页或迭代访问这些值。

在这种情况下,我们将执行一些更简单的操作:只需采取一个 Consumer<Twoot> 回调。这是调用者将传递的一个函数,它接受一个参数——一个 Twoot——并返回 void。我们可以使用 lambda 表达式或方法引用来实现此接口。您可以在示例 7-8 中看到我们的最终方法。

示例 7-8. 查询
void query(TwootQuery twootQuery, Consumer<Twoot> callback);

参见示例 7-9 以了解如何使用此查询方法。这是我们的 onLogon() 方法调用查询的方式。它获取已登录用户,并使用该用户正在关注的用户集合作为查询的用户部分。然后,它使用该查询部分的上次查看位置。接收此查询结果的回调是 user::receiveTwoot,这是对我们之前描述的将 Twoot 对象发布到 UI ReceiverEndPoint 的函数的方法引用。

示例 7-9. 使用查询方法的示例
twootRepository.query(
    new TwootQuery()
        .inUsers(user.getFollowing())
        .lastSeenPosition(user.getLastSeenPosition()),
    user::receiveTwoot);

就是这样——这是我们设计并在应用程序逻辑核心中可用的存储库接口。

还有另一个功能,一些存储库实现使用了,这里我们没有描述,那就是工作单元模式。我们在 Twootr 中没有使用工作单元模式,但它通常与存储库模式一起使用,因此在这里提到它是值得的。企业应用程序通常会执行一个单一操作,与数据存储交互多次。例如,您可能正在两个银行账户之间转移资金,并希望在同一操作中从一个账户中取款并将其添加到另一个账户中。您不希望这些操作中的任何一个成功,而另一个不成功——在债务人账户没有足够资金时,您不希望将资金存入债权人账户。您也不希望在确保可以向债权人账户存入资金之前减少债务人的余额。

数据库通常实现事务和 ACID 合规性,以便使人们能够执行这些类型的操作。事务本质上是一组不同的数据库操作,逻辑上作为单个原子操作执行。工作单元是一种设计模式,可以帮助您执行数据库事务。基本上,您在存储库上执行的每个操作都会在工作单元对象中注册。然后,您的工作单元对象可以委托给一个或多个存储库,将这些操作包装在一个事务中。

到目前为止我们还没有讨论如何实际实现我们设计的存储库接口。就像软件开发中的其他事物一样,通常有不同的路线可选。Java 生态系统包含许多对象关系映射(ORM)工具,试图为您自动化这些实现任务。最流行的 ORM 是 Hibernate。ORM 通常是一种简单的方法,可以为您自动化一些工作;然而,它们往往会产生不够优化的数据库查询代码,并且有时会引入更多复杂性,而不是帮助减少。

在示例项目中,我们为每个存储库提供了两种实现方式。其中一种是非常简单的内存实现,适合用于测试,不会在重新启动时保留数据。另一种方法使用了普通的 SQL 和 JDBC API。我们不会详细讨论实现细节,因为大部分并未展示出特别有趣的 Java 编程思想;然而,在“函数式编程”章节中,我们将讨论如何在实现中应用一些函数式编程的思想。

函数式编程

函数式编程是一种将方法视为数学函数运行的计算机编程风格。这意味着它避免了可变状态和数据改变。你可以在任何语言中以这种风格编程,但有些编程语言提供了功能来帮助简化和改进——我们称之为函数式编程语言。Java 不是一种函数式编程语言,但在发布 20 年后的第 8 版中,它开始添加了一些功能,帮助实现了在 Java 中进行函数式编程。这些功能包括 lambda 表达式、Streams 和 Collectors API,以及 Optional 类。在本节中,我们将简要介绍这些函数式编程特性的使用及在 Twootr 中的应用。

在 Java 8 之前,库编写者在使用抽象级别上存在限制。一个很好的例子是缺乏对大型数据集进行有效并行操作的能力。从 Java 8 开始,你可以编写复杂的集合处理算法,通过改变一个方法调用,就能在多核 CPU 上高效执行这些代码。然而,为了能够编写这类大数据并行库,Java 需要进行一次新的语言改变:lambda 表达式。

当然,这也是有成本的,因为你必须学会编写和阅读支持 Lambda 的代码,但这是一个很好的权衡。程序员学习少量新语法和几种新习惯比手写大量复杂的线程安全代码要容易得多。优秀的库和框架显著降低了开发企业业务应用程序的成本和时间,应该消除开发易于使用和高效库的任何障碍。

抽象是任何进行面向对象编程的人熟悉的概念。不同之处在于,面向对象编程主要是抽象化数据,而函数式编程主要是抽象化行为。现实世界有这两种东西,我们的程序也有,因此我们可以并且应该从两者的影响中学习。

这种新抽象还有其他好处。对于我们中的许多人来说,不是一直编写性能关键代码,这些更重要的优势更胜一筹。您可以编写更易于阅读的代码——花时间表达其业务逻辑意图而不是其实现机制的代码。易于阅读的代码比难以阅读的代码更易于维护,更可靠,更少出错。

Lambda 表达式

我们将定义一个 Lambda 表达式作为描述匿名函数的简洁方式。我们理解一次性掌握这么多内容可能有些困难,因此我们将通过实际的 Java 代码示例来解释 Lambda 表达式是什么。让我们从我们代码库中用于表示回调的接口 ReceiverEndPoint 开始,如示例 7-10 所示。

Example 7-10. ReceiverEndPoint
public interface ReceiverEndPoint {
    void onTwoot(Twoot twoot);
}

在这个例子中,我们正在创建一个新对象,该对象提供了 ReceiverEndPoint 接口的实现。这个接口有一个方法 onTwoot,当 Twootr 对象将一个 Twoot 对象发送到 UI 适配器时,将调用此方法。在 Example 7-11 中列出的类提供了此方法的实现。在这种情况下,为了保持简单,我们只是在命令行上打印它,而不是将序列化版本发送到实际的 UI。

Example 7-11. 使用类实现 ReceiverEndPoint
public class PrintingEndPoint implements ReceiverEndPoint {
    @Override
    public void onTwoot(final Twoot twoot) {
        System.out.println(twoot.getSenderId() + ": " + twoot.getContent());
    }
}
注意

这实际上是行为参数化的一个例子——我们正在对不同的行为进行参数化,以向 UI 发送消息。

在这里调用实际行为的单行代码之前,需要七行样板代码。匿名内部类旨在使 Java 程序员更容易表示和传递行为。您可以在 Example 7-12 中看到一个例子,它减少了一些样板,但如果您希望轻松传递行为,它们仍然不足够简单。

Example 7-12. 使用匿名类实现 ReceiverEndPoint
        final ReceiverEndPoint anonymousClass = new ReceiverEndPoint() {
            @Override
            public void onTwoot(final Twoot twoot) {
                System.out.println(twoot.getSenderId() + ": " + twoot.getContent());
            }
        };

Boilerplate 不是唯一的问题,这段代码很难阅读,因为它掩盖了程序员的意图。我们不想传递一个对象;我们真正想做的是传递一些行为。在 Java 8 或更高版本中,我们会将这段代码示例写成一个 lambda 表达式,如示例 7-13 所示。

示例 7-13. 使用 lambda 表达式实现 ReceiverEndPoint
        final ReceiverEndPoint lambda =
            twoot -> System.out.println(twoot.getSenderId() + ": " + twoot.getContent());

而不是传递实现接口的对象,我们传递了一块代码——一个没有名字的函数。twoot 是参数的名称,与匿名内部类示例中的参数相同。-> 分隔参数和 lambda 表达式的主体,它只是一些在发布 twoot 时运行的代码。

这个例子和匿名内部类之间的另一个区别是如何声明变量 event。以前,我们需要显式地提供它的类型:Twoot twoot。在这个例子中,我们根本没有提供类型,但这个例子仍然可以编译。底层发生的事情是 javac 从 onTwoot 的签名中推断出变量 event 的类型。这意味着当类型显而易见时,你不需要显式地写出类型。

注意

尽管 lambda 方法参数比以前需要的样板代码少,它们仍然是静态类型的。为了可读性和熟悉性,你可以选择包含类型声明,有时编译器确实无法解析!

方法引用

你可能注意到的一个常见习语是创建一个 lambda 表达式来调用其参数上的方法。如果我们想要一个 lambda 表达式来获取一个 Twoot 的内容,我们会写出类似 7-14 的代码。

示例 7-14. 获取两推的内容
twoot -> twoot.getContent()

这是一个非常常见的习惯用法,实际上有一种简写语法可以让你重用现有的方法,称为方法引用。如果我们要使用方法引用来编写前面的 lambda 表达式,它将类似于 7-15。

示例 7-15. 方法引用
Twoot::getContent

标准形式是 类名::方法名。请记住,尽管它是一个方法,但你不需要使用括号,因为你实际上没有调用这个方法。你提供的是一个 lambda 表达式的等价形式,可以在需要时调用方法。你可以在与 lambda 表达式相同的地方使用方法引用。

你也可以使用相同的简写语法调用构造函数。如果你要使用 lambda 表达式来创建一个 SenderEndPoint,你可能会写出 7-16。

示例 7-16. 使用 lambda 创建一个新的 SenderEndPoint
(user, twootr) -> new SenderEndPoint(user, twootr)

你也可以使用方法引用来写,如 7-17 所示。

示例 7-17. 方法引用来创建一个新的 SenderEndPoint
SenderEndPoint::new

这段代码不仅更短,而且更易于阅读。SenderEndPoint::new 立即告诉您正在创建一个新的 SenderEndPoint,而无需扫描整行代码。另一个需要注意的地方是,方法引用自动支持多个参数,只要您有正确的函数接口。

当我们首次探索 Java 8 的变化时,我们的一个朋友说过方法引用“感觉像是作弊”。他的意思是,通过研究我们如何使用 lambda 表达式将代码传递作为数据,直接引用方法感觉像是作弊。

实际上,方法引用确实使一等函数的概念显式化。这意味着我们可以传递行为并像处理另一个值一样对待它。例如,我们可以将函数组合在一起。

执行周围

执行周围模式是一种常见的函数设计模式。您可能会遇到这样的情况,即您有共同的初始化和清理代码,但需要对初始化和清理代码之间运行的不同业务逻辑进行参数化。通用模式示例如 图 7-1 所示。有许多可以使用执行周围的示例情况,例如:

文件

在使用文件之前打开文件,在使用完文件后关闭文件。如果出现问题,您可能还希望记录异常。参数化代码可以从文件中读取或写入数据。

在关键部分之前获取锁,在关键部分之后释放锁。参数化代码是关键部分。

数据库连接

在初始化时打开数据库连接,在完成后关闭连接。如果您还希望池化数据库连接,这通常会更加有用,因为它还允许您的打开逻辑从池中检索连接。

执行周围模式

图 7-1. 执行周围模式

由于初始化和清理逻辑在许多地方都在使用,可能会遇到这样的情况,即此逻辑被复制。这意味着如果您想修改这些通用初始化或清理代码,那么您将不得不修改应用程序的多个不同部分。这也暴露了这些不同代码片段可能变得不一致的风险,从而在您的应用程序中引入潜在的错误。

执行周围模式通过提取一个通用方法来解决这个问题,该方法定义了初始化和清理代码。此方法接受一个参数,其中包含了在同一整体模式的不同用例中行为差异的定义。该参数将使用接口来实现,以便能够由不同的代码块实现,通常使用 lambda 表达式。

示例 7-18 展示了提取方法的具体示例。在 Twootr 中,此方法用于对数据库运行 SQL 语句。它创建一个给定 SQL 语句的预处理语句对象,然后运行我们的 extractor 行为于该语句上。extractor 只是一个回调函数,用于从数据库中提取结果,使用 PreparedStatement

示例 7-18. 在提取方法中使用执行环绕模式
    <R> R extract(final String sql, final Extractor<R> extractor) {
        try (var stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
            stmt.clearParameters();
            return extractor.run(stmt);
        } catch (SQLException e) {
            throw new IllegalStateException(e);
        }
    }

Streams

Java 中最重要的函数式编程特性集中在 Collections API 和 Streams 上。Streams 允许我们以比使用循环更高的抽象级别编写集合处理代码。Stream 接口包含一系列函数,我们将在本章中探索这些函数,每个函数对应于在 Collection 上执行的常见操作。

map()

如果你有一个将一个类型的值转换为另一个类型的值的函数,map() 允许你将此函数应用于值的流,生成另一个新值的流。

你可能已经多年使用 for 循环进行某种类型的映射操作了。在我们的 DatabaseTwootRepository 中,我们已经构建了一个用于查询的元组 String,包含用户关注的所有不同用户的 id 值。每个 id 值都是一个带引号的 String,而整个元组则用括号括起来。例如,如果他们关注的用户有 "richardwarburto""raoulUK" 的 ID,我们将生成一个元组 String,内容为 "(*richardwarburto*,*raoulOK*)"。为了生成这个元组,你可以使用映射模式,将每个 id 转换为 "*id*",然后将它们添加到一个 List 中。然后可以使用 String.join() 方法以逗号分隔它们。示例 7-19 就是以这种风格编写的代码。

示例 7-19. 使用 for 循环构建用户元组
    private String usersTupleLoop(final Set<String> following) {
        List<String> quotedIds = new ArrayList<>();
        for (String id : following) {
            quotedIds.add("'" + id + "'");
        }
        return '(' + String.join(",", quotedIds) + ')';
    }

map() 是最常用的 Stream 操作之一。示例 7-20 展示了构建用户元组的同样示例,但使用了 map()。它还利用了 joining() 收集器,允许我们将 Stream 中的元素连接成一个 String

示例 7-20. 使用 map 构建用户元组
    private String usersTuple(final Set<String> following) {
        return following
            .stream()
            .map(id -> "'" + id + "'")
            .collect(Collectors.joining(",", "(", ")"));
    }

传递给 map() 的 lambda 表达式接受一个 String 作为其唯一参数,并返回一个 String。参数和结果不必是相同类型,但传递的 lambda 表达式必须是 Function 的实例。这是一个只有一个参数的通用函数接口。

forEach()

当你想要对 Stream 中的每个值执行副作用时,forEach() 操作很有用。例如,假设你想打印用户的名称或将流中的每个事务保存到数据库中。forEach() 接受一个参数 —— 一个 Consumer 回调函数,它会在流中的每个元素上执行。

filter()

每当您循环一些数据并使用 if 语句检查每个元素时,您可能想考虑使用Stream.filter()方法。

例如,InMemoryTwootRepository 需要查询不同的 Twoot 对象以找到符合其 TwootQuery 的 twoots。具体来说,位置在上次查看的位置之后,且用户正在被关注。这种写法的示例在 Example 7-21 中显示为循环样式。

示例 7-21. 遍历 twoots 并使用 if 语句
    public void queryLoop(final TwootQuery twootQuery, final Consumer<Twoot> callback) {
        if (!twootQuery.hasUsers()) {
            return;
        }

        var lastSeenPosition = twootQuery.getLastSeenPosition();
        var inUsers = twootQuery.getInUsers();

        for (Twoot twoot : twoots) {
            if (inUsers.contains(twoot.getSenderId()) &&
                twoot.isAfter(lastSeenPosition)) {
                callback.accept(twoot);
            }
        }
    }

您可能编写过类似于这样的代码:它被称为filter模式。过滤器的中心思想是保留Stream的一些元素,同时淘汰其他元素。 Example 7-22 展示了如何以函数式风格编写相同的代码。

示例 7-22. 函数式风格
    @Override
    public void query(final TwootQuery twootQuery, final Consumer<Twoot> callback) {
        if (!twootQuery.hasUsers()) {
            return;
        }

        var lastSeenPosition = twootQuery.getLastSeenPosition();
        var inUsers = twootQuery.getInUsers();

        twoots
            .stream()
            .filter(twoot -> inUsers.contains(twoot.getSenderId()))
            .filter(twoot -> twoot.isAfter(lastSeenPosition))
            .forEach(callback);
    }

map()类似,filter()是一个只接受一个函数作为参数的方法——在这里我们使用了 lambda 表达式。这个函数做的工作与前面的 if 语句中的表达式相同。在这里,如果String以数字开头,则返回true。如果您正在重构遗留代码,则在循环中间存在 if 语句的存在很可能表明您确实想要使用 filter。因为这个函数正在执行与 if 语句相同的工作,所以它必须为给定的值返回truefalsefilter后面的Stream具有前面Stream的元素,这些元素在filter之前被求值为true

reduce()

reduce 是一种模式,对于使用循环操作集合的人来说也很熟悉。当你想要将整个值列表折叠成单个值时,就会写出这样的代码——例如,找到不同交易的所有值的总和。编写循环时,您将看到减少的一般模式显示在 Example 7-23 中。当您有一组值并且想要生成单个结果时,请使用reduce操作。

示例 7-23. 减少模式
Object accumulator = initialValue;
for (Object element : collection) {
 accumulator = combine(accumulator, element);
}

一个accumulator通过循环体被推送,accumulator的最终值是我们试图计算的值。accumulatorinitialValue开始,然后通过调用combine操作将列表的每个元素组合在一起。

这种模式的实现之间的差异在于initialValue和组合函数。在原始示例中,我们使用列表中的第一个元素作为我们的initialValue,但不一定非要这样。为了在列表中找到最短的值,我们的组合将返回当前元素和accumulator中的较短跟踪的较短值。我们现在将看看如何通过流 API 本身的操作来将这种一般模式编码化。

让我们通过添加一个功能来展示reduce操作,该功能将不同的两推组合成一个大的两推。操作将具有Twoot对象列表、Twoot的发送者以及其id作为参数。它需要将不同的内容值组合在一起,并返回组合两推的最高位置。整体代码在示例 7-24 中展示。

我们从一个新创建的Twoot对象开始,使用id、空内容和最低可能的位置——INITIAL_POSITION。然后reduce将每个元素与累加器结合在一起,在每一步都将元素与累加器组合。当我们到达最后的Stream元素时,我们的累加器包含了所有元素的总和。

lambda 表达式,即 reducer,执行组合并接受两个参数。acc是累加器,保存了已组合的先前两推。同时在Stream中传递当前的Twoot。我们的示例中的 reducer 创建了一个新的Twoot,其中包含两个位置的最大值、它们内容的连接,以及指定的idsenderId

示例 7-24. 使用 reduce 实现求和
    private final BinaryOperator<Position> maxPosition = maxBy(comparingInt(Position::getValue));

    Twoot combineTwootsBy(final List<Twoot> twoots, final String senderId, final String newId) {
        return twoots
            .stream()
            .reduce(
                new Twoot(newId, senderId, "", INITIAL_POSITION),
                (acc, twoot) -> new Twoot(
                    newId,
                    senderId,
                    twoot.getContent() + acc.getContent(),
                    maxPosition.apply(acc.getPosition(), twoot.getPosition())));
    }

当然,这些Stream操作单独来看并不那么有趣。它们在组合在一起形成管道时变得非常强大。示例 7-25 展示了从Twootr.onSendTwoot()中的一些代码,我们在这里向用户的关注者发送了两推。第一步是调用followers()方法,该方法返回一个Stream<User>。然后我们使用filter操作找到实际登录的用户,这些用户我们想要发送两推给他们。接着我们使用forEach操作产生期望的副作用:向用户发送一条两推并记录结果。

示例 7-25. 在 onSendTwoot 方法中使用 Stream
        user.followers()
            .filter(User::isLoggedOn)
            .forEach(follower ->
            {
                follower.receiveTwoot(twoot);
                userRepository.update(follower);
            });

可选

Optional是 Java 8 引入的核心 Java 库数据类型,旨在提供比null更好的替代方案。对于旧的 null 值存在相当多的厌恶情绪。即使是发明这个概念的人,Tony Hoare,也将其描述为“我的十亿美元错误”。这就是作为一名有影响力的计算机科学家的麻烦之处——你甚至可能在看不到十亿美元的情况下犯下十亿美元的错误!

null通常用来表示值的缺失,而Optional则是用来替代这种用法。使用null表示缺失值的问题在于可怕的NullPointerException。如果引用一个为null的变量,你的代码就会崩溃。Optional的目标是双重的。首先,它鼓励程序员适当地检查变量是否缺失,以避免错误。其次,它在类的 API 中文档化了预期缺失的值。这使得更容易看到哪些值是被隐藏的。

让我们看一下Optional的 API,以便了解如何使用它。 如果你想从一个值创建一个Optional实例,有一个名为of()的工厂方法。 现在,Optional是这个值的一个容器,可以用get来取出,如 Example 7-26 所示。

Example 7-26. 从一个值创建一个 Optional
Optional<String> a = Optional.of("a");

assertEquals("a", a.get());

因为Optional也可以表示一个不存在的值,所以还有一个名为empty()的工厂方法,你可以使用ofNullable()方法将可空值转换为Optional。 你可以在 Example 7-27 中看到这两种方法,以及isPresent()方法的使用,它指示Optional是否持有一个值。

Example 7-27. 创建一个空的 Optional 并检查它是否包含一个值
Optional emptyOptional = Optional.empty();
Optional alsoEmpty = Optional.ofNullable(null);

assertFalse(emptyOptional.isPresent());

// a is defined above
assertTrue(a.isPresent());

使用Optional的一种方法是在调用get()之前通过检查isPresent()来保护任何调用 —— 这是必需的,因为调用get()可能会抛出一个NoSuchElementException。 不幸的是,这种方法并不是一个很好的使用Optional的编码模式。 如果你以这种方式使用它,你实际上只是复制了使用null的现有模式 —— 在这种模式中,你会检查一个值是否不是null作为守卫。

一个更简洁的方法是调用orElse()方法,它提供了一个替代值,以防Optional为空。 如果创建替代值的计算成本很高,应该使用orElseGet()方法。 这允许您传入一个Supplier函数,只有在Optional真正为空时才调用该函数。 这两种方法都在 Example 7-28 中演示。

Example 7-28. 使用 orElse()和 orElseGet()
assertEquals("b", emptyOptional.orElse("b"));
assertEquals("c", emptyOptional.orElseGet(() -> "c"));

Optional还定义了一系列可以像StreamAPI 一样使用的方法;例如,filter()map()ifPresent()。 您可以将这些方法想象为类似于StreamAPI 的OptionalAPI,但在这种情况下,您的Stream只能包含 1 个或 0 个元素。 因此,如果满足条件,Optional.filter()将在Optional中保留一个元素,并且如果Optional之前为空或谓词未能应用,则返回一个空的Optional。 同样,map()转换Optional中的值,但如果它为空,则根本不应用该函数。 这就是这些函数比使用null更安全的地方 —— 它们仅在Optional中确实有内容时才操作OptionalifPresentforEachOptional对偶 —— 如果有值存在,它将应用Consumer回调,但否则不会。

您可以在 Example 7-29 中看到来自 Twootr.onLogon() 方法的代码片段。这是一个示例,展示了如何组合这些不同的操作以执行更复杂的操作。我们首先通过调用 UserRepository.get() 根据用户 ID 查找 User,该方法返回一个 Optional。然后我们使用 filter 验证用户的密码匹配。我们使用 ifPresent 通知用户他们错过的 twoots。最后,我们将 User 对象映射为一个新的 SenderEndPoint 并从方法中返回。

Example 7-29. 在 onLogon 方法中使用 Optional
        var authenticatedUser = userRepository
            .get(userId)
            .filter(userOfSameId ->
            {
                var hashedPassword = KeyGenerator.hash(password, userOfSameId.getSalt());
                return Arrays.equals(hashedPassword, userOfSameId.getPassword());
            });

        authenticatedUser.ifPresent(user ->
        {
            user.onLogon(receiverEndPoint);
            twootRepository.query(
                new TwootQuery()
                    .inUsers(user.getFollowing())
                    .lastSeenPosition(user.getLastSeenPosition()),
                user::receiveTwoot);
            userRepository.update(user);
        });

        return authenticatedUser.map(user -> new SenderEndPoint(user, this));

在这一部分,我们只是浅尝辄止了函数式编程的表面。如果你有兴趣深入学习函数式编程,我们推荐阅读Java 8 In ActionJava 8 Lambdas

用户界面

在本章中,我们避免过多讨论系统的用户界面,因为我们专注于核心问题域的设计。尽管如此,了解示例项目作为其 UI 的一部分提供了什么,有助于理解事件建模如何组合在一起。在我们的示例项目中,我们提供了一个单页面网站,使用 JavaScript 实现其动态功能。为了保持简单,并且不深入探讨各种框架之争,我们只是使用 jquery 来更新原始 HTML 页面,并在代码中保持了简单的关注点分离。

当您浏览到 Twootr 网页时,它会使用 WebSockets 连接回主机。这些是我们在 “From Events to Design” 中讨论的事件通信选择之一。所有与其通信的代码都位于 chapter_06web_adapter 子包中。WebSocketEndPoint 类实现了 ReceiverEndPoint,并在 SenderEndPoint 上调用任何需要的方法。例如,当 ReceiverEndPoint 接收并解析要关注另一个用户的消息时,它调用 SenderEndPoint.onFollow(),通过用户名传递。返回的 enumFollowStatus 然后被转换为一种线格式的响应并写入 WebSocket 连接。

JavaScript 前端与服务器之间的所有通信都使用 JavaScript Object Notation (JSON) standard。选择 JSON 是因为 JavaScript UI 非常容易对其进行反序列化或序列化。

WebSocketEndPoint 内部,我们需要在 Java 代码中进行 JSON 的映射。有许多库可以用于此目的,这里我们选择了 Jackson 库,这是一种常用且维护良好的库。JSON 在采用请求/响应方式而不是事件驱动方式的应用程序中经常被使用。在我们的情况下,我们手动从 JSON 对象中提取字段,以保持简单性,但也可以使用更高级的 JSON API,如绑定 API。

依赖反转和依赖注入

在本章中我们讨论了很多关于解耦模式的内容。我们的整体应用程序使用了端口和适配器模式以及仓储模式,将业务逻辑与实现细节分离开来。事实上,当我们看到这些模式时,我们可以想到一个大的、统一的原则——依赖反转。依赖反转原则是我们在这本书中讨论的五个 SOLID 原则中的最后一个,像其他原则一样,它也是由 Robert Martin 引入的。它指出:

  • 高级模块不应依赖于低级模块。两者都应依赖于抽象。

  • 抽象不应依赖于细节,细节应依赖于抽象。

这个原则之所以称为反转,是因为在传统的命令式、结构化编程中,高级模块通常组合以生成低级模块。这往往是我们在本章讨论的自顶向下设计的一个副作用。你将一个大问题分解成不同的子问题,为每个子问题编写一个模块来解决,然后主问题(高级模块)依赖于子问题(低级模块)。

在 Twootr 的设计中,我们通过引入抽象来避免了这个问题。我们有一个高级入口点类,名为 Twootr,它不依赖于低级模块,比如我们的 DataUserRepository。它依赖于抽象——UserRepository 接口。在 UI 端口上也是如此。Twootr 不依赖于 WebSocketEndPoint,而是依赖于 ReceiverEndPoint。我们编程时依赖接口,而不是具体实现。

一个相关的术语是依赖注入,或者简称DI。为了理解 DI 是什么以及为什么我们需要它,让我们对我们的设计进行一个思想实验。我们的架构已经确定,主要的 Twootr 类需要依赖于 UserRepositoryTwootRepository 来存储 UserTwoot 对象。我们在 Twootr 内部定义了字段来存储这些对象的实例,如 Example 7-30 所示。问题是,我们如何实例化它们?

示例 7-30. Twootr 类内的依赖项
public class Twootr
{

    private final TwootRepository twootRepository;
    private final UserRepository userRepository;

我们用于填充字段的第一种策略是尝试使用new关键字调用构造函数,如示例 7-31 所示。在这里,我们在代码库中硬编码了使用基于数据库的存储库。现在类中的大部分代码仍然以接口编程,因此我们可以相当容易地更改这里的实现,而无需替换所有代码,但这有点不太优雅。我们必须始终使用数据库存储库,这意味着我们Twootr类的测试依赖于数据库并且运行速度较慢。

不仅如此,如果我们想要将不同版本的 Twootr 交付给不同的客户,例如为企业客户提供使用 SQL 的内部版 Twootr,以及使用 NoSQL 后端的云版本,我们将不得不从两个不同版本的代码库中构建。仅仅定义接口和分离实现是不够的,我们还必须有一种方法来正确地连接适当的实现,以确保不破坏我们的抽象和解耦方法。

示例 7-31. 硬编码字段的实例化
public Twootr()
{
    this.userRepository = new DatabaseUserRepository();
    this.twootRepository = new DatabaseTwootRepository();
}

// How to start Twootr
Twootr twootr = new Twootr();

用于实例化不同依赖项的常用设计模式是抽象工厂设计模式。示例 7-32 展示了这种模式,我们有一个工厂方法可以使用getInstance()方法创建接口的实例。当我们想要设置正确的实现时,我们可以调用setInstance()。例如,我们可以在测试中使用setInstance()创建内存中的实现,在本地安装中使用 SQL 数据库,在云环境中使用 NoSQL 数据库。我们已经将实现与接口解耦,并且可以在任何需要的地方调用这些连接代码。

示例 7-32. 使用工厂创建实例
public Twootr()
{
    this.userRepository = UserRepository.getInstance();
    this.twootRepository = TwootRepository.getInstance();
}

// How to start Twootr
UserRepository.setInstance(new DatabaseUserRepository());
TwootRepository.setInstance(new DatabaseTwootRepository());
Twootr twootr = new Twootr();

不幸的是,这种工厂方法的方法也有其缺点。首先,我们现在创建了一个大的共享可变状态球。任何需要在单个 JVM 中运行具有不同依赖关系的多个Twootr实例的情况都是不可能的。我们还将生命周期耦合在一起——也许有时我们希望在启动Twootr时实例化一个新的TwootRepository,或者有时我们希望重用一个现有的实例。工厂方法的方法不会直接让我们这样做。在我们的应用程序中为每个想要创建的依赖关系都创建一个工厂也可能变得相当复杂。

这就是依赖注入发挥作用的地方。DI 可以被视为好莱坞代理人方法的一个例子——不要打电话给我们,我们会打电话给你。使用 DI,你不是显式地创建依赖项或使用工厂来创建它们,而是简单地接受一个参数,任何实例化你的对象的东西都有责任传递所需的依赖项。这可能是一个测试类的设置方法传入一个模拟对象,也可能是你的应用程序的main()方法传入一个 SQL 数据库实现。在Twootr类中使用这个示例见示例 7-33。依赖反转是一种策略;依赖注入和仓储模式是具体的战术。

示例 7-33. 使用依赖注入创建实例
public Twootr(final UserRepository userRepository, final TwootRepository twootRepository)
{
    this.userRepository = userRepository;
    this.twootRepository = twootRepository;
}

// How to start Twootr
Twootr twootr = new Twootr(new DatabaseUserRepository(), new DatabaseTwootRepository());

以这种方式处理对象不仅使得为对象编写测试变得更容易,而且外部化了对象本身的创建过程。这允许你的应用程序代码或框架控制UserRepository的创建时间以及注入到其中的依赖项。许多开发人员发现使用诸如 Spring 和 Guice 等提供许多高级特性的 DI 框架非常方便。例如,它们可以为 bean 定义生命周期,标准化对象实例化后或在需要时销毁前调用的钩子。它们还可以为对象提供作用域,例如仅在进程生命周期内实例化一次的 Singleton 对象或每个请求的对象。此外,这些 DI 框架通常能够很好地与诸如 Dropwizard 或 Spring Boot 之类的 Web 开发框架集成,并提供即开即用的高效开发体验。

包和构建系统

Java 允许你将代码库拆分成不同的包。在本书中,我们将每个章节的代码放入其自己的包中,而 Twootr 是第一个在项目本身中拆分出多个子包的项目。

这里是可以查看项目内不同组件的包:

  • com.iteratrlearning.shu_book.chapter_06是项目的顶层包。

  • com.iteratrlearning.shu_book.chapter_06.database包含了 SQL 数据库持久化的适配器。

  • com.iteratrlearning.shu_book.chapter_06.in_memory包含了内存持久化的适配器。

  • com.iteratrlearning.shu_book.chapter_06.web_adapter包含了基于 WebSockets 的 UI 适配器。

将大型项目拆分为不同的包有助于组织代码,使开发人员更容易找到所需内容。就像类将相关方法和状态分组在一起一样,包将相关类分组在一起。包应遵循与类相似的耦合和内聚规则。当可能同时更改且与同一结构相关联时,将类放在同一个包中。例如,在 Twootr 项目中,如果我们想要修改 SQL 数据库持久化代码,我们知道要进入database子包。

包还可以实现信息隐藏。我们在 示例 4-3 中讨论了有一个包作用域构造方法的想法,以防止对象在包外实例化。我们还可以对类和方法进行包作用域。这可以防止包外的对象访问类的细节,并帮助我们实现松耦合。例如,WebSocketEndPointweb_adapter 包中实现了 ReceiverEndPoint 接口的包作用域实现。项目中的其他代码不应直接与这个类交互,只能通过作为端口的 ReceiverEndPoint 接口进行交互。

我们在 Twootr 中每个适配器都有一个包的方法很好地符合我们在整个模块中使用的六边形架构模式。然而,并非每个应用程序都是六边形的,其他项目可能会遇到两种常见的包结构。

一种常见的包结构方法是按层次结构化它们,例如,将所有生成网站 HTML 视图的代码放在views包中,并将处理网页请求相关的所有代码放在controller包中。尽管这种方法很流行,但它可能导致耦合性和内聚性不佳。如果要修改现有网页以添加额外参数并基于该参数显示值,则需要修改controllerview包,以及可能还有其他几个包。

另一种代码结构的替代方法是按特性组织代码。例如,如果您编写电子商务网站,可以为购物车使用一个cart包,为产品列表相关的代码使用一个product包,为接受信用卡支付相关的代码使用一个payment包,等等。这通常会更具内聚性。如果要添加支持通过 Mastercard 和 Visa 接收付款,则只需修改payment包即可。

在“使用 Maven”中,我们讨论了如何使用 Maven 构建工具设置基本的构建结构。在本书的项目结构中,我们有一个 Maven 项目,而书中的不同章节则是该项目中的不同 Java 包。这是一个简单而清晰的项目结构,适用于各种不同的软件项目,但不是唯一的选择。Maven 和 Gradle 都提供了从单个顶级项目构建和输出多个构建产品的项目结构。

如果你想部署不同的构建产品,这是有道理的。例如,假设你有一个客户端/服务器项目,你希望有一个单一的构建同时构建客户端和服务器,但客户端和服务器是运行在不同机器上的不同二进制文件。不过,最好不要过于深思熟虑或过于模块化构建脚本。

这些是你和你的团队经常在自己的机器上运行的东西,最高优先级是它们要简单、快速和易于使用。这就是为什么我们选择在整本书中只有一个单一项目,而不是每个项目一个子模块的路线。

限制和简化

你已经看到了我们如何实现 Twootr,并了解了我们沿途做出的设计决策,但这是否意味着我们迄今为止看到的 Twootr 代码库是唯一或最佳的写法呢?当然不是!事实上,我们的方法存在一些限制和我们故意采取的简化,以便将代码库解释在单一章节中。

首先,我们把 Twootr 写成了只能在单个线程上运行的形式,并完全忽略了并发的问题。实际上,我们可能希望在我们的 Twootr 实现中有多个线程响应和发出事件。这样我们就可以利用现代多核 CPU,并在一台服务器上为更多的客户提供服务。

从更宏观的角度来看,我们也忽略了任何能够在服务器宕机时使我们的服务继续运行的故障转移。我们也忽略了可扩展性。例如,要求所有的 Twoot 都有一个单一定义的顺序,在单一服务器上实现起来既容易又高效,但会带来严重的可扩展性/争用瓶颈。同样地,当你登录时看到所有的 Twoot 也会导致瓶颈。如果你去度假一周,当你重新登录时会得到 20000 条 Twoot,这会怎么样!

对这些问题进行详细的讨论超出了本章的范围。然而,如果你希望在 Java 方面深入学习,这些是重要的话题,并且我们计划在本系列的未来书籍中更详细地讨论它们。

总结

  • 现在你可以使用存储库模式将数据存储与业务逻辑解耦。

  • 你已经看到了在这种方法中实现的两种不同类型的存储库。

  • 你已经介绍了函数式编程的概念,包括 Java 8 的 Streams。

  • 你已经看到如何结构化一个具有不同包的较大项目。

迭代你自己

如果你想扩展并巩固这一节的知识,你可以尝试以下其中一项活动。

假设我们对 Twootr 采用了拉取模型。与其通过 WebSockets 持续向基于浏览器的客户端推送消息,我们可以使用 HTTP 来定期轮询获取自某个位置以来的最新消息。

  • 思考我们的设计会如何改变。尝试绘制不同类之间数据流动的图表。

  • 使用 TDD 实现 Twootr 的这种替代模型。你不需要实现 HTTP 部分,只需按照这种模型实现底层类。

完成挑战

我们开发了这个产品,它运行良好。不幸的是,当 Joe 推出时,他意识到有人叫 Jack 发布了一个类似的产品,名字也很相似,获得了数十亿美元的风投资金和数亿用户。事实上,Jack 只比 Joe 早了 11 年到达这个地步;对 Joe 来说真是个倒霉的运气。