发布于 2026-01-06 3 阅读
0

领域驱动设计与六边形架构 为什么叫六边形? 领域模型 应用服务 端口和适配器 多个限界上下文

领域驱动设计与六边形架构

为什么叫六边形?

领域模型

应用服务

端口和适配器

多重限界上下文

本文最初以教程的形式发布在 Vaadin 网站上。由于我今后会将 DDD 相关的文章迁移到这个平台,为了保持时间线的连贯性,我也将原有的教程系列复制到这里。

在前两篇文章中,我们学习了战略战术领域的领域驱动设计。现在是时候学习如何将领域模型转化为可运行的软件了——更具体地说,是如何使用六边形架构来实现这一点。

前两篇文章虽然代码示例是用 Java 编写的,但内容比较通用。尽管本文中的许多理论也适用于其他环境和语言,但我还是专门针对 Java 和 Vaadin 编写了这篇文章。

再次强调,本文内容基于Eric Evans 的《领域驱动设计:软件核心复杂性的应对》(Domain-Driven Design: Tackling Complexity in the Heart of Software)和Vaughn Vernon《领域驱动设计实现》(Implementing Domain-Driven Design)两本书,我强烈推荐大家阅读这两本书。虽然我在之前的文章中也分享了自己的想法、观点和经验,但这篇文章更侧重于我个人的思考和信念。也就是说,正是 Evans 和 Vernon 的著作让我开始接触领域驱动设计,我希望我在这里写的内容与书中的内容不会有太大出入。

为什么叫六边形?

六边形建筑的名称来源于这种建筑通常的描绘方式:

六边形建筑

本文稍后将探讨为何使用六边形。这种架构也称为端口和适配器架构(这更好地解释了其核心思想)和洋葱架构(因为它的分层结构)。

接下来,我们将更仔细地研究一下这个“洋葱”。我们将从核心——领域模型——开始,然后一层一层地深入,直到到达适配器以及与它们交互的系统和客户端。

六边形层与传统层

深入研究六边形架构后,你会发现它与更传统的层级架构有诸多相似之处。事实上,你可以将六边形架构视为层级架构的演进。然而,两者之间也存在一些差异,尤其是在系统与外部世界交互的方式上。为了更好地理解这些差异,让我们先回顾一下层级架构:

传统分层建筑

其原理是将系统构建成层层堆叠的结构。上层可以与下层交互,但反之则不行。通常,在领域驱动的分层架构中,最顶层是用户界面(UI)层。该层与应用服务层交互,应用服务层又与位于领域层中的领域模型交互。最底层是基础设施层,它与数据库等外部系统通信。

在六边形系统中,你会发现应用层和领域层仍然基本相同。然而,用户界面层和基础设施层的处理方式却截然不同。继续阅读,了解其中的原因。

领域模型

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

本系列前两篇文章已经详细介绍了领域模型,因此本文不再赘述。然而,如果无法与领域模型交互,那么它本身就没有任何价值。要实现交互,我们需要深入到“洋葱”模型的下一层。

应用服务

应用程序服务充当客户端与领域模型交互的接口。应用程序服务具有以下特征:

  • 他们是无国籍人士。
  • 他们负责维护系统安全
  • 它们控制数据库交易
  • 它们负责协调业务运营,但不做任何业务决策(即,它们不包含任何业务逻辑)。

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

无国籍

应用程序服务不维护任何可通过与客户端交互而更改的内部状态。执行操作所需的所有信息都应作为输入参数传递给应用程序服务方法。这将使系统更简单,更易于调试和扩展。

如果您需要在单个业务流程的上下文中多次调用应用程序服务,您可以将该业务流程建模为一个独立的类,并将其实例作为输入参数传递给应用程序服务方法。该方法随后会执行其操作,并返回一个更新后的业务流程对象实例,该实例又可以作为其他应用程序服务方法的输入:

public class MyBusinessProcess {
    // Current process state
}

public interface MyApplicationService {

    MyBusinessProcess performSomeStuff(MyBusinessProcess input);

