为什么叫六边形?

边形建筑的名称来自此建筑通常描述的方式:

The hexagonal architecture

image-20211011114125966

我们将回到为什么六边形在本文的后面使用。这种模式还属于端口和适配器(这更好地解释了它背后的中心思想)和洋葱结构(因为它是如何分层的)。

在下面,我们将仔细看看"洋葱"。我们将从核心 (领域模型) 开始,然后自己解决,一层在时间,直到我们到达适配器和系统和客户与他们互动。

六边形 vs. 传统层

一旦我们深入挖掘六边形结构,你会发现它与更传统的分层结构有几个相似之处。事实上,你可以把六边形的结构看作是分层建筑的演变。然而,在系统如何与外部世界相互作用方面,存在一些差异。为了更好地理解这些差异,让我们从分层架构的回顾开始:

The traditional layered architecture

其原理是系统由堆叠在彼此之上的层组成。较高的层可以与下层相互作用*,但不会相反*。通常,在领域驱动的分层架构中,将具有 UI 层的顶部。此层又与应用服务层进行交互,该应用服务层与生活在领域层中的领域模型进行交互。在底部,我们有一个基础设施层,与外部系统(如数据库)进行通信。

在六边形系统中,你会发现应用程序层和领域层仍然大致相同。但是,UI 层和基建层的处理方式大相径庭。继续阅读,找出如何。

领域模型

六边形架构的核心是领域模型,它使用我们上一篇文章中涵盖的战术 DDD 构建基块实现。这就是所谓的商业逻辑的所在,所有商业决策都在这里。这也是软件中最稳定的部分,希望变化最小(当然除非业务本身发生变化)。

领域模型一直是本系列前两篇文章的主题,因此我们不会再在此处覆盖它。但是,如果没有与领域名交互的方法,仅单是领域模型并不能提供任何价值。为此,我们必须在"洋葱"中向上移动到下一层。

应用服务

应用程序服务充当客户与领域模型进行交互的门面。应用服务具有以下特点:

  • 他们是无边界的
  • 它们强制执行系统安全
  • 他们控制数据库交易
  • 他们策划业务运营,但不做出任何业务决策(即,它们不包含任何业务逻辑)

让我们仔细看看这意味着什么。

无边界

应用程序服务不保持任何可以通过与客户互动而更改的内部状态。执行操作所需的所有信息应作为应用程序服务方法的输入参数提供。这将使系统更简单,更容易调试和缩放。

如果发现自己处于一种情况,即必须在单个业务流程中拨打多个应用程序服务呼叫,则可以将业务流程建模为自己的一类,并将该过程的实例作为输入参数传递到应用程序服务方法中。然后,该方法将施展其魔力,并返回业务流程对象的更新实例,该对象可用作其他应用服务方法的输入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class MyBusinessProcess {
    // Current process state
}

public interface MyApplicationService {

    MyBusinessProcess performSomeStuff(MyBusinessProcess input);

    MyBusinessProcess performSomeMoreStuff(MyBusinessProcess input);
}

还可以使业务流程对象变异,并让应用服务方法直接更改对象的状态。我个人不喜欢这种方法,因为我相信它会导致不必要的副作用,特别是如果交易最终回滚。这取决于客户端口如何调用应用程序服务,并将在稍后有关端口和适配器的部分返回此事项。

安全执行

应用程序服务确保允许当前用户执行有关操作。从技术上讲,可以在每个应用服务方法的顶部手动执行此操作,或者使用更复杂的东西(如 AOP)。只要安全性发生在应用程序服务层中而不是领域模型内,则如何执行安全性并不重要。为什么这很重要?

当我们在应用程序中谈论安全性时,我们倾向于更强调防止未经授权的访问,而不是允许授权访问。因此,我们在系统中添加的任何安全检查基本上都会使使用更加困难。如果我们将这些安全检查添加到领域模型中,我们可能会发现自己处于无法执行重要操作的状态,因为在添加安全检查时我们没有想到这一点,而现在它们阻碍了我们。通过将所有安全检查排除在领域模型之外,我们得到了一个更灵活的系统,因为我们可以以任何我们想要的方式与领域模型进行交互。系统仍然是安全的,因为无论如何,所有客户都必须通过应用程序服务。创建新的应用程序服务比更改领域模型要容易得多。

代码示例

下面是两个 Java 示例,说明应用程序服务中的安全执行可能是什么样子。该代码尚未经过测试,应被视为伪代码,而不是实际的 Java 代码。

第一个例子演示了声明性安全执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Service
class MyApplicationService {

