PHP-领域驱动开发-全-
PHP 领域驱动开发(全)
原文:
zh.annas-archive.org/md5/6f6b43f1b17833c04a964a9759e27295译者:飞龙
前言
在 2014 年,经过两年的阅读和工作领域驱动设计后,卡洛斯和克里斯蒂安,作为朋友和同事,前往柏林参加 Vaughn Vernon 的实施领域驱动设计研讨会。培训非常棒,他们在旅行前头脑中盘旋的所有概念突然变得非常真实。然而,他们是房间里唯一两个 PHP 开发者,房间里满是 Java 和.NET 开发者。
大约在同一时间,php[tek],一年一度的 PHP 会议,开启了征稿活动,卡洛斯提交了一篇关于六边形架构的文章。他的演讲被拒绝了,但一个月后,来自musketeers.me和php[architect]的 Eli White 联系了他,想知道他是否对为杂志 php[architect]撰写一篇关于六边形架构的文章感兴趣。因此,在 2014 年 6 月,使用 PHP 的六边形架构一文得以发表。这篇文章,你可以在附录中找到,是这本书的起源。
在 2014 年底,卡洛斯和克里斯蒂安讨论了扩展文章并分享他们在生产中应用领域驱动设计的所有知识和经验。他们对书籍背后的想法非常兴奋:帮助 PHP 社区从实用角度深入了解领域驱动设计。当时,像丰富领域模型和无框架应用程序这样的概念在 PHP 社区中并不常见。因此,在 2014 年 12 月,GitHub 书籍仓库的第一个提交被推送到。
大约在同一时间,在一个平行宇宙中,Keyvan 共同创立了 Funddy,这是一个基于领域驱动设计和构建块的大众众筹平台。领域驱动设计在探索过程和构建早期初创公司如 Funddy 的建模中证明了自己的有效性。它还帮助处理公司的复杂性,因为其环境和需求不断变化。在与卡洛斯和克里斯蒂安讨论书籍后,Keyvan 自豪地签约成为第三位作者。
我们共同编写了我们开始领域驱动设计时想要的书籍。书中充满了例子、现成的代码、捷径,以及基于我们各自团队的经验,对哪些有效和哪些无效的建议。我们通过其构建块——战术模式——到达了领域驱动设计,这就是为什么这本书主要关于它们。阅读它将帮助你学习它们,编写它们,并实施它们。你还将发现如何使用同步和异步方法集成边界上下文,这将打开你的战略设计世界——尽管后者是你必须自己探索的道路。
这本书深受 Vaughn Vernon(又名红皮书)所著的[《实现领域驱动设计》(http://www.amazon.com/Implementing-Domain-Driven-Design-Vaughn-Vernon/dp/0321834577)]和 Eric Evans(又名蓝皮书)所著的[《领域驱动设计:软件核心的复杂性处理》(http://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215)]的启发。你应该购买这两本书。你应该仔细阅读它们。你应该喜欢它们。
谁应该阅读这本书
如果你是一名 PHP 开发者、架构师或技术领导,我们强烈推荐这本书。它将帮助你成为一名更好的专业人士。它将为你正在开发的应用程序提供一个全新的视角和方法。如果你是初级角色,了解值对象、实体、仓储和领域事件对于未来面对的任何领域建模都很重要。对于普通角色,理解六边形架构的好处以及框架和应用程序之间的边界对于编写在现实世界中更容易维护的代码至关重要(框架迁移、测试等)。对于更高级的读者,探索如何使用领域事件来集成应用程序以及深入研究聚合设计都会很有趣。
虽然领域驱动设计不是关于技术的,但你仍然需要它来发出 HTTP 请求以访问你的领域。在整本书中,我们推荐使用特定的 PHP 框架和库,例如 Symfony、Silex 和 Doctrine。对于一些示例,我们也使用了特定的技术,如 MySQL、RabbitMQ、Redis 和 Elasticsearch。然而,最重要的是背后的概念——这些概念无论使用哪种技术实现都是通用的。
此外,这本书充满了大量的细节和示例,例如如何使用 PHP 正确地设计和实现领域驱动设计的所有构建块——包括值对象、实体、服务、领域事件、聚合、工厂、仓储和应用服务——以及如何解释在领域驱动设计中使用的 PHP 库和框架的主要角色。这本书还教授如何在你的应用程序中应用六边形架构,无论你是否使用开源框架还是自己的框架。最后,它展示了如何使用 REST 框架和消息机制来集成边界上下文。如果你对其中任何主题感兴趣,这本书就是为你准备的。
DDD 和 PHP 社区
2016 年,Carlos 和 Christian 参加了首届官方领域驱动设计会议DDD Europe。他们很高兴看到一些 PHP 开源领袖,如 Marco Pivetta(Doctrine)和 Sebastian Bergmann(PHPUnit),参加了会议。
领域驱动设计在会议两年前进入 PHP 社区。然而,仍然缺乏文档和真实的代码示例。为什么?我们认为还没有很多人在生产环境中使用这种类型的做法——即使在其他更成熟的社区中,如 Java。也许这是因为他们的项目复杂性较低,或者也许是因为他们不知道如何做。无论原因如何,这本书是为社区编写的。我们的目标之一是教会你如何编写一个解决你的领域问题的应用程序,而无需与特定的框架或技术耦合。
章节总结
本书按章节组织,每章探讨领域驱动设计的独立战术构建块。它还包括领域驱动设计的介绍、如何集成不同的边界上下文或应用程序的信息,以及附录。
第一章:开始学习领域驱动设计
领域驱动设计是关于什么的?它在复杂系统中扮演什么角色?是否值得学习和探索?当开发者开始学习时,需要了解哪些主要概念?
第二章:架构风格
边界上下文可以以不同的方式实现,并使用不同的方法。然而,两种风格越来越受欢迎,它们是六边形架构和 CQRS + ES。在本章中,我们将看到这两种主要的架构风格,了解它们的主要优势,并发现何时使用它们。
第三章:值对象
值对象是丰富建模的基本组成部分。我们将学习它们的属性以及它们为何如此重要。我们将了解如何使用 Doctrine 和自定义 ORM 持久化它们。我们还将展示如何正确验证和单元测试它们。最后,我们将看到测试不可变性的测试用例是什么样的。
第四章:实体
实体是领域驱动设计构建块,具有唯一标识和可变性的特性。我们将了解如何创建和验证它们,以及如何使用自定义 ORM 和 Doctrine 正确映射它们。我们还将评估是否使用注解是实体映射的最佳方法,并查看生成标识的不同策略。
第五章:领域服务
在本章中,你将了解什么是领域服务以及何时使用它。我们将回顾贫血领域模型和丰富领域模型是什么。最后,我们将处理编写领域服务时的基础设施问题。
第六章:领域事件
领域事件是一个优秀的控制反转(IoC)机制。在领域驱动设计中,它们对于异步通信不同的边界上下文、通过最终一致性提高应用程序性能以及解耦应用程序与其基础设施都至关重要。
第七章:模块
在如此多的战术构建块中,知道如何在代码中放置它们可能有点困难,尤其是如果你正在处理像 Symfony 这样的框架。我们将回顾如何使用 PHP 命名空间来实现模块。我们还将发现用于组织领域模型代码、应用代码和基础设施代码的不同文件夹层次结构。
第八章:聚合
聚合可能是战术领域驱动设计中最困难的部分。我们将探讨处理它们时的关键概念,并了解如何设计它们。我们还将提出一个实际场景,其中在添加业务规则时,两个聚合体合并为一个,并演示其余对象必须如何重构。
第九章:工厂
工厂方法和对象帮助我们保持业务不变性,这就是为什么它们在领域驱动设计中如此重要的原因。在这里,我们还将探索工厂和聚合之间的关系。
第十章:仓库
仓库对于检索和将实体和聚合添加到集合中至关重要。我们将回顾不同类型的仓库,并学习如何使用 Doctrine、自定义 ORM 和 Redis 来实现它们。
第十一章:应用
应用程序是连接外部客户端到你的领域的薄层。在本章中,我们将向你展示如何编写你的应用程序服务,以便它们易于测试并保持薄层。我们还将回顾如何准备请求对象、定义依赖关系以及返回结果。
第十二章:集成限界上下文
我们将探索不同的战术方法来沟通限界上下文,并查看实际实现。我们建议使用 REST 进行同步通信,使用 RabbitMQ 进行异步通信的消息是我们的建议。
附录:使用 PHP 的六边形架构
这里你可以找到 Carlos 撰写并由 php[architect] 在 2014 年 6 月发表的原文。
代码和示例
作者们在 GitHub 上创建了一个名为 Domain-Driven Design in PHP 的组织,这里提供了本书中的所有代码示例、额外的代码片段以及一些完整的示例项目。例如,你可以找到 Last Wishes,这是一个简单的基于领域驱动设计风格的示例应用,展示了本书中解释的不同示例。此外,你还可以找到我们的 CQRS 博客,以及 Gamify,这是一个添加了游戏化功能的 Last Wishes 限界上下文。
最后,如果你在阅读这本书时发现任何问题或修复,或者有建议或评论,你可以在 DDD in PHP 书籍问题 仓库中创建一个问题。我们按收到的问题进行修复。如果你感兴趣,我们也强烈建议你关注我们的项目并提供反馈。
第一章:开始使用领域驱动设计
那么,所有的喧嚣都是关于什么的呢?如果你已经阅读了 Vaughn Vernon 和 Eric Evans 关于这个主题的书籍,你可能对我们即将说的内容很熟悉,因为我们大量借鉴了他们的定义和解释。领域驱动设计(DDD),是一种帮助我们成功理解和构建软件模型设计的途径。它为我们提供了战略和战术建模工具,以帮助设计符合我们商业目标的优质软件。
本书的主要目标是向您展示领域驱动设计战术模式的 PHP 代码示例。如果您想了解更多关于战略模式和主要领域驱动设计的内容,您应该阅读Vaughn Vernon的《领域驱动设计精粹》或Eric Evans的《领域驱动设计参考:定义和模式摘要》。
更重要的是,领域驱动设计不是关于技术的。相反,它是关于围绕业务发展知识,并使用技术提供价值。只有当你能够理解公司所在业务时,你才能参与软件模型发现过程,以产生通用语言。
为什么领域驱动设计很重要
软件不仅仅是代码。如果你仔细想想,代码很少是我们职业的最终目标。代码只是解决商业问题的媒介。那么为什么它必须使用不同的语言呢?领域驱动设计强调确保商业和软件使用相同的语言。一旦打破了障碍,就无需翻译或繁琐的同步,信息不会丢失。每个人都在发现业务领域,而不仅仅是程序员。由此产生的软件是通用语言的唯一真理。
领域驱动设计还提供了一个战略和战术设计的框架——战略的是基于商业价值确定最重要的开发领域,战术的是构建经过实战检验的构建块和模式的可工作的领域模型。
领域驱动设计的三个支柱
领域驱动设计是一种交付软件的方法,它专注于三个支柱:
-
通用语言:领域专家和软件开发者一起为正在开发的业务领域构建一个共同的语言。没有“我们与他们”;总是“我们”。开发软件是一种商业投资,而不仅仅是成本。构建通用语言的努力有助于在所有团队成员中传播深入的领域洞察。
-
战略设计:领域驱动设计关注的是业务方向的策略,而不仅仅是技术方面。它有助于定义内部关系和早期预警反馈系统。在技术方面,战略设计通过提供如何实现面向服务的架构的动机来保护每个业务服务。
-
战术设计:领域驱动设计提供了迭代软件交付的工具和构建块。战术设计工具产生的软件不仅正确,而且可测试且错误率较低。
通用语言
除了第十二章[整合边界上下文],通用语言是领域驱动设计的主要优势之一。
在上下文方面
目前,考虑一个边界上下文是一个系统周围的概念边界。边界内的通用语言具有特定的上下文意义。此上下文之外的概念可以有不同的含义。
因此,如何找到、探索和捕捉这种非常特殊的语言,以下要点将突出同样的事情:
-
识别关键业务流程、它们的输入和输出
-
创建术语表和定义
-
使用某种形式的文档捕捉重要的软件概念
-
与团队中的其他成员(开发人员和领域专家)分享和扩展收集到的知识
自从领域驱动设计诞生以来,出现了许多改进构建通用语言过程的技巧。其中最重要的一个,现在经常使用,就是事件风暴。
事件风暴
阿尔贝托·布兰多利尼在一篇博客文章中解释了事件风暴及其优势,他比我们更简洁地做了这件事。事件风暴是一种快速探索复杂业务领域的研讨会格式:
-
它是强大的:它使我以及许多从业者能够在数小时内而不是数周内构建出一个完整的业务流程的综合模型。
-
它是吸引人的:整个想法是将有疑问的人和知道答案的人放在同一个房间里,共同构建一个模型。
-
它是高效的:生成的模型与领域驱动设计实现风格(尤其是适合事件源方法)完美对齐,并允许快速确定上下文和聚合边界。
-
它是简单的:符号非常简单。没有可能让参与者脱离讨论核心的复杂 UML。
-
它是有趣的:我总是很享受领导研讨会,人们充满活力,交付的成果超过了他们的预期。正确的问题出现了,气氛恰到好处。
如果你想了解更多关于事件风暴的信息,请查看布兰多利尼的书籍,介绍事件风暴。
考虑领域驱动设计
领域驱动设计不是万能的;就像软件中的所有事物一样,它取决于上下文。作为一个经验法则,使用它来简化你的领域,但永远不要增加更多的复杂性。
如果你的应用程序以数据为中心,你的用例主要是操作数据库中的行并执行 CRUD 操作——即创建、读取、更新和删除——那么你不需要领域驱动设计。相反,你的公司只需要在数据库前面有一个花哨的界面。
如果你的应用程序用例少于 30 个,使用像 Symfony 或 Laravel 这样的框架来处理业务逻辑可能更简单。
然而,如果你的应用程序用例超过 30 个,你的系统可能正在走向令人恐惧的大泥球。如果你确信你的系统将增长复杂性,你应该考虑使用领域驱动设计来对抗这种复杂性。
如果你知道你的应用程序将会增长并且很可能会经常变化,领域驱动设计肯定有助于管理复杂性和随着时间的推移重构你的模型。
如果你因为该领域是新的,之前没有人投资解决方案,而对该领域不理解,这可能意味着它足够复杂,可以开始应用领域驱动设计。在这种情况下,你需要与领域专家紧密合作,以确保模型正确。
困难的部分
应用领域驱动设计并不容易。它需要时间精力来理解业务领域、术语、研究和与领域专家的合作,而不是编码术语。你还需要领域专家的承诺来参与这个过程。这需要开放和健康的持续对话,将他们的口头语言转化为软件。此外,我们还需要努力避免技术思维,首先要认真思考对象的行为和通用语言。
战略概述
为了提供一个关于领域驱动设计战略方面的概述,我们将使用来自吉米·尼尔森的书籍《应用领域驱动设计和模式》中的方法,应用领域驱动设计和模式。考虑两个不同的空间:问题空间和解决方案空间。
在问题空间中,领域驱动设计使用领域和子领域来分组和组织公司想要解决的问题。以在线旅行社(OTA)为例,问题在于处理诸如机票和酒店预订等问题。这样的领域可以组织成不同的子领域,如定价、库存、用户管理等。
在解决方案空间中,领域驱动设计提供了两种模式:边界上下文和上下文图。目标是定义如何通过定义它们的交互和交互的细节来为所有已识别的子域提供实现。继续使用 OTA 示例,每个子域都将通过边界上下文实现来解决——例如,考虑一个团队为定价管理子域开发的定制 Web 应用程序,以及为用户管理子域提供的现成解决方案。上下文图将显示每个边界上下文如何相互关联。在上下文图中,我们可以看到两个边界上下文之间有什么类型的关联(例如:客户-供应商,合作伙伴)。理想的方法是每个子域都由一个边界上下文实现,但这并不总是可能的。在实施方面,遵循领域驱动设计时,你最终会得到分布式架构。正如你可能已经知道的,分布式架构比单体架构更复杂,那么这种方法为什么有趣,尤其是对于大型和复杂公司?这真的值得吗?嗯,是的。
分布式架构已被证明可以增加整体公司生产力,因为它们为你的产品定义了由专注的团队开发的边界。
如果你的领域——你需要解决的问题——并不复杂,应用领域驱动设计的战略部分可能会增加不必要的开销并减慢你的开发速度。
如果你想了解更多关于领域驱动设计战略部分的信息,你应该看看Vaughn Vernon的书的头三章,实施领域驱动设计,或者埃里克·埃文斯的书籍领域驱动设计:软件核心的复杂性处理,这两本书都专门关注这个方面。
相关运动:微服务和自包含系统
有其他运动推广遵循与领域驱动设计相同原则的架构。微服务和自包含系统是这方面的良好例子。詹姆斯·刘易斯和马丁·福勒在微服务资源指南中定义了微服务:
微服务架构风格是一种将单个应用程序作为一系列小型服务开发的方法,每个服务都在自己的进程中运行,并通过轻量级机制(通常是 HTTP 资源 API)进行通信。这些服务围绕业务能力构建,并且可以使用完全自动化的机器独立部署。对这些服务的集中式管理最少,这些服务可能用不同的编程语言编写,也可能使用不同的数据存储技术。
如果你想了解更多关于微服务的知识,他们的指南是一个很好的起点。这与领域驱动设计(Domain-Driven Design)有何关系?正如山姆·纽曼(Sam Newman)的书中所解释的,构建微服务,微服务是领域驱动设计边界上下文的实现。
除了微服务(Microservices)之外,另一个相关的运动是自包含系统(SCS)。根据自包含系统网站:
自包含系统(Self-contained System)方法是一种关注将功能分离成许多独立系统的架构,使完整的逻辑系统成为许多较小软件系统的协作。这避免了大型单体不断增长最终变得难以维护的问题。在过去的几年中,我们在许多中型和大型项目中看到了它的好处。这个想法是将一个大型系统分解成几个较小的自包含系统,或 SCS,它们遵循某些规则。
该网站还列出了 SCS 的七个特点:
每个 SCS 都是一个自主的 Web 应用程序。对于 SCS 的域,所有数据、处理这些数据的逻辑以及渲染 Web 界面的所有代码都包含在 SCS 内部。SCS 可以独立完成其主要用例,无需依赖其他系统可用。
每个 SCS 由一个团队拥有。这并不一定意味着只有一支团队可能会更改代码,但拥有团队对代码库中包含的内容有最终决定权,例如通过合并 pull-requests。
与其他 SCS 或第三方系统的通信尽可能异步。具体来说,其他 SCS 或外部系统不应在 SCS 自己的请求/响应周期内同步访问。这解耦了系统,减少了故障的影响,从而支持了自主性。目标是关于时间解耦:即使其他 SCS 暂时离线,SCS 也应能正常工作。即使技术层面的通信是同步的,例如通过复制数据或缓冲请求,这也是可以实现的。
SCS 可以有一个可选的服务 API。因为 SCS 有自己的 Web UI,它可以与用户交互——无需通过 UI 服务。然而,为移动客户端或其他 SCS 提供的 API 可能仍然有用。
每个 SCS 必须包含数据和逻辑。要真正实现任何有意义的特性,两者都是必需的。SCS 应自行实现功能,因此必须包含两者。
SCS 应该通过其自己的 UI 使其功能对最终用户可用。因此,SCS 不应与其他 SCS 共享任何 UI。SCS 之间可能仍然存在链接。然而,异步集成意味着即使另一个 SCS 的 UI 不可用,SCS 仍然应该工作。为了避免紧密耦合,SCS 不应与其他 SCS 共享任何业务代码。可能创建一个 SCS 的 pull-request 或使用公共库是可行的,例如数据库驱动程序或 oAuth 客户端。
练习
与你的同事讨论这种分布式架构的优缺点。考虑使用不同的语言、部署流程、基础设施责任等等。
总结
在本章中,你学到了:
-
领域驱动设计并非关于技术;实际上,它是通过关注模型,在您工作的领域提供价值。每个人都参与发现领域的过程,开发者和领域专家通过共享相同的语言,即通用语言,共同构建知识库。
-
领域驱动设计提供了战术和战略建模工具来设计高质量的软件。战略设计针对业务方向,有助于定义内部关系,并通过定义强大的边界在技术上保护每个业务服务。战术设计提供了迭代设计的有用构建块。
-
领域驱动设计仅在特定情境下才有意义。它并非解决软件中所有问题的万能钥匙,因此是否使用它高度取决于你处理复杂性的程度。
-
领域驱动设计是一项长期投资;它需要积极的努力。领域专家需要与开发者紧密合作,开发者也必须从商业角度思考。最终,必须让业务客户满意。
实施领域驱动设计需要努力。如果它很容易,那么每个人都会编写高质量的代码。准备好吧,因为你很快就会学到如何编写代码,当阅读时,它将完美地描述你公司运营的业务。享受这段旅程吧!
第二章:架构风格
为了能够构建复杂的应用程序,一个关键要求是拥有适合应用程序需求的架构设计。领域驱动设计(Domain-Driven Design)的一个优点是它不依赖于任何特定的架构风格。相反,我们可以自由选择最适合核心领域内每个边界上下文需求的架构。这为每个特定的领域问题提供了多样化的架构选择。
例如,一个订单处理系统可以使用事件溯源(Event Sourcing)来跟踪所有不同的订单操作;一个产品目录可以使用 CQRS(Command Query Responsibility Segregation)来向不同的客户端暴露产品详情;一个内容管理系统可以使用纯六边形架构(Hexagonal Architecture)来暴露如博客、静态页面等需求。
本章介绍了 PHP 领域所有相关的架构风格,从传统的老式 PHP 代码到更复杂的架构进行演变。请注意,尽管存在许多其他现有的架构风格,如数据编织或服务导向架构(SOA),但我们发现其中一些从 PHP 的角度来看过于复杂,难以介绍。
旧时光
在 PHP 4 发布之前,该语言并没有拥抱面向对象范式。那时,编写应用程序的通常方式是使用过程和全局状态。像关注点分离(SoC)和模型-视图-控制器(MVC)这样的概念在 PHP 社区中是陌生的。以下是一个以这种方式编写的应用程序示例,其中应用程序由许多前端控制器与 HTML 代码混合组成。在这段时间里,基础设施层、表示层、UI 层和领域层代码都纠缠在一起:
include __DIR__ . '/bootstrap.php';
$link = mysql_connect('localhost', 'a_username', '4_p4ssw0rd');
if (!$link) {
die('Could not connect: ' . mysql_error());
}
mysql_set_charset('utf8', $link);
mysql_select_db('my_database', $link);
$errormsg = null ;
if (isset($_POST['submit'] && isValid($_POST['post'])) {
$post = getFrom($_POST['post']);
mysql_query('START TRANSACTION', $link);
$sql = sprintf(
"INSERT INTO posts (title, content) VALUES ('%s','%s')",
mysql_real_escape_string($post['title']),
mysql_real_escape_string($post['content']
));
$result = mysql_query($sql, $link);
if ($result) {
mysql_query('COMMIT', $link);
} else {
mysql_query('ROLLBACK', $link);
$errormsg = 'Post could not be created! :(';
}
}
$result = mysql_query('SELECT id, title, content FROM posts', $link);
?>
<html>
<head></head>
<body>
<?php if (null !== $errormsg) : ?>
<div class="alert error"><?php echo $errormsg; ?></div>
<?php else: ?>
<div class="alert success">
Bravo! Post was created successfully!
</div>
<?php endif; ?>
<table>
<thead><tr><th>ID</th><th>TITLE</th>
<th>ACTIONS</th></tr></thead>
<tbody>
<?php while($post = mysql_fetch_assoc($result)) : ?>
<tr>
<td><?php echo $post['id']; ?></td>
<td><?php echo $post['title']; ?></td>
<td><?php editPostUrl($post['id']); ?></td>
</tr>
<?php endwhile; ?>
</tbody>
</table>
</body>
</html>
<?php mysql_close($link); ?>
这种编程风格通常被称为我们在第一章中提到的大泥球(Big Ball of Mud)。然而,在这种风格中看到的一个改进是将网页的页眉和页脚封装在自己的单独文件中,这些文件被包含在页眉和页脚文件中。这避免了重复并促进了重用:
include __DIR__ . '/bootstrap.php';
$link = mysql_connect('localhost', 'a_username', '4_p4ssw0rd');
if (!$link) {
die('Could not connect: ' . mysql_error());
}
mysql_set_charset('utf8', $link);
mysql_select_db('my_database', $link);
$errormsg = null;
if (isset($_POST['submit'] && isValid($_POST['post'])) {
$post = getFrom($_POST['post']);
mysql_query('START TRANSACTION', $link);
$sql = sprintf(
"INSERT INTO posts(title, content) VALUES('%s','%s')",
mysql_real_escape_string($post['title']),
mysql_real_escape_string($post['content'])
);
$result = mysql_query($sql, $link);
if ($result) {
mysql_query('COMMIT', $link);
} else {
mysql_query('ROLLBACK', $link);
$errormsg = 'Post could not be created! :(';
}
}
$result = mysql_query('SELECT id, title, content FROM posts', $link);
?>
<?php include __DIR__ . '/header.php'; ?>
<?php if (null !== $errormsg) : ?>
<div class="alert error"><?php echo $errormsg; ?></div>
<?php else: ?>
<div class="alert success">
Bravo! Post was created successfully!
</div>
<?php endif; ?>
<table>
<thead>
<tr>
<th>ID</th>
<th>TITLE</th>
<th>ACTIONS</th>
</tr>
</thead>
<tbody>
<?php while($post = mysql_fetch_assoc($result)): ?>
<tr>
<td><?php echo $post['id']; ?></td>
<td><?php echo $post['title']; ?></td>
<td><?php editPostUrl($post['id']); ?></td>
</tr>
<?php endwhile; ?>
</tbody>
</table>
<?php include __DIR__ . '/footer.php'; ?>
现在,尽管这种方法被高度禁止,但仍有一些应用程序使用这种过程式编程方式。这种架构风格的主要缺点是没有真正的关注点分离——以这种方式开发的应用程序在维护和成本方面与其它知名且经过验证的架构相比急剧增加。
分层架构
从代码的可维护性和重用性角度来看,使这段代码更容易维护的最佳方法是通过分割概念,即为每个不同的关注点创建层。在我们之前的例子中,塑造不同的层很容易:一个用于封装数据访问和处理,另一个用于处理基础设施问题,最后一个用于封装前两个层的编排。分层架构的一个基本规则是,每一层必须与它下面的层紧密耦合,如下面的图片所示:

SoC 的分层架构
分层架构真正寻求的是将应用程序的不同组件分离。例如,就之前的例子而言,博客文章的表示必须完全独立于作为概念实体的博客文章。相反,作为概念实体的博客文章可以与一个或多个表示相关联,而不是与特定的表示紧密耦合。这通常被称为关注点分离。
另一个寻求相同目的的架构模式和范例是模型-视图-控制器模式。它最初是为了构建桌面 GUI 应用程序而构思和广泛使用的,现在它主要应用于 Web 应用程序,这得益于像 Symfony、Zend Framework 和 CodeIgniter 这样的流行 Web 框架。
模型-视图-控制器
模型-视图-控制器是一种架构模式和范例,它将应用程序分为三个主要层,以下是一些描述:
-
模型:捕获并集中所有领域模型的行为。这一层独立于数据表示管理所有数据、逻辑和业务规则。据说模型层是每个 MVC 应用程序的核心和灵魂。
-
控制器:协调其他层之间的交互,触发模型上的操作以更新其状态,并刷新与模型关联的表示。此外,控制器可以向视图层发送消息,以改变特定的模型表示。
-
视图:展示模型层的不同表示,并提供一种触发模型状态变化的方式。

MVC 模式
分层架构示例
模型
继续上一个例子,我们提到应该将不同的关注点分开。为了做到这一点,我们需要在我们的原始混乱代码中识别出所有层。在整个过程中,我们需要特别注意符合模型层的代码,它将成为应用程序的核心:
class Post
{
private $title;
private $content;
public static function writeNewFrom($title, $content)
{
return new static($title, $content);
}
private function __construct($title, $content)
{
$this->setTitle($title);
$this->setContent($content);
}
private function setTitle($title)
{
if (empty($title)) {
throw new RuntimeException('Title cannot be empty');
}
$this->title = $title;
}
private function setContent($content)
{
if (empty($content)) {
throw new RuntimeException('Content cannot be empty');
}
$this->content = $content;
}
}
class PostRepository
{
private $db;
public function __construct()
{
$this->db = new PDO(
'mysql:host=localhost;dbname=my_database',
'a_username',
'4_p4ssw0rd',
[
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4',
]
);
}
public function add(Post $post)
{
$this->db->beginTransaction();
try {
$stm = $this->db->prepare(
'INSERT INTO posts (title, content) VALUES (?, ?)'
);
$stm->execute([
$post->title(),
$post->content(),
]);
$this->db->commit();
} catch (Exception $e) {
$this->db->rollback();
throw new UnableToCreatePostException($e);
}
}
}
模型层现在由 Post 类和 PostRepository 类定义。Post 类代表一篇博客文章,而 PostRepository 类代表所有可用的博客文章集合。此外,模型内部还需要另一个层——一个协调和编排领域模型行为的层——那就是应用程序层:
class PostService
{
public function createPost($title, $content)
{
$post = Post::writeNewFrom($title, $content);
(new PostRepository())->add($post);
return $post;
}
}
PostService 类被称为应用程序服务,其目的是协调和组织领域行为。换句话说,应用程序服务是使事情发生的服务,它们是领域模型直接客户端。其他类型的对象不应能够直接与模型层的内部层进行通信。
视图
视图是一个可以发送和接收来自模型层和/或控制器层的消息的层。其主要目的是在用户界面级别代表模型,以及每次模型更新时在用户界面中刷新表示。一般来说,视图层接收一个对象——通常是 数据传输对象(DTO)而不是模型层的实例——从而收集所有需要成功表示的信息。对于 PHP,有几个模板引擎可以帮助将模型表示与模型本身和控制器本身分离。最受欢迎的一个叫做 Twig。让我们看看使用 Twig 的视图层将如何看起来。
DTOs 而不是模型实例?这是一个老生常谈且活跃的话题。为什么创建一个 DTO 而不是将模型实例提供给视图层?主要原因和简短的回答是,又是关注点的分离。让视图检查和使用模型实例会导致视图层和模型层之间的紧密耦合。实际上,模型层的变化可能会破坏所有使用更改后的模型实例的视图。
{% extends "base.html.twig" %}
{% block content %}
{% if errormsg is defined %}
<div class="alert error">{{ errormsg }}</div>
{% else %}
<div class="alert success">
Bravo! Post was created successfully!
</div>
{% endif %}
<table>
<thead>
<tr>
<th>ID</th>
<th>TITLE</th>
<th>ACTIONS</th>
</tr>
</thead>
<tbody>
{% for post in posts %}
<tr>
<td>{{ post.id }}</td>
<td>{{ post.title }}</td>
<td><a href="{{ editPostUrl(post.id) }}">Edit Post</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
大多数时候,当模型触发状态变化时,它也会通知相关的视图,以便刷新用户界面。在典型的网络场景中,由于客户端-服务器性质,模型与其表示之间的同步可能有点棘手。在这些环境中,通常需要一些 JavaScript 定义的交互来维护这种同步。因此,近年来,像以下这样的 JavaScript MVC 框架变得非常流行:
控制器
控制层负责组织和协调视图和模型。它从视图层接收消息,并触发模型行为以执行所需操作。此外,它向视图发送消息以显示模型表示。这两个操作都得益于应用层,应用层负责协调、组织和封装领域行为。
在 PHP 的 Web 应用程序中,控制器通常包含一组类,为了实现其目的,它们“说 HTTP”。换句话说,它们接收 HTTP 请求并返回 HTTP 响应:
class PostsController
{
public function updateAction(Request $request)
{
if (
$request->request->has('submit') &&
Validator::validate($request->request->post)
) {
$postService = new PostService();
try {
$postService->createPost(
$request->request->get('title'),
$request->request->get('content')
);
$this->addFlash(
'notice',
'Post has been created successfully!'
);
} catch (Exception $e) {
$this->addFlash(
'error',
'Unable to create the post!'
);
}
}
return $this->render('posts/update-result.html.twig');
}
}
反转依赖:六边形架构
遵循分层架构的基本规则,在实现包含基础设施关注点的领域接口时存在风险。
例如,在 MVC 中,上一个例子中的PostRepository类应该放在领域模型中。然而,将基础设施细节直接放在我们的领域中间违反了关注点分离。这可能是个问题;如果领域层知道技术实现,就难以避免违反分层架构的基本规则,这会导致一种代码风格,如果领域层知道技术实现,就难以测试。
依赖倒置原则(DIP)
我们如何解决这个问题?由于领域模型层依赖于具体的基础设施实现,可以通过将基础设施层放置在其他三个层之上来应用依赖倒置原则(Dependency Inversion Principle),或 DIP。
依赖倒置原则
高层模块不应依赖于底层模块。两者都应依赖于抽象。
抽象不应依赖于细节。细节应依赖于抽象。罗伯特·C·马丁
通过使用依赖倒置原则,架构模式发生变化,基础设施层——可以称为底层模块——现在依赖于 UI、应用层和领域层,这些是高层模块。依赖关系已经反转。
但什么是六边形架构,它是如何适应所有这些的?六边形架构(也称为端口和适配器)由 Alistair Cockburn 在他的书中定义,六边形架构。它将应用程序描绘成一个六边形,其中每一边代表一个带有一个或多个适配器的端口。端口是一个具有可插拔适配器的连接器,它将外部输入转换为内部应用程序可以理解的东西。在 DIP(依赖倒置原则)的术语中,端口将是一个高级模块,而适配器将是一个低级模块。此外,如果应用程序需要向外部发送消息,它也将使用带有适配器的端口来发送并转换成外部可以理解的东西。因此,六边形架构提出了应用程序中的对称概念,这也是架构模式变化的主要原因。它通常被表示为六边形,因为不再有意义讨论顶层或底层。相反,六边形架构主要从外部和内部的角度来讨论。
YouTube 上有许多由Matthias Noback制作的关于六边形架构的优秀视频,他讨论了六边形架构。你可能想看看其中之一以获取更多详细信息。
应用六边形架构
继续使用博客示例应用程序,我们需要的第一概念是外部世界可以与应用程序通信的端口。在这种情况下,我们将使用 HTTP 端口及其相应的适配器。外部将通过端口向应用程序发送消息。博客示例使用数据库来存储整个博客文章集合,因此为了允许应用程序从数据库检索博客文章,需要一个端口:
interface PostRepository
{
public function byId(PostId $id);
public function add(Post $post);
}
此接口公开了应用程序将通过它检索有关博客文章信息的端口,它将位于域层。现在需要为这个端口创建一个适配器。适配器负责定义使用特定技术检索博客文章的方式:
class PDOPostRepository implements PostRepository
{
private $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
public function byId(PostId $id)
{
$stm = $this->db->prepare(
'SELECT * FROM posts WHERE id = ?'
);
$stm->execute([$id->id()]);
return recreateFrom($stm->fetch());
}
public function add(Post $post)
{
$stm = $this->db->prepare(
'INSERT INTO posts (title, content) VALUES (?, ?)'
);
$stm->execute([
$post->title(),
$post->content(),
]);
}
}
一旦我们定义了端口和适配器,最后一步就是重构PostService类,使其使用它们。这可以通过使用依赖注入轻松实现:
class PostService
{
private $postRepository;
public function __construct(PostRepositor $postRepository)
{
$this->postRepository = $postRepository;
}
public function createPost($title, $content)
{
$post = Post::writeNewFrom($title, $content);
$this->postRepository->add($post);
return $post;
}
}
这只是一个简单的六边形架构示例。它是一个灵活的架构,它促进了关注点的分离,就像分层架构一样。它还通过拥有一个与外部通过端口通信的内部应用程序来促进对称性。从现在开始,这将是构建和解释 CQRS 和事件源的基础架构。
关于此架构的更多示例,您可以查看附录,使用 PHP 的六边形架构。对于更详细的示例,您应该跳转到第十一章,应用程序,其中解释了事务性和其他横切关注点等高级主题。
命令查询责任分离(Command Query Responsibility Segregation, CQRS)
六边形架构是一个良好的基础架构,但它有一些局限性。例如,复杂的 UI 可能需要以不同形式显示的聚合信息(第八章,聚合),或者它们可能需要从多个聚合中获得数据。在这种情况下,我们可能会在仓储中拥有大量的查找方法(可能和应用程序中存在的 UI 视图一样多)。或者,我们可能决定将这种复杂性移至应用程序服务,使用复杂结构从多个聚合中积累数据。以下是一个例子:
interface PostRepository
{
public function save(Post $post);
public function byId(PostId $id);
public function all();
public function byCategory(CategoryId $categoryId);
public function byTag(TagId $tagId);
public function withComments(PostId $id);
public function groupedByMonth();
// ...
}
当这些技术被滥用时,UI 视图的构建可能会变得非常痛苦。我们应该评估在使应用程序服务返回领域模型实例和返回某种类型的 DTO 之间的权衡。后者选项可以避免领域模型和基础设施代码(Web 控制器、CLI 控制器等)之间的紧密耦合。
幸运的是,还有另一种方法。如果问题是存在多个且不同的视图,我们可以将它们从领域模型中排除,并开始将它们视为纯粹的基础设施关注点。这个选项基于一个设计原则,即命令查询分离(Command Query Separation, CQS)。这个原则由贝特朗·梅耶定义,反过来,它催生了一个新的架构模式,即命令查询责任分离(Command Query Responsibility Segregation, CQRS),由格雷格·杨定义。
命令查询分离(Command Query Separation, CQS)
提问不应改变答案 - 贝特朗·梅耶
这个设计原则指出,每个方法应该是执行动作的命令,或者返回数据的查询,但不能两者兼而有之,维基百科
CQRS 寻求更激进的关注点分离,将模型分为两部分:
-
写模型:也称为命令模型,执行写入操作并负责真正的领域行为。
-
读模型:负责应用程序内的读取操作,并将它们视为应该从领域模型中分离出来的内容。
每次有人触发对写模型的命令时,都会执行对所需数据存储的写入操作。此外,它还会触发读模型更新,以便在读模型上显示最新的更改。
这种严格的分离导致另一个问题:最终一致性。读取模型的一致性现在取决于写模型执行的命令。换句话说,读取模型是最终一致的。也就是说,每次写模型执行命令时,都会启动一个负责根据写模型上的最后更新来更新读取模型的过程。在一段时间内,UI 可能会向用户展示过时的信息。在 Web 场景中,这种情况经常发生,因为我们受到当前技术的限制。
想象一下在 Web 应用程序前面有一个缓存系统。每次数据库更新新信息时,缓存层上的数据可能已经过时,因此每次更新时,都应该有一个更新缓存系统的过程。缓存系统是最终一致的。
这些类型的流程,用 CQRS 术语来说,被称为写模型投影,或简称投影。我们将写模型投影到读取模型上。这个过程可以是同步的或异步的,取决于您的需求,并且可以通过另一个有用的战术设计模式——章节领域事件——来实现,这在本书后面的章节中将详细解释。写模型投影的基础是收集所有发布的领域事件,并使用事件中来的所有信息更新读取模型。
写模型
这是领域行为的真正持有者。继续我们的例子,仓库接口将被简化为以下内容:
interface PostRepository
{
public function save(Post $post);
public function byId(PostId $id);
}
现在,PostRepository已经从所有的读取关注点中解放出来,除了一个:byId函数,它负责通过 ID 加载聚合体,以便我们可以对其进行操作。一旦完成这项工作,所有查询方法也从帖子模型中移除,只留下命令方法。这意味着我们将有效地去除所有获取方法以及任何其他暴露帖子聚合体信息的其他方法。相反,将通过发布领域事件来触发,以便能够通过订阅它们来触发写模型投影:
class AggregateRoot
{
private $recordedEvents = [];
protected function recordApplyAndPublishThat(
DomainEvent $domainEvent
) {
$this->recordThat($domainEvent);
$this->applyThat($domainEvent);
$this->publishThat($domainEvent);
}
protected function recordThat(DomainEvent $domainEvent)
{
$this->recordedEvents[] = $domainEvent;
}
protected function applyThat(DomainEvent $domainEvent)
{
$modifier = 'apply' . get_class($domainEvent);
$this->$modifier($domainEvent);
}
protected function publishThat(DomainEvent $domainEvent)
{
DomainEventPublisher::getInstance()->publish($domainEvent);
}
public function recordedEvents()
{
return $this->recordedEvents;
}
public function clearEvents()
{
$this->recordedEvents = [];
}
}
class Post extends AggregateRoot
{
private $id;
private $title;
private $content;
private $published = false;
private $categories;
private function __construct(PostId $id)
{
$this->id = $id;
$this->categories = new Collection();
}
public static function writeNewFrom($title, $content)
{
$postId = PostId::create();
$post = new static($postId);
$post->recordApplyAndPublishThat(
new PostWasCreated($postId, $title, $content)
);
}
public function publish()
{
$this->recordApplyAndPublishThat(
new PostWasPublished($this->id)
);
}
public function categorizeIn(CategoryId $categoryId)
{
$this->recordApplyAndPublishThat(
new PostWasCategorized($this->id, $categoryId)
);
}
public function changeContentFor($newContent)
{
$this->recordApplyAndPublishThat(
new PostContentWasChanged($this->id, $newContent)
);
}
public function changeTitleFor($newTitle)
{
$this->recordApplyAndPublishThat(
new PostTitleWasChanged($this->id, $newTitle)
);
}
}
所有触发状态变化的操作都通过领域事件来实现。对于每个发布的领域事件,都有一个负责反映状态变化的应用方法:
class Post extends AggregateRoot
{
// ...
protected function applyPostWasCreated(
PostWasCreated $event
) {
$this->id = $event->id();
$this->title = $event->title();
$this->content = $event->content();
}
protected function applyPostWasPublished(
PostWasPublished $event
) {
$this->published = true;
}
protected function applyPostWasCategorized(
PostWasCategorized $event
) {
$this->categories->add($event->categoryId());
}
protected function applyPostContentWasChanged(
PostContentWasChanged $event
) {
$this->content = $event->content();
}
protected function applyPostTitleWasChanged(
PostTitleWasChanged $event
) {
$this->title = $event->title();
}
}
读取模型
读取模型,也称为查询模型,是一个从领域关注点提升的纯反规范化数据模型。实际上,在 CQRS 中,所有的读取关注点都被视为报告过程,一个基础设施关注点。一般来说,当使用 CQRS 时,读取模型受 UI 需求和构成 UI 的视图复杂性的影响。在读取模型以关系数据库为定义的情况下,最简单的方法是在数据库表和 UI 视图之间设置一对一的关系。这些数据库表和 UI 视图将使用从写入端发布的领域事件触发的写入模型投影进行更新:
-- Definition of a UI view of a single post with its comments
CREATE TABLE single_post_with_comments (
id INTEGER NOT NULL,
post_id INTEGER NOT NULL,
post_title VARCHAR(100) NOT NULL,
post_content TEXT NOT NULL,
post_created_at DATETIME NOT NULL,
comment_content TEXT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Set up some data
INSERT INTO single_post_with_comments VALUES
(1, 1, "Layered" , "Some content", NOW(), "A comment"),
(2, 1, "Layered" , "Some content", NOW(), "The comment"),
(3, 2, "Hexagonal" , "Some content", NOW(), "No comment"),
(4, 2, "Hexagonal", "Some content", NOW(), "All comments"),
(5, 3, "CQRS", "Some content", NOW(), "This comment"),
(6, 3, "CQRS", "Some content", NOW(), "That comment");
-- Query it
SELECT * FROM single_post_with_comments WHERE post_id = 1;
这种架构风格的一个重要特性是读取模型应该是完全可丢弃的,因为应用程序的真实状态由写入模型处理。这意味着读取模型可以在需要时通过写入模型投影进行删除和重建。
在这里,我们可以看到博客应用中可能的一些视图示例:
SELECT * FROM
posts_grouped_by_month_and_year
ORDER BY month DESC,year ASC;
SELECT * FROM
posts_by_tags
WHERE tag = "ddd";
SELECT * FROM
posts_by_author
WHERE author_id = 1;
需要指出的是,CQRS 并不限制读取模型的定义和实现必须使用关系数据库。它完全取决于正在构建的应用程序的需求。它可以是关系数据库、面向文档的数据库、键值存储,或者任何最适合您应用程序需求的数据库。在博客应用博客文章之后,我们将使用 Elasticsearch ——一个面向文档的数据库——来实现读取模型:
class PostsController
{
public function listAction()
{
$client = new ElasticsearchClientBuilder::create()->build();
$response = $client-> search([
'index' => 'blog-engine',
'type' => 'posts',
'body' => [
'sort' => [
'created_at' => ['order' => 'desc']
]
]
]);
return [
'posts' => $response
];
}
}
读取模型代码已经被大大简化为对 Elasticsearch 索引的单个查询。
这表明读取模型实际上并不需要一个对象关系映射器,因为这可能是过度设计。然而,写入模型可能从使用对象关系映射器中受益,因为这将允许您根据应用程序的需求组织和结构化读取模型。
同步写入模型与读取模型
接下来是棘手的部分。我们如何同步读取模型与写入模型?我们之前已经说过,我们将通过使用写入模型事务中捕获的领域事件来实现。对于捕获的每种类型的领域事件,将执行一个特定的投影。因此,领域事件与投影之间将建立一对一的关系。
让我们看看配置投影的一个例子,以便我们更好地理解。首先,我们需要为投影定义一个框架:
interface Projection
{
public function listensTo();
public function project($event);
}
因此,为 PostWasCreated 事件定义一个 Elasticsearch 投影将像这样简单:
namespace Infrastructure\Projection\Elasticsearch;
use Elasticsearch\Client;
use PostWasCreated;
class PostWasCreatedProjection implements Projection
{
private $client;
public function __construct(Client $client)
{
$this->client = $client;
}
public function listensTo()
{
return PostWasCreated::class;
}
public function project($event)
{
$this->client->index([
'index' => 'posts',
'type' => 'post',
'id' => $event->getPostId(),
'body' => [
'content' => $event->getPostContent(),
// ...
]
]);
}
}
投影器实现是一种专门的领域事件监听器。与默认的领域事件监听器相比,主要区别在于投影器对一组领域事件做出反应,而不仅仅是单个事件:
namespace Infrastructure\Projection;
class Projector
{
private $projections = [];
public function register(array $projections)
{
foreach ($projections as $projection) {
$this->projections[$projection->eventType()] = $projection;
}
}
public function project( array $events)
{
foreach ($events as $event) {
if (isset($this->projections[get_class($event)])) {
$this->projections[get_class($event)]
->project($event);
}
}
}
}
以下代码显示了投影器和事件之间的流程:
$client = new ElasticsearchClientBuilder::create()->build();
$projector = new Projector();
$projector->register([
new Infrastructure\Projection\Elasticsearch\
PostWasCreatedProjection($client),
new Infrastructure\Projection\Elasticsearch\
PostWasPublishedProjection($client),
new Infrastructure\Projection\Elasticsearch\
PostWasCategorizedProjection($client),
new Infrastructure\Projection\Elasticsearch\
PostContentWasChangedProjection($client),
new Infrastructure\Projection\Elasticsearch\
PostTitleWasChangedProjection($client),
]);
$events = [
new PostWasCreated(/* ... */),
new PostWasPublished(/* ... */),
new PostWasCategorized(/* ... */),
new PostContentWasChanged(/* ... */),
new PostTitleWasChanged(/* ... */),
];
$projector->project($event);
这段代码有点同步,但如果需要,过程可以是异步的。您可以通过在视图层放置一些警报来让客户意识到这种不同步的数据。
在下一个示例中,我们将结合使用amqplib PHP 扩展和ReactPHP:
// Connect to an AMQP broker
$cnn = new AMQPConnection();
$cnn->connect();
// Create a channel
$ch = new AMQPChannel($cnn);
// Declare a new exchange
$ex = new AMQPExchange($ch);
$ex->setName('events');
$ex->declare();
// Create an event loop
$loop = ReactEventLoopFactory::create();
// Create a producer that will send any waiting messages every half a second
$producer = new Gos\Component\React\AMQPProducer($ex, $loop, 0.5);
$serializer = JMS\Serializer\SerializerBuilder::create()->build();
$projector = new AsyncProjector($producer, $serializer);
$events = [
new PostWasCreated(/* ... */),
new PostWasPublished(/* ... */),
new PostWasCategorized(/* ... */),
new PostContentWasChanged(/* ... */),
new PostTitleWasChanged(/* ... */),
];
$projector->project($event);
为了使这可行,我们需要一个异步投影器。以下是一个简单的实现:
namespace Infrastructure\Projection;
use Gos\Component\React\AMQPProducer;
use JMS\Serializer\Serializer;
class AsyncProjector
{
private $producer;
private $serializer;
public function __construct(
Producer $producer,
Serializer $serializer
) {
$this->producer = $producer;
$this->serializer = $serializer;
}
public function project(array $events)
{
foreach ($events as $event) {
$this->producer->publish(
$this->serializer->serialize(
$event, 'json'
)
);
}
}
}
并且 RabbitMQ 交换上的事件消费者看起来可能像这样:
// Connect to an AMQP broker
$cnn = new AMQPConnection();
$cnn-> connect();
// Create a channel
$ch = new AMQPChannel($cnn);
// Create a new queue
$queue = new AMQPQueue($ch);
$queue->setName('events');
$queue->declare();
// Create an event loop
$loop = React\EventLoop\Factory::create();
$serializer = JMS\Serializer\SerializerBuilder::create()->build();
$client = new Elasticsearch\ClientBuilder::create()->build();
$projector = new Projector();
$projector->register([
new Infrastructure\Projection\Elasticsearch\
PostWasCreatedProjection($client),
new Infrastructure\Projection\Elasticsearch\
PostWasPublishedProjection($client),
new Infrastructure\Projection\Elasticsearch\
PostWasCategorizedProjection($client),
new Infrastructure\Projection\Elasticsearch\
PostContentWasChangedProjection($client),
new Infrastructure\Projection\Elasticsearch\
PostTitleWasChangedProjection($client),
]);
// Create a consumer
$consumer = new Gos\Component\ReactAMQP\Consumer($queue, $loop, 0.5, 10);
// Check for messages every half a second and consume up to 10 at a time.
$consumer->on(
'consume',
function ($envelope, $queue) use ($projector, $serializer) {
$event = $serializer->unserialize($envelope->getBody(), 'json');
$projector->project($event);
}
);
$loop->run();
从现在开始,这可能只需要让所有必要的仓库消费一个投影器实例,然后让它们调用投影过程:
class DoctrinePostRepository implements PostRepository
{
private $em;
private $projector;
public function __construct(EntityManager $em, Projector $projector)
{
$this->em = $em;
$this->projector = $projector;
}
public function save(Post $post)
{
$this->em->transactional(
function (EntityManager $em) use ($post)
{
$em->persist($post);
foreach ($post->recordedEvents() as $event) {
$em->persist($event);
}
}
);
$this->projector->project($post->recordedEvents());
}
public function byId(PostId $id)
{
return $this->em->find($id);
}
}
Post实例和记录的事件在同一事务中被触发和持久化。这确保了不会丢失任何事件,因为如果事务成功,我们将它们投影到读取模型。因此,写入模型和读取模型之间不会存在不一致性。
是使用 ORM 还是不使用 ORM
在实现 CQRS 时,最常见的疑问之一是是否真的需要对象关系映射器(ORM)。我们坚信,对于写入模型使用 ORM 是完全可以接受的,并且具有使用工具的所有优点,这将在我们使用关系数据库时帮助我们节省大量工作。但我们不应忘记,我们仍然需要在关系数据库中持久化和检索写入模型的状态。
事件溯源
CQRS 是一种强大且灵活的架构。它在收集和保存领域事件(在聚合操作期间发生)方面提供了额外的优势,这为您提供了对领域内部发生情况的详细高级度。由于它们在领域中的重要性,领域事件是关键战术模式之一,因为它们描述了过去发生的事情。
注意不要记录过多的事件
事件数量不断增长是一个信号。它可能揭示了在领域中对事件记录的依赖,这很可能是由于业务激励的。作为经验法则,请记住保持简单。
通过使用 CQRS,我们已经能够记录在领域层发生的所有相关事件。领域模型的状态可以通过重现我们之前记录的领域事件来表示。我们只需要一个工具来以一致的方式存储所有这些事件。我们需要一个事件存储库。
事件溯源背后的基本思想是将聚合的状态表示为一系列事件的线性序列
通过 CQRS,我们部分实现了以下目标:Post实体通过使用领域事件来改变其状态,但它被持久化,如前所述,因此将对象映射到数据库表。
事件溯源将这一概念进一步发展。如果我们使用数据库表来存储所有博客文章的状态,另一个来存储所有博客文章评论的状态,依此类推,使用事件溯源将允许我们使用单个数据库表:一个单一的追加——只存储域模型内所有聚合体发布的所有域事件的数据库表。是的,你读得对。一个单一的数据库表。
在这个模型下,不再需要对象关系映射器等工具。所需的唯一工具是一个简单的数据库抽象层,通过它可以追加事件:
interface EventSourcedAggregateRoot
{
public static function reconstitute(EventStream $events);
}
class Post extends AggregateRoot implements EventSourcedAggregateRoot
{
public static function reconstitute(EventStream $history)
{
$post = new static($history->getAggregateId());
foreach ($events as $event) {
$post->applyThat($event);
}
return $post;
}
}
现在,Post聚合体有一个方法,当给定一组事件(或者说,一个事件流)时,能够逐步回放状态直到达到当前状态,所有这些都在保存之前完成。下一步将是构建一个PostRepository端口的适配器,该适配器将从Post聚合体中检索所有已发布的事件并将它们附加到所有事件都附加的数据存储中。这就是我们所说的事件存储:
class EventStorePostRepository implements PostRepository
{
private $eventStore;
private $projector;
public function __construct($eventStore, $projector)
{
$this->eventStore = $eventStore;
$this->projector = $projector;
}
public function save(Post $post)
{
$events = $post->recordedEvents();
$this->eventStore->append(new EventStream(
$post->id(),
$events)
);
$post->clearEvents();
$this->projector->project($events);
}
}
这是我们使用事件存储来保存Post聚合体发布的所有事件的PostRepository实现方式。现在我们需要一种方法来从其事件历史中恢复聚合体。Post聚合体实现了一个reconstitute方法,并用于从触发事件中重建博客文章状态,这在实际操作中非常有用:
class EventStorePostRepository implements PostRepository
{
public function byId(PostId $id)
{
return Post::reconstitute(
$this->eventStore->getEventsFor($id)
);
}
}
事件存储是执行所有与保存和恢复事件流相关的责任的工作马。其公共 API 由两个简单的方法组成:它们是append和getEventsFrom。前者将事件流追加到事件存储中,后者加载事件流以允许聚合体重建。
我们可以使用键值实现来存储所有事件:
class EventStore
{
private $redis;
private $serializer;
public function __construct($redis, $serializer)
{
$this->redis = $redis;
$this->serializer = $serializer;
}
public function append(EventStream $eventstream)
{
foreach ($eventstream as $event) {
$data = $this->serializer->serialize(
$event, 'json'
);
$date = (new DateTimeImmutable())->format('YmdHis');
$this->redis->rpush(
'events:' . $event->getAggregateId(),
$this->serializer->serialize([
'type' => get_class($event),
'created_on' => $date,
'data' => $data
],'json')
);
}
}
public function getEventsFor($id)
{
$serializedEvents = $this->redis->lrange('events:' . $id, 0, -1);
$eventStream = [];
foreach($serializedEvents as $serializedEvent){
$eventData = $this->serializerdeserialize(
$serializedEvent,
'array',
'json'
);
$eventStream[] = $this->serializer->deserialize(
$eventData['data'],
$eventData['type'],
'json'
);
}
return new EventStream($id, $eventStream);
}
}
此事件存储实现建立在广泛使用的键值存储Redis之上。事件通过前缀 events:附加到列表中。此外,在持久化事件之前,我们会提取一些元数据,如事件类别或创建日期,因为它们在以后会很有用。
显然,从性能角度来看,聚合体每次都要遍历其完整的事件历史以到达最终状态是非常昂贵的。这尤其适用于事件流包含数百甚至数千个事件的情况。克服这种情况的最佳方法是从聚合体中获取快照,并仅重新播放快照之后发生的事件流中的事件。快照只是聚合体在任何给定时刻状态的简单序列化版本。它可以基于聚合体事件流的数量,或者基于时间。在第一种方法中,每n个触发事件(例如每 50、100 或 200 个事件)将获取一个快照。在第二种方法中,每n秒获取一个快照。
为了遵循示例,我们将使用快照的第一种方式。在事件的元数据中,我们存储一个额外的字段,版本号,我们将从它开始重新播放聚合的历史:
class SnapshotRepository
{
public function byId($id)
{
$key = 'snapshots:' . $id;
$metadata = $this->serializer->unserialize(
$this->redis->get($key)
);
if (null === $metadata) {
return;
}
return new Snapshot(
$metadata['version'],
$this->serializer->unserialize(
$metadata['snapshot']['data'],
$metadata['snapshot']['type'],
'json'
)
);
}
public function save($id, Snapshot $snapshot)
{
$key = 'snapshots:' . $id;
$aggregate = $snapshot->aggregate();
$snapshot = [
'version' => $snapshot->version(),
'snapshot' => [
'type' => get_class($aggregate),
'data' => $this->serializer->serialize(
$aggregate, 'json'
)
]
];
$this->redis->set($key, $snapshot);
}
}
现在我们需要重构EventStore类,使其开始使用SnapshotRepository以可接受的性能时间加载聚合:
class EventStorePostRepository implements PostRepository
{
public function byId(PostId $id)
{
$snapshot = $this->snapshotRepository->byId($id);
if (null === $snapshot) {
return Post::reconstitute(
$this->eventStore->getEventsFrom($id)
);
}
$post = $snapshot->aggregate();
$post->replay(
$this->eventStore->fromVersion($id, $snapshot->version())
);
return $post;
}
}
我们只需要定期进行聚合快照。我们可以通过负责监控事件存储的过程同步或异步地完成这项工作。以下代码是一个简单的示例,展示了聚合快照的实现:
class EventStorePostRepository implements PostRepository
{
public function save(Post $post)
{
$id = $post->id();
$events = $post->recordedEvents();
$post->clearEvents();
$this->eventStore->append(new EventStream($id, $events));
$countOfEvents =$this->eventStore->countEventsFor($id);
$version = $countOfEvents / 100;
if (!$this->snapshotRepository->has($post->id(), $version)) {
$this->snapshotRepository->save(
$id,
new Snapshot(
$post, $version
)
);
}
$this->projector->project($events);
}
}
是否使用 ORM
从这种架构风格的使用案例中可以看出,仅仅为了持久化/检索事件而使用 ORM 会过度设计。即使我们使用关系数据库来存储它们,我们也只需要从数据存储中持久化/检索事件。
总结
由于有大量的架构风格选项,你可能会在本章中感到有些困惑。你必须考虑每个选项的权衡,以便明智地选择。有一点是明确的:巨大的泥球方法不是一个选择,因为代码会很快腐烂。分层架构是一个更好的选择,但它有一些缺点,比如层与层之间的紧密耦合。可以说,最平衡的选项是六边形架构,因为它可以用作基础架构,并促进应用内外部的高度解耦和对称。这是我们推荐在大多数场景下的做法。
我们还看到了 CQRS 和事件溯源作为相对灵活的架构,它们将帮助你应对严重的复杂性。CQRS 和事件溯源都有它们的位置,但不要让酷炫因素分散你对它们提供的价值的注意力。由于它们都带来了一些开销,你应该有一个技术理由来证明它们的使用是合理的。这些架构风格确实非常有用,而开始使用它们的启发式方法可以在 CQRS 的存储库中的查找器数量和事件溯源触发事件的量中找到。如果查找方法开始增加,并且存储库变得难以维护,那么是时候考虑使用 CQRS 来分割读/写关注点。然后,如果每个聚合操作的事件量趋于增长,并且业务对更细粒度的信息感兴趣,那么可以考虑的一个选择是转向事件溯源可能会带来回报。
摘自布赖恩·福特和约瑟夫·约德的一篇论文:
一个巨大的泥球结构混乱、杂乱无章、粘土和铁丝网粘合,意大利面代码丛林。
第三章:值对象
通过使用 self 关键字,我们不需要... 值对象是领域驱动设计的基本构建块,它们用于在代码中建模你的通用语言的概念。值对象不仅仅是领域中的一个事物——它衡量、量化或描述某物。值对象可以看作是小型、简单的对象——例如货币或日期范围——它们的等价性不是基于身份,而是基于所包含的内容。
例如,可以使用值对象来模拟产品价格。在这种情况下,它不是代表一个事物,而是一个值,它允许我们衡量一个产品值多少钱。这些对象的内存占用非常小(通过它们的组成部分计算得出)并且几乎没有开销。因此,在表示相同值时,新实例的创建比引用重用更受欢迎。然后根据两个实例的字段的可比性来检查等价性。
定义
Ward Cunningham 定义 值对象为:
对某物的衡量或描述。值对象的例子包括像数字、日期、货币和字符串这样的东西。通常,它们是使用相当广泛的小对象。它们的身份基于它们的状态而不是它们的对象身份。这样,你可以有多个相同概念上的值对象副本。每张 5 美元纸币都有其自己的身份(多亏了它的序列号),但现金经济依赖于每张 5 美元纸币都有与其他 5 美元纸币相同的值。
Martin Fowler 定义 值对象为:
一个小的对象,如货币或日期范围对象。它们的关键属性是它们遵循值语义而不是引用语义。你通常可以通过它们的等价性不是基于身份来判断它们,而是如果所有字段都相等,则两个值对象是相等的。尽管所有字段都相等,但如果子集是唯一的,则不需要比较所有字段——例如,货币对象的货币代码就足以测试等价性。一个一般的启发式方法是值对象应该是完全不可变的。如果你想更改一个值对象,你应该用一个新的对象替换它,而不允许更新值对象本身的值——可更新的值对象会导致别名问题。
值对象的例子包括数字、文本字符串、日期、时间、一个人的全名(由名、中名、姓和头衔组成)、货币、颜色、电话号码和邮政地址。
练习
尝试在你的当前领域中定位更多潜在的值对象示例。
值对象与实体
考虑以下来自 维基百科 的例子,以更好地理解值对象与实体之间的区别:
-
值对象:当人们交换美元纸币时,他们通常不会区分每一张独特的纸币;他们只关心美元纸币的面值。在这种情况下,美元纸币是值对象。然而,联邦储备银行可能关心每一张独特的纸币;在这种情况下,每一张纸币都是一个实体。
-
实体:大多数航空公司会在每架航班上唯一区分每个座位。在这种情况下,每个座位都是一个实体。然而,西南航空、易捷航空和瑞安航空不会区分每个座位;所有座位都是相同的。在这种情况下,座位实际上是一个值对象。
练习
考虑地址的概念(街道、号码、邮编等)。在什么情况下可以将地址建模为实体而不是值对象?与同伴讨论你的发现。
货币与货币示例
Currency和Money值对象可能是解释值对象最常用的例子,归功于货币模式。此设计模式提供了一种避免浮点数舍入问题的解决方案,从而允许进行确定性计算。
在现实世界中,货币以与米和码相同的方式描述货币单位。每种货币都使用一个三个字母的大写 ISO 代码表示:
class Currency
{
private $isoCode;
public function __construct($anIsoCode)
{
$this->setIsoCode($anIsoCode);
}
private function setIsoCode($anIsoCode)
{
if (!preg_match('/^[A-Z]{3}$/', $anIsoCode)) {
throw new InvalidArgumentException();
}
$this->isoCode = $anIsoCode;
}
public function isoCode()
{
return $this->isoCode;
}
}
值对象的主要目标之一也是面向对象设计的圣杯:封装。通过遵循此模式,你将最终拥有一个专门的位置来放置给定概念的所有验证、比较逻辑和行为。
货币的额外验证
在前面的代码示例中,我们可以使用 AAA 货币 ISO 代码构建一个货币。这完全不正确。编写一个更具体的规则来检查 ISO 代码是否有效。有效的货币 ISO 代码的完整列表可以在这里找到。如果你需要帮助,请查看Money包管理器库。
货币用于衡量特定货币的数量。它使用金额和货币进行建模。在货币模式的情况下,金额使用货币最不重要的分数的整数表示实现——例如,在美元或欧元的情况下,是分。
作为额外奖励,你可能还会注意到我们正在使用自我封装来设置 ISO 代码,这集中了值对象本身的变化:
class Money
{
private $amount;
private $currency;
public function __construct($anAmount, Currency $aCurrency)
{
$this->setAmount($anAmount);
$this->setCurrency($aCurrency);
}
private function setAmount($anAmount)
{
$this->amount = (int) $anAmount;
}
private function setCurrency(Currency $aCurrency)
{
$this->currency = $aCurrency;
}
public function amount()
{
return $this->amount;
}
public function currency()
{
return $this->currency;
}
}
现在你已经了解了值对象的形式定义,让我们更深入地了解它们提供的强大功能。
特征
在将通用语言概念建模到代码中时,你应该始终优先考虑值对象而不是实体。值对象更容易创建、测试、使用和维护。
考虑到这一点,你可以确定所讨论的概念是否可以建模为值对象,如果:
-
它测量、量化或描述领域中的事物
-
它可以保持不变
-
它通过将相关属性作为整体单元组合来模拟一个概念整体
-
它可以通过值相等与其他对象进行比较
-
当测量或描述发生变化时,它可以完全替换
-
它为协作者提供无副作用的操作
测量、量化或描述
如前所述,值对象不应仅被视为领域中的“事物”。作为一个值,它测量、量化或描述领域中的概念。
在我们的示例中,Currency对象描述了它是哪种类型的货币。Money对象测量或量化特定货币的单位。
不可变性
这是理解最重要的方面之一。对象值在其生命周期内不应能够被更改。正因为这种不可变性,值对象易于推理和测试,并且没有不希望或不预期的副作用。因此,值对象应通过其构造函数创建。为了构建一个,你通常通过这个构造函数传递所需的原始类型或其他值对象。
值对象始终处于有效状态;这就是为什么我们通过单个原子步骤创建它们。带有多个设置器和获取器的空构造函数将创建责任转移到客户端,导致贫血领域模型,这被认为是一种反模式。
还应指出,不建议在值对象中保留实体的引用。实体是可变的,持有它们的引用可能导致值对象中出现不希望或不预期的副作用。
在支持方法重载的语言中,例如 Java,你可以创建具有相同名称的多个构造函数。这些构造函数都提供了不同的选项来构建相同类型的对象。在 PHP 中,我们能够通过工厂方法模式提供类似的能力,这些特定的工厂方法也被称为语义构造函数。fromMoney的主要目标是提供比普通构造函数更多的上下文意义。更激进的方案建议将__construct方法设为私有,并使用语义构造函数来构建每个实例。
在我们的Money对象中,我们可以添加一些有用的工厂方法,如下所示:
class Money
{
// ...
public static function fromMoney(Money $aMoney)
{
return new self(
$aMoney->amount(),
$aMoney->currency()
);
}
public static function ofCurrency(Currency $aCurrency)
{
return new self(0, $aCurrency);
}
}
通过使用self关键字,我们不会将代码与类名耦合。因此,对类名或命名空间的更改不会影响这些工厂方法。这个小的实现细节有助于在以后重构代码时。
静态与自身
当一个值对象从另一个值对象继承时,使用静态而非自身可能会导致不希望的问题。
由于这种不可变性,我们必须考虑如何在有状态上下文中处理常见的可变操作。如果我们需要状态变化,我们现在必须返回一个新的带有这种变化的值对象表示。如果我们想增加例如 Money 值对象的数量,我们必须返回一个新的带有所需修改的 Money 实例。
幸运的是,遵守这个规则相对简单,如下面的示例所示:
class Money
{
// ...
public function increaseAmountBy($anAmount)
{
return new self(
$this->amount() + $anAmount,
$this->currency()
);
}
}
increaseAmountBy 返回的 Money 对象与接收方法调用的 Money 客户端对象不同。这可以在下面的示例比较检查中观察到:
$aMoney = new Money(100, new Currency('USD'));
$otherMoney = $aMoney->increaseAmountBy(100);
var_dump($aMoney === otherMoney); // bool(false)
$aMoney = $aMoney->increaseAmountBy(100);
var_dump($aMoney === $otherMoney); // bool(false)
概念整体
那为什么不直接实现以下示例,从而完全避免需要新的值对象类呢?
class Product
{
private id;
private name;
/**
* @var int
*/
private $amount;
/**
* @var string
*/
private $currency;
// ...
}
这种方法有一些明显的缺陷,比如说,如果你想验证 ISO。让产品负责货币的 ISO 验证(从而违反了单一职责原则)实际上并没有太多意义。如果你想在域的其他部分重用相关的逻辑(以遵守 DRY 原则),这一点尤为突出。
考虑到这些因素,这个用例是抽象成值对象的完美候选者。使用这种抽象不仅给你提供了将相关属性分组在一起的机会,而且还允许你创建更高阶的概念和更具体的通用语言。
练习
与同行讨论一下,是否可以将电子邮件视为值对象。使用的上下文是否重要?
值等价
如本章开头所讨论的,如果两个值对象测量的、计量的或描述的内容相同,则这两个值对象是相等的。
例如,想象有两个 Money 对象代表 1 美元。我们能认为它们相等吗?在现实世界中,两张 1 美元的纸币价值相同吗?当然,它们是相同的。将我们的注意力转回代码,所讨论的值对象指的是 Money 的不同实例。然而,它们都代表相同的值,这使得它们相等。
关于 PHP,使用 == 操作符比较两个值对象是很常见的。查看PHP 文档中对操作符的定义,可以突出一个有趣的行为:
当使用比较操作符 == 时,对象变量以简单的方式进行比较,即:如果两个对象实例具有相同的属性和值,并且是同一类的实例,则这两个对象实例是相等的。
这种行为与我们的值对象正式定义一致。然而,由于存在精确的类匹配谓词,在处理子类型值对象时你应该小心。
考虑到这一点,甚至更严格的 === 操作符不幸地并没有帮上忙:
当使用身份操作符===时,如果对象变量引用的是同一类的同一实例,则它们是相同的。
以下示例应有助于确认这些细微的差异:
$a = new Currency('USD');
$b = new Currency('USD');
var_dump($a == $b); // bool(true)
var_dump($a === $b); // bool(false)
$c = new Currency('EUR');
var_dump($a == $c); // bool(false)
var_dump($a === $c); // bool(false)
一种解决方案是在每个值对象中实现一个传统的equals方法。此方法负责检查其复合属性的类型和相等性。使用 PHP 内置的类型提示,抽象数据类型比较很容易实现。如果需要,您还可以使用get_class()函数来帮助进行可比较性检查。
然而,该语言无法解析在您的领域概念中真正意味着什么,这意味着提供答案的责任在于您。为了比较Currency对象,我们只需确认它们关联的 ISO 代码相同。在这种情况下,===操作符做得相当不错:
class Currency
{
// ...
public function equals(Currency $currency)
{
return $currency->isoCode() === $this->isoCode();
}
}
由于Money对象使用Currency对象,equals方法需要执行这种可比较性检查,同时比较金额:
class Money
{
// ...
public function equals(Money $money)
{
return
$money->currency()->equals($this->currency()) &&
$money->amount() === $this->amount();
}
}
可替换性
考虑一个包含用于量化其价格的Money值对象的Product实体。此外,考虑两个具有相同价格的Product实体——例如 100 美元。这种场景可以使用两个单独的Money对象或两个指向单个值对象的引用来建模。
共享相同的值对象可能存在风险;如果其中一个被修改,两个都会反映这种变化。这种行为可以被认为是一个意外的副作用。例如,如果卡洛斯于 2 月 20 日被雇佣,并且我们知道克里斯蒂安也是在同一天被雇佣的,我们可能会将克里斯蒂安的雇佣日期设置为与卡洛斯相同的实例。如果卡洛斯随后将他的雇佣日期月份改为五月,克里斯蒂安的雇佣日期也会改变。无论这是否正确,这并不是人们所期望的。
由于本例中指出的问题,当持有值对象的引用时,建议整个替换对象而不是修改其值:
$this−>price = new Money(100, new Currency('USD'));
//...
$this->price = $this->price->increaseAmountBy(200);
这种行为类似于 PHP 中基本类型(如字符串)的工作方式。考虑函数strtolower。它返回一个新的字符串而不是修改原始字符串。没有使用引用;相反,返回了一个新值。
无副作用行为
如果我们想在我们的Money类中包含一些额外的行为——比如一个add方法——那么检查输入是否符合任何先决条件并保持任何不变性似乎是自然的。在我们的情况下,我们只想添加相同货币的金额:
class Money
{
// ...
public function add(Money $money)
{
if ($money->currency() !== $this->currency()) {
throw new InvalidArgumentException();
}
$this->amount += $money->amount();
}
}
如果两种货币不匹配,将引发异常。否则,金额将被相加。然而,这段代码有一些不希望出现的陷阱。现在想象一下,在我们的代码中有一个神秘的otherMethod方法调用:
class Banking
{
public function doSomething()
{
$aMoney = new Money(100, new Currency('USD'));
$this->otherMethod($aMoney);//mysterious call
// ...
}
}
一切都很顺利,直到,由于某种原因,当我们返回或完成 otherMethod 时,我们开始看到意外的结果。突然,$aMoney 不再包含 100 美元。发生了什么?如果 otherMethod 内部使用我们之前定义的 add 方法,会发生什么?你可能没有意识到 add 方法会改变 Money 实例的状态。这就是我们所说的副作用。你必须避免产生副作用。你不应该修改你的参数。如果你这样做,使用你的对象的开发者可能会遇到奇怪的行为。他们会抱怨,而且他们是对的。
那么,我们如何解决这个问题?简单——通过确保值对象保持不可变,我们避免了这种意外问题。一个简单的解决方案可能是对于每个可能可变操作,都返回一个新的实例,这是 add 方法所做的:
class Money
{
// ...
public function add(Money $money)
{
if (!$money->currency()->equals($this->currency())) {
throw new \InvalidArgumentException();
}
return new self(
$money->amount() + $this->amount(),
$this->currency()
);
}
}
通过这个简单的更改,不可变性得到了保证。每次两个 Money 实例相加时,都会返回一个新的结果实例。其他类可以执行任何数量的更改,而不会影响原始副本。没有副作用的代码易于理解,易于测试,且错误率较低。
基本类型
考虑以下代码片段:
$a = 10;
$b = 10;
var_dump($a == $b);
// bool(true)
var_dump($a === $b);
// bool(true)
$a = 20;
var_dump($a);
// integer(20)
$a = $a + 30;
var_dump($a);
// integer(50);
虽然 $a 和 $b 是存储在不同内存位置的不同的变量,但在比较时,它们是相同的。它们持有相同的值,因此我们认为是相等的。你可以随时将 $a 的值从 10 改为 20,使新值为 20 并消除 10。你可以随意替换整数值,无需考虑之前的值,因为你没有修改它;你只是在替换它。如果你对这些变量应用任何操作——例如加法(即 $a + $b)——你将得到另一个新值,该值可以分配给另一个变量或之前定义的一个。当你将 $a 传递给另一个函数时,除非明确通过引用传递,否则你传递的是一个值。无论 $a 在该函数内部是否被修改,都无关紧要,因为在你当前的代码中,你仍然会有原始副本。值对象的行为就像基本类型。
测试值对象
值对象与普通对象以相同的方式进行测试。然而,必须测试不可变性和无副作用的行为。一种解决方案是在执行任何修改之前创建你正在测试的值对象的副本。使用实现的相等性检查断言两者相等。执行你想要测试的操作,并断言结果。最后,断言原始对象和副本仍然相等。
让我们将这个应用到实践中,并测试 Money 类中 add 方法的无副作用实现:
class MoneyTest extends *Framework*TestCase
{
/**
* @test
*/
public function copiedMoneyShouldRepresentSameValue()
{
$aMoney = new Money(100, new Currency('USD'));
$copiedMoney = Money::fromMoney($aMoney);
$this->assertTrue($aMoney->equals($copiedMoney));
}
/**
* @test
*/
public function originalMoneyShouldNotBeModifiedOnAddition()
{
$aMoney = new Money(100, new Currency('USD'));
$aMoney->add(new Money(20, new Currency('USD')));
$this->assertEquals(100, $aMoney->amount());
}
/**
* @test
*/
public function moniesShouldBeAdded()
{
$aMoney = new Money(100, new Currency('USD'));
$newMoney = $aMoney->add(new Money(20, new Currency('USD')));
$this->assertEquals(120, $newMoney->amount());
}
// ...
}
持久化值对象
值对象自身不进行持久化;它们通常在聚合(Aggregate)内部进行持久化。尽管在某些情况下可以选择将其作为完整记录进行持久化,但值对象不应作为完整记录进行持久化。相反,最好使用嵌入式值(Embedded Value)或序列化 LOB 模式。这两种模式都可以在持久化对象时使用开源 ORM,如 Doctrine,或定制 ORM。由于值对象较小,嵌入式值通常是最佳选择,因为它提供了一种通过值对象具有的任何属性查询实体的简单方法。然而,如果您不重视通过这些字段进行查询,序列化策略可以非常容易实现。
考虑以下具有字符串 id、name 和 price(Money 值对象)属性的 Product 实体。我们故意决定简化这个例子,将 id 设计为字符串而不是值对象:
class Product
{
private $productId;
private $name;
private $price;
public function __construct(
$aProductId,
$aName,
Money $aPrice
) {
$this->setProductId($aProductId);
$this->setName($aName);
$this->setPrice($aPrice);
}
// ...
}
假设您有一个 第十章,用于持久化 Product 实体的 仓库,创建和持久化一个新的 Product 的实现可能看起来像这样:
$product = new Product(
$productRepository->nextIdentity(),
'Domain-Driven Design in PHP',
new Money(999, new Currency('USD'))
);
$productRepository−>persist(product);
现在我们来看看可以用来持久化包含值对象的 Product 实体的临时性 ORM 和 Doctrine 实现。我们将突出嵌入式值和序列化 LOB 模式的应用,以及持久化单个值对象和它们的集合之间的差异。
为什么选择 Doctrine?
Doctrine 是一个优秀的 ORM。它解决了 PHP 应用面临的大部分需求。它拥有一个庞大的社区。通过正确调优的设置,它可以与定制 ORM(不失去可维护性)的性能相同,甚至更好。我们建议在处理实体和业务逻辑时,大多数情况下使用 Doctrine。这将为您节省大量时间和精力。
持久化单个值对象
对于持久化单个值对象,有许多不同的选项。这些选项从使用序列化 LOB 或嵌入式值作为映射策略,到使用临时性 ORM 或开源替代品,如 Doctrine。我们认为临时性 ORM 是公司可能为了在数据库中持久化实体而开发的定制 ORM。在我们的场景中,临时性 ORM 代码将使用 DBAL 库来实现。根据 官方文档,Doctrine 数据库抽象 & 访问层(DBAL)在类似 PDO 的 API 周围提供了一个轻量级且薄的运行时层,以及许多额外的横向功能,如通过面向对象的 API 进行数据库模式自省和操作。
使用临时性 ORM 的嵌入式值
如果我们处理的是使用嵌入式值模式的 Ad Hoc ORM,我们需要在实体表中为值对象中的每个属性创建一个字段。在这种情况下,当持久化Product实体时需要两个额外的列——一个用于值对象的金额,另一个用于其货币 ISO 代码:
CREATE TABLE `products` (
id INT NOT NULL,
name VARCHAR( 255) NOT NULL,
price_amount INT NOT NULL,
price_currency VARCHAR( 3) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
为了在数据库中持久化实体,我们的第十章,存储库必须映射实体和Money值对象中的每个字段。
如果您使用基于 DBAL 的 Ad hoc ORM 存储库——让我们称它为DbalProductRepository——您必须注意创建INSERT语句,绑定参数,并执行该语句:
class DbalProductRepository
extends DbalRepository
implements ProductRepository
{
public function add(Product $aProduct)
{
$sql = 'INSERT INTO products VALUES (?, ?, ?, ?)' ;
$stmt = $this->connection()->prepare($sql);
$stmt->bindValue(1, $aProduct->id());
$stmt->bindValue(2, $aProduct->name());
$stmt->bindValue(3, $aProduct->price()->amount());
$stmt->bindValue(4, $aProduct
->price()->currency()->isoCode());
$stmt->execute();
// ...
}
}
在执行此代码片段以创建Products实体并将其持久化到数据库后,每个列都填充了所需的信息:
mysql> select * from products \G
*************************** 1\. row ***************************
id: 1
name: Domain-Driven Design in PHP
price_amount: 999
price_currency: USD
1 row in set (0.00 sec)
如您所见,您可以通过 Ad hoc 方式映射您的值对象和查询参数,以持久化您的值对象。然而,事情并不像看起来那么简单。让我们尝试获取带有其关联的Money值对象的持久化产品。一个常见的方法是执行一个SELECT语句并返回一个新的实体:
class DbalProductRepository
extends DbalRepository
implements ProductRepository
{
public function productOfId($anId)
{
$sql = 'SELECT * FROM products WHERE id = ?';
$stmt = $this->connection()->prepare($sql);
$stmt->bindValue(1, $anId);
$res = $stmt->execute();
// ...
return new Product(
$row['id'],
$row['name'],
new Money(
$row['price_amount'],
new Currency($row['price_currency'])
)
);
}
}
这种方法有一些好处。首先,您可以轻松地逐步阅读持久化和随后的创建过程。其次,您可以根据值对象的任何属性执行查询。最后,持久化实体的空间需求正好是所需的——不多也不少。
然而,使用 Ad hoc ORM 方法有其缺点。如第六章中所述,领域事件,实体(以聚合形式)应该在构造函数中触发事件,如果您的领域对聚合的创建感兴趣。如果您使用新操作符,您将根据从数据库中获取聚合的次数触发事件。
这是 Doctrine 使用内部代理和serialize以及unserialize方法来在不使用构造函数的情况下,以特定状态重新构成一个对象及其属性的原因之一。一个实体在其生命周期中应该只使用新操作符创建一次:
构造函数
构造函数不需要为对象中的每个属性包含一个参数。考虑一下博客文章。构造函数可能需要一个 id 和一个标题;然而,在内部它也可以设置其状态属性为草稿。当发布文章时,应该调用发布方法以相应地更改其状态并设置发布日期。
如果您的意图仍然是推出您自己的 ORM,请准备好解决一些基本问题,例如事件、不同的构造函数、值对象、延迟加载关系等。这就是为什么我们建议为领域驱动设计应用程序尝试使用 Doctrine。
此外,在这个例子中,您需要创建一个继承自Product实体并能够从数据库中重新构成实体的DbalProduct实体,而不使用新操作符,而是使用静态工厂方法。
Doctrine >= 2.5.*中的嵌入式值(Embeddables)
目前最新的稳定 Doctrine 版本是版本 2.5,它提供了映射值对象的支持,从而消除了在Doctrine 2.4中自己执行此操作的需求。自 2015 年 12 月以来,Doctrine 还支持嵌套嵌入式对象。虽然支持率不是 100%,但已经足够高,可以尝试一下。如果它不适合您的场景,请查看下一节。对于官方文档,请查看 Doctrine 嵌入式对象参考。如果正确实现,这绝对是我们最推荐的选择。这将是一个最简单、最优雅的解决方案,同时也为您的DQL查询提供了搜索支持。
由于Product、Money和Currency类已经展示过,剩下要展示的只是 Doctrine 映射文件:
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping
xsi:schemaLocation="
http://doctrine-project.org/schemas/orm/doctrine-mapping
https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd">
<entity
name="Product"
table="product">
<id
name="id"
column="id"
type="string"
length="255">
<generator strategy="NONE">
</generator>
</id>
<field
name="name"
type="string"
length="255"
/>
<embedded
name="price"
class="Ddd\Domain\Model\Money"
/>
</entity>
</doctrine-mapping>
在产品映射中,我们定义price为一个实例变量,它将保存一个Money实例。同时,Money被设计为一个金额和一个Currency实例:
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping
xsi:schemaLocation="
http://doctrine-project.org/schemas/orm/doctrine-mapping
https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd">
<embeddable
name="Ddd\Domain\Model\Money">
<field
name="amount"
type="integer"
/>
<embedded
name="currency"
class="Ddd\Domain\Model\Currency"
/>
</embeddable>
</doctrine-mapping>
最后,是时候展示我们的Currency值对象的 Doctrine 映射了:
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping
xsi:schemaLocation="
http://doctrine-project.org/schemas/orm/doctrine-mapping
https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd">
<embeddable
name="Ddd\Domain\Model\Currency">
<field
name="iso"
type="string"
length="3"
/>
</embeddable>
</doctrine-mapping>
如您所见,上述代码具有一个标准的可嵌入定义,只需一个字符串字段来存储 ISO 代码。这种方法是使用可嵌入对象的最简单方式,并且效果更佳。默认情况下,Doctrine 通过在对象名称前加前缀来命名您的列。您可以通过更改 XML 表示法中的列前缀属性来更改此行为以满足您的需求。
Doctrine <= 2.4.*中的嵌入式值
如果您仍然停留在Doctrine 2.4,您可能会想知道使用Doctrine < 2.5的嵌入式值的可接受解决方案是什么。现在我们需要在Product实体中代理所有值对象属性,这意味着创建新的人工属性来保存值对象的信息。有了这个,我们可以使用 Doctrine 映射所有这些新属性。让我们看看这对Product实体有什么影响:
class Product
{
private $productId;
private $name;
private $price;
private $surrogateCurrencyIsoCode;
private $surrogateAmount;
public function __construct($aProductId, $aName, Money $aPrice)
{
$this->setProductId($aProductId);
$this->setName($aName);
$this->setPrice($aPrice);
}
private function setPrice(Money $aMoney)
{
$this->price = $aMoney;
$this->surrogateAmount = $aMoney->amount();
$this->surrogateCurrencyIsoCode =
$aMoney->currency()->isoCode();
}
private function price()
{
if (null === $this->price) {
$this->price = new Money(
$this->surrogateAmount,
new Currency($this->surrogateCurrency)
);
}
return $this->price;
}
// ...
}
如您所见,有两个新属性:一个用于金额,另一个用于货币的 ISO 代码。我们还更新了setPrice方法,以便在设置时保持属性一致性。在此基础上,我们还更新了价格获取器,以便返回由新字段构建的Money值对象。让我们看看相应的 XML Doctrine 映射应该如何更改:
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping
xsi:schemaLocation="
http://doctrine-project.org/schemas/orm/doctrine-mapping
https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd">
<entity
name="Product"
table="product">
<id
name="id"
column="id"
type="string"
length="255" >
<generator strategy="NONE">
</generator>
</id>
<field
name="name"
type="string"
length="255"
/>
<field
name="surrogateAmount"
type="integer"
column="price_amount"
/>
<field
name="surrogateCurrencyIsoCode"
type="string"
column="price_currency"
/>
</entity>
</doctrine-mapping>
代理属性
这两个新字段并不严格属于域,因为它们不引用基础设施细节。相反,它们是由于 Doctrine 缺乏可嵌入支持而成为必要的。有其他方法可以将这两个属性推到纯域之外;然而,这种方法更简单、更容易,并且作为权衡,是可以接受的。本书中还有另一种代理属性的使用;你可以在第四章的“实体”部分的代理身份子部分中找到它。
如果我们想要将这两个属性推到域之外,可以通过使用抽象工厂来实现。首先,我们需要在我们的基础设施文件夹中创建一个新的实体,DoctrineProduct。这个实体将扩展自Product实体。所有代理字段都将放置在新类中,并且像价格或setPrice这样的方法应该被重新实现。我们将映射 Doctrine 使用新的DoctrineProduct而不是Product实体。
现在我们能够从数据库中检索实体,但创建一个新的Product呢?在某个时候,我们需要调用新的Product,但由于我们需要处理DoctrineProduct,并且我们不希望我们的应用程序服务了解基础设施细节,因此我们需要使用工厂来创建Product实体。所以,在每次使用new创建实体的情况下,你将调用ProductFactory上的createProduct。
为了避免将代理属性放置在原始实体中,可能需要许多额外的类。因此,我们建议将所有值对象代理到同一个实体中,尽管这确实会导致一个不那么纯粹的解决方案。
序列化 LOB 和 Ad Hoc ORM
如果向值对象属性添加搜索功能并不重要,可以考虑另一种模式:序列化 LOB。这种模式通过将整个值对象序列化为一个字符串格式,该格式可以轻松持久化和检索。与嵌入式替代方案相比,这种解决方案的最显著区别在于,在后者中,持久化足迹需求减少到单个列:
CREATE TABLE ` products` (
id INT NOT NULL,
name VARCHAR( 255) NOT NULL,
price TEXT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
为了使用这种方法持久化Product实体,需要对DbalProductRepository进行更改。在持久化final实体之前,必须将Money值对象序列化为字符串:
class DbalProductRepository extends DbalRepository implements ProductRepository
{
public function add(Product $aProduct)
{
$sql = 'INSERT INTO products VALUES (?, ?, ?)';
$stmt = this->connection()->prepare(sql);
$stmt->bindValue(1, aProduct−>id());
$stmt->bindValue(2, aProduct−>name());
$stmt->bindValue(3, $this−>serialize($aProduct->price()));
// ...
}
private function serialize($object)
{
return serialize($object);
}
}
让我们看看我们的产品现在在数据库中的表示。表列price是一个TEXT类型的列,其中包含表示 9.99 美元的Money对象的序列化:
mysql > select * from products \G
*************************** 1.row***************************
id : 1
name : Domain-Driven Design in PHP
price : O:22:"Ddd\Domain\Model\Money":2:{s:30:"Ddd\Domain\Model\\
Money amount";i :
999;s:32:"Ddd\Domain\Model\Money currency";O : 25:"Ddd\Domain\Model\\
Currency":1:{\
s:34:" Ddd\Domain\Model\Currency isoCode";s:3:"USD";}}1 row in set(\ 0.00 sec)
这种方法可以完成任务。然而,由于在代码中重构类时可能出现问题,因此不推荐使用。你能想象如果我们决定重命名我们的Money类会出现什么问题吗?你能想象当我们把Money类从一个命名空间移动到另一个命名空间时,我们的数据库表示需要做出哪些更改吗?另一个权衡,如前所述,是缺乏查询能力。无论你是否使用 Doctrine,使用序列化策略时,编写一个查询以获取比 200 美元便宜的产品几乎是不可能的。
查询问题只能通过使用嵌入式值来解决。然而,可以通过用于处理序列化过程的专用库来解决序列化重构问题。
使用 JMS Serializer 改进序列化
当处理类和命名空间重构时,序列化/反序列化原生的 PHP 策略存在问题。一个替代方案是使用你自己的序列化机制——例如,将数量、一个字符分隔符(如|)和货币 ISO 代码连接起来。然而,还有一个更受欢迎的方法:使用开源序列化库,如JMS Serializer。让我们看看在序列化Money对象时应用它的一个例子:
$myMoney = new Money(999, new Currency('USD'));
$serializer = JMS\Serializer\SerializerBuilder::create()->build();
$jsonData = $serializer−>serialize(myMoney, 'json');
为了反序列化对象,过程很简单:
$serializer = JMS\Serializer\SerializerBuilder::create()->build();
// ...
$myMoney = $serializer−>deserialize(jsonData, 'Ddd', 'json');
通过这个例子,你可以重构你的Money类,而无需更新你的数据库。JMS Serializer 可以在许多不同的场景中使用——例如,当与 REST API 一起工作时。一个重要特性是能够指定在序列化过程中应该省略对象哪些属性——例如,密码。
查阅映射参考和食谱以获取更多信息。JMS Serializer 在任何领域驱动设计项目中都是必不可少的。
使用 Doctrine 序列化 LOB
在 Doctrine 中,有不同方式序列化对象以便最终持久化。
Doctrine 对象映射类型
Doctrine 支持序列化 LOB 模式。有许多预定义的映射类型你可以使用,以便将实体属性与数据库列或表匹配。其中一种映射是对象类型,它使用serialize()和unserialize()将 SQL CLOB 映射到 PHP 对象。
根据Doctrine DBAL 2 文档,object类型:
根据 PHP 序列化映射和转换对象数据。如果你需要存储你对象数据的精确表示,你应该考虑使用此类型,因为它使用序列化来在数据库中以字符串形式表示你对象的精确副本。从数据库检索的值始终通过反序列化转换为 PHP 的对象类型,如果没有数据则转换为 null。
由于无法在数据库中本地存储 PHP 对象表示,此类型将始终映射到数据库供应商的文本类型。此外,此类型需要一个 SQL 列注释提示,以便可以从数据库中反向工程。如果供应商不支持列注释,Doctrine 无法正确映射此类型,并将回退到文本类型。
由于 PostgreSQL 内置的文本类型不支持 NULL 字节,对象类型将导致反序列化错误。解决这个问题的一个方法是手动将 PHP 对象 serialize()/unserialize() 和 base64_encode()/base64_decode() 存储到文本字段中。
让我们通过使用对象类型来查看为产品实体可能的 XML 映射:
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping
xsi:schemaLocation="
http://doctrine-project.org/schemas/orm/doctrine-mapping
https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd">
<entity
name="Product"
table="products">
<id
name="id"
column="id"
type="string"
length="255">
<generator strategy="NONE">
</generator>
</id>
<field
name="name"
type="string"
length="255"
/>
<field
name="price"
type="object"
/>
</entity>
</doctrine-mapping>
关键新增是 type="object",它告诉 Doctrine 我们将使用对象映射。让我们看看我们如何使用 Doctrine 创建和持久化 Product 实体:
// ...
$em−>persist($product);
$em−>flush($product);
让我们检查一下,如果我们现在从数据库中检索 Product 实体,它是否以预期的状态返回:
// ...
$repository = $em->getRepository('Ddd\\Domain\\Model\\Product');
$item = $repository->find(1);
var_dump($item);
/*
class Ddd\Domain\Model\Product#177 (3) {
private $productId => int(1)
private $name => string(41) "Domain-Driven Design in PHP"
private $money => class Ddd\Domain\Model\Money#174 (2) {
private $amount => string(3) "100"
private $currency => class Ddd\Domain\Model\Currency#175 (1){
private $isoCode => string(3) "USD"
}
}
}
* /
最后但同样重要的是,Doctrine DBAL 2 文档 指出:
对象类型是通过引用比较的,而不是通过值。当引用改变时,Doctrine 会更新这个值,因此表现得像这些对象是不可变值对象。
这种方法与临时性 ORM 所遇到的重构问题相同。对象映射类型内部使用 serialize/unserialize。那么,我们是否可以使用自己的序列化方法呢?
Doctrine 自定义类型
另一个选项是使用 Doctrine 自定义类型来处理值对象的持久化。自定义类型向 Doctrine 添加了一个新的映射类型——它描述了实体字段与数据库表示之间的自定义转换,以便持久化前者。
如 Doctrine DBAL 2 文档 所解释:
仅重新定义数据库类型如何映射到所有现有的 Doctrine 类型并不十分有用。你可以通过扩展 Doctrine\DBAL\Types\Type 来定义自己的 Doctrine 映射类型。你需要实现 4 个不同的方法才能使其工作。
使用对象类型,序列化步骤包括诸如类等信息,这使得安全重构我们的代码变得相当困难。
让我们尝试改进这个解决方案。考虑一个自定义序列化过程,它可以解决这个问题。
其中一种方法是将 Money 值对象以 amount|isoCode 格式编码为字符串持久化到数据库中:
use Ddd\Domain\Model\Currency;
use Ddd\Domain\Model\Money;
use Doctrine\DBAL\Types\TextType;
use Doctrine\DBAL\Platforms\AbstractPlatform;
class MoneyType extends TextType
{
const MONEY = 'money';
public function convertToPHPValue(
$value,
AbstractPlatform $platform
) {
$value = parent::convertToPHPValue($value, $platform);
$value = explode('|', $value);
return new Money(
$value[0],
new Currency($value[1])
);
}
public function convertToDatabaseValue(
$value,
AbstractPlatform $platform
) {
return implode(
'|',
[
$value->amount(),
$value->currency()->isoCode()
]
);
}
public function getName()
{
return self::MONEY;
}
}
使用 Doctrine,你需要注册所有自定义类型。通常使用一个 EntityManagerFactory 来集中创建这个 EntityManager。
或者,你也可以通过引导应用程序来执行这一步:
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools\Setup;
class EntityManagerFactory
{
public function build()
{
Type::addType(
'money',
'Ddd\Infrastructure\Persistence\Doctrine\Type\MoneyType'
);
return EntityManager::create(
[
'driver' => 'pdo_mysql',
'user' => 'root',
'password' => '',
'dbname' => 'ddd',
],
Setup::createXMLMetadataConfiguration(
[__DIR__.'/config'],
true
)
);
}
}
现在,我们需要在映射中指定我们想要使用我们的自定义类型:
<?xml version = "1.0" encoding = "utf-8"?>
<doctrine-mapping>
<entity
name = "Product"
table = "product">
<!-- ... -->
<field
name = "price"
type = "money"
/>
</entity>
</doctrine-mapping>
为什么使用 XML 映射?
多亏了 XML 映射文件头部的 XSD 架构验证,许多 集成开发环境(IDEs) 设置提供了对映射定义中所有元素和属性的自动完成功能。然而,在其他部分的书里,我们使用 YAML 来展示不同的语法。
让我们检查数据库,看看使用这种方法是如何持久化价格的:
mysql> select * from products \G
*************************** 1\. row***************************
id: 1
name: Domain-Driven Design in PHP
price: 999|USD
1 row in set (0.00 sec)
与之前的方法相比,这种方法在未来的重构方面有所改进。然而,由于列的格式,搜索能力仍然有限。使用 Doctrine 自定义类型,你可以稍微改善这种情况,但这仍然不是构建你的 DQL 查询的最佳选项。有关更多信息,请参阅 Doctrine 自定义映射类型。
讨论时间
思考并与同伴讨论如何使用 JMS 序列化 和 反序列化 值对象来创建 Doctrine 自定义类型。
持久化值对象集合
假设我们现在想向我们的 Product 实体添加一个要持久化的价格集合。这些价格可能代表产品在其生命周期中承担的不同价格,或者不同货币中的产品价格。这可以命名为 HistoricalPrice,如下所示:
class HistoricalProduct extends Product
{
/**
* @var Money[]
*/
protected $prices;
public function __construct(
$aProductId,
$aName,
Money $aPrice,
array $somePrices
){
parent::__construct($aProductId, $aName, $aPrice);
$this->setPrices($somePrices);
}
private function setPrices(array $somePrices)
{
$this->prices = $somePrices;
}
public function prices()
{
return $this->prices;
}
}
HistoricalProduct 从 Product 继承,因此继承了相同的行为,以及价格集合功能。
如前几节所述,如果你不关心查询能力,序列化是一个可行的方案。然而,如果我们知道要持久化的价格数量,嵌入式值是一个可能的选择。但如果我们想持久化一个不确定数量的历史价格会发生什么呢?
集合序列化到单个列
将值对象集合序列化到单个列中可能是最简单的解决方案。在关于持久化单个值对象的章节中解释的所有内容都适用于这种情况。使用 Doctrine,你可以使用对象或自定义类型——但需要考虑一些额外的因素:值对象应该体积小,但如果你想持久化大量集合,请确保考虑数据库引擎可以处理的最大列长度和最大行宽度。
练习
提出使用 Doctrine 对象类型和 Doctrine 自定义 类型实现策略,以不同价格持久化一个产品。
由连接表支持的集合
如果你想通过其相关值对象(Value Objects)持久化和查询实体(Entity),你可以选择将值对象作为实体持久化。在领域(Domain)方面,这些对象仍然是值对象,但我们需要给它们一个 id,并将它们与拥有者(owner)建立一个一对一/一对多(one-to-many/one-to-one)的关系,即一个真正的实体。总结来说,你的对象关系映射(ORM)将值对象集合作为实体处理,但在你的领域(Domain)中,它们仍然被视为值对象。
连接表策略背后的主要思想是创建一个连接所有者实体(owner Entity)及其值对象的表。让我们看看数据库表示:
CREATE TABLE ` historical_products` (
`id` char( 36) COLLATE utf8mb4_unicode_ci NOT NULL,
`name` varchar( 255) COLLATE utf8mb4_unicode_ci NOT NULL,
`price_amount` int( 11 ) NOT NULL,
`price_currency` char( 3) COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
historical_products表将与产品表看起来相同。记住,HistoricalProduct扩展了Product实体,以便更容易地展示如何处理持久化集合。现在需要一个新表来持久化Product实体可以处理的所有的不同Money值对象:
CREATE TABLE `prices`(
`id` int(11) NOT NULL AUTO_INCREMENT,
`amount` int(11) NOT NULL,
`currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
最后,需要一个关联产品和价格的表:
CREATE TABLE `products_prices` (
`product_id` char( 36) COLLATE utf8mb4_unicode_ci NOT NULL,
`price_id` int( 11 ) NOT NULL,
PRIMARY KEY (`product_id`, `price_id`),
UNIQUE KEY `UNIQ_62F8E673D614C7E7` (`price_id`),
KEY `IDX_62F8E6734584665A` (`product_id`),
CONSTRAINT `FK_62F8E6734584665A` FOREIGN KEY (`product_id`)
REFERENCES `historical_products` (`id`),
CONSTRAINT `FK_62F8E673D614C7E7` FOREIGN KEY (`price_id`)
REFERENCES `prices`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
由连接表支持的集合与 Doctrine
Doctrine 要求所有数据库实体都有一个唯一标识符。因为我们想持久化Money值对象,所以我们需要添加一个人工标识符,这样 Doctrine 才能处理其持久化。有两种选择:在Money值对象中包含代理标识符,或者将其放在扩展类中。
第一个选项的问题是新身份标识(new identity)仅由于数据库持久化层(Database persistence layer)的需要。这个身份标识不是领域的一部分。
第二个选项的问题是需要进行大量的修改以避免所谓的边界泄露(boundary leak)。由于创建Money值对象类的新实例会违反反转原则(Inversion Principle),因此不建议从任何领域对象(Domain Object)中创建Money值对象类的新实例。解决方案是再次创建一个Money工厂(Factory),该工厂需要传递给应用程序服务(Application Services)和任何其他领域对象。
在这个场景中,我们建议使用第一个选项。让我们回顾一下在Money值对象中实现它的所需更改:
class Money
{
private $amount;
private $currency;
private $surrogateId;
private $surrogateCurrencyIsoCode;
public function __construct($amount, Currency $currency)
{
$this->setAmount($amount);
$this->setCurrency($currency);
}
private function setAmount($amount)
{
$this->amount = $amount;
}
private function setCurrency(Currency $currency)
{
$this->currency = $currency;
$this->surrogateCurrencyIsoCode =
$currency->isoCode();
}
public function currency()
{
if (null === $this->currency) {
$this->currency = new Currency(
$this->surrogateCurrencyIsoCode
);
}
return $this->currency;
}
public function amount()
{
return $this->amount;
}
public function equals(Money $aMoney)
{
return
$this->amount() === $aMoney->amount() &&
$this->currency()->equals($this->currency());
}
}
如上所示,已添加了两个新属性。第一个属性,surrogateId,在我们的领域(Domain)中不被使用,但它对于持久化基础设施(persistence Infrastructure)将此值对象(Value Object)作为实体(Entity)保存在我们的数据库中是必需的。第二个属性,surrogateCurrencyIsoCode,持有货币的 ISO 代码。使用这些新属性,将我们的值对象与 Doctrine 映射起来变得非常容易。
Money映射相当直接:
<?xml version = "1.0" encoding = "utf-8"?>
<doctrine-mapping
xsi:schemaLocation="
http://doctrine-project.org/schemas/orm/doctrine-mapping
https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd">
<entity
name="Ddd\Domain\Model\Money"
table="prices">
<id
name="surrogateId"
type="integer"
column="id">
<generator
strategy="AUTO">
</generator>
</id>
<field
name="amount"
type="integer"
column="amount"
/>
<field
name="surrogateCurrencyIsoCode"
type="string"
column="currency"
/>
</entity>
</doctrine-mapping>
使用 Doctrine,HistoricalProduct实体将具有以下映射:
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping
xsi:schemaLocation="
http://doctrine-project.org/schemas/orm/doctrine-mapping
https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd">
<entity
name="Ddd\Domain\Model\HistoricalProduct"
table="historical_products"
repository-class="
Ddd\Infrastructure\Domain\Model\DoctrineHistoricalProductRepository
">
<many-to-many
field="prices"
target-entity="Ddd\Domain\Model\Money">
<cascade>
<cascade-all/>
</cascade>
<join-table
name="products_prices">
<join-columns>
<join-column
name="product_id"
referenced-column-name="id"
/>
</join-columns>
<inverse-join-columns>
<join-column
name="price_id"
referenced-column-name="id"
unique="true"
/>
</inverse-join-columns>
</join-table>
</many-to-many>
</entity>
</doctrine-mapping>
由连接表支持的集合与临时对象关系映射(ORM)
使用临时对象关系映射(Ad hoc ORM),其中需要级联INSERTS和JOIN查询,也可以做到同样的事情。重要的是要小心处理值对象的移除,以避免留下孤立的Money值对象。
练习
为DbalHistoricalRepository想出一个处理持久化方法(persist method)的解决方案。
由数据库实体支持的集合
数据库实体与连接表是相同的解决方案,增加了仅由所有者实体管理的值对象。在当前场景中,考虑到Money值对象仅由HistoricalProduct实体使用;连接表将过于复杂。因此,可以使用一对一的数据库关系达到相同的结果。
练习
如果使用数据库实体方法,请考虑HistoricalProduct和Money之间的映射需求。
NoSQL
那么,关于像Redis、MongoDB或CouchDB这样的 NoSQL 机制呢?不幸的是,你无法避开这些问题。为了使用Redis持久化聚合,你需要在设置值之前将其序列化为字符串。如果你使用 PHP 的serialize/unserialize方法,你将再次面临命名空间或类名重构问题。如果你选择以自定义方式(JSON、自定义字符串等)进行序列化,你将需要在Redis检索期间再次重建值对象。
PostgreSQL JSONB 和 MySQL JSON 类型
如果我们的数据库引擎不仅允许我们使用序列化 LOB 策略,而且还可以根据其值进行搜索,我们将拥有两种方法的最佳之处。好消息是:现在你可以这样做。从PostgreSQL 版本 9.4开始,已添加了对JSONB的支持。值对象可以作为 JSON 序列化持久化,并在该 JSON 序列化中进行查询。
MySQL 也做了同样的事情。从MySQL 5.7.8开始,MySQL 支持一种原生 JSON 数据类型,它允许高效访问JSON(JavaScript 对象表示法)文档中的数据。根据MySQL 5.7 参考手册,JSON 数据类型与在字符串列中存储 JSON 格式字符串相比具有以下优势:
-
自动验证存储在 JSON 列中的 JSON 文档。无效的文档将产生错误。
-
优化存储格式。存储在 JSON 列中的 JSON 文档被转换为一种内部格式,允许快速读取文档元素。当服务器稍后必须读取以这种二进制格式存储的 JSON 值时,无需从文本表示中解析该值。这种二进制格式被结构化,以便服务器可以直接通过键或数组索引查找子对象或嵌套值,而无需读取文档中它们之前或之后的所有值。
如果关系数据库添加了对具有高性能和所有原子性、一致性、隔离性、持久性(ACID)哲学优势的文档和嵌套文档搜索的支持,它可以在许多项目中节省大量的复杂性。
安全性
使用值对象来建模你的领域概念的一个有趣细节是其安全优势。考虑一个在销售机票的上下文中的应用。如果你处理的是国际航空运输协会机场代码,也称为IATA 代码,你可以选择使用字符串或使用值对象来建模这个概念。如果你选择使用字符串,想想你会在哪些地方检查这个字符串是否是有效的 IATA 代码。你忘记在某个重要地方的概率有多大?另一方面,想想尝试实例化新的IATA("BCN'; DROP TABLE users;--")。如果你在构造函数中集中了守卫,然后将 IATA 值对象传递到你的模型中,避免 SQL 注入或类似的攻击就会变得更容易。
如果你想了解更多关于领域驱动设计安全方面的信息,你可以关注Dan Bergh Johnsson或阅读他的博客。
总结
在你的领域中用值对象建模那些测量、量化或描述的概念是非常推荐的。正如所示,值对象易于创建、维护和测试。为了在领域驱动设计应用中处理持久化,使用 ORM 是必须的。然而,为了使用 Doctrine 持久化值对象,首选的方式是使用内嵌对象。如果你卡在版本 2.4,有两种选择:直接将值对象字段添加到你的实体中并映射它们(不那么优雅,但更容易),或者扩展你的实体(更加优雅,但更复杂)。
第四章:实体
我们已经讨论了首先将域中的所有内容建模为值对象的益处。但在对域进行建模时,可能会遇到一些情况,你会发现普遍语言中的某些概念需要一条身份线索。
引言
需要身份的对象的清晰例子包括:
-
一个人。一个人总是有一个身份,并且他们的名字或身份证在时间上始终相同。
-
在电子商务系统中,一个订单。在这种情况下,每个新创建的订单都有自己的身份,并且随着时间的推移保持不变。
这些概念具有随时间持久存在的身份。无论概念中的数据如何变化,它们的身份保持不变。这就是它们是实体而不是值对象的原因。在 PHP 实现方面,它们将是普通的旧类。例如,考虑以下关于人的情况:
namespace Ddd\Identity\Domain\Model;
class Person
{
private $identificationNumber;
private $firstName;
private $lastName;
public function __construct(
$anIdentificationNumber, $aFirstName, $aLastName
) {
$this->identificationNumber = $anIdentificationNumber;
$this->firstName = $aFirstName;
$this->lastName = $aLastName;
}
public function identificationNumber()
{
return $this->identificationNumber;
}
public function firstName()
{
return $this->firstName;
}
public function lastName()
{
return $this->lastName;
}
}
或者,考虑以下关于订单的情况:
namespace Ddd\Billing\Domain\Model\Order;
class Order
{
private $id;
private $amount;
private $firstName;
private $lastName;
public function __construct(
$anId, Amount $amount, $aFirstName, $aLastName
) {
$this->id = $anId;
$this->amount = $amount;
$this->firstName = $aFirstName;
$this->lastName = $aLastName;
}
public function id()
{
return $this->id;
}
public function firstName()
{
return $this->firstName;
}
public function lastName()
{
return $this->lastName;
}
}
对象与原始类型
大多数情况下,实体的身份以原始类型表示——通常是字符串或整数。但使用值对象来表示它有更多优势:
-
值对象是不可变的,因此不能被修改。
-
值对象是具有自定义行为的复杂类型,这是原始类型所不具备的。以等价操作为例。使用值对象,等价操作可以建模并封装在自己的类中,使概念从隐式变为显式。
让我们看看OrderId、Order身份(它已演变为值对象)的一个可能的实现:
namespace Ddd\Billing\Domain\Model;
class OrderId
{
private $id;
public function __construct($anId)
{
$this->id = $anId;
}
public function id()
{
return $this->id;
}
public function equalsTo(OrderId $anOrderId)
{
return $anOrderId->id === $this->id;
}
}
对于实现OrderId,你可以考虑不同的实现方式。上面显示的例子相当简单。正如第三章中解释的,值对象,你可以将__constructor方法设为私有,并使用静态工厂方法来创建新实例。与你的团队讨论,进行实验,并达成一致。因为实体身份并不复杂,我们的建议是,你在这里不必过于担心。
回到Order,是时候更新对OrderId的引用了:
class Order
{
private $id;
private $amount;
private $firstName;
private $lastName;
public function __construct(
OrderId $anOrderId, Amount $amount, $aFirstName, $aLastName
) {
$this->id = $anOrderId;
$this->amount = $amount;
$this->firstName = $aFirstName;
$this->lastName = $aLastName;
}
public function id()
{
return $this->id;
}
public function firstName()
{
return $this->firstName;
}
public function lastName()
{
return $this->lastName;
}
public function amount()
{
return $this->amount;
}
}
我们的实体使用值对象建模了身份。让我们考虑创建OrderId的不同方法。
身份操作
如前所述,实体的身份定义了它。因此,处理它就是实体的重要方面之一。通常有四种方式来定义实体的身份:持久化机制提供身份,客户端提供身份,应用程序本身提供身份,或者另一个边界上下文提供身份。
持久化机制生成身份
通常,生成标识符的最简单方法是将它委托给持久化机制,因为绝大多数持久化机制都支持某种类型的标识符生成——比如 MySQL 的AUTO_INCREMENT属性或 Postgres 和 Oracle 序列。虽然这很简单,但有一个主要的缺点:我们只有在持久化实体之后才能获得实体的标识符。因此,在某种程度上,如果我们采用由持久化机制生成的标识符,我们将把标识符操作与底层持久化存储耦合起来:
CREATE TABLE `orders` (
`id` int(11) NOT NULL auto_increment,
`amount` decimal (10,5) NOT NULL,
`first_name` varchar(100) NOT NULL,
`last_name` varchar(100) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
然后我们可能会考虑以下代码:
namespace Ddd\Identity\Domain\Model;
class Person
{
private $identificationNumber;
private $firstName;
private $lastName;
public function __construct(
$anIdentificationNumber, $aFirstName, $aLastName
) {
$this->identificationNumber = $anIdentificationNumber;
$this->firstName = $aFirstName;
$this->lastName = $aLastName;
}
public function identificationNumber()
{
return $this->identificationNumber;
}
public function firstName()
{
return $this->firstName;
}
public function lastName()
{
return $this->lastName;
}
}
如果你曾经尝试构建自己的 ORM,你肯定已经经历过这种情况。创建一个新的 Person 对象的方法是什么?如果数据库将要生成标识符,我们是否需要在构造函数中传递它?何时何地会有魔法更新 Person 对象的标识符?如果我们最终没有持久化实体,会发生什么?
代理标识符
有时在使用 ORM 将实体映射到持久化存储时,会施加一些约束——例如,如果使用IDENTITY生成策略,Doctrine 要求一个整数字段。这可能会与需要另一种类型标识符的领域模型冲突。
处理这种情况的最简单方法是通过使用层超类型,将用于持久化存储创建的标识符字段放置在其中:
namespace Ddd\Common\Domain\Model;
abstract class IdentifiableDomainObject
{
private $id;
protected function id()
{
return $this->id;
}
protected function setId($anId)
{
$this->id = $anId;
}
}
namespace Acme\Billing\Domain;
use Acme\Common\Domain\IdentifiableDomainObject;
class Order extends IdentifiableDomainObject
{
private $orderId;
public function orderId()
{
if (null === $this->orderId) {
$this->orderId = new OrderId($this->id());
}
return $this->orderId;
}
}
Active Record 与富领域模型的数据映射器
每个项目都会面临选择使用哪种 ORM 的决定。PHP 有很多好的 ORM:Doctrine、Propel、Eloquent、Paris 等等。
其中大多数是Active Record实现。Active Record 实现对于 CRUD 应用程序来说很好,但它并不是富领域模型的理想解决方案,以下是一些原因:
-
Active Record 模式假设实体与数据库表之间存在一对一的关系。因此,它将数据库的设计与对象系统的设计耦合在一起。在一个富领域模型中,有时实体是使用可能来自不同数据源的信息构建的。
-
集合和继承等高级功能实现起来很棘手。
-
大多数实现都强制通过继承使用某种类型的构造,这会强加几个约定。这可能导致通过将领域模型与 ORM 耦合,将持久性泄漏到领域模型中。我们看到的唯一不强制从基类继承的 Active Record 实现是来自Castle Project的Castle ActiveRecord,这是一个.NET 框架。虽然这导致在生成的实体中持久性和领域关注点之间有一定的分离,但它并没有将低级持久性细节与高级领域设计解耦。
如前一章所述,目前 PHP 最好的 ORM 是 Doctrine,它实现了 数据映射模式。数据映射将持久性关注点与域关注点解耦,导致持久性无实体。这使得该工具成为想要构建丰富域模型的人的最佳选择。
客户端提供身份
有时,在处理某些域时,身份会自然出现,客户端消费域模型。这可能是理想的情况,因为身份可以轻松建模。让我们看看图书销售市场:
namespace Ddd\Catalog\Domain\Model\Book;
class ISBN
{
private $isbn;
private function __construct($anIsbn)
{
$this->setIsbn($anIsbn);
}
private function setIsbn($anIsbn)
{
$this->assertIsbnIsValid($anIsbn, 'The ISBN is invalid.');
$this->isbn = $anIsbn;
}
public static function create($anIsbn)
{
return new static($anIsbn);
}
private function assertIsbnIsValid($anIsbn, $string)
{
// ... Validates an ISBN code
}
}
根据 维基百科:国际标准书号(ISBN)是一个独特的商业书号。ISBN 被分配给每本书的每个版本和变体(除了再版)。例如,同一本书的电子书、平装版和精装版将各自有不同的 ISBN。如果是在 2007 年 1 月 1 日或之后分配的,ISBN 将有 13 位数字,如果是在 2007 年之前分配的,ISBN 将有 10 位数字。分配 ISBN 的方法基于国家,并且各国之间有所不同,通常取决于一个国家内出版业的规模。
ISBN 的好处在于它已经在域中定义了,它是一个有效的标识符,因为它具有唯一性,并且可以轻松验证。这是一个客户端提供的身份的很好例子:
class Book
{
private $isbn;
private $title;
public function __construct(ISBN $anIsbn, $aTitle)
{
$this->isbn = $anIsbn;
$this->title = $aTitle;
}
}
现在,关键在于使用它:
$book = new Book(
ISBN::create('...'),
'Domain-Driven Design in PHP'
);
练习
考虑其他在域中构建身份的域,并对其进行建模。
应用程序生成身份
如果客户端不能一般性地提供身份,处理身份操作的首选方式是让应用程序生成身份,通常是通过 UUID。如果你没有前述章节中所示的场景,这是我们推荐的方法。
根据 维基百科:
UUID 的目的是使分布式系统能够在不进行重大中央协调的情况下唯一标识信息。在此上下文中,"唯一"一词应理解为"实际上唯一"而不是"保证唯一"。由于标识符具有有限的大小,两个不同的项目可能共享相同的标识符。这是一种哈希冲突的形式。标识符的大小和生成过程需要选择,以便在实际中使这种情况尽可能不可能。任何人都可以创建一个 UUID 并用它来标识某物,有合理的信心认为相同的标识符不会无意中被其他人用来标识其他事物。因此,带有 UUID 标记的信息可以在以后合并到一个数据库中,而无需解决标识符(ID)冲突。
PHP 中有几个库可以生成 UUID,它们可以在 Packagist 上找到:packagist.org/search/?q=uuid。最佳推荐是 Ben Ramsey 在以下链接中开发的版本:github.com/ramsey/uuid,因为它在 GitHub 上有大量的关注者,在 Packagist 上有数百万的安装量。
创建标识的最佳位置是在仓库中(我们将在第十章 Chapter 10,仓库中进一步探讨这个问题):
namespace Ddd\Billing\Domain\Model\Order;
interface OrderRepository
{
public function nextIdentity();
public function add(Order $anOrder);
public function remove(Order $anOrder);
}
在使用 Doctrine 时,我们需要创建一个实现该接口的自定义仓库。它基本上会创建新的标识并使用EntityManager来持久化和删除实体。一个小变化是将nextIdentity实现放入将成为抽象类的接口中:
namespace Ddd\Billing\Infrastructure\Domain\Model\Order;
use Ddd\Billing\Domain\Model\Order\Order;
use Ddd\Billing\Domain\Model\Order\OrderId;
use Ddd\Billing\Domain\Model\Order\OrderRepository;
use Doctrine\ORM\EntityRepository;
class DoctrineOrderRepository
extends EntityRepository
implements OrderRepository
{
public function nextIdentity()
{
return OrderId::create();
}
public function add(Order $anOrder)
{
$this->getEntityManager()->persist($anOrder);
}
public function remove(Order $anOrder)
{
$this->getEntityManager()->remove($anOrder);
}
}
让我们快速回顾一下OrderId值对象的最终实现:
namespace Ddd\Billing\Domain\Model\Order;
use Ramsey\Uuid\Uuid;
class OrderId
{
private $id;
private function __construct($anId = null)
{
$this->id = $id ? :Uuid::uuid4()->toString();
}
public static function create($anId = null )
{
return new static($anId);
}
}
关于这种方法的主要担忧,正如你将在以下章节中看到的,是持久化包含值对象的实体有多容易。然而,根据 ORM 的不同,映射实体内部的嵌入式值对象可能会很棘手。
其他边界上下文生成标识
这可能是最复杂的标识生成策略,因为它迫使本地实体不仅依赖于本地边界上下文事件,还依赖于外部边界上下文事件。因此,在维护方面,成本会很高。
另一个边界上下文提供了一个接口,用于从本地实体中选择标识。它可以采用一些公开的属性作为自己的属性。
当需要在边界上下文的实体之间进行同步时,通常可以通过在每个需要通知原始实体发生更改的边界上下文中使用事件驱动架构来实现。
持久化实体
目前,正如本章前面所讨论的,将实体状态保存到持久存储的最佳工具是 Doctrine ORM。Doctrine 有几种指定实体元数据的方式:通过实体代码中的注解、通过 XML、通过 YAML 或通过纯 PHP。在本章中,我们将深入讨论为什么在映射实体时使用注解不是最佳选择。
设置 Doctrine
首先,我们需要通过 Composer 要求 Doctrine。在项目的根目录中,必须执行以下命令:
> php composer.phar require "doctrine/orm=².5"
然后,这些行将允许你设置 Doctrine:
require_once '/path/to/vendor/autoload.php';
use Doctrine\ORM\Tools\Setup;
use Doctrine\ORM\EntityManager;
$paths = ['/path/to/entity-files'];
$isDevMode = false;
// the connection configuration
$dbParams = [
'driver' => 'pdo_mysql',
'user' => 'the_database_username',
'password' => 'the_database_password',
'dbname' => 'the_database_name',
];
$config = Setup::createAnnotationMetadataConfiguration($paths, $isDevMode);
$entityManager = EntityManager::create($dbParams, $config);
实体的映射
默认情况下,Doctrine 的文档使用注解来展示代码示例。因此,我们开始使用注解编写代码示例,并讨论为什么在可能的情况下应避免使用注解。
为了做到这一点,我们将回顾本章前面讨论过的Order类。
使用注解代码映射实体
当 Doctrine 发布时,通过在代码示例中使用注解来展示如何映射对象是一种吸引人的方式。
什么是注解?
注释是一种特殊的元数据形式。在 PHP 中,它位于源代码注释下。例如,PHPDocumentor 使用注释来构建 API 信息,而 PHPUnit 使用一些注释来指定数据提供者或提供关于代码抛出异常的期望:
class SumTest extends PHPUnit_Framework_TestCase {
/** @dataProvider aMethodName */
public function testAddition() {
//...
}
}
为了将 Order 实体映射到持久化存储,应该修改 Order 的源代码以添加 Doctrine 注释:
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Column;
/** @Entity */
class Order
{
/** @Id @GeneratedValue(strategy="AUTO") */
private $id;
/** @Column(type="decimal", precision="10", scale="5") */
private $amount;
/** @Column(type="string") */
private $firstName;
/** @Column(type="string") */
private $lastName;
public function __construct(
Amount $anAmount,
$aFirstName,
$aLastName
) {
$this->amount = $anAmount;
$this->firstName = $aFirstName;
$this->lastName = $aLastName;
}
public function id()
{
return $this->id;
}
public function firstName()
{
return $this->firstName;
}
public function lastName()
{
return $this->lastName;
}
public function amount()
{
return $this->amount;
}
}
然后,要将实体持久化到持久化存储,只需执行以下操作即可:
$order = new Order(
new Amount(15, Currency::EUR()),
'AFirstName',
'ALastName'
);
$entityManager->persist($order);
$entityManager->flush();
初看,这段代码看起来很简单,这可以是一个指定映射信息的简单方法。但它是有代价的。最终代码有什么奇怪的地方?
首先,领域关注点与基础设施关注点混合在一起。订单是一个领域概念,而表、列等则是基础设施关注点。
因此,这个实体与源代码中指定的注释映射信息紧密耦合。如果实体需要使用另一个实体管理器和不同的映射元数据来持久化,这将不可能实现。
注释往往会引起副作用和紧密耦合,因此最好不用它们。
那么指定映射信息最好的方法是什么?最好的方法是允许你将映射信息与实体本身分离。这可以通过使用 XML 映射、YAML 映射或 PHP 映射来实现。在这本书中,我们将介绍 XML 映射。
使用 XML 映射映射实体
要使用 XML 映射映射 Order 实体,需要稍微修改 Doctrine 的设置代码:
require_once '/path/to/vendor/autoload.php';
use Doctrine\ORM\Tools\Setup;
use Doctrine\ORM\EntityManager;
$paths = ['/path/to/mapping-files'];
$isDevMode = false;
// the connection configuration
$dbParams = [
'driver' => 'pdo_mysql',
'user' => 'the_database_username',
'password' => 'the_database_password',
'dbname' => 'the_database_name',
];
$config = Setup::createXMLMetadataConfiguration($paths, $isDevMode);
$entityManager = EntityManager::create($dbParams, $config);
映射文件应该创建在 Doctrine 将要搜索映射文件的路劲上,并且映射文件应该以完全限定的类名命名,将反斜杠 \ 替换为点。考虑以下示例:
Acme\Billing\Domain\Model\Order
上述示例中的映射文件将命名为如下:
Acme.Billing.Domain.Model.Order.dcm.xml
此外,所有映射文件都使用一个专门为指定映射信息而创建的特殊 XML 架构,这很方便:
https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd
映射实体标识符
我们的标识符,OrderId,是一个值对象。正如前一章所看到的,使用 Doctrine、嵌入对象和自定义类型映射值对象有不同的方法。当值对象用作标识符时,最佳选项是自定义类型。
Doctrine 2.5 中一个有趣的新特性是,现在可以使用对象作为实体的标识符,只要它们实现了 __toString() 魔法方法。因此,我们可以将 __toString 添加到我们的标识符值对象中,并在映射中使用它们:
namespace Ddd\Billing\Domain\Model\Order;
use Ramsey\Uuid\Uuid;
class OrderId
{
// ...
public function __toString()
{
return $this->id;
}
}
检查 Doctrine 自定义类型的实现。它们继承自GuidType,因此它们的内部表示将是 UUID。我们需要指定数据库的本地转换。然后在我们使用它们之前,我们需要注册我们的自定义类型。如果你需要这些步骤的帮助,自定义映射类型是一个很好的参考。
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\GuidType;
class DoctrineOrderId extends GuidType
{
public function getName()
{
return 'OrderId';
}
public function convertToDatabaseValue(
$value, AbstractPlatform $platform
) {
return $value->id();
}
public function convertToPHPValue(
$value, AbstractPlatform $platform
) {
return new OrderId($value);
}
}
最后,我们将设置自定义类型的注册。同样,我们必须更新我们的引导过程:
require_once '/path/to/vendor/autoload.php';
// ...
\Doctrine\DBAL\Types\Type::addType(
'OrderId',
'Ddd\Billing\Infrastructure\Domain\Model\DoctrineOrderId'
);
$config = Setup::createXMLMetadataConfiguration($paths, $isDevMode);
$entityManager = EntityManager::create($dbParams, $config);
最终映射文件
经过所有这些变化,我们终于准备就绪,现在让我们看一下最终的映射文件。最有趣的细节是检查OrderId的 ID 是如何与我们的自定义类型进行映射的:
<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping
xsi:schemaLocation="
http://doctrine-project.org/schemas/orm/doctrine-mapping
https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd">
<entity
name="Ddd\Billing\Domain\Model\Order"
table="orders">
<id name="id" column="id" type="OrderId" />
<field
name="amount"
type="decimal"
nullable="false"
scale="10"
precision="5"
/>
<field
name="firstName"
type="string"
nullable="false"
/>
<field
name="lastName"
type="string"
nullable="false"
/>
</entity>
</doctrine-mapping>
测试实体
测试实体相对简单,因为它们是具有从它们所代表的领域概念派生出的操作的普通 PHP 类。测试的重点应该是实体所保护的那些不变性,因为实体的行为很可能会围绕这些不变性进行建模。
例如,为了简化,假设需要一个博客的领域模型。一个可能的模型可以是这个:
class Post
{
private $title;
private $content;
private $status;
private $createdAt;
private $publishedAt;
public function __construct($aContent, $title)
{
$this->setContent($aContent);
$this->setTitle($title);
$this->unpublish();
$this->createdAt(new DateTimeImmutable());
}
private function setContent($aContent)
{
$this->assertNotEmpty($aContent);
$this->content = $aContent;
}
private function setTitle($aPostTitle)
{
$this->assertNotEmpty($aPostTitle);
$this->title = $aPostTitle;
}
private function setStatus(Status $aPostStatus)
{
$this->assertIsAValidPostStatus($aPostStatus);
$this->status = $aPostStatus;
}
private function createdAt(DateTimeImmutable $aDate)
{
$this->assertIsAValidDate($aDate);
$this->createdAt = $aDate;
}
private function publishedAt(DateTimeImmutable $aDate)
{
$this->assertIsAValidDate($aDate);
$this->publishedAt = $aDate;
}
public function publish()
{
$this->setStatus(Status::published());
$this->publishedAt(new DateTimeImmutable());
}
public function unpublish()
{
$this->setStatus(Status::draft());
$this->publishedAt = null ;
}
public function isPublished()
{
return $this->status->equalsTo(Status::published());
}
public function publicationDate()
{
return $this->publishedAt;
}
}
class Status
{
const PUBLISHED = 10;
const DRAFT = 20;
private $status;
public static function published()
{
return new self(self::PUBLISHED);
}
public static function draft()
{
return new self(self::DRAFT);
}
private function __construct($aStatus)
{
$this->status = $aStatus;
}
public function equalsTo(self $aStatus)
{
return $this->status === $aStatus->status;
}
}
为了测试这个领域模型,我们必须确保测试覆盖了所有的Post不变性:
class PostTest extends PHPUnit_Framework_TestCase
{
/** @test */
public function aNewPostIsNotPublishedByDefault()
{
$aPost = new Post(
'A Post Content',
'A Post Title'
);
$this->assertFalse(
$aPost->isPublished()
);
$this->assertNull(
$aPost->publicationDate()
);
}
/** @test */
public function aPostCanBePublishedWithAPublicationDate()
{
$aPost = new Post(
'A Post Content',
'A Post Title'
);
$aPost->publish();
$this->assertTrue(
$aPost->isPublished()
);
$this->assertInstanceOf(
'DateTimeImmutable',
$aPost->publicationDate()
);
}
}
日期时间
由于DateTimes在实体中广泛使用,我们认为指出针对具有日期类型字段的实体进行单元测试的具体方法很重要。考虑一下,如果一篇Post是在过去 15 天内创建的,那么它就是新的:
class Post
{
const NEW_TIME_INTERVAL_DAYS = 15;
// ...
private $createdAt;
public function __construct($aContent, $title)
{
// ...
$this->createdAt(new DateTimeImmutable());
}
// ...
public function isNew()
{
return
(new DateTimeImmutable())
->diff($this->createdAt)
->days <= self::NEW_TIME_INTERVAL_DAYS;
}
}
isNew()方法需要比较两个DateTimes;这是创建Post时的日期与今天的日期之间的比较。我们计算差异并检查它是否小于指定的天数。我们如何单元测试isNew()方法?正如我们在实现中展示的那样,在测试套件中重现特定的流程是困难的。我们有什么选择?
将所有日期作为参数传递
一个可能的选择是在需要时将所有日期作为参数传递:
class Post
{
// ...
public function __construct($aContent, $title, $createdAt = null)
{
// ...
$this->createdAt($createdAt ?: new DateTimeImmutable());
}
// ...
public function isNew($today = null)
{
return
($today ? :new DateTimeImmutable())
->diff($this->createdAt)
->days <= self::NEW_TIME_INTERVAL_DAYS;
}
}
这对于单元测试来说是最简单的方法。只需传递不同的日期对来测试所有可能的场景,实现 100%的覆盖率。然而,如果你考虑创建和请求isNew()方法结果的客户端代码,事情看起来就不那么好了。由于总是传递今天的DateTime,生成的代码可能有点奇怪:
$aPost = new Post(
'Hello world!',
'Hi',
new DateTimeImmutable()
);
$aPost->isNew(
new DateTimeImmutable()
);
测试类
另一个替代方案是使用测试类模式。这个想法是扩展Post类,创建一个新的类,我们可以操作它来强制特定的场景。这个新类将仅用于单元测试目的。坏消息是我们必须稍微修改原始的Post类,提取一些方法,并将一些字段和方法从private改为protected。一些开发者可能担心仅仅因为测试原因而增加类属性的可见性。然而,我们认为在大多数情况下,这是值得的:
class Post
{
protected $createdAt;
public function isNew()
{
return
($this->today())
->diff($this->createdAt)
->days <= self::NEW_TIME_INTERVAL_DAYS;
}
protected function today()
{
return new DateTimeImmutable();
}
protected function createdAt(DateTimeImmutable $aDate)
{
$this->assertIsAValidDate($aDate);
$this->createdAt = $aDate;
}
}
如您所见,我们已经将获取今天日期的逻辑提取到today()方法中。这样,通过应用模板方法模式,我们可以从派生类中改变其行为。类似的情况也发生在createdAt方法和字段上。现在它们是受保护的,因此可以在派生类中使用和重写:
class PostTestClass extends Post
{
private $today;
protected function today()
{
return $this->today;
}
public function setToday($today)
{
$this->today = $today;
}
}
通过这些更改,我们现在可以通过测试PostTestClass来测试我们的原始Post类:
class PostTest extends PHPUnit_Framework_TestCase
{
// ...
/** @test */
public function aPostIsNewIfIts15DaysOrLess()
{
$aPost = new PostTestClass(
'A Post Content' ,
'A Post Title'
);
$format = 'Y-m-d';
$dateString = '2016-01-01';
$createdAt = DateTimeImmutable::createFromFormat(
$format,
$dateString
);
$aPost->createdAt($createdAt);
$aPost->setToday(
$createdAt->add(
new DateInterval('P15D')
)
);
$this->assertTrue(
$aPost->isNew()
);
$aPost->setToday(
$createdAt->add(
new DateInterval('P16D')
)
);
$this->assertFalse(
$aPost->isNew()
);
}
}
最后一个小细节:使用这种方法,我们无法在Post类上实现 100%的覆盖率,因为today()方法永远不会被执行。然而,它可以通过其他测试来覆盖。
外部模拟
另一个选项是使用新类和一些静态方法包装对DateTimeImmutable构造函数或命名构造函数的调用。这样做,我们可以静态地改变这些方法的结果,使其根据特定的测试场景表现出不同的行为:
class Post
{
// ...
private $createdAt;
public function __construct($aContent, $title)
{
// ...
$this->createdAt(MyCustomDateTimeBuilder::today());
}
// ...
public function isNew()
{
return
(MyCustomDateTimeBuilder::today())
->diff($this->createdAt)
->days <= self::NEW_TIME_INTERVAL_DAYS;
}
}
为了获取今天的DateTime,我们现在使用对MyCustomDateTimeBuilder::today()的静态调用。这个类还有一些设置方法,可以在后续调用中模拟返回结果:
class PostTest extends PHPUnit_Framework_TestCase
{
// ...
/** @test */
public function aPostIsNewIfIts15DaysOrLess()
{
$createdAt = DateTimeImmutable::createFromFormat(
'Y-m-d',
'2016-01-01'
);
MyCustomDateTimeBuilder::setReturnDates(
[
$createdAt,
$createdAt->add(
new DateInterval('P15D')
),
$createdAt->add(
new DateInterval('P16D')
)
]
);
$aPost = new Post(
'A Post Content' ,
'A Post Title'
);
$this->assertTrue(
$aPost->isNew()
);
$this->assertFalse(
$aPost->isNew()
);
}
}
这种方法的主要问题是与对象静态耦合。根据您的使用情况,创建一个灵活的模拟对象也会变得很棘手。
反射
您还可以使用反射技术来构建一个新的Post类,并自定义日期。考虑使用Mimic,这是一个简单的用于对象原型设计、数据注入和数据展示的功能性库:
namespace Domain;
use mimic as m;
class ComputerScientist {
private $name;
private $surname;
public function __construct($name, $surname)
{
$this->name = $name;
$this->surname = $surname;
}
public function rocks()
{
return $this->name . ' ' . $this->surname . ' rocks!';
}
}
assert(m\prototype('Domain\ComputerScientist')
instanceof Domain\ComputerScientist);
m\hydrate('Domain\ComputerScientist', [
'name' =>'John' ,
'surname'=>'McCarthy'
])->rocks(); //John McCarthy rocks!
assert(m\expose(
new Domain\ComputerScientist('Grace', 'Hopper')) ==
[
'name' => 'Grace' ,
'surname' => 'Hopper'
]
)
分享和讨论
与您的同事讨论如何正确地对具有固定DateTimes的实体进行单元测试,并提出额外的替代方案。
如果您想了解更多关于测试模式和方法的资料,请查看 Gerard Meszaros 所著的书籍《xUnit Test Patterns: Refactoring Test Code》。
验证
验证是我们领域模型中一个非常重要的过程。它不仅检查属性的正确性,还检查整个对象及其组成的正确性。为了保持模型的有效状态,需要不同级别的验证。仅仅因为一个对象由有效的属性组成(基于每个属性),并不意味着该对象(作为一个整体)是有效的。反之亦然:有效的对象不一定等于有效的组合。
属性验证
有些人将验证理解为服务验证给定对象状态的过程。在这种情况下,验证符合设计-by-合同方法,该方法包括前置条件、后置条件和不变性。保护单个属性的一种方式是使用第三章,值对象。为了使我们的设计更灵活,我们只关注断言必须满足的领域前置条件。在这里,我们将使用守卫作为验证前置条件的一种简单方式:
class Username
{
const MIN_LENGTH = 5;
const MAX_LENGTH = 10;
const FORMAT = '/^[a-zA-Z0-9_]+$/';
private $username;
public function __construct($username)
{
$this->setUsername($username);
}
private setUsername($username)
{
$this->assertNotEmpty($username);
$this->assertNotTooShort($username);
$this->assertNotTooLong($username);
$this->assertValidFormat($username);
$this->username = $username;
}
private function assertNotEmpty($username)
{
if (empty($username)) {
throw new InvalidArgumentException('Empty username');
}
}
private function assertNotTooShort($username)
{
if (strlen($username) < self::MIN_LENGTH) {
throw new InvalidArgumentException(sprintf(
'Username must be %d characters or more',
self::MIN_LENGTH
));
}
}
private function assertNotTooLong($username)
{
if (strlen( $username) > self::MAX_LENGTH) {
throw new InvalidArgumentException(sprintf(
'Username must be %d characters or less',
self::MAX_LENGTH
));
}
}
private function assertValidFormat($username)
{
if (preg_match(self:: FORMAT, $username) !== 1) {
throw new InvalidArgumentException(
'Invalid username format'
);
}
}
}
如上例所示,为了构建一个用户名值对象,必须满足四个前提条件。它:
-
不能为空
-
必须至少 5 个字符
-
必须少于 10 个字符
-
必须遵循字母数字字符或下划线的格式
如果所有前提条件都满足,属性将被设置,对象将被成功构建。否则,将抛出InvalidArgumentException异常,执行将被终止,并且客户端将显示错误。
一些开发者可能会认为这种验证是防御性编程。然而,我们并没有检查输入是否为字符串或 nulls 是否不被允许。我们无法避免人们错误地使用我们的代码,但我们可以控制我们的领域状态的正确性。如第三章中的值对象所示,验证还可以帮助我们提高安全性。
防御性编程并不是一件坏事。一般来说,当开发将要作为第三方在其他项目中使用的组件或库时,这样做是有意义的。然而,当开发自己的边界上下文时,那些额外的偏执检查(nulls、基本类型、类型提示等)可以通过依赖单元测试套件的覆盖率来避免,从而提高开发速度。
整个对象验证
有时候,一个由有效属性组成的对象整体仍然可能被认为是无效的。可能会诱使人们将这种验证添加到对象本身,但通常这并不是一个好的做法。高级别的验证变化节奏与对象逻辑本身不同。此外,将责任分离是良好的实践。
验证通知客户端任何已发现的错误,或者收集结果以供稍后审查,因为有时我们不想在出现问题的第一个迹象时就停止执行。
一个抽象且可重用的Validator可能如下所示:
abstract class Validator
{
private $validationHandler;
public function __construct(ValidationHandler $validationHandler)
{
$this->validationHandler = $validationHandler;
}
protected function handleError($error)
{
$this->validationHandler->handleError($error);
}
abstract public function validate();
}
具体来说,我们想要验证一个由有效的国家、城市和邮编值对象组成的整个Location对象。然而,这些个别值在验证时可能处于无效状态。也许城市不是国家的一部分,或者邮编可能不符合城市格式:
class Location
{
private $country;
private $city;
private $postcode;
public function __construct(
Country $country, City $city, Postcode $postcode
) {
$this->country = $country;
$this->city = $city;
$this->postcode = $postcode;
}
public function country()
{
return $this->country;
}
public function city()
{
return $this->city;
}
public function postcode()
{
return $this->postcode;
}
}
验证器检查Location对象的整体状态,分析属性之间关系的意义:
class LocationValidator extends Validator
{
private $location;
public function __construct(
Location $location, ValidationHandler $validationHandler
) {
parent:: __construct($validationHandler);
$this->location = $location;
}
public function validate()
{
if (!$this->location->country()->hasCity(
$this->location->city()
)) {
$this->handleError('City not found');
}
if (!$this->location->city()->isPostcodeValid(
$this->location->postcode()
)) {
$this->handleError('Invalid postcode');
}
}
}
一旦所有属性都已设置,我们就能验证实体,这通常是在某个描述的过程之后。表面上看起来,位置似乎是自我验证的。然而,事实并非如此。Location类将这种验证委托给一个具体的验证器实例,将这两个清晰的责任分开:
class Location
{
// ...
public function validate(ValidationHandler $validationHandler)
{
$validator = new LocationValidator($this, $validationHandler);
$validator->validate();
}
}
解耦验证消息
通过对我们现有实现的一些小改动,我们能够将验证消息与验证器解耦:
class LocationValidationHandler implements ValidationHandler
{
public function handleCityNotFoundInCountry();
public function handleInvalidPostcodeForCity();
}
class LocationValidator
{
private $location;
private $validationHandler;
public function __construct(
Location $location,
LocationValidationHandler $validationHandler
) {
$this->location = $location;
$this->validationHandler = $validationHandler;
}
public function validate()
{
if (!$this->location->country()->hasCity(
$this->location->city()
)) {
$this->validationHandler->handleCityNotFoundInCountry();
}
if (! $this->location->city()->isPostcodeValid(
$this->location->postcode()
)) {
$this->validationHandler->handleInvalidPostcodeForCity();
}
}
}
我们还需要将验证方法的签名更改为以下形式:
class Location
{
// ...
public function validate(
LocationValidationHandler $validationHandler
) {
$validator = new LocationValidator($this, $validationHandler);
$validator->validate();
}
}
验证对象组合
验证对象组合可能很复杂。因此,实现这一目标的最佳方式是通过领域服务。然后,该服务与存储库通信,以检索有效的聚合。由于可能创建的复杂对象图,聚合可能处于中间状态,需要先验证其他聚合。我们可以使用领域事件来通知系统的其他部分,特定元素已被验证。
实体和领域事件
我们将在未来的章节中探讨第六章,领域事件;然而,重要的是要强调,对实体执行的操作可以触发领域事件。这种方法用于将领域变化传达给应用程序的其他部分,或者甚至传达给其他应用程序,正如你将在第十二章,集成边界上下文中看到的:
class Post
{
// ...
public function publish()
{
$this->setStatus(
Status::published()
);
$this->publishedAt(new DateTimeImmutable());
DomainEventPublisher::instance()->publish(
new PostPublished($this->id)
);
}
public function unpublish()
{
$this->setStatus(
Status::draft()
);
$this-> publishedAt = null;
DomainEventPublisher::instance()->publish(
new PostUnpublished($this->id)
);
}
// ...
}
当我们的实体创建新实例时,甚至可以触发领域事件:
class User
{
// ...
public function __construct(UserId $userId, $email, $password)
{
$this->setUserId($userId);
$this->setEmail($email);
$this->setPassword($password);
DomainEventPublisher::instance()->publish(
new UserRegistered($this->userId)
);
}
}
总结
领域中的一些概念需要身份——也就是说,它们内部状态的变化不会改变它们自己的唯一身份。我们已经看到如何将身份建模为值对象,除了操作身份本身的逻辑外,还能带来不可变等好处。我们还展示了提供身份的几种方法,以下是一些要点:
-
持久化机制:易于实现,但在持久化对象之前,你将无法获得身份,这会延迟并复杂化事件传播。
-
代理 ID:一些 ORM 要求在实体上添加额外字段,以将身份与持久化机制映射。
-
由客户端提供:有时身份符合领域概念,你可以在领域内部对其进行建模。
-
由应用程序生成:你可以使用库来生成 ID。
-
由边界上下文生成:这可能是最复杂的策略。其他边界上下文提供了一个生成身份的接口。
我们已经看到并讨论了 Doctrine 作为持久化机制,我们研究了使用 Active Record 模式的缺点,最后,我们检查了实体验证的不同级别:
-
属性验证:通过前置条件、后置条件和不变性检查对象状态中的具体内容。
-
整个对象验证:寻找对象作为一个整体的一致性。将验证提取到外部服务是一种良好的实践。
-
对象组合:可以通过领域服务验证复杂对象组合。将此传达给应用程序其余部分的好方法是使用领域事件。
第五章:服务
您已经看到了实体和值对象是什么。作为基本构建块,它们应该包含任何应用程序的大部分业务逻辑。然而,在某些场景中,实体和值对象并不是最佳解决方案。让我们看看埃里克·埃文斯在他的书中对此有何看法,领域驱动设计:软件核心的复杂性处理:
当领域中的某个重要过程或转换不是实体或值对象的自然职责时,将操作添加到模型中,作为独立接口声明的服务。用模型的语言定义接口,并确保操作名称是通用语言的一部分。使服务无状态。
因此,当有需要表示的操作,但实体和值对象不是最佳选择时,您应该考虑将这些操作建模为服务。在领域驱动设计中,通常会遇到三种不同类型的服务:
-
应用服务: 在标量类型上操作,将它们转换为领域类型。标量类型可以被认为是领域模型中未知的任何类型。这包括原始类型和不属于领域的类型。我们将在本章中提供一个概述,但若要深入了解此主题,请参阅第十一章应用,应用。
-
领域服务: 仅在属于领域的类型上操作。它们包含可以在通用语言中找到的有意义的概念。它们包含不适合值对象或实体的操作。
-
基础设施服务: 是满足基础设施关注点的操作,例如发送电子邮件和记录有意义的数据。在六边形架构中,它们位于领域边界之外。
应用服务
应用服务是外部世界和领域逻辑之间的中间件。这种机制的目的是将来自外部世界的命令转换为有意义的领域指令。
让我们考虑用户注册到我们的平台用例。从外部到内部的方法开始:从交付机制,我们需要为我们的领域操作组合输入请求。使用像 Symfony 这样的框架作为交付机制,代码看起来可能像这样:
class SignUpController extends Controller
{
public function signUpAction(Request $request)
{
$signUpService = new SignUpUserService(
$this->get('user_repository')
);
try {
$response = $signUpService->execute(new SignUpUserRequest(
$request->request->get('email'),
$request->request->get('password')
));
} catch (UserAlreadyExistsException $e) {
return $this->render('error.html.twig', $response);
}
return $this->render('success.html.twig', $response);
}
}
正如您所看到的,我们为我们的应用程序服务创建了一个新的实例,传递了所有需要的依赖项——在这个例子中,是一个UserRepository。UserRepository是一个可以由任何特定技术实现的接口(例如:MySQL、Redis、Elasticsearch)。然后,我们为我们的应用程序服务构建一个请求对象,以便抽象化交付机制——在这个例子中,是一个网络请求——从业务逻辑中。最后,我们执行应用程序服务,获取响应,并使用该响应来渲染结果。在领域一侧,让我们检查一个可能的实现,该实现协调满足“用户注册”用例的逻辑:
class SignUpUserService
{
private $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
public function execute(SignUpUserRequest $request)
{
$user = $this->userRepository->userOfEmail($request->email);
if ($user) {
throw new UserAlreadyExistsException();
}
$user = new User(
$this->userRepository->nextIdentity(),
$request->email,
$request->password
);
$this->userRepository->add($user);
return new SignUpUserResponse($user);
}
}
代码中的每一件事都是关于我们想要解决的领域问题,而不是我们用来解决它的特定技术。采用这种方法,我们可以将高级策略与低级实现细节解耦。交付机制与领域之间的通信由称为 DTO 的数据结构携带,我们在第二章中介绍了它,架构风格:
class SignUpUserRequest
{
public $email;
public $password;
public function __construct($email, $password)
{
$this->email = $email;
$this->password = $password;
}
}
有不同的策略用于返回内容,但到目前为止,请考虑我们不应该返回我们的实体,这样它们就不能从我们的应用程序服务外部被修改。这就是为什么通常返回另一个包含信息的 DTO,而不是整个实体。让我们看看一个简单的例子:
class SignUpUserResponse
{
public $id;
public $email;
public function __construct(User $user)
{
$this->id = $user->id();
$this->email = $user->email();
}
}
对于创建您的响应,您可以使用 getter 或公共实例变量。应用程序服务应负责事务范围和安全。然而,您将在第十一章中深入了解这些以及其他与应用程序服务相关的内容,应用程序。
领域服务
在与领域专家的整个对话中,您会遇到一些在通用语言中无法整洁地表示为实体或值对象的概念,例如:
-
用户能够自行登录系统
-
一个购物车能够自行变成订单
上述示例是两个具体概念,它们都不能自然地绑定到实体或值对象。进一步突出这种异常,我们可以尝试如下建模行为:
class User
{
public function signUp($aUsername, $aPassword)
{
// ...
}
}
class Cart
{
public function createOrder()
{
// ...
}
}
在第一种实现的情况下,我们无法知道给定的用户名和密码与被调用的用户实例相关联。显然,这个操作不适合这个实体;相反,它应该被提取到一个单独的类中,使其意图明确。
考虑到这一点,我们可以创建一个仅负责验证用户的领域服务:
class SignUp
{
public function execute($aUsername, $aPassword)
{
// ...
}
}
类似地,正如第二个示例的情况,我们可以创建一个专门从提供的购物车创建订单的领域服务:
class CreateOrderFromCart
{
public function execute(Cart $aCart)
{
// ...
}
}
领域服务可以被定义为执行领域任务的操作,并且自然不适合实体或值对象。作为代表领域操作的概念,领域服务应该由客户端使用,无论它们的运行历史如何。领域服务本身不持有任何状态,因此领域服务是无状态的操作。
领域服务和基础设施服务
在建模领域服务时,遇到基础设施依赖是很常见的——例如,在需要处理密码散列的认证机制的情况下。在这种情况下,你可以使用分离接口,这允许定义多个散列机制。使用这种模式仍然在领域和基础设施之间提供了清晰的关注点分离:
namespace Ddd\Auth\Domain\Model;
interface SignUp
{
public function execute($aUsername, $aPassword);
}
使用在领域中找到的先前接口,我们可以在基础设施层创建一个实现,如下所示:
namespace Ddd\Auth\Infrastructure\Authentication;
class DefaultHashingSignUp implements Ddd\Auth\Domain\Model\SignUp
{
private $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
public function execute($aUsername, $aPassword)
{
if (!$this->userRepository->has($aUsername)) {
throw UserDoesNotExistException::fromUsername($aUsername);
}
$aUser = $this->userRepository->byUsername($aUsername);
if (!$this->isPasswordValidForUser($aUser, $aPassword)) {
throw new BadCredentialsException($aUser, $aPassword);
}
return $aUser;
}
private function isPasswordValidForUser(
User $aUser, $anUnencryptedPassword
) {
return password_verify($anUnencryptedPassword,$aUser->hash());
}
}
这里是基于 MD5 算法的另一个实现:
namespace Ddd\Auth\Infrastructure\Authentication;
use Ddd\Auth\Domain\Model\SignUp
class Md5HashingSignUp implements SignUp
{
const SALT = 'S0m3S4lT' ;
private $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
public function execute($aUsername, $aPassword)
{
if (!$this->userRepository->has($aUsername)) {
throw new InvalidArgumentException(
sprintf('The user "%s" does not exist.', $aUsername)
);
}
$aUser = $this->userRepository->byUsername($aUsername);
if ($this->isPasswordInvalidFor($aUser, $aPassword)) {
throw new BadCredentialsException($aUser, $aPassword);
}
return $aUser;
}
private function salt()
{
return md5(self::SALT);
}
private function isPasswordInvalidFor(
User $aUser, $anUnencryptedPassword
) {
$encryptedPassword = md5(
$anUnencryptedPassword . '_' .$this->salt()
);
return $aUser->hash() !== $encryptedPassword;
}
}
选择这种方案允许我们在基础设施层拥有多个领域服务接口的实现。换句话说,我们最终会得到几个基础设施领域服务。每个基础设施服务将负责处理不同的散列机制。根据实现方式,用户可以通过依赖注入容器轻松管理使用,例如,通过 Symfony 的依赖注入组件:
<?xml version="1.0"?>
<container
xsi:schemaLocation="
http://symfony.com/schema/dic/services
http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="sign_in" alias="sign_in.default" />
<service id="sign_in.default"
class="Ddd\Auth\Infrastructure\Authentication
\DefaultHashingSignUp">
<argument type="service" id="user_repository"/>
</service>
<service id="sign_in.md5"
class="Ddd\Auth\Infrastructure\Authentication
\Md5HashingSignUp">
<argument type="service" id="user_repository"/>
</service>
</services>
</container>
如果在未来,我们希望处理新的散列类型,我们只需从实现领域服务接口开始。然后,只需在依赖注入容器中声明服务,并用新创建的服务别名依赖项替换即可。
代码复用问题
虽然之前描述的实现清楚地定义了关注点分离,但我们每次想要实现一个新的散列机制时,都需要重复密码验证算法。解决此问题的另一种方法,它可以提高代码复用性,是将这两个责任分离出来。我们可以将这些密码散列逻辑提取到一个专门的类中,使用策略模式对所有定义的散列算法进行处理。这使设计可以扩展,但修改是封闭的:
namespace Ddd\Auth\Domain\Model;
class SignUp
{
private $userRepository;
private $passwordHashing;
public function __construct(
UserRepository $userRepository, PasswordHashing $passwordHashing
) {
$this->userRepository = $userRepository;
$this->passwordHashing = $passwordHashing;
}
public function execute($aUsername, $aPassword)
{
if (!$this->userRepository->has($aUsername)) {
throw new InvalidArgumentException(
sprintf('The user "%s" does not exist.', $aUsername)
);
}
$aUser = $this->userRepository->byUsername($aUsername);
if ($this->isPasswordInvalidFor($aUser, $aPassword)) {
throw new BadCredentialsException($aUser, $aPassword);
}
return $aUser;
}
private function isPasswordInvalidFor(User $aUser, $plainPassword)
{
return !$this->passwordHashing->verify(
$plainPassword,
$aUser->hash()
);
}
}
interface PasswordHashing
{
/**
* @param string $password
* @param string $hash
* @return boolean
*/
public function verify($plainPassword, hash);
}
定义不同的散列策略就像实现PasswordHashing接口一样简单:
namespace Ddd\Auth\Infrastructure\Authentication;
class BasicPasswordHashing
implements \Ddd\Auth\Domain\Model\PasswordHashing
{
public function verify($plainPassword, $hash)
{
return password_verify($plainPassword, $hash);
}
}
class Md5PasswordHashing
implements Ddd\Auth\Domain\Model\PasswordHashing
{
const SALT = 'S0m3S4lT' ;
public function verify($plainPassword, $hash)
{
return $hash === $this-> calculateHash($plainPassword);
}
private function calculateHash($plainPassword)
{
return md5($plainPassword . '_' .$this-> salt());
}
private function salt()
{
return md5(self::SALT);
}
}
测试领域服务
在多个领域服务实现的用户身份验证示例中,能够轻松测试服务是非常有益的。然而,通常情况下,测试模板方法实现可能会很棘手。因此,我们将使用一个简单的密码散列实现来进行测试:
class PlainPasswordHashing implements PasswordHashing
{
public function verify($plainPassword, $hash)
{
return $plainPassword === $hash;
}
}
现在,我们可以在领域服务中测试所有情况:
class SignUpTest extends PHPUnit_Framework_TestCase
{
private $signUp;
private $userRepository;
protected function setUp()
{
$this->userRepository = new InMemoryUserRepository();
$this->signUp = new SignUp(
$this->userRepository,
new PlainPasswordHashing()
);
}
/**
* @test
* @expectedException InvalidArgumentException
*/
public function itShouldComplainIfTheUserDoesNotExist()
{
$this->signUp->execute('test-username', 'test-password');
}
/**
* @test
* @expectedException BadCredentialsException
*/
public function itShouldTellIfThePasswordDoesNotMatch()
{
$this->userRepository->add(
new User(
'test-username',
'test-password'
)
);
$this->signUp->execute('test-username', 'no-matching-password')
}
/**
* @test
*/
public function itShouldTellIfTheUserMatchesProvidedPassword()
{
$this->userRepository->add(
new User(
'test-username',
'test-password'
)
);
$this->assertInstanceOf(
'Ddd\Domain\Model\User\User',
$this->signUp->execute('test-username', 'test-password')
);
}
}
贫血型领域模型与丰富型领域模型
你必须小心不要在系统中过度使用领域服务抽象。走这条路可能会导致实体和值对象失去所有行为,仅仅成为数据容器。这与面向对象编程的目标相悖,面向对象编程可以被视为将数据和行为聚集到称为对象的语义单元中,目的是表达现实世界概念和问题。过度使用领域服务可以被视为一种反模式,被称为贫血领域模型。
通常,在开始一个新的项目或功能时,很容易陷入首先建模数据的陷阱。这通常包括认为每个数据库表都有一个直接的一对一对象形式表示。然而,这种想法可能并不总是完全正确。
假设我们被要求建模一个订单处理系统。如果我们首先建模数据,我们可能会得到一个像这样的 SQL 脚本:
CREATE TABLE `orders` (
`ID` INTEGER NOT NULL AUTO_INCREMENT,
`CUSTOMER_ID` INTEGER NOT NULL,
`AMOUNT` DECIMAL(17, 2) NOT NULL DEFAULT '0.00',
`STATUS` TINYINT NOT NULL DEFAULT 0,
`CREATED_AT` DATETIME NOT NULL,
`UPDATED_AT` DATETIME NOT NULL,
PRIMARY KEY (`ID`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
从这个角度来看,创建一个Order类表示相对容易。这个表示包括所需的访问器方法,用于设置或获取从底层订单数据库表的数据:
class Order
{
const STATUS_CREATED = 10;
const STATUS_ACCEPTED = 20;
const STATUS_PAID = 30;
const STATUS_PROCESSED = 40;
private $id;
private $customerId;
private $amount;
private $status;
private $createdAt;
private $updatedAt;
public function __construct(
$customerId,
$amount,
$status,
DateTimeInterface $createdAt,
DateTimeInterface $updatedAt
) {
$this->customerId = $customerId;
$this->amount = $amount;
$this->status = $status;
$this->createdAt = $createdAt;
$this->updatedAt = $updatedAt;
}
public function setId($id)
{
$this->id = $id;
}
public function getId()
{
return $this->id;
}
public function setCustomerId($customerId)
{
$this->customerId = $customerId;
}
public function getCustomerId()
{
return $this->customerId;
}
public function setAmount($amount)
{
$this->amount = $amount;
}
public function getAmount()
{
return $this->amount;
}
public function setStatus($status)
{
$this->status = $status;
}
public function getStatus()
{
return $this->status;
}
public function setCreatedAt(DateTimeInterface $createdAt)
{
$this->createdAt = $createdAt;
}
public function getCreatedAt()
{
return $this->createdAt;
}
public function setUpdatedAt(DateTimeInterface $updatedAt)
{
$this->updatedAt = $updatedAt;
}
public function getUpdatedAt()
{
return $this->updatedAt;
}
}
这种实现的示例用例可能是更新订单状态如下:
// Fetch an order from the database
$anOrder = $orderRepository->find( 1 );
// Update order status
$anOrder->setStatus(Order::STATUS_ACCEPTED);
// Update updatedAt field
$anOrder->setUpdatedAt(new DateTimeImmutable());
// Save the order to the database
$orderRepository->save($anOrder);
关于代码复用,这段代码存在与初始用户认证解决方案类似的问题。为了解决这个问题,这种做法的支持者建议使用一个服务层,从而使操作变得明确且可重用。现在的先前实现现在可以封装到一个单独的类中:
class ChangeOrderStatusService
{
private $orderRepository;
public function __construct(OrderRepository $orderRepository)
{
$this->orderRepository = $orderRepository;
}
public function execute($anOrderId, $anOrderStatus)
{
// Fetch an order from the database
$anOrder = $this->orderRepository->find($anOrderId);
// Update order status
$anOrder->setStatus($anOrderStatus);
// Update updatedAt field
$anOrder->setUpdatedAt(new DateTimeImmutable());
// Save the order to the database
$this->orderRepository->save($anOrder);
}
}
或者,在更新订单金额的情况下,考虑以下情况:
class UpdateOrderAmountService
{
private $orderRepository;
public function __construct(OrderRepository $orderRepository)
{
$this->orderRepository = $orderRepository;
}
public function execute( $orderId, $amount)
{
$anOrder = $this->orderRepository->find(1);
$anOrder->setAmount($amount);
$anOrder->setUpdatedAt(new DateTimeImmutable());
$this->orderRepository->save($anOrder);
}
}
在进行明确意图的操作后,客户端代码将大大减少。
$updateOrderAmountService = new UpdateOrderAmountService(
$orderRepository
);
$updateOrderAmountService->execute(1, 20.5);
实施这种方法可以带来很高的代码复用度。如果有人想要更新订单金额,只需检索一个UpdateOrderAmountService实例,并使用适当的参数调用 execute 方法。
然而,选择这条路径打破了讨论的面向对象设计原则,并承担了构建领域模型而不利用任何好处所带来的成本。
贫血领域模型破坏封装
如果我们回顾定义服务层中服务的代码,我们可以看到,作为一个使用订单实体的客户端,我们需要了解其实际表示的每一个细节。这个发现违反了面向对象编程的基本规则,即结合数据与随后的行为
贫血领域模型带来代码复用的假象
假设有一个实例,客户端绕过UpdateOrderAmountService,而是直接从OrderRepository获取、更新和持久化。那么,UpdateOrderAmountService服务可能拥有的所有额外业务逻辑都不会被执行。这可能导致订单以不一致的状态存储。因此,不变量应该得到正确的保护,而最好的方式是让真正的领域模型来处理。在这个例子中,订单实体将是确保这一点的最佳位置:
class Order
{
// ...
public function changeAmount($amount)
{
$this->amount = $amount;
$this->setUpdatedAt(new DateTimeImmutable());
}
}
注意,通过将此操作推入实体并以通用语言命名,系统实现了代码的重用。现在任何希望更改订单数量的人都必须直接调用Order::changeAmount方法。
这导致产生了更丰富的类,其中行为是代码重用的目标。这通常被称为丰富的领域模型。
如何避免贫血领域模型
避免陷入贫血领域模型的方法是在开始一个新项目或功能时,首先考虑行为。数据库、ORM 等只是实现细节,我们应该努力将使用这些工具的决定推迟到开发过程的后期。这样做,我们可以专注于真正重要的一个属性:行为。
正如与实体一样,领域服务也可以触发第六章,领域事件。然而,当事件主要由领域服务而非实体触发时,这又是一个你可能正在创建贫血领域模型的指标。
总结
正如我们所见,服务代表了我们系统内的操作,我们可以区分它们的三个版本:
-
应用服务:帮助协调来自外部世界的请求到领域。这些服务不应包含领域逻辑。事务在应用级别处理;将你的服务包裹在事务装饰器中会使你的代码对事务不可知。
-
领域服务:仅使用领域概念进行操作,这些概念通过通用语言表达。记住推迟实现细节,首先考虑行为,因为滥用领域服务会导致贫血领域模型和糟糕的面向对象设计。
-
基础设施服务:在基础设施上操作,例如发送电子邮件或记录信息。
我们最重要的建议是在决定创建领域服务之前,考虑所有选项。首先尝试将业务逻辑移入实体或值。与一些同事讨论。再次审查。如果经过不同的方法,最佳选项是创建领域服务,那么就去做吧。
第六章:领域事件
软件事件是系统中发生的事情,其他组件可能想知道。PHP 开发者通常不习惯于使用事件,因为事件不是语言中的特性。然而,更常见的是看到新的框架和库如何接受它们,以提供新的解耦、重用和加速代码的方法。
领域事件是与领域变化相关的事件。领域事件是我们领域中发生的事情,领域专家关心的事情。
在领域驱动设计中,领域事件是基本构建块,有助于:
-
与其他边界上下文进行通信。
-
提高性能和可伸缩性,推动最终一致性。
-
作为历史检查点。
领域事件代表了异步通信的精髓。关于这个话题的更多信息,我们推荐阅读 Gregor Hohpe 和 Bobby Woolf 所著的书籍 企业集成模式:设计、构建和部署消息解决方案(Designing, Building, and Deploying Messaging Solutions)。
简介
想象一下一款 JavaScript 2D 平台游戏。屏幕上有成百上千个不同的组件在相互交互,所有这些都在同一时间进行。有一个组件显示剩余生命值,另一个显示所有得分,还有一个倒计时当前关卡剩余时间。每次你的角色跳到敌人身上,得分就会增加。当你的得分超过一定数量时,你会获得额外生命。当角色捡起钥匙时,通常门就会打开。但是,所有这些组件是如何相互交互的呢?这种场景的最佳架构是什么?
可能有两个主要选项:第一个是将每个组件与其连接的组件耦合在一起。然而,在上面的例子中,这意味着许多组件被耦合在一起,每次添加新组件都需要开发者修改代码。但你还记得开闭原则(OCP)吗?添加新组件不应该让第一个组件需要更新;这将使维护工作变得过于繁重。
第二个——也是更好的——方法是连接所有组件到一个单一的对象,该对象处理游戏中所有重要的事件。它从每个组件接收事件并将它们转发到特定的组件。例如,得分组件会对EnemyKilled事件感兴趣,而LifeCaptured事件对玩家实体和剩余生命值组件非常有用。这样,所有组件都耦合到一个管理所有通知的单个组件上。使用这种方法,添加或删除组件不会影响现有的组件。
当开发单个应用程序时,事件对于解耦组件很有帮助。当以分布式方式开发整个领域时,事件对于解耦在领域中扮演角色的每个服务或应用程序非常有用。关键点是相同的,但规模不同。
定义
领域事件是用于通知本地或远程边界上下文领域变化的一种特定类型的事件。
Vaughn Vernon 定义领域事件为:
领域内发生的事情的一个发生实例。
Eric Evans 定义领域事件为:
领域模型的一个完整部分,表示领域内发生的事情。在制作显式领域专家希望跟踪或通知的事件,或与其它模型对象状态变化相关联的事件时,忽略无关的领域活动。
Martin Fowler 定义领域事件为某种东西:
记录了影响领域内有趣事情的记忆。
在 Web 应用程序中,领域事件的例子有UserRegistered(用户注册)、OrderPlaced(订单已放置)、UserRelocated(用户迁移)和ProductAdded(产品添加)。
短篇小说
在票务销售代理机构中,内容经理决定提高 U2 演出的票价。她使用后台编辑演出。一个ShowPriceChanged(演出价格已更改)领域事件被发布并持久化到数据库中,与新的演出价格在同一笔交易中。
批处理过程将领域事件排队到 RabbitMQ 中。领域事件在两个队列中分发:一个用于相同的本地边界上下文,另一个用于远程的商务智能目的。
在第一个队列中,一个工作者使用事件中的 ID 检索相应的演出,并将其推送到 Elasticsearch 服务器,以便用户在搜索时可以看到新的价格。它也可以在另一个数据库表中更新新的价格。
在第二个队列中,一个工作者将信息插入到日志服务器或数据湖中,在那里可以运行报告或数据挖掘过程。
一个无法使用领域事件集成的外部应用程序可以使用本地边界上下文提供的 REST API 访问所有ShowPriceChanged(演出价格已更改)事件。
如您所见,领域事件对于处理最终一致性和集成不同的边界上下文非常有用。聚合创建事件并发布它们。订阅者可以存储事件,然后将它们转发给远程订阅者。
隐喻
我们在周二去巴布尔餐厅用餐,并使用信用卡支付。这可以建模为一个事件,事件类型为PurchasePlaced(购买已放置),主题是我的信用卡,发生日期为周二。如果巴布尔的系统过时且直到周五才传输交易,那么交易将在周五生效。
事情发生了。并非所有事情都很有趣,有些可能值得记录但不会引起反应。然而,最有趣的事情会引起反应。许多系统需要对有趣的事件做出反应。通常你需要知道为什么系统以这种方式做出反应。
通过将系统输入引导到领域事件流中,您可以记录系统所有输入。这有助于您组织您的处理逻辑,同时也允许您保留系统输入的审计日志。
练习
尝试在你的当前领域定位潜在的领域事件示例。
真实世界示例
在详细讨论领域事件之前,让我们看看一个领域事件的实际例子以及它们如何帮助我们在我们的应用程序和整个领域中。
让我们考虑一个简单的应用程序服务,它将注册新用户——例如,在电子商务环境中。应用程序服务将在另一章中解释,所以不必过于担心接口。相反,只需关注执行方法:
class SignUpUserService implements ApplicationService
{
private $userRepository;
private $userFactory;
private $userTransformer;
public function __construct(
UserRepository $userRepository,
UserFactory $userFactory,
UserTransformer $userTransformer
) {
$this->userRepository = $userRepository;
$this->userFactory = $userFactory;
$this->userTransformer = $userTransformer;
}
/**
* @param SignUpUserRequest $request
* @return User
* @throws UserAlreadyExistsException
*/
public function execute(SignUpUserRequest $request)
{
$email = $request->email();
$password = $request->password();
$user = $this->userRepository->userOfEmail($email );
if ($user) {
throw new UserAlreadyExistsException();
}
$user = $this->userFactory->build(
$this->userRepository->nextIdentity(),
$email,
$password
);
$this->userRepository->add($user);
$this->userTransformer->write($user);
}
}
如所示,应用程序服务部分检查用户是否已存在。如果没有,它将创建一个新的用户并将其添加到UserRepository。
现在考虑一个额外的要求:当新用户注册时,必须通过电子邮件通知他们。没有过多思考,首先想到的方法是更新我们的应用程序服务,包括一段执行这项工作的代码——可能是一种EmailSender,它会在添加方法之后运行。然而,让我们考虑另一种方法。
关于触发一个UserRegistered事件,以便另一个监听组件可以做出反应并发送电子邮件,有什么好处?这种新方法有一些酷炫的好处。首先,当新用户注册时,我们不需要每次都更新应用程序服务的代码来执行新操作。
其次,它更容易测试。应用程序服务保持简单,每次开发新操作时,我们只需为该操作编写测试。
在同一个电子商务项目中稍后,我们被告知要集成一个非 PHP 编写的开源游戏化平台。每次用户在我们的电子商务边界上下文中进行购买或评论产品时,他们都可以获得可以在他们的电子商务用户个人资料页面上显示的徽章,或者通过电子邮件通知。我们该如何建模这个问题?
按照第一种方法,我们会更新应用程序服务以类似于之前电子邮件确认方法的方式与新的平台集成。使用领域事件方法,我们可以为UserRegistered事件创建另一个监听器,该监听器将通过 REST 或 SOA 直接连接到游戏化平台。甚至更好,它可以将事件传递给像 RabbitMQ 这样的消息系统,以便游戏化边界上下文可以订阅并自动接收通知。我们的电子商务边界上下文根本不需要了解游戏化边界上下文。
特征
领域事件通常是不可变的,因为它们是过去某事的记录。除了事件的描述外,领域事件通常还包含事件发生的时间戳以及参与事件的实体标识。此外,领域事件通常还有一个单独的时间戳,表示事件被输入系统的时间,以及输入该事件的人的身份。当有用时,领域事件的标识可以基于这些属性中的一组。例如,如果同一事件的两个实例到达一个节点,它们可以被识别为相同的。
领域事件的本质是,你用它来捕获可以触发你正在开发的应用程序或领域内其他感兴趣的应用程序状态变化的事物。然后处理这些事件对象以引起系统变化,并存储起来以提供审计日志。
命名规范
所有事件都应该用过去时态的动词来表示,因为它们是过去已经完成的事情——例如,CustomerRelocated、CargoShipped或InventoryLossageRecorded。在英语中,有一些有趣的例子,人们可能会倾向于使用名词而不是过去时态的动词;例如,对于对自然灾害感兴趣的国会议员来说,Earthquake或Capsize可以作为相关事件;我们建议避免使用这样的名称作为领域事件,并坚持使用过去时态的动词。
领域事件和通用语言
当我们讨论客户搬迁的副作用时,要考虑通用语言的差异。事件使概念明确,而之前,在聚合内部或多个聚合之间发生的变更被留作一个需要探索和定义的隐含概念。例如,在大多数系统中,当一个库如 Hibernate 或 Entity Framework 发生副作用时,它不会影响领域。这些事件对客户端来说是隐含且透明的。引入事件使概念明确,并成为通用语言的一部分。搬迁客户不仅仅是改变一些东西;相反,它产生了一个在语言中明确定义的CustomerRelocatedEvent。
不可变性
正如我们之前提到的,领域事件谈论的是过去,描述了已经发生的领域变化。根据定义,除非你是 Marty McFly 并且有一个德洛瑞安,否则不可能改变过去,这可能是不可能的。所以,请记住,领域事件是不可变的。
Symfony 事件分发器
一些 PHP 框架支持事件。然而,不要将这些事件与领域事件混淆;它们在特性和目标上不同。例如,Symfony 有事件分发器组件,如果你需要为状态机实现事件系统,你可以依赖它。在 Symfony 中,请求到响应的转换也由事件处理。然而,Symfony 事件是可变的,每个监听器都有能力修改、添加或更新事件中的信息。
建模事件
为了准确描述你的业务领域,你必须与领域专家紧密合作,并定义通用的语言。这需要使用领域事件、实体、值对象等来构建领域概念。在建模事件时,根据它们起源的边界上下文中的通用语言来命名它们及其属性。如果一个事件是执行聚合体上的命令操作的结果,那么其名称通常是从所执行的命令中派生出来的。事件名称反映事件发生的过去性质是很重要的。
让我们考虑我们的用户注册功能;领域事件需要表示它。以下代码显示了一个基础领域事件的简化接口:
interface DomainEvent
{
/**
* @return DateTimeImmutable
*/
public function occurredOn();
}
如你所见,所需的最小信息是一个DateTimeImmutable,这是为了知道事件发生的时间。
现在让我们使用以下代码来建模新的用户注册事件。如我们上面提到的,名称应该是过去时态的动词,所以UserRegistered可能是一个不错的选择:
class UserRegistered implements DomainEvent
{
private $userId;
public function __construct(UserId $userId)
{
$this->userId = $userId;
$this->occurredOn = new \DateTimeImmutable();
}
public function userId()
{
return $this->userId;
}
public function occurredOn()
{
return $this->occurredOn;
}
}
通知订阅者关于新用户创建所需的最小信息量是UserId。有了这些信息,任何过程、命令或应用程序服务——无论是同一个边界上下文还是不同的边界上下文——都可以对此事件做出反应。
作为经验法则
-
领域事件通常设计为不可变的
-
构造函数将初始化领域事件的完整状态。
-
领域事件将提供 getter 来访问它们的属性
-
包含执行动作的聚合体身份
-
包含与第一个相关的其他聚合体身份
-
包含导致事件的参数(如果有用)
但如果你的领域专家来自同一个边界上下文或不同的边界上下文需要更多信息怎么办?让我们看看用更多信息建模的相同领域事件——例如,电子邮件地址:
class UserRegistered implements DomainEvent
{
private $userId;
private $userEmail ;
public function __construct(UserId $userId, $userEmail)
{
$this-> userId = $userId;
$this->userEmail = $userEmail ;
$this->occurredOn = new DateTimeImmutable();
}
public function userId()
{
return $this->userId;
}
public function userEmail ()
{
return $this->userEmail ;
}
public function occurredOn()
{
return $this->occurredOn;
}
}
如上所示,我们添加了电子邮件地址。向领域事件添加更多信息可以帮助提高性能或简化不同边界上下文之间的集成。从另一个边界上下文的角度思考可以帮助建模事件。当上游边界上下文中创建新用户时,下游的一个将不得不创建自己的用户。添加用户电子邮件地址可能在下游需要它的情况下,可能节省对上游边界上下文的同步请求。
你还记得那个游戏化示例吗?为了创建一个游戏化平台上的用户,可能被称为玩家,来自电子商务边界上下文的 UserId 可能已经足够。但如果游戏化平台需要通过电子邮件通知用户奖励信息呢?在这种情况下,电子邮件地址也是必需的。所以如果电子邮件地址包含在原始域事件中,我们就完成了。如果不是这样,游戏化边界上下文需要通过 REST 或 SOA 集成从电子商务边界上下文请求这些信息。
为什么不是整个用户实体?
想知道你是否应该在域事件中包含你边界上下文的整个用户实体吗?我们的建议是不要这样做。域事件可能用于在给定的边界上下文中内部通信消息或外部与其他边界上下文通信。换句话说,在一个 C2C 电子商务产品目录边界上下文中可能是一个“卖家”的,在产品反馈中可能是一个产品的“作者”。两者可以共享相同的 ID 或电子邮件,但“卖家”和“作者”是不同的概念,代表来自不同边界上下文的不同实体。因此,来自一个边界上下文的实体在另一个边界上下文中可能没有意义或具有完全不同的意义。
Doctrine 事件
域事件不仅用于执行批量作业,如发送电子邮件或与其他边界上下文通信;它们对于性能和可扩展性的改进也非常有趣。让我们看看一个例子。
考虑以下场景。你有一个电子商务应用程序。你的主要持久化机制是 MySQL,但为了浏览和过滤你的目录,你使用了一个更好的方法,比如 Elasticsearch 或 Solr。在 Elasticsearch 上,你最终会得到存储在你完整数据库中的信息的一个子集。你如何保持数据同步?当内容团队从后台工具更新目录时会发生什么?
有些人时不时地重新索引整个目录。这非常昂贵且缓慢。一个更聪明的做法可能是更新已更新的产品相关的一个或多个文档。我们如何做到这一点?使用域事件。
然而,如果你一直在使用 Doctrine,这很可能不是什么新鲜事。根据Doctrine 2 ORM 2 文档:
Doctrine 2 拥有一个轻量级的事件系统,它是 Common 包的一部分。Doctrine 使用它来分发系统事件,主要是生命周期事件。你也可以用它来处理你自己的自定义事件。
此外,它声明如下:
生命周期回调是在实体类上定义的。它们允许你在该实体类的实例经历相关生命周期事件时触发回调。每个生命周期事件都可以定义多个回调。生命周期回调最适合用于特定实体类生命周期的简单操作。
让我们看看Doctrine 事件文档中的一个例子:
/** @Entity @HasLifecycleCallbacks */
class User
{
// ...
/**
* @Column(type="string", length=255)
*/
public $value;
/** @Column(name="created_at", type="string", length=255) */
private $createdAt;
/** @PrePersist */
public function doStuffOnPrePersist()
{
$this->createdAt = date('Y-m-d H:i:s');
}
/** @PrePersist */
public function doOtherStuffOnPrePersist()
{
$this-> value = 'changed from prePersist callback!';
}
/** @PostPersist */
public function doStuffOnPostPersist()
{
$this->value = 'changed from postPersist callback!';
}
/** @PostLoad */
public function doStuffOnPostLoad()
{
$this->value = 'changed from postLoad callback!';
}
/** @PreUpdate */
public function doStuffOnPreUpdate()
{
$this->value = 'changed from preUpdate callback!';
}
}
你可以在 Doctrine 实体生命周期中的每个不同的重要时刻挂钩特定的任务。例如,在PostPersist时,你可以生成你的实体 JSON 文档并将其放入 Elasticsearch。这样,就很容易在不同持久化机制之间保持数据的一致性。
Doctrine 事件是围绕实体周围事件带来好处的良好例子。但你可能想知道它们的问题是什么。这是因为它们与框架耦合,是同步的,并且作用于应用层面,但不是用于通信目的。所以这就是为什么尽管领域事件在实现和处理上可能更困难,但它们仍然非常有意思。
持久化领域事件
持久化事件总是一个好主意。有些人可能想知道为什么你不应该直接将领域事件发布到消息或日志系统中。这是因为持久化它们有一些有趣的好处:
-
你可以通过 REST 接口将你的领域事件暴露给其他边界上下文。
-
你可以在将事件和聚合更改推送到 RabbitMQ 之前,在同一个数据库事务中持久化领域事件和聚合更改。(你不想发送关于没有发生的事情的通知,就像你不想错过关于已经发生的事情的通知一样。)
-
商业智能可以使用这些数据进行分析、预测或趋势分析。
-
你可以审计实体更改。
-
对于事件源,你可以从领域事件中重新构成聚合。
事件存储
我们在哪里持久化领域事件?在事件存储中。事件存储是一个存在于我们的领域空间中的领域事件仓库,作为一个抽象(接口或抽象类)。其责任是追加领域事件并查询它们。一个可能的基本接口可能是以下内容:
interface EventStore
{
public function append(DomainEvent $aDomainEvent);
public function allStoredEventsSince($anEventId);
}
然而,根据你的领域事件的使用情况,之前的接口可能有更多方法来查询你的事件。
在实现方面,你可以选择使用 Doctrine 仓库、DBAL 仓库或纯 PDO。由于领域事件是不可变的,使用 Doctrine 仓库会增加不必要的性能惩罚,但对于小型到中型应用,Doctrine 可能还是可以的。让我们看看使用 Doctrine 的一个可能实现:
class DoctrineEventStore extends EntityRepository implements EventStore
{
private $serializer;
public function append(DomainEvent $aDomainEvent)
{
$storedEvent = new StoredEvent(
get_class($aDomainEvent),
$aDomainEvent->occurredOn(),
$this->serializer()->serialize($aDomainEvent, 'json')
);
$this->getEntityManager()->persist($storedEvent);
}
public function allStoredEventsSince($anEventId)
{
$query = $this->createQueryBuilder('e');
if ($anEventId) {
$query->where('e.eventId > :eventId');
$query->setParameters(['eventId' => $anEventId]);
}
$query->orderBy('e.eventId');
return $query->getQuery()->getResult();
}
private function serializer()
{
if (null === $this->serializer) {
/** \JMS\Serializer\Serializer\SerializerBuilder */
$this->serializer = SerializerBuilder::create()->build();
}
return $this->serializer;
}
}
StoredEvent是映射到数据库的 Doctrine 实体。正如你可能看到的,在追加并持久化Store之后,没有flush调用。如果这个操作在 Doctrine 事务中,则不需要。所以,让我们不调用它,我们将在讨论应用程序服务时详细介绍。
现在让我们看看StoredEvent的实现:
class StoredEvent implements DomainEvent
{
private $eventId;
private $eventBody;
private $occurredOn;
private $typeName;
/**
* @param string $aTypeName
* @param \DateTimeImmutable $anOccurredOn
* @param string $anEventBody
*/
public function __construct(
$aTypeName, \DateTimeImmutable $anOccurredOn, $anEventBody
) {
$this->eventBody = $anEventBody;
$this->typeName = $aTypeName;
$this->occurredOn = $anOccurredOn;
}
public function eventBody()
{
return $this->eventBody;
}
public function eventId()
{
return $this->eventId;
}
public function typeName()
{
return $this->typeName;
}
public function occurredOn()
{
return $this->occurredOn;
}
}
这里是其映射:
Ddd\Domain\Event\StoredEvent:
type: entity
table: event
repositoryClass:
Ddd\Infrastructure\Application\Notification\DoctrineEventStore
id:
eventId:
type: integer
column: event_id
generator:
strategy: AUTO
fields:
eventBody:
column: event_body
type: text
typeName:
column: type_name
type: string
length: 255
occurredOn:
column: occurred_on
type: datetime
为了持久化具有不同字段的领域事件,我们必须将这些字段作为序列化字符串连接起来。typeName标识全局领域事件。实体或值对象在边界上下文中是有意义的,但领域事件定义了边界上下文之间的通信协议。
在分布式系统中,总会发生一些事情。你将不得不处理那些未发布、在链中丢失或发布多次的领域事件。这就是为什么将领域事件持久化并带有 ID 很重要,这样就可以轻松跟踪哪些领域事件已被发布,哪些缺失。
从领域模型发布事件
当表示的事实发生时,应该发布领域事件。例如,当新用户注册时,应该发布一个新的UserRegistered事件。
按照报纸的比喻:
-
建模领域事件就像撰写新闻文章
-
发布领域事件就像在报纸上打印文章
-
传播领域事件就像分发报纸,让每个人都能阅读文章
发布领域事件的建议方法是使用简单的监听器-观察者模式来实现DomainEventPublisher。
从实体发布领域事件
继续以一个新用户在我们的应用程序中注册的例子,让我们看看相应的领域事件是如何发布的:
class User
{
protected $userId;
protected $email ;
protected $password;
public function __construct(UserId $userId, $email, $password)
{
$this->setUserId($userId);
$this->setEmail($email);
$this->setPassword($password);
DomainEventPublisher::instance()->publish(
new UserRegistered($this->userId)
);
}
// ...
}
如示例所示,当创建User时,会发布一个新的UserRegistered事件。这是在实体构造函数中完成的,而不是在外部,因为采用这种方法,更容易保持我们的领域一致性;任何创建新User的客户端都会发布其对应的事件。另一方面,这也使得使用需要创建User实体而不使用其构造函数的基础设施变得更加复杂。例如,Doctrine 使用serialize和unserialize技术来重新创建一个对象,而不调用其构造函数。然而,如果你必须自己创建,这不会像在 Doctrine 中那样简单。
通常,从平面数据(如数组)构建对象称为解冻。让我们看看从数据库中获取新User的简单方法。首先,让我们通过应用工厂方法模式将领域事件发布提取到自己的方法中。
根据维基百科:
模板方法模式是一种行为设计模式,它在一个操作中定义了算法的程序骨架,将一些步骤推迟到子类中:
class User
{
protected $userId;
protected $email ;
protected $password;
public function __construct(UserId $userId, $email, $password)
{
$this->setUserId($userId);
$this->setEmail($email);
$this->setPassword($password);
$this->publishEvent();
}
protected function publishEvent()
{
DomainEventPublisher::instance()->publish(
new UserRegistered($this->userId)
);
}
// ...
}
现在,让我们通过添加一个新的基础设施实体来扩展我们当前的User,这个实体将为我们完成工作。这里的技巧是让publishEvent不执行任何操作,这样域事件就不会被发布:
class CustomOrmUser extends User
{
protected function publishEvent()
{
}
public static function fromRawData($data)
{
return new self(
new UserId($data['user_id']),
$data['email'],
$data['password']
);
}
}
记住要小心使用这种方法;你可能会从持久化机制中获取无效的对象,因为域规则总是在变化。不使用父构造函数的另一种方法可能是以下:
class CustomOrmUser extends User
{
public function __construct()
{
}
public static function fromRawData($data)
{
$user = new self();
$user->userId = new UserId($data['user_id']);
$user->email = $data['email'];
$user->password = $data['password'];
return $user;
}
}
使用这种方法,不会调用父构造函数,并且用户属性必须是受保护的。其他替代方案包括反射、在构造函数中传递标志、使用像Proxy-Manager这样的代理库,或者使用像 Doctrine 这样的 ORM。
发布域事件的另一种策略
如前一个示例所示,我们正在使用一个静态类来发布我们的域事件。其他人,作为一个替代方案,尤其是在使用事件溯源时,会建议实体在字段内部保留所有已触发的事件。为了访问所有事件,聚合体中使用了一个 getter。这也是一个有效的方法。然而,有时很难跟踪哪些实体触发了事件。从不是实体的地方触发事件也可能很困难,例如:域服务。优点是,检查实体是否触发了事件要容易得多。
从域或应用服务发布你的域事件
你应该努力从链的更深层次发布域事件。越接近实体或值对象的内部,越好。正如我们在前一个部分中看到的,有时这并不容易,但最终结果对客户端来说更简单。我们已经看到开发者从应用服务或域服务发布域事件。这看起来更容易做,但最终会导致贫血域模型。这就像在域服务中推入业务逻辑而不是将其放入你的实体中一样。
域事件发布者是如何工作的
域事件发布者是一个单例类,可以从我们的边界上下文中获取,用于发布域事件。它还支持附加监听器——域事件订阅者——它们将监听任何它们感兴趣的事件。这与使用 jQuery 的 on 方法订阅事件没有太大区别:
class DomainEventPublisher
{
private $subscribers;
private static $instance = null;
public static function instance()
{
if (null === static::$instance) {
static::$instance = new static();
}
return static::$instance;
}
private function __construct()
{
$this->subscribers = [];
}
public function __clone()
{
throw new BadMethodCallException('Clone is not supported');
}
public function subscribe(
DomainEventSubscriber $aDomainEventSubscriber
) {
$this->subscribers[] = $aDomainEventSubscriber;
}
public function publish(DomainEvent $anEvent)
{
foreach ($this->subscribers as $aSubscriber) {
if ($aSubscriber->isSubscribedTo($anEvent)) {
$aSubscriber->handle($anEvent);
}
}
}
}
publish方法遍历所有可能的订阅者,检查它们是否对发布的事件感兴趣。如果是这样,就会调用订阅者的handle方法。
subscribe方法添加了一个新的DomainEventSubscriber,它将监听特定的域事件类型:
interface DomainEventSubscriber
{
/**
* @param DomainEvent $aDomainEvent
*/
public function handle($aDomainEvent);
/**
* @param DomainEvent $aDomainEvent
* @return bool
*/
public function isSubscribedTo($aDomainEvent);
}
正如我们已经讨论过的,持久化所有领域事件是一个很好的主意。我们可以通过使用特定的订阅者轻松地将我们应用中发布的所有领域事件持久化。让我们创建一个DomainEventSubscriber,它将监听所有领域事件,无论其类型如何,并使用我们的EventStore来持久化它们:
class PersistDomainEventSubscriber implements DomainEventSubscriber
{
private $eventStore;
public function __construct(EventStore $anEventStore)
{
$this->eventStore = $anEventStore;
}
public function handle($aDomainEvent)
{
$this->eventStore->append($aDomainEvent);
}
public function isSubscribedTo($aDomainEvent)
{
return true;
}
}
$eventStore可以是自定义的 Doctrine 存储库,如之前所见,或任何其他能够将DomainEvents持久化到数据库的对象。
设置领域事件监听器
在哪里设置DomainEventPublisher的订阅者最好?这取决于。对于可能会影响整个请求周期的全局订阅者,最好的地方可能是DomainEventPublisher的初始化本身。对于受特定应用程序服务影响的订阅者,服务实例化可能是一个更好的地方。让我们通过 Silex 看看一个例子。
在Silex中,通过使用应用程序中间件注册将所有领域事件持久化的领域事件发布者是最简单的方法。根据Silex 2.0 文档:
一个前置应用程序中间件允许你在控制器执行之前调整请求。
这是订阅负责将稍后发送到 RabbitMQ 的 Event 事件持久化到数据库的监听器的正确位置:
// ...
$app['em'] = $app-> share(function () {
return (new EntityManagerFactory())->build();
});
$app['event_repository'] = $app->share(function ($app) {
return $app['em']->getRepository(
'Ddd\Domain\Model\Event\StoredEvent'
);
});
$app['event_publisher'] = $app->share(function($app) {
return DomainEventPublisher::instance();
});
$app->before(
function(Symfony\Component\HttpFoundation\Request $request)
use($app) {
$app['event_publisher']->subscribe(
new PersistDomainEventSubscriber(
$app['event_repository']
)
);
}
);
使用此设置,每当聚合发布领域事件时,它将被持久化到数据库中。任务完成。
练习
如果你正在使用 Symfony、Laravel 或其他 PHP 框架,找到一种方法来全局订阅特定的订阅者,以执行围绕你的领域事件的任务。
如果你想在请求即将结束时对所有领域事件执行任何操作,你可以创建一个监听器,该监听器将所有已发布的领域事件存储在内存中。如果你给这个监听器添加一个 getter 来返回所有领域事件,然后你可以决定要做什么。如果不想或不能在之前提到的同一事务中持久化事件,这可能会很有用。
测试领域事件
你已经知道如何发布领域事件,但如何进行单元测试以确保UserRegistered事件确实被触发?我们建议的最简单方法是使用一个特定的EventListener,它将作为一个间谍来记录领域事件是否被发布。让我们看看User实体单元测试的一个例子:
use Ddd\Domain\DomainEventPublisher;
use Ddd\Domain\DomainEventSubscriber;
class UserTest extends \PHPUnit_Framework_TestCase
{
// ...
/**
* @test
*/
public function itShouldPublishUserRegisteredEvent()
{
$subscriber = new SpySubscriber();
$id = DomainEventPublisher::instance()->subscribe($subscriber);
$userId = new UserId();
new User($userId, 'valid@email.com', 'password');
DomainEventPublisher::instance()->unsubscribe($id);
$this->assertUserRegisteredEventPublished($subscriber,$userId);
}
private function assertUserRegisteredEventPublished(
$subscriber, $userId
) {
$this->assertInstanceOf(
'UserRegistered', $subscriber->domainEvent
);
$this->assertTrue(
$subscriber->domainEvent->serId()->equals($userId)
);
}
}
class SpySubscriber implements DomainEventSubscriber
{
public $domainEvent;
public function handle($aDomainEvent)
{
$this->domainEvent = $aDomainEvent;
}
public function isSubscribedTo($aDomainEvent)
{
return true;
}
}
上述方法有一些替代方案。你可以为DomainEventPublisher使用静态 setter 或使用一些反射框架来检测调用。然而,我们认为我们分享的方法更自然。最后但并非最不重要的是,记得清理间谍订阅,以免影响单元测试的其余部分的执行。
将消息传播到远程边界上下文
为了将一组领域事件传达给本地或远程的边界上下文,有两种主要策略:消息传递和 REST API。第一种计划使用消息系统(如 RabbitMQ)来传输领域事件。第二种计划为访问特定边界上下文的领域事件创建 REST API。
消息传递
将所有领域事件持久化到数据库后,剩下的唯一事情就是将它们推送到我们喜欢的消息系统。我们更喜欢RabbitMQ,但任何其他系统,如 ActiveMQ 或 ZeroMQ,也能完成这项工作。对于使用 PHP 与 RabbitMQ 集成,选项并不多,但php-amqplib将完成这项工作。
首先,我们需要一个能够将持久化的领域事件发送到 RabbitMQ 的服务。你可能想查询 EventStore 中的所有事件并发送每一个,这并不是一个坏主意。然而,我们可能会多次推送同一个领域事件,一般来说,我们需要最小化重新发布的领域事件数量。如果重新发布的领域事件数量为 0,那就更好了。为了不重新发布领域事件,我们需要某种组件来跟踪哪些领域事件已经被推送,哪些尚未推送。最后但同样重要的是,一旦我们知道哪些领域事件需要推送,我们就将它们发送出去,并跟踪最后一条发布到我们的消息系统中的消息。让我们看看这个服务的可能实现:
class NotificationService
{
private $serializer;
private $eventStore;
private $publishedMessageTracker;
private $messageProducer;
public function __construct(
EventStore $anEventStore,
PublishedMessageTracker $aPublishedMessageTracker,
MessageProducer $aMessageProducer,
Serializer $aSerializer
) {
$this->eventStore = $anEventStore;
$this->publishedMessageTracker = $aPublishedMessageTracker;
$this->messageProducer = $aMessageProducer;
$this->serializer = $aSerializer;
}
/**
* @return int
*/
public function publishNotifications($exchangeName)
{
$publishedMessageTracker = $this->publishedMessageTracker();
$notifications = $this->listUnpublishedNotifications(
$publishedMessageTracker
->mostRecentPublishedMessageId($exchangeName)
);
if (!$notifications) {
return 0;
}
$messageProducer = $this->messageProducer();
$messageProducer->open($exchangeName);
try {
$publishedMessages = 0;
$lastPublishedNotification = null;
foreach ($notifications as $notification) {
$lastPublishedNotification = $this->publish(
$exchangeName,
$notification,
$messageProducer
);
$publishedMessages++;
}
} catch (\Exception $e) {
// Log your error (trigger_error, Monolog, etc.)
}
$this->trackMostRecentPublishedMessage(
$publishedMessageTracker,
$exchangeName,
$lastPublishedNotification
);
$messageProducer->close($exchangeName);
return $publishedMessages;
}
protected function publishedMessageTracker()
{
return $this->publishedMessageTracker;
}
/**
* @return StoredEvent[]
*/
private function listUnpublishedNotifications(
$mostRecentPublishedMessageId
) {
return $this
->eventStore()
->allStoredEventsSince($mostRecentPublishedMessageId);
}
protected function eventStore()
{
return $this->eventStore;
}
private function messageProducer()
{
return $this->messageProducer;
}
private function publish(
$exchangeName,
StoredEvent $notification,
MessageProducer $messageProducer
) {
$messageProducer->send(
$exchangeName,
$this->serializer()->serialize($notification, 'json'),
$notification->typeName(),
$notification->eventId(),
$notification->occurredOn()
);
return $notification;
}
private function serializer()
{
return $this->serializer;
}
private function trackMostRecentPublishedMessage(
PublishedMessageTracker $publishedMessageTracker,
$exchangeName,
$notification
) {
$publishedMessageTracker->trackMostRecentPublishedMessage(
$exchangeName, $notification
);
}
}
NotificationService依赖于三个接口。我们已经看到了EventStore,它负责追加和查询领域事件。第二个是PublishedMessageTracker,它负责跟踪推送的消息。第三个是MessageProducer,这是一个代表我们的消息系统的接口:
interface PublishedMessageTracker
{
/**
* @param string $exchangeName
* @return int
*/
public function mostRecentPublishedMessageId($exchangeName);
/**
* @param string $exchangeName
* @param StoredEvent $notification
*/
public function trackMostRecentPublishedMessage(
$exchangeName, $notification
);
}
mostRecentPublishedMessageId方法返回最后一条PublishedMessage的 ID,以便过程可以从下一条开始。trackMostRecentPublishedMessage负责跟踪最后一条发送的消息,以便在需要时能够重新发布消息。$exchangeName代表我们将用于发送领域事件的通信通道。让我们看看PublishedMessageTracker的 Doctrine 实现:
class DoctrinePublishedMessageTracker extends EntityRepository\
implements PublishedMessageTracker
{
/**
* @param $exchangeName
* @return int
*/
public function mostRecentPublishedMessageId($exchangeName)
{
$messageTracked = $this->findOneByExchangeName($exchangeName);
if (!$messageTracked) {
return null ;
}
return $messageTracked->mostRecentPublishedMessageId();
}
/**
*@param $exchangeName
* @param StoredEvent $notification
*/
public function trackMostRecentPublishedMessage(
$exchangeName, $notification
) {
if(!$notification) {
return;
}
$maxId = $notification->eventId();
$publishedMessage= $this->findOneByExchangeName($exchangeName);
if(null === $publishedMessage){
$publishedMessage = new PublishedMessage(
$exchangeName,
$maxId
);
}
$publishedMessage->updateMostRecentPublishedMessageId($maxId);
$this->getEntityManager()->persist($publishedMessage);
$this->getEntityManager()->flush($publishedMessage);
}
}
这段代码相当简单。我们唯一需要考虑的边缘情况是没有任何领域事件已经被发布。
为什么需要一个交换机名称?
我们将在第十二章“集成边界上下文”中更详细地介绍这一点。然而,当系统运行时,一个新的边界上下文开始发挥作用,你可能希望将所有领域事件重新发送到新的边界上下文。因此,跟踪最后一条已发布的领域事件及其发送的通道可能会在以后派上用场。
为了跟踪已发布的领域事件,我们需要一个交换机名称和一个通知 ID。以下是一个可能的实现:
class PublishedMessage
{
private $mostRecentPublishedMessageId;
private $trackerId;
private $exchangeName;
/**
* @param string $exchangeName
* @param int $aMostRecentPublishedMessageId
*/
public function __construct(
$exchangeName, $aMostRecentPublishedMessageId
) {
$this->mostRecentPublishedMessageId =
$aMostRecentPublishedMessageId;
$this->exchangeName = $exchangeName;
}
public function mostRecentPublishedMessageId()
{
return $this->mostRecentPublishedMessageId;
}
public function updateMostRecentPublishedMessageId($maxId)
{
$this->mostRecentPublishedMessageId = $maxId;
}
public function trackerId()
{
return $this->trackerId;
}
}
这里是其对应的映射:
Ddd\Domain\Event\PublishedMessage:
type: entity
table: event_published_message_tracker
repositoryClass:
Ddd\Infrastructure\Application\Notification\
DoctrinePublished\MessageTracker
id:
trackerId:
column: tracker_id
type: integer
generator:
strategy: AUTO
fields:
mostRecentPublishedMessageId:
column: most_recent_published_message_id
type: bigint
exchangeName:
type: string
column: exchange_name
现在我们来看看MessageProducer接口是用来做什么的,以及它的实现细节:
interface MessageProducer
{
public function open($exchangeName);
/**
* @param $exchangeName
* @param string $notificationMessage
* @param string $notificationType
* @param int $notificationId
* @param \DateTimeImmutable $notificationOccurredOn
* @return
*/
public function send(
$exchangeName,
$notificationMessage,
$notificationType,
$notificationId,
\DateTimeImmutable $notificationOccurredOn
);
public function close($exchangeName);
}
很简单。打开和关闭方法用于打开和关闭与消息系统的连接。send方法接收一个消息体——消息名称和消息 ID——并将它们发送到我们的消息引擎,无论它是什么。因为我们选择了 RabbitMQ,我们需要实现连接和发送过程:
abstract class RabbitMqMessaging
{
protected $connection;
protected $channel ;
public function __construct(AMQPConnection $aConnection)
{
$this->connection =$aConnection;
$this->channel = null ;
}
private function connect($exchangeName)
{
if (null !== $this->channel ) {
return;
}
$channel = $this->connection->channel();
$channel->exchange_declare(
$exchangeName, 'fanout', false, true, false
);
$channel->queue_declare(
$exchangeName, false, true, false, false
);
$channel->queue_bind($exchangeName, $exchangeName);
$this->channel = $channel ;
}
public function open($exchangeName)
{
}
protected function channel ($exchangeName)
{
$this->connect($exchangeName);
return $this->channel;
}
public function close($exchangeName)
{
$this->channel->close();
$this->connection->close();
}
}
class RabbitMqMessageProducer
extends RabbitMqMessaging
implements MessageProducer
{
/**
* @param $exchangeName
* @param string $notificationMessage
* @param string $notificationType
* @param int $notificationId
* @param \DateTimeImmutable $notificationOccurredOn
*/
public function send(
$exchangeName,
$notificationMessage,
$notificationType,
$notificationId,
\DateTimeImmutable $notificationOccurredOn
) {
$this->channel ($exchangeName)->basic_publish(
new AMQPMessage(
$notificationMessage,
[
'type'=>$notificationType,
'timestamp'=>$notificationOccurredOn->getTimestamp(),
'message_id'=>$notificationId
]
),
$exchangeName
);
}
}
现在我们已经有一个DomainService用于将领域事件推送到像 RabbitMQ 这样的消息系统,现在是时候执行它们了。我们需要选择一个交付机制来运行该服务。我们个人建议创建一个Symfony 控制台命令:
class PushNotificationsCommand extends Command
{
protected function configure()
{
$this
->setName('domain:events:spread')
->setDescription('Notify all domain events via messaging')
->addArgument(
'exchange-name',
InputArgument::OPTIONAL,
'Exchange name to publish events to',
'my-bc-app'
);
}
protected function execute(
InputInterface $input, OutputInterface $output
) {
$app = $this->getApplication()->getContainer();
$numberOfNotifications =
$app['notification_service']
->publishNotifications(
$input->getArgument('exchange-name')
);
$output->writeln(
sprintf(
'<comment>%d</comment>' .
'<info>notification(s) sent!</info>',
$numberOfNotifications
)
);
}
}
按照 Silex 示例,让我们看看在Silex Pimple 服务容器中定义的$app['notification_service']的定义:
// ...
$app['event_store']=$app->share( function ($app) {
return $app['em']->getRepository('Ddd\Domain\Event\StoredEvent');
});
$app['message_tracker'] = $app->share(function($app) {
return $app['em']
->getRepository('Ddd\Domain\Event\Published\Message');
});
$app['message_producer'] = $app->share(function () {
return new RabbitMqMessageProducer(
new AMQPStreamConnection('localhost', 5672, 'guest', 'guest')
);
});
$app['message_serializer'] = $app->share(function () {
return SerializerBuilder::create()->build();
});
$app['notification_service'] = $app->share(function ($app) {
return new NotificationService(
$app['event_store'],
$app['message_tracker'],
$app['message_producer'],
$app['message_serializer']
);
});
//...
将领域服务与 REST 同步
在消息系统中已经实现了EventStore之后,添加一些分页功能、查询领域事件以及发布 JSON 或 XML 表示形式的 REST API 应该很容易。这有什么有趣的地方吗?嗯,使用消息传递的分布式系统必须面对许多不同的问题,例如消息没有到达、消息重复到达,或者消息以意外的顺序到达。这就是为什么提供一个 API 来发布你的领域事件,以便其他边界上下文可以请求一些缺失信息是很好的。仅作为一个例子,考虑你向/events端点发出一个 HTTP 请求。可能的结果如下:
[
{
"id": 1,
"version": 1,
"typeName": "Lw\\Domain\\Model\\User\\UserRegistered",
"eventBody": {
"user_id": {
"id": "459a4ffc-cd57-4cf0-b3a2-0f2ccbc48234"
}
},
"occurredOn": {
"date": "2016-05-26 06:06:07.000000",
"timezone_type": 3,
"timezone": "UTC"
}
},
{
"id": 2,
"version": 2,
"typeName": "Lw\\Domain\\Model\\Wish\\WishWasMade",
"eventBody": {
"wish_id": {
"id": "9e90435a-395c-46b0-b4c4-d4b769cbf201"
},
"user_id": {
"id": "459a4ffc-cd57-4cf0-b3a2-0f2ccbc48234"
},
"address": "john@example.com",
"content": "This is my new wish!"
},
"occurredOn": {
"date": "2016-05-26 06:06:27.000000",
"timezone_type": 3,
"timezone": "UTC"
},
"timeTaken": "650"
},
//...
]
如前一个示例所示,我们正在通过 JSON REST API 公开一组领域事件。在输出示例中,你可以看到每个领域事件的 JSON 表示。有一些有趣的地方。首先,version字段。有时你的领域事件会进化:它们将包括更多的字段,它们会改变一些现有字段的行为,或者它们会删除一些现有字段。这就是为什么在领域事件中添加一个版本字段很重要。如果其他边界上下文正在监听此类事件,它们可以使用版本字段以不同的方式解析领域事件。你可能遇到过在版本化 REST API 时遇到相同的问题。
另一点是名称。如果你想使用领域事件的classname,在大多数情况下可能可行。问题是当团队决定因为重构而更改类的名称时,所有监听该名称的边界上下文都会停止工作。这个问题只会在你在同一个队列中发布不同的领域事件时出现。如果你在每个不同的队列中发布每种领域事件类型,这并不是真正的问题,但如果你选择这种方法,你将面临另一组问题,例如接收无序的事件。就像在许多其他情况下一样,这里涉及到权衡。我们强烈建议你阅读《企业集成模式:设计、构建和部署消息解决方案》 Designing, Building, and Deploying Messaging Solutions。在这本书中,你将学习到使用异步方法集成多个应用程序的不同模式。因为领域事件是在集成通道中发送的消息,所以所有消息模式也适用于它们。
练习
考虑一下为领域事件拥有 REST API 的利弊。考虑边界上下文的耦合。你也可以尝试为你的当前应用程序实现 REST API。
总结
我们已经看到了如何使用基接口来正确建模DomainEvent的技巧,我们已经看到了在哪里发布DomainEvent(越靠近实体越好),我们也已经看到了将那些DomainEvents传播到本地和远程边界上下文的策略。现在,唯一剩下的事情就是在消息系统中监听通知,读取它,并执行相应的应用程序服务或命令。我们将在第十二章整合边界上下文和第五章服务中看到如何做到这一点。Chapter 12, Integrating Bounded Contexts 和 Chapter 5, Services.
第七章:模块
当你在模块中将一些类放在一起时,你是在告诉下一个查看你设计的设计师将它们一起考虑。如果你的模型在讲述一个故事,模块就是章节。
-埃里克·埃文斯
在遵循领域驱动设计(Domain-Driven Design)构建应用程序时,一个常见的担忧是代码的放置位置。具体来说,如果你正在使用 PHP 框架,了解推荐的代码放置方式、基础设施代码的放置位置以及模型内部不同概念应该如何结构化是很重要的。
在领域驱动设计中,有一个战术模式用于此:模块。如今,每个人都以模块的形式结构化代码。所有语言都有某种工具可以将类和语言定义分组在一起。Java 有包。Ruby 有模块。PHP 有命名空间。
领域驱动设计更进一步,将你的类打包和分组,并给这些构建块赋予语义意义。确实,它将模块视为模型的一部分。作为模型的一部分,找到最佳命名、将彼此接近的领域对象分组在一起,以及保持不相关的领域对象解耦是很重要的。模块不应被视为分离代码的方式,而应被视为在模型中分离有意义概念的方式。
概述
如第一章“领域驱动设计入门”中所述,我们的领域在内部组织为子领域。每个子领域理想情况下由一个边界上下文(Bounded Context)建模和实现,但有时可能需要多个。如果设计得当,每个边界上下文都是一个独立的系统,将由一个团队开发和维护。我们的建议是使用整个应用程序来实现每个边界上下文。这意味着两个边界上下文不会生活在同一个代码仓库中。因此,它们可以独立部署,有不同的开发周期,甚至可以使用不同的语言进行开发。在你的边界上下文中,你会使用模块来分组彼此之间有强关系的领域对象。
利用 PHP 中的模块
直到 PHP 5.3,模块没有得到完全支持。但自从 PHP 5.3 的引入以来,我们可以使用 PHP 命名空间来实现模块模式。出于历史原因,我们将展示在 PHP 5.3 之前如何使用命名空间,但你应努力使用支持 PHP 命名空间的 PHP 版本。最佳选择始终是 PHP 的最新稳定版本。
一级命名空间
常见的方法是使用一级命名空间来标识您的公司。这将有助于避免与第三方库发生冲突。如果您使用 PSR-0,您将有一个真实的文件夹用于命名空间;如果您使用 PSR-4,则不需要。我们稍后会深入探讨这个问题。但首先,让我们看看 PHP 命名空间的约定。
PEAR 风格命名空间
在 PHP 5.3 之前,由于缺乏命名空间结构,使用了 PEAR 风格的命名空间。PEAR 是 PHP 扩展和应用仓库的缩写,在那些美好的日子里,它是一个可重用组件的仓库。它仍然活跃,但不是很方便,而且很少有人再使用它——尤其是在 Composer 和 Packagist 引入之后。作为可重用组件的来源,PEAR 需要一种避免类名冲突的方法,因此贡献者开始将命名空间前缀添加到类名中。还有一些项目使用这种命名空间形式(例如 PHPUnit 和 Zend 框架 1)。以下是一个 PEAR 风格命名空间的示例:
以下是一个 PEAR 风格命名空间的示例:

使用 PEAR 风格命名空间,Bill 实体的类名将变为 BuyIt_Billing_Domain_Model_Bill_Bill。然而,这有点难看,并且它没有遵循领域驱动设计的主要原则之一:每个类名都应该用通用语言来命名。因此,我们强烈反对使用它。
PSR-0 和 PSR-4 命名空间
当 PHP 5.3 引入时,命名空间进入了场景,以及其他一些重要特性。这是一个重大转变,一些最重要的框架合作者组成了 PHP-FIG,即 PHP 框架互操作性小组的缩写,试图标准化和统一框架和库创建的常见方面。该小组发布的第一个 PHP 标准建议(PSR)是一个自动加载标准,简而言之,它提出了一个类与 PHP 文件之间的一对一关系,使用命名空间。今天,PSR-4 —— 这是 PSR-0 的简化版本,仍然保持了类与物理 PHP 文件之间的关系 —— 是结构代码的首选和推荐方式。我们相信,这应该是用于在项目中实现模块的方式。
回顾上一节中显示的相同文件夹结构,让我们看看 PSR-0 会有什么变化。使用命名空间和 PSR-0,Bill 实体的类名将简单地成为 Bill,完全限定类名将是 BuyIt\Billing\Domain\Model\Bill\Bill。
如您所见,这使我们能够用通用语言命名域对象,这是组织和结构代码的首选方式。如果您使用 Composer(您应该这样做),您需要在您的 composer.json 文件中设置一些自动加载配置:
...
"autoload": {
"psr-0": {
"BuyIt\\": "src/BuyIt/"
}
},
"autoload-dev": {
"psr-0": {
"BuyIt": "tests/BuyIt/"
}
},
...
如果你还没有使用 PSR-4 或尚未从 PSR-0 迁移,我们强烈建议这样做。你可以去掉一级命名空间文件夹,你的代码结构将更好地匹配通用语言:

然而,为了避免与第三方库冲突,仍然建议在composer.json文件中添加一级命名空间:
...
"autoload": {
"psr-4": {
"BuyIt\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"BuyIt\\": "tests/"
}
},
...
如果你更喜欢使用一级命名空间但使用 PSR-4,需要做一些小的修改:

...
"autoload": {
"psr-4": {
"BuyIt\\": "src/BuyIt/"
}
},
"autoload-dev": {
"psr-4": {
"BuyIt\\": "tests/BuyIt/"
}
},
...
正如你可能在示例中注意到的,我们拆分了src和tests文件夹。这样做是为了优化 Composer 生成的自动加载文件,这将减少存储类映射所需的内存。它还将帮助你设置在生成单元测试代码覆盖率报告时的白名单和黑名单选项。如果你想了解更多关于 Composer 自动加载配置的信息,请查看文档。
那么,PHAR 文件怎么办?
它们也可以被使用,但我们不建议这样做。作为一个练习,列出使用 PHAR 文件来建模模块的优缺点列表。
边界上下文和应用程序
如果以一家名为BuyIt的虚构公司为例,该公司从事电子商务领域,为解决特定的领域区域,为每个不同的边界上下文创建不同的应用程序可能是有意义的。
如果一些不同的边界上下文是订单管理、支付管理、目录管理和库存管理,我们建议为每个一个创建一个应用程序:

每个应用程序都公开任何所需的交付机制。随着微服务趋势的兴起,越来越多的人构建最终向外界暴露 REST API 的边界上下文。然而,边界上下文不仅仅是 API。记住,API 只是许多交付机制之一;边界上下文还可以提供一个用于交互的 Web 界面。
两个边界上下文能否在同一个应用程序中?反过来呢?
最佳选择是一个子域,一个边界上下文,一个应用程序。如果我们有一个使用两个应用程序实现的边界上下文,维护和部署会变得有些棘手。如果一个应用程序实现了两个边界上下文,部署过程、运行测试的时间和合并问题可能会减慢开发速度。
注意,每个边界上下文名称代表我们电子商务领域中的一个有意义的概念,并且是以通用语言命名的:
-
目录用于存放所有与产品描述、产品组合等相关代码。
-
库存用于存放所有与产品库存管理相关的代码。
-
订单用于存放所有与订单处理系统相关的代码。它将包含负责处理订单的有限状态机。
-
Payments 用于存放所有与支付、账单和运单相关的代码。
在模块中组织代码
让我们进一步探讨其中一个边界上下文。以订单上下文为例,并检查结构细节。正如其名称所暗示的,这个边界上下文负责表示订单所经过的所有流程——从其创建到交付给购买者的过程。此外,它是一个独立的应用程序,因此包含源代码文件夹和测试文件夹。源代码文件夹包含使此边界上下文正常工作所需的所有代码:领域代码、基础设施代码和应用层。
以下图表应说明组织结构:

所有代码都以前缀为组织名称(在这种情况下为 BuyIt)的供应商命名空间命名,并包含两个子文件夹:Domain 包含所有领域代码,Infrastructure 包含基础设施层,从而将所有领域逻辑与基础设施层的细节隔离开来。遵循此结构,我们明确表示我们将使用六边形架构作为基础架构。以下是一个可用的替代结构示例:

上述结构风格使用一个额外的子文件夹来存储在领域模型中定义的服务。虽然这种组织方式可能是有意义的,但我们的偏好是不要使用它,因为这种分离代码的方式往往更关注于架构元素,而不是模型中的相关概念。我们相信这种风格很容易导致在领域模型之上出现某种服务层,这并不一定是一件坏事。记住,领域服务用于描述不属于实体或值对象的领域中的操作。因此,从现在起,我们将坚持使用之前的代码组织方式。
可以直接在 Domain/Model 子文件夹中放置代码。例如,可能习惯于在其中放置常见的接口和服务,如 DomainEventPublisher 或 DomainEventSubscriber。
如果我们必须对订单管理上下文进行建模,我们可能会有一个 Order 实体及其存储库和所有状态信息。因此,我们的第一次尝试可能是直接将这些元素放置在 Domain/Model 子文件夹中。乍一看,这似乎是最简单的方法:

设计指南
考虑一些基本规则和典型问题,在实现模块时要注意:
-
命名空间应使用通用语言命名。
-
不要根据模式或构建块(值对象、服务、实体等)来命名你的命名空间。
-
创建命名空间,使得其内部内容尽可能松散地与其他命名空间耦合。
-
以与你的代码相同的方式重构命名空间。移动它们、重命名它们、分组它们、提取它们,等等。
-
不要使用商业产品名称,因为它们可能会改变。坚持使用通用语言。
我们已经将订单和OrderLine实体、OrderLineWasAdded和OrderWasCreated事件以及OrderRepository放入同一个子文件夹Domain/Model中。这种结构可能很好,但这是因为我们仍然有一个简单的模型。那么Bill实体及其仓库呢?或者Waybill实体及其相应的仓库?让我们添加所有这些元素,看看它们如何适应实际的代码结构:

虽然这种代码组织风格可能很好,但从长远来看,它可能变得不切实际且难以维护。每次我们迭代并添加新功能时,模型都会变得更大,子文件夹将消耗更多的代码。我们需要以某种方式拆分代码,以便我们可以一眼看出模型的全貌。没有技术问题,只有领域问题。为了达到这个目标,我们可以使用通用语言来拆分模型,通过找到有助于我们在领域内逻辑地分组元素的有意义的概念。
为了做到这一点,我们可以尝试以下方法:

这样做,代码在概念上更加有序。正如埃里克·埃文斯在《蓝皮书》中指出的,模块是沟通的一种方式,因为它们为我们提供了关于领域模型内部工作方式的见解,同时帮助我们增加概念之间的内聚性并减少耦合性。如果我们看看之前的例子,我们可以看到概念Order和OrderLine之间有很强的关联,所以它们位于同一个模块中。另一方面,订单和运单,尽管共享相同的环境,但它们是不同的概念,所以它们位于不同的模块中。模块不仅仅是将模型中的相关概念分组的一种方式,也是表达模型设计部分的一种方式。
我们是否应该将仓库、工厂、领域事件和服务放在各自的子文件夹中?
实际上,它们可以被放入它们自己的子文件夹中,但强烈建议不要这样做。这样做,我们会混淆技术问题和领域问题——记住,模块的主要兴趣是分组来自领域模型的相关概念,并将它们与不相关的概念解耦。模块不是分离代码,而是分离有意义的概念。
基础设施层的模块
到目前为止,我们一直在讨论如何在领域层中结构和组织代码,但我们几乎没有提到基础设施层。由于我们正在使用六边形架构来反转领域层和基础设施层之间的依赖关系,我们需要一个可以放置所有在领域层中定义的接口实现的地方。回到计费上下文的例子,我们需要一个放置BillRepository、OrderRepository和WaybillRepository实现的地方。
很明显,它们应该放在基础设施文件夹中,但具体在哪里?假设我们决定使用 Doctrine ORM 来实现持久性层。我们如何将我们的存储库的 Doctrine 实现放入基础设施文件夹?让我们直接操作并看看效果:

我们可以保持现状,但正如我们在领域层看到的,这种结构和组织会很快腐烂,并在几个模型迭代后变得混乱。每次模型增长,可能需要更多的基础设施,我们最终会混合不同的技术问题,如持久性、消息传递、日志记录等。我们第一次尝试避免基础设施实现混乱的方法是为每个技术问题在边界上下文中定义一个模块:

这看起来要好得多,并且从长远来看,比我们的第一次尝试更容易维护。然而,我们的命名空间缺少某种与通用语言的关系。让我们考虑一种变化:

这要好得多。它与我们的领域模型组织相匹配,但位于基础设施层——而且似乎一切都更容易找到。如果你事先知道你将始终只有一个持久性机制,你可以坚持这种结构和组织。它相当简单且易于维护。
但当你必须与几种持久性机制打交道时怎么办?如今,拥有关系型持久性机制和一些共享内存持久性(如 Redis 或 Riak)或拥有某种本地内存实现以便能够测试代码是很常见的。让我们看看这如何与实际方法相匹配:

我们推荐上述方法。然而,所有存储库实现都生活在同一个模块中。当有这么多不同的技术时,这可能会显得有些奇怪。如果你对此感兴趣,你可以创建一个额外的模块来按其底层技术分组相关实现:

这种方法与单元测试组织类似。然而,有一些类、配置、模板等无法与领域模型匹配。这就是为什么你可能在基础设施模块内部有额外的模块,它们与特定技术相关。
你应该把 Doctrine 映射文件或 Twig 模板放在哪里?

如您所见,为了使 Doctrine 工作,我们需要一个 EntityManagerFactory 和所有映射文件。我们还可以包括任何其他作为基类的所需基础设施对象。因为它们与我们的领域模型没有直接关系,所以将这些资源放在不同的模块中会更好。同样的事情也发生在交付机制(API、Web、控制台命令等)上。实际上,您可以为每个交付机制使用不同的 PHP 框架或库:

在前面的示例中,我们使用了 Laravel 框架来提供 API 服务,使用 Symfony Console 组件作为命令行的入口点,使用 Silex 和 Slim 作为 Web 交付机制。关于用户界面,你应该将其放置在每个交付机制内部。然而,如果有可能在不同交付机制之间共享 UI,你可以在持久化或交付同一级别创建一个名为 UI 的模块。一般来说,我们的建议是努力按照框架指示的方式来组织代码。框架应该服从你,而不是反过来。
混合不同技术
在大型业务关键型应用中,混合使用几种技术是很常见的。例如,在读取密集型的 Web 应用中,通常会有某种去规范化数据源(如 Solr、Elasticsearch、Sphinx 等),它提供应用的所有读取操作,而传统的 RDBMS 如 MySQL 或 Postgres 主要负责处理所有写入操作。当这种情况发生时,通常会出现的一个问题是,我们是否可以让读取操作与搜索引擎一起进行,而写入操作与传统的 RDBMS 数据源一起进行。我们在这里的一般建议是,这类情况是 CQRS 的信号,因为我们需要独立扩展应用的读取和写入。所以,如果您能采用 CQRS,那可能是最佳选择。
但如果您由于任何原因不能采用 CQRS,则需要另一种方法。在这种情况下,使用来自四人帮的代理模式很有用。我们可以根据代理模式定义一个仓库的实现:
namespace BuyIt\Billing\Infrastructure\FullTextSearching\Elastica;
use BuyIt\Billing\Domain\Model\Order\OrderRepository;
use BuyIt\Billing\Infrastructure\Domain\Model\Order\Doctrine\
DoctrineOrderRepository;
class ElasticaOrderRepository implements OrderRepository
{
private $client;
private $baseOrderRepository;
public function __construct(
Client $client,
DoctrineOrderRepository $baseOrderRepository
) {
$this->client = $client;
$this->baseOrderRepository = $baseOrderRepository;
}
public function find($id)
{
return $this->baseOrderRepository->find($id);
}
public function findBy(array $criteria)
{
$search = new \Elastica\Search($this->client);
// ...
return $this->toOrder($search->search());
}
public function add($anOrder)
{
// First we attempt to add it to the Elastic index
$ordersIndex = $this->client->getIndex('orders');
$orderType = $ordersIndex->getType('order');
$orderType->addDocument(
new \ElasticaDocument(
$anOrder->id(),
$this->toArray($anOrder)
)
);
$ordersIndex->refresh();
// When it is done, we attempt to add it to the RDBMS store
$this->baseOrderRepository->add($anOrder);
}
}
本例提供了一个使用DoctrineOrderRepository和 Elastica 客户端的简单实现,Elastica 客户端是一个用于与 Elasticsearch 服务器交互的客户端。请注意,对于某些操作,我们使用 RDBMS 数据源,而对于其他操作,我们使用 Elastica 客户端。此外,请注意,添加操作由两部分组成。第一部分尝试将订单存储到 Elasticsearch 索引中,第二部分尝试将订单存储到关系型数据库中,并将操作委托给 Doctrine 实现。请记住,这只是一个示例和一种做法。它可能需要改进——例如,现在整个添加操作是同步的。我们可以将操作入队到某种消息中间件中,例如,以便将订单存储在 Elasticsearch 中。有很多可能性,具体取决于您的需求。
应用层中的模块
我们已经看到了领域和基础设施模块,现在让我们看看应用程序层。在领域驱动设计中,我们建议使用应用程序服务作为从客户端解耦领域模型及其交互所需必要知识的一种方式。正如你将在第十一章中看到的,应用程序,一个应用程序服务与其依赖项一起构建,使用 DTO 请求执行,并返回 DTO 响应。
它还可以使用输出依赖项来返回结果:

我们的建议是围绕应用程序服务创建模块。每个模块将包含其请求和响应。如果你使用数据转换器作为输出依赖项,就像处理 UI 一样,遵循基础设施方法。
总结
模块是我们应用中分组和分离概念的一种方式。模块的命名应遵循通用语言。我们不应忘记模块是传达高级概念的一种方式,这有助于我们保持耦合度低、内聚度高。我们已看到,通过使用前缀,即使在旧版本的 PHP 中,我们也能创建有意义的模块。如今,遵循 PSR-0 和 PSR-4 命名空间约定来构建模块变得很容易。
第八章:聚合(Aggregates)
聚合(Aggregates)可能是领域驱动设计(Domain-Driven Design)中最难构建的模块。它们难以理解,而且设计起来更加困难。但别担心;我们在这里帮助你。然而,在深入研究聚合之前,我们需要先了解一些关键概念:事务和并发策略。
简介
如果你曾与电子商务应用程序合作,那么你很可能遇到过与数据库中数据不一致相关的错误。例如,考虑一个总金额为$99.99 的购物订单,而这个金额并不等于订单中每行金额的总和,$89.99。那额外的$10 是从哪里来的?
或者,考虑一个销售电影票的网站。有一个电影院有 100 个可用座位,在成功的电影推广之后,每个人都登录网站等待购票。一旦开始销售,一切发生得很快,你不知怎么就卖出了 102 张票。你可能已经指定只有 100 个座位,但不知何故你超过了这个限制。
你甚至可能有过使用 JIRA 或 Redmine 等跟踪系统的经验。想想一个由开发者、质量保证(QAs)和产品负责人组成的团队。如果在规划会议期间每个人都对用户故事进行排序和移动,然后保存,会发生什么?最终的待办事项列表或冲刺优先级将是最后保存的团队成员的。
通常,当我们以非原子方式处理持久化机制时,会出现数据不一致。一个例子是当你向数据库发送三个查询时,其中一些成功而另一些失败。数据库的最终状态是不一致的。有时,你希望这三个查询全部成功或全部失败,这可以通过事务来解决。然而,请注意,正如你将在本章中看到的,并非所有的不一致性都可以通过事务来解决。实际上,有时其他数据不一致需要锁定或并发策略。这类工具可能会影响你的应用程序性能,因此请注意,这里存在权衡。
你可能认为这类数据不一致只发生在数据库中,但这并不正确。例如,如果我们使用文档型数据库,如 Elasticsearch,我们可能会在两个文档之间出现数据不一致。此外,大多数 NoSQL 持久化存储系统不支持 ACID 事务。这意味着你无法在一次操作中持久化或更新多个文档。因此,如果我们向 Elasticsearch 发送不同的请求,其中一个可能会失败,导致 Elasticsearch 中持久化的数据不一致。
保持数据一致性是一个挑战。不让基础设施问题渗透到领域模型中是一个更大的挑战。聚合(Aggregates)旨在帮助你解决这两个问题。
关键概念
持久性引擎——特别是数据库——有一些用于对抗数据不一致性的功能:ACID、约束、引用完整性、锁定、并发控制和事务。在处理聚合之前,让我们回顾这些概念。
这些概念中的大多数都可以在互联网上找到,对公众开放。我们想感谢 Oracle、PostgreSQL 和 Doctrine 团队,他们为他们的文档做了惊人的工作。他们仔细定义并解释了这些重要术语,我们不想重复造轮子,所以我们收集了一些这些官方解释与您分享。
ACID
如前所述,ACID代表原子性、一致性、隔离性和持久性。根据MySQL 术语表:
这些属性在数据库系统中都是可取的,并且都与事务的概念紧密相关。例如,MySQL InnoDB 引擎的事务功能遵循 ACID 原则。
事务是可以提交或回滚的工作的原子单元。当事务对数据库进行多项更改时,要么在事务提交时所有更改都成功,要么在事务回滚时所有更改都被撤销。
数据库在每次提交或回滚后,以及事务进行期间,始终保持一致状态。如果相关数据正在多个表中更新,查询将看到所有旧值或所有新值,而不是旧值和新值的混合。
在进行过程中,事务是隔离的,彼此之间不会相互干扰或看到对方未提交的数据。这种隔离是通过锁定机制实现的。经验丰富的用户可以在确信事务之间不会相互干扰的情况下,调整隔离级别,以牺牲较少的保护来换取更高的性能和并发性。
事务的结果是持久的:一旦提交操作成功,该事务所做的更改就安全免受电源故障、系统崩溃、竞态条件或其他许多非数据库应用程序易受其害的潜在危险的影响。持久性通常涉及写入磁盘存储,并在写入操作期间具有一定的冗余来防止电源故障或软件崩溃。
事务
事务是所有数据库系统的基本概念。事务的基本点是它将多个步骤捆绑成一个单一的全有或全无的操作。步骤之间的中间状态对其他并发事务不可见,如果发生某些故障阻止事务完成,则没有任何步骤会影响数据库。
例如,考虑一个包含各种客户账户余额以及分支总存款余额的银行数据库。假设我们想要记录从爱丽丝账户到鲍勃账户的 100.00 美元的付款。极度简化地,这个操作的 SQL 命令可能如下:
UPDATE accounts
SET balance = balance - 100.00
WHERE name = 'Alice';
UPDATE branches
SET balance = balance - 100.00
WHERE name = (SELECT branch_name FROM accounts WHERE name ='Alice');
UPDATE accounts
SET balance = balance + 100.00
WHERE name = 'Bob';
UPDATE branches
SET balance = balance + 100.00
WHERE name = (SELECT branch_name FROM accounts WHERE name ='Bob');
这些命令的细节在这里并不重要。重要的是,涉及几个单独的更新来完成这个相对简单的操作。我们的银行官员将希望得到保证,要么所有这些更新都发生,要么一个都不发生。如果系统故障导致鲍勃收到本应从爱丽丝账户扣除的 100.00 美元,那就绝对不行。如果爱丽丝在没有为鲍勃贷记的情况下被扣除,她也不会长期成为满意的客户。我们需要保证,如果在操作过程中出现问题,到目前为止执行的任何步骤都不会生效。将更新分组到事务中给我们这个保证。一个事务被称为原子性的:从其他事务的角度来看,它要么完全发生,要么根本不发生。
我们还希望有一个保证,一旦交易完成并被数据库系统确认,它确实已经被永久记录,即使在之后不久发生崩溃也不会丢失。例如,如果我们正在记录鲍勃的现金取款,我们不希望在他走出银行大门后,他的账户扣除发生崩溃,导致扣除消失。事务型数据库保证在事务报告完成之前,所有由事务做出的更新都记录在永久存储(即磁盘)中。
事务型数据库的另一个重要属性与原子更新的概念密切相关:当多个事务同时运行时,每个事务都不应该能够看到其他事务做出的不完整更改。例如,如果一个事务正在忙于汇总所有分支余额,它不应该包括爱丽丝分支的借记,但不包括鲍勃分支的贷记,反之亦然。因此,事务必须在数据库的永久影响以及它们发生时的可见性方面都是全有或全无。一个打开事务到目前为止做出的更新对其他事务是不可见的,直到事务完成,此时所有更新将同时可见。
例如,在 PostgreSQL 中,通过将事务的 SQL 命令用 BEGIN 和 COMMIT 命令包围来设置事务。因此,我们的银行交易实际上看起来可能如下:
BEGIN;
UPDATE accounts
SET balance = balance - 100.00
WHERE name = 'Alice';
-- etc etc
COMMIT;
如果在事务进行过程中我们决定不想提交(也许我们只是注意到爱丽丝的余额变成了负数),我们可以发出 ROLLBACK 命令而不是 COMMIT,我们到目前为止的所有更新将被取消。
PostgreSQL 实际上将每个 SQL 语句都视为在事务中执行。如果你没有发出BEGIN命令,那么每个单独的语句都有一个隐式的BEGIN和(如果成功)COMMIT包围它。由BEGIN和COMMIT包围的一组语句有时被称为事务块。
所有这些操作都在事务块内进行,因此对其他数据库会话不可见。当你提交事务块时,提交的操作作为一个单元对其他会话可见,而回滚的操作永远不会可见。
隔离级别
根据MySQL 术语表,事务隔离是:
数据库处理的基础之一。隔离是 ACID 缩写中的“I”。隔离级别是调整多个事务同时进行更改和执行查询时性能与可靠性、一致性和结果可重复性之间平衡的设置。
从最高的一致性和保护程度到最低,InnoDB 支持的隔离级别,例如,有:SERIALIZABLE、REPEATABLE READ、READ COMMITTED和READ UNCOMMITTED。
在 InnoDB 表中,许多用户可以将所有操作的默认隔离级别设置为REPEATABLE READ。专家用户可能会选择READ COMMITTED级别,因为他们通过 OLTP 处理推动可扩展性的边界,或者在数据仓库操作期间,其中轻微的不一致性不会影响大量数据的汇总结果。边缘级别(SERIALIZABLE和READ UNCOMMITTED)会改变处理行为,以至于它们很少被使用。
参照完整性
根据MySQL 术语表,参照完整性是:
保持数据始终处于一致格式的技术,是 ACID 哲学的一部分。特别是,通过使用外键约束来保持不同表中数据的一致性,这可以防止更改发生或自动将更改传播到所有相关表中。相关的机制包括唯一约束,它可以防止错误地插入重复值,以及NOT NULL约束,它可以防止错误地插入空值。
锁定
根据MySQL 术语表,锁定是:
保护事务免受其他事务查询或更改的数据查看或更改的系统。锁定策略必须在数据库操作的可靠性、一致性(ACID 哲学的原则)和良好并发性所需性能之间取得平衡。微调锁定策略通常涉及选择一个隔离级别,并确保所有数据库操作都适用于该隔离级别,且安全可靠。
并发
根据MySQL 术语表,并发性是:
多个操作(在数据库术语中称为事务)能够同时运行,而不相互干扰的能力。并发性也与性能有关,因为理想情况下,对多个同时进行的事务的保护应具有最小的性能开销,并使用高效的锁定机制。
悲观并发控制(PCC)
由克林顿·戈姆利和扎卡里·汤格合著的《Elasticsearch: The Definitive Guide》一书讨论了 PCC,说:
在关系型数据库中广泛使用的方法,它假设冲突更改很可能发生,因此阻止对资源的访问以防止冲突。一个典型的例子是在读取数据之前锁定一行,确保只有放置锁的线程能够更改该行中的数据。
在 Doctrine 中
根据Doctrine 2 ORM 文档中对锁定支持的解释:
Doctrine 2 本地支持悲观锁定和乐观锁定策略。这允许对应用程序中实体的锁定类型进行非常细粒度的控制。
根据Doctrine 2 ORM 文档中对悲观锁的解释:
Doctrine 2 在数据库级别支持悲观锁定。没有尝试在 Doctrine 内部实现悲观锁定,而是使用供应商特定的和 ANSI-SQL 命令来获取行级锁。每个 Doctrine 实体都可以成为悲观锁的一部分,无需特殊元数据即可使用此功能。
然而,为了使悲观锁定工作,您必须禁用数据库的自动提交模式,并在使用悲观锁的场景周围启动一个事务,使用显式事务界定。如果您尝试获取悲观锁而没有正在运行的事务,Doctrine 2 将抛出异常。
Doctrine 2 当前支持两种悲观锁定模式:
-
悲观写
Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE,锁定底层数据库行以进行并发读和写操作。 -
悲观读
Doctrine\DBAL\LockMode::PESSIMISTIC_READ,锁定其他尝试以写模式更新或锁定行的并发请求。
您可以在三种不同的场景中使用悲观锁:
-
使用
EntityManager#find($className, $id, \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE)或EntityManager#find($className, $id, \Doctrine\DBAL\LockMode::PESSIMISTIC_READ) -
使用
EntityManager#lock($entity, \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE)或EntityManager#lock($entity, \Doctrine\DBAL\LockMode::PESSIMISTIC_READ) -
使用
Query#setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE)或Query#setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_READ)
悲观并发控制
根据 维基百科:
乐观并发控制 (OCC) 是一种应用于事务系统(如关系数据库管理系统和软件事务内存)的并发控制方法。OCC 假设多个事务可以频繁完成而不会相互干扰。在运行过程中,事务使用数据资源而不锁定这些资源。在提交之前,每个事务都会验证是否有其他事务修改了它所读取的数据。如果检查发现冲突修改,提交事务将回滚并可以重新启动。乐观并发控制最初由 H.T. Kung 提出。
OCC 通常用于数据竞争较低的环境。当冲突很少时,事务可以在不管理锁和事务等待其他事务的锁释放的情况下完成,从而比其他并发控制方法具有更高的吞吐量。然而,如果数据资源竞争频繁,重复重启事务的成本会显著损害性能;通常认为在这些条件下,其他并发控制方法具有更好的性能。然而,基于锁定的“悲观”方法也可能导致性能不佳,因为即使避免了死锁,锁定也会极大地限制有效并发。
使用 Elasticsearch
根据 Elasticsearch: The Definitive Guide,当 Elasticsearch 使用 OCC 时:
此方法假设冲突不太可能发生,不会阻止操作尝试。然而,如果在读取和写入之间底层数据已被修改,更新将失败。然后应用程序需要决定如何解决冲突。例如,它可以重新尝试更新,使用新鲜的数据,或者它可以向用户报告这种情况。
Elasticsearch 是分布式的。当文档被创建、更新或删除时,文档的新版本必须复制到集群中的其他节点。Elasticsearch 也是异步和并发的,这意味着这些复制请求是并行发送的,并且可能以错误的顺序到达目的地。Elasticsearch 需要一种确保旧版本的文档永远不会覆盖新版本的方法。
每个文档都有一个 _ 版本号,每当文档被更改时都会递增。Elasticsearch 使用这个 _ 版本号来确保更改按正确的顺序应用。如果一个旧版本的文档在新的版本之后到达,它可以直接被忽略。
我们可以利用 _ 版本号来确保我们的应用程序所做的冲突更改不会导致数据丢失。我们通过指定我们希望更改的文档的版本号来实现这一点。如果该版本已不再是最新的,我们的请求将失败。
让我们创建一个新的博客文章:
PUT /website/blog/1/_create
{
"title": "My first blog entry",
"text": "Just trying this out..."
}
响应体告诉我们这个新创建的文档的 _ 版本号是 1。现在想象一下,如果我们想编辑这个文档:我们将它的数据加载到一个网页表单中,进行更改,然后保存新的版本。
首先,我们检索文档:
GET /website/blog/1
响应体包含相同的 _ 版本号 1:
{
"index": "website",
"type": "blog",
"id": "1",
"version": 1,
"found": true,
"_source": {
"title": "My first blog entry",
"text": "Just trying this out..."
}
}
现在,当我们尝试通过重新索引文档来保存我们的更改时,我们指定了更改应该应用到的版本。我们希望这次更新只在我们索引中此文档的当前 _ 版本是版本 1 的情况下成功:
PUT /website/blog/1?version=1
{
"title": "My first blog entry",
"text": "Starting to get the hang of this..."
}
这个请求成功了,响应体告诉我们 _ 版本号已经增加到 2:
{
"index": "website",
"type": "blog",
"id": "1",
"version": 2,
"created": false
}
然而,如果我们重新运行相同的索引请求,仍然指定 version=1,Elasticsearch 将会响应一个 409 冲突 HTTP 响应代码,以及如下所示的响应体:
{
"error": {
"root_cause": [{
"type": "version_conflict_engine_exception",
"reason":
"[blog][1]: version conflict,current[2],provided [1]",
"index": "website",
"shard": "3"
}],
"type": "version_conflict_engine_exception" ,
"reason":
"[blog][1]:version conflict,current [2],provided[1]",
"index": "website",
"shard": "3"
},
"status": 409
}
这告诉我们,在 Elasticsearch 中的文档当前 _ 版本号是 2,但我们指定了我们在更新版本 1。
我们现在所做的一切都取决于我们的应用程序需求。我们可以告诉用户,其他人已经对文档进行了更改,在尝试再次保存之前需要审查这些更改。或者,就像之前 stock_count 小部件的情况一样,我们可以检索最新的文档并尝试重新应用更改。
所有更新或删除文档的 API 都接受一个版本参数,这允许你仅在你代码的合理部分应用乐观并发控制。
使用 Doctrine
根据 Doctrine 2 ORM 文档中关于乐观锁的说明:
数据库事务在单个请求期间的并发控制中是可行的。然而,数据库事务不应跨越请求,即所谓的“用户思考时间”。因此,跨越多个请求的长时间运行的“业务事务”需要涉及多个数据库事务。因此,仅靠数据库事务本身已无法在如此长时间运行的业务事务中控制并发。并发控制变成了应用程序自身的部分责任。
Doctrine 通过版本字段集成了对自动乐观锁的支持。在这种方法中,任何在长时间运行的业务事务期间应防止并发修改的实体都会获得一个版本字段,该字段可以是简单的数字(映射类型:integer)或时间戳(映射类型:datetime)。当在长时间对话结束时持久化对这种实体的更改时,会将实体的版本与数据库中的版本进行比较,如果它们不匹配,则会抛出 OptimisticLockException 异常,表明实体已被其他人修改。
你可以在实体中指定一个版本字段,如下所示。在这个例子中,我们将使用整数:
class User
{
// ...
/** @Version @Column(type="integer") */
private $version;
// ...
}
当在 EntityManager#flush() 过程中遇到版本冲突时,会抛出 OptimisticLockException 异常,并回滚(或标记为回滚)活动事务。这个异常可以被捕获和处理。对 OptimisticLockException 的潜在响应包括向用户展示冲突,或者在新的事务中刷新或重新加载对象,然后重试事务。
随着 PHP 推崇无共享架构,从显示更新表单到实际修改实体之间的时间,在最坏的情况下可能长达你的应用程序会话超时时间。如果在那个时间段内实体发生了变化,你希望在检索实体时直接知道你会遇到乐观锁异常:
你可以在请求期间验证实体的版本,无论是调用 EntityManager#find():
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\OptimisticLockException;
$theEntityId = 1;
$expectedVersion = 184;
try{
$entity = $em->find(
'User',
$theEntityId,
LockMode::OPTIMISTIC,
$expectedVersion
);
// do the work
$em->flush();
} catch (OptimisticLockException $e){
echo
'Sorry, someone has already changed this entity.' .
'Please apply the changes again!';
}
或者,你可以使用 EntityManager#lock() 来找出:
use DoctrineDBALLockMode;
use DoctrineORMOptimisticLockException;
$theEntityId = 1;
$expectedVersion = 184;
$entity = $em->find('User', $theEntityId);
try {
// assert version em−>lock(entity, LockMode::OPTIMISTIC,
$expectedVersion);
} catch (OptimisticLockException $e){
echo
'Sorry, someone has already changed this entity.' .
'Please apply the changes again!';
}
根据 Doctrine 2 ORM 文档的重要实现说明:
如果你比较了错误的版本,很容易出错。比如说,Alice 和 Bob 正在编辑一个假设的博客文章:
-
Alice 在乐观锁版本 1 (
GET请求) 时阅读了标题为 "Foo" 的博客文章 -
Bob 在乐观锁版本 1 (
GET请求) 时阅读了标题为 "Foo" 的博客文章 -
Bob 将标题更新为"Bar",将乐观锁定版本升级到 2(表单的
POST请求) -
Alice 将标题更新为"Baz",...(表单的
POST请求)
在此场景的最后阶段,在 Alice 应用标题之前,必须再次从数据库中读取博客文章。此时,你将想要检查博客文章是否仍然是版本 1(在这个场景中它不是)。
正确使用乐观锁定,你必须将版本作为额外的隐藏字段添加(或者为了更安全地将其放入SESSION)。否则,你无法验证版本是否仍然是 Alice 执行博客文章GET请求时从数据库中最初读取的那个。如果发生这种情况,你可能会看到你想要通过乐观锁定防止丢失的更新。
查看示例代码,表单(GET 请求):
$post = $em->find('BlogPost', 123456);
echo '<input type="hidden" name="id" value="' .
$post->getId() . '"/>';
echo '<input type="hidden" name="version" value="' .
$post->getCurrentVersion() . '" />';
以及更改标题的动作(POST 请求):
$postId = (int) $_GET['id'];
$postVersion = (int) $_GET['version'];
$post = $em->find(
'BlogPost',
$postId,
DoctrineDBALLockMode::OPTIMISTIC,
$postVersion
);
哇——这真是太多信息要吸收了。然而,如果你不完全理解一切,请不要担心。你与聚合和领域驱动设计工作得越多,你将越会遇到在设计应用程序时必须考虑事务性的时刻。
总结来说,如果你想保持数据的一致性,请使用事务。然而,要注意不要过度使用事务或锁定策略,因为这些可能会减慢你的应用程序速度或使其无法使用。如果你想拥有一个真正快速的应用程序,乐观并发可以帮助你。最后但同样重要的是,某些数据最终可能是一致的。这意味着我们允许我们的数据在特定的时间窗口内不一致。在这段时间内,一些不一致是可以接受的。最终,异步进程将执行最终任务以消除这种不一致。
什么是聚合?
聚合是包含其他实体和值对象以帮助保持数据一致的实体。从 Vaughn Vernon 的实现领域驱动设计:
聚合是精心设计的用于聚集实体和值对象的一致性边界。
另一本你绝对应该购买并阅读的精彩书籍是 Pramod J. Sadalage 和 Martin Fowler 合著的NoSQL Distilled: A Brief Guide to the Emerging World of Polyglot Persistence。这本书说:
在领域驱动设计中,聚合是我们希望将其视为单一实体的相关对象的集合。特别是,它是数据操作和一致性管理的一个单元。通常,我们喜欢使用原子操作更新聚合,并以聚合的形式与我们的数据存储进行通信。
马丁·福勒怎么说...
来自martinfowler.com/bliki/DDD_Aggregate.html:
集合是领域驱动设计中的一个模式。DDD 集合是一组可以被视为单一单元的领域对象。一个例子可能是一个订单及其行项目,这些将是单独的物体,但将订单(及其行项目)作为一个单一集合来处理是有用的。
一个集合将有一个组件对象作为集合根。来自集合外部的任何引用都应该只指向集合根。因此,根可以确保整个集合的完整性。
集合是您请求加载或保存整个集合的数据存储传输的基本元素。事务不应跨越集合边界。
DDD 集合有时会与集合类(列表、映射等)混淆。DDD 集合是领域概念(订单、诊所访问、播放列表),而集合是通用的。一个集合通常会包含多个集合,以及简单的字段。术语“集合”是一个常见的术语,在不同的上下文中使用(例如:UML),在这种情况下,它并不指代与 DDD 集合相同的概念。
维基百科上是这样说的...
来自 en.wikipedia.org/wiki/Domain-driven_design#Building_blocks_of_DDD:
集合:一组通过根实体(也称为集合根)绑定在一起的物体。集合根通过禁止外部对象持有其成员的引用来保证集合内更改的一致性。
例如:当你开车时,你不必担心如何让车轮向前移动,让发动机通过火花和燃料燃烧等;你只是在开车。在这个背景下,汽车是几个其他物体的集合,并作为所有其他系统的集合根。
为什么需要集合?
热心的读者可能会想知道所有这些与集合和集合设计有什么关系。实际上,这是一个很好的问题。它们之间有直接的关系,所以让我们来探讨一下。关系模型使用表来存储数据。这些表由行组成,其中每一行通常代表应用程序感兴趣的概念的一个实例。此外,每一行可以指向同一数据库中其他表的行,这种关系的一致性可以通过使用引用完整性来保持。这个模型是好的;然而,它缺少一个非常基本的词:对象这个词。
事实上,当我们谈论关系模型时,我们实际上是在谈论表、行以及行之间的关系。而当我们谈论面向对象模型时,我们主要是在谈论对象的组合。所以每次我们从关系型数据库中检索数据——一组行——时,我们都会运行一个转换过程,负责构建一个我们可以操作的内存表示。同样的情况也适用于相反的方向。每次我们需要在数据库中存储一个对象时,我们都应该运行另一个转换过程,将该对象转换为一个特定的行集或表。这种从对象到行或表的转换意味着你可能需要对数据库运行不同的查询。因此,不使用任何特定的工具,如事务,不可能保证数据的一致性。这个问题就是所谓的阻抗不匹配。
阻抗不匹配
对象关系阻抗不匹配是一组概念和技术困难,当使用面向对象编程语言或风格的程序编写时,尤其是在对象或类定义直接映射到数据库表或关系模式时,经常会遇到这些问题。
提取自 维基百科
阻抗不匹配不是一个容易解决的问题,所以我们强烈建议不要自己尝试解决它。这将是一项巨大的任务,而且根本不值得付出努力。幸运的是,有一些库可以处理这个转换过程。它们通常被称为对象关系映射器(我们在前面的章节中讨论过),它们的主要关注点是简化从关系模型到面向对象模型以及相反方向的转换过程。
这是一个也会影响 NoSQL 持久化引擎,而不仅仅是数据库的问题。大多数 NoSQL 引擎使用文档。实体被转换为一个文档表示,如 JSON、XML、二进制等,然后进行持久化。与 RDBMS 数据库的主要区别在于,如果一个主实体(如Order)有其他相关实体(如OrderLines),你可以更容易地设计一个包含所有信息的单一 JSON 文档。采用这种方法,通过向你的 NoSQL 引擎发送一个单一请求,你不需要事务。
然而,如果你使用 NoSQL 或 RDBMS 来检索和持久化你的实体,你需要一个或多个查询。为了确保数据一致性,这些查询或请求需要作为一个单一操作执行。作为一个单一操作执行可以保证数据的一致性。
一致性意味着什么?这意味着所有持久化到我们数据库中的数据都必须符合所有业务规则,也称为不变量。一个业务不变量的例子可能是 GitHub 上,一个用户能够拥有无限数量的公共仓库但没有私有仓库。然而,如果这个用户每月支付 12 美元,那么他们可以拥有多达 10 个私有仓库。
关系数据库提供了三个主要工具来帮助我们实现数据一致性:* 引用完整性:外键、可空性检查等。* 事务:将多个查询作为一个单一操作运行。事务的问题与你的代码仓库中的分支和合并相同。保持分支有性能成本(内存、CPU、存储、索引等)。如果太多人(并发)正在接触相同的数据,将发生冲突,事务提交将失败。* 锁定:阻塞行或表。围绕相同表和行的其他查询必须等待锁定被移除。锁定对你的应用程序性能有负面影响。
假设我们有一个电子商务应用程序,我们希望将其扩展到其他国家地区,并且假设发布进展顺利,销售额增加。发布的一个明显副作用是数据库应该能够处理额外的负载增加。如前所述,有两种扩展方法:向上或向外。
扩展意味着我们改进我们已有的硬件基础设施(例如:更好的 CPU、更多的内存、更好的硬盘)。扩展意味着添加更多的机器,这些机器将组织成集群以执行特定的工作。在这种情况下,我们可能有一个数据库集群。
但关系数据库并不是为了横向扩展而设计的,因为我们无法配置它们将一组行保存到一台机器上,而另一组行保存到另一台机器上。关系数据库很容易向上扩展,但关系模型无法横向扩展。
在 NoSQL 世界中,数据一致性要困难一些:事务和引用完整性通常不支持,而锁定是支持的,但通常不鼓励。
NoSQL 数据库不会受到阻抗不匹配的严重影响。它们与聚合设计完美匹配,因为它们使我们能够轻松地原子性地存储和检索单个单元。例如,当使用像 Redis 这样的键值存储时,一个聚合可以被序列化并存储在特定的键上。在一个面向文档的存储,如 Elasticsearch 上,一个聚合会被序列化为 JSON 并作为文档持久化。正如之前提到的,问题出现在必须同时更新多个文档时。
因此,当持久化具有单一表示(一个文档,因此不需要多个查询)的任何对象时,很容易将这些单一单元分布到多个机器上,这些机器被称为节点,构成了一个 NoSQL 数据库集群。众所周知,这些数据库易于分布,这意味着数据库的风格易于横向扩展。
一点历史
大约在 21 世纪初,像亚马逊和谷歌这样的公司迅速增长。为了巩固他们的增长,他们使用了聚类技术:他们不仅拥有更好的服务器,而且依赖更多的服务器协同工作。
在这样的场景中,决定如何存储你的数据是关键。如果你将一个实体分散到多个服务器,多个集群节点中,控制事务所需的工作量就很高。如果你想要获取一个实体,也是如此。所以,如果你能以某种方式设计你的实体,使其在集群的节点中持久化,那么事情就会容易得多。这就是为什么聚合设计如此重要的原因之一。
如果你想了解关于聚合设计在领域驱动设计之外的更多历史,请查看《NoSQL 精粹:多语言持久性新兴世界的简要指南》.
聚合的解剖
聚合是一个可能包含其他实体和值对象的实体。父实体被称为根实体。
一个没有子实体或值对象的单一实体本身也是一个聚合。这就是为什么在某些书中,使用聚合这个词而不是实体这个词。当我们在这里使用它们时,实体和聚合意味着同一件事。
聚合的主要目标是保持你的领域模型一致。聚合集中了大部分业务规则。聚合在你的持久化机制中原子性地持久化。无论根实体内部有多少子实体和值对象,它们都将作为一个单一单元原子性地持久化。让我们看看一个例子。
考虑一个电子商务应用程序、网站等。用户可以下订单,订单包含多个行,定义了购买的产品、价格、数量和行总金额。订单也有一个总金额,它是所有行金额的总和。
如果你更新了行金额但没有更新订单金额会发生什么?数据不一致。为了解决这个问题,对聚合内任何实体或值对象的任何修改都通过根实体执行。我们合作过的大多数 PHP 开发者更习惯于先构建对象,然后从客户端代码中处理它们的关系,而不是将业务逻辑推入实体中:
$order = ...
$orderLine = new OrderLine(
'Domain-Driven Design in PHP', 24.99
);
$order->addOrderLine($orderLine);
如前一个代码示例所示,新手甚至普通开发者通常会先构建子对象,然后使用设置器将它们与父对象相关联。考虑以下方法:
$order = ...
$orderLine = $order->addOrderLine(
'Domain-Driven Design in PHP', 24.99
);
或者,考虑这种方法:
$order = ...
$order->addOrderLine(
'Domain-Driven Design in PHP', 24.99
);
这些方法非常有趣,因为它们遵循两个软件设计原则:告诉而非询问(Tell-Don't-Ask)和迪米特法则(Law of Demeter)。
根据 Martin Fowler:
告诉而非询问(Tell-Don't-Ask)是一个原则,它帮助人们记住面向对象是关于将数据与其操作数据的功能捆绑在一起。它提醒我们,而不是询问一个对象数据并对其数据进行操作,我们应该告诉一个对象要做什么。这鼓励将行为移动到对象中,以与数据一起。
根据 Wikipedia:
迪米特法则(LoD)或最小知识原则是软件开发的设计指南,尤其是面向对象程序的开发。在其一般形式中,LoD 是松耦合的一个特例...并且可以用以下方式简洁地总结:
-
每个单元应该只对其他单元有有限的知识:只有与当前单元“紧密”相关的单元。
-
每个单元应该只与其朋友交流;不要和陌生人说话。
-
只和你的直接朋友交流。
基本概念是,给定的对象应该尽可能少地假设其他任何东西的结构或属性(包括其子组件),这符合“信息隐藏”的原则。
让我们继续以订单为例。你已经学会了如何通过根实体运行操作。现在让我们更新订单中某行的产品数量。这增加了数量、行总额和订单总额。太棒了!现在,是时候将更改后的订单持久化了。
如果你正在使用 MySQL,你可以想象我们需要两个 UPDATE 语句:一个用于订单表,另一个用于 order_lines 表。如果这两个查询不在一个事务中执行,可能会发生什么?
假设更新行订单的 UPDATE 语句正常工作。然而,由于网络连接问题,对订单总额的 UPDATE 失败。在这种情况下,你会在你的领域模型中遇到数据不一致。事务可以帮助你保持这种一致性。
如果你正在使用 Elasticsearch,情况略有不同。你可以使用一个包含内部订单行的 JSON 文档来映射订单,因此只需要一个请求。然而,如果你决定用一个 JSON 文档来映射订单,并为每个订单行使用另一个 JSON 文档,那么你将遇到麻烦,因为 Elasticsearch 不支持事务。哎呀!
聚合是通过其自己的第十章,存储库来检索和持久化的。如果两个实体不属于同一个聚合,它们各自都会有自己的存储库。如果存在真实业务不变量,并且两个实体属于同一个聚合,你将只有一个存储库。这个存储库将是根实体的存储库。
聚合的缺点是什么?处理事务时的问题在于可能出现性能问题和操作错误。我们将在不久的将来深入探讨这个问题。
聚合设计规则
在设计聚合时,有一些规则和考虑事项需要遵循,以便获得所有好处并最大限度地减少负面影响。如果你现在不理解一切,请不要担心;作为一个例子,我们将向你展示一个小型应用程序,我们将在这个应用程序中引用我们向你介绍的规则。
基于业务真实不变量设计聚合
首先,什么是不变量?不变量是在代码执行期间必须始终为真且一致的规则。例如,栈是一个后进先出(LIFO)的数据结构,我们可以向其中推送项目,并从中弹出项目。我们还可以询问栈中有多少个项目;这就是所谓的栈的大小。考虑一个不使用任何特定 PHP 数组函数(如 array_pop)的纯 PHP 实现:
class Stack
{
private $data;
public function __construct()
{
$this->data = [];
}
public function push($value)
{
$this->data[] = $value;
}
public function size()
{
$size = 0;
for ($i = 0; $i < count($this->data); $i++) {
$size++;
}
return $size;
}
/**
* @return mixed
*/
public function pop()
{
$topIndex = $this->size() - 1;
$top = $this->data[$topIndex];
unset($this->data[$topIndex]);
return $top;
}
}
考虑之前的 size 方法实现。它远非完美,但它是可行的。然而,如上代码所示,它是一个 CPU 密集型且成本高昂的调用。幸运的是,有一个选项可以改进这个方法,通过引入一个私有属性来跟踪内部数组中的元素数量:
class Stack
{
private $data;
private $size;
public function __construct()
{
$this->data = [];
$this->size = 0;
}
public function push($value)
{
$this->data[] = $value;
$this->size++;
}
public function size()
{
return $this->size;
}
/**
* @return mixed
*/
public function pop()
{
$topIndex = $this->size--;
$top = $this->data[$topIndex];
unset($this->data[$topIndex]);
return $top;
}
}
通过这些更改,size 方法现在是一个快速操作,因为它只是返回 size 字段的值。为了实现这一点,我们引入了一个新的整数属性,称为 size。当创建一个新的 Stack 时,size 的值为 0,栈中没有元素。当我们使用 push 方法向栈中添加新元素时,我们也会增加 size 字段的值。同样,当我们使用 pop 方法从栈中移除值时,我们会减少 size 的值。
通过增加和减少 size 的值,我们使其与 Stack 中实际元素的数量保持一致。在调用 Stack 类中的任何公共方法之前和之后,size 值都是一致的。因此,size 值始终等于 Stack 中的元素数量。这是一个不变量!我们可以将其写成 $this->size === count($this->data)。
真实的业务不变性是一个必须始终为真并且在聚合体中事务一致的业务规则。通过事务一致,我们指的是更新聚合体必须是一个原子操作。聚合体内部包含的所有数据都必须原子性地持久化。如果我们不遵循这个规则,我们可能会持久化表示非有效聚合体的数据。
根据Vaughn Vernon的说法:
一个设计得当的聚合体是可以按照业务需求进行修改的,其不变性在单个事务中保持完全一致。而一个设计得当的边界上下文在所有情况下每次事务只修改一个聚合体实例。更重要的是,如果不应用事务分析,我们无法正确地推理聚合体的设计。
如引言中所述,在一个电子商务应用中,订单的金额必须与该订单中每行金额的总和相匹配。这是一个不变性,或业务规则。我们必须在同一个事务中将Order及其OrderLines持久化到数据库中。这迫使我们让Order和OrderLine成为同一个聚合体的一部分,其中Order是聚合根。因为Order是根,所以所有与OrderLines相关的操作都必须通过Order进行。因此,不再在Order之外实例化OrderLine对象,然后使用 setter 方法将OrderLines添加到Order中。相反,我们必须使用Order上的工厂方法。
采用这种方法,我们有一个单一的入口点来对聚合体执行操作:Order。这意味着没有机会调用一个方法来违反这样的规则。每次你通过Order添加或更新一个OrderLine时,Order的金额都会在内部重新计算。让所有操作都通过根进行有助于我们保持聚合体的一致性。这样,就更加难以破坏任何不变性。
小聚合体与大型聚合体
在我们参与的大多数网站和项目中,几乎 95%的聚合体都是由一个单一的根实体和一些值对象组成的。不需要其他实体存在于同一个聚合体中。因此,在大多数情况下,没有真正的业务不变性需要保持一致。
在处理那些不一定使两个实体成为同一个聚合体(其中一个为根实体)的 has-a/has-many 关系时,要小心。正如我们将看到的,这些关系可以通过引用实体标识来处理。
如介绍中所述,聚合(Aggregate)是一个事务边界。边界越小,在提交多个并发事务时发生冲突的机会就越少。在设计聚合时,你应该努力使它们尽可能小。如果没有真正的不变量来保护,这意味着所有单个实体(Entity)本身就是一个聚合。这很好,因为这是实现最佳性能的最佳场景。为什么?因为锁定问题和事务失败问题都得到了最小化。
如果你决定采用大的聚合,保持数据一致性可能更容易,但可能不太实用。当具有大聚合的应用程序在生产环境中运行时,当多个用户执行操作时,它们开始遇到问题。当使用乐观并发时,主要问题是事务失败。当使用锁定时,问题是速度慢和超时。
让我们考虑一些极端的例子。当使用乐观并发(optimistic concurrency)时,想象一下整个领域(Domain)被版本化了,并且对任何实体的任何操作都会为整个领域创建一个新的版本。在这种情况下,如果有两个用户在完全不同的实体上执行不同的操作,第二个请求会因为版本不同而经历事务失败。另一方面,当使用悲观并发(pessimistic concurrency)时,想象一下在每个操作上锁定数据库的场景。这将阻止所有用户直到锁被释放。这意味着许多请求会等待,并且可能在某个时刻超时。这两个例子都保持了数据的一致性,但应用程序不能被多个用户使用。
最后但同样重要的是,当设计大聚合时,因为它们可能持有实体的集合,所以考虑在内存中加载此类集合的性能影响是很重要的。即使使用像 Doctrine 这样的 ORM(对象关系映射),它可以延迟加载集合(仅在需要时加载集合),如果集合太大,它可能无法适应内存。
通过身份引用其他实体
当两个实体不形成聚合但相关时,最佳选项是通过使用身份(Identities)使实体相互引用。身份已经在第四章,实体中解释过了。
考虑一个用户和他们的订单,并假设我们还没有找到任何真正的不变量。用户和订单不会是同一个聚合的一部分。如果你需要知道哪个用户拥有特定的订单,你可能会询问订单它的UserId是什么。UserId是一个值对象(Value Object),它持有用户的身份。我们通过使用它的存储库,即UserRepository来获取整个用户。这段代码通常位于应用程序服务(Application Service)中。
作为一般解释,每个聚合都有自己的仓储。如果你已经获取了一个特定的聚合,并且需要获取另一个相关的聚合,你将在你的应用程序服务或领域服务中这样做。应用程序服务将依赖于仓储来获取所需的聚合。
从一个聚合跳转到另一个聚合通常被称为遍历或导航你的领域。使用对象关系映射(ORMs),通过映射你实体之间所有的关系来做这件事很容易。然而,这也非常危险,因为你很容易在特定的功能中运行无数个查询。作为规则,你不应该这样做。不要映射你实体之间所有的关系,因为你可以。相反,如果你的 ORM 中两个实体构成一个聚合,只映射聚合内部实体之间的关系。如果不是这种情况,你将使用仓储来获取引用的聚合。
每个事务和请求修改一个聚合
考虑以下场景:你发起一个请求,它进入你的控制器,并打算更新两个不同的聚合。每个聚合在其内部保持数据的一致性。然而,如果请求在第一个聚合更新之后顺利执行,但突然停止(服务器重启、重新加载、内存不足等),而第二个聚合没有更新,这会是一个数据一致性的问题吗?可能是的。让我们考虑一些解决方案。
来自 Vaughn Vernon 的实现领域驱动设计:
在一个设计良好的边界上下文中,在所有情况下每个事务只修改一个聚合实例。更重要的是,没有应用事务分析,我们无法正确地推理聚合设计。将修改限制为每个事务一个聚合实例可能听起来过于严格。然而,这是一个经验法则,在大多数情况下应该是目标。它解决了使用聚合的真正原因。
如果在一个单独的请求中,你需要更新两个聚合,那么这两者可能实际上是一个单一的聚合,并且它们需要在同一个事务中同时更新。如果不是这样,你可以将整个请求包裹在一个事务中,但我们不推荐这样做,因为这样做会涉及性能问题和事务错误。
如果不同聚合上的更新不需要包裹在一个事务中,这意味着我们可以假设一次更新和另一次更新之间存在一些延迟。在这种情况下,一个更领域驱动的设计方法是使用领域事件。这样做时,第一个聚合更新将触发一个领域事件。该事件将与聚合更新保存在同一个事务中,然后发布到我们的消息队列。稍后,一个工作进程将从队列中取出事件并执行第二个聚合的更新。这种方法推动最终一致性,减少了事务边界的规模,提高了性能,并减少了事务错误。
示例应用服务:用户和愿望
现在你已经知道了聚合设计的基本规则。
了解聚合的最佳方式是通过查看代码。所以让我们考虑一个网络应用程序的场景,用户可以提出愿望,如果发生某些事情,这些愿望将被实现,类似于遗嘱。例如,我想要给我妻子发一封邮件,解释如果我发生可怕的事故,应该如何处理我的 GitHub 账户,或者我可能想要发一封邮件告诉她我有多么爱她。检查我是否仍然活着的方法是回答平台发送给我的邮件。(如果你想了解更多关于这个应用程序的信息,你可以访问我们的GitHub账户。因此,我们有用户和他们的愿望。让我们考虑一个用例:“作为一个User,我想提出一个愿望。”我们该如何建模呢?在设计和聚合时使用良好的实践,让我们尝试推动小型聚合。在这种情况下,这意味着使用两个不同的聚合,每个聚合包含一个实体,User和Wish。对于它们之间的关系,我们应该使用一个标识符,例如UserId。
无不变性,两个聚合
我们将在接下来的章节中讨论应用服务,但就目前而言,让我们检查实现愿望的不同方法。对于初学者来说,第一个方法可能类似于以下内容:
class MakeWishService
{
private $wishRepository;
public function __construct(WishRepository $wishRepository)
{
$this->wishRepository = $wishRepository;
}
public function execute(MakeWishRequest $request)
{
$userId = $request->userId();
$address = $request->address();
$content = $request->content();
$wish = new Wish(
$this->wishRepository->nextIdentity(),
new UserId($userId),
$address,
$content
);
$this->wishRepository->add($wish);
}
}
这段代码可能允许实现最佳性能。你几乎可以看到幕后发生的INSERT语句;对于此类用例,所需的最小操作数是一个,这是好的。根据业务需求,我们可以创建尽可能多的愿望,这也是好的。
然而,可能存在一个潜在问题:我们可以为可能不在域中存在的用户创建愿望。无论我们使用什么技术来持久化聚合,这都是一个问题。即使我们使用内存实现,我们也可以创建一个没有对应用户的愿望。
这是错误的企业逻辑。当然,这可以通过在数据库中使用外键来修复,从wish (user_id)到user(id),但如果我们不使用带有外键的数据库呢?如果它是一个如 Redis 或 Elasticsearch 这样的 NoSQL 数据库呢?
如果我们想要修复这个问题,使得相同的代码可以在不同的基础设施中正常工作,我们需要检查用户是否存在。最简单的方法可能是在同一个应用服务中:
class MakeWishService
{
// ...
public function execute(MakeWishRequest $request)
{
$userId = $request->userId();
$address = $request->address();
$content = $request->content();
$user = $this->userRepository->ofId(new UserId($userId));
if (null === $user) {
throw new UserDoesNotExistException();
}
$wish = new Wish(
$this->wishRepository->nextIdentity(),
$user->id(),
$address,
$content
);
$this->wishRepository->add($wish);
}
}
这可能可行,但在应用服务中进行检查时存在一个问题:这个检查在委托链中位置较高。如果任何其他代码片段(如域服务或另一个实体)想要为不存在的用户创建一个Wish,它可以做到。看看下面的代码:
// Somewhere in a Domain Service or Entity
$nonExistingUserId = new UserId('non-existing-user-id');
$wish = new Wish(
$this->wishRepository->nextIdentity(),
$nonExistingUserId,
$address,
$content
);
如果你已经阅读了第九章工厂,那么你已经有了解决方案。工厂帮助我们保持业务不变量,这正是我们在这里需要的。
有一个隐含的不变量表示,我们不允许在没有有效用户的情况下做出愿望。让我们看看工厂如何帮助我们:
abstract class WishService
{
protected $userRepository;
protected $wishRepository;
public function __construct(
UserRepository $userRepository,
WishRepository $wishRepository
) {
$this->userRepository = $userRepository;
$this->wishRepository = $wishRepository;
}
protected function findUserOrFail($userId)
{
$user = $this->userRepository->ofId(new UserId($userId));
if (null === $user) {
throw new UserDoesNotExistException();
}
return $user;
}
protected function findWishOrFail($wishId)
{
$wish = $this->wishRepository->ofId(new WishId($wishId));
if (!$wish) {
throw new WishDoesNotExistException();
}
return $wish;
}
protected function checkIfUserOwnsWish(User $user, Wish $wish)
{
if (!$wish->userId()->equals($user->id())) {
throw new \InvalidArgumentException(
'User is not authorized to update this wish'
);
}
}
}
class MakeWishService extends WishService
{
public function execute(MakeWishRequest $request)
{
$userId = $request->userId();
$address = $request->address();
$content = $request->content();
$user = $this->findUserOrFail($userId);
$wish = $user->makeWish(
$this->wishRepository->nextIdentity(),
$address,
$content
);
$this->wishRepository->add($wish);
}
}
如你所见,Users创建Wishes,我们的代码也是如此。makeWish是一个工厂方法,用于构建愿望。该方法返回一个使用所有者的UserId构建的新Wish:
class User
{
// ...
/**
* @return Wish
*/
public function makeWish(WishId $wishId, $address, $content)
{
return new Wish(
$wishId,
$this->id(),
$address,
$content
);
}
// ...
}
为什么我们返回一个Wish而不是像使用 Doctrine 那样仅仅将新的Wish添加到内部集合中呢?总结来说,在这种情况下,User和Wish并不符合聚合体的定义,因为没有真正的业务不变量需要保护。User可以添加和删除他们想要的任意数量的Wishes。如果需要,Wishes及其User可以在不同的事务中独立地在数据库中更新。
遵循之前解释的聚合设计规则,我们应该追求小的聚合体,这就是结果。每个实体都有自己的仓库。愿望使用身份引用其拥有的User——在这种情况下是UserId。可以通过WishRepository中的查找器执行获取用户的全部愿望,并且可以轻松分页而不会出现任何性能问题:
interface WishRepository
{
/**
* @param WishId $wishId
*
* @return Wish
*/
public function ofId(WishId $wishId);
/**
* @param UserId $userId
*
* @return Wish[]
*/
public function ofUserId(UserId $userId);
/**
* @param Wish $wish
*/
public function add(Wish $wish);
/**
* @param Wish $wish
*/
public function remove(Wish $wish);
/**
* @return WishId
*/
public function nextIdentity();
}
这种方法的一个有趣方面是,我们不需要在我们的首选 ORM 中映射User和Wish之间的关系。因为我们使用UserId从Wish引用User,所以我们只需要仓库。让我们考虑一下使用 Doctrine 映射此类实体可能看起来如何:
Lw\Domain\Model\User\User:
type: entity
id:
userId:
column: id
type: UserId
table: user
repositoryClass:
Lw\Infrastructure\Domain\Model\User\DoctrineUser\Repository
fields:
email:
type: string
password:
type: string
Lw\Domain\Model\Wish\Wish:
type: entity
table: wish
repositoryClass:
Lw\Infrastructure\Domain\Model\Wish\DoctrineWish\Repository
id:
wishId:
column: id
type: WishId
fields:
address:
type: string
content:
type: text
userId:
type: UserId
column: user_id
没有定义任何关系。在创建一个新的愿望之后,让我们编写一些代码来更新现有的一个:
class UpdateWishService extends WishService
{
public function execute(UpdateWishRequest $request)
{
$userId = $request->userId();
$wishId = $request->wishId();
$email = $request->email();
$content = $request->content();
$user = $this->findUserOrFail($userId);
$wish = $this->findWishOrFail($wishId);
$this->checkIfUserOwnsWish($user, $wish);
$wish->changeContent($content);
$wish->changeAddress($email);
}
}
由于User和Wish不构成一个聚合体,为了更新Wish,我们首先需要使用WishRepository检索它。一些额外的检查确保只有所有者才能更新愿望。正如你可能已经看到的,$wish已经是我们的领域中的一个现有实体,因此没有必要再次使用仓库添加它。然而,为了使更改持久化,我们的 ORM 必须知道更新的信息,并在工作完成后将任何剩余的更改刷新到数据库中。不用担心;我们将在第十一章应用中更详细地探讨这一点。为了完成这个例子,让我们看看如何删除一个愿望:
class RemoveWishService extends WishService
{
public function execute(RemoveWishRequest $request)
{
$userId = $request->userId();
$wishId = $request->wishId();
$user = $this->findUserOrFail($userId);
$wish = $this->findWishOrFail($wishId);
$this->checkIfUserOwnsWish($user, $wish);
$this->wishRepository->remove($wish);
}
}
正如你可能看到的,你可以重构一些代码部分,比如构造函数和所有权检查,以便在应用程序服务中重用。请随意考虑你将如何做。最后但同样重要的是,我们如何能够获取特定用户的全部愿望:
class ViewWishesService extends WishService
{
/**
* @return Wish[]
*/
public function execute(ViewWishesRequest $request)
{
$userId = $request->userId();
$wishId = $request->wishId();
$user = $this->findUserOrFail($userId);
$wish = $this->findWishOrFail($wishId);
$this->checkIfUserOwnsWish($user, $wish);
return $this->wishRepository->ofUserId($user->id());
}
}
这相当直接。然而,我们将在相应的章节中更深入地探讨如何在应用程序服务中渲染和返回信息。现在,返回一个愿望集合就能完成这项工作。
让我们总结一下这种非聚合方法。我们找不到任何真正的业务不变量来将User和Wish视为一个聚合体,这就是为什么它们各自都是一个聚合体。User有自己的存储库,即UserRepository。Wish也有自己的存储库,即WishRepository。每个Wish都持有指向所有者User的UserId引用。即便如此,我们也没有要求进行事务处理。这是在性能和可扩展性方面最好的情况。然而,生活并不总是如此美好。考虑一下在真正的业务不变量下可能会发生什么。
每个用户不超过三个愿望
我们的应用程序取得了巨大的成功,现在是时候从中获取一些收益了。我们希望新用户最多只能有三个愿望。作为一个用户,如果你想拥有更多的愿望,你将来可能需要支付一个高级账户的费用。让我们看看我们如何修改代码以遵循关于最大愿望数量的新业务规则(在这个例子中,不考虑高级用户)。
考虑以下代码片刻。除了上一节中解释的将逻辑推入我们的实体之外,以下代码能工作吗?
class MakeWishService
{
// ...
public function execute(MakeWishRequest $request)
{
$userId = $request->userId();
$address = $request->email();
$content = $request->content();
$count = $this->wishRepository->numberOfWishesByUserId(
new UserId($userId)
);
if ($count >= 3) {
throw new MaxNumberOfWishesExceededException();
}
$wish = new Wish(
$this->wishRepository->nextIdentity(),
new UserId($userId),
$address,
$content
);
$this->wishRepository->add($wish);
}
}
它看起来可以。这很简单——可能太简单了。而且我们遇到了不同的问题。第一个问题是应用程序服务必须协调,但不应该包含业务逻辑。相反,更好的地方是将检查最大三个愿望的逻辑放入User中,这样我们就可以更好地控制User和Wish之间的关系。然而,对于这里展示的方法,代码似乎可以工作。
第二个问题是它在竞争条件下无法工作。暂时忘记领域驱动设计。在重负载下,这段代码有什么问题?思考一下。是否有可能打破一个User拥有超过三个愿望的规则?为什么在运行一些压力测试后,你的质量保证(QA)人员会如此高兴?
你的质量保证(QA)人员尝试两次制作愿望功能,结果是一个用户有两个愿望。这是正确的。你的质量保证(QA)人员继续测试这个功能。想象一下,他们同时在浏览器中打开两个标签页,在每个标签页中填写每个表单,并设法同时提交两个按钮。突然,在两个请求之后,用户在数据库中有四个愿望。这是错误的!发生了什么?
以调试器的角度思考,考虑两个不同的请求同时获取到if ($count > 3) {这一行。这两个请求都会返回 false,因为用户只有两个愿望。所以这两个请求都会创建一个Wish,并且两个请求都会将其添加到数据库中。结果是,一个User有四个愿望。这是不一致的!
我们知道你在想什么。那是因为我们遗漏了将所有内容放入事务中。想象一下,用户 ID 为 1 的用户已经有两个愿望,所以还剩下一个。两个创建不同愿望的 HTTP 请求同时到达。我们对每个请求启动一个数据库事务(我们将在第十一章,应用)中回顾如何处理事务和请求)。考虑之前 PHP 代码将要运行的所有查询。记住,如果你使用任何可视数据库工具,你需要禁用任何自动提交标志:


用户 ID 为1有多少个愿望?没错,四个。这是怎么发生的?如果你将这个 SQL 块在不同的连接中逐行执行,你会看到愿望表在两次执行结束时都将有四行。所以看起来这并不是关于用事务保护的问题。我们如何解决这个问题?如介绍中所述,并发控制可以帮助解决这个问题。
对于那些在数据库技术方面更为高级的开发者,调整隔离级别可能有效。然而,我们认为这个选项过于复杂,因为问题可以用其他方法解决,而且我们并不总是处理数据库。
悲观并发控制
在放置锁时有一个重要的考虑因素:任何尝试更新或查询相同数据的其他连接都将挂起,直到锁被释放。锁很容易产生大部分的性能问题。例如,在 MySQL 中,放置锁有不同的选项:显式锁定表 UN/LOCK tables,以及锁定读取 SELECT ... FOR UPDATE and SELECT ... LOCK IN SHARE MODE。
正如我们在一开始就分享的那样,根据克林顿·戈姆利和扎卡里·汤的书籍 Elasticsearch: 《 definitive guide》:
广泛应用于关系型数据库,这种方法假设冲突性更改很可能会发生,因此会阻止对资源的访问以防止冲突。一个典型的例子是在读取数据之前锁定一行,确保只有放置锁的线程能够更改该行中的数据。
我们可以 LOCK 表,但我们认为这种方法复杂且风险较大。在使用锁时,你必须小心,因为你可能会陷入两个线程或请求都在等待对方释放锁的情况。这就是所谓的死锁。
根据我们的经验,一些开发者使用 SELECT ... FOR UPDATE 方法。让我们看看使用这个选项的相同两个请求场景:

如您所见,在第一个请求的COMMIT之后,第二个请求的愿望数量为三。这是一致的,但第二个请求在锁未释放时等待。这意味着在一个有很多请求的环境中,它可能会产生性能问题。如果第一个请求花费太多时间释放锁,第二个请求可能会因为超时而失败:
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
上述看起来像是一个有效的选项,但我们需要意识到可能存在的性能问题。有没有其他替代方案?
乐观并发控制
另一个替代方案:完全不使用锁。考虑给我们的聚合添加一个版本属性。当我们持久化它们时,持久化引擎将 1 设置为持久化聚合的版本。稍后,我们检索相同的聚合并对其进行一些更改。我们持久化聚合。持久化引擎检查我们拥有的版本是否与当前持久化的版本相同,即版本 1。持久化引擎以新状态持久化聚合并更新其版本为 2。如果有多个请求检索相同的聚合,对其进行一些更改,然后尝试持久化,第一个请求将成功,第二个请求将尝试并出错。最后一个请求只是更改了一个过时的版本,因此持久化引擎抛出错误。然而,第二个请求可以再次尝试检索聚合,合并新状态,尝试执行更改,然后持久化聚合。
根据Elasticsearch: The Definitive Guide:
这种方法假设冲突不太可能发生,并且不会阻止尝试操作。然而,如果在读取和写入之间底层数据已被修改,更新将失败。然后应用程序必须决定如何解决冲突。例如,它可以重新尝试更新,使用新鲜的数据,或者它可以向用户报告这种情况。
这个想法之前已经讨论过,但值得再次强调。如果你尝试将乐观并发应用于我们检查应用程序服务中最大愿望的场景,它将不会工作。为什么?我们正在创建一个新的愿望,所以两个请求将创建两个不同的愿望。我们如何让它工作?嗯,我们需要一个对象来集中添加愿望。我们可以在该对象上应用乐观并发技巧,所以看起来我们需要一个将持有愿望的父对象。有什么想法吗?
总结一下,在审查并发控制后,有一个悲观选项正在工作,但关于性能影响有一些担忧。有一个乐观选项,但我们需要找到一个父对象。让我们考虑最终的MakeWishService,但进行一些修改:
class WishAggregateService
{
protected $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
protected function findUserOrFail($userId)
{
$user = $this->userRepository->ofId(new UserId($userId));
if (null === $user) {
throw new UserDoesNotExistException();
}
return $user;
}
}
class MakeWishService extends WishAggregateService
{
public function execute(MakeWishRequest $request)
{
$userId = $request->userId();
$address = $request->address();
$content = $request->content();
$user = $this->findUserOrFail($userId);
$user->makeWish($address, $content);
// Uncomment if your ORM can not flush
// the changes at the end of the request
// $this->userRepository->add($user);
}
}
我们没有传递 WishId,因为它应该是用户内部的东西。makeWish 也不返回一个 Wish;它将新的愿望内部存储。在应用程序服务的执行之后,我们的 ORM 将将 $user 上执行的所有更改刷新到数据库。根据我们的 ORM 多么好,我们可能需要再次使用存储库显式地添加我们的 User 实体。User 类需要哪些更改?首先,应该有一个可以容纳用户内部所有愿望的集合:
class User
{
// ...
/**
* @var ArrayCollection
*/
protected $wishes;
public function __construct(UserId $userId, $email, $password)
{
// ...
$this->wishes = new ArrayCollection();
// ...
}
// ...
}
wishes 属性必须在 User 构造函数中初始化。我们可以使用一个普通的 PHP 数组,但我们选择了使用 ArrayCollection。ArrayCollection 是一个带有 Doctrine Common Library 提供的一些额外功能的 PHP 数组,并且可以独立于 ORM 使用。我们知道有些人可能认为这可能是边界泄漏,并且不应该有任何对基础设施的引用在这里,但我们真的相信这不是问题。事实上,相同的代码使用普通的 PHP 数组也能工作。让我们看看 makeWish 实现是如何受到影响的:
class User
{
// ...
/**
* @return void
*/
public function makeWish($address, $content)
{
if (count($this->wishes) >= 3) {
throw new MaxNumberOfWishesExceededException();
}
$this->wishes[] = new Wish(
new WishId,
$this->id(),
$address,
$content
);
}
// ...
}
到目前为止,一切顺利。现在,是时候回顾其他操作是如何实现的。
推动最终一致性
看起来业务逻辑不希望用户拥有超过三个愿望。这将迫使我们考虑将 User 作为包含 Wish 的根聚合。这将影响我们的设计、性能、可扩展性问题等等。考虑一下如果我们允许用户添加超过限制的愿望会发生什么。我们可以检查谁超出了这个限制,并通知他们需要购买高级账户。允许用户超过限制并在之后通过电话警告他们,将是一个非常好的商业策略。这甚至可能让你们团队的开发者避免将 User 和 Wish 设计为同一个聚合的一部分,以 User 作为其根。你已经看到了不设计单个聚合的好处:最大性能。
class UpdateWishService extends WishAggregateService
{
public function execute(UpdateWishRequest $request)
{
$userId = $request->userId();
$wishId = $request->wishId();
$email = $request->email();
$content = $request->content();
$user = $this->findUserOrFail($userId);
$user->updateWish(new WishId($wishId), $email, $content);
}
}
因为 User 和 Wish 现在形成了一个聚合,所以需要更新的 Wish 已不再通过 WishRepository 获取。我们使用 UserRepository 来获取用户。更新 Wish 的操作是通过根实体来执行的,在这个例子中是 User。为了识别我们想要更新的哪个 Wish,WishId 是必要的:
class User
{
// ...
public function updateWish(WishId $wishId, $email, $content)
{
foreach ($this->wishes as $wish) {
if ($wish->id()->equals($wishId)) {
$wish->changeContent($content);
$wish->changeAddress($address);
break;
}
}
}
}
根据你框架的特性,这个任务可能更便宜或更贵。遍历所有愿望可能意味着执行太多的查询,或者更糟糕的是,获取太多的行,这将对内存产生巨大影响。实际上,这是拥有大型聚合的主要问题之一。所以,让我们考虑如何删除一个愿望:
class RemoveWishService extends WishAggregateService
{
public function execute(RemoveWishRequest $request)
{
$userId = $request->userId();
$wishId = $request->wishId();
$user = $this->findUserOrFail($userId);
$user->removeWish($wishId):
}
}
如前所述,WishRepository不再必要。我们使用其 Repository 获取User并执行移除Wish的操作。为了移除一个Wish,我们需要从内部集合中移除它。一个选择是遍历所有元素并匹配具有相同WishId的一个:
class User
{
// ...
public function removeWish(WishId $wishId)
{
foreach ($this->wishes as $k => $wish) {
if ($wish->id()->equals($wishId)) {
unset($this->wishes[$k]);
break;
}
}
}
// ...
}
这可能是可能的最不依赖于 ORM 的代码。然而,在幕后,Doctrine 正在检索所有愿望并遍历它们。要获取所需实体而不那么不依赖于 ORM 的更具体的方法如下:
为了使所有魔法按预期工作,必须更新 Doctrine 映射。虽然愿望映射保持不变,但User映射有了新的单向oneToMany关系:
Lw\Domain\Model\Wish\Wish:
type: entity
table: lw_wish
repositoryClass:
Lw\Infrastructure\Domain\Model\Wish\DoctrineWish\Repository
id:
wishId:
column: id
type: WishId
fields:
address:
type: string
content:
type: text
userId:
type: UserId
column: user_id
Lw\Domain\Model\User\User:
type: entity
id:
userId:
column: id
type: UserId
table: user
repositoryClass:
Lw\Infrastructure\Domain\Model\User\DoctrineUser\Repository
fields:
email:
type: string
password:
type: string
manyToMany:
wishes:
orphanRemoval: true
cascade: ["all"]
targetEntity: Lw\Domain\Model\Wish\Wish
joinTable:
name: user_wishes
joinColumns:
user_id:
referencedColumnName: id
inverseJoinColumns:
wish_id:
referencedColumnName: id
unique: true
在上面的代码中,有两个重要的配置:orphanRemoval和级联。根据 Doctrine 2 ORM 文档中的孤儿移除和传递性持久化/级联操作:
如果类型 A 的实体包含对私有实体 B 的引用,那么如果从 A 到 B 的引用被移除,实体 B 也应该被移除,因为它不再被使用。OrphanRemoval与一对一、一对多和多对多关联一起工作。当使用orphanRemoval=true选项时,Doctrine 假设实体是私有拥有的,并且不会被其他实体重用。如果你忽略了这一假设,你的实体可能会被 Doctrine 删除,即使你将孤儿实体分配给了另一个实体。
持久化、删除、分离、刷新和合并单个实体可能会变得相当繁琐,尤其是在涉及高度交织的对象图时。因此,Doctrine 2 提供了一种通过级联这些操作来实现传递性持久化的机制。每个关联到另一个实体或实体集合的关联都可以配置为自动级联某些操作。默认情况下,没有操作被级联。
更多信息,请仔细阅读 Doctrine 2 ORM 2 文档中的关联操作。
最后,让我们看看如何从用户那里获取愿望:
class ViewWishesService extends WishService
{
/**
* @return Wish[]
*/
public function execute(ViewWishesRequest $request)
{
return $this
->findUserOrFail($request->userId())
->wishes();
}
}
如前所述,特别是在使用聚合的这种场景中,返回一个愿望集合(Wishes)并不是最佳解决方案。你永远不应该返回领域实体,因为这会阻止应用程序服务之外(如控制器或你的 UI)意外地修改它们。使用聚合,这一点更加明显。非根实体——即属于聚合但不是根的实体——应该对其他外部人员保持私有。
我们将在第十一章应用中更深入地探讨这一点。现在,为了总结,你有不同的选择:
-
应用程序服务返回通过访问聚合信息构建的 DTO。
-
应用程序服务返回由聚合返回的 DTO。
-
应用程序服务使用一个输出依赖项来写入聚合。这种输出依赖项将处理转换为 DTO 或其他格式的转换。
将愿望数量渲染为练习,考虑我们想要在用户账户页面上渲染用户所提出的愿望数量。你会如何实现这个功能,考虑到用户和愿望不形成一个聚合?如果用户和愿望形成一个聚合,你会如何实现它?考虑最终一致性如何帮助你解决问题。
事务
我们在示例中没有展示beginTransaction、commit或rollback。这是因为事务是在应用程序服务级别处理的。现在不用担心;你将在第十一章应用中找到更多关于这个问题的细节。
总结
聚合(Aggregates)的核心是持久性和事务。实际上,在设计聚合时,你必须考虑它们如何被持久化。设计合适的聚合的基本规则是:使它们保持小巧,找到真正的业务不变量,通过领域事件推动最终一致性,通过标识符引用其他聚合,并且每个请求只修改一个聚合。回顾一下,如果两个实体形成一个单一的聚合,代码会有怎样的变化。使用工厂来丰富你的实体。最后,放松一下。在我们所见到的大多数 PHP 应用程序中,只有大约五分的实体是由两个或更多实体组成的聚合。在设计实现聚合时,与你的同事讨论。
第九章:工厂
工厂是一个强大的抽象。它们帮助客户端与领域交互的细节解耦。客户端不需要知道如何构建复杂对象和聚合,因此可以使用工厂创建整个聚合,从而强制执行其不变性。
聚合根上的工厂方法
经典著作中定义的工厂方法模式,在《设计模式:可复用面向对象软件的基础》一书中,是一个创建型模式,它:
定义了一个创建对象的接口,但将其实例化类型的选择留给子类,创建在运行时延迟。
在聚合根中添加工厂方法隐藏了创建聚合的内部实现细节,这对于任何外部客户端都是不可见的。这也将聚合完整性的责任移回根。
在一个包含User实体和Wish实体的领域模型中,User充当聚合根。没有User就没有Wish。User实体应该管理其聚合。
将Wish的控制权移回User实体,是通过在聚合根中放置一个工厂方法来实现的:
class User
{
// ...
public function makeWish(WishId $wishId, $email, $content)
{
$wish = new WishEmail(
$wishId,
$this->id(),
$email,
$content
);
DomainEventPublisher::instance()->publish(
new WishMade($wishId)
);
return $wish;
}
}
客户端不需要知道聚合根如何处理创建逻辑的内部细节:
$wish = $aUser->makeWish(
$wishRepository->nextIdentity(),
'user@example.com',
'I want to be free!'
);
强制不变性
聚合根中的工厂方法也是处理不变性的好地方。
在一个包含Forum和Post实体的领域模型中,其中Post是聚合根Forum的聚合部分,发布Post可能看起来像这样:
class Forum
{
// ...
public function publishPost(PostId $postId, $content)
{
$post = new Post($this->id, $postId, $content);
DomainEventPublisher::instance()->publish(
new PostPublished($postId)
);
return $post;
}
}
与领域专家交谈后,我们得出结论,当Forum关闭时,不应发布Post。这是一个不变性,我们可以在创建Post时直接强制执行,从而防止领域状态不一致:
class Forum
{
// ...
public function publishPost(PostId $postId, $content)
{
if ($this->isClosed()) {
throw new ForumClosedException();
}
$post = new Post($this->id, $postId, $content);
DomainEventPublisher::instance()->publish(
new PostPublished($postId)
);
return $post;
}
}
服务上的工厂
解耦创建逻辑也对我们服务的实现很有帮助。
构建规范
在我们的服务中使用规范可能是说明如何在服务中使用工厂的最佳例子。
考虑以下服务示例。给定来自外部世界的请求,我们希望根据系统中最新添加的Posts构建一个 feed:
namespace Application\Service;
use Domain\Model\Post;
use Domain\Model\PostRepository;
class LatestPostsFeedService
{
private $postRepository;
public function __construct(PostRepository $postRepository)
{
$this->postRepository = $postRepository;
}
/**
* @param LatestPostsFeedRequest $request
*/
public function execute($request)
{
$posts = $this->postRepository->latestPosts($request->since);
return array_map(function(Post $post) {
return [
'id' => $post->id()->id(),
'content' => $post->body()->content(),
'created_at' => $post-> createdAt()
];
}, $posts);
}
}
仓库中的查找方法,如latestPosts,有一些限制,因为它们会无限期地向我们的仓库添加复杂性。正如我们在第十章中讨论的,仓库规范是一个更好的方法。
幸运的是,我们PostRepository中有一个不错的query方法,它与Specifications一起工作:
class LatestPostsFeedService
{
// ...
public function execute($request)
{
$posts = $this->postRepository->query($specification);
}
}
使用规范的具体实现是一个坏主意:
class LatestPostsFeedService
{
public function execute($request)
{
$posts = $this->postRepository->query(
new SqlLatestPostSpecification($request->since)
);
}
}
将高级应用程序服务与低级规范实现耦合会混合层次并破坏关注点的分离。此外,这是一种将我们的服务耦合到具体基础设施实现中的非常糟糕的方式。你无法在 SQL 持久化解决方案之外使用此服务。如果我们想使用内存实现来测试我们的服务怎么办?
解决这个问题的方法是通过使用抽象工厂模式将规范创建与服务本身解耦。根据OODesign.com:
抽象工厂提供了一个创建一组相关对象的接口,而不需要明确指定它们的类。
由于我们可能有多个规范实现,我们首先需要为工厂创建一个接口:
namespace Domain\Model;
interface PostSpecificationFactory
{
public function createLatestPosts(DateTimeImmutable $since);
}
然后,我们需要为每个 PostRepository 实现创建工厂。例如,内存 PostRepository 实现的工厂可能看起来像这样:
namespace Infrastructure\Persistence\InMemory;
use Domain\Model\PostSpecificationFactory;
class InMemoryPostSpecificationFactory
implements PostSpecificationFactory
{
public function createLatestPosts(DateTimeImmutable $since)
{
return new InMemoryLatestPostSpecification($since);
}
}
一旦我们有一个集中化的创建逻辑位置,就很容易将其从服务中解耦:
class LatestPostsFeedService
{
private $postRepository;
private $postSpecificationFactory;
public function __construct(
PostRepository $postRepository,
PostSpecificationFactory $postSpecificationFactory
) {
$this->postRepository = $postRepository;
$this->postSpecificationFactory = $postSpecificationFactory;
}
public function execute($request)
{
$posts = $this->postRepository->query(
$this->postSpecificationFactory->createLatestPosts(
$request->since
)
);
}
}
现在,通过内存 PostRepository 实现对服务进行单元测试相当简单:
namespace Application\Service;
use Domain\Model\Body;
use Domain\Model\Post;
use Domain\Model\PostId;
use Infrastructure\Persistence\InMemory\InMemoryPostRepositor;
class LatestPostsFeedServiceTest extends PHPUnit_Framework_TestCase
{
/**
* @var \Infrastructure\Persistence\InMemory\InMemoryPostRepository
*/
private $postRepository;
/**
* @var LatestPostsFeedService
*/
private $latestPostsFeedService;
public function setUp()
{
$this->latestPostsFeedService = new LatestPostsFeedService(
$this->postRepository = new InMemoryPostRepository()
);
}
/**
* @test
*/
public function shouldBuildAFeedFromLatestPosts()
{
$this->addPost(1, 'first', '-2 hours');
$this->addPost(2, 'second', '-3 hours');
$this->addPost(3, 'third', '-5 hours');
$feed = $this->latestPostsFeedService->execute(
new LatestPostsFeedRequest(
new \DateTimeImmutable('-4 hours')
)
);
$this->assertFeedContains([
['id' => 1, 'content' => 'first'],
['id' => 2, 'content' => 'second']
], $feed);
}
private function addPost($id, $content, $createdAt)
{
$this->postRepository->add(new Post(
new PostId($id),
new Body($content),
new \DateTimeImmutable($createdAt)
));
}
private function assertFeedContains($expected, $feed)
{
foreach ($expected as $index => $contents) {
$this->assertArraySubset($contents, $feed[$index]);
$this->assertNotNull($feed[$index]['created_at']);
}
}
}
构建聚合体
实体对持久化机制是无关的。你不希望将持久化细节耦合并污染你的实体。看看下一个应用程序服务:
class SignUpUserService
{
private $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
/**
* @param SignUpUserRequest $request
*/
public function execute( $request)
{
$email = $request->email();
$password = $request->password();
$user = $this->userRepository->userOfEmail($email);
if (null !== $user) {
throw new UserAlreadyExistsException();
}
$this->userRepository->persist(new User(
$this->userRepository->nextIdentity(),
$email,
$password
));
return $user;
}
}
想象一个像以下这样的 User 实体:
class User
{
private $userId;
private $email;
private $password;
public function __construct(UserId $userId, $email, $password)
{
// ...
}
// ...
}
假设我们想使用 Doctrine 作为我们的基础设施持久化机制。Doctrine 需要有一个作为普通字符串实例变量的 id 才能正常工作。在我们的实体中,$userId 是一个 UserId 值对象。仅仅因为 Doctrine 就在我们的 User 实体中添加一个额外的 id 会将我们的持久化机制与领域模型耦合。
我们在第四章中看到,通过在基础设施层中围绕我们的 User 实体创建一个包装器,我们可以通过代理 ID 解决这个问题:
class DoctrineUser extends User
{
private $surrogateUserId;
public function __construct(UserId $userId, $email, $password)
{
parent:: __construct($userId, $email, $password);
$this->surrogateUserId = $userId->id();
}
}
由于在应用程序服务中创建 DoctrineUser 会使持久化层再次与领域模型耦合,我们需要通过抽象工厂将创建逻辑从服务中解耦。
我们可以通过在我们的领域创建一个接口来完成这个操作:
interface UserFactory
{
public function build(UserId $userId, $email, $password);
}
然后,我们将其实现在我们的基础设施层中:
class DoctrineUserFactory implements UserFactory
{
public function build(UserId $userId, $email, $password)
{
return new DoctrineUser($userId, $email, $password);
}
}
一旦解耦,我们只需要将工厂注入到我们的应用程序服务中:
class SignUpUserService
{
private $userRepository;
private $userFactory;
public function __construct(
UserRepository $userRepository,
UserFactory $userFactory
) {
$this->userRepository = $userRepository;
$this->userFactory = $userFactory;
}
/**
* @param SignUpUserRequest $request
*/
public function execute($request)
{
// ...
$user = $this->userFactory->build(
$this->userRepository->nextIdentity(),
$email,
$password
);
$this->userRepository->persist($user);
return $user;
}
}
测试工厂
当你编写测试时,你会看到一个常见的模式。这是因为构建实体和复杂的聚合体可能是一个非常繁琐和重复的过程。不可避免地,复杂性和重复性将开始渗透到你的测试套件中。考虑以下实体:
class Author
{
private $username;
private $email ;
private $fullName;
public function __construct(
Username $aUsername,
FullName $aFullName,
Email $anEmail
) {
$this->username = $aUsername;
$this->email = $anEmail ;
$this->fullName = $aFullName;
}
// ...
}
在你的系统中某个地方,你最终会得到一个看起来像这样的测试:
class MyTest extends PHPUnit_Framework_TestCase
{
/**
* @test
*/
public function itDoesSomething()
{
$author = new Author(
new Username('johndoe'),
new FullName('John', 'Doe' ),
new Email('john@doe.com' )
);
//do something with author
}
}
边界内的服务共享诸如实体、聚合体和值对象等概念。想象一下,在测试中反复重复相同的构建逻辑是多么的杂乱。正如我们将看到的,将构建逻辑从测试中提取出来非常方便,并且可以防止重复。
对象母体
对象母体是一个吸引人的名称,用于指代为你的测试创建固定配置的工厂。
与前面的例子类似,我们可以将重复的逻辑提取到对象母体中,以便在多个测试中复用:
class AuthorObjectMother
{
public static function createOne()
{
return new Author(
new Username('johndoe'),
new FullName('John', 'Doe'),
new Email('john@doe.com )
);
}
}
class MyTest extends PHPUnit_Framework_TestCase
{
/**
* @test
*/
public function itDoesSomething()
{
$author = AuthorObjectMother::createOne();
}
}
你会注意到,随着测试和情况的增加,工厂将拥有更多的方法。
由于对象母体不够灵活,它们往往会迅速增加复杂性。幸运的是,有一个更灵活的替代方案适用于你的测试。
测试数据构建器
测试数据构建器只是普通的构建器,它们在测试套件中仅使用默认值,这样你就不必在特定的测试用例中指定无关的参数:
class AuthorBuilder
{
private $username;
private $email ;
private $fullName;
private function __construct()
{
$this->username = new Username('johndoe');
$this->email = new Email('john@doe.com');
$this->fullName = new FullName('John', 'Doe');
}
public static function anAuthor()
{
return new self();
}
public function withFullName(FullName $aFullName)
{
$this->fullName = $aFullName;
return $this;
}
public function withUsername(Username $aUsername)
{
$this->username = $aUsername;
return $this;
}
public function withEmail(Email $anEmail)
{
$this->email = $anEmail ;
return $this;
}
public function build()
{
return new Author($this->username, $this->fullName, $this->email);
}
}
class MyTest extends PHPUnit_Framework_TestCase
{
/**
* @test
*/
public function itDoesSomething()
{
$author = AuthorBuilder::anAuthor()
->withEmail(new Email('other@email.com'))
->build();
}
}
我们甚至可以将测试数据构建器组合起来构建更复杂的聚合体,例如一个Post:
class Post
{
private $id;
private $author;
private $body;
private $createdAt;
public function __construct(
PostId $anId, Author $anAuthor, Body $aBody
) {
$this->id = $anId;
$this->author = $anAuthor;
$this->body = $aBody;
$this->createdAt = new DateTimeImmutable();
}
}
让我们看看我们Post对应的测试数据构建器。我们可以复用AuthorBuilder来构建默认的Author:
class PostBuilder
{
private $postId;
private $author;
private $body;
private function __construct()
{
$this->postId = new PostId();
$this->author = AuthorBuilder::anAuthor()->build();
$this->body = new Body('Post body');
}
public static function aPost()
{
return new self();
}
public function withAuthor(Author $anAuthor)
{
$this->author = $anAuthor;
return $this;
}
public function withPostId(PostId $aPostId)
{
$this->postId = $aPostId;
return $this;
}
public function withBody(Body $body)
{
$this->body = $body;
return $this;
}
public function build()
{
return new Post($this->postId, $this->author, $this->body);
}
}
这个解决方案现在足够灵活,可以覆盖任何测试用例,包括构建内部实体的可能性:
class MyTest extends PHPUnit_Framework_TestCase
{
/**
* @test
*/
public function itDoesSomething()
{
$post = PostBuilder::aPost()
->withAuthor(AuthorBuilder::anAuthor()
->withUsername(new Username('other'))
->build())
->withBody(new Body('Another body'))
->build();
//do something with the post
}
}
总结
工厂是将构建逻辑从我们的业务逻辑中解耦的强大工具。工厂方法模式不仅通过将创建责任转移到聚合根来帮助,还可以强制执行领域不变性。在我们的服务中使用抽象工厂模式允许我们将领域逻辑与基础设施创建细节分离。一个常见的用例是规范及其相应的持久化实现。我们已经看到,工厂在我们的测试套件中也很有用。虽然我们可以将构建逻辑提取到对象母体工厂中,但测试数据构建器为我们的测试提供了更多的灵活性。
第十章:仓库
为了与领域对象交互,你需要持有它的引用。实现这一目标的一种方式是通过创建。或者,你也可以遍历一个关联。在面向对象程序中,对象有链接(引用)到其他对象,这使得它们易于遍历,从而增加了我们模型的表达能力。但是,这里有一个问题:你需要一种机制来检索第一个对象,即聚合根。
仓库充当存储位置,检索的对象以持久化时的相同状态返回。在领域驱动设计中,每个聚合(Aggregate)类型通常都有一个唯一的关联仓库,用于其持久化和获取需求。然而,在需要共享聚合对象层次结构的情况下,类型可能共享一个仓库。
一旦你成功从仓库中检索到聚合,你做的任何更改都会被持久化,这样就消除了返回到仓库的需要。
定义
马丁·福勒将仓库(Repository)定义为:
领域和数据映射层之间的机制,充当内存中的领域对象集合。客户端对象以声明性方式构建查询规范,并将其提交给仓库以满足。对象可以被添加到仓库中,也可以从简单的对象集合中移除,仓库封装的映射代码将在幕后执行适当的操作。从概念上讲,仓库封装了在数据存储中持久化的对象集合及其上的操作,提供了对持久化层的面向对象视图。仓库还支持实现领域和数据映射层之间干净分离和单向依赖的目标。
仓库不是 DAO
数据访问对象(Data Access Objects)(DAO)是将领域对象持久化到数据库的常见模式。很容易将 DAO 模式与仓库混淆。显著的差异在于,仓库代表集合,而 DAO 更接近数据库,通常是更以表为中心的。通常,DAO 会包含特定领域对象的 CRUD 方法。让我们看看一个 DAO 的常见接口可能是什么样的:
interface UserDAO
{
/**
* @param string $username
* @return User
*/
public function get($username);
public function create(User $user);
public function update(User $user);
/**
* @param string $username
*/
public function delete($username);
}
DAO 接口可能有多个实现,这些实现可能从使用 ORM 构造到使用纯 SQL 查询。DAO 的主要问题在于它们的职责定义不明确。DAO 通常被视为数据库的网关,因此为了查询数据库,很容易通过许多特定方法来大大降低内聚性:
interface BloatUserDAO
{
public function get($username);
public function create(User $user);
public function update(User $user);
public function delete($username);
public function getUserByLastName($lastName);
public function getUserByEmail($email);
public function updateEmailAddress($username, $email);
public function updateLastName($username, $lastName);
}
正如你所见,我们添加的新方法越多,单元测试 DAO 就越困难,它与用户对象耦合得也越来越紧密。随着时间的推移,这个问题会逐渐扩大,许多其他贡献者共同使这个大泥球(Big Ball of Mud)变得更大。
集合导向的仓库
仓库通过实现它们的通用接口特征来模拟集合。作为一个集合,仓库不应该泄露任何持久化行为的意图,例如保存到存储的概念。
基础的持久化机制必须支持这一需求。你不需要在对象的整个生命周期中处理对象的变更。集合引用了对象的最新的变更,这意味着在每次访问时,你都会得到最新的对象状态。
仓库实现了一个具体的集合类型,即 Set。Set 是一种数据结构,具有一个不变性,即不包含重复条目。如果你尝试向 Set 中添加一个已经存在的元素,它将不会被添加。这在我们的用例中很有用,因为每个聚合体都有一个与根实体关联的唯一标识符。
例如,考虑以下领域模型:
namespace Domain\Model;
class Post
{
const EXPIRE_EDIT_TIME = 120; // seconds
private $id;
private $body;
private $createdAt;
public function __construct(PostId $anId, Body $aBody)
{
$this->id = $anId;
$this->body = $aBody;
$this->createdAt = new \DateTimeImmutable();
}
public function editBody(Body $aNewBody)
{
if($this->editExpired()) {
throw new RuntimeException('Edit time expired');
}
$this->body = $aNewBody;
}
private function editExpired()
{
$expiringTime= $this->createdAt->getTimestamp() +
self::EXPIRE_EDIT_TIME;
return $expiringTime < time();
}
public function id()
{
return $this->id;
}
public function body()
{
return $this->body;
}
public function createdAt()
{
return $this->createdAt;
}
}
class Body
{
const MIN_LENGTH = 3;
const MAX_LENGTH = 250;
private $content;
public function __construct($content)
{
$this->setContent(trim($content));
}
private function setContent($content)
{
$this->assertNotEmpty($content);
$this->assertFitsLength($content);
$this->content = $content;
}
private function assertNotEmpty($content)
{
if(empty($content)) {
throw new DomainException('Empty body');
}
}
private function assertFitsLength($content)
{
if(strlen($content) < self::MIN_LENGTH) {
throw new DomainException('Body is too short');
}
if(strlen($content) > self::MAX_LENGTH) {
throw new DomainException('Body is too long');
}
}
public function content()
{
return $this->content;
}
}
class PostId
{
private $id;
public function __construct($id = null)
{
$this->id = $id ?: uniqid();
}
public function id()
{
return $this->id;
}
public function equals(PostId $anId)
{
return $this->id === $anId->id();
}
}
如果我们想要持久化这个Post实体,可以创建一个简单的内存Post仓库,如下所示:
class SimplePostRepository
{
private $post = [];
public add(Post $aPost)
{
$this->posts[(string) $aPost->id()] = $aPost;
}
public function postOfId(PostId $anId)
{
if (isset($this->posts[(string) $anId])) {
return $this->posts[(string) $anId];
}
return null;
}
}
并且,正如你所期望的,它被当作一个集合来处理:
$id = new PostId();
$repository = new SimplePostRepository();
$repository->add(new Post($id, 'Random content'));
// later ...
$post = $repository->postOfId($id);
$post->editBody('Updated content');
// even later ...
$post = $repository->postOfId($id);
assert('Updated content' === $post->body());
如你所见,从集合的角度来看,在仓库中不需要保存方法。影响对象的变化被底层持久化层正确处理。集合导向的仓库是那些不需要添加之前已持久化的聚合体的仓库。这主要发生在基于内存的仓库中,但我们也有方法在持久化导向的仓库中这样做。我们稍后会看到这一点;此外,我们将在第十一章,应用中更深入地探讨这一点。
设计仓库的第一步是为它定义一个类似集合的接口。该接口需要定义通常的集合方法,如下所示:
interface PostRepository
{
public function add(Post $aPost);
public function addAll(array $posts);
public function remove(Post $aPost);
public function removeAll(array $posts);
// ...
}
对于实现这样的接口,你也可以使用一个抽象类。一般来说,当我们谈论接口时,我们指的是一般概念,而不仅仅是特定的 PHP 接口。为了保持你的设计简单,不要添加你不需要的方法;仓库接口定义及其相应的聚合体应该放在同一个模块中。
有时remove操作并不会在数据库中实际删除聚合体。这种策略——其中聚合体有一个状态字段被更新为已删除的值——被称为软删除。为什么这种方法有趣?它对于审计变更和性能来说可能很有趣。在这种情况下,你可以将聚合体标记为禁用或逻辑删除。接口可以根据需要相应地更新,通过移除删除方法或在仓库中提供禁用行为。
仓库的另一个重要方面是查找方法,如下所示:
interface PostRepository
{
// ...
/**
* @return Post
*/
public function postOfId(PostId $anId);
/**
* @return Post[]
*/
public function latestPosts(DateTimeImmutable $sinceADate);
}
正如我们在 第四章 中所建议的,实体,我们更喜欢应用程序生成的标识符。为聚合生成新标识符的最佳位置是其存储库。因此,为了检索 Post 的全局唯一 ID,一个合理的做法是在 PostRepository 中包含它:
interface PostRepository
{
// ...
/**
* @return PostId
*/
public function nextIdentity();
}
负责构建每个 Post 实例的代码调用 nextIdentity 来获取一个唯一标识符,PostId:
$post = newPost($postRepository->nextIdentity(), $body);
一些开发者倾向于将实现放置在接口定义附近,作为模块的子包。然而,因为我们希望有一个清晰的关注点分离,所以我们建议将其放置在基础设施层内部。
内存实现
如 Uncle Bob 在 Screaming Architecture 中所写:
一个好的软件架构允许将关于框架、数据库、Web 服务器和其他环境问题和工具的决定推迟和延迟。一个好的架构使得在项目后期才决定 Rails、Spring、Hibernate、Tomcat 或 MySql 成为可能。一个好的架构还使得改变这些决定变得容易。一个好的架构强调使用案例,并将它们与外围关注点解耦。
在应用程序的早期阶段,一个快速的内存实现可能会很有用。你可以用它来成熟系统的其他部分,允许你将数据库决策推迟到正确的时间点。内存存储库简单、快速且易于实现。
对于我们的 Post 存储库,一个内存中的哈希表就足以提供我们需要的所有功能:
namespace Infrastructure\Persistence\InMemory;
use Domain\Model\Post;
use Domain\Model\PostId;
use Domain\Model\PostRepository;
class InMemoryPostRepository implements PostRepository
{
private $posts = [];
public function add(Post $aPost)
{
$this->posts[$aPost->id()->id()] = $aPost;
}
public function remove(Post $aPost)
{
unset($this->posts[$aPost->id()->id()]);
}
public function postOfId(PostId $anId)
{
if (isset($this->posts[$anId->id()])) {
return $this->posts[$anId->id()];
}
return null;
}
public function latestPosts(\DateTimeImmutable $sinceADate)
{
return $this->filterPosts(
function (Post $post) use($sinceADate) {
return $post->createdAt() > $sinceADate;
}
);
}
private function filterPosts(callable $fn)
{
return array_values(array_filter($this->posts, $fn));
}
public function nextIdentity()
{
return new PostId();
}
}
Doctrine ORM
我们在过去的章节中已经多次提到了 Doctrine。Doctrine 是一套用于数据库存储和对象映射的库。它默认捆绑了流行的 Symfony2 网络框架,并且,在众多特性中,它允许你通过 数据映射模式轻松地将你的应用程序与持久层解耦。
同时,ORM 位于一个强大的数据库抽象层之上,它通过一个称为 Doctrine 查询语言(DQL)的 SQL 语法来实现数据库交互,该语言受到了著名的 Java Hibernate 框架的启发。
如果我们要使用 Doctrine ORM,第一个任务是完成通过 Composer 向我们的项目中添加依赖项:
composer require doctrine/orm
对象映射
您的领域对象与数据库之间的映射可以被视为实现细节。领域生命周期不应该意识到这些持久性细节。因此,映射信息应该作为基础设施层的一部分来定义,位于领域之外,并且作为存储库的实现。
Doctrine 自定义映射类型
由于我们的 Post 实体由像 Body 或 PostId 这样的值对象组成,因此制作自定义映射类型或使用 Doctrine Embeddables 是一个好主意,正如在值对象章节中看到的那样。这将使对象映射变得容易得多:
namespace Infrastructure\Persistence\Doctrine\Types;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Domain\Model\Body;
class BodyType extends Type
{
public function getSQLDeclaration(
array $fieldDeclaration, AbstractPlatform $platform
) {
return $platform->getVarcharTypeDeclarationSQL(
$fieldDeclaration
);
}
/**
* @param string $value
* @return Body
*/
public function convertToPHPValue(
$value, AbstractPlatform $platform
) {
return new Body($value);
}
/**
* @param Body $value
*/
public function convertToDatabaseValue(
$value, AbstractPlatform $platform
) {
return $value->content();
}
public function getName()
{
return 'body';
}
}
namespace Infrastructure\Persistence\Doctrine\Types;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Domain\Model\PostId;
class PostIdType extends Type
{
public function getSQLDeclaration(
array $fieldDeclaration, AbstractPlatform $platform
) {
return $platform->getGuidTypeDeclarationSQL(
$fieldDeclaration
);
}
/**
* @param string $value
* @return PostId
*/
public function convertToPHPValue(
$value, AbstractPlatform $platform
) {
return new PostId($value);
}
/**
* @param PostId $value
*/
public function convertToDatabaseValue(
$value, AbstractPlatform $platform
) {
return $value->id();
}
public function getName()
{
return 'post_id';
}
}
不要忘记在 PostId 值对象上实现 __toString 魔法方法,因为 Doctrine 需要这个:
class PostId
{
// ...
public function __toString()
{
return $this->id;
}
}
Doctrine 提供了多种映射格式,如 YAML、XML 或注解。XML 是我们的首选选择,因为它提供了强大的 IDE 自动完成:
<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping
xsi:schemaLocation="
http://doctrine-project.org/schemas/orm/doctrine-mapping
http://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd">
<entity name="Domain\Model\Post" table="posts">
<id name="id" type="post_id" column="id">
<generator strategy="NONE" />
</id>
<field name="body" type="body" length="250" column="body"/>
<field name="createdAt" type="datetime" column="created_at"/>
</entity>
</doctrine-mapping>
练习
将使用 Doctrine Embeddables 方法的情况下映射看起来会是什么样子写下来。如果你需要一些帮助,请查看第 值对象或实体章节。
实体管理器
EntityManager 是 ORM 功能的中心访问点。启动它很容易:
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools;
Type::addType(
'post_id',
'Infrastructure\Persistence\Doctrine\Types\PostIdType'
);
Type::addType(
'body',
'Infrastructure\Persistence\Doctrine\Types\BodyType'
);
$entityManager = EntityManager::create(
[
'driver' => 'pdo_sqlite',
'path'=> __DIR__ . '/db.sqlite',
],
Tools\Setup::createXMLMetadataConfiguration(
['/Path/To/Infrastructure/Persistence/Doctrine/Mapping'],
$devMode = true
)
);
记得根据你的需求和设置进行配置。
DQL 实现
在这个仓储的情况下,我们只需要 EntityManager 直接从数据库中检索领域对象:
namespace Infrastructure\Persistence\Doctrine;
use Doctrine\ORM\EntityManager;
use Domain\Model\Post;
use Domain\Model\PostId;
use Domain\Model\PostRepository;
class DoctrinePostRepository implements PostRepository
{
protected $em;
public function __construct(EntityManager $em)
{
$this->em = $em;
}
public function add(Post $aPost)
{
$this->em->persist($aPost);
}
public function remove(Post $aPost)
{
$this->em->remove($aPost);
}
public function postOfId(PostId $anId)
{
return $this->em->find('Domain\Model\Post', $anId);
}
public function latestPosts(\DateTimeImmutable $sinceADate)
{
return $this->em->createQueryBuilder()
->select('p')
->from('Domain\Model\Post', 'p')
->where('p.createdAt > :since')
->setParameter(':since', $sinceADate)
->getQuery()
->getResult();
}
public function nextIdentity()
{
return new PostId();
}
}
如果你检查一些 Doctrine 示例,你可能会发现运行持久化或删除后应该调用 flush。但是,正如我们的提案中看到的,没有调用 flush。刷新和处理事务被委托给应用程序服务。这就是为什么你可以使用 Doctrine,考虑到所有实体的更改将在请求结束时刷新。从性能的角度来看,一个刷新调用是最好的。
面向持久化的仓储
有时候,面向集合的仓储与我们的持久化机制不太匹配。如果你没有工作单元,跟踪聚合变化是一项艰巨的任务。唯一持久化这些变化的方法是显式调用 save。
面向持久化的仓储的接口定义类似于你定义面向集合的等效仓储:
interface PostRepository
{
public function nextIdentity();
public function postOfId(PostId $anId);
public function save(Post $aPost);
public function saveAll(array $posts);
public function remove(Post $aPost);
public function removeAll(array $posts);
}
在这种情况下,我们现在有保存和 saveAll 方法,它们提供了类似于之前添加和 addAll 方法的功能。然而,重要的区别在于客户端如何使用它们。在面向集合的风格中,你只需使用一次添加方法:当聚合创建时。在面向持久化的风格中,你不仅在创建新的聚合后使用 save 动作,而且在现有的聚合被修改时也会使用:
$post = new Post(/* ... */);
$postRepository->save($post);
// later ...
$post = $postRepository->postOfId($postId);
$post->editBody(new Body('New body!'));
$postRepository->save($post);
除了这个区别之外,细节仅在于实现中。
Redis 实现
Redis 是一个内存中的键值存储,可以用作缓存或存储。
根据情况,我们可以考虑将 Redis 作为我们的聚合存储。
要开始,确保你有一个 PHP 客户端来连接 Redis。我们推荐的一个好的是 Predis:
composer require predis/predis:~1.0
namespace Infrastructure\Persistence\Redis;
use Domain\Model\Post;
use Domain\Model\PostId;
use Domain\Model\PostRepository;
use Predis\Client;
class RedisPostRepository implements PostRepository
{
private $client;
public function __construct(Client $client)
{
$this->client = $client;
}
public function save(Post $aPost)
{
$this->client->hset(
'posts',
(string) $aPost->id(), serialize($aPost)
);
}
public function remove(Post $aPost)
{
$this->client->hdel('posts', (string) $aPost->id());
}
public function postOfId(PostId $anId)
{
if($data = $this->client->hget('posts', (string) $anId)) {
return unserialize($data);
}
return null;
}
public function latestPosts(\DateTimeImmutable $sinceADate)
{
$latest = $this->filterPosts(
function(Post $post) use ($sinceADate) {
return $post->createdAt() > $sinceADate;
}
);
$this->sortByCreatedAt($latest);
return array_values($latest);
}
private function filterPosts(callable $fn)
{
return array_filter(array_map(function ($data) {
return unserialize($data);
},
$this->client->hgetall('posts')), $fn);
}
private function sortByCreatedAt(&$posts)
{
usort($posts, function (Post $a, Post $b) {
if ($a->createdAt() == $b->createdAt()) {
return 0;
}
return ($a->createdAt() < $b->createdAt()) ? -1 : 1;
});
}
public function nextIdentity()
{
return new PostId();
}
}
SQL 实现
在一个经典示例中,我们可以通过使用纯 SQL 查询为我们的 PostRepository 创建一个简单的 PDO 实现:
namespace Infrastructure\Persistence\Sql;
use Domain\Model\Body;
use Domain\Model\Post;
use Domain\Model\PostId;
use Domain\Model\PostRepository;
class SqlPostRepository implements PostRepository
{
const DATE_FORMAT = 'Y-m-d H:i:s';
private $pdo;
public function __construct(\PDO $pdo)
{
$this->pdo = $pdo;
}
public function save(Post $aPost)
{
$sql ='INSERT INTO posts ' .
'(id, body, created_at) VALUES ' .
'(:id, :body, :created_at)';
$this->execute($sql, [
'id' => $aPost->id()->id(),
'body' => $aPost->body()->content(),
'created_at' => $aPost->createdAt()->format(
self::DATE_FORMAT
)
]);
}
private function execute($sql, array $parameters)
{
$st = $this->pdo->prepare($sql);
$st->execute($parameters);
return $st;
}
public function remove(Post $aPost)
{
$this->execute('DELETE FROM posts WHERE id = :id', [
'id' => $aPost->id()->id()
]);
}
public function postOfId(PostId $anId)
{
$st =$this->execute('SELECT * FROM posts WHERE id = :id',[
'id' => $anId->id()
]);
if($row = $st->fetch(\PDO::FETCH_ASSOC)) {
return $this->buildPost($row);
}
return null;
}
private function buildPost($row)
{
return new Post(
new PostId($row['id']),
new Body($row['body']),
new \DateTimeImmutable($row['created_at'])
);
}
public function latestPosts(\DateTimeImmutable $sinceADate)
{
return $this->retrieveAll(
'SELECT * FROM posts WHERE created_at > :since_date', [
'since_date' => $sinceADate->format(self::DATE_FORMAT)
]
);
}
private function retrieveAll($sql, array $parameters = [])
{
$st = $this->pdo->prepare($sql);
$st->execute($parameters);
return array_map(function ($row) {
return $this->buildPost($row);
}, $st->fetchAll(\PDO::FETCH_ASSOC));
}
public function nextIdentity()
{
return new PostId();
}
public function size()
{
return $this->pdo->query('SELECT COUNT(*) FROM posts')
->fetchColumn();
}
}
由于我们没有任何映射配置,在同一个类中为模式提供一个初始化方法将非常有用。共同变化的事物应该保持在一起:
class SqlPostRepository implements PostRepository
{
// ...
public function initSchema()
{
$this->pdo->exec(<<<SQL
DROP TABLE IF EXISTS posts;
CREATE TABLE posts (
id CHAR(36) PRIMARY KEY,
body VARCHAR (250) NOT NULL,
created_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SQL);
}
}
额外行为
interface PostRepository
{
// ...
public function size();
}
实现可能看起来像这样:
class DoctrinePostRepository implements PostRepository
{
// ...
public function size()
{
return $this->em->createQueryBuilder()
->select('count(p.id)')
->from('Domain\Model\Post', 'p')
->getQuery()
->getSingleScalarResult();
}
}
向存储库添加额外行为可能非常有益。一个例子是能够计算给定集合中所有项的能力。你可能会想到添加一个名为 count 的方法;然而,由于我们试图模仿集合,更好的名称应该是 size:
你还能够在存储库中放置特定的计算、计数器、读取优化查询或复杂命令(INSERT、UPDATE或DELETE)。然而,所有行为仍然应该遵循存储库的集合特征。你被鼓励尽可能地将逻辑移动到领域特定的无状态领域服务中,而不是简单地将这些责任添加到存储库中。
在某些情况下,你可能不需要整个聚合来简单地访问少量信息。为了解决这个问题,你可以添加存储库方法来作为快捷方式访问这些信息。你应该确保只访问可以通过聚合根导航检索到的数据。因此,你不应该允许访问聚合根的私有和内部区域,因为这会违反既定的合同协议。
对于某些用例,你可能需要非常具体的查询,这些查询是多个聚合类型的组合,每个返回特定的信息。这些查询可以运行并作为单个值对象返回。存储库返回值对象是非常常见的。
如果你发现自己正在创建许多用例最优的查找方法,你可能引入了一个常见的代码异味。这可能表明对聚合边界的判断有误。然而,如果你确信边界是正确的,那么可能是时候探索 CQRS 了。
查询存储库
在比较时,如果考虑到它们的查询能力,存储库与集合不同。存储库处理大量对象,这些对象在执行查询时通常不在内存中。在内存中加载域对象的全部实例并对其执行查询是不切实际的。
一个好的解决方案是传递一个标准并让存储库处理实现细节以成功执行操作。它可能将标准转换为 SQL 或 ORM 查询,或者遍历内存中的集合。然而,这并不重要,因为实现会处理它。
规范模式
对于标准对象的一个常见实现是规范模式。规范是一个简单的谓词,它接受一个域对象并返回一个布尔值。给定一个域对象,如果它指定了规范,则返回 true,否则返回 false:
interface PostSpecification
{
/**
* @return boolean
*/
public function specifies(Post $aPost);
}
我们只需要给我们的存储库添加一个query方法:
interface PostRepository
{
// ...
public function query($specification);
}
内存实现
例如,如果我们想通过使用内存实现的规范来在我们的 PostRepository 中复制 latestPosts 查询方法,它可能看起来像这样:
namespace Infrastructure\Persistence\InMemory;
use Domain\Model\Post;
interface InMemoryPostSpecification
{
/**
* @return boolean
*/
public function specifies(Post $aPost);
}
latestPosts 行为的内存实现可能看起来像这样:
namespace Infrastructure\Persistence\InMemory;
use Domain\Model\Post;
class InMemoryLatestPostSpecification
implements InMemoryPostSpecification
{
private $since;
public function __construct(\DateTimeImmutable $since)
{
$this->since = $since;
}
public function specifies(Post $aPost)
{
return $aPost->createdAt() > $this->since;
}
}
我们存储库实现的 query 方法可能看起来像这样:
class InMemoryPostRepository implements PostRepository
{
// ...
/**
* @param InMemoryPostSpecification $specification
*
* @return Post[]
*/
public function query($specification)
{
return $this->filterPosts(
function (Post $post) use($specification) {
return $specification->specifies($post);
}
);
}
}
从存储库中检索所有最新帖子就像创建上述实现的一个定制实例一样简单:
$latestPosts = $postRepository->query(
new InMemoryLatestPostSpecification(new \DateTimeImmutable('-24'))
);
SQL 实现
标准规范对于内存实现来说效果很好。然而,由于我们不会预先在内存中加载所有领域对象,对于 SQL 实现,我们需要一个更具体的规范来处理这些情况:
namespace Infrastructure\Persistence\Sql;
interface SqlPostSpecification
{
/**
* @return string
*/
public function toSqlClauses();
}
此规范的 SQL 实现可能看起来像这样:
namespace Infrastructure\Persistence\Sql;
class SqlLatestPostSpecification implements SqlPostSpecification
{
private $since;
public function __construct(\DateTimeImmutable $since)
{
$this->since = $since;
}
public function toSqlClauses()
{
return "created_at >'" .
$this->since->format('Y-m-d H:i:s') .
"'";
}
}
下面是一个如何查询 SQLPostRepository 实现的例子:
class SqlPostRepository implements PostRepository
{
// ...
/**
* @param SqlPostSpecification $specification
*
* @return Post[]
*/
public function query($specification)
{
return $this->retrieveAll(
'SELECT * FROM posts WHERE ' .
$specification->toSqlClauses()
);
}
private function retrieveAll($sql, array $parameters = [])
{
$st = $this->pdo->prepare($sql);
$st->execute($parameters);
return array_map(function ($row) {
return $this->buildPost($row);
}, $st->fetchAll(\PDO::FETCH_ASSOC));
}
}
管理事务
领域模型不是管理事务的地方。在领域模型上应用的操作应该对持久化机制无感知。解决此问题的常见方法是在应用程序层放置一个 外观模式,从而将相关的用例组合在一起。当从 UI 层调用外观方法时,业务方法开始一个事务。一旦完成,外观通过提交事务结束交互。如果发生任何错误,事务将被回滚:
use Doctrine\ORM\EntityManager;
class SomeApplicationServiceFacade
{
private $em;
public function __construct(EntityManager $em)
{
$this->em = $em;
}
public function doSomeUseCaseTask()
{
try {
$this->em->getConnection()->beginTransaction();
// Use domain model
$this->em->getConnection()->commit();
} catch (Exception $e) {
$this->em->getConnection()->rollback();
throw $e;
}
}
}
使用外观(Facades)引入的问题是我们必须反复重复相同的样板代码。如果我们统一执行用例的方式,我们可以使用 装饰器模式 将它们包装在事务中:
interface ApplicationService
{
/**
* @param $request
* @return mixed
*/
public function execute(BaseRequest $request);
}
class SomeApplicationService implements ApplicationService
{
public function execute(BaseRequest $request)
{
// do something
}
}
我们不希望将应用程序层与具体的交易程序耦合起来,因此我们可以为它创建一个简单的接口:
interface TransactionalSession
{
/**
* @param callable $operation
* @return mixed
*/
public function executeAtomically(callable $operation);
}
一个可以将任何应用程序服务转换为事务性的装饰器模式实现就像这样简单:
class TransactionalApplicationService implements ApplicationService
{
private $session;
private $service;
public function __construct(
ApplicationService $service,
TransactionalSession $session
) {
$this->session = $session;
$this->service = $service;
}
public function execute(BaseRequest $request)
{
$operation = function() use($request) {
return $this->service->execute($request);
};
return $this->session->executeAtomically(
$operation->bindTo($this)
);
}
}
在此之后,我们可以选择创建一个 Doctrine 事务会话实现:
class DoctrineSession implements TransactionalSession
{
private $entityManager;
public function __construct(EntityManager $entityManager)
{
$this->entityManager = $entityManager;
}
public function executeAtomically(callable $operation)
{
return $this->entityManager->transactional($operation);
}
}
现在我们已经拥有了在事务中执行用例所需的一切:
$useCase = new TransactionalApplicationService(
new SomeApplicationService(
// ...
),
new DoctrineSession(
// ...
)
);
$response = $useCase->execute();
测试存储库
为了确保存储库在生产环境中能够正常工作,我们需要测试其实现。为此,我们必须测试系统的边界,确保我们的预期是正确的。
在 Doctrine 测试的情况下,设置将稍微复杂一些:
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools;
use Domain\Model\Post;
class DoctrinePostRepositoryTest extends \PHPUnit_Framework_TestCase
{
private $postRepository;
public function setUp()
{
$this->postRepository = $this->createPostRepository();
}
private function createPostRepository()
{
$this->addCustomTypes();
$em = $this->initEntityManager();
$this->initSchema($em);
return new PrecociousDoctrinePostRepository($em);
}
private function addCustomTypes()
{
if (!Type::hasType('post_id')) {
Type::addType(
'post_id',
'Infrastructure\Persistence\Doctrine\Types\PostIdType'
);
}
if (!Type::hasType('body')) {
Type::addType(
'body',
'Infrastructure\Persistence\Doctrine\Types\BodyType'
);
}
}
protected function initEntityManager()
{
return EntityManager::create(
['url' => 'sqlite:///:memory:'],
Tools\Setup::createXMLMetadataConfiguration(
['/Path/To/Infrastructure/Persistence/Doctrine/Mapping'],
$devMode = true
)
);
}
private function initSchema(EntityManager $em)
{
$tool = new Tools\SchemaTool($em);
$tool->createSchema([
$em->getClassMetadata('Domain\Model\Post')
]);
}
// ...
}
class PrecociousDoctrinePostRepository extends DoctrinePostRepository
{
public function persist(Post $aPost)
{
parent::persist($aPost);
$this->em->flush();
}
public function remove(Post $aPost)
{
parent::remove($aPost);
$this->em->flush();
}
}
一旦我们设置了此环境,我们就可以继续测试存储库的行为:
class DoctrinePostRepositoryTest extends \PHPUnit_Framework_TestCase
{
// ...
/**
* @test
*/
public function itShouldRemovePost()
{
$post = $this->persistPost('irrelevant body');
$this->postRepository->remove($post);
$this->assertPostExist($post->id());
}
private function assertPostExist($id)
{
$result = $this->postRepository->postOfId($id);
$this->assertNull($result);
}
private function persistPost(
$body,
\DateTimeImmutable $createdAt = null
) {
$this->postRepository->add(
$post = new Post(
$this->postRepository->nextIdentity(),
new Body($body),
$createdAt
)
);
return $post;
}
}
根据我们之前的断言,如果我们保存一个 Post,我们期望在完全相同的状态中找到它。
现在我们可以继续测试通过指定一个给定日期来查找最新帖子:
class DoctrinePostRepositoryTest extends \PHPUnit_Framework_TestCase
{
// ...
/**
* @test
*/
public function itShouldFetchLatestPosts()
{
$this->persistPost(
'a year ago', new \DateTimeImmutable('-1 year')
);
$this->persistPost(
'a month ago', new \DateTimeImmutable('-1 month')
);
$this->persistPost(
'few hours ago', new \DateTimeImmutable('-3 hours')
);
$this->persistPost(
'few minutes ago', new \DateTimeImmutable('-2 minutes')
);
$posts = $this->postRepository->latestPosts(
new \DateTimeImmutable('-24 hours')
);
$this->assertCount(2, $posts);
$this->assertEquals(
'few hours ago', $posts[0]->body()->content()
);
$this->assertEquals(
'few minutes ago', $posts[1]->body()->content()
);
}
}
使用内存实现测试您的服务
设置一个完全持久的仓库实现可能很复杂,并可能导致执行缓慢。你应该关心保持你的测试快速。通过整个数据库设置然后查询会极大地减慢你的速度。拥有一个内存实现可以帮助延迟持久化决策直到最后。我们可以像以前一样进行测试,但这次,我们将使用一个功能齐全、快速简单的内存实现:
class MyServiceTest extends \PHPUnit_Framework_TestCase
{
private $service;
public function setUp()
{
$this->service = new MyService(
new InMemoryPostRepository()
);
}
}
总结
仓库是一种充当存储位置的机制。DAO 和仓库之间的区别在于,DAO 采用数据库优先的方法,通过许多低级方法查询数据库,从而降低内聚性。根据底层持久化机制的不同,我们看到了不同的仓库方法:
-
面向集合的仓库倾向于更纯粹地符合领域模型,即使它们持久化实体。从客户端的角度来看,一个面向集合的仓库看起来就像一个集合(集合)。在实体更新时,不需要对实体进行显式的持久化调用,因为仓库跟踪对象的变化。我们探讨了如何使用 Doctrine 作为此类仓库的底层持久化机制。
-
面向持久化的仓库需要显式的持久化调用,因为它们不跟踪对象变化。我们探讨了 Redis 和纯 SQL 的实现。
在这个过程中,我们发现规范(Specification)是一种帮助我们查询数据库而不牺牲灵活性和内聚性的模式。我们还研究了如何管理事务以及如何使用简单快速的内存仓库实现来测试我们的服务。
第十一章:应用程序
应用程序层是分隔领域模型(Domain Model)和查询或更改其状态的客户端的区域。应用程序服务是构建此类层的基本块。正如 Vaughn Vernon所说:“应用程序服务是领域模型的直接客户端。”你可以将应用程序服务视为外部世界(HTML 表单、API 客户端、命令行、框架、UI 等)与领域模型本身的接触点。通过考虑系统向世界公开的最高级用例可能会有所帮助,例如:“作为访客,我想注册”,“作为已登录用户,我想购买产品”,等等。
在本章中,我们将探讨如何实现应用程序服务,了解命令模式的作用,并确定应用程序服务的责任。为此,让我们考虑注册新用户的用例。
从概念上讲,为了注册新用户,我们需要:
-
从客户端获取 email 和密码
-
检查 email 是否已被使用
-
创建一个新用户
-
将此新用户添加到现有用户集
-
返回我们刚刚创建的用户
让我们开始吧。
请求
我们需要将email和password发送到应用程序服务。从客户端(HTML 表单、API 客户端,甚至是命令行)有多种方法可以这样做。我们可以直接通过方法签名发送标准参数(email 和 password),或者构建并发送包含这些信息的数据结构。后一种方法,发送DTO,在桌面上带来了一些有趣的功能。通过发送对象,它将被序列化并排队在命令总线(Command Bus)上。它还可能添加类型安全和一些 IDE 帮助。
数据传输对象
DTO(数据传输对象)是一种在进程之间携带信息的数据结构。不要将其误认为是具有完整功能的对象。DTO 除了存储和检索自己的数据(访问器和修改器)之外,没有任何行为。DTO 是简单的对象,不应包含任何需要测试的业务逻辑。
如 Vaughn Vernon所说:
应用程序服务方法签名仅使用原始类型(int、strings等),以及可能的 DTO。然而,作为这些方法的替代方案,更好的方法可能是设计命令对象。这并不一定有对或错。这主要取决于你的品味和目标。
用于存储应用程序服务所需数据的 DTO 实现可能如下所示:
namespace Lw\Application\Service\User;
class SignUpUserRequest
{
private $email;
private $password;
public function __construct($email, $password)
{
$this->email = $email;
$this->password = $password;
}
public function email()
{
return $this->email;
}
public function password()
{
return $this->password;
}
}
如你所见,SignUpUserRequest没有行为,只有数据。这可以来自 HTML 表单或 API 端点,尽管我们不在乎它是哪一个。
构建应用程序服务请求
从交付机制、你最喜欢的框架中创建请求应该相当直接。在网络上,你可以从控制器请求中提取参数,并将它们传递到 DTO 中的服务内部。同样的原则也适用于 CLI 命令:读取输入参数,然后再发送下去。
使用 Symfony,我们可以从HttpFoundation组件中提取我们需要的请求对象数据:
// ...
class UsersController extends Controller
{
/**
* @Route('/signup', name = 'signup')
* @param Request $request
* @return Response
*/
public function signUpAction(Request $request)
{
// ...
$signUpUserRequest = new SignUpUserRequest(
$request->get('email'),
$request->get('password')
);
// ...
}
// ...
在一个更复杂的 Silex 应用程序中,它使用Form组件来捕获和验证参数,看起来会是这样:
// ...
$app->match('/signup', function (Request $request) use ($app) {
$form = $app['sign_up_form'];
$form->handleRequest($request);
if ($form->isValid()) {
$data = $form->getData();
try {
$app['sign_in_user_application_service']->execute(
new SignUpUserRequest(
$data['email'],
$data['password']
)
);
return $app->redirect(
$app['url_generator']->generate('login')
);
} catch (UserAlreadyExistsException $e) {
$form
->get('email')
->addError(
new FormError(
'Email is already registered by another user'
)
);
} catch (Exception $e) {
$form
->addError(
new FormError(
'There was an error, please get in touch with us'
)
);
}
}
return $app['twig']->render('signup.html.twig', [
'form' => $form->createView(),
]);
});
请求设计
当设计你的请求对象时,你应该始终遵循以下原则:使用原始数据类型,设计为可序列化,并且不要在内部包含业务逻辑。这样,你将能够节省单元测试的成本。
使用原始数据类型
我们建议使用基本类型来构建你的请求对象——这意味着字符串、整数、布尔值等等。我们只是在抽象化输入参数。你应该能够独立于交付机制来消费应用程序服务。即使是相当复杂的 HTML 表单,在控制器级别也总是被转换成基本类型。你不希望混淆你的框架和业务逻辑。
在某些场景下,直接使用值对象可能很有诱惑力。不要这样做。值对象定义的更新将影响所有客户端,并且你将客户端与你的领域逻辑耦合起来。
可序列化
使用基本类型的一个酷炫副作用是,任何请求对象都可以轻松地序列化为字符串,通过网络发送,并存储在消息系统或数据库中。
没有业务逻辑
避免在请求对象中放置任何业务逻辑——甚至验证。验证应该在领域内部进行——这是在实体、值对象、领域服务等内部。验证是强制执行业务不变性和领域约束的一种方式。
没有测试
应用程序请求是数据结构,而不是对象。单元测试数据结构就像测试 getter 和 setter 一样。没有行为可以测试,所以尝试单元测试请求对象和 DTOs 的价值不大。这些结构将作为更复杂测试(如集成测试或验收测试)的副作用得到覆盖。
命令是请求对象的替代方案。我们可以设计一个具有多个应用程序方法的 Service,每个方法都有你会在请求中放入的参数。这对于简单应用程序来说是可行的,但我们会稍后再讨论这个话题。
应用程序服务的解剖结构
一旦我们将数据封装在请求中,就是业务逻辑的时间了。正如 Vaughn Vernon所说:“保持应用程序服务瘦,只使用它们在模型上协调任务。”
首先要做的是从请求中提取必要的信息,即email和password。从高层次来看,我们需要检查是否存在具有特定电子邮件的现有用户。如果不是这种情况,那么我们创建并添加用户到UserRepository。在找到具有相同电子邮件的用户这一特殊情况下,我们抛出异常,以便客户端可以按自己的方式处理——通过显示错误、重试或简单地忽略它:
namespace Lw\Application\Service\User;
use Ddd\Application\Service\ApplicationService;
use Lw\Domain\Model\User\User;
use Lw\Domain\Model\User\UserAlreadyExistsException;
use Lw\Domain\Model\User\UserRepository;
class SignUpUserService
{
private $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
public function execute(SignUpUserRequest $request)
{
$email = $request->email();
$password = $request->password();
$user = $this->userRepository->ofEmail($email);
if ($user) {
throw new UserAlreadyExistsException();
}
$this->userRepository->add(
new User(
$this->userRepository->nextIdentity(),
$email ,
$password
)
);
}
}
很好!如果你想知道这个UserRepository在构造函数中做什么,我们将在下一部分向你展示。
异常处理
应用程序服务抛出的异常是向客户端传达异常情况和流程的一种方式。这一层的异常与业务逻辑(如找不到用户)有关,而不是与实现细节(如PDOException、PredisException或DoctrineException)有关。
依赖倒置
处理用户不是服务的责任。正如我们在第十章章节 10,“存储库”中看到的,有一个专门处理User集合的类:User存储库。这是应用程序服务对存储库的依赖。我们不希望应用程序服务与存储库的具体实现耦合,因为那样的话,我们的服务将与基础设施细节耦合。因此,我们依赖于具体实现所依赖的合同(接口),即UserRepository。
将UserRepository的具体实现构建并传递到运行时——例如,使用DoctrineUserRepository,这是一个使用 Doctrine 的具体实现。传递一个具体实现也可以在测试时工作。例如,NotAvailableUserRepository可以是一个具体实现,每次执行操作时都会抛出异常。这样,我们可以测试所有应用程序服务的所有行为,包括悲伤路径,即当应用程序必须正确行为,即使某些事情出错时。
应用程序服务也可以依赖于领域服务,如GetBadgesByUser。在运行时,此类服务的实现可能相当复杂。想象一下一个HttpGetBadgesByUser,它通过 HTTP 协议集成一个边界上下文。
根据抽象,我们将使我们的应用程序服务免受底层基础设施更改的影响。
实例化应用程序服务
实例化应用程序服务本身很容易,但构建依赖树可能很棘手,这取决于构建依赖的复杂性。为此目的,大多数框架都提供了一个依赖注入容器。如果没有,你最终会在你的控制器中的某个地方得到以下代码:
$redisClient = new Predis\Client([
'scheme' => 'tcp',
'host' => '10.0.0.1',
'port' => 6379
]);
$userRepository = new RedisUserRepository($redisClient);
$signUp = new SignUpUserService($userRepository);
$signUp->execute(new SignUpUserRequest(
'user@example.com',
'password'
));
我们决定使用 Redis 实现来为 UserRepository。在之前的代码示例中,我们构建了构建使用 Redis 内部实现的仓库所需的所有依赖项。这些依赖项包括:一个 Predis 客户端,以及连接到我们的 Redis 服务器的所有参数。这不仅效率低下,而且还在控制器中传播了重复代码。
你可以将构建逻辑重构为一个工厂,或者你可以使用依赖注入容器——大多数现代框架都自带这个功能。
使用依赖注入容器是不是坏事?
完全不是。依赖注入容器只是一个工具。它们通过抽象出构建依赖项的复杂性来帮助你。它们在构建基础设施工件时非常有用。Symfony 提供了一个完整的解决方案。
请注意,将整个容器作为一个整体传递给某个服务是一种不良做法。这就像将应用程序的整个上下文与领域耦合在一起。如果一个服务需要特定的对象,从你的框架中构建它们,并将它们作为依赖项传递给服务,但不要让该服务了解整个上下文。
让我们看看如何在 Silex 中构建依赖项:
$app = new \Silex\Application();
$app['redis_parameters'] = [
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379
];
$app['redis'] = $app->share(function ($app) {
return new Predis\Client($app['redis_parameters']);
});
$app['user_repository'] = $app->share(function($app) {
return new RedisUserRepository(
$app['redis']
);
});
$app['sign_up_user_application_service'] = $app->share(function($app) {
return new SignUpUserService(
$app['user_repository']
);
});
// ...
$app->match('/signup' ,function (Request $request) use ($app) {
// ...
$app['sign_up_user_application_service']->execute(
new SignUpUserRequest(
$request->get('email'),
$request->get('password')
)
);
// ...
});
正如你所见,$app 被用作服务容器。我们注册所有需要的组件及其依赖项。sign_up_user_application_service 依赖于上面定义的内容。更改 user_repository 的实现就像返回其他东西(MySQL、MongoDB 等)一样简单,所以我们根本不需要更改服务代码。
对于 Symfony 应用程序,其等效操作如下:
<?xml version=" 1.0" ?>
<container
xsi:schemaLocation="
http://symfony.com/schema/dic/services
http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service
id="sign_up_user_application_service"
class="SignUpUserService">
<argument type="service" id="user_repository" />
</service>
<service
id="user_repository"
class="RedisUserRepository">
<argument type="service">
<service class="Predis\Client" />
</argument>
</service>
</services>
</container>
现在你已经在 Symfony 服务容器中定义了你的应用程序服务,稍后获取它就非常简单了。所有交付机制——Web 控制器、REST 控制器,甚至是控制台命令——都共享相同的定义。服务在实现 ContainerAware 接口的任何类上都是可用的。获取服务就像调用 $this->get('sign_up_user_application_service') 一样简单。
总结一下,你如何构建你的服务(临时、使用服务容器、使用工厂等)并不重要。然而,保持你的应用程序服务设置在基础设施边界之外是很重要的。
定制应用程序服务
定制你的应用程序服务的主要方式是选择你传递的依赖项。根据你的服务容器功能,这可能会有些棘手,因此你还可以添加一个 setter 来动态更改依赖项。例如,你可能需要更改输出依赖项,以便你可以设置一个默认值,然后之后再更改它。如果逻辑变得过于复杂,你可以创建一个应用程序服务工厂来为你处理这种情况。
执行
调用应用服务有两种不同的方法:每个用例一个专用类,具有单个执行方法,以及同一类中的多个应用服务和用例。
每个应用服务一个类
这是我们首选的方法,可能也是适合所有场景的方法:
class SignUpUserService
{
// ...
public function execute(SignUpUserRequest $request)
{
// ...
}
}
使用每个应用服务一个专用类可以使代码更健壮,抵御外部变化(单一职责原则)。由于服务只做一件事情,因此更改类的理由更少。由于应用服务做的事情较少,因此它将更容易进行测试。实现一个通用的应用服务合同更容易,这使得类装饰更容易(参见第十章的子节事务,存储库)。这还将导致更高的内聚性,因为所有依赖项都专门用于单个用例。
execution方法可以有一个更具表达力的名称,比如signUp。然而,执行命令模式格式标准化了应用服务之间的通用合同,从而使得装饰变得容易,这在事务中很有用。
每个类中多个应用服务方法
有时候,将连贯的应用服务分组在同一类下可能是个好主意:
class UserService
{
// ...
public function signUp(SignUpUserRequest $request)
{
// ...
}
public function signIn(SignUpUserRequest $request)
{
// ...
}
public function logOut(LogOutUserRequest $request)
{
// ...
}
}
我们不推荐这种方法,因为并非所有应用服务都是 100%连贯的。一些服务将需要不同的依赖项,你最终会得到依赖它们不需要的东西的应用服务。另一个问题是这种类型的类会迅速增长。因为它违反了单一职责原则,所以会有多个理由去更改它,甚至可能破坏它。
返回值
在注册后,我们可能会考虑将用户重定向到个人资料页面。将所需信息直接返回给控制器的自然方式是从服务中返回用户实体:
class SignUpUserService
{
// ...
public function execute(SignUpUserRequest $request)
{
$user = new User(
$this->userRepository->nextIdentity(),
$email,
$password
);
$this->userRepository->add($user);
return $user;
}
}
然后,从控制器中,我们会获取id字段并重定向到其他地方。然而,仔细考虑我们刚才所做的事情。我们返回了一个功能齐全的实体给控制器,这将允许交付机制绕过应用层并直接与领域交互。
假设User实体提供了一个updateEmailAddress方法。你可以尝试阻止它,但在未来的某个时刻,有人可能会考虑使用它:
$app-> match( '/signup' , function (Request $request) use ($app) {
// ...
$user = $app['sign_up_user_application_service']->execute(
new SignUpUserRequest(
$request->get('email'),
$request->get('password'))
);
$user->updateEmailAddress('shouldnotupdate@email.com');
// ...
});
不仅如此,表示层所需的数据与领域管理的数据并不相同。我们不希望领域层围绕表示层进行演变和耦合。相反,我们希望它们自由地演变。
要做到这一点,我们需要一种灵活的方式来解耦这两层。
从聚合实例中获取 DTO
我们可以返回带有表示层所需信息的无状态数据结构。正如我们之前所看到的,DTOs(数据传输对象)适合这种场景。我们只需要在应用服务中组合它们,然后返回给客户端:
class UserDTO
{
private $email ;
// ...
public function __construct(User $user)
{
$this->email = $user->email ();
// ...
}
public function email ()
{
return $this->email ;
}
}
UserDTO将暴露我们在表示层从User实体中需要的所有只读数据,从而避免暴露行为:
class SignUpUserService
{
public function execute(SignUpUserRequest $request)
{
// ...
$user = // ...
return new UserDTO($user);
}
}
任务完成。现在我们可以向模板引擎传递参数,并将它们转换为小部件、标签或子模板,或者对表示层上的数据进行任何我们想要的操作:
$app->match('/signup' , function (Request $request) use ($app) {
/**
* @var UserDTO $user
*/
$userDto=$app['sign_up_user_application_service']->execute(
new SignUpUserRequest(
$request->get('email'),
$request->get('password')
)
);
// ...
});
然而,让应用服务决定如何构建 DTO 揭示了另一个限制。因为构建 DTO 完全取决于应用服务,所以适应不同客户端的 DTO 将非常困难。考虑 Web 控制器上的重定向所需的数据和同一用例的 REST 响应所需的数据。这根本不是相同的数据。
让客户端通过传递特定的 DTO 组装器来定义如何构建 DTO:
class SignUpUserService
{
private $userDtoAssembler;
public function __construct(
UserRepository $userRepository,
UserDTOAssembler $userDtoAssembler
) {
$this->userRepository = $userRepository;
$this->userDtoAssembler = $userDtoAssembler;
}
public function execute(SignUpUserRequest $request)
{
$user = // ...
return $this->userDtoAssembler->assemble($user);
}
}
现在客户端可以通过传递特定的UserDTOAssembler来自定义响应。
数据转换器
在某些情况下,为更复杂的响应(如 JSON、XML、CSV 和 iCAL 联系人)生成中间 DTO 可能被视为不必要的开销。我们可以在缓冲区中输出表示,并在交付时再请求它。
转换器通过将高级领域概念转换为低级客户端细节来帮助减少这种开销。让我们看看一个例子:
interface UserDataTransformer
{
public function write(User $user);
/**
* @return mixed
*/
public function read();
}
考虑为给定产品生成不同数据表示的情况。通常,产品信息通过 Web 界面(HTML)提供,但我们可能对提供其他格式(如 XML、JSON 或 CSV)感兴趣。这可能使与其他服务的集成成为可能。
考虑一个类似的博客案例。我们可能将我们的写作潜力通过 HTML 展示给世界,但有些人可能对通过 RSS 消费我们的文章感兴趣。用例——应用服务——保持不变。表示则不同。
DTOs(数据传输对象)是一个干净且简单的解决方案,可以传递给模板引擎以实现不同的表示,但这可能会使数据转换的最后一步的逻辑变得复杂,因为这样的模板的逻辑可能成为维护、测试和理解的问题。
在特定情况下,数据转换器可能是一个更好的方法。这些只是以领域概念(聚合、实体等)作为输入,以只读表示(XML、JSON、CSV 等)作为输出的黑盒。这些转换器可能非常容易测试:
class JsonUserDataTransformer implements UserDataTransformer
{
private $data;
public function write(User $user)
{
// More complex logic could be placed here
// As using JMSSerializer, native json, etc.
$this->data = json_encode($user);
}
/**
* @return string
*/
public function read()
{
return $this->data;
}
}
这很简单。想知道 XML 或 CSV 的样子吗?让我们看看如何将数据转换器集成到我们的应用服务中:
class SignUpUserService
{
private $userRepository;
private $userDataTransformer;
public function __construct(
UserRepository $userRepository,
UserDataTransformer $userDataTransformer
) {
$this->userRepository = $userRepository;
$this->userDataTransformer = $userDataTransformer;
}
public function execute(SignUpUserRequest $request)
{
$user = // ...
$this->userDataTransformer()->write($user);
}
/**
* @return UserDataTransformer
*/
public function userDataTransformer()
{
return $this->userDataTransformer;
}
}
这与 DTO 组装器方法类似,但这次没有返回具体值。数据转换器被用来持有和交互数据。
DTO 的问题主要在于编写它们的开销。大多数时候,你的领域概念和 DTO 表示将呈现相同的结构。大多数时候,你可能会觉得花时间进行这种映射不值得。话虽如此,表示和聚合之间的关系不是 1:1。你可以在单个表示中一起表示两个聚合。你也可以用多种方式表示相同的聚合。你如何做总是取决于你的用例。
然而,根据 马丁·福勒:
有一种情况下使用类似 DTO 的东西是有用的,那就是当你的表示层模型与底层领域模型之间存在显著不匹配时。在这种情况下,创建一个针对表示层的特定外观/网关,它将领域模型映射到方便表示的接口是有意义的。它与表示模型很好地结合在一起。这样做是值得的,但只有在存在这种不匹配的屏幕上才值得(在这种情况下,这不是额外的工作,因为你本来也必须在屏幕上这样做。)
我们认为长期愿景将值得投资。在中到大型的项目中,界面表示和领域概念的变化节奏非常不同。你可能希望将它们彼此解耦,以降低更新的摩擦。使用 DTO 或数据转换器允许你自由地发展你的模型,而无需总是考虑破坏布局。
复合布局上的多个应用程序服务
大多数时候,没有布局像单个应用程序服务那样简单。我们的项目有相当复杂的界面。
考虑一个特定项目的首页。我们如何渲染这么多部分和用例?有几个选项,让我们来看看。
AJAX 内容集成
你可以让浏览器直接请求不同的端点,并通过 AJAX 或 Hijax 在布局中立即组合数据。这将避免在你的控制器中混合大量的应用程序服务,但可能会因为触发的请求数量而带来性能损失。
ESI 内容集成
边缘侧包含(ESI)是一种类似于之前方法的微小标记语言,但它在服务器端。它需要额外的努力来配置额外的中间件,如 NGINX 或 Varnish,以使其工作。包含(ESI)是一种类似于之前方法的微小标记语言,但它在服务器端。它需要额外的努力来配置额外的中间件,如 NGINX 或 Varnish,以使其工作。
Symfony 子请求
如果你使用 Symfony,子请求可能是一个有趣的选择。根据 Symfony 文档:
除了发送到HttpKernel::handle的主要请求之外,你还可以发送所谓的子请求。子请求看起来和表现得像任何其他请求一样,但通常只用于渲染页面的一小部分而不是整个页面。你通常会在控制器(或可能是在控制器渲染的模板内部)中创建子请求。这创建了一个新的完整请求-响应周期,其中这个新的请求被转换为一个响应。唯一的内部区别是,一些监听器(例如:安全)可能只对主请求起作用。每个监听器都会传递一些KernelEvent的子类,其中isMasterRequest()可以用来检查当前请求是主请求还是子请求。
这很棒,因为你可以获得调用单独应用程序服务的优势,而不受 AJAX 惩罚或复杂的 ESI 配置。
一个控制器,多个应用程序服务
最后一个选项可能是管理同一控制器内的多个应用程序服务,尽管控制器逻辑可能会变得有些复杂,因为它将处理和合并响应以传递给视图。
测试应用程序服务
由于你对测试应用程序服务本身的行为感兴趣,没有必要将其转换为与真实数据库进行复杂设置的集成测试。你不对测试低级细节感兴趣,所以大多数情况下,单元测试就足够了:
class SignUpUserServiceTest extends \PHPUnit_Framework_TestCase
{
/**
* @var \Lw\Domain\Model\User\UserRepository
*/
private $userRepository;
/**
* @var SignUpUserService
*/
private $signUpUserService;
public function setUp()
{
$this->userRepository = new InMemoryUserRepository();
$this->signUpUserService = new SignUpUserService(
$this->userRepository
);
}
/**
* @test
* @expectedException
* \Lw\Domain\Model\User\UserAlreadyExistsException
*/
public function alreadyExistingEmailShouldThrowAnException()
{
$this->executeSignUp();
$this->executeSignUp();
}
private function executeSignUp()
{
return $this->signUpUserService->execute(
new SignUpUserRequest(
'user@example.com',
'password'
)
);
}
/**
* @test
*/
public function afterUserSignUpItShouldBeInTheRepository()
{
$user = $this->executeSignUp();
$this->assertSame(
$user,
$this->userRepository->ofId($user->id())
);
}
}
我们已经为User存储库使用了一个内存实现。这被称为模拟:一个完全功能化的存储库实现,将使我们的单元测试工作正常。我们不需要访问数据库来测试这个类的行为。这会使我们的测试变慢且脆弱。
检查域事件提交可能也有趣。如果创建用户触发了用户注册事件,确保它已被触发可能是个好主意:
class SignUpUserServiceTest extends \PHPUnit_Framework_TestCase
{
// ...
/**
* @test
*/
public function itShouldPublishUserRegisteredEvent()
{
$subscriber = new SpySubscriber();
$id = DomainEventPublisher::instance()->subscribe($subscriber);
$user = $this->executeSignUp();
$userId = $user->id();
DomainEventPublisher::instance()->unsubscribe($id);
$this->assertUserRegisteredEventPublished(
$subscriber, $userId
);
}
private function assertUserRegisteredEventPublished(
$subscriber, $userId
) {
$this->assertInstanceOf(
'UserRegistered', $subscriber->domainEvent
);
$this->assertTrue(
$subscriber->domainEvent->userId()->equals($userId)
);
}
}
class SpySubscriber implements DomainEventSubscriber
{
public $domainEvent;
public function handle($aDomainEvent)
{
$this->domainEvent = $aDomainEvent;
}
public function isSubscribedTo($aDomainEvent)
{
return true;
}
}
事务
事务是与持久化机制相关的实现细节。领域层不应该意识到这个低级实现细节。在这个级别考虑开始、提交或回滚事务是一个很大的问题。这个级别的细节属于基础设施层。
处理事务的最佳方式是根本不处理它们。我们可以用处理事务会话的装饰器实现来包装我们的应用程序服务。
我们在我们的存储库中实现了一个解决方案来解决这个问题,你可以在这里查看它:
interface TransactionalSession
{
/**
* @return mixed
*/
public function executeAtomically(callable $operation);
}
此合约接受一段代码并原子性地执行它。根据你的持久化机制,你将得到不同的实现。
让我们看看我们如何使用 Doctrine ORM 来实现它:
class DoctrineSession implements TransactionalSession
{
private $entityManager;
public function __construct(EntityManager $entityManager)
{
$this->entityManager = $entityManager;
}
public function executeAtomically(callable $operation)
{
return $this->entityManager->transactional($operation);
}
}
这就是客户端如何使用之前代码的方式:
/** @var EntityManager $em */
$nonTxApplicationService = new SignUpUserService(
$em->getRepository('BoundedContext\Domain\Model\User\User')
);
$txApplicationService = new TransactionalApplicationService(
$nonTxApplicationService,
new DoctrineSession($em)
);
$response = $txApplicationService->execute(
new SignUpUserRequest(
'user@example.com',
'password'
)
);
现在我们有了事务性会话的 Doctrine 实现,创建一个用于我们的应用服务的装饰器将非常棒。采用这种方法,我们使事务性请求对领域透明:
class TransactionalApplicationService implements ApplicationService
{
private $session;
private $service;
public function __construct(
ApplicationService $service, TransactionalSession $session
) {
$this->session = $session;
$this->service = $service;
}
public function execute(BaseRequest $request)
{
$operation = function () use ($request) {
return $this->service->execute($request);
};
return $this->session->executeAtomically($operation);
}
}
使用 Doctrine 会话的一个很好的副作用是它自动管理 flush 方法,因此你不需要在领域或基础设施中添加 flush。
安全
如果你想知道如何管理和处理用户凭证和一般安全,除非这是你的领域的责任,我们建议让框架来处理。用户会话是交付机制的关心点。将此类概念引入领域将使开发变得更加困难。
领域事件
在应用服务执行之前必须配置领域事件监听器,否则没有人会注意到。在某些情况下,你必须在执行应用服务之前明确配置监听器:
// ...
$subscriber = new SpySubscriber();
DomainEventPublisher::instance()->subscribe($subscriber);
$applicationService = // ...
$applicationService->execute(...);
大多数情况下,这将通过配置依赖注入容器来完成。
命令处理器
通过命令总线库执行应用服务是一种有趣的方式。一个好的选择是Tactician。从 Tactician 网站:
命令总线是什么?这个术语通常在我们将命令模式与服务层结合使用时使用。它的任务是取一个命令对象(描述用户想要做什么)并将其与处理器(执行它)匹配。这可以帮助你整洁地组织代码。
— 我们的应用服务是服务层,我们的请求对象看起来几乎像是命令。
没问题——我们的应用服务是服务层,我们的请求对象看起来几乎像是命令。如果我们有一个机制来链接所有应用服务,并根据请求执行正确的服务,那岂不是很好?实际上,这就是命令总线的作用。
战术家库和其他选项
Tactician 是一个命令总线库,它允许你为你的应用服务使用命令模式。它特别方便用于应用服务,但你也可以使用任何类型的输入。
让我们看看Tactician网站上的一个例子:
// You build a simple message object like this:
class PurchaseProductCommand
{
protected $productId;
protected $userId;
// ...and constructor to assign those properties...
}
// And a Handler class that expects it:
class PurchaseProductHandler
{
public function handle(PurchaseProductCommand $command)
{
// use command to update your models, etc
}
}
// And then in your Controllers, you can fill in the command using your favorite
// form or serializer library, then drop it in the CommandBus and you're done!
$command = new PurchaseProductCommand(42, 29);
$commandBus->handle($command);
就这样。Tactician 是$commandBus服务。它负责找到正确的处理器和方法的所有管道工作,这可以避免大量的样板代码。在这里,命令和处理器只是普通类,但你可以根据你的应用更好地配置它们。
总结来说,我们可以得出结论,命令只是请求对象,命令处理器只是应用服务。
Tactician(以及命令总线一般)的一个酷特点是它们非常容易扩展。Tactician 为常见任务提供了插件,如日志记录和数据库事务。这样,你就可以忘记在每个处理器上设置连接了。
Tactician 的另一个有趣的插件是 Bernard 集成。Bernard 是一个异步作业队列,允许你将一些任务留待稍后处理。重处理过程会阻塞响应。大多数时候,我们可以分支并延迟它们的执行。为了获得最佳体验,尽快回答客户的问题,并在分支过程完成后通知他们。
Matthias Noback 开发了一个类似的项目,名为 SimpleBus,它可以作为 Tactician 的替代品。主要区别在于 SimpleBus 命令处理器没有返回值。
总结
应用服务代表了你的边界上下文中的应用层。这些高级用例应该是相对简单和精简的,因为它们的目的主要围绕领域协调。应用服务是领域逻辑交互的入口点。我们注意到请求和命令有助于保持事物的组织性;DTOs(数据传输对象)和数据转换器使我们能够将数据表示与领域概念解耦;使用依赖注入容器构建应用服务相当直接;并且我们在复杂布局中组合应用服务有许多选择。
第十二章:集成边界上下文
每个企业级应用程序通常由公司运营的几个区域组成。例如,计费、库存、运输管理、目录等区域是常见的例子。管理所有这些关注点的最简单方法可能倾向于采用单体系统。但是,你可能想知道,这必须是这样吗?如果通过将这个大单体应用程序拆分成更小、更独立的块来减少在这些不同区域工作的团队之间的摩擦,会怎么样呢?在本章中,我们将探讨如何做到这一点,因此请准备好了解关于战略设计的见解和启发式方法。
处理分布式系统
处理分布式系统是困难的。将系统分解成独立的自主部分有其好处,但它也增加了复杂性。例如,分布式系统的协调和同步不是微不足道的,因此应该仔细考虑。正如马丁·福勒在PoEAA一书中所说,分布式系统的第一定律始终是:不要分布式。
通过数据存储进行集成
集成应用程序不同部分的最常用技术之一始终是共享相同的数据存储库和相同的代码库。这通常被称为单体应用,并且通常最终会形成一个单一的数据存储库,它托管着与应用程序中所有相关关注点相关的数据。
考虑一个电子商务应用程序。共享的数据存储库将包含所有关注点(例如:关系型数据库中的表)周围的相关内容,如目录、计费、库存等。这种方法本身并没有问题——例如,在小型线性应用程序中,复杂性不是太高。然而,在复杂的领域内,可能会出现一些问题。如果你在多个涉及多个应用程序关注点的表中共享数据,事务将对性能产生重大影响。
另一个可能出现的非技术问题与通用语言有关。边界上下文分离的主要优势是每个边界上下文都有一个单一的通用语言。通过这样做,模型将被分离到它们自己的上下文中。在同一个上下文中混合所有模型可能会导致歧义和混淆。
回到电子商务系统,假设我们想要引入 T 恤的概念。在目录上下文中,T 恤将是一个具有如颜色、尺寸、材质和可能的一些花哨的图片等属性的产品。然而,在库存系统中,我们并不真正关心这些事情。在这里,产品有不同的含义,我们关注的是不同的属性,如重量、仓库中的位置或尺寸。将这两个上下文混合在一起会混淆概念并复杂化设计。在领域驱动设计的术语中,以这种方式混合概念被称为共享内核。
共享内核
指定一些领域模型子集,让团队达成共识并共享。当然,这包括与该模型部分相关的代码子集或数据库设计子集。这些明确共享的内容具有特殊地位,并且未经与其他团队协商不应更改。频繁地集成功能系统,但频率略低于团队内部持续集成的步伐。在这些集成中,运行两个团队的测试。埃里克·埃文斯 - 领域驱动设计:软件核心的复杂性处理
我们不建议使用共享内核,因为多个团队在开发过程中可能会发生冲突,这不仅会导致维护问题,还可能成为摩擦的焦点。然而,如果您选择使用共享内核,应在所有相关方之间事先达成一致。从概念上讲,这种方法还有其他问题,比如人们将其视为一个可以放置不属于其他任何地方的东西的袋子,并且这种东西会无限增长。处理单体不断增长的复杂性的更好方法是将它拆分成不同的自主部分,例如通过 REST、RPC 或消息系统进行通信。这需要绘制清晰的边界,每个上下文可能最终都会拥有自己的基础设施——数据存储、服务器、消息中间件等等——甚至自己的团队。
如您所想,这可能会导致一定程度的重叠,但这是我们为了减少复杂性而愿意做出的权衡。在领域驱动设计中,我们称这些独立部分为边界上下文。
集成关系
客户 - 供应商
当两个边界上下文之间存在单向集成时,其中一个充当提供者(上游),另一个充当客户端(下游),我们将得到客户 - 供应商开发团队。
在两个团队之间建立清晰的客户/供应商关系。在规划会议中,让下游团队扮演上游团队的客户角色。协商并预算下游团队的需求任务,以便每个人都能理解承诺和进度。共同开发将验证预期接口的自动化验收测试。将这些测试添加到上游团队的测试套件中,作为其持续集成的一部分。这种测试将使上游团队在没有下游副作用恐惧的情况下进行更改。埃里克·埃文斯 - 领域驱动设计:软件核心的复杂性处理。
客户-供应商开发团队是有界上下文集成最常见的方式,并且当团队紧密合作时通常代表双赢的局面。
分道扬镳
继续以电子商务为例,考虑向一个旧的遗留零售商财务系统报告收入。这种集成可能非常昂贵,以至于不值得投入精力去实施。在领域驱动设计的战略术语中,这被称为分道扬镳。
集成总是昂贵的。有时好处很小。因此,宣布一个有界上下文与其它上下文没有任何联系,允许开发者在这样一个小范围内找到简单、专业的解决方案。埃里克·埃文斯 - 领域驱动设计: 软件核心的复杂性处理。
顺从者
再次考虑电子商务示例和与第三方物流服务的集成。这两个领域在模型、团队和基础设施方面都存在差异。负责维护第三方物流服务的团队不会参与你的产品规划或为电子商务系统提供任何解决方案。这些团队之间没有紧密的关系。我们可以选择接受并顺从他们的领域模型。在战略设计中,我们称之为顺从集成。
通过盲目遵循上游团队的模型来消除有界上下文之间翻译的复杂性。尽管这可能会限制下游设计师的风格,并且可能不会产生理想的应用模型,但选择一致性可以极大地简化集成。此外,你将与供应商团队共享一个无处不在的语言。供应商处于主导地位,因此让他们的沟通变得容易是好事。利他主义可能足以让他们与你分享信息。埃里克·埃文斯 - 领域驱动设计: 软件核心的复杂性处理。
实施有界上下文集成
为了简化事情,我们将假设有界上下文有一个客户-供应商关系。
现代 RPC
在现代 RPC 中,我们通过 RESTful 资源来指代 RPC。一个有界上下文揭示了一个清晰的接口,用于与外界交互。它暴露了可以通过 HTTP 动词进行操作的资源。我们可以这样说,有界上下文提供了一套服务和操作。从战略的角度来看,这被称为开放主机服务。
开放主机服务
定义一个协议,该协议将您的子系统作为一组服务提供访问。开放协议,以便所有需要与您集成的人都可以使用它。增强和扩展协议以处理新的集成需求,除非单个团队有特殊需求。然后,使用一次性翻译器来增强该特殊情况的协议,以便共享协议可以保持简单和一致。埃里克·埃文斯 - 领域驱动设计: 在软件核心处理复杂性.
让我们探索这本书的 GitHub 组织提供的最后愿望应用程序中的示例。
该应用程序是一个旨在让人们在他们去世之前保存他们的遗嘱的 Web 平台。有两个上下文:一个负责处理遗嘱的有界上下文(Will Bounded Context),另一个负责给系统用户评分的游戏化上下文。在遗嘱上下文中,用户可能会有与他们在游戏化上下文中获得的积分相关的徽章。这意味着我们需要将这两个上下文集成在一起,以便显示用户在遗嘱上下文中的徽章。
游戏化上下文是一个完整的事件驱动应用程序,由一个定制的事件源引擎提供支持。它是一个全栈 Symfony 应用程序,使用FOSRestBundle、BazingaHateoasBundle、JMSSerializerBundle、NelmioApiDocBundle和OngrElasticsearchBundle来提供 3 级及以上 REST API(通常称为 REST 的荣耀),根据理查森成熟度*模型。在这个上下文中触发的事件都会投影到一个 Elasticsearch 服务器上,以产生视图所需的数据。我们将通过类似http://gamification.context.host/api/users/{id}的端点公开特定用户的积分数量。
我们还将从 Elasticsearch 获取用户投影,并将其序列化为与客户端先前协商的格式:
namespace AppBundle\Controller;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\FOSRestController;
use Nelmio\ApiDocBundle\Annotation\ApiDoc;
class UsersController extends FOSRestController
{
/**
* @ApiDoc(
* resource = true,
* description = "Finds a user given a user ID",
* statusCodes = {
* 200 = "Returned when the user have been found",
* 404 = "Returned when the user could not be found"
* }
* )
*
* @Rest\View(
* statusCode = 200
* )
*/
public function getUserAction($id)
{
$repo = $this->get('es.manager.default.user');
$user = $repo->find($id);
if (!$user) {
throw $this->createNotFoundException(
sprintf(
'A user with an ID of %s does not exist',
$id
)
);
}
return $user;
}
}
正如我们在第二章中解释的,架构风格的读取被视为基础设施关注点,因此不需要将它们包裹在命令/命令处理流程中。
用户的结果 JSON+HAL 表示形式如下:
{
"id": "c3c587c6-610a-42df",
"points": 0,
"_links": {
"self": {
"href":
"http://gamification.ctx/api/users/c3c587c6-610a-42df"
}
}
}
现在,我们处于整合两个上下文的好位置。我们只需要在 Will 上下文中编写客户端来消费我们刚刚创建的端点。我们应该混合这两个领域模型吗?直接消化游戏化上下文将意味着将 Will 上下文调整为游戏化上下文,从而导致同质化的集成。然而,分离这些关注点似乎值得付出努力。我们需要一个层来保证领域模型在 Will 上下文中的完整性和一致性,并且我们需要将分数(游戏化)转换为徽章(Will)。在领域驱动设计中,这种转换机制被称为反腐败层。
反腐败层
创建一个隔离层,为客户端提供其自身领域模型的功能。该层通过其现有接口与其他系统通信,对其他系统几乎没有或不需要进行修改。内部,该层在两个模型之间按需进行双向转换。埃里克·埃文斯 - 领域驱动设计:在软件核心解决复杂性。
那么,反腐败层看起来是什么样子?大多数时候,服务将与适配器和外观的组合进行交互。服务封装并隐藏了这些转换背后的低级复杂性。外观有助于隐藏和封装从游戏化模型获取数据所需的访问细节。适配器在模型之间进行转换,通常使用专门的翻译器。
让我们看看如何在 Will 模型中定义一个用户服务,该服务将负责检索给定用户获得的徽章:
namespace Lw\Domain\Model\User;
interface UserService
{
public function badgesFrom(UserId $id);
}
现在,让我们看看基础设施方面的实现。我们将使用适配器来完成转换过程:
namespace Lw\Infrastructure\Service;
use Lw\Domain\Model\User\UserId;
use Lw\Domain\Model\User\UserService;
class TranslatingUserService implements UserService
{
private $userAdapter;
public function __construct(UserAdapter $userAdapter)
{
$this->userAdapter = $userAdapter;
}
public function badgesFrom(UserId $id)
{
return $this->userAdapter->toBadges($id);
}
}
下面是UserAdapter的 HTTP 实现:
namespace Lw\Infrastructure\Service;
use GuzzleHttp\Client;
class HttpUserAdapter implements UserAdapter
{
private $client;
public function __construct(Client $client)
{
$this->client = $client;
}
public function toBadges( $id)
{
$response = $this->client->get(
sprintf('/users/%s', $id),
[
'allow_redirects' => true,
'headers' => [
'Accept' => 'application/hal+json'
]
]
);
$badges = [];
if (200 === $response->getStatusCode()) {
$badges =
(new UserTranslator())
->toBadgesFromRepresentation(
json_decode(
$response->getBody(),
true
)
);
}
return $badges;
}
}
如您所见,适配器也充当了游戏化上下文的外观。我们这样做是因为在游戏化侧获取用户资源相当直接。适配器使用UserTranslator来执行转换:
namespace Lw\Infrastructure\Service;
use Lw\Infrastructure\Domain\Model\User\FirstWillMadeBadge;
use Symfony\Component\PropertyAccess\PropertyAccess;
class UserTranslator
{
public function toBadgesFromRepresentation($representation)
{
$accessor = PropertyAccess::createPropertyAccessor();
$points = $accessor->getValue($representation, 'points');
$badges = [];
if ($points > 3) {
$badges[] = new FirstWillMadeBadge();
}
return $badges;
}
}
翻译器专门负责将来自游戏化上下文的分数转换为徽章。
我们已经展示了如何集成两个边界上下文,其中相应的团队共享客户-供应商关系。游戏化上下文通过一个由 RESTful 协议实现的开放主机服务公开集成。另一方面,Will 上下文通过一个反腐败层消费服务,该层负责将一个领域模型转换为另一个领域模型,确保 Will 上下文的完整性。
消息队列
RESTful 资源并不是实现边界上下文之间集成的唯一方式。正如我们将看到的,消息中间件使得不同上下文之间的解耦集成成为可能。
在继续使用 Last Wishes 应用程序的过程中,我们刚刚实现了两支团队之间的单向关系,以管理各自上下文中的积分和徽章。然而,我们故意将一个重要的功能排除在范围之外:每次用户许愿时都给予奖励。
我们可以考虑采用另一种具有拉取策略的开放主机服务。Will Context 将会定期拉取 Gamification Context 以同步徽章(例如:通过像 Cron 这样的调度器)。这种解决方案将影响用户体验,并且会浪费大量不必要的资源。
更好的方法是使用消息中间件。使用这种解决方案,上下文可以将消息推送到中间件(通常是消息队列)。感兴趣的各方将能够订阅、检查和按需以解耦的方式消费信息。为了做到这一点,我们需要一种专业、共享和通用的通信语言,以便所有各方都能理解传输的信息。这就是所谓的发布语言。
发布语言
使用一个经过良好文档化的共享语言,该语言可以表达必要的领域信息,作为通用的通信媒介,并在必要时将其翻译成和从该语言中翻译出来。 埃里克·埃文斯 - 领域驱动设计: 软件核心的复杂性处理。
在思考这些消息的格式并更仔细地审视我们的领域模型时,我们意识到我们已经有我们所需要的东西:第六章,领域事件。没有必要定义一种新的在边界上下文之间进行通信的方式。相反,我们可以直接使用领域事件来定义上下文之间的通用语言。领域专家关心刚刚发生的事情的定义与我们正在寻找的完美契合:一种正式的发布语言。
在我们的示例中,我们可以使用 RabbitMQ 作为消息中间件。这可能是最可靠和最健壮的消息 AMQP 协议之一。我们还将整合广泛使用的 PHP 库 php-amqplib 和 RabbitMQBundle。
让我们从 Will Context 开始,因为它是当用户注册或许愿时触发事件的那个。正如我们在 第六章 中看到的,领域事件,将领域事件存储到持久机制中是一个好主意,所以我们假设这就是所做的工作。我们需要一个消息发布者来从事件存储中检索并发布存储的领域事件到消息中间件。我们已经在 第六章 的 领域事件 中完成了与 RabbitMQ 的集成,所以我们只需要在 Gamification Context 中实现代码。我们将监听 Will Context 触发的事件。由于我们使用的是 Symfony 框架,我们利用一个名为 RabbitMQBundle 的 Symfony 包。
我们为 用户注册 和 愿望已创建 事件定义了两个消息消费者:
namespace AppBundle\Infrastructure\Messaging\PhpAmqpLib;
use Lw\Gamification\Command\SignupCommand;
use OldSound\RabbitMqBundle\RabbitMq\ConsumerInterface;
use PhpAmqpLib\Message\AMQPMessage;
class PhpAmqpLibLastWillUserRegisteredConsumer
implements ConsumerInterface
{
private $commandBus;
public function __construct($commandBus)
{
$this->commandBus = $commandBus;
}
public function execute(AMQPMessage $message)
{
$type = $message->get('type');
if('Lw\Domain\Model\User\UserRegistered' === $type) {
$event = json_decode($message->body);
$eventBody = json_decode($event->event_body);
$this->commandBus->handle(
new SignupCommand($eventBody->user_id->id)
);
return true;
}
return false;
}
}
注意,在这种情况下,我们只处理类型为 Lw\Domain\Model\User\UserRegistered 的消息:
namespace AppBundle\Infrastructure\Messaging\PhpAmqpLib;
use Lw\Gamification\Command\RewardUserCommand;
use Lw\Gamification\Domain\Model\AggregateDoesNotExist;
use OldSound\RabbitMqBundle\RabbitMq\ConsumerInterface;
use PhpAmqpLib\Message\AMQPMessage;
class PhpAmqpLibLastWillWishWasMadeConsumer implements ConsumerInterface
{
private $commandBus;
public function __construct($commandBus)
{
$this->commandBus = $commandBus;
}
public function execute(AMQPMessage $message)
{
$type = $message->get('type');
if ('Lw\Domain\Model\Wish\WishWasMade' === $type) {
$event = json_decode($message->body);
$eventBody = json_decode($event->event_body);
try {
$points = 5;
$this->commandBus->handle(
new RewardUserCommand(
$eventBody->user_id->id,
$points
)
);
} catch (AggregateDoesNotExist $e) {
// Noop
}
return true;
}
return false;
}
}
再次强调,我们只对跟踪 Lw\Domain\Model\Wish\WishWasMade 事件感兴趣。
在这两种情况下,我们使用命令总线,这在应用章节中已经讨论过。然而,我们可以将其总结为一个解耦命令和接收者的高速公路。命令的 何时 和 如何 执行与 谁 触发它是独立的。
Gamification Context 使用 Tactician(以及 TacticianBundle),这是一个简单的命令总线,可以扩展和适应您的系统。因此,我们现在几乎准备好从 Will Context 消费事件了。
我们还需要做的一件事是在 Symfony 的 config.yml 文件中定义 RabbitMQBundle 配置:
services:
last_will_user_registered_consumer:
class:
AppBundle\Infrastructure\Messaging\
PhpAmqpLib\PhpAmqpLibLastWillUserRegisteredConsumer
arguments:
- @tactician.commandbus
last_will_wish_was_made_consumer:
class:
AppBundle\Infrastructure\Messaging\
PhpAmqpLib\PhpAmqpLibLastWillWishWasMadeConsumer
arguments:
- @tactician.commandbus
old_sound_rabbit_mq:
connections:
default:
host: " %rabbitmq_host%"
port: " %rabbitmq_port%"
user: " %rabbitmq_user%"
password: " %rabbitmq_password%"
vhost: " %rabbitmq_vhost%"
lazy: true
consumers:
last_will_user_registered:
connection: default
callback: last_will_user_registered_consumer
exchange_options:
name: last-will
type: fanout
queue_options:
name: last-will
last_will_wish_was_made:
connection: default
callback: last_will_wish_was_made_consumer
exchange_options:
name: last-will
type: fanout
queue_options:
name: last-wil
最方便的 RabbitMQ 配置可能是 [发布/订阅] 模式。Will Context 发布的所有消息都将被发送到所有连接的消费者。这在 RabbitMQ 交换配置中被称为 fanout。
交换由一个负责将消息发送到相应队列的代理组成:
> php app/console rabbitmq:consumer --messages=1000 last_will_user_registered
> php app/console rabbitmq:consumer --messages=1000 last_will_wish_was_made
使用这两个命令,Symfony 将执行两个消费者,它们将开始监听领域事件。我们指定了消费消息的限制为 1,000 条,因为 PHP 不是执行长时间运行进程的最佳平台。使用类似 Supervisor 的工具定期监控和重启进程可能也是一个好主意。
总结
尽管我们只看到了其中的一小部分,但战略设计是领域驱动设计的核心和灵魂。它是开发更好、更语义化模型的一个基本部分。我们建议使用消息中间件来集成边界上下文,因为这自然会引导出更简单、解耦和事件驱动的架构。
第十三章:使用 PHP 的六边形架构
以下文章由 Carlos Buenosvinos 于 2014 年 6 月发布在 php|architect 杂志上。
简介
随着领域驱动设计(DDD)的兴起,促进以领域为中心设计的架构变得越来越流行。这就是六边形架构(也称为端口和适配器)的情况,它似乎刚刚被 PHP 开发者重新发现。由敏捷宣言的作者之一 Alistair Cockburn 于 2005 年发明,六边形架构允许应用程序由用户、程序、自动化测试或批处理脚本同等驱动,并且可以在与最终运行时设备和数据库隔离的情况下开发和测试。这导致出现无依赖的基础设施 Web 应用程序,这些应用程序更容易测试、编写和维护。让我们看看如何使用真实的 PHP 示例来应用它。
您的公司正在构建一个名为Idy的头脑风暴系统。用户添加和评分想法,以便最有趣的想法可以在公司中实施。这是周一早晨,另一个冲刺即将开始,您正在与您的团队和产品负责人审查一些用户故事。作为一个未登录的用户,我想对想法进行评分,并且作者应该通过电子邮件通知,这是一个非常重要的用例,不是吗?
第一种方法
作为一名优秀的开发者,您决定分而治之用户故事,所以您将从第一部分开始,我想对想法进行评分。之后,您将面临作者应该通过电子邮件通知。这听起来像是个计划。
在业务规则方面,对想法进行评分就像在想法存储库中通过标识符找到想法一样简单,所有想法都存储在那里,添加评分,重新计算平均值,并将想法保存回存储库。如果想法不存在或存储库不可用,我们应该抛出异常,以便我们可以显示错误消息,重定向用户或执行业务要求我们做的任何事情。
为了执行这个用例,我们只需要用户的想法标识符和评分。这两个整数将来自用户请求。
您的公司网络应用程序正在处理一个 Zend Framework 1 遗留应用程序。像大多数公司一样,您的应用程序可能某些部分较新,更符合 SOLID 原则,而其他部分可能只是一团糟。然而,您知道您使用的框架并不重要,关键是编写干净的代码,使维护成为公司低成本的作业。
您试图应用您在上次会议中记得的一些敏捷原则,是的,我记得“先让它工作,再让它正确,最后让它快速”。经过一段时间的工作,您得到了类似于列表 1 的内容。
class IdeaController extends Zend_Controller_Action
{
public function rateAction()
{
// Getting parameters from the request
$ideaId = $this->request->getParam('id');
$rating = $this->request->getParam('rating');
// Building database connection
$db = new Zend_Db_Adapter_Pdo_Mysql([
'host' => 'localhost',
'username' => 'idy',
'password' => '',
'dbname' => 'idy'
]);
// Finding the idea in the database
$sql = 'SELECT * FROM ideas WHERE idea_id = ?';
$row = $db->fetchRow($sql, $ideaId);
if (!$row) {
throw new Exception('Idea does not exist');
}
// Building the idea from the database
$idea = new Idea();
$idea->setId($row['id']);
$idea->setTitle($row['title']);
$idea->setDescription($row['description']);
$idea->setRating($row['rating']);
$idea->setVotes($row['votes']);
$idea->setAuthor($row['email']);
// Add user rating
$idea->addRating($rating);
// Update the idea and save it to the database
$data = [
'votes' => $idea->getVotes(),
'rating' => $idea->getRating()
];
$where['idea_id = ?'] = $ideaId;
$db->update('ideas', $data, $where);
// Redirect to view idea page
$this->redirect('/idea/' . $ideaId);
}
}
我知道读者在想什么:谁会直接从控制器访问数据?这是一个 90 年代的做法! 好吧,好吧,你说得对。如果你已经在使用框架,那么你很可能也在使用 ORM。可能是你自己做的,或者是现有的任何一个,比如 Doctrine、Eloquent、Zend 等等。如果是这种情况,你比那些只有一些数据库连接对象但没有实际操作的人更进一步。但在孵化之前不要过早地计算你的小鸡。
对于新手来说,列表 1 的代码可以正常工作。然而,如果你仔细看看控制器,你会发现不仅仅是业务规则,你还会看到你的 Web 框架如何将请求路由到业务规则,对数据库的引用或如何连接到它。非常接近,你看到了对你的 基础设施 的引用。
基础设施是使你的业务规则生效的 细节。显然,我们需要某种方式来访问它们(API、Web、控制台应用程序等等),并且实际上我们需要一个物理位置来存储我们的想法(内存、数据库、NoSQL 等等)。然而,我们应该能够用另一个以相同方式但具有不同实现的行为的组件来交换这些组件。那么从数据库访问开始怎么样?
所有那些 Zend_DB_Adapter 连接(或者如果你是直接使用 MySQL 命令,那么就是直接 MySQL 命令)都在请求提升为某种封装了获取和持久化想法对象的对象。它们在恳求成为仓库。
仓库和持久化优势
无论业务规则还是基础设施是否有变化,我们都必须编辑相同的代码。相信我,在计算机科学中,你不想让很多人出于不同的原因触摸相同的代码。尽量让你的函数只做一件事,这样人们就不太可能对相同的代码进行干扰。你可以通过查看 单一职责原则(SRP)来了解更多关于这一点。有关此原则的更多信息:www.objectmentor.com/resources/articles/srp.pdf
列表 1 明显是这种情况。如果我们想迁移到 Redis 或添加作者通知功能,你将不得不更新 rateAction 方法。影响与更新无关的 rateAction 方面的可能性很高。列表 1 的代码很脆弱。如果你的团队中经常听到 如果它工作,就不要动它,那么就缺少了 SRP。
因此,我们必须解耦我们的代码,并将处理获取和持久化想法的责任封装到另一个对象中。正如之前解释的,最好的方式是使用仓库。挑战接受!让我们看看列表 2 中的结果:
class IdeaController extends Zend_Controller_Action
{
public function rateAction()
{
$ideaId = $this->request->getParam('id');
$rating = $this->request->getParam('rating');
$ideaRepository = new IdeaRepository();
$idea = $ideaRepository->find($ideaId);
if (!$idea) {
throw new Exception('Idea does not exist');
}
$idea->addRating($rating);
$ideaRepository->update($idea);
$this->redirect('/idea/' . $ideaId);
}
}
class IdeaRepository
{
private $client;
public function __construct()
{
$this->client = new Zend_Db_Adapter_Pdo_Mysql([
'host' => 'localhost',
'username' => 'idy',
'password' => '',
'dbname' => 'idy'
]);
}
public function find($id)
{
$sql = 'SELECT * FROM ideas WHERE idea_id = ?';
$row = $this->client->fetchRow($sql, $id);
if (!$row) {
return null;
}
$idea = new Idea();
$idea->setId($row['id']);
$idea->setTitle($row['title']);
$idea->setDescription($row['description']);
$idea->setRating($row['rating']);
$idea->setVotes($row['votes']);
$idea->setAuthor($row['email']);
return $idea;
}
public function update(Idea $idea)
{
$data = [
'title' => $idea->getTitle(),
'description' => $idea->getDescription(),
'rating' => $idea->getRating(),
'votes' => $idea->getVotes(),
'email' => $idea->getAuthor(),
];
$where = ['idea_id = ?' => $idea->getId()];
$this->client->update('ideas', $data, $where);
}
}
结果更令人满意。IdeaController 的 rateAction 方法更易于理解。当阅读时,它讨论的是业务规则。IdeaRepository 是一个 业务概念。当与业务人员交谈时,他们理解 IdeaRepository 是什么:一个存放想法并获取它们的地方。
存储库通过使用类似集合的接口来访问领域对象,在领域和数据映射层之间进行调解,正如在马丁·福勒的模式目录中找到的那样。
如果你已经使用了一个如 Doctrine 的 ORM,你的当前存储库是从一个EntityRepository扩展出来的。如果你需要获取这些存储库之一,你要求 Doctrine 的EntityManager来完成这项工作。生成的代码几乎相同,只是在控制器动作中额外访问了EntityManager以获取IdeaRepository。
在这一点上,我们可以在六边形的景观中看到其中一条边,即persistence边。然而,这一边画得并不好,IdeaRepository是什么以及它是如何实现的之间仍然存在一些关系。
为了在应用程序边界和基础设施边界之间进行有效的分离,我们需要额外的步骤。我们需要使用某种形式的接口显式地解耦行为和实现。
解耦业务和持久化
你是否曾经遇到过这样的情况:当你开始与你的产品负责人、业务分析师或项目经理谈论你的数据库问题时?你能记得他们解释如何持久化和检索对象时的表情吗?他们对你所说的毫无头绪。
事实是,他们并不关心,但这没关系。如果你决定在 MySQL 服务器、Redis 或 SQLite 中存储想法,那是你的问题,不是他们的。记住,从业务角度来看,你的基础设施是一个细节。业务规则不会因为使用 Symfony 或 Zend Framework、MySQL 或 PostgreSQL、REST 或 SOAP 等而改变。
这就是为什么将我们的IdeaRepository与其实现解耦很重要。最简单的方法是使用一个合适的接口。我们如何实现这一点?让我们看看列表 3。
class IdeaController extends Zend_Controller_Action
{
public function rateAction()
{
$ideaId = $this->request->getParam('id');
$rating = $this->request->getParam('rating');
$ideaRepository = new MySQLIdeaRepository();
$idea = $ideaRepository->find($ideaId);
if(!$idea) {
throw new Exception('Idea does not exist');
}
$idea->addRating($rating);
$ideaRepository->update($idea);
$this->redirect('/idea/' . $ideaId);
}
}
interface IdeaRepository
{
/**
* @param int $id
* @return null|Idea
*/
public function find($id);
/**
* @param Idea $idea
*/
public function update(Idea $idea);
}
class MySQLIdeaRepository implements IdeaRepository
{
// ...
}
很简单,不是吗?我们已经将IdeaRepository的行为提取到一个接口中,将IdeaRepository重命名为MySQLIdeaRepository,并将rateAction更新为使用我们的MySQLIdeaRepository。但有什么好处呢?
我们现在可以用任何实现相同接口的存储库来替换控制器中使用的存储库。那么,让我们尝试一个不同的实现。
将我们的持久化迁移到 Redis
在冲刺阶段和与一些伙伴交谈之后,你意识到采用 NoSQL 策略可以提高你功能的性能。Redis 是你的好朋友之一。去做吧,并给我展示你的列表 4:
class IdeaController extends Zend_Controller_Action
{
public function rateAction()
{
$ideaId = $this->request->getParam('id');
$rating = $this->request->getParam('rating');
$ideaRepository = new RedisIdeaRepository();
$idea = $ideaRepository->find($ideaId);
if (!$idea) {
throw new Exception('Idea does not exist');
}
$idea->addRating($rating);
$ideaRepository->update($idea);
$this->redirect('/idea/' . $ideaId);
}
}
interface IdeaRepository
{
// ...
}
class RedisIdeaRepository implements IdeaRepository
{
private $client;
public function __construct()
{
$this->client = new Predis\Client();
}
public function find($id)
{
$idea = $this->client->get($this->getKey($id));
if (!$idea) {
return null;
}
return unserialize($idea);
}
public function update(Idea $idea)
{
$this->client->set(
$this->getKey($idea->getId()),
serialize($idea)
);
}
private function getKey($id)
{
return 'idea:' . $id;
}
}
再次简单。你已经创建了一个实现IdeaRepository接口的RedisIdeaRepository,我们决定使用 Predis 作为连接管理器。代码看起来更小、更简单、更快。但控制器呢?它保持不变,我们只是更改了使用的存储库,但这只是一行代码。
作为对读者的练习,尝试创建 SQLite、文件或使用数组的内存实现IdeaRepository。如果你考虑 ORM 存储库如何与领域存储库相匹配以及 ORM @annotations如何影响这种架构,那么你将获得额外的分数。
解耦业务和 Web 框架
我们已经看到从一种持久化策略切换到另一种策略是多么容易。然而,持久化并不是我们六边形架构的唯一优势。用户如何与应用程序交互呢?
您的 CTO 在路线图中已经设定,您的团队将迁移到 Symfony2,因此当您在当前的 ZF1 应用程序中开发新功能时,我们希望使即将到来的迁移更加容易。这有点棘手,请展示您的列表 5:
class IdeaController extends Zend_Controller_Action
{
public function rateAction()
{
$ideaId = $this->request->getParam('id');
$rating = $this->request->getParam('rating');
$ideaRepository = new RedisIdeaRepository();
$useCase = new RateIdeaUseCase($ideaRepository);
$response = $useCase->execute($ideaId, $rating);
$this->redirect('/idea/' . $ideaId);
}
}
interface IdeaRepository
{
// ...
}
class RateIdeaUseCase
{
private $ideaRepository;
public function __construct(IdeaRepository $ideaRepository)
{
$this->ideaRepository = $ideaRepository;
}
public function execute($ideaId, $rating)
{
try {
$idea = $this->ideaRepository->find($ideaId);
} catch(Exception $e) {
throw new RepositoryNotAvailableException();
}
if (!$idea) {
throw new IdeaDoesNotExistException();
}
try {
$idea->addRating($rating);
$this->ideaRepository->update($idea);
} catch(Exception $e) {
throw new RepositoryNotAvailableException();
}
return $idea;
}
}
让我们回顾一下变化。我们的控制器根本没有任何业务规则。我们已经将所有逻辑推入一个名为RateIdeaUseCase的新对象中,该对象封装了它。这个对象也被称为控制器、交互器或应用程序服务。
魔法是由execute方法完成的。所有依赖项,如RedisIdeaRepository,都作为参数传递给构造函数。我们 UseCase 内部对IdeaRepository的所有引用都指向接口,而不是任何具体实现。
这真的很酷。如果你看看RateIdeaUseCase内部,没有任何关于 MySQL 或 Zend Framework 的提及。没有引用,没有实例,没有注解,什么都没有。就像你的基础设施不在乎一样。它只是谈论业务逻辑。
此外,我们还调整了我们抛出的异常。业务流程也有异常。NotAvailableRepository和IdeaDoesNotExist是其中两个。根据抛出的异常,我们可以在框架边界中采取不同的反应。
有时候,一个 UseCase 接收的参数数量可能太多。为了组织它们,使用数据传输对象(DTO)构建一个UseCase 请求来一起传递是很常见的。让我们看看如何在列表 6 中解决这个问题:
class IdeaController extends Zend_Controller_Action
{
public function rateAction()
{
$ideaId = $this->request->getParam('id');
$rating = $this->request->getParam('rating');
$ideaRepository = new RedisIdeaRepository();
$useCase = new RateIdeaUseCase($ideaRepository);
$response = $useCase->execute(
new RateIdeaRequest($ideaId, $rating)
);
$this->redirect('/idea/' . $response->idea->getId());
}
}
class RateIdeaRequest
{
public $ideaId;
public $rating;
public function __construct($ideaId, $rating)
{
$this->ideaId = $ideaId;
$this->rating = $rating;
}
}
class RateIdeaResponse
{
public $idea;
public function __construct(Idea $idea)
{
$this->idea = $idea;
}
}
class RateIdeaUseCase
{
// ...
public function execute($request)
{
$ideaId = $request->ideaId;
$rating = $request->rating;
// ...
return new RateIdeaResponse($idea);
}
}
这里的主要变化是引入了两个新对象,一个请求和一个响应。它们不是强制的,也许一个 UseCase 没有请求或响应。另一个重要细节是如何构建这个请求。在这种情况下,我们是通过从 ZF 请求对象获取参数来构建它的。
好的,但等等,真正的益处是什么?从一种框架切换到另一种框架,或者通过另一种交付机制执行我们的 UseCase,这更容易。让我们看看这个观点。
使用 API 对想法进行评分
在白天,你的产品负责人来到你面前说:“顺便说一句,用户应该能够使用我们的移动应用对想法进行评分。我想我们需要更新 API,你能在这个冲刺中完成吗?”PO 又来了。“没问题!”业务对你的承诺印象深刻。
正如罗伯特·C·马丁所说:网络是一种交付机制 [...] 你的系统架构应该尽可能不了解它如何交付。你应该能够以控制台应用程序、Web 应用程序或甚至是 Web 服务应用程序的形式交付它,而不会造成不必要的复杂性或对基本架构的任何更改
。
你当前的 API 是使用基于 Symfony2 组件的 PHP 微框架 Silex 构建的。让我们在列表 7 中看看:
require_once __DIR__.'/../vendor/autoload.php';
$app = new Silex\Application();
// ... more routes
$app->get(
'/api/rate/idea/{ideaId}/rating/{rating}',
function ($ideaId, $rating) use ($app) {
$ideaRepository = new RedisIdeaRepository();
$useCase = new RateIdeaUseCase($ideaRepository);
$response = $useCase->execute(
new RateIdeaRequest($ideaId, $rating)
);
return $app->json($response->idea);
}
);
$app->run();
有什么熟悉的东西吗?你能识别出你之前见过的某些代码吗?我会给你一个提示:
$ideaRepository = new RedisIdeaRepository();
$useCase = new RateIdeaUseCase($ideaRepository);
$response = $useCase->execute(
new RateIdeaRequest($ideaId, $rating)
);
哇!我记得这三行代码。它们看起来和 Web 应用程序一模一样。没错,因为用例封装了你准备请求、获取响应并相应行动所需的企业规则。
我们为用户提供了一种对想法进行评分的另一种方式;另一种交付机制。主要区别在于我们从哪里创建了RateIdeaRequest。在第一个例子中,它来自 ZF 请求,现在它来自使用路由中匹配的参数的 Silex 请求。
控制台应用程序 评分
有时,用例将从 Cron 作业或命令行执行。例如,批量处理或一些测试命令行以加速开发。在通过 Web 或 API 测试这个功能时,你会发现有一个命令行来做这件事会很好,这样你就不必通过浏览器了。
如果你使用 shell 脚本文件,我建议你检查 Symfony Console 组件。代码会是什么样子:
namespace Idy\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class VoteIdeaCommand extends Command
{
protected function configure()
{
$this
->setName('idea:rate')
->setDescription('Rate an idea')
->addArgument('id', InputArgument::REQUIRED)
->addArgument('rating', InputArgument::REQUIRED);
}
protected function execute(
InputInterface $input,
OutputInterface $output
) {
$ideaId = $input->getArgument('id');
$rating = $input->getArgument('rating');
$ideaRepository = new RedisIdeaRepository();
$useCase = new RateIdeaUseCase($ideaRepository);
$response = $useCase->execute(
new RateIdeaRequest($ideaId, $rating)
);
$output->writeln('Done!');
}
}
再次是这三行代码。和之前一样,用例及其业务逻辑保持不变,我们只是提供了一个新的交付机制。恭喜你,你已经发现了用户端六边形边缘。
还有好多事情要做。正如你可能听说的,真正的工匠会做 TDD。我们已经开始了我们的故事,所以我们必须接受只是测试。
测试 评估一个想法 用例
迈克尔·费瑟斯给遗留代码下了一个定义,即没有测试的代码。你不想你的代码一出生就是遗留的,对吧?
为了对这一用例对象进行单元测试,你决定从最容易的部分开始,如果仓库不可用会发生什么?我们如何生成这种行为?我们在运行单元测试时停止我们的 Redis 服务器吗?不。我们需要一个具有这种行为的对象。让我们在列表 9 中使用一个模拟对象:
class RateIdeaUseCaseTest extends \PHPUnit_Framework_TestCase
{
/**
* @test
*/
public function whenRepositoryNotAvailableAnExceptionIsThrown()
{
$this->setExpectedException('NotAvailableRepositoryException');
$ideaRepository = new NotAvailableRepository();
$useCase = new RateIdeaUseCase($ideaRepository);
$useCase->execute(
new RateIdeaRequest(1, 5)
);
}
}
class NotAvailableRepository implements IdeaRepository
{
public function find($id)
{
throw new NotAvailableException();
}
public function update(Idea $idea)
{
throw new NotAvailableException();
}
}
很好。NotAvailableRepository具有我们需要的特性,我们可以使用它与RateIdeaUseCase一起,因为它实现了IdeaRepository接口。
接下来要测试的情况是如果想法不在仓库中会发生什么。列表 10 显示了代码:
class RateIdeaUseCaseTest extends \PHPUnit_Framework_TestCase
{
// ...
/**
* @test
*/
public function whenIdeaDoesNotExistAnExceptionShouldBeThrown()
{
$this->setExpectedException('IdeaDoesNotExistException');
$ideaRepository = new EmptyIdeaRepository();
$useCase = new RateIdeaUseCase($ideaRepository);
$useCase->execute(
new RateIdeaRequest(1, 5)
);
}
}
class EmptyIdeaRepository implements IdeaRepository
{
public function find($id)
{
return null;
}
public function update(Idea $idea)
{
}
}
这里,我们使用相同的策略,但使用了一个EmptyIdeaRepository。它也实现了相同的接口,但实现总是返回 null,无论 find 方法接收哪个标识符。
为什么我们要测试这些情况?记住肯特·贝克的这句话:测试所有可能出错的东西。
让我们继续其他功能的剩余部分。我们需要检查一个与有一个可读但不可写的存储库相关的特殊情况。解决方案可以在列表 11 中找到:
class RateIdeaUseCaseTest extends \PHPUnit_Framework_TestCase
{
// ...
/**
* @test
*/
public function whenRatingAnIdeaNewRatingShouldBeAdded()
{
$ideaRepository = new OneIdeaRepository();
$useCase = new RateIdeaUseCase($ideaRepository);
$response = $useCase->execute(
new RateIdeaRequest(1, 5)
);
$this->assertSame(5, $response->idea->getRating());
$this->assertTrue($ideaRepository->updateCalled);
}
}
class OneIdeaRepository implements IdeaRepository
{
public $updateCalled = false;
public function find($id)
{
$idea = new Idea();
$idea->setId(1);
$idea->setTitle('Subscribe to php[architect]');
$idea->setDescription('Just buy it!');
$idea->setRating(5);
$idea->setVotes(10);
$idea->setAuthor('john@example.com');
return $idea;
}
public function update(Idea $idea)
{
$this->updateCalled = true;
}
}
好的,现在这个功能的要点仍然存在。我们有不同的测试方式,我们可以编写自己的模拟或使用模拟框架,如 Mockery 或 Prophecy。让我们选择第一个。另一个有趣的练习将是使用这些框架之一编写这个示例和前面的示例:
class RateIdeaUseCaseTest extends \PHPUnit_Framework_TestCase
{
// ...
/**
* @test
*/
public function whenRatingAnIdeaNewRatingShouldBeAdded()
{
$ideaRepository = new OneIdeaRepository();
$useCase = new RateIdeaUseCase($ideaRepository);
$response = $useCase->execute(
new RateIdeaRequest(1, 5)
);
$this->assertSame(5, $response->idea->getRating());
$this->assertTrue($ideaRepository->updateCalled);
}
}
class OneIdeaRepository implements IdeaRepository
{
public $updateCalled = false;
public function find($id)
{
$idea = new Idea();
$idea->setId(1);
$idea->setTitle('Subscribe to php[architect]');
$idea->setDescription('Just buy it!');
$idea->setRating(5);
$idea->setVotes(10);
$idea->setAuthor('john@example.com');
return $idea;
}
public function update(Idea $idea)
{
$this->updateCalled = true;
}
}
嘭!UseCase 的 100% 覆盖率。也许,下次我们可以使用 TDD 来实现,这样测试就会先进行。然而,由于这种架构中推广了解耦的方式,测试这个功能实际上非常简单。
你可能对此感到好奇:
$this->updateCalled = true;
我们需要一个方法来确保在 UseCase 执行期间已经调用了更新方法。这个方法就做到了这一点。这个 测试替身 对象被称为 间谍,模拟的表亲。
何时使用模拟?作为一个一般规则,当跨越边界时使用模拟。在这种情况下,我们需要模拟,因为我们正在从领域跨越到持久性边界。
关于测试基础设施,怎么办?
测试基础设施
如果你想要为整个应用程序实现 100% 的覆盖率,你也必须测试你的基础设施。在这样做之前,你需要知道,这些单元测试将比业务测试更多地与你的实现相关联。这意味着,随着实现细节的变化而出现问题的概率更高。因此,这是一个你必须考虑的权衡。
所以,如果你想继续,我们需要做一些修改。我们需要进一步解耦。让我们看看列表 13 中的代码:
class IdeaController extends Zend_Controller_Action
{
public function rateAction()
{
$ideaId = $this->request->getParam('id');
$rating = $this->request->getParam('rating');
$useCase = new RateIdeaUseCase(
new RedisIdeaRepository(
new Predis\Client()
)
);
$response = $useCase->execute(
new RateIdeaRequest($ideaId, $rating)
);
$this->redirect('/idea/' . $response->idea->getId());
}
}
class RedisIdeaRepository implements IdeaRepository
{
private $client;
public function __construct($client)
{
$this->client = $client;
}
// ...
public function find($id)
{
$idea = $this->client->get($this->getKey($id));
if (!$idea) {
return null;
}
return $idea;
}
}
如果我们想要 100% 单元测试 RedisIdeaRepository,我们需要能够将 Predis\Client 作为参数传递给存储库,而不指定类型提示,这样我们就可以传递一个模拟来强制执行必要的代码流,以覆盖所有情况。
这迫使我们更新控制器以建立 Redis 连接,将其传递给存储库,并将结果传递给 UseCase。
现在,一切都关于创建模拟、测试用例,并在断言中享受乐趣。
哎呀,这么多依赖项!
我必须手动创建这么多依赖项是正常的吗?不。使用具有这种功能的依赖注入组件或服务容器是常见的。再次,Symfony 来拯救我们,然而,你也可以检查 PHP-DI 4。
让我们看看在将 Symfony 服务容器组件应用于我们的应用程序后,列表 14 中产生的代码:
class IdeaController extends ContainerAwareController
{
public function rateAction()
{
$ideaId = $this->request->getParam('id');
$rating = $this->request->getParam('rating');
$useCase = $this->get('rate_idea_use_case');
$response = $useCase->execute(
new RateIdeaRequest($ideaId, $rating)
);
$this->redirect('/idea/' . $response->idea->getId());
}
}
控制器已被修改以访问容器,这就是为什么它继承自一个新的基控制器 ContainerAwareController,该控制器有一个 get 方法来检索每个包含的服务:
<?xml version="1.0" ?>
<container
xsi:schemaLocation="
http://symfony.com/schema/dic/services
http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service
id="rate_idea_use_case"
class="RateIdeaUseCase">
<argument type="service" id="idea_repository" />
</service>
<service
id="idea_repository"
class="RedisIdeaRepository">
<argument type="service">
<service class="Predis\Client" />
</argument>
</service>
</services>
</container>
在列表 15 中,你还可以找到用于配置服务容器的 XML 文件。它真的很容易理解,但如果你需要更多信息,请查看 Symfony 服务容器组件网站。
领域服务和通知六角边缘
我们是不是忘记了什么?作者应该通过电子邮件通知,是的!这是真的。让我们看看在列表 16 中我们是如何更新 UseCase 来完成这项工作的:
class RateIdeaUseCase
{
private $ideaRepository;
private $authorNotifier;
public function __construct(
IdeaRepository $ideaRepository,
AuthorNotifier $authorNotifier
) {
$this->ideaRepository = $ideaRepository;
$this->authorNotifier = $authorNotifier;
}
public function execute(RateIdeaRequest $request)
{
$ideaId = $request->ideaId;
$rating = $request->rating;
try {
$idea = $this->ideaRepository->find($ideaId);
} catch(Exception $e) {
throw new RepositoryNotAvailableException();
}
if (!$idea) {
throw new IdeaDoesNotExistException();
}
try {
$idea->addRating($rating);
$this->ideaRepository->update($idea);
} catch(Exception $e) {
throw new RepositoryNotAvailableException();
}
try {
$this->authorNotifier->notify(
$idea->getAuthor()
);
} catch(Exception $e) {
throw new NotificationNotSentException();
}
return $idea;
}
}
正如你所意识到的那样,我们添加了一个新参数来传递AuthorNotifier服务,该服务将发送电子邮件给作者。这是端口在端口和适配器命名中的端口。我们还更新了执行方法中的业务规则。
存储库不是唯一可能访问你的基础设施的对象,应该使用接口或抽象类进行解耦。领域服务也可以。当你的领域中有一个行为不是由一个实体明确拥有时,你应该创建一个领域服务。一个典型的模式是编写一个具有一些具体实现和一些其他抽象方法的抽象领域服务,这些方法将由适配器实现。
作为一项练习,定义AuthorNotifier抽象服务的实现细节。选项有 SwiftMailer 或直接使用mail调用。这取决于你。
让我们回顾一下
为了拥有一个清晰的架构,帮助你创建易于编写和测试的应用程序,我们可以使用六角架构。为了实现这一点,我们将用户故事的业务规则封装在 UseCase 或 Interactor 对象中。我们根据框架请求构建 UseCase 请求,实例化 UseCase 及其所有依赖项,然后执行它。我们得到响应并根据它采取相应的行动。如果我们的框架有一个依赖注入组件,你可以使用它来简化代码。
同样的 UseCase 对象可以从不同的交付机制中使用,以便用户可以从不同的客户端(Web、API、控制台等)访问功能。
对于测试,可以玩一些模拟所有定义的接口的模拟,这样也可以覆盖特殊案例或错误流程。享受这份出色的工作吧。
六角架构
在几乎所有的博客和书中,你都会找到关于软件不同区域的同心圆的插图。正如罗伯特·C·马丁在他的Clean Architecture帖子中解释的那样,外圈是你的基础设施所在的地方。内圈是你的实体所在的地方。使这种架构发挥作用的主要规则是依赖规则。这个规则说,源代码依赖只能指向内。内圈中的任何东西都不能知道外圈中的任何东西。
关键点
如果 100%的单元测试覆盖率对你的应用程序非常重要,或者你想要能够切换你的存储策略、Web 框架或其他任何类型的第三方代码,请使用这种方法。这种架构对于需要跟上不断变化的需求的长期应用程序特别有用。
接下来是什么?
如果你想要了解更多关于六边形架构以及其他相关概念,你应该回顾文章开头提供的相关网址,看看 CQRS 和事件源。此外,别忘了订阅关于领域驱动设计(DDD)的谷歌群组和 RSS,例如dddinphp.org,并在 Twitter 上关注像@VaughnVernon和@ericevans0这样的人。
第十四章:参考文献列表
Beck, Kent. 测试驱动开发:实例. Addison-Wesley Professional, 2002.
Brandolini, Alberto. 介绍事件风暴. Leanpub, 2016.
Evans, Eric. 领域驱动设计参考:定义和模式摘要. Dog Ear Publishing, 2014.
Evans, Eric. 领域驱动设计:软件核心的复杂性处理. Addison-Wesley Professional, 2003.
Fowler, Martin. 企业应用架构模式. Addison-Wesley Professional, 2002.
Hohpe, Gregor 和 Bobby Woolf. 企业集成模式:设计、构建和部署消息解决方案. Addison-Wesley Professional, 2012.
Martin, Robert C. 敏捷软件开发:原则、模式和实务. Pearson, 2002.
Martin, Robert C. 代码整洁之道:敏捷软件工艺手册. Prentice Hall, 2008.
Meszaros, Gerard. xUnit 测试模式:重构测试代码. Addison-Wesley Professional, 2007.
Newman, Sam. 构建微服务. O’Reilly Media, 2015.
Nilsson, Jimmy. 应用领域驱动设计和模式:以 C# 和 .NET 为例. Addison-Wesley Professional, 2006.
Sadalage, Pramod J. 和 Martin Fowler. NoSQL 精粹:多语言持久性的新兴世界简明指南. Addison-Wesley Professional, 2012.
Vernon, Vaughn. 领域驱动设计精粹. Addison-Wesley Professional, 2016.
Vernon, Vaughn. 实施领域驱动设计. Addison-Wesley Professional, 2013.
第十五章:结束语
恭喜你,你已经完成了这本书!我们想亲自感谢你,没有你的支持和反馈,这本书根本不可能完成。这是一段不可思议的旅程,我们感到非常幸运能依靠你。我们真心希望你也像我们一样享受这段旅程。
我们一直致力于为读者提供最好的体验。我们根据你惊人的反馈,反复迭代了书的内容。如果你有任何可以改进的地方,请通过在我们的 GitHub 项目上打开一个 issue来帮助我们。
如果你喜欢它并且对你有帮助,它可能对其他人也有用!请随意在 Twitter 上分享你的体验或在 Goodreads 上给我们一个评价。
再次感谢!
– 卡洛斯、克里斯蒂安和凯万


浙公网安备 33010602011771号