    MyBusinessProcess performSomeMoreStuff(MyBusinessProcess input);
}
Enter fullscreen mode Exit fullscreen mode

你也可以将业务流程对象设为可变对象,并让应用程序服务方法直接修改对象的状态。我个人并不推荐这种方法,因为我认为它可能会导致一些意想不到的副作用,尤其是在事务最终回滚的情况下。这取决于客户端调用应用程序服务的方式,我们将在后面关于端口和适配器的章节中详细讨论这个问题。

对于如何实施更复杂、更长期的业务流程,我建议您阅读 Vernon 的书。

安全执法

应用程序服务确保当前用户有权执行相关操作。从技术上讲,您可以在每个应用程序服务方法的顶部手动执行此操作,或者使用更复杂的方法,例如面向切面编程 (AOP)。只要安全措施发生在应用程序服务层而不是领域模型层,具体如何实施并不重要。那么,为什么这很重要呢?

当我们讨论应用程序安全时,我们往往更注重防止未经授权的访问,而不是允许授权访问。因此,我们添加到系统中的任何安全检查实际上都会增加系统的使用难度。如果我们把这些安全检查添加到领域模型中,我们可能会遇到这样的情况:由于在添加安全检查时没有考虑到这一点,导致我们无法执行重要的操作,而这些安全检查现在却成了障碍。通过将所有安全检查都排除在领域模型之外,我们可以获得一个更灵活的系统,因为我们可以以任何我们想要的方式与领域模型进行交互。系统仍然安全,因为所有客户端无论如何都需要通过应用程序服务。创建一个新的应用程序服务远比修改领域模型容易得多。

代码示例

以下是两个用 Java 编写的应用服务安全强制执行示例。这些代码未经测试,应视为伪代码而非实际的 Java 代码。

第一个例子演示了声明式安全强制执行:

@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>
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. 该注解指示框架仅允许具有该角色的已认证用户ROLE_BUSINESS_PROCESSOR调用该方法。
  2. 应用程序服务从领域模型中的存储库查找聚合根。
  3. 应用程序服务将聚合根传递给领域模型中的领域服务,并存储结果(无论是什么)。
  4. 应用程序服务使用域服务的结果来更新业务流程对象,并将其返回,以便将其传递给参与同一长时间运行流程的其他应用程序服务方法。

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

@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);
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. 在实际应用中,你可能会创建一些辅助方法,用于在用户未登录时抛出异常。我在此示例中仅提供了一个更详细的版本,以展示需要检查的内容。
  2. 与之前的情况一样,只有具有该角色的用户ROLE_BUSINESS_PROCESSOR才能调用该方法。

交易管理

每个应用程序服务方法都应设计成自身构成一个独立的事务,无论底层数据存储是否使用事务。如果一个应用程序服务方法成功执行,则除了显式调用另一个用于撤销操作的应用程序服务(如果存在这样的方法)之外,没有其他方法可以撤销该操作。

如果您发现自己需要在同一事务中调用多个应用程序服务方法,则应检查应用程序服务的粒度是否正确。也许应用程序服务正在执行的某些操作实际上应该放在领域服务中?您可能还需要考虑重新设计系统,以使用最终一致性而不是强一致性(有关此内容的更多信息,请参阅之前关于战术领域驱动设计的文章)。

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

代码示例

以下是两个用 Java 编写的应用服务事务管理示例。这些代码未经测试,更像是伪代码而非实际的 Java 代码。

第一个示例演示了声明式事务管理:

@Service
class UserAdministrationService {

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

第二个例子演示了手动交易管理:

@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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. 事务管理器已注入到应用程序服务中,以便服务方法可以显式地启动新事务。
  2. 如果一切正常,密码重置后交易即被提交。
  3. 如果发生错误,则事务回滚并重新抛出异常。

管弦乐

正确编排业务逻辑或许是设计优秀应用服务中最难的部分。这是因为即使你认为自己只是在进行业务逻辑编排,也需要确保不会意外地将业务逻辑引入到应用服务中。那么,在这种情况下,业务逻辑编排究竟意味着什么呢?

所谓流程编排,指的是按正确的顺序查找并调用正确的领域对象,传递正确的输入参数,并返回正确的输出。最简单的应用服务可以根据 ID 查找聚合,调用该聚合的方法,保存结果并返回。然而,在更复杂的情况下,该方法可能需要查找多个聚合,与领域服务交互,执行输入验证等等。如果您发现自己编写的应用服务方法过长,则应该问自己以下问题:

  • 该方法是在做出业务决策,还是让领域模型做出决策?
  • 是否应该将部分代码移至域事件监听器?

也就是说,即使某些业务逻辑最终出现在应用服务方法中,也并非世界末日。它仍然非常接近领域模型,封装良好,以后也很容易重构回领域模型。如果还不清楚某个逻辑应该放在领域模型还是应用服务中,就不要浪费太多宝贵时间去思考这个问题。

代码示例

以下是一个典型的 Java 代码编排示例。该代码未经测试,应视为伪代码而非实际的 Java 代码。

@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>
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. 应用程序服务方法在事务中运行。
  2. 任何用户都可以访问该应用程序服务方法。
  3. 我们调用 JSR-303 验证器来检查传入的注册请求是否包含所有必要信息。如果请求无效,我们会抛出一个异常,并将该异常信息反馈给用户。
  4. 我们调用一个域服务,该服务会检查数据库中是否已存在具有相同信息的客户。如果存在,域服务将抛出一个异常(此处未显示),并将该异常传播回用户。
  5. 我们调用一个域工厂,该工厂将Customer使用来自注册请求对象的信息创建一个新的聚合。
  6. 我们调用域存储库来保存客户,并返回新创建和保存的客户聚合根。

领域事件监听器

在上一篇关于战术领域驱动设计的文章中,我们讨论了领域事件和领域事件监听器。然而,我们并没有探讨领域事件监听器在整个系统架构中的位置。回顾上一篇文章,领域事件监听器不应该能够影响最初发布该事件的方法的执行结果。在实践中,这意味着领域事件监听器应该运行在它自己的事务中。

因此,我认为领域事件监听器是一种特殊的应用程序服务,它并非由客户端调用,而是由领域事件触发。换句话说:领域事件监听器属于应用程序服务层,而非领域模型层。这也意味着领域事件监听器是一个协调器,不应包含任何业务逻辑。根据特定领域事件发布时需要执行的操作,如果存在多个处理路径,则可能需要创建一个单独的领域服务来决定如何处理该事件。

话虽如此,我在上一篇文章关于聚合的部分提到,即使违反了聚合设计准则,有时在同一事务中修改多个聚合也是合理的。我还提到,最好通过领域事件来实现这一点。在这种情况下,领域事件监听器必须参与当前事务,从而可能影响发布事件的方法的结果,这违反了领域事件和应用程序服务的设计准则。只要你是有意为之,并且意识到将来可能面临的后果,这并非世界末日。有时候,你只需要务实一些。

输入和输出

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

  1. 直接使用领域模型中的实体和值对象。
  2. 使用单独的数据传输对象(DTO)。
  3. 使用结合了上述两种特性的域有效载荷对象 (DPO)。

每种方案都有其自身的优点和缺点,让我们仔细看看每一种方案。

实体和聚合

第一种方案中,应用程序服务返回整个聚合数据(或其部分内容)。客户端可以对这些数据进行任意操作,当需要保存更改时,这些聚合数据(或其部分内容)会作为参数传递回应用程序服务。

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

如果客户端通过 REST 或 SOAP 访问系统,并且聚合数据可以轻松地序列化为 JSON 或 XML 格式,反之亦然,这种方法也适用。在这种情况下,客户端实际上并非直接与聚合数据交互,而是与聚合数据的 JSON 或 XML 表示形式交互,而该表示形式可能使用完全不同的语言实现。从客户端的角度来看,这些聚合数据就如同 DTO 一样。

这种方案的优点是:

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

缺点包括:

  • 它将领域模型直接耦合到客户端。如果领域模型发生变化,客户端也必须随之更改。
  • 它对验证用户输入的方式施加了限制(稍后会详细介绍)。
  • 你必须设计你的聚合,使得客户端无法将聚合置于不一致的状态或执行不允许的操作。
  • 在聚合(JPA)中延迟加载实体时,您可能会遇到问题。

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

数据传输对象

在第二种方案中,应用程序服务会使用并返回数据传输对象 (DTO)。DTO 可以对应于领域模型中的实体,但更常见的情况是,它们是为特定的应用程序服务,甚至是特定的应用程序服务方法(例如请求和响应对象)而设计的。应用程序服务负责在 DTO 和领域对象之间来回传输数据。

当领域模型包含非常丰富的业务逻辑、聚合很复杂,或者预期领域模型会经常变化,同时还要保持客户端 API 尽可能稳定时,这种替代方案效果最佳。

这种方案的优点是:

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

缺点包括:

  • 你需要维护一组新的 DTO 类。
  • 你需要在 DTO 和聚合之间来回传输数据。如果 DTO 和实体的结构非常相似,这项工作会变得尤为繁琐。如果你在团队中工作,你需要准备好充分的解释,说明为什么 DTO 和聚合需要分离。

就我个人而言,大多数情况下我都会首先采用这种方法。有时我最终会将 DTO 转换为 DPO,这也是我们接下来要探讨的另一种方法。

域有效载荷对象

第三种方案中,应用程序服务会使用并返回域有效负载对象。域有效负载对象是一种数据传输对象,它了解域模型,并且可以包含域对象。这本质上是前两种方案的结合。

这种替代方案最适用于领域模型不够完善、聚合对象规模小且稳定,以及需要实现涉及多个不同聚合对象的操作的情况。就我个人而言,我更倾向于将领域对象用作输出对象,而不是输入对象。不过,我会尽可能地将领域对象在领域对象中的使用限制在值对象范围内。

这种方案的优点是:

  • 并非所有情况都需要创建 DTO 类。如果直接将域对象传递给客户端就足够了,那就这样做。如果需要自定义 DTO,那就创建一个。如果两者都需要,那就两者都用。

缺点包括:

  • 与第一种方案相同。可以通过仅在数据对象对象 (DPO) 中包含不可变值对象来减轻这些缺点。

代码示例

以下是两个分别使用 DTO 和 DPO 的 Java 示例。DTO 示例展示了一种使用 DTO 比直接返回实体更合适的用例:只需要实体属性的一部分,并且需要包含实体中不存在的信息。DPO 示例展示了一种使用 DPO 更合适的用例:需要包含许多彼此关联的聚合。

该代码未经测试,应视为伪代码而非实际的 Java 代码。

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

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;
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. 数据传输对象 (DTO) 只是一个数据结构,不包含任何业务逻辑。此 DTO 专为用户界面列表视图而设计,该视图仅需显示客户名称和最近一次开票日期。
  2. 我们从数据库中查找所有客户汇总数据。在实际应用中,这将是一个分页查询,只会返回部分客户信息。
  3. 客户实体中未存储最后一张发票的日期,因此我们需要调用域服务来查找它。
  4. 我们创建 DTO 实例并填充数据。

其次,我们来看域有效载荷对象示例:

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;
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. 领域有效载荷对象是一种不包含任何业务逻辑的数据结构,其中包含领域对象(在本例中为实体)和附加信息(在本例中为年份和月份)。
  2. 我们从存储库中获取客户的聚合根目录。
  3. 我们获取客户指定年份和月份的发票。
  4. 我们创建 DPO 实例并填充数据。

输入验证

正如我们之前提到的,聚合必须始终保持一致状态。这意味着,我们需要正确验证所有用于改变聚合状态的输入。那么,我们应该如何以及在哪里进行验证呢?

从用户体验的角度来看,用户界面应该包含验证机制,确保用户在数据无效的情况下无法执行任何操作。然而,在六边形系统中,仅仅依赖用户界面验证是不够的。原因在于,用户界面只是系统众多入口点之一。即使用户界面能够正确验证数据,如果 REST 端点允许任何无效数据传递到领域模型,也无济于事。

在考虑输入验证时,实际上有两种截然不同的验证类型:格式验证和内容验证。格式验证是指检查特定类型的特定值是否符合特定规则。例如,社会安全号码应该遵循特定的格式。内容验证是指我们已经拥有格式正确的数据,并且我们想要检查这些数据是否合理。例如,我们可能需要检查一个格式正确的社会安全号码是否确实对应一个真实的人。这些验证可以通过不同的方式实现,让我们仔细看看。

格式验证

如果你在领域模型中使用了大量值对象(我个人就经常这样做),这些值对象是对基本类型(例如字符串或整数)的封装,那么将格式验证直接构建到值对象的构造函数中就很有意义。换句话说,创建 `ValueObject`EmailAddress或 ` SocialSecurityNumberValueObject` 实例时,必须传入格式正确的参数。这样做还有一个额外的好处:如果存在多种输入有效数据的方式(例如,输入电话号码时,有些人可能使用空格或短横线将号码分成几组,而有些人可能完全不使用空格),你可以在构造函数内部进行一些解析和清理工作。

现在,当值对象有效时,我们如何验证使用这些值的实体呢?Java 开发人员有两种选择。

第一种方法是在构造函数、工厂方法和 setter 方法中添加验证。这样做的目的是确保聚合体不会处于不一致的状态:构造函数中必须填充所有必填字段,必填字段的 setter 方法不能接受空参数,其他 setter 方法不能接受格式或长度不正确的值等等。我个人在处理业务逻辑非常丰富的领域模型时倾向于使用这种方法。它使领域模型非常健壮,但实际上也迫使你在客户端和应用程序服务之间使用 DTO,因为几乎不可能正确地绑定到 UI。

第二种方法是使用 Java Bean Validation (JSR-303)。在所有字段上添加注解,并确保应用程序服务在对聚合数据进行Validator任何其他操作之前先对其进行验证。我个人在处理贫血领域模型时倾向于使用这种方法。尽管聚合数据本身并不能阻止任何人将其置于不一致的状态,但您可以安全地假设所有从存储库中检索或通过验证的聚合数据都是一致的。

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

内容验证

内容验证最简单的例子是确保同一聚合中的两个或多个相互依赖的属性有效(例如,如果一个属性已设置,则另一个属性必须为 null,反之亦然)。您可以直接在实体类中实现此功能,也可以使用类级别的 Java Bean Validation 约束。由于格式验证和内容验证使用相同的机制,因此在执行格式验证时,内容验证也会自动完成。

更复杂的内容验证场景是检查某个值是否存在于某个查找列表中。这完全是应用程序服务的职责。在允许任何业务或持久化操作继续进行之前,应用程序服务应该执行查找,并在必要时抛出异常。您不应该将此类操作放在实体中,因为实体是可移动的领域对象,而查找所需的对象通常是静态的(有关可移动对象和静态对象的更多信息,请参阅之前关于战术领域设计的文章)。

内容验证中最复杂的情​​况是根据一组业务规则来验证整个聚合数据。在这种情况下,职责由领域模型和应用服务共同承担。领域服务负责执行验证本身,而应用服务负责调用领域服务。

代码示例

接下来我们将探讨三种不同的验证方法。第一种方法是在值对象(例如电话号码)的构造函数中执行格式验证。第二种方法使用内置验证机制的实体,从根本上避免了对象处于不一致状态的可能性。第三种方法也是最后一种方法,使用 JSR-303 验证实现相同的实体。这种方法允许对象处于不一致状态,但不会将其直接保存到数据库中。

带有格式验证的值对象可能如下所示:

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
}
Enter fullscreen mode Exit fullscreen mode
  1. 首先,我们检查输入值是否不为空。
  2. 我们最终存储的电话号码只包含数字。对于国际电话号码,我们也应该支持以“+”号作为第一个字符,但这部分留给读者自行探索。
  3. 我们允许电话号码中使用空格和某些特殊字符,但会忽略这些字符。
  4. 最后,当所有清理工作完成后,我们会检查电话号码是否为空。