    @Secured("ROLE_BUSINESS_PROCESSOR") // <1>
    public MyBusinessProcess performSomeStuff(MyBusinessProcess input) {
        var customer = customerRepository.findById(input.getCustomerId()) // <2>
            .orElseThrow( () -> new CustomerNotFoundException(input.getCustomerId()));
        var someResult = myDomainService.performABusinessOperation(customer); // <3>
        customer = customerRepository.save(customer);
        return input.updateMyBusinessProcessWithResult(someResult); // <4>
    }
}
  1. 注释指示框架仅允许具有该角色的经过验证的用户调用该方法。ROLE_BUSINESS_PROCESSOR
  2. 应用程序服务从领域模型中的存储库查找聚合根。
  3. 应用程序服务将聚合根传递到领域模型中的领域服务,存储结果(无论其是什么)。
  4. 应用程序服务使用领域服务的结果更新业务流程对象并将其返回,以便将其传递给参与同一长期流程的其他应用服务方法。

第二个例子演示了手动安全执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@Service
class MyApplicationService {

    public MyBusinessProcess performSomeStuff(MyBusinessProcess input) {
        // We assume SecurityContext is a thread-local class that contains information
        // about the current user.
        if (!SecurityContext.isLoggedOn()) { // <1>
            throw new AuthenticationException("No user logged on");
        }
        if (!SecurityContext.holdsRole("ROLE_BUSINESS_PROCESSOR")) { // <2>
            throw new AccessDeniedException("Insufficient privileges");
        }

        var customer = customerRepository.findById(input.getCustomerId())
            .orElseThrow( () -> new CustomerNotFoundException(input.getCustomerId()));
        var someResult = myDomainService.performABusinessOperation(customer);
        customer = customerRepository.save(customer);
        return input.updateMyBusinessProcessWithResult(someResult);
    }
}
  1. 在真正的应用程序中,如果用户未登录,可能会创建帮助方法,以抛出例外。我只在此示例中包含了一个更冗长的版本,以显示需要检查的内容。
  2. 与前一种情况一样,只有具有该角色的用户才能调用该方法。ROLE_BUSINESS_PROCESSOR

交易管理

每个应用程序服务方法的设计方式应是,它形成自己的单一交易,无论基础数据存储是否使用交易。如果应用服务方法成功,则除非明确调用其他撤销操作的应用程序服务(如果甚至存在此类方法),否则无法撤消该方法。

如果发现自己处于想要在同一事务中调用多个应用程序服务方法的状态,则应检查应用服务的粒度是否正确。也许的应用程序服务正在做的一些事情实际上应该在领域服务中代替?可能还需要考虑重新设计的系统,以使用最终的一致性,而不是强大的一致性(有关此的更多信息,请查看有关战术领域驱动设计的前一篇文章)。

从技术上讲,可以在应用程序服务方法内手动处理交易,也可以使用由 Spring 和 Java EE 等框架和平台提供的声明易。

代码示例

下面是两个 Java 示例,说明应用程序服务中的交易管理可能是什么样子。该代码尚未经过测试,应被视为伪代码,而不是实际的 Java 代码。

第一个例子演示了声明易管理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Service
class UserAdministrationService {

    @Transactional // <1>
    public void resetPassword(UserId userId) {
        var user = userRepository.findByUserId(userId); // <2>
        user.resetPassword(); // <3>
        userRepository.save(user);
    }
}
  1. 该框架将确保整个方法在单个交易内运行。如果抛出例外,则交易将回滚。否则,当方法返回时,就会提交该方法。
  2. 应用程序服务调用领域模型中的存储库以查找聚合根。User
  3. 应用程序服务在聚合根上调用业务方法。User

第二个例子演示了更改交易管理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Service
class UserAdministrationService {

    @Transactional
    public void resetPassword(UserId userId) {
        var tx = transactionManager.begin(); // <1>
        try {
            var user = userRepository.findByUserId(userId);
            user.resetPassword();
            userRepository.save(user);
            tx.commit(); // <2>
        } catch (RuntimeException ex) {
            tx.rollback(); // <3>
            throw ex;
        }
    }
}
  1. 交易管理器已注入应用服务,以便服务方法能够明确启动新交易。
  2. 如果一切正常,则在重置密码后进行交易。
  3. 如果发生错误,则交易将回滚,例外情况将重新出现。

配器

正确安排也许是设计良好应用服务最困难的部分。这是因为需要确保不会意外地将业务逻辑引入应用程序服务,即使认为只是在做编排。那么,在这种情况下,编排意味着什么呢?

通过编排,我的意思是查找和调用正确的领域对象在正确的顺序,传递正确的输入参数,并返回正确的输出。在其最简单的形式中,应用程序服务可以根据 ID 查找聚合,在该聚合上调用方法,保存该聚合并返回。但是,在更复杂的情况下,该方法可能需要查找多个聚合、与领域服务交互、执行输入验证等。如果发现自己正在编写长期应用服务方法,应该问自己以下问题:

  • 是做出业务决策的方法还是要求领域模型做出决策的方法?
  • 某些代码应该移动到领域事件收听器吗?

话虽如此,有一些商业逻辑最终在应用程序服务方法是不是世界末日。它仍然非常接近领域模型,封装良好,应该很容易在以后重新塑造到领域模型。不要浪费太多的宝贵的时间去思考,如果不清楚,应该将某些东西进入领域模型还是进入应用程序服务。

