抽象出外部系统

在本节中,我们将考虑在应用六边形架构方法时需要做出的一些决策。我们将逐步处理外部系统,首先决定领域模型需要什么,然后找出隐藏其技术细节的正确抽象。我们将考虑两个常见的外部系统:Web 请求和数据库访问。

决定我们的领域模型需要什么

我们设计的第一步是从领域模型开始。我们需要为领域模型设计一个合适的端口,以便与外部系统进行交互。这个端口必须摆脱任何外部系统的细节,同时必须回答我们的应用程序需要这个系统的原因。我们正在创建一个抽象。

思考抽象的一个好方法是思考如果我们改变执行任务的方式,什么会保持不变。假设我们想在午餐时喝热汤。我们可能会在炉子上的锅里加热,或者用微波炉加热。无论我们选择哪种方式,我们所做的事情是相同的。我们正在加热汤,这就是我们要找的抽象。

在软件系统中,我们通常不会加热汤,除非我们正在构建一个自动售汤机。但我们会使用几种常见的抽象类型,这是因为在构建典型的 Web 应用程序时,通常会使用一些常见的外部系统。第一个也是最明显的是与 Web 本身的连接。在大多数应用程序中,我们会遇到某种数据存储,通常是第三方数据库系统。对于许多应用程序,我们还会调用另一个 Web 服务。反过来,该服务可能会调用我们公司内部的一系列其他服务。另一个典型的 Web 服务调用是调用第三方 Web 服务提供商,例如信用卡支付处理器。

让我们看看如何抽象这些常见的外部系统。

抽象 Web 请求和响应

我们的应用程序将响应 HTTP 请求和响应。我们需要设计的端口以领域模型的方式表示请求和响应,剥离 Web 技术。

我们的销售报告示例可以引入这些概念作为两个简单的领域对象。这些请求可以由一个 RequestSalesReport 类表示:

package com.sales.domain;
import java.time.LocalDate;

public class RequestSalesReport {
    private final LocalDate start;
    private final LocalDate end;

    public RequestSalesReport(LocalDate start, LocalDate end) {
        this.start = start;
        this.end = end;
    }

    public SalesReport produce(SalesReporting reporting) {
        return reporting.reportForPeriod(start, end);
    }
}

在这里,我们可以看到请求的领域模型的关键部分:

  • 我们请求的内容——即销售报告,体现在类名中

  • 请求的参数——即报告期间的开始和结束日期

我们还可以看到响应的表示方式:

  • SalesReport 类将包含请求的原始信息

我们还可以看到哪些内容没有出现:

  • Web 请求中使用的数据格式

  • HTTP 状态码,如200 OK

  • HTTPServletRequestHttpServletResponse 或等效的框架对象

这是一个纯粹的领域模型表示,表示在两个日期之间请求销售报告。没有任何迹象表明这是来自 Web 的请求,这一点非常有用,因为我们可以从其他输入源(如桌面 GUI 或命令行)请求它。更好的是,我们可以在单元测试中轻松创建这些领域模型对象。

前面的示例展示了一种面向对象的 “告诉而不询问” 的方法。我们同样可以选择函数式编程(FP)方法。如果选择 FP 方法,我们会将请求和响应表示为纯数据结构。Java 17 中添加的 record 功能非常适合表示这样的数据结构。重要的是,请求和响应纯粹以领域模型的术语编写——不应该出现任何 Web 技术。

抽象数据库

没有数据,大多数应用程序并不是特别有用。没有数据存储,它们会变得对我们提供的数据健忘。访问数据存储(如关系数据库和 NoSQL 数据库)是 Web 应用程序开发中的常见任务。

在六边形架构中,我们首先设计领域模型将与之交互的端口,再次以纯粹的领域术语进行设计。创建数据库抽象的方法是思考需要存储什么数据,而不是如何存储。

数据库端口有两个组成部分:

  • 一个接口,用于反转对数据库的依赖。

    这个接口通常称为 仓库(Repository)。它也被称为 数据访问对象(DAO)。无论名称如何,它的工作是将领域模型与数据库及其访问技术的任何部分隔离开来。

  • 表示数据本身的值对象,以领域模型的术语表示。

    值对象的存在是为了在地方之间传输数据。两个持有相同数据值的值对象被认为是相等的。它们非常适合在数据库和我们的代码之间传输数据。

回到我们的销售报告示例,我们仓库的一个可能设计如下:

package com.sales.domain;

public interface SalesRepository {
    List<Sale> allWithinDateRange(LocalDate start, LocalDate end);
}

在这里,我们有一个名为 allWithinDateRange() 的方法,允许我们获取特定日期范围内的所有销售交易。数据以 java.util.List 的形式返回,其中包含简单的 Sale 值对象。这些是完全功能的领域模型对象。它们可能具有执行某些关键应用程序逻辑的方法。它们可能只是基本的数据结构,可能使用 Java 17 的 record 结构。这个选择是我们决定在特定情况下什么是良好工程设计的部分工作。

同样,我们可以看到哪些内容没有出现:

  • 数据库连接字符串

  • JDBC 或 JPA API 细节——标准的 Java 数据库连接库

  • SQL 查询(或 NoSQL 查询)

  • 数据库模式和表名

  • 数据库存储过程细节

我们的仓库设计专注于领域模型需要数据库提供什么,但不限制它如何提供。因此,在设计仓库时,必须做出一些有趣的决定,涉及我们在数据库中投入多少工作,以及我们在领域模型中投入多少工作。例如,我们是否会在数据库适配器中编写复杂的查询,或者我们会编写更简单的查询并在领域模型中执行额外的工作。同样,我们是否会在数据库中使用存储过程?

无论我们在这些决定中做出什么权衡,数据库适配器是所有这些决策的所在地。适配器是我们看到数据库连接字符串、查询字符串、表名等的地方。适配器封装了数据模式和数据库技术的设计细节。

抽象对Web服务的调用

调用其他 Web 服务是一项频繁的开发任务。示例包括调用支付处理器和地址查找服务。有时,这些是第三方外部服务,有时它们位于我们的 Web 服务舰队内部。无论哪种方式,它们通常需要从我们的应用程序发出一些 HTTP 调用。

抽象这些调用的过程与抽象数据库类似。我们的端口由一个接口和一些值对象组成,接口用于反转对我们调用的 Web 服务的依赖,值对象用于传输数据。

例如,抽象调用像 Google Maps 这样的地图 API 的示例可能如下所示:

package com.sales.domain;

public interface MappingService {
    void addReview(GeographicLocation location, Review review);
}

我们有一个表示 MappingService 整体的接口。我们添加了一个方法,用于在我们最终使用的任何服务提供商上添加对特定位置的评论。我们使用 GeographicLocation 来表示一个地方,以我们的术语定义。它可能包含一对纬度和经度,或者基于邮政编码。这是另一个设计决策。同样,我们没有看到底层地图服务或其 API 细节。这些代码位于适配器中,适配器将连接到真正的外部地图 Web 服务。

这种抽象为我们提供了使用 测试替身(test double)来替代外部服务的能力,并能够在未来更换服务提供商。你永远不知道外部服务何时会关闭或变得过于昂贵而无法使用。通过使用六边形架构,保持我们的选择开放是很好的。

本节介绍了在六边形架构中与外部系统交互时最常见的任务的一些思路。在下一节中,我们将讨论在领域模型中编写代码的一般方法。