一个内置验证功能的实体可能如下所示:

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");
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. 我们在构造函数中调用 setter 方法,以便执行 setter 方法中实现的验证。从构造函数中调用可重写的方法存在一定的风险,因为子类可能会重写其中的一些方法。在这种情况下,最好将 setter 方法标记为 final,但某些持久化框架可能对此有异议。您需要清楚自己在做什么。
  2. 这里我们检查字符串的长度。长度下限是业务需求,因为每个客户都必须有姓名。长度上限是数据库要求,因为本例中数据库的模式只允许存储长度为 50 个字符的字符串。通过在此处添加验证,可以避免在后续尝试向数据库中插入过长字符串时出现恼人的 SQL 错误。

经过 JSR-303 验证的实体可能如下所示:

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
}
Enter fullscreen mode Exit fullscreen mode
  1. 此注解确保在保存实体时客户编号不能为空。
  2. 此注解确保实体保存时名称不能为空或为null。
  3. 此注解确保实体保存时名称长度不超过 50 个字符。

尺寸重要吗?

在深入探讨端口和适配器之前,我想再简要提及一点。与所有外观模式一样,应用程序服务始终存在着变成庞大的“上帝类”的风险,这些类知道的信息过多,功能也过于强大。这类类往往难以阅读和维护,原因就在于它们过于庞大。

那么,如何才能保持应用程序服务的精简呢?第一步当然是将规模过大的服务拆分成更小的服务。然而,这样做也存在风险。我见过这样的情况:两个服务非常相似,以至于开发人员根本分不清它们之间的区别,也不知道哪个方法应该放在哪个服务中。结果就是,服务方法分散在两个不同的服务类中,有时甚至由不同的开发人员分别在两个服务中实现两次。

在设计应用服务时,我会尽量使它们保持一致性。在 CRUD 应用中,这意味着每个聚合可能对应一个应用服务。而在更侧重领域驱动的应用中,这意味着每个业务流程可能对应一个应用服务,甚至针对特定用例或用户界面视图使用单独的服务。

命名是设计应用程序服务时非常重要的指导原则。尽量根据应用程序服务的功能来命名,而不是根据它们涉及的聚合来命名。例如,`Service`EmployeeCrudService或 ` EmploymentContractTerminationUsecaseService` 比 `Service` 这类含义模糊的名称要好得多EmployeeService。此外,还要花些时间思考一下你的命名约定:真的需要所有服务都以 ` ServiceService` 结尾吗?在某些情况下,使用 `Service`Usecase或 ` Service` 之类的后缀Orchestrator,甚至完全省略 `Service` 后缀是否会更合理?

最后,我想提一下基于命令的应用程序服务。在这种情况下,每个应用程序服务模型都建模为一个命令对象,并附带一个相应的命令处理程序。这意味着每个应用程序服务都包含一个方法,该方法处理一个特定的命令。您可以使用多态性来创建专用的命令或命令处理程序。这种方法会产生大量的小型类,尤其适用于用户界面本质上是命令驱动的,或者客户端通过某种消息传递机制(例如消息队列 (MQ) 或企业服务总线 (ESB))与应用程序服务交互的应用程序。

代码示例

我不会举例说明上帝类(God class)是什么样子,因为那样篇幅太长。而且,我认为大多数从业多年的开发者都见过不少这类类。相反,我们将来看一个基于命令的应用程序服务示例。这段代码未经测试,更像是伪代码,而非实际的 Java 代码。

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);
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Command接口只是一个标记接口,它同时指示命令的结果(输出)。如果命令没有输出,则结果可以是空字符串Void
  2. CommandHandler接口由一个类实现,该类知道如何处理(执行)特定命令并返回结果。
  3. 客户端与网关交互,CommandGateway无需手动查找单个命令处理程序。网关了解所有可用的命令处理程序,并能根据给定的命令找到正确的处理程序。由于查找处理程序的代码依赖于底层的处理程序注册机制,因此示例中未包含该代码。
  4. 每个命令都实现了Command接口,并包含执行该命令所需的所有信息。我喜欢将命令设置为不可变并内置验证,但您也可以将其设置为可变并使用 JSR-303 验证。您甚至可以将命令保留为接口,让客户端自行实现,以获得最大的灵活性。
  5. 每个命令都有自己的处理程序,用于执行命令并返回结果。