代码示例

下面是一个 Java 示例,说明了典型的编排可能是什么样子。该代码尚未经过测试,应被视为伪代码,而不是实际的 Java 代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Service
class CustomerRegistrationService {

    @Transactional // <1>
    @PermitAll // <2>
    public Customer registerNewCustomer(CustomerRegistrationRequest request) {
        var violations = validator.validate(request); // <3>
        if (violations.size() > 0) {
            throw new InvalidCustomerRegistrationRequest(violations);
        }
        customerDuplicateLocator.checkForDuplicates(request); // <4>
        var customer = customerFactory.createNewCustomer(request); // <5>
        return customerRepository.save(customer); // <6>
    }
}
  1. 应用程序服务方法在交易内运行。
  2. 任何用户都可以访问应用程序服务方法。
  3. 我们调用 JSR-303 验证器,以检查传入的注册请求是否包含所有必要的信息。如果请求无效,我们会抛出一个例外,该例外将被报告给用户。
  4. 我们调用一个领域名服务,该服务将检查数据库中是否已有具有相同信息的客户。如果是这样的话,领域名服务将抛出一个例外(此处未显示),该例外将传回用户。
  5. 我们调用一个领域名工厂,该工厂将使用来自注册请求对象的信息创建新的聚合。Customer
  6. 我们调用领域存储库以保存客户并返回新创建和保存的客户聚合根。

领域事件监听

在之前关于战术领域驱动设计的文章中,我们谈到了领域事件和领域事件监听。但是,我们没有讨论领域事件监听融入整个系统架构的位置。我们从上一篇文章中回顾,领域事件听者不应首先影响发布事件的方法的结果。实际上,这意味着领域事件听者应在其自己的交易中运行。

因此,我认为领域事件收听者是一种特殊的应用程序服务,不是由客户端调用,而是由领域事件调用。换句话说:领域事件监听属于应用程序服务层,而不是领域模型内。这也意味着领域事件监听是不应包含任何业务逻辑的编排器。根据发布特定领域事件时需要发生的情况,可能需要创建单独的领域服务,如果前进的路径不止一条,则决定如何处理它。

话虽如此,在前一篇文章中关于聚合的章节中,我提到有时改变同一交易中的多个聚合可能合理,即使这有悖于聚合设计准则。我还提到,最好通过领域事件来完成此工作。在这种情况下,领域事件监听必须参与当前交易,从而影响发布事件的方法的结果,从而违反领域事件和应用程序服务的设计准则。只要你有意这样做,并且意识到你将来可能面临的后果,这不是世界末日。有时候你只需要务实。

输入和输出

在设计应用程序服务时,一个重要的决定是决定要使用哪些数据(方法参数)以及要返回哪些数据。有三种选择:

  1. 直接从领域模型中使用实体和值对象。
  2. 使用单独的数据传输对象 (DTO)。
  3. 使用领域有效载荷对象 (DPOs),这是上述两者的组合。

每个替代方案都有其利弊,因此让我们仔细研究一下每个备选方案。

实体和聚合

在第一种备选方案中,应用程序服务会返回整个聚合(或其部分)。客户端可以随心所欲地使用它们执行任何计划,当需要保存更改时,聚合(或其部分)将作为参数传回应用服务。

当领域模型贫血(即仅包含数据且没有业务逻辑)且聚合体较小且稳定(在不久的将来不太可能发生太大变化)时,此替代方案效果最佳。

如果客户端将通过 REST 或 SOAP 访问系统,并且聚合可以轻松地序列化到 JSON 或 XML 并返回,则该系统也有效。在这种情况下,客户实际上不会直接与的聚合体进行交互,而是使用可能以完全不同的语言实现的聚合的 JSON 或 XML 表示。从客户的角度来看,聚合只是 DTO。

此替代方案的优点是:

  • 可以使用已有的类
  • 无需在领域对象和 DTO 之间转换。

缺点是:

  • 它将领域模型直接与客户进行结合。如果领域名模型发生变化,也必须更改客户端。
  • 它对如何验证用户输入施加限制(稍后将对此进行更多了解)。
  • 必须设计聚合器的方式,使客户端无法将聚合置于不一致状态或执行不允许的操作。
  • 可能会遇到聚合 (JPA) 内实体的懒惰加载问题。

就我个人而言,我尽量避免这种做法。

数据传输对象

在第二种选择中,应用程序服务消耗和返回数据传输对象。DTO 可以与领域模型中的实体相对应,但更常见的情况是,它们被设计为特定的应用程序服务,甚至特定的应用程序服务方法(如请求和响应对象)。然后,应用程序服务负责在 DTO 和领域对象之间来回移动数据。

当领域模型非常丰富的业务逻辑、聚合物复杂或领域模型预期会发生很大变化,同时保持客户端 API 尽可能稳定时,此替代方案效果最佳。

此替代方案的优点是:

  • 客户端与领域模型脱钩,因此无需更改客户端即可轻松发展领域模型。
  • 只有实际需要的数据才在客户和应用程序服务之间传递,从而提高性能(特别是当客户端和应用程序服务在分布式环境中通过网络进行通信时)。
  • 控制对领域模型的访问变得更加容易,特别是如果只允许某些用户调用某些聚合方法或查看某些聚合属性值。
  • 只有应用程序服务才会与活动交易中的聚合体进行交互。这意味着可以在聚合 (JPA) 中利用实体的懒惰加载。
  • 如果 DTO 是接口而不是类,则将获得更大的灵活性。

缺点是:

  • 可以保留一组新的 DTO 类。
  • 必须将数据在 DTO 和聚合体之间来回移动。如果 DTO 和实体在结构上几乎相似,这可能特别乏味。如果在团队中工作,需要准备好一个很好的解释,解释为什么需要将 DTO 和聚合物分离。

就我个人而言,这是我在大多数情况下开始的方法。有时我最终把我的 Dtos 转换为 Dpos, 这是我们要研究的下一个选择。

领域有效载荷对象

在第三种选择中,应用程序服务消耗并返回领域有效载荷对象。领域有效载荷对象是了解领域模型并包含领域位对象的数据传输对象。这基本上是前两种选择的组合。

此备选方案在领域模型贫血、聚合体小且稳定且要实施涉及多个不同聚合的操作的情况下效果最佳。就我个人而言,我会说,我更经常地使用 DPO 作为输出对象,而不是作为输入对象。但是,如果可能,我尝试限制在 DPOs 中使用领域对象来对对象进行估价。

此替代方案的优点是:

  • 不需要为所有内容创建 DTO 类。当将领域对象直接传递给客户端时,确实会这样做。当需要自定义 DTO 时,创建一个。当需要两者兼得时,请同时使用两者。

缺点是:

  • 与第一个替代方案相同。只有在 DPO 中包括不可变的价值对象,才能缓解这些缺点。

代码示例

以下是分别使用 DTO 和 DPO 的两个 Java 示例。DTO 示例演示了使用 DTO 比直接返回实体有意义的用例:只需要实体属性的一小部分,我们需要包含实体中不存在的信息。DPO 示例演示了使用 DPO 有意义的用例:我们需要包含许多不同聚合体,这些聚合体以某种方式相互关联。

该代码尚未经过测试,应被视为伪代码,而不是实际的 Java 代码。

首先,我们查看数据传输对象示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class CustomerListEntryDTO { // <1>
    private CustomerId id;
    private String name;
    private LocalDate lastInvoiceDate;

    // Getters and setters omitted
}

@Service
public class CustomerListingService {

    @Transactional 
    public List<CustomerListEntryDTO> getCustomerList() {
        var customers = customerRepository.findAll(); // <2>
        var dtos = new ArrayList<CustomerListEntryDTO>();
        for (var customer : customers) {
            var lastInvoiceDate = invoiceService.findLastInvoiceDate(customer.getId()); // <3>
            dto = new CustomerListEntryDTO(); // <4>
            dto.setId(customer.getId());
            dto.setName(customer.getName());
            dto.setLastInvoiceDate(lastInvoiceDate);
            dtos.add(dto);
        }
        return dto;
    }
}
  1. 数据传输对象只是一个没有任何业务逻辑的数据结构。此特定 DTO 设计用于用户界面列表视图中,该视图仅需要显示客户姓名和上次发票日期。
  2. 我们从数据库中查找所有客户聚合。在实际应用中,这将是一个只返回一部分客户的填充查询。
  3. 最后一个发票日期不存储在客户实体中,因此我们必须调用领域服务来查找它。
  4. 我们创建 DTO 实例并填充数据。

其次,我们查看领域有效载荷对象示例:填充模式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class CustomerInvoiceMonthlySummaryDPO { // <1>
    private Customer customer;
    private YearMonth month;
    private Collection<Invoice> invoices;

    // Getters and setters omitted
}

@Service
public class CustomerInvoiceSummaryService {

    public CustomerInvoiceMontlySummaryDPO getMonthlySummary(CustomerId customerId, YearMonth month) {
        var customer = customerRepository.findById(customerId); // <2>
        var invoices = invoiceRepository.findByYearMonth(customerId, month); // <3>
        var dpo = new CustomerInvoiceMonthlySummaryDPO(); // <4>
        dpo.setCustomer(customer);
        dpo.setMonth(month);
        dpo.setInvoices(invoices);
        return dpo;
    }
}
  1. 领域有效载荷对象是一个数据结构,没有任何业务逻辑包含领域对象(在此例中为实体)和附加信息(在此例中为年份和月份)。
  2. 我们从存储库获取客户的总根。
  3. 我们获取客户指定年份和月份的发票。
  4. 我们创建 DPO 实例,并将其填充到数据中。