六边形与实体控制边界

如果您之前听说过实体-控制-边界(Entity-Control-Boundary)模式,那么您会发现这种六边形架构很熟悉。您可以将聚合视为实体,将领域服务、工厂和存储库视为控制器,将应用服务视为边界

端口和适配器

到目前为止,我们已经讨论了领域模型以及围绕它并与之交互的应用服务。然而,如果客户端无法调用这些应用服务,它们就完全没有用处,而这正是端口和适配器发挥作用的地方。

什么是港口?

端口是系统与外部世界之间的接口,其设计目的或协议各不相同。端口不仅用于允许外部客户端访问系统,还用于允许系统访问外部系统。

现在,人们很容易把端口理解为网络端口,把协议理解为网络协议,比如HTTP。我自己也犯过这样的错误,事实上,弗农在他的书中至少举了一个例子,也犯了同样的错误。然而,如果你仔细阅读弗农提到的阿利斯泰尔·科克伯恩的文章,你会发现事实并非如此。实际上,这篇文章的内容远比这有趣得多。

端口是一种与技术无关的应用程序编程接口 (API),它专为与应用程序进行特定类型的交互而设计(因此称为“协议”)。如何定义此协议完全取决于您,而这正是这种方法的魅力所在。以下是一些您可能遇到的不同端口示例:

  • 应用程序用于访问数据库的端口
  • 应用程序用于发送消息(例如电子邮件或短信)的端口
  • 供用户访问您的应用程序的端口
  • 其他系统用于访问您的应用程序的端口
  • 特定用户组用于访问您的应用程序的端口
  • 一个展示特定用例的端口
  • 一个专为轮询客户端而设计的端口
  • 专为订阅客户设计的端口
  • 专为同步通信设计的端口
  • 专为异步通信设计的端口
  • 专为特定类型设备设计的端口

这份清单绝非详尽无遗,我相信您自己还能想到更多例子。您也可以将这些类型组合起来使用。例如,您可以设置一个端口,允许管理员使用异步通信客户端来管理用户。您可以根据需要向系统中添加任意数量的端口,而不会影响其他端口或域模型。

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

六边形建筑

内部六边形的每一条都代表一个端口。这就是为什么这种架构通常这样表示的原因:它提供了六个现成的边,可用于不同的端口,并且有足够的空间绘制所需的任意数量的适配器。但是,什么是适配器呢?

什么是适配器?

我之前已经提到过,端口本身与技术无关。但是,你仍然需要通过某种技术与系统交互——例如网页浏览器、移动设备、专用硬件设备、桌面客户端等等。这时就需要用到适配器了。

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

  • REST适配器允许REST客户端通过某个端口与系统交互。
  • RabbitMQ 适配器允许 RabbitMQ 客户端通过某个端口与系统交互。
  • SQL适配器允许系统通过某个端口与数据库进行交互。
  • Vaadin适配器允许用户通过某个端口与系统进行交互。

您可以为单个端口配置多个适配器,甚至可以为多个端口配置单个适配器。您可以根据需要向系统中添加任意数量的适配器,而不会影响其他适配器、端口或域模型。

代码中的端口和适配器

现在,你应该对端口和适配器的概念有了一些了解。但是如何将这些概念转化为代码呢?让我们一起来看看!

大多数情况下,端口会以接口的形式体现在你的代码中。对于允许外部系统访问你的应用程序的端口,这些接口就是你的应用程序服务接口:

使用端口接口的适配器

接口的实现位于应用程序服务层,适配器仅通过接口使用该服务。这与传统的层级架构非常相似,在传统的层级架构中,适配器只是使用应用程序层的另一个客户端。主要区别在于,端口的概念有助于设计更好的应用程序接口,因为您需要考虑接口的客户端,并认识到不同的客户端可能需要不同的接口,而不是采用一刀切的方法。

当我们研究允许应用程序通过某种适配器访问外部系统的端口时,事情就变得更有趣了:

实现端口接口的适配器

在这种情况下,实现接口的是适配器。应用程序服务随后通过此接口与适配器交互。接口本身可以位于应用程序服务层(例如工厂接口)或领域模型层(例如存储库接口)。这种方法在传统的分层架构中是不被允许的,因为接口会在上层(“应用层”或“领域层”)声明,但却在下层(“基础架构层”)实现。

请注意,在这两种方法中,依赖关系箭头都指向接口。应用程序始终与适配器解耦,适配器也始终与应用程序的实现解耦。

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

示例 1:REST API

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

REST适配器

该端口是适合通过 REST 接口公开的应用程序服务。REST 控制器充当适配器。我们通常会使用 Spring 或 JAX-RS 等框架,它们开箱即用地提供了 servlet 以及 POJO(普通 Java 对象)与 XML/JSON 之间的映射。我们只需要实现 REST 控制器,它将:

  1. 输入可以是原始 XML/JSON 数据,也可以是反序列化的 POJO 对象。
  2. 调用应用程序服务,
  3. 构建响应,响应格式可以是原始 XML/JSON,也可以是 POJO,后者将由框架进行序列化。
  4. 将响应返回给客户端。

无论客户端是运行在浏览器中的客户端 Web 应用程序,还是运行在各自服务器上的其他系统,它们都不属于这个特定的六边形系统。只要客户端符合端口和适配器支持的协议和技术,系统也无需关心客户端的具体身份。

示例 2:服务器端 Vaadin UI

在第二个例子中,我们将介绍另一种类型的适配器,即服务器端的 Vaadin UI:

Vaadin适配器和HTTP端口

端口指的是适合通过 Web UI 公开的应用程序服务。适配器则是 Vaadin UI,它将传入的用户操作转换为应用程序服务的方法调用,并将输出转换为可在浏览器中渲染的 HTML。将用户界面视为另一个适配器,是保持业务逻辑独立于用户界面之外的绝佳方法。

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

在第三个例子中,我们将换个角度,来看一个适配器,它允许我们的系统调用外部系统,更具体地说,是关系数据库:

存储库适配器和 JDBC 端口

这次,因为我们使用的是 Spring Data,所以端口是领域模型中的存储库接口(如果我们不使用 Spring Data,端口很可能是某种数据库网关接口,提供对存储库实现、事务管理等的访问)。

适配器是 Spring Data JPA,所以我们实际上不需要自己编写,只需要正确配置即可。应用程序启动时,它会自动使用代理实现接口。Spring 容器会负责将代理注入到使用它的应用程序服务中。

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

在第四个也是最后一个例子中,我们将介绍一个适配器,它允许我们的系统通过 REST 调用外部系统:

REST 客户端适配器和 HTTP 端口

由于应用服务需要与外部系统通信,因此它声明了一个用于此目的的接口。您可以将其视为防腐层的第一部分(如果您需要复习一下战略性领域驱动设计(DDD)的概念,请返回阅读相关文章)。

适配器随后实现此接口,构成防腐层的第二部分。与之前的示例类似,适配器通过某种依赖注入机制(例如 Spring)注入到应用程序服务中。然后,它使用内部 HTTP 客户端向外部系统发出请求,并将接收到的响应转换为集成接口所定义的领域对象。

多重限界上下文

到目前为止,我们只研究了六边形架构应用于单个限界上下文时的样子。但是,当有多个限界上下文需要相互通信时,会发生什么呢?

如果上下文运行在不同的系统上并通过网络通信,您可以这样做:为上游系统创建一个 REST 服务器适配器,为下游系统创建一个 REST 客户端适配器:

两个运行在不同节点上的有界上下文

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

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

同一块巨石内的两个封闭上下文

由于两个上下文都运行在同一台虚拟机中,我们只需要一个适配器直接与两个上下文交互。该适配器实现下游上下文的端口接口,并调用上游上下文的端口。所有上下文映射都在适配器内部完成。

文章来源:https://dev.to/peholmst/domain-driven-design-and-the-hexagonal-architecture-2o87