输入验证

正如我们前面提到的,聚合必须始终处于一致状态。这意味着我们需要正确验证用于更改聚合状态的所有输入。我们如何以及在哪里这样做?

从用户体验的角度来看,用户界面应包含验证,以便当数据无效时,用户甚至无法执行操作。但是,仅仅依靠用户界面验证在六边形系统中还不够好。原因是用户界面只是系统中可能的许多入口点之一。如果 REST 端点允许任何垃圾进入领域模型,则用户界面无法正确验证数据。

在考虑输入验证时,实际上有两种不同的验证类型:格式验证和内容验证。当我们验证格式时,我们会检查某些类型的某些值是否符合某些规则。例如,社会保障号码预计将处于特定模式。当我们验证内容时,我们已经有一个精心形成的数据,并有兴趣检查数据是否合理。例如,我们可能想要检查一个完善的社会保障号码是否与真实的人相对应。可以以不同的方式实施这些验证,以便让我们仔细查看。

格式验证

如果在领域模型中使用了大量价值对象(我倾向于亲自使用),这些对象是围绕原始类型(如字符串或整数)进行包装的,那么将格式验证直接构建到的值对象构造器中是有意义的。换句话说,在不通过形成良好的论点的情况下,就不可能创建例如实例或实例。这还有一个额外的优势,即如果有多种已知的输入有效数据的方法(例如,当输入电话号码时,有些人可能会使用空格或破折号将号码拆分为组,而其他人可能根本不使用任何空白)。"EmailAddress``SocialSecurityNumber

现在,当值对象有效时,我们如何验证使用它们的实体?有两个选项可供 Java 开发人员使用。

第一个选项是将验证添加到的构造器、工厂和设置方法中。这里的想法是,它甚至不应该把一个集合到一个不一致的状态:所有必需的字段必须填充在构造器中,任何设置所需的字段不会接受空参数,其他设置者将不接受错误格式或长度的值,等等。当我使用非常丰富的商业逻辑的领域模型时,我个人倾向于使用这种方法。它使领域模型非常强大,但也实际上迫使在客户端和应用程序服务之间使用 DTO,因为它或多或少不可能正确绑定到 UI。

第二个选项是使用爪哇豆验证 (JSR-303)。在所有字段上放置注释,并确保的应用程序服务在使用它执行其他任何工作之前运行聚合。当我使用贫血的领域模型时,我个人倾向于使用这种方法。即使聚合本身并不阻止任何人将其置于不一致状态,但可以有把握地假设从存储库检索或已通过验证的所有聚合物都是一致的。Validator

还可以使用领域模型中的第一个选项和 Java Bean 验证来合并这两个选项,以用于传入的 DTO 或 DPO。

内容验证

内容验证的最简单案例是确保同一聚合中两个或多个相互依赖的属性有效(例如,如果设置一个属性,另一个属性必须为空属性,反之亦然)。可以直接将此实现到实体类本身,也可以使用类级 Java Bean 验证约束。此类型的内容验证将在执行格式验证时免费进行,因为它使用相同的机制。

内容验证的一个更复杂的案例是检查某处查找列表中是否存在某个值(或不存在)。这是申请服务的责任。在允许任何业务或持久性操作继续运行之前,应用程序服务应执行查找,并在需要时抛出例外。这不是你想放入实体的东西,因为实体是可移动的领域对象,而查找所需的对象通常是静态的(请参阅上一篇有关战术 DDD 的文章,了解有关可移动和静态对象的更多信息)。

内容验证最复杂的案例是根据一组业务规则验证整个聚合。在这种情况下,责任在领域模型和应用程序服务之间划分。领域服务将负责执行验证本身,但应用程序服务将负责调用领域服务。

代码示例

在这里,我们将研究三种不同的处理验证方法。在第一种情况下,我们将查看值对象构造器(电话号码)内执行格式验证。在第二种情况下,我们将查看具有内置验证的实体,以便无法将对象首先置于不一致状态。在第三个也是最后一个案例中,我们将查看同一实体,但使用 JSR-303 验证实施。这使得将对象置于不一致状态,但无法将其保存到数据库。

具有格式验证的价值对象可能看起来像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class PhoneNumber implements ValueObject {
    private final String phoneNumber;

    public PhoneNumber(String phoneNumber) {
        Objects.requireNonNull(phoneNumber, "phoneNumber must not be null"); // <1>
        var sb = new StringBuilder();
        char ch;
        for (int i = 0; i < phoneNumber.length(); ++i) {
            ch = phoneNumber.charAt(i);
            if (Character.isDigit(ch)) { // <2>
                sb.append(ch);
            } else if (!Character.isWhitespace(ch) && ch != '(' && ch != ')' && ch != '-' && ch != '.') { // <3>
                throw new IllegalArgument(phoneNumber + " is not valid");
            }
        }
        if (sb.length() == 0) { // <4>
            throw new IllegalArgumentException("phoneNumber must not be empty");
        }
        this.phoneNumber = sb.toString();
    }

    @Override
    public String toString() {
        return phoneNumber;
    }

    // Equals and hashCode omitted
}
  1. 首先,我们检查输入值是否无效。
  2. 我们只包括我们实际存储的最终电话号码中的数字。对于国际电话号码,我们也应该支持一个"+“符号作为第一个字符,但我们会把它作为练习留给读者。
  3. 我们允许,但忽略,空白和某些特殊字符,人们经常在电话号码中使用。
  4. 最后,当所有的清洁完成,我们检查电话号码是不是空的。

具有内置验证的实体可能看起来像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Customer implements Entity {

    // Fields omitted

    public Customer(CustomerNo customerNo, String name, PostalAddress address) {
        setCustomerNo(customerNo); // <1>
        setName(name);
        setPostalAddress(address);
    }

    public setCustomerNo(CustomerNo customerNo) {
        this.customerNo = Objects.requireNonNull(customerNo, "customerNo must not be null");
    }

    public setName(String name) {
        Objects.requireNonNull(nanme, "name must not be null");
        if (name.length() < 1 || name.length > 50) { // <2>
            throw new IllegalArgumentException("Name must be between 1 and 50 characters");
        }
        this.name = name;
    }

    public setAddress(PostalAddress address) {
        this.address = Objects.requireNonNull(address, "address must not be null");
    }
}
  1. 我们调用构造器中的setters,以执行setters方法中实施的验证。如果子类决定推翻其中任何一种方法,从构造器中调用可覆盖的方法的风险很小。在这种情况下,最好将设置方法标记为最终方法,但一些持久性框架可能有此问题。你只需要知道你在做什么。
  2. 在这里,我们检查字符串的长度。下限是业务要求,因为每个客户都必须有一个名称。上层是数据库要求,因为在这种情况下,数据库有一个模式,只允许它存储 50 个字符的字符串。通过在此处添加验证,可以在以后尝试在数据库中插入过长的字符串时避免恼人的 SQL 错误。

具有 JSR-303 验证的实体可能看起来像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class Customer implements Entity {

    @NotNull <1>
    private CustomerNo customerNo;

    @NotBlank <2>
    @Size(max = 50) <3>
    private String name;

    @NotNull
    private PostalAddress address;

    // Setters omitted
}
  1. 此注释可确保实体保存时,客户编号不能无效。
  2. 此注释可确保实体保存时名称不能空或空。
  3. 此注释可确保实体保存时,名称不能超过 50 个字符。

大小重要吗?

在我们进入端口和适配器之前,还有一件事我想简要地提及。与所有门面一样,应用程序服务也一直存在风险,因为应用服务会成长为知道太多、做得太多的巨型类。这些类型的类往往很难阅读和维护,只是因为它们是如此之大。

那么,如何保持应用程序服务的小型性呢?当然,第一步是将增长过大的服务拆分为较小的服务。然而,这也有风险。我看到的情况是两种服务如此相似,以至于开发人员不知道它们之间的区别,也不知道应该采用哪种服务。结果是,服务方法分散在两个不同的服务类别中,有时甚至实施两次-每次服务一次-但由不同的开发人员。

当我设计应用程序服务时,我会尽量使它们连贯一致。在 CRUD 应用程序中,这可能意味着每个聚合项提供一个应用程序服务。在更多领域驱动的应用程序中,这可能意味着每个业务流程提供一项应用程序服务,甚至针对特定使用案例或用户界面视图提供单独的服务。

在设计应用程序服务时,命名是一个很好的准则。尝试根据应用服务的执行内容(而不是它们所关注的聚合物)命名应用服务。例如 或是比这意味着什么好得多的名字。还要花一些时间思考的命名惯例:真的需要用后缀结束所有服务吗?在某些情况下,使用后缀(如后缀)甚至将后缀完全排除在外是否更有意义?EmployeeCrudService``EmploymentContractTerminationUsecase``EmployeeService``Service``Usecase``Orchestrator

最后,我只想提到基于命令的应用程序服务。在这种情况下,将每个应用程序服务模型建模为命令对象,并配备相应的命令处理程序。这意味着每个应用程序服务都包含一种处理完全一个命令的方法。可以使用多态性创建专门的命令或命令处理程序。此方法导致大量小类,特别是在用户界面固有命令驱动或客户端通过某种消息传递机制(如消息队列 (MQ) 或企业服务总线 (ESB) 与应用程序服务进行交互的应用程序中非常有用。

代码示例

我不会给你一个完整的例子, 因为那会占据太多的空间。此外,我认为大部分从事这个行业一段时间的发展商,在这类类别中所占的比例是相当的。相反,我们将查看基于命令的应用程序服务可能是什么样子的示例。该代码尚未经过测试,应被视为伪代码,而不是实际的 Java 代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public interface Command<R> { // <1>
}

public interface CommandHandler<C extends Command<R>, R> { // <2>

    R handleCommand(C command);
}

public class CommandGateway { // <3>

    // Fields omitted

    public <C extends Command<R>, R> R handleCommand(C command) {
        var handler = commandHandlers.findHandlerFor(command)
            .orElseThrow(() -> new IllegalStateException("No command handler found"));
        return handler.handleCommand(command);
    }
}

public class CreateCustomerCommand implements Command<Customer> { // <4>
    private final String name;
    private final PostalAddress address;
    private final PhoneNumber phone;
    private final EmailAddress email;

    // Constructor and getters omitted
}

public class CreateCustomerCommandHandler implements CommandHandler<CreateCustomerCommand, Customer> { // <5>

    @Override
    @Transactional
    public Customer handleCommand(CreateCustomerCommand command) {
        var customer = new Customer();
        customer.setName(command.getName());
        customer.setAddress(command.getAddress());
        customer.setPhone(command.getPhone());
        customer.setEmail(command.getEmail());
        return customerRepository.save(customer);
    }
}
  1. 接口只是一个标记界面,也指示命令的结果(输出)。如果命令没有输出,结果可能是。Command``Void
  2. 接口由知道如何处理(执行)特定命令并返回结果的类实现。CommandHandler
  3. 客户与 A 进行交互,以避免查找单个命令处理程序。网关知道所有可用的命令处理程序以及如何根据任何给定的命令找到正确的命令处理程序。查找处理程序的代码不包括在示例中,因为它取决于注册处理程序的基本机制。CommandGateway
  4. 每个命令都实现接口,并包含执行命令的所有必要信息。我喜欢通过内置验证使我的命令不可改变,但也可以使它们可变,并使用 JSR-303 验证。甚至可以将命令保留为接口,并让客户自行实施,以获得最大的灵活性。Command
  5. 每个命令都有自己的处理程序来执行命令并返回结果。

六角形 vs. 实体控制边界

如果以前听说过实体控制边界模式,会发现六边形架构很熟悉。可以将的聚合体视为实体、领域服务、工厂和存储库作为控制器,将应用程序服务视为边界

端口和适配器

到目前为止,我们已经讨论了领域模型以及围绕它并与之交互的应用程序服务。但是,如果客户无法调用这些应用程序服务,并且端口和适配器进入图片,则这些应用程序服务将完全没用。

什么是端口?

端口是系统与外部世界之间的接口,专为特定目的或协议而设计。端口不仅用于允许外部客户端访问系统,还允许系统访问外部系统。

现在,很容易开始将端口视为网络端口,将端口视为 HTTP 等网络协议。我自己也犯了这个错误, 事实上弗农在他的书中至少有一个例子也犯了这个错误。然而,如果你仔细看看阿利斯泰尔·科克本的文章,弗农提到,你会发现事实并非如此。事实上,这远比这有趣。

端口是一种技术不可知的应用程序编程接口 (API),专为特定类型的与应用程序的交互而设计(因此"协议"一词)。如何定义此协议完全由决定,这就是此方法令人激动的原因。以下是可能拥有的不同端口的一些示例:

  • 应用程序用于访问数据库的端口
  • 应用程序用于发送电子邮件或短信等邮件的端口
  • 人类用户用于访问应用程序的端口
  • 其他系统用于访问应用程序的端口
  • 特定用户组用于访问应用的端口
  • 暴露特定用例的端口
  • 专为投票客户端口设计的端口
  • 专为订阅客户设计的端口
  • 专为同步通信设计的端口
  • 专为异步通信设计的端口
  • 专为特定类型的设备设计的端口

这份清单绝不是详尽无遗的,我相信你自己可以想出更多的例子。也可以将这些类型组合在一起。例如,可以有一个端口,允许管理员使用使用异步通信的客户端来管理用户。可以在不影响其他端口或领域模型的情况下,根据需要或需要向系统添加尽可能多的端口。

让我们再看看六边形架构图:

The hexagonal architecture

内六边形的每一代表一个端口。这就是为什么这种建筑经常被这样描述的原因:你得到六面开箱即用,你可以用于不同的端口和足够的空间来吸引尽可能多的适配器,你需要。但是什么是适配器呢?

什么是适配器?

我已经提到港口是技术不可知论者。尽管如此,还是通过一些技术与系统进行交互 - Web 浏览器、移动设备、专用硬件设备、桌面客户端等。这就是适配器的用处。

适配器允许使用特定技术通过特定端口进行交互。例如:

  • REST 适配器允许 REST 客户端通过某些端口与系统交互
  • 兔子MQadpter允许兔子MQ客户端通过某些端口与系统交互
  • SQL 适配器允许系统通过某些端口与数据库进行交互
  • Vaadin 适配器允许人类用户通过某些端口与系统交互

可以为单个端口提供多个适配器,甚至多个端口的多个适配器。可以在不影响其他适配器、端口或领域模型的情况下,根据需要或需要向系统添加尽可能多的适配器。

代码中的端口和适配器

现在,你应该对什么是端口和什么是适配器在概念层面上有所了解。但是,如何将这些概念转换为代码呢?让我们来看看!

在大多数情况下,端口将作为代码中的界面实现。对于允许外部系统访问应用的端口,这些接口是的应用程序服务接口:

An adapter using a port interface

界面的实现位于应用程序服务层内,适配器仅通过其界面使用该服务。这与经典的分层架构非常一致,适配器只是另一个使用应用层的客户端。主要区别在于,端口的概念可以帮助设计更好的应用程序接口,因为实际上必须考虑接口的客户端是什么,并承认不同的客户端可能需要不同的接口,而不是采用一刀切的方法。

当我们查看允许的应用程序通过某个适配器访问外部系统的端口时,事情变得更加有趣:

An adapter implementing a port interface

在这种情况下,是适配器实现了接口。然后,应用程序服务通过此接口与适配器进行交互。接口本身要么位于应用程序服务层(如工厂界面),要么位于的领域模型(如存储库接口)中。传统分层架构不允许采用此方法,因为界面将在上层(“应用层"或"领域层”)中申报,而是在下层(“基础层”)中实现。

请注意,在这两种方法中,依赖箭头指向界面。应用程序始终与适配器脱钩,适配器始终与应用程序的实现脱钩。

为了使这一点更加具体,让我们来看看一些代码示例。

示例 1:REST API

在第一个例子中,我们将为我们的 Java 应用程序创建 REST API:

A REST adapter

端口是一些应用程序服务,适合通过 REST 暴露。REST 控制器充当适配器。当然,我们使用的框架,如春天或JAX-RS,提供POJOs(普通老爪哇对象)和XML/JSON开箱即用之间的伺服和映射。我们只需要实施 REST 控制器,这将:

  1. 以生的 XML/JSON 或去隔离的 POJOs 作为输入,
  2. 调用应用程序服务,
  3. 构建一个响应,作为原始 XML/JSON 或将按框架序列化的 POJO,以及
  4. 将响应返回给客户。

客户端,无论他们是在浏览器中运行的客户端 Web 应用程序,还是在自己的服务器上运行的其他系统,都不是此特定六边形系统的一部分。只要客户符合端口和适配器支持的协议和技术,系统也不必关心客户是谁。

示例 2: 服务器Vaadin UI

在第二个示例中,我们将查看不同类型的适配器,即服务器侧 Vaadin UI:

A Vaadin adapter and HTTP port

端口是一些应用程序服务,适合通过 Web UI 进行曝光。适配器是 Vaadin UI,可将传入的用户操作转换为应用程序服务方法呼叫,输出可在浏览器中呈现。将用户界面视为另一个适配器是将业务逻辑保留在用户界面之外的极好方法。

示例 3:与关系数据库通信

在第三个例子中,我们将扭转局面,并查看一个适配器,使我们的系统能够调用到外部系统,更具体地说,一个关系数据库:

A repository adapter and JDBC port

这一次,由于我们使用 Spring Data,端口是领域模型中的存储库接口(如果我们不使用 Spring Data,端口可能是某种数据库网关界面,可访问存储库实现、交易管理等)。

适配器是Spring数据 JPA,因此我们实际上不需要自己编写,只需正确设置它。当应用程序开始时,它将使用代理自动实现界面。Spring容器将负责将代理注入使用它的应用程序服务中。

示例 4:通过 REST 与外部系统通信

在第四个也是最后一个例子中,我们将查看一个适配器,该适配器允许我们的系统通过 REST 向外部系统发出调用:

A REST client adapter and HTTP port

由于应用程序服务需要与外部系统联系,因此它已宣布要为此使用界面。你可以把这看作是反腐败层的第一部分(如果你需要复习一下,请回去阅读关于战略DD的文章)。

然后,适配器实现此接口,形成反腐败层的第二部分。与上一个示例一样,适配器使用某种依赖性注射(如 Spring)注入应用服务。然后,它使用一些内部 HTTP 客户端向外部系统进行呼叫,并将收到的响应转换为集成界面决定的领域对象。

多个绑定上下文

到目前为止,我们只查看了六边形架构在应用于单个边界上下文时的外观。但是,当有多个需要相互通信的有界限的上下文时会发生什么情况?

如果上下文在单独的系统上运行并通过网络进行通信,可以进行类似的事情:为上游系统创建 REST 服务器适配器,为下游系统创建 REST 客户端适配器:

Two bounded contexts running on separate nodes

不同上下文之间的映射将在下游系统的适配器中进行。

如果上下文以模块运行在单个单一系统中,仍然可以使用类似的架构,但只需要一个适配器:

Two bounded contexts inside the same monolith

由于两个上下文都在同一虚拟机内运行,因此我们只需要一个直接与两个上下文交互的适配器。适配器实现下游上下文的端口接口,并调用上游上下文的端口。任何上下文映射都发生在适配器内部。