Laravel-领域驱动教程-全-

Laravel 领域驱动教程(全)

原文:Domain-Driven Laravel

协议:CC BY-NC-SA 4.0

一、奠定基础

领域驱动设计(DDD)已经存在了近二十年。在这段时间里,人们对它的兴趣急剧增加,因为它提供了清晰的指导方针、战术策略和方法来解决在任何行业开发应用时可能遇到的问题,尤其是复杂的应用。它在现实世界中非常实用,并为过去缺乏任何标准或实践的情况提供了解决方案,这些标准或实践建议如何最好地着手解决底层业务的特定领域问题…这种问题您在日常编程中不会看到,如果不是在您的业务运营以实现目标(可能是为了盈利,但可能是为了服务客户、支持用户、销售产品、跟踪指标等)的领域的上下文中,您甚至不会编写。).

这本书是如何设计的

在研究这本书的两个中心话题中的任何一个时,都有很多材料要涵盖。Laravel 和域驱动设计都有各自的博客文章、白皮书、书籍、教程,当然还有实际的例子。我们将回顾 Laravel 的一些基础知识,但是我强烈建议你在网上找到一些介绍性的教程,这样你就可以感受一下框架的组件是如何组合在一起的。Laracasts.com 是一个很好的资源。我强烈建议至少看看开头几集;或者,如果你喜欢阅读而不是看/听,你可以快速“搜索”(那是一个“谷歌搜索”)拉勒维尔教程的介绍。

然而,它是你熟悉框架的水平,你将需要应用一个领域驱动的 Laravel 应用是可以接受的。正是基于这些基础信息,我们将在本书的后面开发一个实际的、真实世界的例子,这个例子将建立在一个受控的环境中,使用 DDD 作为我们决策的基础。在此过程中,我将给出一些实用的建议和潜在的模式,供您在尝试实现域驱动的 Laravel 应用时参考。我希望你会发现这些材料在现实生活环境和团队中既有用又容易理解。

我并不期望你对领域驱动设计有全面的了解(或者以前听说过)。我在本书中用了大量的篇幅来描述和定义 DDD,并根据其在开发 web 应用中的应用来阐述这些概念。如果你已经熟悉了 DDD 的概念,那太好了!这将使学习曲线变平一点,但这不是必需的。我将描述一些必须对 DDD 的原始版本进行的修改,以作为开发现代 web 应用的实用方法(使用 Laravel 作为实现的手段,核心焦点是设计、建模和细化领域层。

我将在整本书中向您展示各种最佳实践,这些实践要么是关于软件开发的广泛接受的标准,要么是我自己的实践和捷径,这些实践和捷径花费了我作为一名专业 web 开发人员十年左右的时间,以及两倍多的时间来研究好奇心激发的主题(借助于我迄今为止阅读的大约 150 本 IT 和软件工程书籍)来辨别这些信息。通常,我会用代码演示这些概念,这样你就可以清楚地理解它们是什么,也可以从坚实的原则中获得一些背景知识。我将提供一些例子,这些例子可能是一堆你可能不记得的随机建议,而且几乎肯定不会在你自己的项目中使用。

这一章有一节介绍了交易的所有基本工具,一些基本概念和重要术语的定义,这些概念和定义与演示和例子相关,用来支持我提出的所有理论。然而,在更高的层面上,我想让你从本章学到的是对构造(双关语!).除了给你一些关于 web 开发的基础知识,使你能够跟随我们在本书后面的理论例子,我的目的还在于向你灌输学习领域驱动的设计原则的愿望,并激发你学习 Laravel 的高级用法和定制的兴趣,使它像你的应用所需要的那样灵活。

我将在这一章中稍微跳一下,让你对我们在这里试图学习的东西有一个很好的了解:什么是 DDD,它与我们的软件质量有什么关系?为了回答这个问题,我将向您介绍一些重要的概念、模式和实践,您将需要这些来成功地在软件中建模真实世界的领域。

领域驱动的什么?

DDD 本身是使用各种最佳实践和可靠的设计模式构建的,它起源于极限编程(XP)和敏捷开发。我将向您介绍 DDD 的一些更基本的方面,包括如果由于开发人员的无知或管理不善的开发工作而忽略了这些最佳实践和可靠的模式,那么软件恐怖就会变成现实。

我能想到的描述 DDD 的最简单的方式是:它是一系列实际的和有用的概念,过程, 和技术,通过关注底层业务规则的核心方面来帮助以系统化和结构化的方式对复杂软件系统建模,以便制造一个领域模型,该领域模型根据软件来真实地描述和表示该业务,然后让来自与各个领域专家的反复讨论和小组会议的知识和见解来指导软件的开发和构造,该软件最终被构建来服务于该业务的客户或前端用户。 DDD 基本上起源于软件业的一个空白,这个空白是关于如何正确地设计和开发最适合企业需求的软件。没有神奇的手册给出任何类型的标准或基本方法来构建专注于应用领域(以领域为中心)的应用,也没有任何公开发表的方法来学习业务核心流程的细节,以构建代表它的软件。

程序员在我们的代码工具箱中拥有设计模式已经很多年了,尽管直到 1994 年四人帮出版了设计模式书籍的圣杯设计模式:可重用的面向对象软件的元素,它们才被记录下来。设计模式是一套重要的、核心的、实用的、可重复的解决方案,用于解决您在构建计算机程序时可能会遇到的常见问题。它们是经过深思熟虑和全面测试的方法,可以解决几乎任何程序、任何编程语言中可能遇到的最常见的问题。一个这样的模式,著名的策略模式,当你需要在运行时通过将改变封装到行为族中来给对象提供额外的行为时,是非常有用的。另一个是适配器模式,用于集成两个不同的接口(比如那些运行在不同系统上或者完全用不同编程语言编程的接口)。如果您需要您的对象具有动态行为,可以在运行时添加或删除,并遵循类似的套件,允许对所述对象组进行聚合计算,您可以使用装饰模式

我们所需要的正是开发解决方案的同样东西——就代码而言——为了响应软件开发需求的增长而出现的不断增长和大量的业务问题。换句话说,我们需要某种方法来开发应用的域层,以便我们可以用软件来表达任何这样的域(业务)模型。业务问题的本质是它们往往非常具体,并且它们各自的解决方案是多年来设计、开发、测试和提炼业务流程的产物,这些流程对于每个业务或行业都是独一无二的。因此,过去和现在都很难建立任何类型的标准或最佳实践来促进任何软件项目的最关键方面的开发:领域层。相比之下,有大量的参考资料、工具包和框架来帮助设计其他层(这些可能确实是交谈,了解领域层),但是他们没有封装它。相反,他们包围了它。基础设施层基本上是应用层和领域模型之间的纽带。它方便了所有直接操作、管理和处理域对象的移动部分。

我们需要的是如何在我们工作的公司中寻找真实来源的策略,以便在软件中正确地建模,以及帮助我们以领域驱动的方式实现该模型的工具。业务信息并不总是直接或容易获得的,尤其是在跨多个组件的复杂业务流程的上下文中(至少我们希望它们被分成一些组件结构,但这并不总是现实)。如果应用中的代码被多年来的大量开发人员“践踏”或殴打,迫使其行为以非预期或设计的方式弯曲,实现领域驱动的方法将变得更加困难。但是仍然有可能避免整个应用的重写(这几乎总是一个糟糕的想法)。我们将在本书的后面探索如何做到这一点,但简单的答案是使用一个反腐败层来分割应用的部分,然后不断用较小的分割代码替换遗留代码,直到该层吸收旧代码,直到没有遗留代码存在。

体系结构

系统的架构修饰了系统领域模型的完整结构,包括领域对象、模块、有界上下文以及它们之间的各种交互。架构是应用中最重要的事情之一,因为它是软件中的基础结构,并且充当应用其余部分的“支撑梁”。

现实世界中往往会发生的是,这些业务流程和模型是在没有最佳实践或适当结构的情况下构建的,并且在生产中使用,因为它们“工作”不要误解我的意思,开发人员利用我们现有的东西,通常可以“黑”出一些确实“有用”的东西。然而,当我们在对过程本身进行了改进之后忽略了重构代码,或者未能对我们可能已经澄清的任何不清楚或模糊的定义进行微调,或者未能反映对业务模型以及这些洞察如何影响业务运营及其软件的洞察时,我们很可能会走向一个“泥巴大球”(或者我称之为 sh 的大球...pottå to)。

一个大泥球是一个:

“随意构建,蔓延,草率,胶带和打包线,意大利面条代码丛林。这些系统显示出不受控制的增长和重复的权宜修复的明显迹象…“

——布莱恩·福特

通常情况下,这些泥巴球是作为整体架构构建的,缺乏在“平台级”(物理层)上完全分离关注点的概念。整体架构是独立的,其中包含了应用的所有关注点(基础设施、数据库、应用级和表示关注点)。我们将在本章的后面讨论软件系统的架构层。

与单片应用相对的是微服务。微服务是分布在各种不同组件上的微小应用,这些组件共同构成了一个完整的可用系统。微服务架构中涉及的设置比简单地使用目录名要生动得多(这实际上是将各种结构物理地分成相关的组)。组件本身通常存在于不同的平台上,通常在云中的不同机器上,并且实现各种策略,旨在促进它们的使用,并实现它们自己和客户机之间的通信(调用代码)。

输入 laravel

DDD 的核心特征之一是它是一种不可知的系统架构设计方法。这意味着它不假设您正在使用什么框架、决定使用什么数据库,或者您是否使用数据持久性(如果您是一名 web 开发人员,当然,您很可能会这样做)。它更像是一种设计可伸缩企业系统的通用方法。那么,为什么我会建议不仅仅是一个框架的概念,而是一个与 DDD 提供的概念和策略相结合的特定的风格

这个问题可以用一句日益证明正确的老话来回答:需要是发明之母。我注意到现实世界需要一套如何着手开发复杂 web 系统的指导方针,以及如何在现实世界场景中实现领域驱动应用的一些策略,而不需要重新发明轮子,同时使用 web 开发行业中一些更受欢迎的工具,如 Laravel 和口才——允许他们做他们最擅长的事情,以便我们可以专注于对业务本身进行建模,并构建一个丰富的领域层,一个反映它被建立来管理的业务的需求和要求的领域层。我们将使用 DDD 附带的工具和概念来完成所有这些工作,并针对 web 开发项目进行调整,同时在 Laravel 应用中实现这些概念。

直到最近,这两个概念(领域驱动的设计和框架)才足够接近,从而实际上产生了足够的吸引力,这表明使用领域驱动的方法来开发基于 web 和 Internet 的应用是有用的。最后,我意识到 Laravel 可以作为一种媒介来构建一个领域驱动的设计。

然而,由于 DDD 的创建方式以及 web 应用的基本结构,它与使用任何类型的框架的想法都不太协调。我们将回顾许多与 DDD 准则相关的模糊区域的例子。我们几乎需要一种定制的 DDD 实现,以便能够在我们需要的级别上使用它来构建 web 应用,使用它作为域设计的主干。如果我们考虑到在一个 web 开发环境中工作与一个系统中的环境边界有关,这个系统需要直接地并且主要通过网络操作。如果我们看看 DDD 的技术战略支柱中包含的知识类别,很明显它们可以与 Laravel 应用共存(例如,仓库、dto、工厂、工作等)。)…我们实际上可以看到,DDD 建议的大多数方法与拉勒韦尔的组件和内部工作方式非常吻合。在这方面,DDD 非常适合打造一个网络系统或互联网应用。

使用 Laravel 框架实现领域驱动设计的想法对我来说非常可行。如果我能改变策略和指导方针中的一些规则,我就能让这两种技术很好地协同工作。正因为如此,我会马上说,这不是 DDD 及其所有不同方面、模式、方法和指导方针的真正实现。DDD 需要大量的前期工作,但是当你把事情安排妥当的时候,你会得到很大的回报。话虽如此,我意识到这并不总是可取的,尤其是如果你是一家初创公司或正在为一家初创公司工作。成本可以成就一家创业公司,也可以毁掉一家创业公司,而且不可能总是分配如此大量的资源、时间和金钱来实施所有的工具、程序和架构结构,这些工具、程序和架构结构不仅是代码(如果您已经在代码库中工作)而且是业务核心功能的深度探索性分析的产物。

所以,在决定使用 DDD 的指导方针构建应用时,你应该非常小心:大多数领域都不够复杂,不需要 DDD 试图简洁地管理的复杂程度。另一方面,Laravel 提供了一种小范围的方法来从业务模型中创建某个领域的实现。我写这本书的兴趣在于在一个真实的项目环境中结合使用这两者。然而,只有在仔细检查了领域模型的需求以及底层业务模型作为软件的复杂性之后,才能决定采用基于 DDD 的设计。

选择性内容

那么,我的目标是为您提供 DDD 的主要要素、工具和策略,这样您就可以通过在自己的项目中实施它来获得真正的价值。这是可能的,因为我们最初放弃了传统 DDD 中常见的大量信息和开销——让我们专注于快速启动所需的核心方面和策略——并在本书稍后深入研究 DDD 和拉勒维尔时回到这些主题。

我还必须提到,当您试图实现一个半生不熟的 DDD 实现时,这有点冒险。如果你开始应用 DDD 的所有技术和技术模式,不管你愿不愿意,你将会在不完全知道它们做什么的情况下结束构建,或者更糟的是,将会在不正确的上下文中应用特定的技术或模式,最终迫使一个大的重构或完全重写。这样做的原因是因为 DDD 的纯粹的大小;这门学科本身有如此多的概念和想法,以至于很容易误解或混淆定义。然而,只要你以领域驱动的方式进行,也就是说,通过让你的业务需求和无处不在的语言来引导开发,这种情况发生的可能性是最小的。

避免陷阱

我要避免这种灾难的方法是通过讨论你需要的基础知识的坚实基础,以便理解为什么事情以某种方式完成,或者为什么最好在特定的环境中实现一些架构组件。通过反复灌输一种经过充分研究和提炼的通用语言的重要性,以及一些关于如何正确构建一种语言以及如何开始使用它作为构建实际应用的基础的方法,我完全有信心你会对领域驱动技术有一个全面的了解,并且不会误用或滥用它们。相信我,DDD 的糟糕实现有时等同于根本没有应用,因为由于以下一个或多个原因,你最终不得不重写整个该死的东西:

  • 由于缺乏任何类型的“真实来源”来解释给定的业务术语的真实含义,或其应用的上下文,业务术语在应用中被歪曲和混淆。

  • 最初创建系统(或其流程)时没有领域专家参与,因此概念要么应用不正确,要么根本没有应用——当向不正确的业务模型添加更复杂的逻辑时,经常会导致领域层(以及接触 it 的事物)内设计问题的连锁多米诺效应。

  • 同样由于缺乏与领域专家的交流,许多关于某些业务流程做什么的假设被错误地实现,就像开发人员认为他们应该做什么一样。

  • 当最初规划领域模型时,开发人员选择而不是来投资于知识发现或精化阶段,结果,构建了解决错误问题的组件和模块。

我打算用 DDD 在现实世界中的实际应用来武装你。我会给你一些我在这个行业从业 10 年后自己培养出来的建议,我会教你什么是“最佳实践”以及你遵循它们会得到什么回报。我们将学习 DDD 的基础,以及如何以实际可行的方式实现一个领域驱动的模型。然而,这不是一条容易走的路,迷失在 DDD 主题包含的大量信息和提炼的知识中并不困难。

着手领域驱动的设计

我们将着手以一种有组织的、深思熟虑的方式构建我们的领域模型,当我们对我们试图建模的业务操作了解得越来越多时,这将为我们提供扩展和改进的基础结构和形式。此外,我们将开发一种无处不在的语言,它是整个公司一致同意的业务术语的核心定义。然而,构建良好的无处不在的语言并不便宜。需要与不同的部门领导、领域专家、开发人员和利益相关者进行多次对话,以建立清晰的定义和边界,封装各种领域级组件及其交互,它们共同构成了业务系统的主体。

当然,采用以前没有的任何类型的标准、实践和范例都会有一点点开销。在初步了解了 DDD 的本质以及我们将如何在现实世界中使用它之后,我们将使用 Laravel 实现我们从各种对话、会议和探索性研究中吸收的内容。我们将把我们在开始时获得的所有知识压缩成一组文档化的组件和数据映射(即,创建一种无处不在的语言),然后规划我们实际上如何布局组件,这些组件将构成一个完整的工作分布式系统,该系统具有模型驱动的设计、文档化良好的策略和定义以及经过充分测试的代码,并且将使用您可以在现实世界项目中使用的经过验证和测试的最佳实践来构建。

这种开销来自于学习和理解交易的基本工具。我将简单介绍一下我每天使用的工具或服务,并提供替代建议,这样你就能很好地了解在真实的商业世界中编码是什么样子。此外,我希望给你一些关于如何提高你自己的工作效率和质量的想法。

我们还将回顾各种可以实现的最佳实践,以及当您忽略它们时,代码库和项目最终会发生什么。

前几节将带您了解一些更基本的部分,我们将需要将这台机器实际投入实践(在真实世界的场景中),并从中获得一个应用,该应用具有一个使用 DDD 的程序和技术开发的模型,该模型基于经过深思熟虑和实施的策略和核心最佳实践,将确保它能够处理业务可能需要在以后进行的任何更改或更新。

有趣的组合

在 web 开发应用中成功采用领域驱动方法的关键是不要重新发明轮子。我们将开始关注创建一种专门针对特定业务模型的无处不在的语言所涉及的战略和战术,以便我们可以使用它来驱动实体、值对象和域级组件的开发,我们稍后将围绕这些组件实现周围的服务和基础设施——同时保持关注点的清晰分离,最重要的是,允许我们的业务规则和核心流程不仅指导我们的开发工作,还创建结构并赋予代码实质。

仔细想想,web 框架——或者一般的框架——都旨在通过提供工具来帮助管理应用的,在我们的例子中,就是请求/响应生命周期,从而减少构建应用所需的时间。还有许多组件可以帮助管理 web 开发项目的各个方面:验证、认证、数据库模型等。所有这些功能都在那里,所以你不必重新发明轮子。这让我们可以几乎完全专注于领域层,这很重要。

雄辩的 ORM

数据定义语言(DDL)中包含的一个重要组件违背了 DDD 中的实践和价值观。包含雄辩的 ORM(只允许稍微改变 DDD 没有 ORM 的建议)将允许我们做许多简洁的事情。

  • 将我们的领域事件与雄辩的生命周期联系起来,允许我们的模型自动发出特定的领域事件,或者当我们关心的事情发生时

  • 轻松地创建模型之间的关系,使我们能够创建和管理我们的领域模型,并使用表达性语法查询我们的数据库,使它们之间的交互变得简单明了

  • 为级联更新设置我们的实体和模型(在给定模型的更新过程中被修改的任何关系模型也会得到更新)

  • 扩展了口才的Model类,让我们继承了口才自带的一些很酷的特性

    • 批量分配

    • 查询范围

    • 急切装载

    • 收集

    • 模型事件

    • 模型观察者

  • 一个验证组件,它将确保我们的模型处于有效、一致的状态,并且有适当的限制来防止错误的使用

在 Laravel 中开发领域驱动设计的最重要的工具之一(在事情的“代码方面”)是雄辩的。雄辩是一个基于活动记录的 ORM,它提供了一系列内置的很酷的特性和工具(事实上在 Laravel 中是免费的)。这些是我们追求 DDL 应用目标时非常需要的一些关键东西。

在现代编程中,设计一个对象的数据和它的行为的分割已经成为一种趋势。实现这一点的方法是将一个对象包含的原始数据分离到一个称为域转移对象 (DTO)的专门类中。尽管我们将在本书后面详细讨论 dto,但现在要理解它是一个基本对象,对于对象上存在的每个需要检索或存储的属性,它都有 getter 和 setter 方法。它只保存数据,不保存行为。

我知道这听起来很奇怪,可能确实违背了你的内部程序员所说的一切是正确的,但是,在雄辩中,实体和它们的 DTO 对应物或多或少地以一种实用的方式混合在一起,并且免费出现在任何扩展雄辩的Model类的 PHP 对象中。现在,这并不是说你不能在你的持久层和 ORM 之间有一个独立的 DTO 层,但是这真的是一个没有意义的努力,因为你所做的只是重写已经存在于雄辩的抽象Model类中的特性。如果需要模型的原始数据库表示,只需直接从模型中访问任何字段。然而,如果您想要一个存在于一个Model对象上的所有属性及其对应的未被该类的访问器修改过的原始值的列表(类似于您在 DTO 中会找到的),您可以只使用 concertive 的toArray()方法。

雄辩的榜样

Note

我们将忽略这个例子中模型的设计是有缺陷的这一事实,本可以用比我在这里描述的更有条理的方式来处理,但是它为当前的上下文提供了一个很好的演示。

假设您有一个Customer型号(以及数据库中相应的customer表,该表有一个phone字段(包含客户的电话号码)和一个phone_type字段,包含 1 表示家庭电话,2 表示手机,3 表示工作电话。使用口才,我们可以实现清单 1-1 中所示的类来表示一个Customer对象。

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Customer extends Model
{
     public $table = 'customers';
     protected $fillable = [‘name’, ‘phone’, ‘phone_type'];
     }

Listing 1-1Sample Customer Class Implementation

基本上,这里我们有一个公共的$table属性,它告诉口才 MySQL 中相应的表,其中这个类的一个对象将表示一个数据库表行。$fillable属性是该表上的字段列表,您希望启用一个名为批量赋值的特性,我们将在后面的章节中更详细地介绍这个特性。

表 1-1 提供了数据库中的一些样本记录。

表 1-1

来自表客户的示例记录

|

编号

|

名字

|

电话

|

电话类型

|
| --- | --- | --- | --- |
| one | 杰西·格里芬 | Six billion one hundred and ninety-seven million seven hundred and seventy-nine thousand one hundred and twenty-five | one |
| Two | 埃里克·埃文斯 | Nine billion nine hundred and ninety-eight million eight hundred and eighty-seven thousand seven hundred and seventy-seven | Two |
| three | 泰勒·奥特韦尔 | Seven hundred and seventy-seven million eight hundred and eighty-eight thousand nine hundred and ninety-nine | three |

清单 1-2 提供了一个客户端代码如何使用Customer模型的快速演示。

<?php

use App\Models\Customer;

$customer = Customer::first(); //gets the first in the db
                               //(with the lowest id)
echo $customer->phone;
//returns "6197779125"

echo $customer->phone_type;
//returns "1"

Listing 1-2Sample Code Retrieving a Customer Record from the Database and Acting on It

Note

口才的抽象Model类有很多方便的方法,比如first()load()intersect()makeHidden()only()等等。我们将在第十四章中探讨这些特性,但是要获得这些方法的列表,请查看 http://laravel.com/docs/6.x/eloquent-collections#available-methods

现在,假设我们想要动态返回客户的电话类型返回值。例如,当在某个地方的模板中显示电话类型时(最有可能在用户的配置文件配置页面上),我们可能希望显示一个漂亮的、漂亮的英文表示。然而,在其他情况下,我们希望使用存储在表中相应字段内的原始数据库值,比如说,在对其执行额外的逻辑之前进行比较或预检查。

有多种方法可以不用雄辩的来解决这个问题。通常这需要定义某种类型的 DTO,它基本上充当一个代表数据库中给定客户行的瞬态对象。这些 d to 被称为瞬态,因为它们以特定的形式在应用中传递,就像前面描述的那样。最常见的是,它们用于将数据对象转换成发送给用户的响应,或者用于定制 API 响应。这可能是一个足够的解决方案,可以在数据层和应用层之间保持清晰的分离,但是您基本上是在分离同一个对象的两个表单,这通常被认为是一种不好的做法。**

例如,在设计数据库模式时,您不希望字段customer_type_name customer_type_id都存在于相同的表中。这将是以不可取的冗余方式复制数据。

Note

在我给你的关于Customer模型的例子中,用这个来提示应该重构什么。

除此之外,您仍然需要实现如何完成转换部分,这通常是由转换器完成的。

Note

一个 transformer 是一个专用于改变特定对象类的属性值的类,以便用不同的方式表示它们。

想想看,我们真的需要这个模型能够做什么?简而言之,我们需要它来改变特定属性的返回值;我们需要不同的形式的财产。在前面的例子中,我们希望一个表单是英文可读的单词,另一个是直接的原始数据库值。需要意识到的是:我们正在处理同一个数据库对象的不同的形式。唯一的区别是它们呈现给使用它们的客户的方式;它们代表相同的数据库实体。如果是这样的话,我看不出有什么理由把这些行为分开。

作为一个额外的好处,假设我们还希望返回的name值的第一个字母大写,这样我们就可以在网页上正确地显示它,而不需要任何额外的格式逻辑。幸运的是,对于我们所描述的所有问题,雄辩术提供了一个通用的解决方案,它使用了雄辩术的一些内置特性:赋值函数和访问函数。参见清单 1-3 。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Customer extends Model
{
     public $table = 'customers';
     protected $fillable = ['name','phone','phone_type'];

     /**
     * Accessor returning the name with capital first letter
     */
     public function getNameAttribute($name)
     {
          return ucfirst($name);
     }

     /**
      * Accessor returning English version of phone_type
     */
     public function getPhoneTypeAttribute($phone_type)
     {
          switch ($phone_type) {
               case 1:
                    return "home phone";
                    break;
               case 2:
                    return "cell phone";
                    break;
               case 3:
                    return "work phone";
                    break;
               default:
                    throw new Exception("dang!");
          }
     }
}

Listing 1-3The Eloquentized Customers Class

在清单 1-3 中,我们添加了两个新方法:getNameAttribute()getPhoneTypeAttribute()。这两者都被称为访问器。它们允许我们引用模型上的给定属性,并自动调用模型上定义的相应访问器(如果有的话)来返回该属性的特定形式。清单 1-4 展示了一个使用这个模型的例子。

<?php

use App\Models\Customer;

$customer = Customer::find(2); //retrieve customer Eric Evans
echo $customer->phone_type;
//displays "cell phone"

echo $customer->getAttribute('phone_type');
//displays the raw database value, "2"

Listing 1-4The Client Code for the New Eloquentized Customer Class

正如您所看到的,通过在模型类上指定访问器并编写转换原始值所需的逻辑,我们已经成功地为我们的模型添加了一定程度的动态性,在通过直接属性(即$customer->{field_name})请求模型上给定属性的值时,会立即调用该逻辑。

由于抽象模型的内部属性数组,我们还能够保持原始数据库值的完整性,甚至不需要尝试就能保持数据的完整性。

Note

在引擎盖下,Customer模型的内部$attributes阵列使这成为可能。这个数组保存数据库中的实际值。值可以以类似于访问器的方式保存,只需在值进入数据库之前修改值,使用所谓的赋值器

这也表明我们总是将原始形式的数据库值保存到这个内部的$attributes数组中,并且我们可以通过使用$this->attributes['property_name']来访问这些值。

我发现使用这种方法消除了为您的每个模型实现单独的 d to 对象的需要(以及随之而来的转换器),当然,除非您正在处理一个过于复杂的领域,该领域可能有跨各种上下文的对象数据的许多不同的转换。在这种情况下,完整的 DTO 和变压器实现可能是一个可行的解决方案。

唯一的缺点(如果你认为这是一个缺点的话)是,本质上讲,雄辩混合了基础设施层和部分领域层,提供了一个无所不包的解决方案,几乎模糊了各层之间的界限。然而,由于 Laravel 的直接功能、富于表现力的语法和易于理解的特性,模糊应用各层之间的一些界限是值得的。这是 Laravel 或多或少与领域驱动设计方法不完全兼容的另一个例子。

这里要始终记住的关键点是我们让领域领导架构和应用决策,这些决策最终驱动开发工作,并有助于实现一个丰富而优雅的领域模型,该模型真正捕获了它们所代表的业务的需求和要求。

为 Web 开发定制 DDD

值得注意的是,在“纯”DDD 实现中,领域层(以及其中的模型)对于使用它们的外部世界是不可见的。这里的问题是,在 web 开发中,我们将不得不依靠其他代码来处理 Web 应用中涉及的基本功能和基本操作。在 DDD,关于依赖的概念是尽可能地避免它们…我们需要一种方法来更好地表达这种概念,从 web 开发的角度来看是有用的。

在我在本书中采用的方法中,这不完全是 ?? 的情况。相反,我稍微变通了一下规则,为雄辩的 ORM 让路。这个组件非常重要,值得我对 DDD 处理系统设计的方式进行调整。在架构层面上,关键的区别在于,雄辩中的领域模型带有一个数据库抽象层(DBAL ),可以用来(或多或少地)手动编写定制的 SQL 查询,一个活动记录实现,便于在对象层面上管理模型。此外,您还获得了一个富有表现力的语法,允许您优雅地处理实体关系、执行内联突变和属性转换、设置急切加载、挂钩到雄辩的生命周期事件,以及使用全局和局部范围作为对数据库中底层模型对应表中所有对象的约束来构建无缝过滤器(由受保护的$table属性表示)。

虽然我可能已经用这个例子吊起了你对雄辩的胃口,但我们不会一头扎进雄辩,直到本书的后面。决定使用雄辩作为表达领域驱动模型的方法的一个缺点是,它违反了 DDD 的自包含领域标准。这只是我必须修改以适应开发领域驱动的 web 应用的一个例子。正如您将看到的那样,这种违反肯定是合理的,因为用 Laravel 和口才实现的 DDD 作为开发 web 应用的实用解决方案配合得很好(正如您刚刚看到的,这只是冰山一角)。

为 DDD 定制 Laravel

领域驱动的设计,在本领域的常规意义上,建议围绕领域层(在这种情况下是基础设施层)构建应用的底层组件(例如,管理连接到数据存储所需的连接和配置的组件)。这意味着我们还必须手工构建管道代码,以便在持久性级别、应用级别和领域级别上简化这些模型——基本上是从头开始。当您分解所有涉及这样一件事的各种结构时,您需要开发各种驱动程序实现、DTO 级别的对象、ORM 类型的组件(如实体管理器)、存储库和一系列其他低级对象,否则这些对象将需要构建以允许模型能够与服务对话并持久化捕获的数据。

因此,我们需要的是一种以可扩展和动态的方式开发基于云的解决方案的方法,这种方法能够真正为核心业务流程提供解决方案。在企业级架构中构建定制应用时,如果有一个清晰的流程和路线图愿景就好了,这种架构使用最佳实践,并允许底层业务逻辑(即,领域模型/领域层)驱动丰富而强大的领域的开发和细化。定义一种无处不在的语言(领域驱动设计的先决条件)的一个核心必要性是,通过不断地参考您的领域/业务/组织中的业务领域专家,自己对领域信息有一个牢固的掌握,以便获得业务对象的清晰定义,应用的其余部分可以使用这些定义来执行它在领域层需要的任何服务和操作。

也就是说,只有利用最流行、最现代的工具,使 web 开发更容易、更快、更好,才是有意义的。输入 Laravel。Laravel 提供了开发任何基于 web 的应用时需要构建的最常见组件的各种实现。它是使用最佳实践构建的,并且是完全开源的,这使得修改它的内部结构来改变框架的行为变得容易和快速。它还附带了一个代码生成器,可以通过一个简单的命令调用它来构建完整的控制器、API 资源、模型、作业和一大堆我们可以随意使用的其他东西。

Laravel 的库存装置的问题是

Laravel 有一个重要的方面与领域驱动的设计理念不太相符,那就是它的目录和名称空间结构。Laravel 最初是作为一个整体应用建立起来的——它的所有组件都是松散耦合的,并促进最佳实践,但都在一个整体结构中。这是一个问题,因为 DDD 是用来创建分布式系统的,在这个系统中,不同的组件甚至可能不知道彼此。这是微服务架构的中心目标和关注点。

例如,在默认的 Laravel 安装中,您会发现它也有一个整体结构,目录名(和名称空间,因为 PSR-4)相对于应用的关注点,而不一定是域。图 1-1 提供了开箱即用的通用 Laravel 目录结构(带描述)。

img/488324_1_En_1_Chapter/488324_1_En_1_Fig1a_HTML.png img/488324_1_En_1_Chapter/488324_1_En_1_Fig1b_HTML.png

图 1-1

默认 Laravel 结构

我必须修改的 Laravel 的一个核心方面是目录和名称空间结构。因为它是一个包罗万象的框架(旨在在一个单一的整体结构中实现应用的所有关注点),Laravel 被分解为特定于应用的边界——与相应的应用关注点相关的类的分割组,如日志记录、发出 API 请求或管理认证和访问控制。

数据库层的关注点也应该从任何使用它或者依赖它来执行自己的职责的逻辑中分离出来。关注点的分离有助于巩固这种思维方式,并作为开发的更重要的指导方针之一。忽略关注点分离规则可能会导致不仅仅是混乱的代码,功能被填充到跨越几个架构边界和部门的大量服务中,这是由于缺乏分离而发生的常见场景。这样的项目可能被领域分割,也可能不被领域分割,并且可能被聚集成一个包罗万象的整体结构,很难处理和维护。

当应用的核心业务逻辑主要存储在服务层内部时,它被称为贫血的服务层。在更高的层面上,这与古老的咒语“胖控制器,瘦模型”相关出于我们讨论的目的,将一个贫血的服务层想象成采用“胖服务,瘦模型”的方法——结果同样适得其反。相反,我们在本书中关注的是如何以一种“胖模型,瘦控制器”或者“胖模型,瘦控制器”的方式对领域建模。当我们将领域逻辑放在服务的范围内时,我们为将来的开发错误敞开了大门,因为当应用的其他地方需要该服务中的类似功能时,开发人员(或任何其他人)可能会忘记服务中的某个步骤。长话短说,最好将大多数业务逻辑放在模型本身中。这是我的观点,我们将在本书后面更详细地讨论这个概念。

什么时候在你的项目中使用 DDD

领域驱动的设计最适合于包含 30 个或更多用例的复杂应用,而整体架构更适合于简单和较小的项目。事实上,对这样一个简单的应用使用 DDD 可能有些矫枉过正,并且可能导致浪费大量精力来构建领域驱动设计所需的所有管道和支持结构。

这种设计以及这种设置适合开发 web 应用的原因有很多,例如简单、易于部署、快速上市和快速应用开发。

整体结构很简单。所有的类、事件、交互和进程都是独立的,存在于同一个服务器、同一个文件系统、甚至同一个根目录和名称空间中。它很容易实现,因为您不必担心跨网络的内部对象通信之类的事情,也不必将对各种服务的访问分成单独的 API 调用——甚至不必为您需要访问或利用来完成某项任务的每个特定于域的服务分配单独的客户端。在 web 开发的环境中,这意味着我们要使用任何这样的服务,要么直接导入它,要么使用静态方法(或facade—另一种设计模式)来访问它。虽然当你开发的东西本身简单时,简单是很好的,但是任何需要更大规模(超过 30 个用例)的更健壮的解决方案的项目可能会超出 Laravel 的扁平架构,在这种情况下,分布式系统可能更合适(DDD 在这种情况下是理想的)。

易于部署

由于整体架构的扁平结构,所有包、类和域服务都驻留在同一个根文件夹中,并且它们的依赖项存在于项目根目录下的不同文件夹中。这使得部署 web 应用变得简单。Laravel 本身实际上只需要几个命令就可以完全从头开始安装,如清单 1-5 所示。

curl -Ss getcomposer.org/installer | php
php composer.phar global require laravel/installer
laravel new blog
php artisan key:generate
php artisan serve

Listing 1-5Sample Laravel Installation via Command Line

通过这五个命令,我们已经成功地下载了 Laravel 框架,安装了它所需的所有依赖项,在 base64 中配置了一个安全的应用密钥,并使用artisan serve命令启动了一个即时 web 服务器(它只是调用 PHP 的内置 web 服务器来提供来自位于public/文件夹中的 web 根目录的文件),所有这些都不费吹灰之力(向史奇雷克斯大喊)。

当您拥有运行源代码的独立环境时,部署过程会变得更加容易。例如,您可能有一个配置为使用测试数据库的环境,您可以在该环境上进行开发工作(并且可能存在,因此如果您完全破坏了它,您可以很容易地重新创建它,而不会影响其他人的工作或生产站点)。开发服务器通常具有特定的配置,可以禁用缓存、允许在屏幕上显示错误、禁用公共访问或限制外部 IP 地址,并具有其他特定于开发的设置,允许程序员在上进行实际的开发工作。

然后,当然,每个人都有他们的功能,面向公众(或面向公司)的生产环境,为你的真实世界的用户服务。生产环境的配置很可能会连接生产数据库,默认情况下不显示任何错误,而是写入日志文件或引发异常,并且可能会有缓存来维护良好的用户体验。此外,在公司的内部网上可能存在其他应用,我们的应用必须能够访问这些应用,它们拥有自己的生产和开发环境设置。

这些情况很常见,因此 Laravel 采用了 dotenv 标准来定义其配置参数,允许每个环境拥有自己相应的配置,这些配置在位于项目根目录中的单独文件中指定,并以名称.env作为后缀。您可以为您拥有的每个环境创建多个.env文件:.env.testing.env.development.env.staging等。

快速上市

如果你在开发行业或整个 IT 行业工作,你可能有也可能没有机会在创业公司工作。创业公司不同于企业组织,因为他们更关心成本、将想法推向市场所需的时间以及软件开发生命周期的速度。很多时候,这意味着为了节省时间而跳过正常的最佳实践和标准。创业公司的需求要么尽快得到满足,要么根本得不到满足,因为他们的经营方式是“要么成功,要么失败”。

为这样的公司构建一个复杂的应用可能会让你觉得分布式系统将是最好的方法,但是考虑到正确建立一个分布式系统所花费的时间、金钱和精力(以及实际实现一个领域驱动的设计),这通常是不可能的...至少一开始是这样。在这种情况下,最好使用整体结构,这样你就可以把产品提供给客户和最终用户,从而开始赚钱。一旦发生这种情况,你将处于一个更有利的位置,去说服上层管理人员以正确的方式做事。

我无法告诉你有多少次我走进(或者说继承)一个构造糟糕的单片应用,它只有很少的文档,在过去十年中一直运行着相同的过时技术、框架和实践,迫切需要重写和重新设计。唯一的问题是,到那时,代码已经不堪重负,而且充斥着黑客攻击、快速修复和变通办法,因此完全重写似乎是更好的解决方案(尽管情况很可能并非如此)。因为应用从未被开发,其内部结构也从未超出最初的“立即推出”版本,公司通常会在必要时处于非常不利的地位(例如,当现代浏览器失去对公司基础软件所基于的技术的支持时)。

在像这样的最坏情况下,完全重写实际上是需要的。不要到这个地步!重构和提炼代码,确保它是按照高标准编写的,并且能够经受住任何时候对领域模型的额外洞察而产生的变化和重构。怎么做?首先,请继续阅读这本书。

快速应用开发

在内部,Laravel 为日志记录、事件广播、作业和队列设施、缓存、路由、视图和模板(通过刀片)、认证和授权等问题提供了开箱即用的解决方案。它配有一个脚手架系统,使快速应用开发和概念验证变得简单快捷。它还附带了一整套通用的、通用的契约,这些契约提供了一组接口,可以实现这些接口,从而以一种内聚和松散耦合的方式实现各种排序功能。有许多内置工具来管理 API 创建,包括 API 资源、REST 开发、请求(输入)验证和认证。

像这样的支持组件有助于快速的应用开发,因为它们遵循了有史以来最古老的最佳实践之一:不要重新发明轮子。通过这样做,我们已经扫清了道路,让我们能够专注于对业务真正重要的东西:充分满足为其创作付费的公司需求的软件。无论是内部的还是面向公众的,整体的还是分布式的,或者任何其他真正的东西,通过利用这些由框架(比如 Laravel)提供的预制的、插入式的、即插即用的解决方案,面向领域驱动设计的开发工作可以变得更加容易和快速。

结论

在这一章中,我们看了一下我们将在整本书中关注的各种概念。我们还讨论了使用 Laravel 框架作为实现领域驱动设计的方法,包括我为了实现这两种技术的结合而不得不“变通”的一些规则。我们快速浏览了一下雄辩的 ORM,并回顾了它在通向领域驱动的 Laravel 的过程中的重要性。

我可能在这一章跳了一下,但这是有意的,目的是为了吊起你的胃口,我希望,灌输给你学习更多关于使用 Laravel 制作领域驱动设计的欲望。我们回顾了基本架构,对比了微服务架构和整体架构之间的差异,还回顾了 Laravel 对 web 应用部署便利性的影响。我们简要地讨论了 d to 和口才,以及口才如何基本上不需要提供单独的 dto(当然,除非需要)。

二、基础训练营

在本章中,您将学习一些核心工具和术语,以及足够的概念性材料,以帮助您阅读本书,并能够理解本书后面更难和技术性更强的章节。我还将介绍一些其他的关键概念,它们本身并不是我们将要学习的核心原则和标准,而是更多的周围“管道”,如果你愿意的话,这将帮助你在现实世界的设置中开始。这一章的一些内容将是我认为值得一提的理论。

我们还将了解一些 web 开发框架的历史,以及我们是如何使用 Laravel 作为我们应用的基础的(当然还有 DDD)。我们将退一步,从更广阔的角度来看问题。

Note

即使你不是为了学习拉弗尔或 DDD 的新概念而阅读这本书,你也会从阅读这一章中受益。它基于软件行业的普遍性。

做开发者意味着什么?

成为一名网站开发者需要付出很多,而成为一名成功的网站开发者需要付出更多。在过去的几十年里,关于我们所做的工作类型,web 开发人员的定义并没有太大的变化;然而,我们开发应用的方式已经以越来越快的速度发生了变化,可用于构建软件的可用工具、包和框架也是如此,并且在未来没有放缓的迹象。(值得注意的是,今天的大多数软件都遵循某种类型的设计模式或原则,所有这些模式或原则在编程的早期就已经存在,并在 1994 年左右由四人帮的对象模式书正式认可。)

这些进步已经改变了我们思考、构建、扩展和跟踪软件系统的方式,并开创了一个编程和开发的新时代。它们可以被认为是下列类别之一的一部分:软件开发工具、软件设计范例和编码标准,和/或一般的编程最佳实践。作为一名开发人员意味着利用这样的工具,不是因为它们是当时最新最棒的东西,而是因为它们为我们的代码以及最终产品增加了价值、速度和质量。DDD 本身是从开发行业的需求中诞生的,这种需求为制作特定于特定领域的软件提供了足够的指导,但也足够通用于我们试图建模的任何领域。

软件开发工具

软件开发工具(更确切地说,web 工具)是第三方软件或服务,您可以将它们合并到您的软件开发生命周期中——就像那些用于促进基于 web 的系统的创建的软件或服务一样。总的来说,该工具已经成为一种催化剂,促使人们普遍认识到创建高质量代码的重要性,并成功地改变了编程环境。该行业本身已经并正在使投入生产的代码质量突飞猛进——以至于它帮助延长了应用的生命周期,因为它们是为了以更可维护的方式创建代码而构建的,允许它们更容易(或至少更容易)扩展和协作。

对于软件工具,我指的是任何第三方开发工具,如 IDEs,在线服务,如 Google Cloud 和 AWS,软件版本控制系统,如 Git(有专有版本,如 Bitbucket 和 GitHub),以及其他公司提供的各种其他解决方案,以增强开发生命周期的某些方面(无论是专有/开源,免费或付费),并使开发定制软件和 web 应用更加简化和可靠,而且大多数时候,这恰好是一个自然可重复的过程。

从开发人员的角度来看,使用现代工具和新兴技术比使用 Notepad++作为 IDE 更令人愉快,这些工具和技术能够吸引我的注意力,并提供新的和改进的突破和体验。这在更大的范围内意味着,作为一名开发人员,意味着在为代码增加价值(为业务增加价值)的技术方面是高效的,并且有助于促进软件开发项目中的协作努力,在该项目中,多个开发人员一起工作。每个开发人员都使用相同的代码库,但是使用不同的版本,并且每个人都使用他们自己的变更部分来更新他们自己的副本,这通常是经常发生的(一天多次并不奇怪),同时确保他们使用其他人的最新代码变更,以便当所有的更新都合并到最终形式中时,不会引入任何不可预见的错误或合并问题。

PHP MVC 框架和开源

自 20 世纪 70 年代以来,模型-视图-控制器(MVC)架构就一直存在,在过去的 15 年中,它首次被引入“官方”web 应用。开源世界已经看到了这种 MVC 框架的一些重大进步,它们现在已经成为实现几乎任何基于 web 的系统的事实上的标准。

可以说“改变游戏”的原始框架是 2005 年左右发布的 Zend 框架和 Symfony 框架(版本 1)。从那以后,PHP MVC 领域出现了几十种不同的框架,今天最流行的是 Laravel 框架(它使用了各种 Symfony 和 Zend 组件)。

当今流行的大多数 MVC PHP 框架都是由 Symfony 的 Fabian Pontecier 最初发布的构建块构成的,他将它们创建为松散耦合、独立、可重用的组件,表示 HTTP 请求和 HTTP 响应,目的是封装一个对象以方便进入应用(请求)和对该请求的响应(响应),它封装并模仿了各种 HTTP 级属性、头和其他元数据,这些元数据出现在来自浏览器的典型客户机-服务器请求/响应循环中(显然是为了跨网络使用)。

集成开发环境

当我提到集成开发环境(ide)时,我指的不是诸如 TextPad、Notepad++或 Sublime 之类的东西。这些都是很棒的程序,但它们不是 ide。他们是文本编辑器。其中一些有很少的插件可以扩展文本编辑器的功能,但它们都旨在使编辑器的功能像 IDE 一样,具有语法突出显示、LINT-ing 功能、代码格式化程序等扩展。—基本上,一个好的 IDE 支持开箱即用的所有东西。

目前,我在 web 开发(PHP 开发)领域看到的最常用的两个 ide 是 JetBrains 的 PhpStorm 和微软的 VSCode。就我个人而言,我使用 PhpStorm 是因为它几乎包含了我用 PHP 编程所需要的一切,这将使我成为一个更快、更干净、更好的开发人员。此外,Laravel、Symfony 和 PHP 生态系统中其他常用组件的可用扩展使我几乎不必离开 IDE,它们允许我专注于开发项目的领域模型(稍后将详细介绍)。

这两种 ide 都带有自动完成功能(尽管 PHP 使用一种更本地的方法来自动完成和处理不同的文件类型)。此外,在两个 ide 中都有大量的颜色主题可供选择,这在检查 bug 时非常重要,并且使阅读代码变得更加容易和有趣(同样,也许我只是有点奇怪,但是嘿)。当然还有其他的 IDEs 我只是分享我见过的用 PHP 做 web 开发最多的。主要的一点是,它通过为 IDE 的高级功能提供快速简单的低级快捷方式,提高了代码的速度和正确性。其中一个特性是为迷宫般的供应商包名称空间提供自动完成功能。如果您曾经使用过 Composer 来管理您的依赖项,您就会知道自动完成是多么节省时间。

版本控制系统

尽管软件版本控制已经存在很长时间了,但是以一种可管理的、符合逻辑的方式签入代码并对代码进行修改,以适应整个团队同时从事同一个项目,这种想法从来没有像代码库行业的两个主要参与者 GitHub 和 Bitbucket 那样简单和直接。两者都提供相似的功能,尽管 Bitbucket 是免费的,也没有那么漂亮或功能齐全(但确实很好地集成到了 Atlassian 的任务管理软件吉拉中)。GitHub 提供了一个很棒的界面,以及关于其用户和项目的各种见解和统计数据,还有一个漂亮的个人资料页面,通常用于显示或炫耀对开源项目的贡献。Git 版本控制系统已经成为 web 开发团队和软件工程师跟踪他们代码的标准。

PHP 的进步

在过去的 15 到 20 年里,我们用来开发 web 应用的语言发生了很大的变化。以 PHP 为例,它最初是一种脚本语言,旨在(预)处理超文本标记,并向浏览器用来呈现在线内容的无逻辑 HTML 语言添加逻辑。它直到 PHP 版本 4 才支持对象、类或继承,直到版本 5 才真正开始提供真正的 OOP 支持——命名空间直到版本 5.3 才出现!随着 version 7 的发布,我们现在有了一些很酷的内置特性,比如返回类型声明、零合并操作符(??)和宇宙飞船操作符(<=>),更不用说与以前的版本相比性能有了巨大的提高。事实上,PHP7 比任何其他编程语言的任何新版本都有更大的性能提升。PHP 已经获得了巨大的普及,并在今天被用来运行大部分的网络。下面是一些将 PHP 用于他们自己的应用的公司:

  • 松弛的

  • Etsy 的

  • 云 flare

  • 特斯拉

  • 维基百科(一个基于 wiki 技术的多语言的百科全书协作计划ˌ也是一部用不同语言写成的网络百科全书ˌ 其目标及宗旨是为全人类提供自由的百科全书)ˌ开放性的百科全书

  • 博客

  • Tumblr

使用 PHP 构建的框架已经变得足够流行,被认为是创建现代 web 应用和分布式系统的事实上的标准(尽管目前的趋势是从 PHP 转向 NodeJS 这样的服务器端语言,NodeJS 使用 JavaScript 作为其主要语言,但针对服务器端编程进行了重新设计)。尽管 PHP 的总体使用量最近有所下降,但基于它在互联网上作为基础语言的地位,以及 80%的 PHP 运行于其上的事实,我认为它不会很快消失。总是需要支持(并最终转换)遗留系统,所以精通 PHP 从来都不是坏事。

说到 JavaScript,前端开发在过去几年里已经取得了长足的进步。ECMAScript 6 在过去几年里一直是人们谈论的话题;React、Material、VueJS 和 Angular 等突破性技术的引入,以及 redux 模式和其他状态管理关注点等概念上的进步,为前端 web 开发带来了更复杂的方法,使其更像后端开发。

依赖性管理系统

包管理系统已经存在很长时间了,但是它的焦点是共享代码库,这些代码库必须是专门使用的,并且缺乏任何整体的平台或者获取的手段。现在,有两个这样的系统驱动着所有 PHP 框架的 web 应用的开发,并且是管理 web 应用中依赖关系的标准方法,包括前端和后端。它们被称为 Composer(一个 PHP 依赖管理器)和 Node Package Manager (NPM)。Composer 最常用于引入后端依赖项(并且几乎总是在为您的应用安装任何第三方依赖项时的第一步,包括 Laravel 之类的框架)。

构成任何现代 MVC 框架的几乎所有安装基础的流行命令如下所示:

composer install

这两个词足够强大,可以下载composer.json文件中列出的所有所需依赖项的指定版本(称为版本锁定,它保存在composer.lock文件中),并创建一个包罗万象的自动加载器,只需一行简单的代码就可以导入在composer.json文件中定义的每个第三方依赖项(清单 2-1 )。

<?php
require_once('vendor/autoload.php');
//good to go! use your installed dependencies freely

Listing 2-1Example Use of Installed Composer Dependencies

NPM 与 Webpack 结合使用,提供前端资产,这些资产可以下载、安装、缩小并以类似于 Composer 的方式运行,只需使用一个命令:npm install。Webpack 用于配置当前主导前端世界的所有高科技、新时代的库和包(如 React 和 Angular)的各种可用选项。不再需要进入网页,手动找到下载链接,然后在软件中使用它之前手动安装和配置它。这一切都是由 NPM 和 Webpack 完成的。尽管使用这些工具确实需要一些额外的知识(在其他人使用的生产/操作系统上部署东西时甚至需要更多的经验),但是它们为有时被称为依赖地狱的东西增加了理智。

只需一下子(基本上是两个命令),您就可以安装应用前端和后端所需的几乎所有第三方代码,并立即投入使用。这也为前端资产提供了一个整体结构,以流线型和流畅的方式围绕现代技术进行实践。

总的来说,这些和其他产品、库和平台所取得的进步让我们开发人员的生活变得更加轻松和有趣。我们不再承担管理我们自己的依赖关系并将这些依赖关系连接到一组可用的包含中的单调乏味的任务。我们可以只发布一个 Composer 命令或使用 NPM 来获取前端包..有了 PhpStorm、VSCode 这样的产品,以及 Bitbucket 和 GitHub 这样的版本控制系统,我们作为一个行业已经在全球范围内发展了 web 开发实践。这些进步对任何选择实现它们的企业的底线都有直接的影响,并且对开发人员的幸福和满足也有深远的影响,这两者都是成功的秘诀。

编码标准和实践

由于编程的本质(特别是 web 开发行业),在 web 应用和程序之间存在某种标准化的全球需求,这些应用和程序旨在通过网络从浏览器使用。感谢 PHP-FIG(类似于 web 开发的 RFC)这样的倡议,我们现在有了一套标准的建议,用于日志记录、结构化代码等方面,以便于阅读。开发人员还可以使用类似接口的东西来处理基本的请求/响应和关于流 web 响应的规范,以及自动加载问题。Web 编程通常只不过是几个非常大的文件(通常是“意大利面条式的代码”),所有的表示、业务逻辑和页面样式都打包成一个单一的整体结构,分布在两三个难以阅读、修改和维护的文件中。忽略这样的实践,因为它们具有清晰的关注点分离(在架构级别和领域级别上),使得负责维护代码的开发人员一想到要查看数英里构造不良和拼凑在一起的逻辑就畏缩不前,由于完全忽略基本的缩进和代码样式,这些逻辑的可读性也一样差。

这里要注意的另一件重要的事情是,代码的呈现不仅会影响下一个开发它的人,而且随着时间的推移,它会成为所谓的破窗理论的一个有效例子。在城市地区,当建筑物只有一扇破碎的窗户而没有及时修复时,自然会有更多的窗户被打破的趋势,这反过来又为更多的窗户打开了大门(或者更准确地说,窗户),这些窗户被打破而没有得到修复,这进一步延长了循环,最终导致不可避免的没有窗户的建筑物...一切都是从没有修好第一个开始的。

在软件中也是如此。未能修复应用中出现的第一个问题或问题只会使其他问题更有可能出现,如果不是很有可能的话,并且在这一点上,似乎“无论如何都是错误的”,所以只要事情仍然工作,谁会在乎是否有另一个小小的错误呢?这是一种糟糕的心态,因为在某些时候,这些无关紧要的小错误会累积起来,导致系统崩溃,导致严重的停机,影响公司的其他部门,可能会影响员工和客户。如果第一个 bug 被立即修复,那么任何其他的 bug 都会成为一个更大的问题。想想看:哪个开发人员想成为将一个错误引入一个没有错误的系统的人?(现实中可能有也可能没有“无 bug”系统这种东西,但你明白这一点。)

最佳实践是开发直接旨在防止这种类型灾难的软件的经过试验和测试的方法。有一些方法,当正确实施时,将有助于增加源代码的结构、深度和意义,并且最终将被用来创建一个高质量的软件工作件,该软件在其整个生命周期中确保其可维护性和可扩展性。

关键是要开发能够以任何速度伸缩的软件系统,并且能够承受系统的任何变化而不破坏系统的完整性。同样重要的是开发具有高内聚性的软件组件,并且用松散耦合的组件来表达,以便我们可以在应用的其他地方重用我们的代码。使用最佳实践意味着创建一个可靠的、可信赖的、可重复的连续迭代/连续开发(CI/CD)流程。这意味着频繁地向存储库提交和推送,在足够小的部分中进行微小的更改,以便于测试、管理和部署。让我们讨论一下开发环境中的内聚性是什么,以及如何将内聚性与低耦合性结合起来使用,以创建能够承受变化的可重用软件组件。

什么是凝聚力?

DDD 提供了一种组织应用的方法,这样它们可以作为独立的进程独立工作,但在协调运行时具有高度的内聚性。内聚是软件开发中的一个重要概念,还有耦合。与软件开发相关的术语内聚是由 Larry Constantine 在 20 世纪 60 年代末创造的,用来表达一个模块(或一组类)应该或多或少以统一的解决方案为目标的思想。在模块层次上,类应该足够分离,以便在没有太多开销的情况下可用和可扩展,并且应该通常专注于手头问题的一个方面。每个模块应该以这样一种方式相互关联,使它们作为一个组在功能上具有凝聚力——这意味着模块中的所有元素都应该有助于一个单一的、明确定义的任务。

请注意各种实践,并尝试认识到这里有两个主要原则,尽管是低级原则。一旦您能够识别单一责任原则和关注点分离,您将会发现这两者自然是相辅相成的。一旦你坚持认为一个类只专注于解决一个特定的问题(除了粒度),通过确保它的成员以一种最自然和最适合公司业务领域的方式,几乎总是直接根据无处不在的语言中定义的术语,很容易同时坚持关注点的分离。

  • 凝聚力:当一个团体或社会的成员团结在一起时

  • (形容词):团结一致,有效地一起工作

与此同时,这些类最终需要实际“绑定”在一起,以产生手头问题的期望解决方案,其方式是可测试的、可重用的,并且根据其功能和核心业务逻辑,在关键系统组件之间划分出清晰的边界。

清单 2-2 提供了一个不那么内聚的类的例子。请记住,该示例没有验证或前置/后置条件,不应在生产环境中使用,而只是一个严格的学习练习。

<?php
namespace App\Registration;

use App\User;
class RegisterUser
{

   protected $name;
   protected $username;
   protected $isAdmin = false;
   protected $isPremierMember = false;
   public function setName($name) {
       $this->name = $name;
   }
   public function setUsername($username) {
       $this->username = $username;
   }
   public function makeAdmin() {
       $this->isAdmin = true;
   }
   public function getUserAttributes() {
       return [
           'name' => ucfirst($this->name),
           'username' => $this->username,
           'isAdmin' => $this->isAdmin == false ? "NO" : "YES",
           'isPremierMember' => $this->isPremierMember == false ?
                  "NO" : "YES"
       ];
   }
   public function registerUser() {
   $user = new User($this->name, $this->username,
$this->isAdmin, $this->isPremierMember);
       return $user;
   }
}

Listing 2-2Example of Low Cohesion Within a Class

有几件事你可能马上就能发现,那就是类的设计和缺乏语义的问题。例如,通常一个执行高级用户注册过程的类很可能会检查重复的用户名和用户名是否有效的特定标准。现在,我只想关注例子中成员函数内部引用其他成员函数的情况,以及它是如何毫无理由地在类中传递自己的成员的。更重要的是,为了使用它,很多工作都留给了客户端。清单 2-3 是清单 2-2 中类的示例客户端。

<?php

use App\Registration\RegisterUser;

//... collect user attributes--most likely via a form request
$params = [`name` => `Jesse`, `username` => `debdubstep`];

$userRegister = new RegisterUser();
$userRegister->setName($params[`name`]);
$userRegister->setUsername($params[`username`]);
$user = $userRegister->registerUser();
//now we have an unsaved $user...

Listing 2-3Example of Client Code for Low-Cohesive Class RegisterUser

总的来说,这是一个糟糕的设计。将特定的方法或例程留给客户端代码会产生它们可能不会被调用的可能性。与此同时,在registerUser()方法中,它用给定的参数创建用户,但不把它保存到数据库中。如果开发人员期望返回的用户是成功保存到数据库的记录,这可能会是一个问题。当然,由于缺乏错误消息或异常,我们无法从应用中了解情况,直到我们开始注意到用户没有被持久保存到数据库(或者人们开始抱怨,这是一种更有可能的情况)。

在持久化用户对象之前,没有检查来确保所有需要的数据和方法都已经放在类中。当然,我们可以编写一个逻辑,在我们创建和持久化新用户之前,检查是否已经在对象上设置了本地参数$username$name。然而,这样做的问题是,我们必须考虑如何通知用户必须首先设置这些属性(手动)。我们会抛出一个异常吗?

我们可以,但我不认为停止软件执行的完全错误是值得的,因为对象上的参数还没有设置,但也许你在一个上下文中它会。例如,在 web 表单上,当您注册某个网站的新帐户时,您会立即得到反馈(通常通过 JavaScript ),告知您错过了某个字段或者该字段的格式无效。一旦你点击了提交,这个请求就会被应用处理,然后(通常)你就会被带到某种类型的个人资料页面。您不知道或不想知道创建您的用户帐户的内部进程;你想知道的是,你有权访问该网站和登录。

以类似的方式,实际注册新用户的代码应该封装到一个单独的类中(可能在应用层中作为一个利用域层对象的服务),并且应该作为在系统中创建新用户的单一入口点。客户端代码应该根据需要初始化这个类的一个对象来执行请求,然后“点击提交”很明显,没有可以点击的提交按钮,但是像run()execute()handle()这样的方法基本上是这样操作的,启动一个作业或初始化某种类型的注册服务,该服务将处理创建新用户的各个方面,并包含确保处理任务的先决条件得到满足的逻辑。

此外,使用技术分析会使我们得出这样的结论:注册用户的概念应该分解成不同的关注点,确保保持域模型的完整性。例如,我们可以引入一个存储库,减轻类处理User对象持久化的负担,使Registration类更干净、更轻便。我们还可以实现一个UserFactory类,封装注册新用户所涉及的知识和逻辑。

虽然这个例子确实是这样,但我们已经超越了自己。让我们把注意力集中在我们在前面几段中描述的这个类的更明显的问题上。

清单 2-4 展示了一个更好的(但不是最好的)解决方案。

<?php
namespace App\Registration;

use App\User;

class RegisterUser
{
   protected $safeAttributes;

   protected $user;
   public function __construct(array $params) {
       $attributes = User::fillableFromArray($params);
       $this->safeAttributes = $attributes;
       $this->user = new User();
   }
   public function makeAdmin() {
       $this->user->admin = true;
   }
   public function makePremiumMember() {
       $this->user->premiumMember = true;
   }
   public function getUser() {
        return $this->user;
   }
   public function registerUser() {
       $this->user->fill($this->safeAttributes);
       $this->user->save();
   }
}

Listing 2-4A Refactored Version of RegisterUser with Higher Cohesion

在这个例子中,我们去掉了所有单独的 setter 方法,取而代之的是通过构造函数(构造函数注入),这是一种依赖注入技术。正如您所看到的,这些字段是通过使用一种叫做fillableFromArray()的简便的雄辩方法来验证的。这个方法是一个功能强大的函数,它接受一个数组并返回另一个数组,数组中的值是模型中存在的有效属性名。当我说“有效”时,我的意思是属性被User类认为是“可填充的”还请注意,我省略了任何检查,以确保参数不为空(或者传入的$params变量不是空数组——为了简洁起见)。

我在这里留下了两个make()方法,以防客户需要能够在用户被保存之前设置它们,但是我也可以很容易地在构造函数中包含这两个额外的参数,并在方法签名中设置它们的默认值。使用所谓的流畅界面是有益的(稍后会有更多介绍)。当该说的都说了,该做的都做了,getUser()方法将简单地返回我们在调用registerUser()后建立的用户。从客户端的角度来看,使用该类的简单性使客户端保持简单,只需调用更少的方法来实现某种结果。

什么变了?

属性的内部引用或内部参数的“传递”过于频繁会使类变得混乱,难以测试、维护和在其他地方重用。努力实现关注点的清晰分离是有好处的。在前面的RegisterUser类中,它和User对象之间有很高的内聚性。User对象的属性由RegisterUser类指定,但是它的持久化是在User类内部处理的(User是一个有说服力的模型)。在应用的这一小部分中存在着一定程度的内聚性,这样各个部分就可以一起工作以达到最终的结果,并且这一部分遵循了对象中的关注点的清晰分离,从而促进了它的实现。

这里起作用的另一个因素是我们为客户提供的灵活性,特别是通过方法getUser()makeAdmin()makePremiumMember()。这些方法提供了额外的“特别”选项,由客户端负责调用。同样,我们也可以将它们实现为额外的构造函数参数,然后设置为默认值null。在调用了registerUser()方法之后,我们可以使用getUser来检索现在持久化的新的User对象。

这个设计远非完美。事实上,我们将以 Laravel 作业和队列的形式讨论一种更好的方法来处理这种功能,但是作为一个介绍性的例子,这种方法效果很好。

低内聚力

方法之间没有任何链接,类中的过程之间也没有任何共享资源的利用。它们都分别作用于单个成员变量,这里没有什么有趣的事情。尽管这个类能够并且可能实现关注点的分离,但是它很可能有一个错误的东西被分离出来。我们在领域层中的概念边界之间划的线对我们的对象相互作用(或不相互作用)以产生预期结果的方式有深远的影响。图 2-1 显示了一个低内聚类的例子。

img/488324_1_En_2_Chapter/488324_1_En_2_Fig1_HTML.jpg

图 2-1

低内聚的类

被认为具有高度内聚性的代码通常作为单独的片段存在,它们都有助于并致力于单一的定义的目标或任务。图 2-1 中的SomeClass是一个类,它的结构取决于它的属性。每个函数都是独立的,并且每个函数都只使用在类中定义的相应参数——如果该结构是基于特定的业务问题并保证了它的使用,那么这是很好的。

一般来说,这样的类不可重用,也不容易扩展。也许我不应该说“容易”,但扩展真的“毫无意义”,因为子类将使用这种结构,并且通常只作为独立对象有用。这个类中没有内聚性,因为其中的每个方法只与类中定义的单个参数相关。这些方法类似于某种形式的(通常是不需要的)getter 和 setter 方法。对于获取和设置数据(我们将在本书的后面深入探讨),雄辩有一个更复杂的解决方案,即通过它的赋值函数(setter)和属性(setter),也通过它的魔法方法。

必须保持他们分开(关注,这是...)

不,我指的不是后代的老派歌曲,我说的是应用或软件中存在的问题。当实际“分离关注点”时,产生的配置应该是软件试图解决的特定业务问题的指示。你不应该“仅仅因为”把事情分成不同的类和组件如果不同类或类内参数之间的分离似乎不太适合领域的整体调整,那么它很可能不需要以这种方式分离,或者根本不需要分离。相比之下,如果在两个独立的对象中发现的两个对象或属性似乎过于紧密地联系在一起而不能分开,它们可能只是属于一起。这很难做到正确,当然这完全取决于你所从事的特定领域。

Note

在整本书中,你会经常在我关于代码结构的讨论中看到名称空间文件夹结构目录这些词。只要知道我的意思是他们可以互换使用。

通常,这种分离是以严格的名称空间和目录结构的形式出现的,其中类是按照它们是什么而不是它们做什么来分离的。大多数(如果不是全部的话)现代框架都是这种情况,这也是 Laravel 的默认名称空间结构和目录的基本设置方式。控制器都在一个App\Http\Controller文件夹中。可能会在它们下面设置子文件夹,但它们仍然都源于主App\名称空间下的这个单一目录,由它们是什么分开(图 2-2 )。

img/488324_1_En_2_Chapter/488324_1_En_2_Fig2_HTML.png

图 2-2

正在运行的 Laravel 应用的目录结构示例

这种类型的结构关系到每件作品是什么;存储库放在App\Repositories名称空间中,控制器放在App\Http\Controllers名称空间中。在一段时间内,这一切看起来都很好,但随着时间的推移,系统中会增加额外的功能和业务需求(实际上是无限的),很难在单个名称空间(甚至是模块化的名称空间)中管理所有控制器。在这样的结构中,领域的真正含义并不清楚——它并不特定于领域。

在我们开始使用 Laravel 之前,我将为您提供一种方法,将您的应用恰当地组织到单独的筒仓中。每个竖井将有其自己的名称空间,并且在每个竖井内将只有使该竖井起作用所需的组件和代码。顺便说一下,我使用筒仓模块基本上是指同一件事。一旦您根据每个特定模块对领域的重要性开始分解应用的单一整体结构,您将发现管理和测试变得更加容易,因为所有的模块都是相互隔离的——然而所有的模块都以同步和谐的方式一起工作以产生最终的应用。

分离的先决条件

为了实现正确的结构,需要做一些必要的工作(“正确”是指对您的业务来说“正确”的任何东西)。要记住的主要事情是意识到可能的概念边界可能位于业务的核心中。如果我们努力保持模型驱动的设计,那么下一步将这些界限转化为实际的代码结构将会更容易,这取决于在这种无处不在的语言中描述的事物的质量和正确性。

有些事情将很难分开,如果,不管出于什么原因,在两个组件之间画一个适当的分离看起来太复杂或太困难,解决方案可能是使边界线比有界上下文的边界线更细。这可以通过使用每个组件的模块实现来完成,并且仔细地(并且显式地)将两者之间所需的通信抽象到单独的类中,或者作为服务或作业职责的一部分(然后可以在 RESTful 接口后面设置,并在多个有界上下文中使用)。模块提供了一种不太正式(但仍然显式)的机制,用于分离出您的业务(以及您的应用)所依赖的各种组件。

模块还提供了另一种在应用中对相似概念进行分组的方法,并且可以(并且应该)代表底层业务实际上是如何构建的。我们应该能够看到一个应用的模块图,并对其中发生的事情以及模块如何组合在一起形成一个完整的工作应用有一个很好的总体感觉。

当我们讨论有界上下文时,我们将研究一些不同的构建模块的方法,但是现在这里有一个模块结构的例子,在这个例子中我们可以清楚地理解它做什么以及它的操作中涉及的各种组件。

重构遗留系统

如果在试图将特定的概念或类集合移动到它们各自的模块或上下文中之后,它们似乎仍然不能自然地与应用的其余部分相适应,或者违反了在通用语言中定义的定义和关系,那么考虑它们实际上不需要被分离的可能性。这可能是由于缺乏管理所述概念和过程的业务规则的完整知识,或者是这种无处不在的语言的内部工作中的一个错误。我经常认为这是由于热情(或错误的信息/误解等)的结果。)离岸团队最终为一个应用留下了一个大烂泥球,而碰巧的是,他们成功地进行了数以千计的小黑客攻击,以获得一个 MVP 来同步并产生某种形式的系统,而无需做更多的工作。

如果这描述了你,并且你已经继承了一堆蹩脚的、离岸的代码,并且必须一行一行地通过遗留应用来弄清楚他们试图做什么(因为肯定没有文档可以帮助你),我同情你!在我的职业生涯中,我去过那里很多次。大多数时候(有充分的理由),从零开始重写遗留应用不是一个选项。

向前推进的最佳方式是尽可能使用最佳实践和标准来实现任何新功能,然后(可能在计划好的冲刺中)创建所谓的反腐败层。这一层基本上是应用特定部分的一个分割区域;通常这一部分与没有它的整个工作或过程的实现一样大。一旦您围绕遗留代码和新的反腐败层建立了参数(是的,这是一种奇怪的反腐败层的说法,但 ACL 已经被采用),在其中构建一个完整的功能,只与旧代码建立强制性的连接和集成点,并使用最佳实践在新代码中构建任何东西。最终,遗留系统基本上吸收了这个反腐败层,并将其视为根本不存在——就好像它只是遗留代码导入或引用的另一组类和对象。

之后,休息一下,喝杯啤酒,因为这是一个伟大的成就,在复杂的系统上很难做到!一旦你重新开始工作,重复这个过程,只是用一个不同的现有功能,甚至是一个新功能。构建另一个反腐败层,作为或多或少的“插入式”替换或添加,遗留应用可以固有地交谈、发出请求和委托(这通常最好通过 API、事件系统或队列来完成),以便处理用户的请求。在这种情况下,用户和遗留应用应该无法区分这两者。我们已经巧妙地创建了一个对外部几乎不可见的遗留应用边界,使用最佳实践封装了一个动态特性,并附带了单元测试和文档。现在你已经很好地掌握了这个过程,继续更新遗留应用的一小部分,并在它们各自的反腐败层中实现微小的功能,直到不再有遗留代码。

这是使用最佳实践和高质量代码构建任何新东西的可靠方法,同时使遗留应用越来越好,直到它从代码库中完全消失。当然,事情远不止如此,但你已经明白了。在我们深入这些概念之前,我想澄清一下我所说的“领域层”或“模型层”是什么意思这个定义是理解本书其余概念和思想的关键。

分层(洋葱)架构

那么,什么是领域层呢?很高兴你问了!从 DDD 的意义上来说,领域(或模型)层是软件架构背后的核心驱动力,它涉及的对象和过程代表了现实世界中的业务问题和需求,这些问题和需求是设计用来解决的(见图 2-3 )。领域层的成员通常是核心对象或业务流程,它们被表示为整体业务和应用的一等公民。

img/488324_1_En_2_Chapter/488324_1_En_2_Fig3_HTML.jpg

图 2-3

应用的层。有时被称为“洋葱”

本质上,模型层封装了业务实际做的上下文中存在的所有内容。它是应用的核心。所有其他代码、策略、过程和基础设施的存在只是为了方便在模型层中找到对象和组件。

因此,关键是能够将这些代表实际业务实体的业务规则和类“分割”到它们自己的层中。这是模型层。通过关注核心业务规则和基础领域知识,我们将构建一个强大而丰富的领域模型,它是业务本身的真实表现。各种模块将多个模型组成子部分——所有这些都是为了实现(理想的)关注点分离,其中每个模块对应于一个目标,或者整个业务的模型级关注点的一部分。

这可能表现为企业不同部门之间的明确区分,其中每个模块封装了整个部门的关注点,或者可能存在于更细粒度的级别上,而模块代表各种资源或特定于产品的关注点。

其他层在哪里?

在图 2-3 中,注意领域层是如何处于应用的核心的。该图描述了操作的“流程”(对应于每个特定层的客户端),以及每个层通常如何指向它下面的层,使得每个层只能依赖(并调用)它下面的层。

基本上,领域层,应用的核心,作为它自己的实体存在,因此应该依赖(或依赖)该层中的其他成员或组件。其上的一层,即基础设施层,用于促进这些域层对象的读写(因此必须直接“了解”它们)。最后一个外圈是应用层。应用通过基础设施层访问域层,但是在 Laravel 中,可以直接访问域模型。

应用层

应用层是其客户端通常在请求之外的层(通过一些输入源,如 HTTP 点击、API 调用或 SOAP 实现的操作)。这一层的客户通常是应用的用户。当需要处理业务规则或业务对象时,应用层不会直接处理它们。

相反,它处理基础设施层,基础设施层最了解如何处理域层(或模型层)中对象的各种细节。下面是对每一层中存在的组件的一些说明:

  • 应用服务存在于应用层,但是没有任何领域逻辑。

  • 它们控制持久性事务和安全性。

  • 他们可以向其他系统发送基于事件的通知。

  • 他们撰写给用户的电子邮件。

  • 他们可以订阅从域层发出的事件。

  • 这一层中的应用服务是域模型的直接客户。

基础设施层

基础设施层包含以下内容:

  • 存放在基础设施层中为域中定义的接口实现的存储库

  • 如果与依赖注入结合使用,可以“颠倒”过来,给予基础结构层在它下面的层中实现任何接口的能力

  • 工厂可以存在于这一层(或领域层)

  • 持久性机制

模型层(领域层)

模型层包含特定于业务的规则和实体,构建它们的公司使用这些规则和实体来运行。请记住,代码就是业务。照此处理。

  • 域服务处理与域相关的特定过程和任务。

  • 这一层发布领域事件,供应用的其余部分作出反应。

  • 这种模式的客户端通常存在于应用层。

  • 有界的上下文和业务组件的分离是关键。

注意一些关于模型层的事情是很重要的。

  • 模型层是任何应用中最重要的一层。

  • 应用中存在的其他层只是为了支持模型层中的对象。

  • 在 DDD 分布式应用中,模型层的不断迭代、重组、澄清和细化是非常重要的。

  • 模型层(领域层)是领域驱动设计的焦点。

  • 对模型的改进通常是在开发人员和某类领域专家之间对业务如何运作和功能进行长时间讨论之后进行的。

服务层

服务层(或只是层)也有明确的区别,几乎每个应用都有一些概念。这些服务层几乎与它们的“架构层”对等物直接相关。例如,应用服务位于应用层中,但是针对域层中的对象执行操作(通常通过基础设施层)。类似地,跨越多个领域对象并直接涉及系统核心业务规则的业务流程将存在于领域层中,并由基础设施层中的对象或组件来操作。

应用服务程序

这些层与本章前面介绍的应用层非常相似,但是它们更细粒度,因为它们代表服务本身,主要关注业务的特定操作或流程,而不是应用的整个“层”。示例服务可以是诸如SendNotificationEmailsSubscribeToNewsfeedExportAccountingStatement等。

首先是应用服务。这一层中的服务基本上充当外部客户机(请求)和域逻辑之间的中介。这一层可以(并且通常确实)作为组件而不是服务存在——控制器就是一个例子。他们当然是 MVC 架构中的“C”。控制器分派特定的服务和作业,并控制任何侦听器、订户或其他接收器来响应这些作业和服务调用。请求和响应也包含在这一层中。

应用有时使用控制器作为保存和运行域层过程的手段。通过将它们与控制器内联编码,它们极大地降低了代码的可重用性,同时也使得任何只针对领域逻辑的测试变得更加困难,因为这个过程不是孤立的。单元测试的关键是能够测试彼此独立的事物(以“单元”为单位)。

没有肥胖控制者

拥有肥胖的控制者不是好的做法;应该通过显式定义服务或其他领域层类和组件来避免这种情况,这些服务或其他领域层类和组件封装了此类流程的特定领域知识,然后从控制器内部调用这些知识,控制器传递流程并返回响应。除非过程非常简单,否则单独的类会显得多余,核心业务逻辑必须分离到应用的域层中,或者作为模型、域服务、事件监听器,或者任何最适合其目的的东西。根据通用语言命名领域层中的对象。

外部客户端(请求)是应用层中对象的直接客户端。然后,应用层根据该请求采取行动,通常包括与领域层中的对象进行交互。

在哪里划线?

这就是本书希望帮助回答的问题。我们需要一种在 web 环境中开发 Web 应用和系统级架构的方法。这就是 DDD 要发挥作用的地方。

我想简单地谈一下,因为它对于培养一种强大的、定义良好的通用语言至关重要——不是可选的,而是至关重要的,这种语言将用于对应用的核心结构进行建模,并确定其模块的粒度。这只有在对公司当前持有的“公认”标准进行一些挖掘和探索之后才会发生(通常是通过深入到他们如何工作和他们实际做什么的更细微之处)。很多时候,您最终发现的或多或少是假定的业务知识或模糊的边界,即,部门或核心流程的不清晰分离,经常看起来跨越几个服务或有多个主要关注点。

Eric Evan 的书 DDD:在软件的核心解决复杂性中有很大一部分是关于系统各个方面的概念边界以及在哪里画它们。通常,这些边界在应用中的正确位置并不总是像我们希望的那样清晰和直接。最有可能的是,领域模型中组件的分割需要开发人员和领域专家在一个被称为知识收集信息收集的过程中进行许多彻底的讨论。

最后,这些上下文边界的产品划分出不同的功能组,这些功能组在业务本身的上下文中正常而自然地存在。当基于无处不在的语言中的概念和定义时,这变得更加健壮。不太正式的分离机制包括模块、域、子域或一般子域。从更广泛的意义上来说,作为分离机制的有界上下文也有助于业务范围内无处不在的语言的细化——特别是当设置为使用 REST 接口实现的发布语言时(我们将在后面讨论)。

现在,当您将 DDD 提供的策略和工具结合起来,并将它们与领先的 Laravel 框架相结合时,您就能够在一个可重复且简化的过程中满足几乎任何架构或业务领域规范的需求。该框架提供了各种组件的实现,这些组件是通过领域驱动设计的战略层发现的(它突出了围绕您正在构建的领域中各种解耦结构的概念边界的绘制)。我们将使用各种技术来更好地促进这一过程——比如依靠领域专家对业务概念的全面定义,以及以业务软件的形式实现所获得的知识,这些软件可以在企业范围内通过网络提供服务。这将为我们在应用的代码端实现我们需要的东西提供一条清晰的路径,并且基本上利用 Laravel 和 PHP 来实现我们已经获得的概念和过程。

我们从 DDL/DDD 得到了什么

最终,我们得到了一个功能完整的软件,它易于部署并建立在最佳实践之上,具有各种结构和名称空间,实际上与业务级的实体和结构相关联。该软件是完全可扩展的,因为我们明智地决定在云上推出我们的应用和基础设施,并且由于我们定义的关注点的明确分离,它们可以很容易地修改。我们也有一个清晰的模块路线图,需要构建这些模块来正确处理它所促进的核心级业务流程。

随着我们从最初的领域关注点分解进展到反映业务的真实需求和要求的细化的领域模型,我们开始看到事情以一种有机的方式排列起来。概念和理想似乎毫不费力地相互联系起来,并且在实现新功能或对现有功能进行更改时,开销开始大大减少,因为应用的底层结构是以与其领域模型紧密耦合(并表示)的方式制作的,这使得所有这些更容易理解、扩展和测试。在开发过程中,我们可以使用特定的单元测试作为快速简单的概念证明。我们将在后面的章节中详细讨论这一点。

结论

在这一章中,我们看了拉弗尔是如何构建的,并与 DDD 建议的指导原则进行了对比,以指出一些明显的差异。我们讨论了整体式应用是如何构建的,以及它们为什么不如一种更为分离的方法(比如微服务)理想。增加这种复杂程度的决定必须直接来自于与领域有关的决策,应该根据应用构建于其上的标准业务流程中存在的相同逻辑对领域进行分离。

我们还讨论了一些雄辩模型的例子,并回顾了一些有助于向数据库添加新用户的代码,以及为什么把以领域为中心的代码放在模型的范围内比放在控制器或服务的范围内更好。只有当实体或值对象的使用不适合您试图解决的问题的上下文时,才应该创建服务。

现在我们知道了 Laravel 在使用 DDD 作为指导构建架构方面的缺点,我们可以回顾一下我们将需要的各种更改,以使默认 Laravel 安装的结构实际可行。我希望我也激起了您对 Laravel 提供的其他组件的好奇,激发了您学习更多内容的欲望,这将在接下来的章节中介绍。

三、领域驱动是什么?

在前一章中,我给了你一些我们在本章和其他章节中继续探索的东西,关于 DDD 建立的思想和概念。在这个过程中,我希望能够激起你在 Laravel 学习领域驱动设计的兴趣。我们将继续探索 DDD 提出的策略和方法,然后讨论如何使用 Laravel 实现这些想法。

在这一章中,我们将更多地关注 DDD 提供的核心定义和策略,并且给出一些领域驱动设计的高层次概述以及它的内容是如何分解的。我的目的是给你足够的知识,用你在现实世界中构建一个基于 DDD 的项目所需的基本核心实践和过程武装你。我将为您提供在任何领域构建软件时可能会遇到的示例问题,并且我将为您提供各种解决方案,这些解决方案将突出一些不同的组件,并说明它们定义的各种概念和上下文。

软件的本质

软件很少会自我实现。相反,它是达到目的的一种手段...做某事的方法。很少为了编码而写代码。您很可能不会遇到太多最终产品是代码本身的情况,除非您正在为一本书编写代码,或者在一篇博客文章中强调一段源代码,甚至编写一个开源库。通常情况下,你的软件的目的是做一些与构建软件无关的事情。

例如,开展电子邮件活动的营销公司希望跟踪统计有多少用户打开了该电子邮件,有多少用户点击了该电子邮件中的广告,有多少用户实际购买了该产品,等等。这些报告具有很大的商业价值,有助于推动商业决策,并验证营销活动作为一个整体是否符合某些跟踪目标。首席执行官和其他高管经常使用这些数据来做出影响公司健康和重点的重要决策。

对于利益相关者和公司的 CEO 来说,他们想看到的只是他们的报告。他们很可能不太关心这些报告是如何构建的,而是这些报告对他们来说是可见的,并且是准确的。对他们和其他企业来说,代码只是收集数据、计算数字和生成报告的一种手段。他们通常不关心诸如单元测试、安全性(不幸的是)、架构、编码风格、编程语言或系统的任何其他技术方面...他们只想要漂亮的报告。

开发和业务目标之间的分歧如此之大,以至于大多数时候,在各个部门之间有一种单独的语言来描述相同的业务概念。在极端的情况下,围绕软件系统的部门本身变得如此分离,以至于他们每个人都有一个语言版本,开始是一个单一的定义,但发展到包括几个上下文有偏见的定义。每一个定义都对应于它们被构思的部门的中心焦点或方面,以这种方式来创建伪概念,这意味着几乎与它们的祖先一样的东西,但是有足够的细微差别,以至于该概念在整个组织中不成立。

业务主管/利益相关者关心的是诸如报告准确性、用户界面设计、网站可用性、性能和总体用户体验,因为这是用户所看到的。在任何与技术相关的业务或软件努力的这两个基本方面之间,肯定会有,而且很可能永远会有一些摩擦:业务的需求和软件的质量。当质量因为时间的原因而得到补偿时,我在第一章中讨论的事情开始变成现实,你最终会以不开心的开发人员、比通常更长的周转时间、意想不到的火灾和一大堆其他糟糕的事情而告终,最终会变成一团烂泥。

金三角

图 3-1 代表了一个流行的图表,用来表达软件开发工作的三个主要方面之间的权衡。

img/488324_1_En_3_Chapter/488324_1_En_3_Fig1_HTML.jpg

图 3-1

软件开发的金三角

在图 3-1 中,对于一个给定的软件项目,你只能得到三个理想结果中的两个:开发的速度(时间)、源代码的质量(质量)和成本(费用)。我们可以根据表 3-1 来确定结果的可能性。

表 3-1

金三角组合的可能结果

|

选择#1

|

选择#2

|

结果

|

真实世界的例子

|
| --- | --- | --- | --- |
| 质量 | 时间 | 快速构建的高质量产品不会很便宜。 | 快速应用开发 |
| 时间 | 费用 | 快速廉价的产品质量会很差。 | 创业公司的旗舰应用 |
| 费用 | 质量 | 高质量的廉价产品需要时间来制造。 | 开源软件 |
| 质量 | 质量 | 一个超高质量的产品是要及时和昂贵的。 | 企业软件 |
| 时间 | 时间 | 一个超快的产品将会很贵而且缺乏质量。 | 任何微软软件 |
| 费用 | 费用 | 一个超级便宜的产品将会是低质量和耗时的。 | 外包海外开发 |

什么和如何

软件既是具体化的东西,也是抽象的东西。软件开发工作的结果是决定项目是否成功的最明显的方式。在 web 开发的情况下,web 应用中的自定义页面——包括其计算、外观、交互和条件——可以通过使用浏览器与应用进行交互来判断其正确性和完整性。用户体验几乎总是高层和主管们优先考虑的事情,一个页面的表现是证明花了十几个小时或更多时间完成的工作确实完成了的手段。因此,在这方面有一个具体化的概念:当你能在屏幕上看到一个模态时,为一个网页开发一个模态的结果就应该完成了,它的行为正是需求所规定的。这个区域可以被认为是。

软件在外部的行为方式(例如,从最终用户的角度看,它的外观和功能)并不一定表明在内部塑造它的代码的质量或结构。构成用户体验的代码可能非常糟糕,写得也很糟糕(例如,如果标准的安全措施因为时间的原因而被抛弃,或者验证的实践没有在 HTML 表单中实现),但是外部可能会也可能不会实际反映这一事实,因为代码本身与作为代码结果而存在的元素、框架和功能有概念上的差异。应用的这个内部区域可以被认为是 how。

如何在系统中测试通常是最困难的事情。这是因为直到我们开始遇到错误和奇怪的问题时,才知道代码写得有多好,这些问题似乎已经解决了很多次,但仍然出现在应用中。how 包括所有实际的代码,并且是关于代码本身质量问题的答案的真实来源,这并不总是像它在浏览器中显示的那样。你不能简单地看一张表格就断定 CSRF 保护措施没有被用来阻止 XSS 袭击。您必须检查文档并查看表单的代码,寻找某种类型的 CSRF 令牌或编码字符串。

当“如何做”和“做什么”之间相差太远时,问题就会出现。虽然产品经理和执行官通常更关心应用是什么,但是开发团队和工程人员关心的是(或者应该是)如何。例如,如果我们构建的模型看起来和工作起来都很棒,产品经理就会签字同意,对他们来说,这是一个很好的开始。

现在让我们说,在开发过程中,我们在代码中的某个地方留下了一个巨大的安全持有,它只在特定的条件下出现。以这段代码为例:

<?php

if ($request->parameter == 3) {
     //to remove!
     exec(‘cat .env’);
}

假设在我们构建的模型上有一个表单或输入,在这个表单上有一个带有下拉框的输入框,用于选择和设置表单元素。在开发这个模型时,我们进行了前面的检查,因为我们有兴趣看到(无论出于什么原因)在.env文件上发出cat命令的结果(.env文件是保存您的应用的所有私有配置值的文件,不是存储库的一部分),但前提是名为parameter的表单变量等于 3。是的,我完全知道这个例子中的代码有多糟糕,但它只是一个例子;不要看太多。

这是一个严重的安全缺陷,它基本上显示了整个.env文件,其中包含我们所有的数据库密码和我们的应用运行所需的其他秘密数据。我们绝不会向任何用户透露这些信息,所以这是一个相当大的问题。这类似于导致切尔诺贝利核事故的问题。原本可以防止这种灾难的安全措施和实践被关闭,以“测试”系统。当问题真正出现时,它以巨大爆炸的形式出现,从反应堆核心释放出大量有毒的放射性物质。显然,我们的问题是少损失惨重,但你明白了这一点。

产品经理很容易忽视这一点,他认为一切“看起来”都没问题,因为事实就是如此。然而,在幕后,是一个根深蒂固的问题,这个问题开始时只是为了开发而进行的简单测试,在他们推出最终的软件后并没有得到适当的解决。防止上述情况的责任完全落在开发团队的肩上。在这种情况下,应用的“是什么”(它是包含某种按预期工作的形式的模型)被验证为处于良好的状态,但是“如何”从来没有被另一个开发人员适当地审查过,这使我们对标准和实践产生了疑问,这些标准和实践要么在开始时没有实施,要么在该软件的开发过程中被完全忽略。

Note

作为对这个安全问题的额外思考,考虑现实情况:代码缺乏所有形式的代码审查;如果没有,错误就会被发现。还能做些什么来防止这个缺陷呢?单元测试。如果这个特性有足够的单元测试来覆盖模态逻辑的这一部分(当然应该),那么在合并到主分支之前进行的一个简单的自动化测试就会以一种相当明显的方式指出这个问题(在这种情况下,显示.env文件的内容)!

如何构建软件来实现它实际做什么是我称为开发的抽象方面的领域。我成为程序员的原因是因为我对解决问题的方式感兴趣。我总是着迷于必须做出的决定,以及如何使用编程语言的工具和功能作为解决复杂问题的手段。这门科学有一定的艺术。不管最终的范例和最佳实践的实现如何,您都应该注意编写足够多的单元测试,以便应用中的每一部分代码都可以被相应的测试覆盖。这些测试作为自动化套件的一部分运行,该套件在每次将拉请求合并到主分支时被触发。一个好的 CI/CD 系统对于软件开发团队来说是无价的。

开发软件需要思维、学习和编码的平衡。代码并不是解决问题的唯一手段,很多问题可以(也应该)通过解决,而不需要*诉诸代码。

Note

代码是最后的手段。如果除了多写代码还有其他可行的方案就不要多写代码了。每一行代码都是必须维护和重构的另一行,每当我们编写代码时,我们都冒着将新的错误引入系统的风险。

DDD 概念的分解

DDD 涵盖的主题错综复杂,深入浅出,红皮书和蓝皮书都有详细讨论。我的目标是让你知道我们将在本书中关注什么,并让你熟悉我们需要覆盖的 DDD 的不同领域,以便使用 DDL 构建一个有用的系统。

请注意,图 3-2 中的图表省略了被称为蒸馏的整个 DDD 部分。这一部分中的概念包括重构策略和如何实现某些模式来逐步淘汰遗留软件,以及其他方法来细化您的领域模型,并获得一个更准确的模型,该模型用无处不在的语言中的适当术语来表达业务。

img/488324_1_En_3_Chapter/488324_1_En_3_Fig2_HTML.jpg

图 3-2

DDL 概念图

在图 3-2 中,我们有一个三角形的三个点(这三个点与金三角没有关系,只是为了让你知道),它们之间的相互作用用线条描述,并简要描述了它们之间的关系。然而,请注意,它们似乎都与无处不在的语言泡沫密切相关。那是因为它们都应该来源于或者直接使用无处不在的语言里面的物品。

战略设计将包括完成领域驱动设计的策略,以及包括最佳实践和高级架构讨论。它还包含着手设计您的领域模型的过程和方法(比如让一位领域专家在附近澄清任何缺点或复杂的逻辑区域,这些对于进入公司的新员工来说可能不完全理解)。我们在 DDD 的战略设计部分获得的知识将被用于制作和提炼一种正确的通用语言,这种语言将被整个公司使用,以传达实际商业模式中存在的各种含义。这些知识也将用于驱动我们应用的开发(在我们的例子中,使用 Laravel 框架来构建这些概念和过程)。

战术设计是我们需要的所有技术结构和考虑因素,以便建立我们通过位于 DDD 的战略设计支柱中的工具和方法实现的所有不同的对象、策略和结构。我们将使用它们作为一种手段,将无处不在的语言中的对象和作用于这些对象的业务流程转换成允许它们在 web 应用中运行的实际代码。战术设计经常被表达为 UML 图或者其他 OOP 类型的图和图像,以帮助说明创建领域驱动设计中涉及的复杂概念...DDL 也是如此。在我们的 Laravel 应用中,战术设计中的组件将直接对应于它帮助建模的代码。最后,我们的 Laravel 应用将是我们使用战术设计构建的这些对象和流程的实现。

Note

战术设计流程只能在初始知识发现/粗略建模阶段完成后才能完成。你只能从战术设计的角度开始对领域建模(例如,使用实际的代码结构,比如仓库、工厂、实体、集合等等。)在您至少对您试图用代码重新创建的真实世界系统有了部分了解之后。这本书的内容将以这种方式工作。

在图 3-1 的第三个气泡中,我们有我们的 Laravel 应用。这个应用将是许多关于系统核心功能和需求的讨论、会议和计划会议的结果。它将是与我们正在构建的组件或系统有任何联系的几个(如果不是全部)部门协作的产物。

领域建模的过程

人们常说,领域建模与其说是一门科学,不如说是一门艺术。这当然源于软件独特的流体状态。它的不断扩展的结构和可延展的趋势提供了无限的可能性,这只有在存在法律、限制、过程和约束的情况下才是有用的,这些法律、限制、过程和约束有助于为工作空间提供某种上下文,并将概念的混合划分到封装的逻辑边界的容器中,这对于程序员来说更容易理解。

这带来了另一个好的观点:从本质上讲,软件开发的复杂性只对我们这些阅读和操作它的人来说是不同的,而对实际执行和运行它的机器来说不是。我们在领域设计中建立的区别、封装、清晰度、符号、规范和描述,以及代码中相应的实现,都是专门为人类消费而制定的。另一方面,从理论上讲,机器更愿意被源源不断地输入 1 和 0,这样它们就可以做它们被创造出来要做的低级处理。他们不关心函数名或类名称空间结构,它们是为了我们开发人员在以后修改代码时阅读和洞察而存在的。

在开发应用时记住这一点是有好处的,因为我们需要善待下一个看代码的人。当我们忘记对doSomethingOdd()进行额外的调用时,它也可以作为一个很好的提醒,直到我们看到这样一个冗长的注释:

/** !!!!IMPORTANT!!!! DO NOT, NOT CALL THIS FUNCTION! ITS MANDATORY*/
$this->value = doSomethingOdd() . doSomethingOdd();

信不信由你,这是我参与的一个真实项目的样本!它完全没有意义,并且给下一个必须阅读它的人增加了许多困惑。

变量、类和对象的名称可以让开发人员了解代码中发生了什么,根据复杂性,可以写在每一行来描述每一行的作用。它们或多或少是解释性的注释,有助于用简单的英语描述过程的细节。虽然注释非常有用,但是过度使用注释会破坏代码,降低可读性和开发速度。但评论绝不是无用的。不管你喜不喜欢,随着新功能的产生和新问题的增加,即使是最小的应用也总是面临着复杂性增加的风险。最后,找到注释和它们所描述的源代码之间的平衡是很重要的。

不要做什么

构建应用时最糟糕的事情就是错误地管理复杂性——无论是高估还是低估。当复杂性在项目中具体化时,试图避免复杂性的做法被证明与过度设计软件来处理预期的复杂性一样糟糕。对于后者,往往会发生的是你浪费了宝贵的开发时间来创建一些可能是或可能不是未来实际关注的东西,而你本可以将这些时间花在真正的应用需求上。除此之外,当第一次对一个“可能”的系统建模时所做的假设和前提条件很可能已经随着对底层业务规则(域模型)的细化和更新而改变了。只有在扔掉了大量系统无法使用的构建代码之后,您才意识到您可能必须完全重写系统的这一部分,因为您构建的解决方案不再满足新的问题范围。

避免复杂性是一种在开发过程中会出现的现象,并且通常开发人员没有意识到这一点,至少一开始没有。最初,开发人员从良好的意图出发,坚持最佳实践和良好的架构结构,但往往有些犹豫,并违背这些最佳实践(在某些情况下通过引入反模式)。但是,如果解决方案实际上有效,它就会被发货。如果团队定期参与代码审查,并不断努力完善和改进核心业务模型,这不是一个问题,因为他们最终会重构代码,并实现比第一个版本更好、更有知识的解决方案,而第一个版本只是第一个工作的东西。

然而,当重构不是开发软件的标准实践时,这些问题将会表现为一大团泥巴,上面充斥着陈旧过时的评论,通常不会让读者更好地了解实际发生了什么。在一些极端的情况下,逻辑可能会达到这样一个程度,即在同一个文件、类或模型中包含许多不同的关注点的整体结构中,被强加了太多的复杂性。如果没有大量的重构工作,增加复杂性甚至不再可能,重构工作通常会持续几周或几个月。

从本质上讲,这种可怕的情况会变成现实有两个主要原因第一个原因是没有遵循最佳实践。第二种情况是,在没有足够的领域知识和经验的情况下构建解决方案,而这些知识和经验对于正确地对业务领域(以及那些模型上的操作)建模是必要的。

如果从事软件工作的人没有正确理解软件背后的商业概念,并且没有投入精力为这些概念建立概念基础,他们会不顾一切地尝试任何可能有助于完成他们需要做的事情的解决方案。当这种情况发生时,代码会被随机地插入到各处,以测试各种东西或转储出只在特定条件或特定状态下实际存在的变量值(由于 PHP 和 web 开发的无状态特性)。测试变得如此复杂和繁琐,以至于测试本身在某些时候几乎被完全忽略,在系统代码库的测试覆盖范围中留下了一个大的缺口,并慢慢地恶化了 CI/CD 管道以及您的 DevOps 工程师花费数周时间准备的所有酷东西,因为它们完全无用。

如果发生这种情况,它几乎总是指向缺乏清晰的边界,通常是由于在领域级别上违反了关注点分离或者使用了在通用语言中没有正确定义的不正确的组件。

当“调试代码”的概念等同于依靠旧的“dump and die”或print_r($var);die;命令来戳代码并试图弄清楚在代码执行中的这个特定位置发生了什么时,您知道您正在处理这些地狱般的应用中的一个,因为环境是以这样一种方式制造的,任何错误输出都几乎不存在,更不用说缺乏任何正确调试代码的手段。

处理复杂性

领域架构完全是关于在复杂性出现时处理它,并且首先将简单的实例放在适当的位置,随着获得关于业务领域的额外洞察力,对那些实例的关注和积极的改进。DDD 有各种技术和步骤来帮助我们开始为我们的应用建立一个坚实的基础。

DDD 流

我发现在我的日常 web 开发项目中实现 DDD 的最好方法是遵循这个一般过程。请记住,这些步骤与 DDD 的技术方面无关(我们将在后面讨论)。

  1. 深入了解你正在工作的领域。让自己沉浸其中,这样你就能对该领域的问题、解决方案和架构了如指掌。例如,如果你在医疗计费行业工作,你可能已经记住了 CPT 代码,并且可以背诵医疗病人资格地址。如果你从事金融行业,你可能知道贷款是如何构成的,以及如何在资产的生命周期内分配成本折旧。

  2. 与领域专家一起创造一种无处不在的语言。由于你很可能而不是在你工作的领域里(特别是如果你刚刚开始你的 web 开发生涯),你将需要依靠专家来构建一套通用的、公司范围的术语和定义,以及关于它们之间可能存在的关系的准确描述和文档。

  3. 根据您在第 1 步和第 2 步中获得的派生学习和对系统的部分理解,按照企业解决方案空间描述的以及您最初理解的那样,构建系统的粗略设计。即使最初的设计有缺失的部分,或者最初没有清楚地指出分隔各个部分的界限,也要获得某种基于核心领域模型的概念化设计,并用无处不在的语言进行描述。

  4. 经常重构和细化模型的设计,很快失败。因为我们重视敏捷开发实践,并且热衷于迭代开发,所以我们根据对业务领域的理解不断更新我们的模型。领域模型的设计应该总是反映领域本身最新获得和接受的知识。我们通过“快速失败”来做到这一点如果我们在开发某个系统时尽可能快地失败,我们被迫同样快地修复这些失败,每一个都丰富领域模型并精炼其结构(即使作为如何而不是做某事的一种方式)。

  5. 每当获得对领域模型的新见解时,重复步骤 4,并创建一系列可重复的步骤,这些步骤可用于持续地将领域变更部署到您的生产环境中(CI/CD 管道)。有一个好的持续部署管道可以让我们在对领域的概念理解和实现它的代码之间保持一定程度的连续性。

每隔一段时间,往往会发生的是,在领域模型本身(一个过程,一个假定属性的重新定义映射,或者甚至是一个无处不在的语言中的项目的错误标识)中,将会有一个重要的被忽略的步骤的实现,它将大到足以强制进行重大的更新或重构来适应。它最有可能是对一些不相关的事情发生的认识,例如,一个事件被激发的效果,它使侦听器分散在多个有界的上下文中。喊出“重构我”的变更是首先要考虑的,因为它们通常掩盖了真正的深层含义,并且可能没有在当前实现的领域中得到充分的体现。

对领域的每一次细化都是创建一个领域的又一小步,这个领域代表了业务的核心价值,反映了它的沉着,并概念化了它存在的本质。这意味着应用的每一次更新或更改都很重要,因为每一次更新或更改都代表了对业务模型的一些洞察或理解。

深入你工作的领域

如果有的话,你很可能对你刚被雇佣参与的领域知之甚少。你很可能熟悉软件开发(至少我希望如此,如果你真的得到了一份开发工作的话),并且掌握了各种模式、框架、工具和习惯用法来帮助管理逐渐进入应用范围的复杂性。尽管如此,很有可能你对你工作的核心业务领域知之甚少。

我鼓励你最大程度地拥抱你公司的领域。你的目标应该是最终成为领域专家,并获得业务领域模型的深入知识。那么你将被认为是你工作的公司的资产,并且处于一个很好的位置来为该公司设计领域驱动的设计。

如果不是这样,你不知道你在做什么,你将很难为任何项目实现领域驱动的设计。毕竟,当您对领域模型本身知之甚少甚至一无所知的时候,您怎么可能构建只存在于促进它们所代表的相应业务对象的实现细节呢?这个问题的答案和几乎任何可以想象到的领域的问题的答案是一样的:你受你的领域专家的支配,因为他们是收集关于领域和业务的足够信息的关键,以实际上制作一个代表它的领域模型。与领域专家的交流肯定会有帮助,但是根据领域的复杂性,他们很容易被一个新的开发人员淹没,并且他们可能需要时间来真正掌握信息。

与领域专家一起创造一种无处不在的语言

领域专家最终会成为开发人员最好的朋友,因为领域专家掌握着业务各个方面的真相,通常是在他们的头脑中。作为开发人员,我们的工作是将这些信息从他们的头脑中提取出来,放入软件中,这样知识就可以与应用的所有其他方面共享。从领域专家那里获取信息有一定的方法,我在下面的列表中总结了这些方法:

  • 尽量不要用复杂的技术细节和开发人员行话来淹没他们。相反,尝试匹配专家用来描述业务的术语和语言,模仿他们用来描述系统的业务方面的上下文和组件。

  • 做好确保澄清任何不清楚或难以理解的概念或商业模式的元素。

  • 使用领域专家自己的行话作为形成通用语言的基础,并使通用语言中的结果条目存在于一个狭窄的范围内,以便其定义不会被误认为是业务方言中的另一个术语。

  • 致力于建立清晰的领域模型术语定义,以及公司范围内公认的一组细节,这些细节属于使其独一无二的定义。

构建一个粗略的、幼稚的领域设计

一旦您对领域的高级方面有了某种感觉,就用它来创建一个领域模型的框架草图。这将最初是一个天真的尝试,描述业务如何在技术层面上运作,但不要太关心它对于早期版本来说有多小。请记住,领域驱动的设计实际上永远不会“完成”一旦我们为 DDD 准备好了所有需要的东西,我们就可以使用与持续集成/持续部署(CI/CD)相关的技术来确保我们的领域模型不断地被精炼、更新,并且随着团队获得的新的洞察力在应用本身中被描绘出来(通过它在代码中的实现)而变得更好。

Note

最初,您的领域模型可以由写在纸上的各种文档、绘图和注释构成。它可以由你需要的任何东西组成,以便成功地描述业务领域,用模型来满足它。

经常重构(经常失败)

伟大的托马斯·爱迪生在经过大约 10,000 次的尝试,终于成功地发明了一个能用的灯泡之后,说了这样一句话:

“我没有失败。我刚刚发现了一万种行不通的方法。生活中的许多失败都是因为人们在放弃时没有意识到自己离成功有多近。”

—爱迪生

在成功的过程中,失败是不可避免的。重要的是,你从这些失败中吸取教训,并利用这些知识帮助找到可行的解决方案。因此,“快速失败”的概念不是为了成功,而是为了失败。在一次又一次的失败中,你对如何而不是做某事有了一个很好的想法,这会让你离成功更近一步,因为如果有的话,这是一个如何不去做的例子。因此,显而易见的是,失败得越快,你就会越快意识到实际可行的解决方案。

这是敏捷开发中的一个重要概念,因为敏捷的核心意味着不断地给予和接受反馈。信息和知识的流动应该在各个方向自由流动,从开发人员到领域专家,再到高层管理人员。快速失败给你获得洞察力的能力,即使是以不能快速工作的形式,这样知识可以被封装在领域模型和所有使用它的人之中。在这些领域有帮助的行业工具是 SaaS,如 Atlassian 的 Asana,再加上吉拉的任务管理和构建定制文档和操作方法库的 Confluence。其他好的产品有用于设计的 Invision,用于存储库管理的 GitHub 和 Bitbucket,用于数据库导航/设计的 MySQL Workbench 或 Sequal Pro,以及用于团队沟通和协作的 Slack。

技术方面

到目前为止,我一直将我们的讨论集中在 DDD 的战略部分。软件系统的“技术方面”可以被认为是如何并且基本上是一组结构,供您用作工具(模板)以代码形式实现您的领域模型。这些工具包括我们将讨论的各种概念,它们都基于可靠的最佳实践。

在本书中,我将为您提供一些工具,这些工具将有助于使您的代码与业务需求保持一致,从而使软件成为业务本身的翻版。我将向您展示如何依靠您无处不在的语言来构建组件,这些组件将以领域驱动的方式驱动您的应用和软件开发。您将学会关注业务本身的各个方面,并使用这些知识来构建您的初始领域模型的框架。然后,您将使用 DDD 提供的技术设计和实践,在代码中实际表达这些领域概念,并定义对它们的任何操作、使用它们的服务或它们所代表的数据库模式。

框架为你提供了控制器、路径、响应和其他各种各样的东西,这些东西是用来作为工具来促进应用中对象的交互和使用的。这些工具通常采用某种形式的对象设计模式。这些是存在于对象/类级别的模式,它们是通用的。在领域驱动设计中,有许多组件是使用一个或多个这些设计模式来建模的。例如,DDD 提出了以下架构组件来帮助您的领域层中的对象的交互和使用:

  • 实体

  • 工厂

  • 仓库

  • 价值对象

  • 总计

  • 领域事件

  • 域服务

  • 模块

这些都是重要的概念,目的是通过使用最佳实践,促进领域模型,分离系统的不同关注点,并建立系统中对象存在和交互方式的一些标准。它们足够通用,不会将您工作的实际领域或上下文作为任何特定类型或方式。它们旨在为典型的 web 开发或软件应用项目中出现的常见业务问题提供与领域无关的解决方案。

就领域驱动的 Laravel 而言,它们对我们意味着什么,它们基本上是 DDL 的思想和实现的关键,因此被给予了充分的讨论空间(每个都用了整整一章来介绍和讨论更好的细节)。我们将研究这些并在 Laravel 中创建合理的实现,使用我们将在下一章创建的定制框架。

现在,我将使用下面的项目给你一点我到目前为止所讨论的内容的背景。

示例项目 3-1:仓库管理

这个示例项目将介绍一个虚构的仓库管理应用,我们负责从头开始开发。这个例子将作为一种指南来帮助捕捉软件的意图,获得解决软件问题所需的背景信息的知识,并形成一种无处不在的语言,然后我们将把它转换成一种粗略的架构,使其在现实世界中工作。我们将主要关注我们正在设计的系统的信息收集和高级形态。实际上,直到本书的后面,我们才开始编写使用 Laravel 框架的代码。我在示例中确实使用了 PHP 代码来演示各种概念,但是这些想法在本质上更加通用。在这一点上不需要以前有过 Laravel 的经验(但绝不会伤害!).

在知识收集阶段,我们希望以一种非平凡的、非技术性的方式关注模型。通过最初用简单的英语和流程图/图表对项目建模,我们通过延迟来摆脱设计系统需要运行的所有类、接口和对象图的开销。这允许我们用每个人(不仅仅是开发人员)都能理解的术语对领域建模。然后,我们将为所有这些常见的业务术语创建一个目录;这就是所谓的无处不在的语言。

这个业务术语和概念的目录很重要,因为它是我们将用来对系统的所有相关方面进行建模的文字基础,并且同样地,将被组织的所有成员用来传达业务思想和描述内部操作。这种语言是这个领域的核心。

Note

请理解,只有通过与领域专家和组织的其他成员进行无数次的讨论、辩论、研究和规划会议,才能完美地定义术语表中的项目。我在现实世界中看到的一种方法是让每个部门的一个人与领域专家和开发人员在同一个房间里。神奇的事情会发生。对于这个例子,我省略了这个方面。

需求概述

我们将建立一种仓库管理系统。假设我们有一个客户,他希望实现日常流程的自动化,以提高效率、发运更多产品并赚更多钱。他们已经有了一个模拟的、古老的系统,结合了印刷纸和手工流程。尽管订单和运输由电子商务平台管理,但该系统缺乏任何跟踪功能或库存管理,因此是手工完成的。该应用的所谓报告功能包括一个彩色的 Excel 电子表格,其中有各种公式和求和,只有在对公式计算中涉及的后续单元格进行正确输入后,才能进行计算。更重要的是,他们最近扩建了一个更大的仓库,这给工人完成新订单带来了新的挑战。该仓库(现在大得多)缺乏适当的产品位置管理解决方案,因此履行流程大大减慢,仅完成一个大订单就需要 10 到 45 分钟。

基本上,我们的工作是创建一个新的系统,为典型仓库的标准问题提供一个全面的解决方案,同时通过设计一个新的位置方案来解决正确的产品位置管理问题,使工人更容易找到产品并完成订单。我们将讨论如下内容:

  • 订单管理

    • 库存更新/审计

    • 订单跟踪生命周期/工作流程

  • 存货管理

    • 项目跟踪
      • 仓库中的位置

      • 数量

        • 保留量:已经被预定的产品数量

        • :产品在库存中准备出售的数量

        ** 期望:订购(延期交货)的产品数量*

    ** 项目生命周期流程/工作流*
    ** 完成

    • 挑选和打包

    • 运输系统

    • 订单履行流程/工作流程*

*这些是我们将在这个例子中关注的中心概念。

订单管理

仓库通过接收订单并向客户发货来赚钱。成功处理和运送订单所需的时间对利润有着重大影响,因此仓库需要准确、可重复和快速的订单工作流,以便实现收入目标。

目前,该仓库有一个接受订单和付款的电子商务平台,因此我们不必关心订单的下达和计费,这使我们可以专注于管理和跟踪订单的履行及其生命周期,从跟踪处理订单时必须进行的库存调整,到挑选和包装订单上的行项目并将其运送给客户。这个过程就是订单工作流程

需要跟踪订单工作流环境中的状态。订单是在其整个生命周期中经历不同阶段的实体。图 3-3 显示了订单所经历的生命周期的基本流程(基于我们对订购流程正常流程的初始假设)。

img/488324_1_En_3_Chapter/488324_1_En_3_Fig3_HTML.jpg

图 3-3

订单工作流程/生命周期

基本上,我们有一个相当简单的订单流。你可以很容易地称之为订单生命周期(事件建模为图中的线条)或订单流程,因为它实际上并没有那么重要。重要的是在整个文档和通用语言(UL)中保持名称一致。领域专家在通用业务相关行话中使用的术语和软件模型在 UL 中约定的概念方面必须保持一致。

这通常等同于就使用哪些术语来指代哪些业务对象达成共识,并在整个组织中使用一致同意的措辞和定义。我们不能在我们的 UL 中有重复的定义,主要是因为它增加了不必要的定义副本,这只是系统中可能被错误、误传或误解的一个额外的东西。更简单和更好的方法是清楚地定义一个术语的定义,它不会以相同的形式在另一个术语的定义中共享。

Note

我们最终将在软件中创建的东西应该是生活在业务领域中的所有真实的、标准的对象和过程的镜像。有时,对一个领域进行建模会暴露出需要解决的逻辑或流程中的错误,这样相同的错误就不会出现在代码中。

回到图 3-3 ,订单在在线电子商务系统上发出,仓库收到订单,并在使用自动银行和支付网关插件确认订单付款后,立即将其标记为订单待处理阶段。在此阶段,订单不被视为有资格完成(等待挑选&包装后的剩余步骤)。他们有资格进入提货&包装步骤的方式是,仓库工人核实订单上的产品实际上有库存,并且每件商品的数量可以说明。

一旦发生这种情况,订单就进入提货和包装阶段,在这里它现在位于“提货清单”上,并排队等待在仓库中实际找到商品(也称为提货)并将其放入箱子中运输给客户(包装)。发生这种情况后,可能会有一些额外的验证步骤,以确保箱子中的物品是正确的,并且数量正确,例如在将箱子打包发给客户之前直接进行人工二次检查。除此之外,包裹已经准备好运送,由快递公司(UPS,FedEx,无论什么)提供的日常取件服务运走。此时,订单最终进入订单完成阶段。这似乎是一个非常简单和半完整的模型(至少对于这个例子来说)。

Note

到目前为止,该项目的范围被有意地缩小了,这样我就可以演示如何着手在软件中建模一个领域(或者至少从哪里开始),而不用占用宝贵的页面空间来描述仓库管理系统的每个复杂方面。

存货管理

现在我们已经勾勒出了订单工作流的基本轮廓,我们可以开始研究系统中的更多细节了。在现实世界中,特性和需求可能随时出现,并且在规模、范围或重要性方面完全不可预测。出于我们的目的,我们将不得不在系统中构建更多的复杂性,这将是促进和管理仓库库存的附加组件的形式(因为这是仓库所做的),当然。

这意味着我们必须考虑仓库中的所有物品,以便我们能够正确管理实际数量,并获得问题的答案,帮助做出企业赖以成功的决策。库存管理的一个很好的例子是知道何时订购额外的库存,因为(很明显)如果我们没有任何库存,我们就无法完成订单。负责在典型仓库中购买卡车货物的团队使用该库存管理上下文提供的数据作为一种手段,来确定哪些货物需要在供应商订单上,以及应该订购多少。(这可以通过查看库存管理组件提供的延期交货数量清单以及涵盖过去 30 天的销售报告来确定,该清单显示了产品的近期需求。)

事实上,我们的电子商务团队很可能依赖于某种关于仓库在任何给定时间的库存产品数量的报告或通知,以便他们可以在网站上正确列出产品,并将没有库存的产品标记为“售完”(尽管这将是一些自动化的好地方)。这是一个微小的细节,在实际项目中,需要额外的澄清,因为我们有相同的数据驱动多种需求,这些需求在任何给定的时间都可能与事实的来源不同步,在这种情况下,事实的来源是仓库中实际产品的数量。我们暂时忽略这个细节,稍后再来讨论。

就像前面描述的订单管理组件有一个工作流(或状态机)一样,库存组件也有一个工作流,只适用于一个项目或产品的上下文。这里需要注意的是,库存管理组件之间必须有一些协作。

知识收集是软件建模时最重要的事情之一。它最终塑造了对象的内部结构(关于它们的命名空间),并且是创建可用 UL 的第一步。在这个例子中,我们要稍微作弊一下,假装我们已经和领域专家讨论了足够多的内容来构建一个粗略的 UL。

完成

因此,我们已经介绍了系统的订单管理方面,它将处理订单的生命周期,并跟踪从收到订单到发货的整个过程。我们还有系统的库存管理方面,它保存仓库内所有产品的记录和计数,以及它们的位置(这是一个重要的细节)。

履行将被“吸收”到订单工作流中,该工作流基本上包含最初被列为“履行”的所有项目履行流程只是一个通过其工作流程的活动订单。该工作流程的步骤如下:

  1. 在订单上找到每个产品。

  2. 抓取或挑选指定数量的物品(受影响的物品数量将列在“预期数量”下)。

  3. 将上述物品放入包装盒中,密封包装,并通过快递寄出。

到目前为止,这似乎相当简单。有一点你可能还没有考虑到,那就是第一步的介入。在现实生活中,我在许多仓库中看到货架上产品的适当“地图”或位置参考。通常,这些知识包含在领域专家的头脑中,由于缺乏适当的文档,使得教导新员工变得极其困难。这里有一个痛点。

在我们的例子中,假设这个痛点存在,仓库工人花了太多的时间来找到产品,以便他们可以运送订单。这种情况的根本原因是缺乏一个合适的编码系统或产品地图,以帮助工人快速找到和选择货架上的商品。因此,我们需要能够充分跟踪一个项目在仓库中的位置。

从哪里开始

这看起来像是一下子要处理很多,但是在现实世界中,像这样的需求通常要复杂和详细得多,并且在从头开始创建一个新系统的情况下,它们似乎都是一下子向你扑来的。有时这可能会让人不知所措,我们经常太急于将这种知识从我们的大脑转移到现实世界。在这种情况下,将需求中列出的每一个关注点作为一个单独的组来开始系统的架构解决方案可能很有诱惑力(图 3-4 )。

img/488324_1_En_3_Chapter/488324_1_En_3_Fig4_HTML.jpg

图 3-4

可能的架构分组

但是请想一想:我们已经将履行从系统的订单管理关注点中分离出来,它们确实是独立的需求,但是它们应该是同一上下文的一部分。在这种情况下,我们要做的是跟踪订单从进入仓库到离开(即发货)的生命周期。生命周期有多个阶段,从技术上讲,整个生命周期(工作流)可以被认为是订单的履行。

更好地理解堆积如山的需求和领域知识并开始区分实际需要做什么的一个方法是找到问题空间和解决方案空间在哪里。

问题空间

问题空间可以被认为是公司想要解决的所有事情。它包含需求,并被分解为域和子域。在我们的例子中,问题空间包含我们的仓库管理软件的三个主要组成部分:订单管理、库存管理和履行。基本上,图 3-5 中的图表可以很好地代表问题空间,或被认为是完整的项目所需的主要目标/验收标准,只要它在一个子域中有两个相似的要求。从现在开始,我们将把问题空间中的这三个项目称为模型的子域。一旦我们做了这样的调整,我们就会明白,事实上,有两个子域可以从需求列表中派生出来。

img/488324_1_En_3_Chapter/488324_1_En_3_Fig5_HTML.jpg

图 3-5

问题空间

解空间

如果你还没猜到的话,解空间是解决我们之前列出的子域中的问题的结果。为了知道从哪里开始根据项目的需求建模您的解决方案,您可以为每个子域建模一个解决方案组(也称为有界上下文)。在我们的例子中,我们得出结论,这个项目存在两个有界的上下文,每个子域一个(图 3-6 )。

img/488324_1_En_3_Chapter/488324_1_En_3_Fig6_HTML.jpg

图 3-6

通过子域定义的解空间

Note

一般的经验法则是每个有界上下文有一个子域,但是如果项目不是非常复杂,那么可能不需要所有的子域(每个问题空间都需要一个有界上下文来解决)。

在以这种方式分割项目时,我们可以清楚地了解到需要完成哪些工作(尽管没有任何特定子域优先级的指示)。我们还可以得到一个领域模型结构的基本概念,我们需要创建这个模型来以逻辑和领域驱动的方式充分地解决问题。这意味着努力实现可重用性,并通过细化和重构创建一个丰富、健壮的领域模型,最终建立一个领域的可靠表示,建模为软件组件。

创造一种无处不在的语言

既然我们已经定义了我们的项目需要解决的问题,以及解决这些问题需要哪些解决方案,我们就可以开始为软件创建一种无处不在的语言,这种语言来源于领域知识。

请注意(正如我所提到的),真正的 UL 只有在与领域专家进行了无数次的讨论之后,并且在领域中实现或产生了额外的洞察力时,通过重构业务术语的定义才能实现。

我们需要马上定义两个重要的术语。

  • Order :一个收到的表单,包含客户要求的产品及其相应数量的列表

  • 库存:所有每件产品在仓库的位置和可供销售的数量的日志

添加完这些之后,我们还应该添加一些额外的术语。

  • 产品:仓库中销售的单品;包含项目的价格、名称和简短描述等数据

  • 订单行:属于一个订单的单行,由一个产品和该产品的数量组成

Note

请记住,这些不是我们的域对象,也绝对不是我们将在代码中使用的类,尽管其中一些很可能是,比如一个OrderProduct

UL 可以说是软件设计中最重要的事情。它是我们正在构建的整个平台的基础,将在整个公司范围内以定义如何与业务相关的方式为人所知。我们需要随时更新我们的模型,即使这意味着删除或更改 UL 中的术语,以更好地反映它们所源自的业务概念。

实现这一点的方法是不断地将 UL 和域模型作为一个整体进行重构,以最好地表示业务操作中涉及的业务对象和流程。即使在每次冲刺和每次拉取请求中,我们也在不断地重构应用,使其变得更好。我们希望我们的 UL 也是这样,这就是为什么我会在我们进行的过程中对它进行修正和修改。

定义要构建的内容

因此,我们有一个粗略的 UL,它似乎反映了用于描述问题空间和解决方案的总体措辞。它们在 UL(可以是文档、电子表格、纯文本文件或纸张)中清楚地表达出来,并且用领域专家和开发人员已经同意的术语来定义。那全是肉汁!

下一步是了解为完成应用所需的特性而发生(和需要发生)的操作。我们可以利用这个例子开始时给出的需求,用它们来描述我们希望应用做什么,以及它应该如何做。最好用简单的英语组成完整的句子,用无处不在的语言来描述每个需求。我们从用户的角度出发来形成句子,以捕捉他们的需求(这些被称为用户故事)。

  • 作为一名运营助理,我希望能够快速导航到产品在仓库中的位置,并能够快速识别货架上的产品,以便准备好订单进行发货。

  • 作为一名仓库管理员,我希望能够在仓库中找到一件商品,并以一种非侵入性的方式调整它的库存,这样我就可以让每一个正在增长的仓库都有存货。

  • 作为销售代表,我希望能够快速识别产品及其库存水平,以便与客户协调订单。

  • 作为运营经理,我需要实时跟踪产品数量,包括我们当前拥有的数量、从供应商处订购的数量以及正在订购的数量,以便我可以解决业务问题。

浏览这些用户故事,我们可以对我们将要创建的功能有一个很好的想法,并且可以开始按照领域驱动设计中使用的一些可靠的模式来组合一些实际的架构。

图 3-7 显示了它们在我们之前的有界环境图上的位置。

img/488324_1_En_3_Chapter/488324_1_En_3_Fig7_HTML.jpg

图 3-7

从用户故事到有界上下文的映射

工作流程/生命周期

似乎我们需要定义两个生命周期工作流:一个用于订单上下文,另一个用于库存上下文。我们需要的是一种通过这些转换来跟踪订单状态的方法,以便在状态(阶段)之间创建一个“流”。我们要做的在计算机科学中被称为工作流网,它是 petri 网的衍生物。

工作流由状态以及作用于一个状态到达下一个状态的事件(即转换)组成。转换对我们很重要,因为它们包含了对于底层实体所处的特定状态,哪些转换是有效的规范。它定义了状态和转换如何交互,并且是我们将用来控制我们的工作单工作流的。

订单工作流程

在本章的开始,我给了你一个工作流程,Order对象将从头到尾经历这个流程,它非常适合我们的状态和转换模型,因为它们已经被分解了。在订单工作流的情况下,我们可以假设以下状态:

  1. 已下订单

  2. 订单待定

  3. 挑选和打包

  4. 订单已发货

  5. 订单完成

对于每个状态,我们需要指定可以对其执行的事件或转换,以前进到下一个状态。这里有一个例子:

  • 从阶段 1 进入阶段 2 需要支付费用

  • 从阶段 2 进入阶段 3 意味着产品已经确认有货。

  • 从第 3 阶段进入第 4 阶段需要将订单从货架上取下,并装入客户会收到的箱子中。

  • 从阶段 4 转到阶段 5 将表明订单已经通过快递卡车成功发送给客户。

我们也将使用这种基本格式来创建产品的生命周期。

产品工作流程

简而言之,产品的生命周期始于产品在接收码头被接收,产品在接收时被登录到系统,该产品的正确数量条目将被更新(但仅当该产品已经在系统中时)。如果是,我们可以假设已经有一个与之相关的量。让我们假设这些数量的计数是正确的(即,预期计数的数量是准确的),这意味着我们将从系统帐簿中的预期数量计数中减去收到的数量,然后将该数量添加到该产品的可用数量计数中。

如果产品对系统来说是新的,我们必须在系统中为该产品创建新的记录,然后将收到的数量添加到该产品的可用数量计数中。我们可以马上看到,在系统中创建一个新产品并不像接收一个已有数量那样简单。我们很可能需要建立一个系统,在那里接收方可以输入新产品的规格,并可以将其添加到库存日志中,以供将来跟踪。现在,我们将忽略这种复杂性,只假设进入仓库的每个产品都有一个现有记录。

之后,产品被放在一辆手推车上,仓库保管员用它将产品放在仓库中各自的位置。它将在货架上以“可用”的状态等待,直到收到一个订单,该订单中有该产品的一行。此时,在操作人员从货架上拿起产品后,数量会进行调整,并将产品的最终状态标记为“产品已售出”图 3-8 提供了产品工作流程图。

img/488324_1_En_3_Chapter/488324_1_En_3_Fig8_HTML.jpg

图 3-8

产品工作流程/生命周期

Note

这将是一个很好的机会来停下来,将我们在过去几页中建立的产品工作流和订单工作流这两个新概念,以及这两个概念的简短而准确的定义,添加到这种无处不在的语言中。

完成

这列在软件要求中;我们之前决定“履行”只是一个订单通过其典型的工作流程。我们在订单约束的上下文中捕获了对履行的关注,因此我们可以从 UL 和系统中消除履行的概念,而只使用“订单工作流”来表示相同的东西。系统中的对象越少,我们在以后调试时需要跟踪的东西就越少。

提货和打包阶段也是如此,它属于订单上下文,甚至存在于订单工作流本身。因此,我们可以将挑选和打包的行为放在域工作流中,但是围绕挑选和打包阶段的数据值得探究。图 3-9 显示了我们的上下文和它们支持的特性的图表。

img/488324_1_En_3_Chapter/488324_1_En_3_Fig9_HTML.jpg

图 3-9

有界上下文

产品位置

如本例前面所述,仓库工作人员面临的一个棘手问题是,他们要花很长时间才能找到仓库中数千个货架中的一个。作为一个小练习,我们将提供一个合理的解决方案,它将有助于确定任何给定产品的确切位置,以便挑选和包装。这是一个不使用代码提供问题解决方案的例子。记住,代码是最后的手段。

为了给这个例子添加一些上下文,让我们假设这个仓库运输鞋子。该仓库占地 10,000 平方英尺,其中约 8,000 平方英尺用于存储产品。我们需要一种方法来快速识别仓库中的一只鞋。这意味着我们需要某种类型的编码方案,附加到每个产品上,指定它在仓库中的位置。

Note

在本文中,我交替使用术语来表示一双鞋(显然,我们不需要一双单独的鞋...除了可能扔向政客之类的)。

首先,让我们更深入地思考这个问题。鞋子都需要在合理的时间内定位,所以我们显然必须组织鞋子来分解这个组织问题。基本上,我们需要将仓库和订单处理这个大问题分解成更小、更易管理的工作单元。一种方法是通过观察领域模型中概念自然分离的方式。我首先想到的是性别。鞋子的性别是分割库存最明显的方式。看似简单直接的分离,就是直到我们发现某些鞋品牌只做女鞋,有些严格意义上的男鞋,有些两者都有。

Note

目前,我们不打算考虑鞋子的模型,这在像这样的真实世界项目中通常是必须考虑的。我们只是暂时推迟它。

我们仍然可以让性别分离起作用,这将会给我们类似图 3-10 的东西。

img/488324_1_En_3_Chapter/488324_1_En_3_Fig10_HTML.jpg

图 3-10

按性别和品牌分隔仓库

唯一需要注意的是,单一品牌可能会有重复的位置,这对于企业来说可能是问题,也可能不是问题,完全取决于其特定需求。无论是哪种情况,我们都将依靠与领域专家的对话来确定这个仓库方案对于公司的日常运营是否可行。

领域专家告诉您,我们所描述的布局将会起作用,因为性别是附加到任何鞋子上的,所以我们可以使用它根据鞋子的性别来划分仓库。只要有一个足够的系统来跟踪和标记鞋子在仓库中的位置,重复的品牌就不会有太大的问题,这取决于品牌和性别。

身份

因此,我们需要拿出一个易于阅读的仓库示意图,将每只鞋分开,这样就不会有两只鞋有相同的标识符。我们可以假设需要有一个与每只鞋相关联的内部密钥形式的身份,该内部密钥可以在系统的其余部分中作为一种识别手段使用。让我们给鞋子一个内部生成的身份

在思考了这个生成的身份之后,你就有了智慧的储备。这里我们需要的是一个条形码系统!这将允许我们通过以条形码的形式给每只鞋分配一个身份来跟踪每只鞋。为了确保唯一性,我们决定使用 UUID 方案,该方案可以使用第三方包轻松转换为条形码(要查看可用选项,请在谷歌上搜索 Laravel 条形码生成器)。

所以,条形码系统是正确的选择。然而,有一个问题。UUID 方案根本不是人类可读的,因为它的标准格式是 16 进制的 32 个十六进制字符(这里有一个例子:123e 4567-e89b-12 D3-a456-426655440000)。那并不完全是从舌尖上滚出来的。如果(不管出于什么原因)我们需要在没有条形码扫描仪的情况下找到一只给定的鞋的位置,这也会引起一个问题。通常,我们会发明某种 UUIDs 到货架上位置的内部映射,这只能通过扫描条形码并从内部数据库中检索产品位置来推断。简而言之,它将使鞋子的位置直接与条形码扫描仪相连。对于某些人来说,这可能是一个好的解决方案,但是对于这个例子,我们可以做得更好。

我们想要创造一种标准的方式来识别仓库中的鞋子,这种方式既可以扫描条形码,也可以被人类读取。我们放弃了 UUID 的实现,而是决定创建自己的实现。该方案必须有性别、品牌和尺码信息,以清楚地表明这是什么鞋。然后我们可以将这个代码与仓库货架联系起来,每个货架代表一组特定的鞋子。

我们很快就拼凑出了编码系统的草图。

(gender: m/f) - (brand: first 4 letters) - (size: 2 digit integer)

这似乎涵盖了几乎所有的要求。它内置了三个数据点,没有条形码扫描仪的人也能轻松阅读和理解。唯一的问题是,相同性别和尺码的同一品牌的鞋子会有完全相同的身份证号码。这是不好的,因为我们希望唯一地标识每只鞋。

考虑到这一点,我们对编码方案进行了修改。

(m/f) - (brand: 1st 4) - (size: 2 digits) - (Unix timestamp)

我想我们可能有一个可行的解决方案。通过将 Unix 时间戳添加到 ID 号的末尾,我们保持了一种独特的方式,除了保持人的可读性之外,还可以将一只鞋与大型仓库中的所有其他鞋区分开来。

图 3-11 显示了一双鞋的分解识别号。(我意识到最后一句话在任何其他背景下都毫无意义,但在这里却行得通。)

img/488324_1_En_3_Chapter/488324_1_En_3_Fig11_HTML.jpg

图 3-11

每只鞋的唯一标识符的分解

Unix 时间戳保证了每双鞋都需要我们的唯一标识号,至少虚拟地说是,因为可能有两个相同的新产品在同一时刻被添加到系统中,从而产生相同的唯一标识符。然而,这种情况在现实生活中发生的几率很小,不足以承担这个风险。这很可能永远不会发生。

*既然我们对进入系统(即接收)的每双鞋都有了一个标准身份,我们需要将这个字符串转换成一个条形码,以便于扫描和检索。

生成条形码

因为条形码只是用来区分每件产品,所以所有的工作基本上都已经完成了,因为我们已经为鞋子创建了编码方案,可以唯一地识别它们。现在,开始将该计划付诸实施所需的全部工作就是从该身份信息生成条形码。一种方法是使用 QR 码(或“快速响应”码)。

二维码是通用的,受到很好的支持,可以保存我们想要的任何数据,包括一双鞋上的识别字符串!在这一点上,我们不会过多地讨论实现细节,但是如果您有兴趣,您可以在 Google 上搜索 Laravel QR 条形码生成器,以找到一个支持库,该库将自动处理从身份到条形码图像的转换。这是像这样的系统在生产中工作的最终要求,主要是因为条形码需要在识别时打印出来,并实际放在鞋盒上,所有的数据点都包含在识别号中。图 3-12 显示了翻译标识符 M-VANS-105-156756631 产生的二维码。

img/488324_1_En_3_Chapter/488324_1_En_3_Fig12_HTML.jpg

图 3-12

从字符串 M-VANS-105-1567566317 生成的条形码图像

这是一个非常方便的解决方案,基本上可以应用于任何需要跟踪或说明的事情。最棒的是,你可以将任何数据编码到这个条形码中!无论你用什么来生成图像,都可以在你用条形码应用扫描后被解密。(您可以将手机用于开发目的,但在现实世界中,出于性能和人体工程学的原因,您可能需要购买一些专业的 3D 条形码扫描仪。)在我们的例子中,我们可以使用前面创建的身份编码方案来生成一个有效的、人类可以理解的、唯一的字符串,该字符串可以被扫描仪转换和反转。

消除隔阂

到目前为止,我们有以下内容:

  • 每只鞋的唯一标识符

  • 一个格式良好的字符串,表示人可识别的标识符,其中包含各种信息(性别、大小和品牌)。

  • 一种创新且经过深思熟虑的读取条形码的方法

我们一直忽略了什么?实施细则!

到目前为止,还不清楚条形码系统在实践中如何工作。具体来说,我们在实际运行系统后端的软件和它们所基于的业务规则之间存在持续的脱节。我们需要详细说明货架组织的细节,以及我们如何使用条形码系统来管理这些细节。

搁置系统

我们决定的搁置方案需要支持人类可读性,就像我们的产品标识符一样。为了让系统自主工作,我们需要一种方法来将给定的货架与仓库中的一部分产品关联起来。在我们的案例中,已经通过性别和品牌进行了细分。我们只需要我们的仓库在物理上反映这个限定符。

让我们根据鞋企在货架上的位置,用理论数量来划分仓库(图 3-13 )。

img/488324_1_En_3_Chapter/488324_1_En_3_Fig13_HTML.jpg

图 3-13

仓库结构草图

这可能行得通,但有人可能会说,你不知道任何特定品牌的会占据更多空间(如果有的话)。如果公司是新成立的,这将是一个合理的担忧,但实际上,你如何知道在任何给定的时间内,任何特定品牌将有多少在仓库内,以及你应该以什么间隔将品牌彼此分开?

鉴于我们正在进行的“代码作为最后手段”的教训,我认为这个问题可以通过查看去年的销售报告并估计大致相同水平的库存来轻松解决。

图 3-13 中的草图描绘了按品牌划分的仓库货架(实际上不止四个,但请配合我的工作),空间为所有品牌平均分配。这可能是一个解决方案,但是性别呢?我们在条形码标记中有性别,所以我们可以用它来区分实物库存。见图 3-14 。

img/488324_1_En_3_Chapter/488324_1_En_3_Fig14_HTML.jpg

图 3-14

按性别划分的品牌

如图 3-14 所示,品牌本身分为男鞋和女鞋。这给了我们所有可能的产品组合(假设只有四个品牌)。

真实世界的场景

你是一个仓库保管员,有一批鞋子要收货。收货人负责将物品及其数量记录到系统中,这意味着每个箱子上都有一个条形码供您扫描。然而,贵公司的网络开发人员相当聪明,他们在图片上方加入了英文版的条形码。这些鞋子属于以下类别:

W-GLB-65-1567566899

你告诉自己,“没问题,我有这个,”并对自己读代码的翻译版本:“女性地球仪,大小 6.5。”你去指定的货架拿着所有的 Globe 牌鞋子,去 Globe 的女款那一半,用它做什么?

哦不!我们忽略了系统设计中的一个关键细节。我们对货架问题的探索还不够深入。我们停在了一个更粗粒度的解决方案上,这在实现中留下了一个漏洞。不过,这没什么大不了的,因为所要做的只是向系统中添加另一个数据点,作为整理鞋子的额外划分。

我们可以用什么来分隔鞋架上的鞋子,每双鞋子都有,而且每双鞋子之间的差异足够大,因此可以作为一个很好的分隔机制。如果你还没猜到,很简单:大小!

精彩的演绎,华生!我们可以按性别分割每个货架区,每个货架由一个品牌的鞋子组成,并分成不同的“尺寸洞”(坦率地说,它们是什么;我想用我能想到的最直接的方式来称呼它)。

图 3-15 只是对鞋子组织问题的一个更详细的示例解决方案,鞋子的尺寸是鞋子属性的最终区别因素,因此它可以用作进一步分离进入仓库的大量鞋子的手段。

img/488324_1_En_3_Chapter/488324_1_En_3_Fig15_HTML.jpg

图 3-15

通过鞋码组织个人货架

该图是货架上单排鞋号的布局。大小之间的间隔马上会成为问题,这是一个需要咨询领域专家的细节。

作为程序员,我们可以也确实对我们正在做的工作做一些假设。这对于开发来说非常好。如果出现软件必须考虑的情况,但无法以精确的方式定义或当时未知,则进行有根据的猜测,并用此猜测值填补缺失的空白,直到您有机会回顾并确定正确的结果,这通常是通过与领域专家的进一步交谈来实现的,或者可能需要更大程度的投资来确定。

让我重新表述一下我之前说过的话:假设作为尚未知晓的价值/过程的占位符是完全可以的,假设创建它的团队/个人有定期的代码审查和技术债务消除会议,并为重构分配足够的时间。这些应该定期发生,有宗教信仰。

在与领域专家讨论了每个品牌的每个尺码应该有多大的空间后,你已经明白应该将它们分成不同的鞋号组,如图 3-16 所示。

img/488324_1_En_3_Chapter/488324_1_En_3_Fig16_HTML.jpg

图 3-16

属于一个品牌的每个货架的鞋号分布

这一尺码分布表明,大多数男性的尺码将在 9 至 11.5 之间。还有一个适合儿童尺寸的地方(在这种情况下,是男孩)。在这种情况下,我们假设组织因素应该是什么,我们的设计是正确的;然而,我们不知道如何以最适合业务的方式对货架系统进行建模,所以我们对其可能的样子进行了合理的估计,然后我们在有时间与领域专家讨论此事时再次对其进行了修改,他们让我们知道应该根据尺寸的流行程度来划分尺寸,我们能够在图 3-16 中正确地描绘出这一点。

结论

这一章占据了相当多的页面,主要是因为我们的示例场景(顺便说一下,我们已经完全解决了)。在编程中,我们经常面临与代码质量、时间估计和开发成本相关的选择。向三角形的四分之一倾斜会导致对另一个的轻视。随着时间的推移,质量、成本和时间总是会成为你能更好估计的问题。

此外,我们检查了一些可能的火灾,这些火灾可能是由于没有正确地维护您的代码库以及没有频繁地参与代码评审而引起的。不重构通常也属于这一类。只有在正确的开发周期中,并且组件设计是迭代的,由小的胜利和“低挂的果实”组成,DDD 模型才能很好地工作将较大的问题分割成较小的部分,使得为其开发解决方案变得更加容易和易于管理。试图将不同的组件混为一谈可能会导致灾难,如果关注点分离的原则对系统整体策略的上下文轮廓有意义的话,应该总是尝试。

我在本章中创建的例子很好地为我在本章第一部分中表达的观点提供了一些背景。我们经历了一个场景,在这个场景中,我们必须设计一个鞋类仓库,该仓库要为运行操作(即,促进订单)正确地设置,正确地跟踪库存,并提供一种组织货架的方法。我们决定使用 3D 条形码形式的技术,在每只鞋上正确地生成一个加密代码,以表明它在仓库中的相对位置。我们已经使入库功能变得更加可行,而且整个流程也更加严谨,以便在现实世界中发挥作用。我们设计的实现的某些细节被忽略了,这在现实世界中是绝对行不通的。通过领域专家的频繁对话和确认,确保您走在正确的方向上。他们是你在大多数领域相关问题上的真理来源。文档也可以根据领域专家对给定概念或过程的想法来编写。我们可以使用逻辑对开发进行有根据的猜测,但是一定要确保定期重新审视设计,并对您正在使用的值和变量(或者您当时选择忽略的细节)建立适当的约束。

顺便提一下,这个例子来自我作为 PHP 开发人员工作过的一家公司。我省略了一些次要的细节,以便更好地关注我在本书中已经讨论过的概念。

Note

我确实遗漏了一个细节,即“挑选和包装”问题,工人们不得不花费太多时间在货架上寻找特定尺寸和品牌的鞋子。我将把这个留给你去思考,并提出可能的解决方案。你应该考虑的是如何在比我们之前设计的架子系统更精细的细节层次上表达鞋子的位置。

现在我们已经有了系统的设计,我们将在以后继续关注技术问题...但是,首先,介绍一下拉勒维尔。***

四、Laravel 简介

在本章中,您将了解各种 Laravel 工具的来龙去脉,当这些工具与最佳实践结合使用时,可以在相对较短的时间内产生显著的效果。正确设置环境很重要,网上有很多教程(或者访问 Laravel.com,阅读安装文档,这很棒)。我们将讨论如何在您的本地环境中安装 Laravel(使用 Composer 和 Laravel 安装程序脚本)。

Note

如果您阅读了安装文档并决定使用homestead虚拟主机选项,那么您不需要我在本章中概述的安装过程,因此可以随意跳到下一节。

在我们为 Laravel 开发做好适当的准备之后,我们将经历另一个您可能会在某个时候遇到的示例场景。我们将构建一个简单的表单,该表单接受并验证特定类型的文件,并点击定义为路由的各个端点,这些端点将请求转发到控制器,然后控制器将处理生成文件名并将其存储在文件系统中。

我们将复习 Laravel 基础知识,如下所示:

  • 路由配置

  • 控制器

  • Artisan 用于生成脚手架代码的命令行实用程序

  • 请求和请求验证

  • 前端和刀片模板

    • 设置 Ajax 请求和响应(就前端方面而言,我给出的例子有点过时,但我在本书中并不太关注前端。有关最新和最棒的前端技术的更多信息,我建议您查看 React。)
  • Laravel 的请求/响应周期

  • 一个基本的“流程”,你可以重复它来开始你自己的项目

我们将修改 Artisan 工具提供的脚手架,以调整路由、请求、控制器和其他预构建的模板。我们将允许外部通过基于 URI 的路由访问应用。我们将学习 Laravel 中的请求,包括请求/响应生命周期和传入请求数据的验证。我将向您展示如何通过在控制器方法中简单地输入提示来无缝地验证您的请求,以及如何在请求的rules()方法中设置适当的验证约束来自动验证整个请求对象。我们希望我们的 API 和应用尽可能地安全,Laravel 有许多优秀的特性,只需(通常)几行代码就可以轻松实现众所周知的安全实践。我将深入探讨这些问题,并讨论一些考虑因素和背景知识,这将有助于您开始理解事情。

你还将了解到 Laravel 的运作方式。如果我们要创建一个使用 Laravel 框架和最佳实践实现的具有模型驱动设计的应用,我们需要全面了解 Laravel 这些实践。Laravel 将是本章的主题,但是我们将遵循 Laravel 本身所建立的最佳实践。

我已经包括了几个部分,这些部分偏离了一些选择的主题,但是仍然是你不应该跳过的重要概念。这些边栏旨在让您更深入地思考一个特定的概念,包括我们在软件实现方面所做决定的利弊,并为您提供一些想法,以找到以不同方式完成相同任务的替代方法。在 Laravel 中,您可以自由地定制应用的行为方式、做什么以及如何做。当然,Laravel 应用有一个标准的“流程”,它内置于框架本身(否则它就不是真正的框架),但是它提供了足够的扩展点和程序的不同执行方式,允许我们完成几乎任何我们在标准 PHP 中可以完成的事情,只需要通过一个建立在可靠组件之上的明确定义的 API。

在这一章中,我已经省略了雄辩的 ORM 概念;然而,我们将在后面深入讨论口才,因为它是 DDL 中的关键角色。

Note

假设您的本地系统上已经安装了一份 PHP。如果没有,可以在这里阅读如何安装: http://php.net/manual/en/install.php

现在,让我们继续学习 Laravel 的设置,并开始学习一些很酷的东西。

为什么是拉弗尔?

在过去的二十年里,我们已经看到了 web 开发领域发生了一些非常极端的变化——在史诗般的规模上。这些以前端和后端进步、系统设计模式以及新的方言和库的形式出现。2000 年,Cake PHP 推出了第一个 PHP web 框架,并在全球范围内对这种全新的推理和设计 web 应用的方式产生了反响。该系统的核心包括各种各样经过试验和测试的设计模式的实现,它本身被建模为 MVC 框架。在我看来,最重要的事情是,这是一种分离架构关注点的清晰方式,允许开发人员开始摆脱创建混杂层(UI+数据+基础设施)的混乱意大利式丛林,将它们塞进一个文件中。

在 MVC 出现之前,大多数 PHP 代码本质上都是过程化的。即使复杂的 web 应用也很少甚至没有层的划分。现实世界需要更好的东西。实现 MVC 模式的决定以新的(竞争的)PHP MVC 框架的形式引起了广泛的反响。这很可能是激发杨奇煜·普朗西开始 Symfony 框架项目的原因,也是促使 Zend 公司构建 Zend 框架的原因。随着 PHP v5.3 的发布,出现了各种更加进化的 PHP 框架。Symfony 2 和 ZF 2 完全改写了他们以前的自我,并接受了 PHP 5.3 中新的命名空间特性。隐藏在这些事情中间的是策划人泰勒·奥特威尔,他炮制了著名的拉勒韦尔框架。

我坚信熟能生巧的理念。也就是说,你从这一章中获得任何东西的最好方法是跟随并练习编写例子。复制和粘贴代码要容易得多(如果你正在阅读这本书的印刷版本,甚至更好),因为通过在计算机上实际尝试并试图获得预期的结果,你会学得更快更好,我也提供了这一点。读代码好,写代码更好。我在这一章中保留了非常实用的内容,以便更好地让你了解现实世界中的事情会如何发展。

使用 Composer 安装

Composer 既是包管理器又是依赖管理器。要安装它,打开一个终端并进入一个新目录。运行以下命令:

curl -Ss getcomposer.org/installer | php

您应该会看到如下所示的内容:

All settings correct for using Composer
Downloading...
Composer (version 1.9.0) successfully installed to: /home/vagrant/Projects/laravel/composer.phar
Use it: php composer.phar

虽然有许多方法可以设置一个新的 Laravel 应用,但是我们将通过 Laravel Composer 脚本来完成。要安装此脚本,请运行以下命令:

php composer.phar global require laravel/installer

这不仅会下载您在composer.json文件中指定的包,还会生成一个漂亮的小自动加载程序,您可以在一行代码中“即插即用”。

require_once('vendor/autoload.php');

在我们即将设置的 Laravel stub 项目中,这个文件已经包含在系统内部的 public index.php文件中,所以我们不用担心;只要知道它的存在。键入以下命令来启动 Laravel 安装程序,这将导致安装最新的框架版本(在撰写本文时,它是 6.0):

laravel new ddl

该命令将生成丰富的屏幕输出,如下所示:

Crafting application...
Loading composer repositories with package information
Installing dependencies (including require-dev) from lock file
    1/3: https://codeload.github.com/laravel/framework/legacy.zip/f38711564c642ee88a58bf180010a0c7a7ab062e
    2/3: https://codeload.github.com/facade/flare-client-php/legacy.zip/a276603dbb7b9b35636d573d5709df5816dd4d2b
    3/3: https://codeload.github.com/facade/ignition/legacy.zip/1f92a209c1a5a60f43c5bbff196177810e817095
    Finished: success: 3, skipped: 0, failure: 0, total: 3
Package operations: 84 installs, 0 updates, 0 removals
  - Installing doctrine/inflector (v1.3.0): Loading from cache
{LONG LIST OF OTHER DEPENDENCIES INSTALLED FROM CACHE OR DOWNLOADED}
facade/ignition RUNS)
Discovered Package: suggests installing laravel/telescope (².0)
(LONG LIST OF OTHER SUGGESTED INSTALLS - IGNORE THESE)
Generating optimized autoload files
(A FEW COMMANDS THAT COMPOSER nunomaduro/collision
(A FEW OTHER DISCOVERED PACKAGES)
Package manifest generated successfully.
Application ready! Build something amazing.

如果这是您第一次安装应用的依赖项,您可能会看到一串Downloading...行,而不是Loading from cache...。如果是这种情况,耐心一点就好;有相当多的软件包需要让 Laravel 运行,可能需要几分钟。

您可以通过发出一个ls命令来调查在文件系统中到底发生了什么

我们已经在第一章中对 Laravel 目录结构有了一个很好的了解,但是这里还有一些细节:

  • 这是我们的应用代码和域模型所在的源文件夹。它是典型的 Laravel 应用中大多数自定义代码的位置。

  • bootstrap/:它保存了应用的启动脚本和一些类别映射文件。

  • config/:保存应用的默认配置,并被.env文件中定义的参数覆盖。

  • 这存放了数据库文件,包括迁移、种子和测试工厂。

  • 这是一个可公开访问的文件夹,保存着编译过的资产,当然还有 Laravel 的前门,即文件。

  • resources/:它保存前端资产,如 JavaScript 文件、语言文件、CSS/SASS 文件和刀片模板。

  • routes/:应用中的所有路线都在这里面;它们是 API 或浏览器特定的。

  • storage/:保存系统中的所有临时文件,包括应用生成的缓存数据和会话,以及编译后的视图文件和日志文件。

  • tests/:这包含单元和功能测试。

  • vendor/:它保存了 Composer 安装的依赖项。

现在,让我们用 Artisan 命令设置应用的其余部分。Artisan 是 Laravel 附带的命令行实用程序,在开发 Laravel 应用时非常方便。.env文件包含应用和外部包所需的所有值,并被用作覆盖在/config文件夹中找到的默认配置的手段。

需要注意的是 Laravel 内部的Http组件的结构。控制器、路由和中间件的概念已经被分组到一个名为应用的Http名称空间的父名称空间中。位于/dll/app/Http,分解如下:

  • App\Http\Controllers

  • App\Http\Middleware

  • App\Http\Requests

除了领域模型本身之外,我们还将与App\Http名称空间中的项目进行大量的交互,所以最好熟悉这个结构以及其中的项目。我建议先浏览一下名称空间中的所有文件,这样您就可以了解 Laravel 是如何构造的,以及它是如何处理 HTTP 请求的。

项目 4-1: Laravel 文件上传

Note

在完成示例项目(以及本文中的所有项目)时,我建议花些时间将源代码输入到我们在本章前面设置的本地环境中。我不建议您键入注释每个类和方法的 PHP 文档块,因为这些是由 IDE 生成的(特别是 JetBrains PHPStorm,我强烈建议您尝试一下)。VSCode 是微软开发的另一个 IDE。也不值得输入所有的评论,除非你觉得有必要这样做。还要注意,在本文的任何示例中都没有使用注释;这是有原因的,我稍后会解释。

在本教程中,我们将构建一个简单的应用,它将完成以下任务:

  1. 通过 HTTP 路由显示 web 表单。

  2. 提供一种在客户端通过 Ajax 提交表单的方法,并向用户返回一个有效的响应,指示成功或失败以及保存文件的名称。

  3. 创建一个表单请求对象,处理传入数据的验证(包括任何附加文件的验证),然后将这些数据注入控制器。

  4. 从请求中提取文件,生成文件名,并以新的名称将其存储在文件系统中。

  5. 在客户端,表单提交请求的响应将通过 Ajax 发送和接收,响应将被解析,并在成功或失败的事件中适当地通知用户。

Note

对于这个例子,我没有考虑数据库级或用户级的安全问题。在现实世界的应用中,这些类型的事情应该在任何时候都作为最高优先级

*表 4-1 描述了我们对付这个怪物所需的各种组件。

表 4-2

我们的文件上传示例应用的路由配置

|

路线名称

|

URI 路由

|

目的

|
| --- | --- | --- |
| upload | /upload | 这将是显示我们上传文件的 web 表单的主页。 |
| process | /process | 这是表单(位于/upload)开始提交数据到表单action值内路径的地方。 |
| list | /list | 这将列出系统中所有上传的文件。 |

表 4-1

文件上传演示应用所需的组件

|

组件类型

|

组件名称

|

描述

|
| --- | --- | --- |
| 途径 | /upload``/process``/list | 定义客户端使用的端点,这些端点指向位于指定控制器(在本例中是UploadController)上的某个方法 |
| 模板 | upload.blade.php | 保存 web 表单和用于提交表单和检索响应的 Ajax 调用 |
| 表单请求 | UploadFileRequest | 用于描述表单的预期内容,还验证数据,并提供添加自定义身份验证逻辑的机制 |
| 控制器 | UploadController | 处理请求/响应周期 |

配置路线

Laravel 中的路由基本上是一个 URI 或端点,允许通过已知的 URL 地址从外部世界到特定控制器方法的通信,并且可能包含 URI 参数(查询字符串)。它基本上是一个客户端向一个定义的路由发出请求,该路由根据一组要求验证表单数据,并将请求转发到控制器上的适当位置,然后控制器委托用于存储上传文件的逻辑,并返回某种类型的响应,指示请求成功(成功或失败)。

一个路由可以是自包含的,具有在传入的闭包函数内完成请求所需的逻辑,该函数运行并返回给客户端,或者一个路由可以简单地转发给控制器上的特定方法,该方法接受请求并返回响应。使用闭包定义的路由也是一样,但是最大的区别是不能使用 Laravel 的缓存工具来缓存基于闭包的路由。您还可以声明能够被路由组中定义的名称空间命中的特定 HTTP 方法。参见https://laravel.com/docs/6.0/routing#basic-routingLaravel 文档中的路线部分。

Laravel 提供了一种方法来限制给定的路由只能通过特定的 HTTP 动词来使用,通过中间件将运行时逻辑附加到路由组,并用易于记忆(和键入)的名称来标记任何路由,这样在代码中引用不同的端点时就不必记住长串的 URL。它还为我们提供了许多关于如何构建路由和定义路由级参数的附加选项——这个组件有很大的灵活性。我建议查看文档,了解路由组件开箱即用的强大功能。整个应用的路由配置位于两个中心文件中(但是可以定制成任何您想要的结构):routes/web.phproutes/api.php

在本书的后面,我们将通过将每一组定义的路由分离到我们的域模型中的相应模块中,来重新构建整个 Laravel 框架的目录和名称空间映射,使之更加领域驱动。我们将使用我们的域模型中定义的模块作为路由的直接“组”。在 Laravel 中,由于路由组件提供的健壮性和灵活性,这项任务变得很容易。

Note

如果您阅读了安装文档并决定使用homestead虚拟主机选项,那么您不需要我在本章中概述的安装过程,因此可以随意跳到下一节。

正如我前面提到的,路由不仅可以转发给控制器方法,还可以用回调函数自定义。想法是一样的:运行回调函数中的逻辑并返回某种类型的响应。下面是一个定义为闭合函数的简单路径示例:

Route::get('/uri/something', function() {
     return 'You are at the path : /uri/something';
});

这是最简单的路线。它匹配 URL 字符串/uri/something并返回一个简单的“你在路径:/uri/什么地方。”你可以这样想,有一些 URI,在调用时,将返回回调函数的任何结果计算。它提供了与使用控制器相同的请求处理,并且它与使用控制器来定义特定路由的逻辑一样方便,所以实际上是由您和您的团队来使用。然而,我建议使用控制器来定义您的路由运行的逻辑,以便它与路由定义本身相分离。

如果您和我一样,不喜欢在一个(或多个)路由文件中混合路由配置和控制逻辑,您可以选择让路由将请求分派给控制器,在控制器中,您可以添加任何其他验证(稍后我们将看到)或定制逻辑,您需要这些来生成对请求的正确响应。

Route::get('/uri/something', 'SomethingController@something');

我个人认为这是一个更好的方法,因为它没有用业务逻辑膨胀路由文件,而是使用控制器和路由文件方法,如下所示:

/**
* Something Controller - something()
*/
Public function something()
{
     return 'You are at the path : /uri/something';
}

这实现了与闭合定义的路线相同的效果。

Note

当将请求路由到控制器时,关于路由应该被指定的方式有其他方法和理论。例如,Symfony 的做法是将您的“控制逻辑”和您的路由定义放在一起,但作为注释存在于控制器中,注释是代码的注释,可以被解析和使用。在这方面,将它们放在一起是有意义的,因为它们在不同的上下文中,而不是在 routes 文件中。我的观点是,它使用注释来验证参数和传入数据的方式太冗长了。Laravel 提供了一个比这更好更干净的解决方案,我将在本章后面演示。就这一点而言,注释极其丑陋,使代码看起来就像一开始就被注释掉的一堆乱七八糟的垃圾,所以乍一看并不总是很明显它们实际上在做什么。

我们有多条路线可以定义,做不同的事情。尽管不鼓励使用控制器来容纳核心域逻辑,但是将它们分组在一个控制器中可以保持关注点的清晰分离。记住,业务逻辑的适当位置是在域模型中;控制器位于域模型之外,负责通过请求与客户机“握手”,将需要完成的任何工作分派给其他组件,并返回指示请求成功或失败的响应。此外,最好使用控制器来定义路由的行为,而不是内联闭包函数,因为 Laravel 中的路由只有在路由定义中没有内联回调时才能被缓存。

路由文件

路线存储在项目根目录下的/routes文件夹下的文件中。默认情况下,有几个不同的文件对应于应用的不同“侧面”(术语侧面来自六角形架构)。这些边一起形成了一个边界,将应用的业务逻辑封装在一个假想的形状(可能是六边形)中,域模型位于该形状的正中心,所有进入该形状的请求必须使用某种类型的适配器,以允许它们跨越边界进入应用。

在 Laravel 中,有一些开箱即用的不同路由文件。

  • 这是面向公众的基于浏览器的路由。这些是最常见的,也是网络浏览器会碰到的。它们通过 web 中间件组运行,还包含 CSRF 保护功能(有助于抵御基于表单的恶意攻击和黑客攻击),并且通常包含一个“状态”线程(我的意思是它们利用会话)以便在请求之间持久化数据。

  • api.php:这有对应于一个 API 组的路由,因此默认启用 API 中间件。这些路由是无状态的,没有会话或交叉请求内存(一个请求不与任何其他请求共享数据或内存;每一个都是自封装的)。API 路由通常位于授权机制(如承载令牌)之后,必须包含在每个请求头中。

  • 这些路径对应于定制的 Artisan 命令,我们运行这些命令来生成 Laravel 组件的框架。稍后,我们将创建我们自己的 Artisan 命令,这些命令可以通过命令行运行,以构建许多快速且肮脏的过程,这些过程可以被调度或发送到消息队列。

  • channels.php:注册事件广播的路由。我们不会在本书中过多地讨论事件广播,但是 Laravel 确实为它提供了内置支持。要了解 Laravel 中事件广播的更多信息,请查看文档。

此时需要关注的主要文件是特定于浏览器的文件web.php。默认情况下,已经定义了一个路由,当用户导航到应用的 web 根目录(web 根目录位于公共目录中)或主页时,这个路由会被正确命中。值得一提的是,特定于 API 的路由并不适合返回一个要在浏览器窗口中呈现的完整 HTML 页面。API 通常有更简单的响应,通常由 JSON 编码的字符串和元数据组成,比如状态代码、可读消息或错误通知。这就是为什么有两个单独的路由文件。当您访问一个网页时,该路由来自于web.php路由文件。但是,如果您要在该页面上提交一个表单,您可能实际上会遇到一个 API route(在api.php)来执行表单的处理。我们需要三个不同的路径来运行我们的上传应用示例(见图 4-2)。

Note

如果我们想把显示上传表单文件列表的所有逻辑放在一个页面上,可能不需要/list端点;然而,为了给我们的演示添加一些额外的上下文和范围,我暂时将它们分开。我们稍后将再次讨论这个问题。

让我们创建基本的 HTML 上传表单,从路由定义开始。打开你的routes/web.php文件,输入清单 4-1 中的代码。

// routes/web.php
Route::get('/upload', 'UploadController@upload')->name('upload');
Route::post('/process', 'UploadController@process')->name('process');
Route::get('/list', 'UploadController@list')->name('list');

Listing 4-1The Routes We Use in the Example file-uploader Application

对于每个所需的路由,我们使用一个可用的特定于 HTTP 的请求方法(get()post()put()patch()delete()options())在相应的路由文件(在本例中为web.php)中为其显式地创建一个单独的条目。要了解每一项的详细情况,请查看网站。这些方法的作用是指定允许哪些 HTTP 动词通过定义的 URI(或端点)访问给定的路由。如果您需要一个能够接受多个 HTTP 动词的路由(如果您使用一个页面来显示初始数据和提交的表单数据,就可能出现这种情况),您可以使用Route::any()方法。一般来说,GET routes 用于提出问题或从应用中检索/读取一些数据,而 POST routes 用于提交应用中某些模型或数据结构的表单更新。PUT 用于创建模型或数据库实体新实例。

这些路由定义的第一个参数是您希望访问路由的 URI 或端点(web 服务器上的物理位置)。Route::get()Route::post()方法(Route facade 支持的任何其他 HTTP-verb 相关方法)的第二个参数是代码的位置,该代码在使用允许的 HTTP 动词(GET、POST、PATCH 等)到达路由端点时执行。).Laravel 提供了一种简单的方法来指定路由器将转发请求的控制器/控制器方法组合。我们将UploadController用于所有三条路线,并以下列方式指定它们:

Route::get('/upload', '{CONTROLLER_NAME}@{CONTROLLER_METHOD}`)

我们在每个路由上调用的最后一个方法是它的name()函数,该函数接受单个字符串作为参数,并用于用一个容易记住的名称(在我们的例子中是 upload、process 和 list)或多或少地“标记”一个特定的路由。我意识到,当 URL 名称相同时,给每个路由起自己的名字似乎不是一个很好的特性,但当你有一个像/users/profile/dashboard/config这样的特定路由时,它确实很方便,因为它更容易被记住为profile-adminuser-config

Note

Laravel 的许多组件上都提供了这种方法链,允许您以一种非常类似英语的易用方式调用返回对象上的其他方法。这就是所谓的流畅界面。下面是一个来自 Laravel 迁移的例子,它用于定义和跟踪数据库模式的变化(在下面的例子中,我们定义了一个外键约束):

$table->foreign('user_id')

->references('id')->on('users')

->onDelete('cascade');

正如您所看到的,方法链接的“语言”构成了一个完整的英语句子,这通常比任意的方法名称更容易记忆。

关于立面的一个注记

外观 为应用的服务容器中可用的类提供一个“静态”接口。

*> "它们提供了一种简洁、易记的语法,允许您使用 Laravel 的特性,而无需记住必须手动注入或配置的长类名。

—旅行证件

在前面的路由定义中,我们使用路由外观,而不是手动实例化一个新的Illuminate / Routing / Router对象并在该对象上调用相应的方法。只是省打字的捷径。Laravel 框架中大量使用了外观;你可以也应该更熟悉他们。立面文件可在 https://laravel.com/docs/6.x/facades 找到。如果你不喜欢 facades,你可以使用静态方法调用来代替。

生成控制器

闲话少说(虽然这本书主要是 PHP,而不是 small talk),让我们继续并生成控制器,它将成为应用层的一部分,作为处理请求的机制,即接受请求并返回响应。这是所有控制器都应该做的!

值得重复的是:以下是管制员应该做的唯一的事情:

  • 接受请求:路由将一个 URI 连接到一个通用或特定的Request对象,其数据在被自动注入到路由中定义的控制器方法之前被验证(通过简单地在控制器方法中类型提示请求对象来完成)。请求/响应生命周期的这一部分也可以被认为是客户端和应用之间的握手式机制。

  • 返回响应:在分派工作(很可能是以作业、命令或其他可操作的域对象或外观的形式)之后,需要根据用户访问系统的特定方法给请求用户一个响应。例如,对 web 应用提供的 API 的调用通常需要某种 JSON 编码的响应,带有可读的成功消息、HTTP 状态代码,可能还有一些关于请求状态的附加数据。另一方面,对浏览器请求的响应可能是一系列不同的 HTML、CSS 和 JavaScript 代码,由浏览器解析并显示在窗口中。

好消息是,Laravel 提供了易于使用的设施来完成所有这些甚至更多!为了获得控制器的基本结构,我们可以使用这个例子,从项目的根目录中运行以下命令:

// inside the directory "ddl/"
php artisan make:controller UploadController

本质上,这个命令在主控制器目录中的/app/Http/Controllers/UploadController.php处为名为UploadController的控制器生成一个存根。打开来看一看。这很简单,因为它只是控制器的一个存根版本,具有正确的名称空间路径和它所扩展的必需类(参见清单 4-2 )。

// ddl/app/Http/Controllers/UploadController.php

<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class UploadController extends Controller
{
    //
}

Listing 4-2Generated Controller Stub From

生成请求

在我们继续对UploadController生成的存根进行一些更改之前,让我们一次生成所有的搭建代码。这样,我们可以获得整个过程的清晰、高层次的画面,而不会迷失在使其内部工作的更细粒度的细节中。

Note

理解表单请求和请求对象之间的区别很重要。一个表单请求是一种特定于标准 HTML web 表单的请求,包含提交的数据和任何附件,以及请求的rules()方法中指定的验证需求。另一方面,request 对象是 Laravel 对 Symfony request 对象的扩展,它封装了一个真正的 HTTP 请求(例如,通过一个curl命令),并用易于使用的访问器和操纵器对其进行打包(稍后将详细介绍),还包含诸如头信息、查询参数、请求体参数、cookies、会话值、HTTP 动词、URL 和引用 URL 等数据。

处理请求的控制器方法必须在其签名中键入特定请求类的提示。Laravel 中有一些快捷方式可以让您访问令人惊叹的验证特性,针对诸如请求验证、数据库验证和参数验证等问题,它们让我们有机会使用Illuminate\Contracts\Validation契约的实现来创建额外的复杂域级验证。(在本章的后面你会学到更多。)现在,让我们再次使用 Artisan 命令来生成我们的请求存根。

php artisan make:request UploadFileRequest

这个命令将在app/Http/Requests/UploadFileRequest中生成一个名为UploadFileRequest的文件。打开存根看一眼。你会发现它很简单,只包含两种方法,authorize()rules() .

我们刚刚生成的内容被称为表单请求。这意味着从 HTML 表单中捕获任何传入的数据,根据我们指定的验证检查来验证传入的数据,并将其注入到一个可以使用或修改它的控制器方法中(尽管由于请求是不可变的,所以不建议使用后者)。

清单 4-3 展示了生成的请求开箱后的样子。

// ddl/app/Http/Requests/UploadFileReq    uest.php
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UploadFileRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return false;
    }
    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            //
        ];
    }
}

Listing 4-3A Generated Form Request Stub

定制生成的请求

让我们修改前面的请求存根,以满足我们的应用的需要。脚手架代码很好,因为它允许你基本上原型化你需要的各种组件,使你的应用做你需要它做的事情。

创建验证逻辑

打开UploadFileRequest文件,将其更改为清单 4-4 。

// ddl/app/Http/Requests/UploadFileRequest.php

<?php

namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UploadFileRequest extends FormRequest
{
    /**
    * Determine if the user is authorized to make this request.
    *
    * @return bool
    */
    public function authorize()
    {
        // NOTE: This is a convenient location to include any auth
        // checks or other authentication logic specific to this
        // specific form request.

        return true;
    }

    /**
     * Get the validation rules that apply to the request. It is here
     * that we specify how we want the data to be structured and what
     * it should look like.
     *
     * @return array
     */
    public function rules()
    {
      return [
           'fileName' => 'required|string',
           'userFile' => 'required|file'
       ];
    }
}

Listing 4-4Modified Request Stub for Our Upload Application

清单 4-4 没有太多变化。首先,authorize()方法现在返回 true 而不是 false。该方法负责允许请求传入应用。如果评估结果为 false,它将阻止请求进入系统(即,不将请求传递给路由中定义的相应控制器方法),而是返回某种错误响应。同样,这个响应将是特定于请求类型的:web、API 或。这将是一个方便的地方,可以对用户或任何其他逻辑进行授权检查,以确定请求是否可以前进到控制器方法,在控制器方法中处理请求并返回响应,或者,逻辑可以确定请求无效,而是发出带有 4xx 或 5xx 状态代码的错误响应(指示“未找到”错误或服务器问题), 这可以使用特定的Exception类在后端记录错误来描述,但也可能在浏览器上显示为一般的错误页面。

现在,我们只需在这里返回 true,以允许任何东西使用请求,但这也是添加身份验证逻辑和其他与身份验证或授权相关的验证检查的好地方。请参阅下一节中更深入的示例。

另一个方法是rules(),这是验证的神奇之处。想法很简单:返回一个包含一组规则的数组,格式如下:

'formFieldName' => 'pipe-delimited validators'

Laravel 支持各种现成的约束。如需完整列表,请查看在线文档 https://laravel.com/docs/6.x/validation#available-validation-rules 。对于我们的上传应用,将有两个字段通过来自前端表单的 POST 请求传递。fileName参数必须包含在表单主体中(即必需的),并用作我们将在存储器中存储文件的文件名(这在控制器中完成——我们稍后会用到它)。我们还通过添加管道字符(|)和单词string来指定文件名必须是一个字符串。约束总是由管道分隔,允许您在一行中为给定字段指定许多附加条件。这是一些强大的东西!

第二个参数userFile,是用户从网页上的表单上传的实际的文件UserFile也是必需的,必须是文件。

Note

如果我们期望上传的文件是一个图像,那么我们将使用 image 约束,这将限制文件类型为最流行的图像类型之一(JPEG、PNG、BMP、GIF 或 SVG)。由于我们希望允许用户上传任何类型的文件,我们将坚持文件验证约束,而不检查扩展名。这是一个有效的安全措施还是一个潜在的安全缺陷,我们现在通过指定我们的应用所接受的允许的文件扩展名的类型来引入它?我将在“更大的图片”一节的后面讨论这一点,但是,更具体地说,我将在接下来的“使用 MIME 类型来验证上传的文件”一节中演示如何使用 MIME 类型来验证文件是否如它们所声称的那样。

关于请求对象的rules()方法需要注意的另一点是,如果由于某种原因我们省略了一个我们希望在请求中验证的参数,它显然不会有任何要求,并且会与用户使用表单提交的其他参数一起传递给控制器。只有当您在请求的rules()方法中指定表单中的所有字段以及它们的约束时,自动验证才会正确工作。此外,当您在rules()方法中包含一个不存在于表单传入值中的参数时,Laravel 将停止请求,因为它不符合相应路由定义中定义的控制器方法。

这就是请求对象的全部内容。它的主要工作是简单地保存一组可接受的标准(约束),表单的主体参数必须满足这些标准才能更深入地应用。另外需要注意的是,这两个字段(userFilefilename)也必须以输入字段的形式在 HTML 代码内部指定(字段名对应请求对象内部的名称)。

您可能会问:当然,这定义了表单请求应该包含的特征,但是在哪里执行对这些约束的实际检查呢?我们将通过使用我们的请求中配置的验证,发现以自动化方式执行这些检查的最佳方式;但是,手动执行身份验证检查的方式如下所示:

   $validatedData = $request->validate([
        'fileName' => 'required|string,
        'userFile' => 'required|file',
    ]);

定制生成的控制器

我们准备定制控制器逻辑。打开app/Http/Controllers/UploadController.php并修改它,使它看起来像清单 4-5 中列出的那个。请记住,如果您按照示例进行操作并手动输入应用,您不必担心转录注释,因为我在本书中没有使用注释来实现任何特殊功能。为了更有用的东西,避免额外的击键。然而,在生产环境中,或者在已发布语言或 API 文档的一部分中,文档块应该包含在每个类、方法和包中。

<?php
namespace App\Http\Controllers;

use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Request;
use App\Http\Requests\UploadFileRequest;
use Illuminate\Support\Facades\Storage;
class UploadController extends Controller
{
    /**
     * This is the method that will simply list all the files uploaded
     * by name and provide a link to each one so they may be
     * downloaded
     * @param $request : A standard form request object
     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
     * @throws BindingResolutionException
     */
     public function list(Request $request)
     {
          $uploads = Storage::allFiles('uploads');
          return view('list', ['files' => $uploads]);
      }
    /**

     * @param $file
     * @return \Symfony\Component\HttpFoundation\BinaryFileResponse
     * @throws BindingResolutionException
    */
    public function download($file)
    {
        return response()->download(storage_path('app/'.$file));
    }
    /**
     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
     * @throws BindingResolutionException
     */
     public function upload()
     {
        return view('upload');
     }
    /**
     * This method will handle the file uploads. Notice that the
     * parameter's type-hint is the exact request class we generated
     * in the last step. There is a reason for this!
     *
     * @param $request : The form request for uploading a file
     * @return array | \Illuminate\Http\UploadedFile |
     * \Illuminate\Http\UploadedFile[] | null
     * @throws BindingResolutionException
     */
     public function store(UploadFileRequest $request)
     {
          $filename = $request->fileName;
          //the request is valid at this point because of the defined
          //parameters we specified in the form request
           $file = $request->file('userFile'); //no isset() req’d

          //retrieve the original extension of uploaded file
           $extension = $file->getClientOriginalExtension();
          //create a new file name
           $saveAs = $filename . "." . $extension;
          //save the file to the local filesystem, inside uploads/
           $file->storeAs('uploads', $saveAs, 'local');
          //return a success message
          return response()->json(['success' => true]);
    }

}

Listing 4-5The Modified UploadController That Handles the Persistence of the Uploaded File

因此,这是一个相当简单的方法来保存上传到磁盘的文件。下面是对upload()方法的分析:

  1. 在执行重要功能的控制器方法中键入提示请求类,这样我们就可以自动验证传入的数据。

  2. 从控制器方法内的请求对象中获取文件(现在已经过验证)。

  3. 从请求中获取文件名。

  4. 生成将用于保存文件的最终文件名。getClientOriginalExtension()方法检索上传文件的原始上传扩展名(当然,如果您猜不出名字所隐含的明显功能)。

  5. 使用其storeAs()方法将文件存储到本地文件系统,将/storage目录中的命名路径作为第一个参数,将文件名作为第二个参数。

  6. 返回一个表明请求成功的 JSON 响应。

控制器中还包括一些方法来促进浏览器和应用之间的用户交互,例如下载给定的文件、查看过去上传的文件列表,或者在用户可以访问的页面上显示表单。

图 4-1 显示了正在发生的事情。

img/488324_1_En_4_Chapter/488324_1_En_4_Fig1_HTML.jpg

图 4-1

整个应用的概述,包括请求和响应

这种设计是可行的,但并不完美。仍有改进的余地。你能认出是哪里吗?如果你不能,我会给你一些提示(这样你就可以更好地处理现实生活中发生的这类事情)。我能给你的最重要的建议之一是:养成依靠最佳实践的习惯,熟悉设计模式、架构模式和核心软件设计原则,这样当你陷入困境或对如何构建应用感到困惑时,你就有地方可去了(正确的答案是与领域模型一致)。

如果你还记得在这一章的前面,我反复强调控制器应该被限制做两种可能的事情之一:接受一个请求或者返回一个响应。我之前创建的控制器确实可以工作,但是问题在于违反了核心软件设计原则:关注点分离。控制器的store()方法包含保存上传文件所涉及的实际业务逻辑(完整地!)时,它实际上应该与控制器分离,作为域模型的一部分。在中心主题中存在多个关注点的事实暗示了它们应该位于除了与控制器内联之外的其他地方。这里有几个例子:

  • 为上传的文件创建一个新文件名,然后在新文件上附加与上传文件相同的扩展名

  • 指定特定文件系统上的位置

  • 指定要保存文件的文件系统

如果我们想要更改这些内容,我们必须在控制器中进行,这并不理想,因为使用它的客户端可能每次都期望相同的响应,如果我们不断更改逻辑的这一部分,这是不可能的,因为对于每个实现,依赖于它的调用代码也必须每次都更改。

这里要注意的另一件事是,尽管我们为从表单(通过表单请求)传递到应用的数据设置了适当的验证,并且确信数据在到达控制器的store()方法时应该是好的,但是我们没有对文件实际上成功保存进行额外的验证。按照现在的情况,如果文件没有正确保存到文件系统中,并且这导致了一个无声的错误,该错误记录到日志中,而不是显示在屏幕上,那么您将没有任何迹象表明保存上传文件实际上没有发生,并且会继续假设一切都很好,因为从该方法返回的唯一响应是一个基本的 JSON 类型的 API 响应, 只有打开您最喜欢的 web 浏览器的开发者工具插件并在一堆网络调用中搜索才能看到,这对于现实世界中的实际实现来说是完全低效的。 我们实际上缺乏对保存到指定文件系统的文件的任何验证。因为我们是专业的、高技能的(非常帅的)开发人员,他们欣赏他们工作的质量,我们会克服这个缺点并改正它。

如果这段时间还有其他事情困扰着你,那可能是因为我们使用了控制器的方法体来容纳我们对应用的主要关注:接受、验证和存储上传文件的过程。这些是应该在领域模型中处理的领域关注点。有许多不同的方法来处理这种情况。

让我们从UploadController中的upload()方法体内的逻辑错位开始。我们知道在这个控制器中发生了太多的事情,所以我们决定将涉及将上传的文件保存到存储器的域逻辑分开,我们将从我们的控制器内部委托一个调用。有许多其他方法可以解决这个问题,这将迫使我们将文件处理和存储逻辑下推到域层。我将重点介绍几种方法来做到这一点(还有许多其他方法也可以做到这一点)。

我们可以选择实现某种命令总线。本质上,命令总线是一种设计模式,它有两个组件,用于促进涉及应用服务层的操作,以及促进应用的响应:Command对象和Handler对象。Command对象只是保存一个用户请求(或客户端请求),所有参数和传入的用户数据都封装到这个Command对象中——这就是“是什么”。Handler组件是执行者(或如何做),它封装了任何直接响应Command对象请求的逻辑。理论上,每个Command都有一个特定于那个Command对象的处理程序对象。可以把处理程序看作是执行封装在Command对象中的请求的一种方式。

由于命令总线架构的流行,出现了几个 PHP 特定的库,它们处理各种主题和工作命令总线的输入和输出。他们是百老汇( https://github.com/broadway/broadway )和战术家( https://tactician.thephpleague.com/ )。这两个包都写得很好,有高质量的代码和支持它们的测试,如果你要建立一个完整的命令总线管道,有事件源、CQRS 支持、事件重放和预测,你可能要考虑看看百老汇,因为它支持所有这些,甚至更多。

另一方面,如果您正在寻求实现一个更小的基于微服务的架构,该架构将具有基本的命令和处理程序设置,用于相对少量的最近可能的请求和响应,那么 Tactician 可能是一个更好的选择。战术家在磁盘上有一个小尺寸,是超级快速,有效,易于学习。它支持各种不同的中间件(或者您可以推出自己的中间件),并支持自定义扩展点,例如日志记录、缓存以及对触发和跟踪事件的支持。

非凡软件包联盟是一个为 PHP 社区创建和维护战术家和其他高质量独立软件的组织。你可以在这里找到它们,以及非常高质量的文档: https://thephpleague.com

处理这种情况的另一种可能方式是将与处理和存储通过表单上传的文件相关的代码放在一个域服务中。每当我们对系统以及系统与我们的领域对象的交互进行建模时,对于我们向所有应用组件提出的基本问题,并不总是有一个简单明了、万无一失的答案:它是什么,它属于哪里?如果它不是一个东西,而是属于业务流程的范畴,那么就创建一个服务来封装业务逻辑,并从控制器中调用它,传递完成请求所需的任何变量或数据。然后,服务执行自己的任务,或者将结果返回给控制器,然后返回给客户端,或者将结果写入单独的日志,甚至将事件记录到任务或消息队列中。

另一个可能的解决方案是创建一个职位。作业存在于领域模型中,并作为独立的工作单元运行,可以从代码库中的任何地方调用。Laravel 完全支持创建和管理作业,甚至有一个单独的预建应用,它提供了一个强大的 UI,允许管理员以可视化的方式查看和管理系统中运行的不同作业。我们将在后面的章节中深入研究作业,但有一点需要注意(像 Laravel 中的大多数其他东西一样),有一种生成作业的机制,它将通过 Artisan 命令生成一个通用Job类的脚手架。在本章后面的“使用 Laravel 作业封装业务逻辑”一节中,我们将讨论一个这样的作业的可能实现

Using Mime Types to Verify Uploaded Files

我想后退一步,重新审视一下系统接受未经验证的文件类型的问题。在我们的系统中,除了回答最简单的问题“它是一个文件吗?”之外,基本上没有其他验证这应该是一个非常响亮的信号,表明需要额外的安全性,这样我们就不会因为对允许用户上传的文件类型过于宽松而在系统中造成巨大的安全漏洞。

假设验证文件扩展名提供了某种程度的安全性来防止恶意文件进入系统,则可以并且应该对其进行验证,以便“剔除”除了附加了特定扩展名的文件类型之外的任何文件类型。然而,除了防止合法用户选择错误的文件之外,它并不能很好地防止用户上传恶意文件。在通过 web 表单上传已知恶意文件之前,人们可以很容易地更改其扩展名。在没有任何适当验证的情况下,理论上,用户可以将包含蠕虫算法的文件上传到我们的系统,因为我们决定不实施更高级的方法来验证文件的真实类型。

Laravel 确实为我们提供了一些工具,我们可以利用它们来增强应用的安全性。我们需要通过使用其声明的 MIME 类型来验证传入的文件,以确定真正的文件扩展名(与在文件名中提供的对应于最后一个句点之后的所有内容的扩展名相反,这对于用户来说很容易更改,就像在上传文件之前重命名文件一样)。然后将从文件中提取的扩展名类型与由文件名后缀指定的文件扩展名进行比较。

当在名为putFileAs()File facade 上使用一个方法或者通过调用直接位于请求内的文件上的store()方法时,这是自动完成的。

//calling the store() method in a chain-like manner from the request
$path = $request->file('customers')->store('customers.csv');

//explicitly using putFileAs() on the Storage facade
Storage::putFileAs(‘customers’, new File('/path/to/customers'), 'customers.csv’);

这样做的唯一问题是,得到验证的 MIME 类型实际上是从有问题的文件所指示的 MIME 类型中获得的,可以很容易地修改它,使其看起来像是其他类型。Laravel 提供了一个 MIME 类型验证,我们可以使用它来尝试从文件的内容中猜测任何给定文件的 MIME 类型,而不是依赖于它的元数据。我们所要做的就是在表单请求对象的rules()方法中添加以下内容:

//in ddl/app/Http/Requests/UploadFileRequest.php

//replace the rule for userFile to look like the following in the
public function rules() {
//...
'userFile' => 'mimetypes:video/avi,video/mpeg,video/quicktime,image/bmp,image/jpeg,image/gif'
//...

重要提示:虽然这为我们的应用提供了额外的安全性,但作为一种折衷,我们确实损失了一些灵活性,因为所做的更改将文件的 MIME 类型限制为键userFile处的rules()方法中列出的受支持的视频或图像格式之一。这是一场永无止境的斗争,你将在现实世界中不断面对:安全与便利。我们将在后面的章节中更深入地讨论这一点。

刀片模板

我们需要的应用的最后一部分是在浏览器中实际显示表单并处理所有 Ajax 调用的部分,这些调用实际执行提交表单和将文件上传到服务器所需的逻辑。在ddl/resources/views/upload.blade.php位置创建一个新文件(列表 4-6 )。

<-- ddl/resources/views/upload.blade.php -->
<body>
   <h1>Upload a file</h1>
   <form id="uploadForm" name="uploadForm"
action="{{route('upload')}}" enctype="multipart/form-data">
       @csrf
       <label for="fileName">File Name:</label>
       <input type="text" name="fileName" id="fileName" required />
         <br />
         <label for="userFile">Select a File</label>
       <input type="file" name="userFile" id="userFile" required />
       <button type="submit" name="submit">Submit</button>
   </form>
   <h2 id="success" style="color:green;display:none">Successfully uploaded file</h2>
   <h2 id="error" style="color:red;display:none">Error Submitting File</h2>
   <script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
   <script>
        $('#uploadForm').on('submit', function(e) {
            e.preventDefault();
            var form = $(this);
            var url = form.attr('action');
            $.ajax({
                url: url,
                type: "POST",
                data: new FormData(this),
                processData: false,
                contentType: false,
                dataType: "JSON",
                success: function(data) {
                    $("#fileName").val("");
                    $("#userFile").val("");
                }
            }).done(function() {
                $('#success').css('display', 'block');
                window.setTimeout(()=>($("#success").css('display',
                'none')), 5000);
            }).fail(function() {
                $('#error').css('display', 'block');
                window.setTimeout(()=>($("#error").css('display',
                'none')), 5000);
            });
        });
   </script>
</body>
</html>

Listing 4-6The Blade Template

这是一个包含 HTML 表单和 JavaScript/jQuery 的刀片文件的典型示例,用于添加异步功能(因此我们可以调用服务器端点并从这些调用中接收数据,而无需刷新当前页面)。有一个基本的<form>标签,没有方法属性(我马上会解释),有一个奇怪的action属性,值为{{route('file.upload')}}。在刀片中,这就是所谓的指令。指令只是函数的一个花哨名字;有一些特定于刀片模板的函数,这些函数执行不同的操作,这些操作对于构建网页和 web 应用是常见的。为了更好地理解 blade 可以做的所有很酷的事情,请查看 https://laravel.com/docs/6.x/blade 。在前一个例子中,我们使用 route 指令为表单提交生成一个 URL。

请记住,我们之前在应用的web.php文件中定义了我们的路线,为每条路线指定了一个容易记住的名称。{{route()}}指令接受一个路由的名称,在内部缓存的路由列表中查找,并基于web.php文件中该路由的定义生成一个完整的 URL。对于第一种情况,我们指定希望表单将其提交的数据发送到/process端点,这被定义为 POST 路由。

您可能已经注意到的下一个奇怪的事情是开始表单标签下面的@csrf标签。在 blade 中,这个标签在表单上生成一个_token参数,在允许处理表单数据之前,在应用内部对这个参数进行检查。这可以确保表单中的数据来源有效,并防止跨站点请求伪造攻击。有关这方面的更多信息,请参见 https://laravel.com/docs/6.x/csrf

Note

如果我们在前面的模板中省略了@csrf标签,Laravel 将不接受表单,而是返回一个“419: Page Expired”错误,除非您将表单所在的特定页面添加到位于app/Http/Middleware/VerifyCsrfTokenVerifyCsrfToken中间件的$except属性中,就像这样(在我们构建的示例项目中不要这样做;这仅用于演示):

//inside VerifyCsrfToken.php
/**
 * The URIs that should be excluded from CSRF verification.
 *
 * @var array
 */
 protected $except = [
    '/payment-gateay-url'
 ];

现在,您应该能够使用以下命令启动本地开发服务器:

php artisan serve

这应该会给您一个类似于下面的消息:

Laravel development server started: <http://127.0.0.1:8000>

当您在浏览器中导航到此页面时,您将看到屏幕上显示的 Laravel 默认启动页面。继续并导航至http://127.0.0.1:8000/upload在屏幕上查看基本表单。填写某种类型的文件名,从您的计算机中选择任何类型的文件用于上传输入,然后单击 Submit。一旦你这样做了,你应该在屏幕上看到我们前面定义的成功消息,或者你可能会得到错误消息,如果事情不顺利(无论什么原因)。

需要注意的是,如果在提交表单后收到“403 : Unauthorized”错误,很可能是因为您忘记将表单请求的authorize()方法改为返回 true 而不是 false。

如果由于某种原因,您没有看到您期望看到的内容,或者如果表单过程由于某种原因失败,请确保您转录到自己的编辑器中的所有代码都是正确的。可能还有一些其他潜在的故障点,我们在编写这个功能时没有想到或预料到。这是对现实世界中 web 开发的准确描述。我们得到一个需求列表,通过对话和会议来确定确切的需求,设计满足这些需求的方法,并构建解决需求的解决方案的实现。当我们在软件中为一个请求编写规格说明时,我们不可能想到所有可能发生在这个请求上的事情。简直不可能。

相反,我们剩下的是依靠好的编程标准和最佳实践。在这种情况下,我们需要记住应用的概念是迭代开发,或者对应用进行小的、增量的更改,以在代码中充实所有的需求。

大局

让我们看看我们做了什么。

您应该还记得,我们在本教程开始时构建的请求对象应该在它的 rules 方法中定义了与 blade 模板中的表单相同的参数(如果没有,请重新阅读“创建验证逻辑”一节)。用户填写位于/upload的网页上的表单,该表单通过刀片模板引擎呈现并提交。模板底部的 jQuery 代码停止默认提交(它将自动重定向到表单的action参数中指定的页面),创建一个 Ajax POST 请求,加载带有表单数据和上传文件的请求,并将整个内容发送到我们的应用中,应用创建内部使用的请求对象,该对象被提供给包含该端点逻辑的 route 或 controller 方法。

通过将rules()方法中的参数与提交的表单参数相关联来填充请求对象,然后根据每个指定的规则验证数据。如果满足所有规则,请求将被传递给与路由文件web.php中定义的值相对应的任何控制器方法。在这种情况下,是UploadControllerprocess()方法在起作用。一旦我们点击控制器,我们已经知道请求通过了验证,所以我们不必重新测试给定的文件名是否实际上是一个stringuserFile参数实际上保存了某种类型的文件。我们可以像平常一样继续。

然后,控制器方法从请求对象中获取经过验证的参数,通过将传入的参数fileNameuserFile的原始扩展名连接起来生成文件名,将文件存储在应用的目录中,然后返回一个简单的 JSON 编码的响应,验证请求是否成功。

A Note on Security Concerns

问题:我们已经实现的系统(到目前为止)中,有没有哪个部分引起你的注意,成为错误或混乱的潜在来源?

答:除了很可能有效但未在此列出的其他问题之外,还有以下我们在应用中尚未考虑或防范的场景:

  • 安全问题

    • 应用中没有用户级的授权,尽管一个可能的解决方案是实现一个策略,我将在本章后面的“验证表单请求和使用策略”一节中的示例中简要讨论这个问题。

    • 表单请求中缺少指定的约束,尤其是围绕上载到服务器的文件。如果保持原样,将不会有检查来保证文件不包含恶意软件或某种类型的僵尸网络复制软件。检查扩展只能帮你完成一部分。这个问题的解决方案可以在本章前面的“使用 MIME 类型验证上传的文件”一节中找到。

  • 持久性问题

    • 一个不太明显的问题是表单中的另一个用户输入参数。fileName,必输字符串,是保存的文件进入系统后的对应名称。就目前的情况而言,用户可以提交任何有效的字符串,只要它既不是空的也没有被省略,我们的应用将很乐意接受它,验证字符串,并尝试使用它在给定的文件系统中保存文件。这个文件系统可能是一个 Dropbox 帐户,也可能是硬盘上的一个本地位置——我们不知道。因此,这些平台都对文件的命名方式、文件名的长度以及组成文件名的字符集有一定的限制。这个问题的一个解决方案是利用 Laravel 的漂亮的League\Flysystem\Util::normalizePath()方法,只需在定义了$filename变量之后,向UploadControllerstore()方法添加以下代码。
  • 建筑问题

    • 业务逻辑的位置(它存在于控制器本身中)抛出了一面红旗,它尖叫着“重构我”,因为它明显忽略了关注点的分离。与业务或底层领域相关的最重要的逻辑应该移到领域层内的其他地方。控制器位于应用层,而域逻辑应该被分成...嗯,一个领域层。

我鼓励你去看看非凡包联盟的 Flysystem 库中其他很酷的类似忍者的工具,我之前已经给你介绍过了。以防你忘记,这是他们的网站,特别是他们的 Flysystem 库,它帮助管理和执行对某些类型的支持文件系统的修改和添加: https://flysystem.thephpleague.com/docs/

$filename = League\Flysystem\Util::normalizePath($filename);

这一行简单的代码接受一个给定的字符串,并基于一个内部过程对其进行修改,该过程去除任何非法字符,并将字符串限制在特定长度,以便它可以用作正在上传的文件的有效名称。这样做的唯一缺点是,因为用户在表单中指定了文件的名称,所以他们会希望文件的名称与他们输入的名称完全一样,除非在提交文件后他们得到通知。我们可以通过多种方式解决这个问题,包括简单地通知用户文件以不同的名称存储,因为他们提供的名称无效,并且在响应中包含他们文件实际存储的名称。另一种解决方案是甚至不允许用户命名文件,而是生成文件名,然后将生成的文件名与拥有它的用户的 ID 和文件在文件系统上的位置一起存储在关系数据库中。清单 4-7 显示了一个可能的解决方案,它实现了给定上传的自动生成的 ID 号,而不是允许用户指定 name 参数。

//in ddl/app/Http/Controllers/UploadController.php

use App\UserUpload;
use Illuminate\Support\Facades\Auth;
//..
class UploadController extends Controller
{
     public function store(UploadFileRequest $request)
     {
           $file = $request->file('userFile')
           //save the file to the local filesystem, inside /uploads
           //*NOTE*: this also runs the MIME type check automatically:
           $path = $file->store('uploads');

           // $path will be a string returned from the store() method
           // corresponding to the saved path of the uploaded file
           $upload = UserUpload::create([
                 'user_id' => Auth::user()->id,
                 'filename' => $path,
                 //a way to track the source of the uploaded file
                  ‘form_id’ => $request->form_id //this is arbitrary
           ]);
           //return a success message
          return response()->json(['success' => true, ‘upload’ =>
                 json_encode($upload)]);
    }
}

Listing 4-7Possible Solution

jQuery 逻辑(驻留在 blade 模板中,在后面的示例中显示)接收响应,它执行一些与 UI 相关的任务,比如显示成功(或错误)消息五秒钟,然后隐藏它,并清除以前的表单条目。这是为了让用户确信请求成功,如果他们愿意,可以上传另一个文件。

另外,请注意图 4-1 中客户端和服务器之间的分界线。理解这个概念对您来说绝对是至关重要的,它将帮助您解决将来在处理各种问题时可能遇到的难题,例如,在任何给定的时间都可能出现多个异步请求。

通过一个请求对象,客户端关注点与服务器端关注点的分离就存在于我们应用的边界。请求对象本身可以被认为是客户端希望对我们的应用采取的操作,路由以某种方式使用这些操作来生成响应并返回给客户端(用户),从而完成请求/响应生命周期。它通过运行我们在FormRequestrules()方法中指定的验证,自动对从 web 浏览器传入的表单值进行初始验证和注册。

如果它们被认为是有效的,那么它们将被传递给控制器(或者路由定义的主体,如果它配置了闭包函数的话)。之前的一切都在前端(“客户端”字面意思是“在用户的计算机上”)。响应从应用返回到客户端,在客户端,我们的 jQuery 代码耐心地监听它的到达,并在收到响应后执行一些简单的 UI 任务,以便正确地通知用户请求成功或发生了错误。

其他注意事项

在本章的第一部分,我想把重点放在这个示例项目的核心功能上,以便让您清楚地了解这个过程是如何在一个较高的层次上完整地工作的,而不要过多地涉及这个项目在现实世界中开发时会遇到的细节和考虑事项。然而,在这样做的时候,我忽略了一些重要的部分,在现实世界的实现中,这些部分是需要纠正的。例如,在list()方法中,我们只是抓取特定目录中的所有文件,并将它们全部显示给最终用户。在现实生活中,我们显然不想公开显示其他用户的文件。在这种情况下,我们可能会选择以特定的格式保存文件,以便轻松地确定文件属于哪个用户。下面是一个文件名格式,它允许我们确定哪些文件属于哪个用户:

{user-specified-file-name}.{userId}.{extension}

通过将用户 ID 硬编码到文件名中,我们可以确定每个文件的所有者。然而,这种方法会有一些其他的问题。如果我们考虑到应用可能包含许多用户和数百个文件,我们将不得不遍历给定目录中的每个用户和文件,以便按所有者来分隔文件。更好的方法可能是允许每个用户拥有自己的私有目录,该目录以标准格式命名,可能包括他们的user_id或用户名来标识每个用户。这样,我们就可以按名称查找目录,并返回该目录中存在的所有文件的集合。

更复杂的方法是在关系数据库表中存储一条记录,该表基本上将每个文件链接到其对应的所有者(很可能通过user_id字段上的外键)。这将是我们解决这个问题的最好方法,因为我们不需要在一个目录上执行 glob 并遍历每个文件,然后检查哪个文件名与哪个user_id匹配,我们可以简单地发出一个查询来获取属于给定用户的所有文件名,并使用文件的路径来显示每个文件名的链接,然后用户可以单击并下载或接收关于这些文件名的元数据。

这是现实生活中的应用在某个时候会出现的东西(也就是说,将文件保存在磁盘上,甚至保存在离站存储中,并通过某种类型的接口管理文件))。 https://laravel.com/docs/6.x/filesystem#storing-files 的文档详细说明了如何在不同类型的文件系统(本地和远程)中存储和检索数据,如何处理和更改文件的元数据,如何上传和下载文件,以及如何通过文件权限管理对文件的访问。

在控制器内部仍然存在混合关注的问题。用于存储上传文件的逻辑与控制器方法内联。这是一个问题,因为我们已经认识到控制器只是一个接收请求和返回响应的地方;然而,在我们的例子中,情况显然不是这样。控制器处理上传文件的处理和存储中涉及的所有逻辑,在我们的例子中,这是核心域逻辑。我们将在本章和未来讨论解决这一难题的不同方法。

政策介绍

以下部分描述了在企业 web 应用中发现的与安全约束相关的常见问题,对于该问题,唯一明确的解决方案是实现某种健壮的用户管理和授权系统来处理用户管理,以及通过Role实体管理每个用户的权限。通常,在执行完成请求所需的逻辑之前,您需要知道用户是谁以及用户可以做什么。我们可以在请求对象中使用这个漂亮的小点(在authorize()方法中),作为在允许用户访问和提交特定表单之前正确检查用户类型的一种方法,但是如果我们想要实现这样的业务策略,比如在每个域模型的基础上进行授权,以便一个域类的任何给定实例都具有与相同类型的任何其他实例相同的安全设置集,那该怎么办呢?我们可以使用 Laravel 的政策做到这一点,我们将在接下来讨论。

Authenticating form Requests and using Policies

例如,假设您的应用支持一个企业、公司或其他一些大中型公司。在该企业中,有一个内置于应用核心的自定义身份验证层,该层具有一组定义的用户类型和一个相应的权限、角色和/或组表,它们共同定义了允许每个用户在应用中访问、查看和执行的所有操作。

比方说,这个应用管理不同医生使用的索赔提交流程,该流程允许他们为满足特定收入和贫困水平的人提供的医疗服务和治疗向联邦医疗计划收费。其工作方式是,患者与系统中的签约提供者预约,他们出现在医生的办公室并接受一些医疗需求的护理。提供护理后,医生获得服务报酬的方式是向完全合格的医疗保健中心(FQHCs)提交医疗索赔,该中心负责向医疗服务保护伞下的提供者支付报销费用。

联邦政府对不准确的索赔申请绝不手软,只有在百分之百准确的情况下才会接受和支付。为患者完成的所需数据、文件和程序(在称为 CPT 编码系统的复杂系统中建模)、患者信息、提供者信息以及一系列其他检查和平衡确保患者有资格接受护理之前,FQHC 将向提供者开出支票。

为了帮助这一过程,创建了一个应用,允许不同类型的用户登录到应用的不同部分,以便他们可以在不干扰系统其他用户的情况下完成工作。一组可用的权限决定了每个用户有权做什么或看什么。这些用户类型包括提供护理的医生、来自 FQHC 的负责管理付款的记账人、有权访问每份索赔以便“清理”并验证所有数据 100%准确的审核人,以及可以访问所有内容的系统管理员。

让我们假设我们负责适当地构建一个表单,该表单接受一组通常在这些医疗声明中找到的(虚假)数据。我们知道我们只想让医生和管理人员能够将医疗索赔表实际提交到系统中。医生显然需要提交索赔来获得他们的钱,管理员显然需要能够将假索赔发送到系统中进行测试。我们将如何着手做这样的事?

利用表单请求的 authorize()方法

如前所述,负责表示(和验证)索赔表单的表单请求可以基于用户类型进行限制,这可能发生在表单请求类本身内部。我们还将使用 Auth facade 来帮助我们完成这项任务,因为这是一种访问我们需要的关于当前登录用户的几乎所有信息的简单方法。

public function authorize()
{
    $user = Auth::user();
    switch ($user->role) {
    case 'Administrator':
    case 'Provider':
        return true;
        break;
    default:
        return false;
        break;
    }
    return false;
}

典型表单请求类中的这个authorize()方法首先获取试图提交表单的登录用户,然后检查该用户的角色属性(在本例中,该属性评估为一个简单的字符串,描述用户类型的英文单词),以查看该用户是管理员还是医疗提供者。

注意在我们的 Laravel 应用中,用户的角色很可能是存储在数据库中的一些记录。例如,假设 MySQL 数据库中有一个包含两个外键的user_roles表:一个表示用户的user_id字段和一个表示用户所属特定角色的role_id字段。根据应用的需要,可以为一个用户分配多个角色,也可以只分配一个角色。

如果用户是接受的用户类型之一,则该方法返回 true,请求被授权,将请求本身转发给路由中定义的控制器方法。如果除了用户拥有这两个可能角色中的一个之外,还有任何其他条件为真,则该方法返回 false,表单的执行将暂停,并出现一个异常,说明表单请求未被授权(或者,如果在生产环境中,异常信息和堆栈跟踪将被写入日志文件)。

让我们增加一些复杂性,假设系统中有一个额外的用户类型,对应于医生办公室内的办公室助理,他将已经提交的索赔的更新信息——比如说,更正信息——输入到索赔的表单中,而不是医生(这是常见的做法,因为医生的时间显然比通过计算机将数据输入到系统中更有价值)。只有当这些办公室助理被注册为特定办公室的一部分(即,在医生的工资单上)时,他们才被允许将索赔表提交到系统中并更新预先存在的索赔。这是为了防止不同办公室的助理代表他们不直接为之工作的医生更新报销申请。隐私在医疗行业是一件大事,需要满足某些措施来保护患者隐私和建立受保护的健康信息(PHI)准则。

我们需要我们的应用能够处理这种限制,并且只允许类型为OFFICE_ASSISTANT的用户提交办公室的表单,前提是他们是为该办公室工作的注册用户。我们可能会想跳回到前面请求类,并更新authenticate()方法来包含对该需求的额外检查。这样做的问题是,我们实际上无法访问系统用提交的表单数据创建的虚拟的Claim对象,也无法在调用authenticate()方法时将实际数据传递到请求中,因此我们无法验证数据是否来自提供者在同一帐户上雇佣的相应 office 助手。为了正确地构造这个身份验证特性需求,我们在某个时候需要登录的用户对象以及我们在相同的上下文中对照验证的对象,以便正确地检查它们并决定是否允许该用户提交声明。

Laravel Policies to Protect Resources

幸运的是,Laravel 附带了一个叫做策略的组件。策略是负责验证单个特定类型的域对象的类。它基本上是一种组织与特定资源或实体相关的任何给定身份验证或权限检查的方法。例如,为了创建一个核心业务对象的保护性包装(像Claim模型),我们将使用 Artisan 命令(就像我们通常在创建新的 Laravel 文件时所做的那样)来创建将成为ClaimPolicy的脚手架。

注意不要担心运行这些命令或者在这个部分中键入任何代码。它仅供参考,在这里使用是为了给你模型级安全性和策略主题的更多上下文。

php artisan make:policy --model=App\Claim ClaimPolicy

这个命令在目录ddl/app/Policies/UploadPolicy.php中为我们创建了一个UploadPolicy.php文件,看起来像清单 4-8 。

// ddl/app/Policies/ClaimPolicy.php

<?php
namespace App\Policies;
use App\User;
use App\Claim;
use Illuminate\Auth\Access\HandlesAuthorization;
class ClaimPolicy
{
    use HandlesAuthorization;
    /**
    * Determine whether the user can view any Claim.
    *
    * @param  \App\User  $user
    * @return mixed
    */
    public function viewAny(User $user)
    {
        //
    }
    /**
    * Determine whether the user can view the Claim.
    *
    * @param  \App\User  $user
    * @param  \App\Claim  $claim
    * @return mixed
    */
    public function view(User $user, Claim $claim)
    {
        //
    }
    /**
    * Determine whether the user can create claims.
    *
    * @param  \App\User  $user
    * @return mixed
    */
    public function create(User $user)
    {
        //
    }
    /**
    * Determine whether the user can update the Claim
    *
    * @param  \App\User  $user
    * @param  \App\Claim  $claim
    * @return mixed
    */
    public function update(User $user, Claim $claim)
     {
       //
     }
    /**
    * Determine whether the user can delete the Claim.
    *
    * @param  \App\User  $user
    * @param  \App\Claim  $claim
    * @return mixed
    */
    public function delete(User $user, Claim $claim)
    {
        //
    }
    /**
    * Determine whether the user can restore the Claim.
    *
    * @param  \App\User  $user
    * @param  \App\Claim  $claim
    * @return mixed
    */
    public function restore(User $user, Claim $claim)
    {
        //
    }
    /**
     * Determine whether the user can permanently delete the Claim.
     *
     * @param  \App\User  $user
     * @param  \App\Claim  $claim
     * @return mixed
     */
    public function forceDelete(User $user, Claim $claim)
    {
        //
    }
}

Listing 4-8A Generated Policy Class That Provides Authentication for Claim Objects

一般的概念是由应用中的两个标准对象组成的:一个用户对象(代表试图访问我们的资源的用户)和一个Claim对象(我们正在保护的资源)。支架代码已经为我们预先创建了所有的类型提示,因为我们在生成这个类时,通过初始命令传入的参数--model=指定了资源。策略中的所有方法都对应于可以在任何给定的Claim模型(业务对象)上采取的各种“动作”。我们有查看、更新、存储和删除Claim资源的方法。剩下要做的就是指定您希望每个可操作的场景如何按照所需的逻辑运行,以确定用户是否被允许做某事。

在这种情况下,如果用户的角色是管理员或提供者,或者当且仅当助理被视为同一提供者办公室的注册员工时,如果角色是Office_Assistant,我们希望仅允许存储给定的索赔模型。最初,我们将逻辑放在请求对象的authenticate()方法中,但是策略提供了一种更健壮、可定制的验证方法,在处理资源(业务对象)时,这种方法更适合用于更高程度的控制。

为了充分利用这个讨论,我们将把重点放在需求的更新部分。如果他们是 office 的注册用户,office 助手可以更新已经提交的申请。清单 4-9 展示了create()方法如何满足这个需求。

/**
 * Determine whether the user can update the Claim
 *
 * @param  \App\User  $user
 * @param  \App\Claim  $claim
 * @return mixed
 */
 public function update(User $user, Claim $claim)
 {
     switch ($user->role) {
          case 'Administrator':
          case 'Provider':
               return true;
               break;
          case 'Office_Assistant':
               $employeeManager = (new EmployeeManager());
               $providerOffice = $employeeManager->
                    findRegistrationFor($user);
               if ($claim->provider === $providerOffice->provider) {
                    return true;
               }
               Return false;
               break;
          default:
               return false;
     }
 }

Listing 4-9Possible Implementation of a Policy’s update() Action on a Given Claim Object

这个例子定义了一个update()方法,它采用一个User对象和一个Claim对象来确定请求是否应该被允许继续。如果用户的角色是管理员或提供者,那么他们可以更新特定的Claim。如果用户属于Office Assistant类型,那么在switch语句中有额外的内嵌逻辑来创建一个新的域服务实例EmployeeManager,,该实例又会找到助手注册到的提供商的办公室,并将其与附加到claim的提供商进行比较。只有当这些值相同时,应用才允许请求进入系统内部。稍后我将向您展示如何使用您自己的策略。

此外,我已经省略了与实际实现该策略相关的所有代码,但是现在只需要知道它们可以通过几种不同的方式实现,这取决于您要完成的任务的上下文。

  • 通过使用方法can()cant()User模型,这些方法接受要检查的模型以及该模型中对应于将用于授权检查的策略方法的动作

  • 在给定的路线上,通过中间件

if ($user->can(‘update’, $claim) {
     // perform update logic after the “update” method has
     // been called on the ClaimPolicy
}

  • 通过助手方法在控制器内部
Route::post(‘/claim/{claim}’, function (Claim $claim) {
     // perform update logic
})->middleware(‘can:update,claim’);

/**
 * Update the given claim
 *
 * @param Request $request
 * @param Claim $claim
 * @return Response
 * @throws \Illuminate\Auth\Access\AuthorizeException;
 /*
public function update(Request $request, Claim $claim)
{
     $this->authorize(‘update’, $claim);
     //the current user can update the claim
}

表 4-3 显示了控制器方法到其用于认证的相应方法的映射。

表 4-3

控制器方法到策略上相应方法的映射

|

控制器方法

|

路由定义的 HTTP 动词

|

政策方法

|
| --- | --- | --- |
| index() | 得到 | viewAny() |
| show() | 得到 | view() |
| create() | 得到 | create() |
| store() | 邮政 | create() |
| edit() | 得到 | update() |
| update() | 上传/修补 | update() |
| destroy() | 删除 | delete() |

注意,对于这些选项中的任何一个,Laravel 都会自动检查是否有针对给定模型的策略被请求访问;然而,如果没有,它将退回到AuthServiceProvider中定义的任何已定义的门验证关闭。

关于策略需要注意的另一件事是传递到策略方法中的域对象的类型,允许修改或删除该对象类型。传递给策略方法的对象类型是该策略所保护的模型(或资源)。这不要与资源模型混淆,资源模型是使用特定于特定模型上的动作的资源控制器来定义的。

关于它们的另一个真正伟大的事情是它们支持雄辩的关系,使得以直接和非介入的方式转换对应于其他对象的关系的对象变得容易。我们将在本书的后面部分触及所有这些内容。

设计 API 优先的应用

此时,我们知道了大多数请求细节(比如它们是什么以及如何验证它们),所以我们现在可以开始在脑海中看到应用的整体结构。我们已经包含了表单进入我们的应用时我们所期望的表单的正确定义,这是您将与之交互的数据类型的一个很好的高层次概述。这是构建应用的方法,所以如果你不知道从哪里开始一个项目,API 是一个很好的选择。

在本书的后面,我们将讨论如何使用 Laravel 中提供的设施和管道设置来构建和实现一个实际的 API,但是为了吊起你的胃口,我在侧边栏中加入了一个叫做 API 优先设计的东西。

API-First Design and the Open API Specification

在专业的 web 开发中,API 优先的设计与我在本章中描述的过程有很大的不同。这样做的原因是因为我想让你熟悉拉勒韦尔流动的方式;做到这一点的最佳方式是允许您专注于特定的概念元素组,而不被更多的架构实践和模式分散注意力。随着这本书的进展,我们将在适当的时候了解这些。

主要区别在于,在 API 优先的设计中,通常从创建 API 将遵循的模式开始,以便完成模型所需的各种应用任务。这通常是以一种与语言和数据库无关的方式完成的,因此您最终得到的是 API 层的严格准则,下至诸如所请求的参数类型之类的本质细节,甚至是进入和离开应用的请求和响应的整个定义结构,通过端点分组到类似的功能焦点。您首先定义 API 中的所有端点,然后编写一行实际代码来使用它。

开放的 API 规范和吹嘘

在现实世界中,有多种方法可以做到这一点。我向你们提出的一个解决方案是利用所谓的开放 API 规范,就像 SwaggerHub ( https://swagger.io/tools/swaggerhub/ )这样的 API 设计工具应用所使用的那样。在您自己的应用中开始使用 Swagger 需要一定的学习过程,但是花时间学习是非常值得的。SwaggerHUB 的优点在于它提供了一种独特的方式来查看您在 API 中定义的数据结构(使用开放 API 规范作为一种手段)。它还提供了一系列很酷的特性,比如快照创建、版本跟踪、分叉/合并和发布特性。版本跟踪在团队环境中特别有用,因为它使得团队的所有成员在 API 标准建立时更容易使用最新版本。

这里有一些我觉得非常酷的东西:当从不同角度查看 API 并导航到特定定义时,SwaggerHub 提供的可视化非常有用,它们都是使用开放 API 标记语言生成的。学习如何定义您自己的应用中需要的各种结构、请求和响应需要一些时间,但是这是非常值得的。一旦掌握了窍门,您就可以快速定义对应用的成功至关重要的 API 设计细节。使用像 SwaggerHub 和 Open API 规范这样的工具,您可以保证定义是有意义的(因为它大约每五秒钟被解析和验证一次,重新生成对应于路由定义的可视化工具),并且您不会重复每个特定请求和响应中涉及的实体的定义。

您只需定义一次这样的结构(从组件/模式节点中),然后就可以在 API 中的任何端点、请求或响应定义中引用它们。如果你引用了一个没有定义的特定结构,你会得到一个友好的错误标记,解释为什么会有这个问题。验证是有帮助的,因为您为应用指定的 API 定义由客户端实现精确地遵循,这意味着您为自己的 API 布局的定义、类型和数据结构必须是准确的。

*一旦您对组成您的 API 的所有结构的定义感到满意,那么您就可以将更改“发布”到一个特定的已发布版本,防止对该版本的进一步修改,除非创建了一个新版本(例如,使用形式为 version.major.minor 的增量版本控制)。您还可以指定使用哪个版本作为默认版本,这样您就可以拥有尽可能多的“进行中”版本,同时强制团队使用某个特定版本来开发使用该 API 的应用的其他区域。

中间件

中间件是一个在 PHP 世界获得广泛支持的概念,特别是在 Laravel 等 MVC 框架和 Node's Express 等其他服务器端框架中。中间件被用作过滤进入应用的 HTTP 请求的机制,它们可以用于您可能想做的几乎任何事情,无论是之前、之后还是之间。它们通常在 web 应用中用于各种有效和合法的目的,包括:

  • 认证和授权检查

  • 会话验证和会话变量的修改

  • 读取、设置或修改请求和响应头(对应于中间件之前和之后)

  • 记录事务和 API 调用

  • 重定向和内部站点“流”定制,并中断标准的请求/响应生命周期以完全替换其中任何一个

  • 发出或响应特定的领域事件

  • 修改对应用的每个调用的请求或响应,或者只在特定的端点上,从特定的 IP 地址修改请求或响应,这些 IP 地址也限制请求,因此它们必须包括具有适当值的有效请求头,以及您可能需要的几乎任何其他安全问题

  • 更加

作为另一个例子,在 Laravel 中,从应用内部运行的检查和平衡(可以这么说)通过一个必需的认证会话(使用 Cookie 头)来确保请求的有效性和真实性,通常实现为某种类型的令牌认证,以限制应用中不应该对系统的非用户可用的部分。Laravel 使用中间件在整个系统中建立几乎所有的认证操作。

要获得 Laravel 应用中默认配置的中间件的高层次概述,您可以查看 Laravel 应用的Kernel类,它位于/ddl/app/Http/Kernel.php,是 Symfony 著名的HttpKernel实现的扩展,几乎是所有现代 web 应用框架中事实上的标准Kernel

// ddl/app/Http/Kernel.php

<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
    /**
     * The application's global HTTP middleware stack.
     *
     * These middleware are run during every request to your application.
     *
     * @var array
     */
    protected $middleware = [
        \App\Http\Middleware\TrustProxies::class,
        \App\Http\Middleware\CheckForMaintenanceMode::class,
        \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
        \App\Http\Middleware\TrimStrings::class,
        \Illuminate\Foundation\Http\Middleware\ /
        ConvertEmptyStringsToNull::class,
    ];
    /**
     * The application's route middleware groups.
     *
     * @var array
     */
    protected $middlewareGroups = [
        'web' =>
      \App\Http\Middleware\EncryptCookies::class,
        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
       \Illuminate\Session\Middleware\StartSession::class,
    // \Illuminate\Session\Middleware\AuthenticateSession::class,
       \Illuminate\View\Middleware\ShareErrorsFromSession::class,
       \App\Http\Middleware\VerifyCsrfToken::class,
       \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],

        'api' => [
            'Throttle:60,1',
            'Bindings',
        ],
    ];
    /**
     * The application's route middleware.
     *
     * These middleware may be assigned to groups or used individually.
     *
     * @var array
     */
    protected $routeMiddleware = [
        'auth' => \App\Http\Middleware\Authenticate::class,
        'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
        'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
        'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
        'can' => \Illuminate\Auth\Middleware\Authorize::class,
        'guest' =>
\App\Http\Middleware\RedirectIfAuthenticated::class,
        'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
        'throttle' =>
\Illuminate\Routing\Middleware\ThrottleRequests::class,
        'verified' =>
\Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
    ];

    /**
     * The priority-sorted list of middleware.
     *
     * This forces non-global middleware to always be in the given order.
     *
     * @var array
     */
    protected $middlewarePriority = [
        \Illuminate\Session\Middleware\StartSession::class,
        \Illuminate\View\Middleware\ShareErrorsFromSession::class,
        \App\Http\Middleware\Authenticate::class,
        \Illuminate\Routing\Middleware\ThrottleRequests::class,
        \Illuminate\Session\Middleware\AuthenticateSession::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
        \Illuminate\Auth\Middleware\Authorize::class,
    ];
}

Kernel类提供了特定中间件到给定路由类型的分配。我在这里使用路由类型来表示 web 路由和 API 路由。相比之下,我使用路由组来指代在一个中央路由文件中定义的单个路由组。每个中间件都有一个快捷语法,您可以在前面的代码中看到,它对应于为每个路由类型定义的数组键。我们将在本章末尾讨论这两种路由类型的区别。

清单 4-10 展示了一个中间件的例子。看一下文件dll/app/Http/Middleware/RedirectIfAuthenticated.php 该中间件对应于web路由配置组中的guest键(列表 4-10 )。

// dll/app/Http/Middleware/RedirectIfAuthenticated.php

<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Support\Facades\Auth;

class RedirectIfAuthenticated
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @param  string|null  $guard
     * @return mixed
     */
    public function handle($request, Closure $next, $guard = null)
    {
        if (Auth::guard($guard)->check()) {
            return redirect('/home');
        }
        return $next($request);
    }
}

Listing 4-10An Included Middleware in Laravel That Redirects Users Already Authenticated to the /home Route

中间件很方便,因为它位于进入应用的请求和域模型(或应用的核心)之间,它可以充当任何类型的身份验证/授权检查、缓存机制、应用日志记录、会话/cookie 参数的机制,或者通过 CSRF 保护检查来确保表单的有效性以防止 XSS 攻击。这些都是如何在 Laravel 应用中调用和建立这些东西的例子。然而,给定特性的特定实现的实际定义、协作和依赖关系通常位于其他类或组件中。中间件只是实现那些列出的关注点的一种机制,但不应该直接内联实现它们相应的逻辑。相反,设计架构以在域层中容纳这样的问题,然后您可以从特定的中间件或控制器方法中调用它,但是只有在您正在处理的问题的上下文中这样做才有意义。

保持这种关注点的分离允许我们更好地组织应用的核心逻辑,并给我们一个清晰的边界,即哪个逻辑需要在哪个层,甚至当域模型被实现为一系列跨越多个有界上下文的独立和专门的模块时,允许更细粒度的控制,并且这些有界上下文中的每一个都可以被认为是其封闭模块的一部分。这有助于在域级别上组织代码,这正是我们想要的。

另一个需要注意的重要事情是,这个机制在进入应用的每个请求以及应用发出的每个响应时都会被触发(调用)。当然,有一些方法可以配置中间件,使它们只“激活”应用的一部分,而不是每个请求/响应,但实际上处理请求的额外过滤的方法是添加确定是否应该运行或跳过中间件的逻辑——该逻辑应该放在中间件定义的主体内,并且应该立即返回下一个可调用的中间件(中间件之前),或者在检查执行之前或之后在堆栈上的中间件主体的末尾(中间件之后)。在本书的稍后部分,我将向您展示如何配置前中间件和后中间件。

如果您不希望中间件在每个请求上触发,您也可以指定一个路由或路由组来强制使用特定的中间件,就像清单 4-10 中的那样。将特定的中间件分配给应用的相关部分,还有什么比使用路由配置更好的方法呢?因为我们使用特定于路由的 URIs 将所有不同的部分(组件)组合在一起,所以 Laravel 提供了一种直观和简单的方法,使特定的中间件只在特定的路由被客户端找到时才运行。

虽然我们还没有深入到在您自己的应用中实现中间件的细节,但是您对它们有一个基本的了解是很重要的,这样您就可以在本章的下一节中讨论路由文件。Laravel 中有两个主要的中间件组,对应于中间件实际涉及的范围,默认定义为:API 中间件组和 web 中间件组。这些组可以在位于此处的服务提供商内进行配置(和更改):app/Providers/RouteServiceProvider。如果你打开这个文件看一看,你会注意到一个基本的map()方法调用了同一个类中的两个默认方法:mapWebRoutes()mapApiRoutes()。这两个中间件组由两个 URIs 确定,这是内部使用的机制,用于将给定的中间件映射到其激活的相应路由组(表 4-4 )。

表 4-4

Laravel 的默认中间件配置

|

中间件组名称

|

路由前缀

|

有效路线示例

|

路由文件

|
| --- | --- | --- | --- |
| Api | /api/ | /api/users/create | routes/api.php |
| Web | / | /about | routes/web.php |

在典型的真实场景中,应用很可能包含其 API 的不同版本,这可以通过向一组选定的路由添加路由前缀来表示,该前缀指示给定客户端使用的 API 版本。以下是 API 路由端点的典型示例:

基于 Web 的路由的 Web 中间件➤

web 中间件组在routes/web.php文件中配置,被认为是用户通过浏览器与之交互的应用的“公共”范围。默认情况下,它对应于所有不以/api开头的 URIs,这通常是 web 应用中 URIs 的大部分。任何以/(除了/api)开头的东西都被认为是网络路由组的成员,因此有更多的公共设置,在这种情况下,允许非登录用户查看标准的网络路由。最基本的例子是应用的主页或它的“关于”页面。

用于基于 API 的路由的 API 中间件➤

无论如何,我们都不希望应用的所有 URIs(路线)对未经身份验证的用户(来宾)可用。应用的 API 也是如此,它通常包含已定义的 REST 接口,允许客户端访问和修改系统或域的核心部分。例如,如果我们要在我们的应用中建立一个新用户帐户的实现,我们会希望将 URIs (routes) 放在之后,以便在未经身份验证的用户尝试一些恶意行为时提供安全手段(并且您总是必须假设他们会尝试这样的事情)。在这种情况下,我们可能会指定一个/api/users路由,它对应于一个 API 的 REST-ful 实现,允许该路由根据 HTTP 动词执行所需的功能,通过基于路由的闭包或者引用 Laravel 的快捷语法中定义的控制器方法来引用特定控制器上的单个方法。

这在 Laravel 中很容易配置,稍后我将向您展示细节。现在,只要确保您理解什么是中间件,并认识到可以(并且已经)通过 Laravel 应用中的每个中间件组配置的东西的类型。稍后我们将回到中间件的概念。

Laravel 工作简介

本节介绍了作业的概念,它基本上是一组封装到单个对象中的事务或操作,其内部流程是为了解决领域级别的业务问题而创建的。作业可以像命令一样使用,可以在控制器中轻松调用,可以放在队列中,可以用 supervisorD 监控,以便并发处理它们(即异步)。您可以为现代任务和消息队列 sch 设置一个流行的选择,如 RabbitMQ、Kafka 或 ActiveMQ,以异步方式处理各个任务的通信、管理和报告,以便操作看起来更加流畅,等待时间比同步处理少。然而,对于这个示例,我们将只创建一个同步实现,并将作业的结果立即返回给调用代码(在控制器中),而不是使用使异步处理如此流行的“设置好就忘了”技术。我们将深入研究如何使用消息队列。

Using Laravel Jobs to Encapsulate Business Logic

正如我们现在所知道的,在 MVC 架构中,控制器的目的应该仅仅是与客户端握手,将任何域工作委托给域层(域层依次一次处理一个或使用直接使用域层的应用服务),并返回指示给定请求成功或失败的响应。也就是说,我们最初设计的UploadControllerstore()方法包含了处理、命名和存储上传文件所需的所有逻辑。这个域逻辑应该封装在控制器方法体之外的某个地方,一种可能的方法是创建一个作业。

要生成新的作业支架,请使用以下命令:

php artisan make:job SaveUploadedFile

这个命令将在app/Jobs/SaveUploadedFile.php产生文件内的代码(列表 4-11 )。

// ddl/app/Jobs/SaveUploadedFile.php
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class SaveUploadedFile implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * Create a new job instance.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }
    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        //
    }
}

Listing 4-11Scaffold Code Generated from make:job Command

这个类非常简单,只包含两个方法:构造函数和handle()方法。构造函数被用作依赖注入的一种方式。完成这项工作所需的任何附加对象都在构造函数的签名中进行了类型提示,并由 Laravel 的依赖注入组件自动解析。事实上,如果你需要更多关于完成特定任务的对象的定制,你可以指定你想要如何使用 Laravel 的服务容器构建注入服务或作业的对象(在 https://laravel.com/docs/master/container 阅读)。我们将在本书的后面讨论服务容器的更高级的用法。

构造函数也是传递来自请求的任何数据的地方,这些数据是完成封装在该作业中的任务所需要的。因为我们关心的是存储用户提交的文件,所以我们需要包含任何曾经内嵌在控制器的store()方法中的参数,并将它们作为单独的项放在Job类的构造函数中。我们不会将整个请求对象传递给构造函数;这是不好的做法。

清单 4-11 中的第二个方法是handle()方法,它是作业定义的核心。这是调用作业时主逻辑运行的地方。它基本上利用了您在构造函数中指定的任何对象(这些对象也应该作为类成员参数包含在作业定义中)。当作业被分派时(通常是从控制器),它会在构造函数中注入任何需要的东西,并调用handle()方法。让我们看看保存用户上传的文件并将生成的文件 ID 存储在数据库中以跟踪哪个用户拥有哪个文件的作业是什么样子的。

<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Symfony\Component\HttpFoundation\File\File;
use App\UserUpload;
class SaveUploadedFile implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    protected $fileName;

    protected $upload;

    /**
     * Create a new job instance.
     *
     * @return void
     */
    public function __construct(File $upload)
    {
        $this->upload = $upload;

    }
    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
           //save the user’s file and grab its path on the filesystem
           $path = $this->upload->store('uploads');
           //create a record to track user’s uploaded files
           $upload = UserUpload::create([
                 ‘user_id’ => Auth::user()->id,
                  ‘filename’ => $path,
           ]);

           //do a final verification check that the saved file exists
           if (!is_file($path)) {
                  throw new Exception(“Problem with saving the file”);
     `}

           return $path;
     }
}

有了这个新的作业,我们可以修改我们的控制器,消除任何与文件存储相关的问题,而是简单地将该任务委派给新的SaveUploadedFile作业。通过一个静态方法,这可能看起来是这样的(我们实际上使用了 Laravel 中所有作业类附带的内置方法):

public function store(UploadFileRequest $request)
{
     $file = $request->file('userFile');
     $filePath = SaveUploadedFile::dispatchNow($file);

     return response()->json(['success' => true]);
}

正如您所看到的,我们的控制器方法在大小和复杂性上都有所降低,因为我们现在将存储上传文件的任务委托给了一个作业类,而不是与处理请求的控制器方法内联。现在我们有了一个清晰的关注点分离,加上我们正在实践一个叫做意图揭示接口的东西,在这里我们以一种清楚地表明它们的目的的方式命名类、对象和参数,同时也尽可能清楚地定义和揭示对那些参数采取的动作。

在现实世界中保持质量

管理这种迭代开发过程并帮助您专注于解决系统的领域问题的最佳方式是采用在持续集成技术的应用中发现的概念,这样您就可以不断地用高质量的代码来开发软件,这些代码包括单元测试以及自动测试机制,您设置这些机制是为了在每次提交代码库或每次从拉请求进行合并时运行。这有助于确保您编写的新代码不会破坏任何旧的功能,并为您提供一条在应用用于生产后进行升级和维护的清晰道路。不破坏旧的代码对于维护高质量的 web 软件是至关重要的,一个可靠的 CI/CD 管道肯定会有所帮助。

总的来说,我在本章中概述的过程是这样的:

设计领域和架构➤生成通用代码➤定制通用代码➤重申系统设计➤重构新见解。

从更高的层面来看,它看起来更像这样:

设计➤原型➤实现➤重构。

增量变化的概念是几种流行的编程范例的核心基本点。极限编程(XP)和敏捷开发都依赖于开发的迭代周期给 web 应用开发工作带来的价值。你甚至可以在 http://continuousiteration.com 找到关于这个主题的博客(不是“持续集成”,而是“持续迭代”)。所有好的域名都被占了,所以我试图想出一个意思相同,听起来接近相同的东西,这就是我想出来的。不讨厌。

结论

在这一章中,我使用 Composer 包管理器解决了依赖关系,检查了 Laravel 在本地系统上的安装。之后,我们看了一个使用 Laravel 构建的示例应用,了解了 Laravel 应用中涉及的主要组件。我向您展示了如何使用 Laravel 附带的 Artisan 命令行工具来为这些组件生成脚手架代码,然后我们使用它们来实现我们需要的功能。我们以与真实场景中大致相同的方式完成了这个例子:我们创建了一个天真的实现来满足系统的需求,然后我们返回并在设计中加入了一些额外的想法,并意识到它缺少一些重要的东西并违反了一些重要的规则。

我们用代码制作了模型的原型,很清楚它并不完美。我们慢慢地开始一遍又一遍地去除商业模式的弱点或不准确的表达,直到我们结束修补所有的漏洞。最终,这给我们留下了一个工作的软件,它与构建它的商业模型相一致。

在我们为上传应用构建了实现的粗略草案之后,我们回顾了应用的结构和组件的一些额外的考虑,还讨论了初始版本的一些缺点。您学习了如何尽可能地减少这些缺点,并对代码的结构和逻辑进行调整,让您(我希望)对如何处理基本请求、验证传入的请求参数、使用作业封装业务逻辑、使用控制器作为完成请求/响应生命周期的手段,以及使用 Laravel 提供的其他一些工具有一个坚实的了解。

现在你有了这样的理解,我们可以进入这本书的实质,讨论我们实际上在领域驱动的 Laravel 方面正在尝试做什么;然后,我们将解决如何在现实世界中实现它。***

五、高级 Laravel

在这一章中,我们将探索 Laravel 的一些更高级的主题。我们将关注 Laravel 的特性、工具和快捷方式,它们与本书中使用的示例和支持代码直接相关。这并不意味着对 Laravel 的每一个特性都有一个深入的、包罗万象的解释,当你需要澄清 Laravel 的任何东西时,你应该直接参考 Laravel 的文档。

出于我们的目的,我们将关注以下主题:

  • Laravel 应用的周期(流程)

  • 服务提供商

  • 服务容器和$app变量

  • 队列和 Laravel 作业

  • 契约

  • 事件

在您对 Laravel 中的一些更高级的特性有了更好的理解之后,我们将在本章结束时把所有的主题联系起来,讨论一些高级的方法来构建不同的部分以满足我们的应用的需求。Laravel 中的工具可供您随意使用。我们希望最终实现的不仅仅是拥有一组可供我们使用的工具,而是以一种允许我们为需要运行以满足客户请求的各种命令和服务形成一个可靠的开发管道的方式来定位这些工具。我们只需要根据领域驱动的方法来使用它们,以打造一个坚实的、可重用的基础,我们可以在以后再次推动额外的需求。可以将这个管道想象成一组可重复的步骤,这些步骤实现了一些与领域相关的任务或过程,这些任务或过程可以重复无数次,以实现类似的结构,但支持完全不同的指令。它们并不是一成不变的,但是当您从事使用 Laravel 编写的真实项目时,它们应该会为您提供足够的指导。

Laravel 应用的周期

Laravel 通过在框架引导期间发生的一系列操作以可预测的方式运行。所有传入的请求首先命中/public/index.php文件(有时称为前端控制器,它加载 Composer 自动加载器(/vendor/autoload.php ) ,然后从bootstrap/app.php文件加载应用。该文件采取的第一个动作是创建一个Application实例(服务容器的一个实例)。看起来是这样的:

// ddl/app/bootstrap.php
$app = new Illuminate\Foundation\Application(
    $_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
);

这段代码创建了 main Application类的一个新实例,它接受 Laravel 应用的位置作为参数。这可以在.env文件中配置,该文件保存了整个应用中使用的主要配置值。这些值将被 Laravel 获取并注入到应用的各个部分。在前面的例子中,如果$_ENV['APP_BASE_PATH']没有被定义(即不在.env文件中),它将默认为当前目录。

根据请求的类型(是来自普通浏览器还是通过控制台命令),Laravel 将分别利用app\Http\Kernel类或Console/Kernel类。无论使用哪个Kernel类,Laravel 都会将传入的请求传递给内核,然后内核会为特定环境加载适当的配置和设置。内核定义了应用中使用的所有中间件,并将它们注册在框架的Application对象中的一种类似堆栈的结构中。Laravel 中的中间件层很重要,因为它根据流程中使用的Kernel类的类型和进入应用的请求类型,为应用提供了被请求的特定环境。有两种主要类型的请求:web 请求(基于浏览器)和 API 请求(基于 HTTP 动词)。

Laravel 中的中间件负责处理各种任务,并设置 Laravel 的一些基本功能。这些包括但不限于以下内容:

Web 请求中间件

  • 会话设置

  • Cookie 加密

  • CSRF 保护

  • 证明

API 请求中间件

  • 节流请求

路由中间件

  • 缓存头

  • URL 签名验证

  • 批准

路由中间件可以附加到路由文件中定义的单个路由或路由组(主要是/routes/app.php/routes/web.php)。中间件还负责确定应用是否处于维护模式,如果是,则将用户重定向到临时维护页面。

所有这些中的关键角色是Application内核。在高层次上,内核的handle()负责两件事:

  • 接收请求

  • 返回响应

内核是现存的几乎所有主要 PHP web 应用框架的基础,实际上最初是由 Symfony 框架开发的,也是为 Symfony 框架开发的。在决定使用哪个内核之后,还要执行几个额外的引导任务,包括读取环境变量、加载在/config文件夹中定义的配置、注册应用外观以及引导服务提供者。

服务提供者在config/app.php文件中指定,并由应用通过分别运行每个提供者的register()方法和每个提供者的boot()方法来加载。我们稍后将更深入地讨论服务提供商。加载完所有服务提供程序后,请求将被发送到路由器,路由器将根据各自的 routes 文件中的配置进行调度。路由器接受请求,并将其转发给指定的控制器进行处理,或者甚至可以在路由定义内内联处理请求的内容。我们在前一章中介绍了一些路由原则,但只是重述一下,路由将请求转发给指定的控制器进行处理和操作,然后返回某种响应(无论是响应 API 调用的 JSON 对象还是显示在浏览器上的完整网页)。

服务提供商

服务提供者拥有框架的所有主要特性,可以说是框架中最重要的方面。默认提供程序位于app/Providers目录中。下面是 Laravel 文档中关于服务提供者的描述:

“服务提供者负责引导所有框架的各种组件,比如数据库、队列、验证和路由组件。因为他们引导和配置框架提供的每一个特性,所以服务提供商是整个 Laravel 引导过程中最重要的方面。”

—拉勒维尔文档

从这句话中,我们可以得出结论,服务提供者是定义功能配置细节的特定部分的手段,以便它可以被框架拾取和识别,然后通过自动加载或服务容器绑定的方式供应用的其余部分使用。您可以将服务提供者视为将应用结合在一起的粘合剂,并允许对框架和应用进行扩展和添加。框架本身也利用它们来注册较低级别的组件,这些组件构成了 Laravel 提供的特性集。一些服务提供者被设置成只在需要的时候才被加载(称为延迟加载延迟加载)。

每个服务提供者包含两个主要的方法:register()boot()

寄存器()

register()方法是任何服务提供者在引导框架时首先调用的。在这里,您可以包含服务提供者的任何逻辑,这些逻辑可以在不使用任何其他服务提供者的情况下完成。register 方法在服务容器被完全实例化之前运行,并为服务提供者提供服务的应用的任何部分执行任何先决逻辑。

您应该只将东西绑定到服务容器中,比如类或服务,而不应该将东西从服务容器中取出(因为在每个提供者上调用register()方法时,它还没有完全构建或实例化。如果您需要使用其他服务,或者需要使用已经绑定到容器的东西,或者注册任何路由、监听器或类似的东西,请使用boot()方法。

清单 5-1 为某个应用提供了一个示例ServiceProvider,该应用实例化了一个用于与某个 Redis 数据库接口的类。

// An example service provider

<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Predis\Client;

class RedisServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
          $this->app->singleton(Redis::class, function ($app) {
               return new Client(
                    config('database.redis.options.default'));
          });
    }
}

Listing 5-1Service Provider’s register() Method

在清单 5-1 中,我们有一个标准的服务提供者,它定义了一个单一服务(单一服务是一个特定的类,在任何给定的时间只能存在一个)。该服务通过使用在redis键下的/config/database.php中定义的配置创建一个实例来使用Predis\Client类,如清单 5-2 所示。

// /ddl/config/redis.php
return [
     // ...
      "redis" => [
     "client" => "phpredis",
     "options" => [
          "cluster" => "redis",
          "prefix" => "laravel_database_",],
          "default" => [
          "url" => null,
          "host" => "127.0.0.1",
          "password" => null,
          "port" => "6379",
          "database" => 0,
          ],
         "cache" => [
           "url" => null,
           "host" => "127.0.0.1",
           "password" => null,
           "port" => "6379",
           "database" => 1,
         ],
      ]
]
];

Listing 5-2The Default Configuration for a Redis Connection, Located in app/config/database.php

如果您还没有猜到,config()方法的参数对应于指定配置文件(.php)中特定设置的位置,该文件位于app/config目录中,它返回一个单一的多维数组,其中的键对应于配置值所对应的配置“节点”。database.redis的第一部分,即句点左边的所有内容(即database),对应于文件名。第二部分是句点左边的所有内容,对应于从该文件返回的数组的键。在前面的例子中,它指的是config/database.php文件中的redis['options']['default']键。

启动()

boot()方法中,您可以包含任何依赖于其他配置、服务或系统其他方面的逻辑。它在服务容器被 Laravel 实例化之后运行,因此,它被允许使用其他服务提供者提供的任何其他功能或服务容器中存在的东西。

当服务提供者需要依赖项来配置应用的这一部分时,您可以将它们注入到boot()方法中,它们将作为参数自动传入供您使用,如清单 5-3 所示。

<?php

class ClaimServiceProvider {
     // properties and register() method

     public function boot(ClaimRepository $claimRepository, CptCodeRepository $cptCodeRepository)
     {
     $claim = $claimRepository->findBy('patient_id', 3345);
     $cptCode = $cptCodeRepository->whereIn('cpt_code',
     $claim->cpt_codes);
     //... additional logic
     }

}

Listing 5-3Example boot() Method in a Service Provider

这要归功于 Laravel 的服务容器,我们将在接下来讨论它。我们将开始讨论 Laravel 的服务容器的许多不同方面,这些方面使它不同于您过去可能使用过的任何其他服务容器(它使大多数服务容器相形见绌)。一个ServiceProviderboot()方法实际上可以用来配置系统的任何方面,并且因为服务容器在这个方法被调用时是完全加载和准备好的,所以你能够在系统中注入任何依赖或者利用任何你想要的其他服务。一开始,您可能会发现使用服务容器有些困难,因此,如果您不知道如何将所有内容插入其他内容,也不用担心。在尝试编写自己的服务提供者或尝试为服务的依赖项建立注入策略之前,请关注我们在本章中讨论的高级主题。

服务容器

Laravel 的服务容器确实非常出色,使用它的好处也是让 Laravel 成为如此出色的框架的部分原因。整个应用中的依赖注入由服务容器处理,就像“服务”的管理和定义一样(因为没有更好的词),通过实例化对象或以特定的方式解析依赖。

重要的是要注意,简单的依赖关系,比如那些不需要任何额外的配置或参数被实例化的依赖关系,是由服务容器自动处理的,并且是在代码中没有任何额外逻辑的情况下注入的。清单 5-4 显示了文档中服务容器的一个例子。

<?php
namespace Claim\Submission\Application\Controllers;

use App\Http\Controllers\Controller;
use App\Repositories\UserRepository;
use App\User;
class UserController extends Controller
{
    /**
     * The user repository implementation.
     *
     * @var UserRepository
     */
    protected $users;
    /**
     * Create a new controller instance.
     *
     * @param  UserRepository  $users
     * @return void
     */
    public function __construct(UserRepository $users)
    {
        $this->users = $users;
    }

    /**
     * Show the profile for the given user.
     *
     * @param  int  $id
     * @return Response
     */
    public function show($id)
    {
        $user = $this->users->find($id);
        return view('user.profile', ['user' => $user]);
    }
}

Listing 5-4Controller with Dependencies Injected That the Service Container Can Resolve by Itself

$app 变量

在 Laravel 中,$app变量不仅仅是一些普通的配置对象或数据容器,而是应用在任何给定时间的全局状态。如果您可以访问$app变量,那么您就可以访问几乎每一个组件、配置设置、特定于框架的功能,以及注册到服务容器的任何对象或服务,因为它实际上就是服务容器。所有的服务提供者都有一个在他们的父类中设置的$app属性。

Tip

请务必查看课程Illuminate\Support\ServiceProvider;它的源代码位于/vendor/laravel/framework/src/Illuminate/Support/ServiceProvider.php。在这个抽象的父类中有许多有趣的事情,包括它如何加载视图、路由、语言设置、配置、命令以及系统的重要功能和设置的其他方面。这些是系统中每个服务提供者继承的默认参数和行为,因此非常值得花额外的精力去阅读和理解源代码。

$app变量还用于将类和对象“绑定”到服务容器,以抽象这些类和服务的设置、引导或前不变检查,从而为它们的实例化提供手段。将东西绑定到服务容器的方法是编写一个ServiceProvider,它将处理设置/配置服务所涉及的所有方面,并提供一种方法来获取服务上下文中包含的对象的特定实例。理想情况下,但不总是这样,这些类分组和实现应该与领域保持一致。也就是说,我们希望使用核心领域中涉及的思想、概念、业务规则和不变量作为区分我们的类、模块和任何其他概念的指南。

Note

我使用术语 service 来指代一个特定的上下文或者一组类,它们组成了一个特定的特性或者完成了一个特定的目标。它比模块粒度大,比单个对象或类粒度小。

不继承接口或不需要任何配置或附加参数来实例化的简单对象可以由服务容器使用反射来构建(清单 5-5 )。像这样简单的事情不需要服务定义。

<?php
namespace Claim\Submission\Application\Services;
class ClaimNotificationService
{
     protected $claimRepository;

     public function __construct(ClaimRepository $claimRepository)
     {
          $this->claimRepository = $claimRepository;
     }
     public function doStuff()
     {
          // do something with $this->claimRepository...
     }
}

class ClaimRepository extends Repository
{
     public function findBy($field, $value)
     {
          return Claim::where($field, $value)->get();
     }
}

Listing 5-5Service Requiring a Dependency on a ClaimRepository Class That Can Automatically Be Resolved Using Reflection

在清单 5-5 中,要获得ClaimNotificationService的一个实例(不需要手动提供它的依赖关系,内联到你实际使用的地方)需要做的事情如下:

use Claim\Submission\Application\Services\ClaimNotificationService;
// anywhere in the code where you would have access to $app — like a
// service provider, or anywhere the helper method app() is available
$claimNoticationService = $this->app->make(
     ClaimNotificationService::class);

如您所见,我们可以简单地将类名传递给$appmake()方法来解析我们需要的特定类实例,而无需手动指定依赖关系。在幕后,这是通过反射实现的,我们不会在本书中讨论。现在,只需要知道后端发生了一些神奇的事情,允许服务容器解析简单的依赖关系,就像前面的例子一样。

绑定到服务容器

根据官方文档,系统中的大部分绑定将在服务提供商内部完成。有不同的方法可以将某些东西绑定到容器,以便您可以在以后使用它(只需很少的前期工作),但整个想法基本上是设置如何实例化服务或类;然后,不需要在多个客户端上下文中再次手动完成所有这些工作,您只需引用服务容器的特定绑定名称,它将完全按照您定义的那样在服务提供者内部构建您的服务或类。它基本上是你需要的任何类或对象的定义实例的注册表,以及如何正确配置这些类的说明。

如果服务提供者实现了接口\Illuminate\Contracts\Support\DeferrableProvider,那么关于如何构建特定对象的指令将只能按需运行和获取(即延迟加载)。如果服务提供者没有实现这个接口,那么每次运行服务容器时,都将构建有问题的对象并将其存储在内存中。

假设我们有一个名为CoolService的类,它有一个方法doCoolThing(),我们想把它绑定到容器上以便于访问。要将单个类或对象绑定到服务容器中,我们将使用:

$this->app->bind('CoolService', \App\Services\CoolService::class);

以后当我们想要使用这个很酷的服务时,我们必须像这样从服务容器中提取它:

$coolService = app()->make('CoolService');
$result = $coolService->doCoolThing();

如果出于某种原因,CoolService类需要额外的设置或配置才能被实例化,只需提供一个闭包,封装设置它所需的代码(这将在下一节中详细介绍)。

使用服务容器

举个例子,我们希望清单 5-5 中的通知服务能够支持 SMS 通知、电子邮件通知和 Slack 通知。我们不想在每个应该相同的类之间重复代码。确保这一点的一种方法是抽象出通知的交付机制(在本例中我称之为处理程序)。

处理这种情况的一种方法是在通知程序服务中添加一个方法,该方法将接受要发送的特定类型的通知。然后,我们可以将通知服务的整个“如何”部分封装到一组三个行为中,每个行为对应一种类型,这些行为将根据传递给这个新方法的类型参数进行加载。这个理论上的类图的 UML 看起来类似于图 5-1 。

img/488324_1_En_5_Chapter/488324_1_En_5_Fig1_HTML.jpg

图 5-1

一种可能的面向对象设计方法是使用抽象来解决拥有多种通知类型的需求

基本上,我们有两个定义的接口,NotificationNotificationHandlerAbstractNotification实现了Notification,并添加了由ExceptionNotificationInfoNotification类扩展的通知“类型”和“处理程序”的概念。这些类代表了通过send()方法发送消息的方法,但是依赖于图左侧的另一组类来实际完成发送部分。图 5-1 中的Notification处理程序接口有三个子类,每种消息类型一个。每个类都有一个handle()方法,负责将消息传输到所需的媒介(一个 Slack 通知、一封电子邮件或一条文本消息)。抽象类使用接口,这样就可以很容易地交换实现来替换交付机制,而不会破坏应用的其他部分。我们已经用这个设计很好地封装了通知系统的变化。

现在我们已经有了通知系统的总体架构,为了发送一条消息而实际实例化这样一个系统的代码似乎有点多。例如,按照现在的情况,对于您想通过应用发送的每条通知消息,您必须运行类似于清单 5-6 中的代码。

// example of using the above design
$context = $request->notification->isError() ? 'exception'
                           : 'info';
$notificationType = $request->notification->type;
switch ($notificationType) {
     case 'sms':
          $notificationHandler = new SmsNotifierHandler();
          break;
     case 'email':
          $notificationHandler = new EmailNotificationHandler();
          break;
     case 'slack':
          $notificationHandler = new SlackNotificationHandler();
          break;
     default:
          throw new InvalidNotificationTypeException();
}
if ($context == 'exception') {
     $notificationContext = new ExceptionNotification();
} else if ($context == 'info') {
     $notificationContext = new InfoNotification();
}
//finally, send our message
$notificationContext->setNotificationHandler($notificationHandler)->
     send($request->notification->message);

Listing 5-6A Possible Usage of the Design in Figure 5-1 as Done by Hand, Not Using the Service Container or Dependency Injection

这只是一个不切实际的解决方案。我们如何使用 Laravel 的服务容器来帮助我们呢?

我们可以创建一个 FQCN 的别名(使用我们想要的任何名称空间,即使它完全是虚构的),它将指向InfoNotificationExceptionNotification,然后自动调用其对应的setNotificationHandler()方法来设置正确的类型。清单 5-7 展示了一个例子,它使用闭包作为定义逻辑的手段来构建期望的对象/服务。(这很可能在AppServiceProvider内部完成,它是整个系统用于应用关注和配置的提供者。)

$this->app->bind('SlackExceptionNotifier', function($app) {
     $notificationContext = new ExceptionNotification();
     $notificationContext->setNotificationHandler (new
          SlackNotificationHandler());
     return $notificationContext;
});

Listing 5-7Example Service Container Binding for the Aforementioned Notification System

基本上,我们在这里所做的就是在服务容器中的标识符SlackExceptionNotifier下创建一个条目,当运行时,它将执行匿名函数中包含的逻辑并返回结果,在本例中是一个完全实例化、许可和认证的通知对象,供您用来发送 Slack 通知。下面是它的用法示例:

$slackExceptionService = app()->make('SlackExceptionNotifier');
$slackExceptionService->send("Some slack message");

请注意,这不是处理本例中提到的用例的最佳方式,主要是因为您必须编写总共六个服务(三个通知类型*两个通知上下文)来处理每种可能的组合。在现实世界中,这可能有些过头了;然而,它服务于我们所需要的目的:演示在服务容器中绑定和检索对象/服务。除此之外,不要过多地研究它,因为即使在最初的系统设计中,你也可能会发现缺陷。

对象和服务不是唯一可以绑定到容器的东西。您还可以选择绑定单例类,这意味着在应用中的任何给定时间,该类的对象只有一个副本。

$this->app->singleton('ClaimsApi', function ($app) {
    return new Claim\Application\Api($app->make('ClaimHttpClient'));
});

您还可以将特定的实例绑定到容器,并期望从容器中检索到相同的实例后,将它返回给您。

$notifier = new ExceptionNotification();
$notifier->setNotificationHandler(
     new SlackNotificationHandler()
);
$this->app->instance('SlackExceptionNotifier', $notifier);

如您所见,我们简单地实例化了一个ExceptionNotification的实例,并使用容器的instance()方法将该实例存储在标识符SlackExceptionNotifier下,这是您在检索'SlackExceptionNotifier':的实例时作为参数传递给$appmake()方法的内容

app()->make('SlackExceptionNotifier')->send('some slack message');

将接口绑定到实现

这可以说是 Laravel 服务容器最酷的地方,也是这个框架如此强大和灵活的原因。因此,我们现在知道了如何将一些东西绑定到服务容器,比如类、实例和对象,这些东西需要创建额外的逻辑,并且可以在系统中的任何地方使用。

服务容器还有另一个特性,允许您将接口绑定到实现。这个功能强大的原因是,它允许您动态地传递一个具体的类,该类充当给定接口的实现。一旦设置好,Laravel 将为应用提供该实现,只要它实现的接口在应用中被引用。这允许您拥有单个接口的不同实现,并允许您通过简单地修改服务提供者内部的一行代码来交换实现。

要将实现绑定到接口,可以使用以下语法:

$this->app->bind('Claim\Submission\Domain\ClaimRepositoryInterface',
     'Claim\Submission\Infrastructure\Repositories\ClaimRepository');

Laravel 将会看到这段代码,并在它找到对Claim\Submission\Domain\ClaimRepositoryInterface的引用的任何地方自动注入实现(Claim\Submission\Infrastructure\Repositories\ClaimRepository)。这是一个强大的特性,也是 Laravel 如此强大的部分原因。

我们来看一个例子。假设您有一个开源代码库,其中包括一个需要持久化的图书模型,以跟踪图书的信息,如作者、ISBN 和其他普通图书的定义特征。您还有模型的消费者,他们使用图书信息进行研究和跟踪。问题在于,因为代码意味着共享,所以您不知道图书信息将被持久化的确切方式。大多数情况下,用户会利用 MySQL 来满足其存储和持久性需求,但也有一些用户更愿意将书籍存储在 Redis 甚至其他非关系型持久性机制中,如 MongoDB 和 Elasticsearch。您不知道用户最终会将哪一个用于自己的实现,所以您需要考虑所有三种变化,以使您的代码对其他人非常有用。

我们可以做的是创建一个接口来处理书籍的持久性,然后为每个客户端的特定持久性机制实现该接口。这样,我们可以为每个客户提供他们需要与我们的图书模型交互的特定入口点。一旦我们定义了我们的实现,我们只需要将接口与服务容器中所选机制的实现绑定,Laravel 将为我们处理剩下的事情!

图 5-2 提供了该系统的示意图,以便于说明。

img/488324_1_En_5_Chapter/488324_1_En_5_Fig2_HTML.jpg

图 5-2

一个接口 BookRepository 及其可能的实现,每个实现都特定于一个给定的持久层

这看起来相当不错:对于我们能想到的每一种持久性机制,我们都有一个BookRepository的实现(显然还有更多,但这也适用于演示)。唯一缺少的是告诉应用使用哪个存储库实现的设置。有很多方法可以解决这个问题(这个列表并不全面)。

  • .env文件中提供一个BOOK_REPO_TYPE参数,该参数将通过一个配置文件获取并加载到应用的内存中

  • 让用户通过AppServiceProvider直接在服务容器中硬编码他们的特定存储库

  • 让用户在单独的服务提供者中指定存储库

无论您决定以哪种方式集成一个特定的存储库版本,您都可以通过在服务提供者中定义来配置存储库的类型,这样它就可以在任何找到作为类型提示的BookRepository接口的地方使用该存储库(清单 5-8 )。

$bookRepoType = "RedisBookRepository"; // this is derived from one of
                                       // the methods listed above
$this->app->bind('Domain\Books\BookRepository',
     'Interface\Repository\RedisBookRepository');

Listing 5-8The Service Container Configuration Needed to Support All Possible Repositories in the Application

一旦我们准备好了,剩下唯一要做的事情就是确保我们不直接依赖于任何子存储库类(清单 5-9 )。

// ..some controller
// EXAMPLE OF WHAT NOT TO DO
public function generateBookList(
     RedisBookRepository, outputFormat="csv") {
          // do stuff
}

Listing 5-9An Example of What Not to Do...Rely on a Concrete Method

除了系统无法工作这一明显问题之外,这段代码还违反了编程中的一条重要原则。不要依赖具体,要依赖抽象。为了在前面的代码中实践这个原则,我们可以简单地用更抽象的东西替换掉RedisBookRepository,这样我们就可以利用这个generateBookList()方法。BookRepository类非常适合这里,因为它包含了所有的实现,同时将实际的细节留给了子类。代替清单 5-9 中的代码,使用一个更粗糙的类定义作为参数将产生清单 5-10 中的代码。

public function generateBookList(
     BookRepository, outputFormat="csv") {
          // do stuff
}

Listing 5-10A Better Approach to Defining the Dependency of a Book Repository

Note

你能找出清单 5-9 和清单 5-10 中不同的原则吗?我给你一个提示:不止一个,它们都属于坚实的原则。

需要记住的重要一点是,像这样的定义应该只在必要的时候使用,因为十有八九,简单地用适当的依赖关系类型提示参数就足够了。这被称为自动注入,大多数时候,你甚至不需要告诉 Laravel 如何构建一个特定的对象...它将通过反射自行确定。

队列和 Laravel 作业

在 Laravel 中,您可以选择合并一些需要在Job类中运行的逻辑。您可能希望异步运行该作业,或者甚至将该作业推迟到以后运行。一个队列通常是一些外部队列系统,比如亚马逊 SQS、RabbitMQ、Redis 或 MySQL。Laravel 提供了将这些服务连接到您的应用的快速简单的方法。这些服务的配置可以在config/queue.php文件中找到。虽然在本书的后面我们不会深入到设置队列系统,但是我们将使用作业来封装一次性任务,并且您可以使用队列系统来处理这些作业。有关为一个受支持的队列系统配置驱动程序的更多信息,请参见位于 https://laravel.com/docs/6.x/queues .的 Laravel 文档

如前所述,队列只是一堆等待被某个已配置的队列系统分派和处理的作业。队列通常以一种易于识别的方式分组,即特定队列处理哪种作业,例如“默认”或“主要”将作业推送到不同队列的能力以一种更易于管理的方式对作业进行了分段,并有助于确定哪里需要这些作业。对于大型应用来说尤其如此,在这些应用中,有多个作业在不同的时间出于不同的原因而启动。有时,您需要执行的作业是资源密集型的,可能需要很长时间才能运行(至少从用户的角度来看)。由于 PHP 是一种解释型语言,所以可以使用队列来发送作业并立即向用户返回响应,这样用户就不会在那里等着想为什么您的应用或页面“坏了”

响应时间和页面加载时间不仅对用户体验很重要,而且对谷歌搜索引擎排名的网站质量也很重要。响应时间长的网页通常是应用中的错误来源,作为一名开发人员,我负责的许多调试项目都与“永远”需要加载的代码库部分有关。当这种情况发生时,队列系统可以解决由于加载时间过长而产生的许多问题。

队列如何工作

简而言之,队列根据选定的队列驱动器进行操作。如前所述,Laravel 内置了对许多队列管理器的支持。您可以将队列驱动程序看作是一个处理作业分派并跟踪它们的驱动程序,这样它们就不会运行超过一次(也是为了记录已经分派的作业)。除此之外,作业本身可以包含处理特定任务所需的所有逻辑。如果您需要分派一个事件,以便数量为 x 的侦听器能够听到它并相应地采取行动,那么您应该在那个handle()方法中这样做。如果您需要在当前任务之后分派另一个任务,只需将该逻辑放入任务的handle()方法中。您可以将任何需要的依赖项作为构造函数参数,分配给您的Job类的属性,这样当代码到达handle()时,它们就可以运行了。

调度作业时,如果不指定队列标识符(队列名),它将默认为“默认”队列,使用您在配置中启用的任何队列驱动程序。那个驱动程序真正做的只是告诉 Laravel 在哪里存储已经处理的作业和当前在堆栈上的作业。当使用“数据库”队列驱动程序时,作业将全部存储在名为jobs的 SQL 表中。下面是如何在您的config/queue.php文件中配置它:

'default' => env('QUEUE_DRIVER', 'database')

但是,默认情况下不包含该表,因此您需要运行创建该表所需的命令,如下所示:

php artisan queue:table

这将创建一个名为_create_jobs_table.php的迁移,以当前时间戳为前缀。之后,您需要运行migrate命令在数据库中创建表。

php artisan migrate

要创建作业,可以发出以下 Artisan 命令:

php artisan make:job JobClassName

这将在App\Jobs中产生一个JobClassName类(记住这是默认目录,不一定是我们将在本书中使用的目录)。为了创建作业实际做的内容,您只需将任何依赖项注入到它的构造函数中,并将您想要执行的逻辑放在handle()方法中。Laravel 会自动将依赖项直接注入到作业中,供您在handle()中使用。

例如,清单 5-11 显示了一个用于更新某个患者的主治医师的作业。

class ChangePatientPhysician implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    protected $patientProviderRepository;

    /**
     * Create a new job instance.
     *
     * @param  Patient $patient
     * @param  Provider $newProvider
     * @return void
     */
     public function __construct( PatientProviderRepository
          $patientProviderRepository)
    {
        $this->patientProviderRepository = $patientProviderRepository;
    }
    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle(Patient $patient, Provider $newProvider)
    {
     //verify this patient is registered under this provider
     $isRegistered = $this->patientProviderRepository
                          ->patientRegisteredFor(
                     $patient, $provider);
     if ($isRegistered) {
          $patient->provider()->associate($provider);
          $patient->save();
     } else {
          throw new PatientNotRegisteredWithProviderException();
     }
     }
}

Listing 5-11An Example Laravel Job That Accepts $patient and $provider Objects, Then Modifies the Patient’s Set Provider to Be the One Passed into the Handle Method

要分派前一个作业,可以使用清单 5-12 中的语法。

// most likely in a controller somewhere
public function updatePatientsProvider($patient, $provider)
{
     //dispatch the job, passing in parameters that correspond to the
     //signature of the Job's  handle() method
     ChangePatientPhysician::dispatch();
     }

Listing 5-12Dispatching a Job from a Controller Method

正如您所看到的,在 Laravel 中创建和分派作业是轻而易举的事情,它使用底层的队列驱动程序连接来促进作业通过工作队列。如果出于某种原因,您不想使用队列,而是希望作业立即运行,那么您可以使用dispatchNow()方法。

ChangePatientPhysician::dispatch();

在本书的后面,当我们为Claims模型构建 API 时,我们将大量使用 jobs。

拉勒维尔合同

Laravel 附带了一长串标准化接口,包括任何 web 应用中使用的一些最常见的模式、类和组件。契约都存在于Illuminate\Contracts名称空间中,框架提供的特性、工具和支持是契约的具体实现。你也可以在 GitHub 的 https://github.com/illuminate/contracts .找到它们,你可以在Illuminate\名称空间的某个地方找到每个契约的实现。例如,清单 5-13 显示了一个描述命令总线的契约,它是由 Laravel 实现的,用来调度作业,就像上一节讨论的那样。

// Illuminate\Contracts\Bus\Dispatcher
namespace Illuminate\Contracts\Bus;
interface Dispatcher
{
    /**
     * Dispatch a command to its appropriate handler.
     *
     * @param  mixed  $command
     * @return mixed
     */
    public function dispatch($command);
    /**
     * Dispatch a command to its appropriate handler in the current process.
     *
     * @param  mixed  $command
     * @param  mixed  $handler
     * @return mixed
     */
    public function dispatchNow($command, $handler = null);
    /**
     * Determine if the given command has a handler.
     *
     * @param  mixed  $command
     * @return bool\
     */

    public function hasCommandHandler($command);
    /**
     * Retrieve the handler for a command.
     *
     * @param  mixed  $command
     * @return bool|mixed
     */
    public function getCommandHandler($command);
    /**
     * Set the pipes commands should be piped through before dispatching.
     *
     * @param  array  $pipes
     * @return $this
     */
    public function pipeThrough(array $pipes);
    /**
     * Map a command to a handler.
     *
     * @param  array  $map
     * @return $this
     */
    public function map(array $map);
}

Listing 5-13Laravel’s Contract for a Standard Command Bus

这里我们有一个描述命令总线的基本接口,包括实现要定义的类所需的方法,以及每个类在注释掉的文档块中应该做什么的细节。通过浏览这个接口,我们可以很快推断出任何实现它的东西都将定义有dispatch()dispatchNow()hasCommandHandler()getCommandHandler()pipeThrough()map()方法。Laravel 用来处理调度命令和任务的实现太长了,不能放在这本书里;然而,我强烈要求在 https://github.com/laravel/framework/blob/6.x/src/Illuminate/Bus/Dispatcher.php .检查一下。注意,这个类实际上用一个方法dispatchToQueue(),扩展了另一个接口,这是QueueingDispatcher接口所需要的。

不要与嵌套接口混淆。从版本 6 开始,Laravel 改进了契约的接口和实现方式,但从长远来看,它们更有意义,有逻辑结构。他们不得不这样做,看起来好像他们都以某种形式被用于产生框架本身的特性和功能!尽管我们不会在本书中大量使用契约,但它是理解的一个重要基础。

事件

Laravel 中的事件是一个强大的工具,可以让应用的其他区域知道发生了一些有趣的事情。事件的另一面是侦听器,它遵循基本的观察者模式。侦听器(观察者)基本上只是简单的类,它们被设置为在应用中发生特定事件(被观察到)后运行。请注意,我在提到事件时使用了过去式,这是正确的,因为从技术上讲,所有事件都发生在过去。你看的最后那句话,其实现在已经是过去了。因此,所有事件都应该有它所做的的过去式版本。以下是一些常见事件的例子:

  • UserWasRegistered

  • AccountWasDeactivated

  • ChangedPatientsPrimaryPhysician

  • ClaimWasSubmitted

  • SomethingHasHappened

你明白了。

所有事件配置都在app/Providers/EventServiceProvider.php文件中指定。该文件中的类有一个名为$listen的受保护属性,它是包含每个事件类型及其后续事件侦听器的数组。默认情况下,该属性中有一个元素(清单 5-14 )。

protected $listen = [
        Registered::class => [
            SendEmailVerificationNotification::class,
        ],
    ];

Listing 5-14The Default Event Listeners That Come with Laravel

这意味着“一旦由Registered类定义的事件发生,启动包含在SendEmailVerificationNotification."中的监听器,你可以在开发过程中手动将你的事件和它们对应的监听器一起添加到这个数组中。另一个更好的选择是简单地添加事件(使用完全限定的名称空间)和它们相应的监听器(观察者),如清单 5-15 所示。我创建它仅仅是为了演示,而且我没有在$listen数组中包含任何类。

protected $listen = [
     //...
     ClaimWasSubmitted::class => [
          NotifyAccountManager::class,
          SendEmailSubmissionConfirmation::class,
          \Infrastructure\Services\UpdateElasticSearch::class,
     ];
     ];

Listing 5-15Example $listen Array Inside the App\Providers\EventServiceProvider Class

根据您的喜好更改EventServiceProvider之后,您可以运行下面的 Artisan 命令来生成包含在$listen数组中的所有类,在它们各自的默认文件夹(App\EventsApp\Listeners)中或者在 FQCN 中指定的目录中(假设该目录已经使用composer.json ) 中的自动加载器进行了正确配置):

php artisan event:generate

Laravel 的股票事件和听众

另一种思考方式是将其与时事通讯的工作方式进行比较。时事通讯将有一个接收它的订户(即听众)列表,因此当作业被分发(即分派)时,只有列表上的订户将接收它,但是对于世界上的其他人来说,它甚至不存在。侦听器显然会包含一些一旦事件被调度就要执行的逻辑。

例如,在清单 5-16 中,我们可以推断出有一个Registered事件,一旦一个新用户在系统中注册,该事件很可能会被分派,并且一个名为SendEmailVerificationNotificationListener会监听该事件,该事件很可能会向该用户发送一封确认电子邮件(假设他们的电子邮件是注册页面上的一个表单)。通过查看监听器的源代码,我们看到(当然),Laravel 已经考虑到了这一点(清单 5-16 )。

<?php

namespace Illuminate\Auth\Listeners;

use Illuminate\Auth\Events\Registered;
use Illuminate\Contracts\Auth\MustVerifyEmail;

class SendEmailVerificationNotification
{
    /**
     * Handle the event.
     *
     * @param  \Illuminate\Auth\Events\Registered  $event
     * @return void
     */
    public function handle(Registered $event)
    {
        if ($event->user instanceof MustVerifyEmail && !
            $event->user->hasVerifiedEmail()) {
                 $event->user->sendEmailVerificationNotification();
        }
    }
}

Listing 5-16Example $listen Array Inside the App\Providers\EventServiceProvider Class

从这段源代码中我们可以清楚地看到,它监听了由类Illuminate\Auth\Events\Registered定义的Registered事件。它还具有仅当用户在注册期间提供电子邮件时才发送通知的逻辑。发送通知所需的所有逻辑都包含在侦听器的handle()方法中。它是自封装的。

从 Laravel 5.8.9 开始,您可以配置所谓的自动事件发现,而不是在EventServiceProvider中手动注册事件。当它被激活时,Laravel 将扫描您指定的任何监听器目录,并将任何监听器自动注册到事件中。每当 Laravel 遇到一个带有已定义的handle()方法的监听器类时,Laravel 会将这些方法注册为事件监听器,用于该方法签名中类型暗示的任何事件(清单 5-17 )。

<?php

use App\Events\PodcastProcessed;
class SendPodcastProcessedNotification
{
    /**
     * Handle the given event.
     *
     * @param  \App\Events\PodcastProcessed
     * @return void
     */
    public function handle(PodcastProcessed $event)
    {
        // do work here
    }
}

Listing 5-17Example Listener That Has the Corresponding Event Type-Hinted in the handle() Method

在前面的事件中,当 Laravel 点击这个类并看到handle()方法时,它将向侦听器PodcastProcessed注册事件(SendPodcastProcessedNotification)。要配置这个特性,您需要覆盖EventServiceProvider中的shouldDiscoverEvents方法(清单 5-18 )。

public function shouldDiscoverEvents()
{
    return true;
}

Listing 5-18Enabling Event Auto-registration in Laravel

要指定希望 Laravel 扫描哪些目录来自动注册事件(默认情况下只包括App\Listeners目录),您可以用希望 Laravel 扫描的侦听器的路径覆盖discoverEventsWithin()方法(清单 5-19 )。

public function discoverEventsWithin()
{
     return [
          'App\Listeners',
          'Domain\Listeners'
     ];
}

Listing 5-19Specifying Locations Where Laravel Will Scan to Auto-register Events

事件是通过它们使用的一个名为SerializesModels的特征生成的。这一特性使得事件可以很容易地序列化传递给它的任何有说服力的模型。如果你触发的事件应该停止其他事件的传播,只需从handle()方法返回false

如果您正在使用队列,您可以通过向您的监听器类添加一个ShouldQueue特征来获得该功能。有关定制特定队列的更多信息,请参见 https://laravel.com/docs/6.x/events#queued-event-listeners .

调度事件

要分派事件,使用event()助手,向其传递Event类的名称。

$podcast = Podcast::find(345);
event(new SendPodcastProcessedNotification($podcast));

事件通常用于促进应用中的松散耦合,并有助于保持事物的分离。例如,不是直接从注册控制器发送电子邮件确认,从而耦合电子邮件逻辑和注册逻辑,而是能够从控制器调度一个事件,SendEmailConfirmation监听器将拾取该事件并相应地采取行动,这提供了一个完全解耦的解决方案,使您的注册逻辑专注于注册。这是事件和监听器的一个常见用例,Laravel 提供的功能非常有用。

结论

本章涵盖了 Laravel 的一些优点,我们将在本书的其余部分使用它们来实现一种合理的领域驱动设计方法。通过利用 Laravel 作业、解耦事件和监听器,以及 Laravel 的标准请求/响应流和框架的所有特性,我们可以将它们串联起来,创建一个标准化的操作“流”,以封装我们的业务逻辑,并允许我们创建分离和松散耦合的组件。我们将在本书的后面更深入地讨论我们将如何创建我们的业务流程的整个流程。

现在,考虑一下 Laravel 中可用的不同组件如何很好地满足一个标准的现代 web 应用的需求。Laravel 本身是建立在依赖注入容器之上的,它可以根据应用的需要基于每个服务进行配置。我们还可以使用服务容器将特定类型对象的特定实例绑定到代码中引用该对象接口的任何位置。这为我们提供了一种灵活、干净、简洁的方式来管理依赖关系,这也是 Laravel 的服务容器如此强大的部分原因。Laravel 作业非常适合封装域模型中定义良好、自包含的一次性任务,这种方式可以轻松地与队列系统集成,以支持作业的异步处理(在 Laravel 中也很容易做到这一点,您将在后面的章节中看到)。当应用中发生有趣的事情时(比如一个作业被分派到一个队列中,然后实际运行),我们可以使用一个Event来标记该作业的发生,然后通过编写一个Event监听器在应用中的任何地方对该事件做出反应。

六、构建索赔处理系统

本书中有两个核心概念,它们是使用 Laravel 作为主干框架成功设计和实现模型驱动、架构良好的 web 应用的关键。这本书的结构有时可能看起来有点奇怪,但我试图在您需要的所有先决知识与您将使用这些知识创建的实际实现之间取得平衡,同时兼顾这两个重要主题。坦率地说,有很多东西需要学习,这取决于你开始阅读这本书时的专业水平。我深入研究了 DDD 中涉及的各种概念以及与 Laravel 相关的潜在对应代码,我认为在您的实际应用中实现这些想法和概念时,这种方式对您最为有益。我将通过为您提供这些概念将成为良好用例的情况,让您对这些概念有一个坚实的理解,这样您就可以使用 Laravel 框架作为实现领域驱动设计的一种方式来尝试自己的实现,无论是在您自己的项目中还是在您公司的项目中。以下是选择 DDD 中涉及的各种模式的良好用例,以便我们可以充分探索它们为我们的开发工作带来的价值:

  • 掌握每个模式的核心用途,以及使用每个模式的利弊(无论该模式是来自 DDD 的技术、灵活性还是战略支柱)

  • 帮助解决你在野外可能会遇到的问题(我也遇到过)

  • 让您对应用的内部工作原理有一个清晰的认识,并帮助您使用 DDD 模式定义应用的高级结构,以细化各种领域层组件的特性

  • 当使用 DDD 的技术模式作为实现核心组件的一种方式来构建核心组件时,要考虑架构问题,同时也要记住,它们只是简单的可能性,而不是具体的解决方案

此外,请记住,在没有适当考虑其对系统其余部分的潜在影响的情况下,不应该放弃任何想法,这是基于用业务中无处不在的语言描述的核心业务规则。几乎总是有不止一种方法来做某事,这些潜在的解决方案通常不会在没有几个小时的讨论、计划、原型、意识到你在这个过程中搞砸了一些事情,然后在你获得了对该领域更细粒度部分的额外洞察力之后纠正错误的情况下出现。这是一个不小的壮举,并且将(大部分时间)需要大量的先决学习、提问、记录和重构,以真正获得对领域模型的有价值的洞察力。

也就是说,可能有(而且很可能是)比我在书中概述的方法更好的方法,我向你发出挑战,让你成为提出这种方法的人。我用在 DDD 发现的信息、最佳实践和技术模式的知识武装你,你可以在你的工作项目或你自己的项目中使用它们来创建一个结构良好的应用,它具有一个丰富的模型,充分地捕获业务所表达的逻辑和规则,以做它所做的来赚钱。我发现的最好的方法是给你一些好的例子,以及用来得出潜在解决方案的思考过程——所有这些都清晰地展示出来,这样你就可以看到我为达到最终目标所采取的步骤。如果您能够理解技术考虑(DDD 结构的一个支柱)并使用模型和架构设计技术(DDD 的另一个支柱)作为使用这些技术模式的手段,那么您可以使用这些模式作为您的工具来构建新的需求或特性(第三个支柱),这是用于在 DDD 中表达领域模型的技术方面和结构。

也就是说,这一章像胶水一样把我建议用来构建 web 应用软件的两个中心概念粘在一起(提示:它们在这本书的标题里)。我将向你介绍一些我自己有幸解决的现实生活中的问题。我们将扩展我在前一章给你的医疗索赔例子,讨论我们将使用 DDD 构建块设计的各种主题(即,那些与领域驱动设计的技术模式相关的主题),我们将使用 Laravel 实现构建块模式作为实现的手段。我们将以 API 优先、测试驱动的方式来全面建立核心业务对象和在这些对象上运行的流程,并且我们将小心遵循我们所知的最佳实践和标准来编写任何高质量、可维护的代码。

我们将把 DDD 的教导作为一种将质量融入软件的手段。具体来说,我们将重点关注以下内容:

  • 开发一个干净的、语言无关的 API,它将清楚地描述我们正在构建的系统的一部分,给企业一个有用的领域模型

  • 为涉及的业务术语、操作和领域对象开发一个精炼的、良好的定义(无处不在的语言)

  • 简要定义 DDD 的核心概念(工具)

    • 仓库

    • 服务

    • 领域事件

    • 总计

  • 描述什么是有界上下文,以及如何使用它们在我们的业务逻辑中划分域和子域,然后利用这些上下文创建一个上下文图,最终创建一组模型,作为我们的应用的域和名称空间结构的主干

  • 请记住,我们可以使用敏捷方法、迭代开发、持续建模和持续集成等过程来构建高质量的企业级 web 应用,这些应用易于测试、具有内聚性和松散耦合性,并且能够准确、完整地代表构建应用的领域

  • 重构代码,使之与业务领域模型更加一致,这对于创建可维护的软件非常重要,这些软件可以扩展以解决未来新功能需求带来的问题

那我们开始吧!

医疗索赔提交

我们将在前一章的基础上扩展我们的医疗索赔讨论,并更深入地讨论我们将在本章其余部分使用的所需背景信息。

我们公司是怎么赚钱的?通常,医疗计费系统中涉及的索赔提交过程往往很困难,因为它涉及多个步骤和一个精确的检查和平衡系统,政府必须遵守这些步骤和系统才能为服务提供者开出支票。我们公司开发了一个应用,有助于缓解手动提交索赔过程中的棘手问题。但是,要做到这一点,应用必须跟踪和执行各种州管理任务,以验证索赔 100%准确,并准备好提交给联邦合格健康中心(FQHC)。

我们将通过建立索赔的不同状态来实现这一点,它还会跟踪通过我们系统的每个索赔。例如,必须对索赔进行审查,以确保其准确性,这样 FQHC 就不会以CORRECTION_NEEDED状态拒绝索赔,从而延迟对提供商的付款。

基本上,可以将我们公司提供的服务看作是索赔的交换所,这样索赔在第一次提交给 FQHC 时就会被接受,提供商也会得到报酬。这一点之所以重要,是因为当提供商直接向 FQHC 提交索赔时,会发生大量的退回索赔(和延迟付款)。接待员在选择对每个病人进行的程序时会犯太多的错误;这就是所谓的痛点

我们的公司在市场上找到了一个利基市场,并用一个漂亮的新应用填充了这个市场,该应用使提供商(医生)的办公室能够使用我们根据联邦索赔审查流程设计的工具和流程提交整个索赔。该流程验证所有必需的数据是否存在,并将索赔排队等待审查。审查团队在审查过程中负责核实索赔,确保所有患者数据、医疗状况、接受护理的描述、代表对患者进行的各种程序的 CPT 代码以及大量其他医生/保险提供商信息都在手边,并且在将索赔标记为“审查者批准”之前都是 100%正确的

此时,索赔已准备好提交给 FQHC 进行计费,以验证金额,然后处理付款并向提供商开出支票。我们拦截支票,拿走我们的收入,然后在扣除费用和开支后,把剩余的钱付给供应商。然而,为了让联邦实体批准对提供者的支出补偿,必须保证索赔是正确的。索赔是分批提交和支付的,每批大约有 100 至 1000 份单独的索赔。

与我们新的索赔提交系统相比,通过纸质表格、传真机和影印文件提交索赔的“传统方式”大约需要五到十倍的时间。这个过程过去是 100%手工完成的,当时没有计算机检查来确保索赔表上的数据在实际提交给联邦政府之前是有效的。没有任何措施来确保程序代码组合(又称 CPT 代码组合)代表提供者为患者完成的实际付费工作。官方报销申请提交流程还有许多其他(相当严格的)要求,如果不满足这些要求,将强制要求将报销申请退回给提供商,要求他们在尝试再次提交报销申请之前进行必要的更正。

医疗程序代码

索赔根据预先确定的成本结构进行支付,该成本结构基于一种叫做工资代码表的东西。FQHC 确定向提供商支付多少费用的方式取决于提供商注册时的成本结构类型。这两种结构如下:

  • 每次就诊付费:这种成本结构规定,无论患者接受了何种常规、服务或程序,提供者每次从享有医疗福利的注册患者处接受就诊时,都要为其服务支付预定的固定金额。他们每次就诊都获得相同的固定金额。金额由提供商和 FQHC 商定,但我们公司实际上向提供商签发支票。通常,每次访问的付费金额从 100 美元到 150 美元不等。请注意,虽然他们不确定支付给提供者的金额,但每次就诊付费计划仍然要求使用程序代码(也称为 CPT 代码)跟踪每个相关程序。

  • 按程序付费:这就是事情变得复杂的地方。处于按程序付费结构下的医生根据他们为患者提供的程序获得报酬。通过分析一组给定的单个程序(称为 CTP 代码)来确定数量。每个 CPT 代码代表一个在病人身上完成的医疗程序(x 光检查,使用石膏来修复断臂等)。).确定索赔金额的方法是分析列出的各组 CPT 代码,并在一个名为 paycode sheet 的东西中查找 CPT 代码组合。

CPT 代码组

让事情变得更加复杂的是,FQHC 根据这些 CPT 代码组确定付款,称为 CPT 代码组合。这里的大问题是,这些组合非常具体,其中大多数都有包含在索赔中的要求。一些 CPT 组合组可能包含多个相同的 CPT 代码,或者可能具有一个 CPT 代码,该代码要求只有在另一个特定的 CPT 代码存在于同一组中时才被视为有效。

还存在其他要求。每个 CPT 代码组合对应于一个或多个 CPT 代码,这些代码可能具有先决条件要求,可能仅包含在另一组 CPT 代码中,或者可能用于特定的步骤序列中,每个步骤又具有自己的 CPT 特定要求。医生可能对患者执行的所有可用程序都被建模为一系列 CPT 代码组合。这就是 FQHC 如何确定这些程序的确切支付金额。所有可能的组合及其预定的费用金额(支付给提供商)都存储在特定的支付代码表中。

工资代码表跟踪所有这些 CPT 组合,这些组合代表在每个患者身上完成的各种程序,并以不可读的预设格式建模和表达。这要求提供者的办公室手动查找他们想要使用的每个 CPT 代码,然后确保他们与索赔一起提交的 CPT 组合与特定 paycode 表中的有效条目相对应。

例如,以下是一个(伪造的)CPT 代码列表,这些代码对应于因呼吸问题接受治疗的患者:

  • 胸部 x 光检查完成(代码 3892)

  • 已订购血液工作(代码 3332)

  • 通过喷雾器向患者施用硫酸沙丁胺醇(代码 4523)

这三个代码都出现在一个索赔中,因为它们都发生在同一次访问中。FQHC 有一项严格的政策,规定任何患者每天只能就诊一次,因此在某一天为该患者进行的每项手术都必须包括在相应的报销申请中,他们不会接受每位患者每天超过一次的报销申请。

这些代码可以在给定的支付代码表中找到,该支付代码表包含提供者从治疗患者中获得的指定金额。表 6-1 显示了支付代码表中的示例记录,该记录规定了支付给具有前三个 CPT 代码的索赔的金额。

表 6-1

对应于一组 CPT 代码的薪资代码表中的记录

|

CPT 代码 id

|

CPT 代码

|

数量

|
| --- | --- | --- |
| 3; 38; 420; | 3392, 3332, 4523 | $150 |

作为系统提供者(应用的主要用户和我们销售团队关注的中心客户)的一项功能,应用应该以某种方式保存支付代码表数据以及在内部关系数据库中与索赔一起提交的数据,解析出提交的索赔的代码组合,在相关的支付代码表中查找该组合,并将索赔的估计金额附加到索赔的元数据中。

图 6-1 分解 CPT 编码系统。

img/488324_1_En_6_Chapter/488324_1_En_6_Fig1_HTML.jpg

图 6-1

医疗索赔提交流程中涉及的工作流程

在图 6-1 中,我们从左上角开始,提供者(或提供者办公室或诊所的接待员)登录到我们的应用,并转到创建新索赔页面。在这个页面上有各种表格输入,用于成功处理索赔所需的各种信息。完成并提交表单需要以下数据:

  • 服务日期(DOS),即在患者身上完成手术的日期

  • 相关的医生日志/患者治疗史,称为进展记录

  • 在患者身上完成的程序,通过 CPT 代码组合进行跟踪,根据与给定支付代码表中定义的 CPT 代码组合相对应的金额,CPT 代码组合必须有效

  • 提供商信息

    1. NPI 号(国家提供商识别号)

    2. 提供商的名称、位置、执照/医学学位

    3. 提供者从事的相关业务

  • 基本患者信息

    1. 名,中间名,姓

    2. 出生日期,性别

    3. 头发颜色,眼睛颜色,体重

    4. 社会保险号

    5. 当前街道地址

    6. 紧急联系人

  • 病人的文件必须存档。这包括以下内容:

    1. 患者身份证或身份证复印件

    2. 患者医疗福利卡的复印件

    3. 接受护理的合同副本(或电子存储)

此外,当用户提交索赔时,会出现许多自动验证任务,包括检查索赔是否符合所有以前的要求,验证 DOS 是否在去年内,以及通过查找提供商办公室提供的上传文档来验证患者资格。

在没有人工参与的情况下,应用尽可能地验证了索赔的所有数据后,索赔被物理地提交给系统,并以状态PENDING_REVIEW保存。一旦索赔被正式提交,在后端,我们有几个额外的流程在它到达审查员之前运行。首先,我们希望自动化患者资格要求,以便应用实际上可以在线访问 Medi-Cal 页面,通过操作 DOM 提交患者信息,抓取返回的响应,然后将该响应附加到索赔中。我们将在本章的后面处理这个问题。

提交时运行的另一个任务是验证索赔中的患者实际上已在该提供者处注册。如果注册没有完成,应用应该不允许用户继续进行声明,并返回到更正声明。索赔上的提供者链接到一个支付代码表,该表确定每个程序代码分组将向提供者支付多少。在审查索赔之前,需要做的最后一件事是估计索赔的金额。这是通过在工资代码表中查找 CPT 组合并在索赔中附上“估计金额”来完成的。如果无法从薪资代码表中解析出金额,则表明该薪资代码表中不存在 CPT 代码组合,必须在将索赔发送到 FQHC 之前添加该组合。

在自动化流程全部运行之后,索赔在发出之前会提交给审查小组进行审查。审查者再次检查索赔上的所有文档,并手动验证索赔上提交的所有内容(以及应用创建并附加到索赔的字段);审查者要么批准它,要么认为它需要更正,在这种情况下,索赔被发送回提供者进行更正(索赔状态表明这一点:CORRECTION_NEEDED)。在评论页面上还有一个注释部分,评论者可以用来输入关于索赔的具体注释或评论,这些注释附在索赔上;然后,提交索赔的提供者会收到一个通知,告知索赔需要关注。他们可以更正并再次发送,重新开始这个过程。

如果索赔准备就绪,审查者将其标记为REVIEWER_APPROVED,并将索赔发送到 FQHC 进行计费审查。在 FQHC 工作的计费用户调出与索赔相关联的数据,并根据估计金额进行最终检查,确保该金额对应于该提供商各自的支付代码表中列出的有效 CPT 组合。完成后,记账人将索赔标记为BILLER_APPROVED,三周后,我们公司将代表提供商收到一份薪水支票。然后我们把钱分发给他们。否则,记账方将该索赔标记为BILLER_CORRECTION_NEEDED,然后再次将其发送回提供方进行更正(此时提交流程重新开始)。总的来说,这个过程非常简单,但是有很多验证和确认进入索赔,以确保它将被接受和支付。

我们在建造什么?

现在您已经对索赔提交流程有了一些了解,我们可以开始合理化我们希望如何实现这个例子的特性。然而,首先,界定我们在建设什么是至关重要的。我将带您在这个应用中设置项目的上下文图并定义有界的上下文。

请记住,该应用被视为“企业应用”,因为它管理多种用户类型,提供身份验证和授权管理,并且存在于各种环境中。在我们根据边界、域、上下文和子域(我们稍后会谈到所有这些)设计出应用后,我们将重点设计和实现系统的几个不同方面,具体如下:

  • 索赔提交流程,包括所有必需的检查和平衡以及状态代码更改。

  • 角色系统,定义系统中各种用户类型的角色,以及在执行系统任务(即授权)时它们各自的检查。

  • 根据输入的 CPT 代码组合及其在给定提供商的薪资代码表中的相应行确定索赔金额的过程。

  • 刮除器将刮除 Medi-Cal 部位并返回检查结果的图像,以确定患者是否有资格获得福利。该流程在索赔创建时自动运行。

  • 验证索赔的所有数据点并核实其准确性,以及提交索赔所需的文件。

识别域、子域和有界上下文

那么,我们从哪里开始?我们识别系统需求中涉及的域、子域和有界上下文;然后我们开始构建我们天真的原型;最后,我们提炼概念,直到我们有一个有效的、工作的领域模型。

表 6-2 更好地代表了上一节中的列表,同时也指定了未知项(当我们检查它们时,您可以回来填充空白)。

表 6-2

索赔示例要求

|

要求

|

描述

|

领域

|

子整环

|

公元前

|
| --- | --- | --- | --- | --- |
| 提交索赔 | 索赔提交流程模型 | 提交索赔 | - | - |
| 索赔核实 | 核实提交的索赔 | 提交索赔 | - | - |
| 索赔估计 | 确定索赔的预期金额 | 提交索赔 | - | - |
| 资格刮刀 | 用于 Medi-Cal 患者资格验证的刮刀 | 提交索赔 | - | - |
| 角色系统 | 基于每个用户的用户授权 | 作家(author 的简写) | - | - |

在表 6-1 中,我已经简单地包含了上述列表的需求,并给出了它们的核心领域。以下是 Eric Evan 对核心领域的评论,摘自他的书,《领域驱动设计参考》(狗耳出版社,2014):

《把模型简化。定义一个核心领域,并提供一种方法来轻松地将其与大量支持模型和代码区分开来。突出最有价值和最专业的概念。把核心做小。”

—埃里克·埃文斯

基于我们对核心域应该是什么的理解,我似乎在表 6-1 中把域说得太宽泛了。Evans 指出,核心域应该很小,并且应该很容易与支持或促进核心域中的模型的其他模型区分开来。最初,我只为每个主请求包含了两个核心域:CLAIM SUBMIT 和 AUTH。如果我们再仔细考虑一下,我们会发现提交索赔的过程应该不同于验证索赔的过程。然而,这两个概念是密切相关的(也就是说,如果一开始就没有索赔提交,就根本无法进行索赔验证)。我们甚至可以得出这样的结论:索赔确认是索赔提交过程整体的一部分。我们需要能够以这样一种方式分割领域,即应用的结构由更小的部分编织在一起,每个部分都可能有自己的上下文、无处不在的语言,甚至是负责维护和发布周期的独立团队。

DDD 提供了一些有价值的工具,给你一些在核心域、子域和有界上下文之间画边界的想法。一般来说,系统中每一个有界上下文通常有一个子域,多个子域/有界上下文共同生活在同一个核心域中。一旦这样做了,我们就开始了解封装了系统的各种关注点的模块是如何工作的,并且可以看到不同领域协同工作以在系统内实现一个单一的、明确定义的目标的完整视角。

有了这些知识,让我们重温一下表 6-1 。起初,我们可能认为索赔提交应用的概念应该有不同的核心域,例如,一个处理提交过程本身,另一个验证提交的索赔。在意识到它们是不同的关注点,但是彼此又如此相关,以至于一个(即索赔验证)离不开另一个(即索赔提交)之后,我们得出结论,这两个应该在同一个核心域中是有意义的。

现在,让我们回顾一下索赔评估需求。索赔的估计金额是一个计算值,它依赖于一个流程,该流程确定每个索赔的特定 CPT 组合,并在给定的支付代码表中为该组合确定一个金额。对我来说,这意味着评估索赔的过程不能在没有 CPT 组合和provider_id的情况下发生,而这两件事只是在索赔的意义和上下文中一起发生。然而,当从高层次上看这件事时,我想到的一个细节是,索赔评估发生在成功提交索赔之后。我们应该将它建模为一个不同的核心领域吗?我不这么认为,原因如下:提交索赔并验证索赔是否准确以及是否包含所有必需的数据和文档的行为发生在提交过程的同时,但它实际上发生在索赔被视为“已提交”之前

只有在这种情况发生后,才能计算出索赔支出的估计金额。总的来说,这些事实让我(我希望你也是)认识到它们是同一个整体过程的不同背景。索赔提交由提供商完成,我们的应用会在正式“提交”之前自动验证其准确性,然后根据与 CPT 代码组合和支付代码表相关的不同流程将估计金额附加到索赔中。现在,我们将估计索赔金额逻辑建模为同一个核心域 CLAIM SUBMIT 中的另一个有界上下文。此列表中与索赔本身相关的唯一其他项目是通过抓取 Medi-Cal 资格页面并向其提供患者信息(包括其 Medi-Cal 编号)以及提供护理的提供者来验证患者资格并获取结果的流程。在技术上“提交”索赔后,也会触发此流程尽管如此,我们有许多不同的方法可以选择来拆分应用。

  1. 我们可以让它保持原样(也就是说,让两个核心域声明 SUBMIT 和 AUTH ),只给每个域中的元素分配不同的子域。

  2. We could make the core domains a little more granular, which may look something like Figure 6-2.

    img/488324_1_En_6_Chapter/488324_1_En_6_Fig2_HTML.png

    图 6-2

    对构成我们应用核心领域的概念的初步深入研究

  3. We can choose to create each core domain in light of the various models existing within the system. In this regard, the AUTH bounded context would basically be absorbed by the surrounding contexts (Figure 6-3).

    img/488324_1_En_6_Chapter/488324_1_En_6_Fig3_HTML.png

    图 6-3

    按用户类型细分的概念

    以这种方式分离应用的主要缺点是,我们现在必须将缺失的 AUTH 上下文作为一个独立的、不统一的概念来处理,它在不同的用户类型中有不同的实现。基本上,这样做不是最好的,因为我们会将授权关注点的单独实现混合到定义它们的每个特定用户类型中,导致公然违反关注点分离和 DRY 原则(代表“不要重复自己”),因为我们必须定义每个特定用户在每个上下文中可以做什么,如果我们有一个 AUTH 服务或类似东西的单一实现,这并不是很糟糕,但这里不是这样。不理想。让我们找到一个更好的结构,它更有意义,更容易适应领域模型。

  4. We can combine the idea presented in option 1 of this list with that in option 2 and have the core domains separated by the two primary concerns living within the core domain that revolves around a claim, while making the variations between each concern explicit and obvious by specifying the subdomains each of them target and then creating a bounded context for each one of these subdomains. Figure 6-4 shows what that might look like.

    img/488324_1_En_6_Chapter/488324_1_En_6_Fig4_HTML.png

    图 6-4

    将概念分成三个中心力量(如果你愿意的话),它们在领域模型的核心工作

  5. We could also split up the domain in terms of the central model existing in our feature requirements: the claim. In this case, the claim would be the center of attention, and different contexts can be created in regard to its association to a claim, particularly the contexts that a claim is in initially and after it’s submitted (Figure 6-5).

    img/488324_1_En_6_Chapter/488324_1_En_6_Fig5_HTML.png

    图 6-5

    根据系统中的中心实体存在的域:声明

    Note

    关于前面的可能性列表中的选项 5,AUTH 域有两个相似的探测上下文,但是有一个重要的区别。认证是一个与确保用户是他们所声称的那个人有关的概念,如果是的话,就进行认证。另一方面,授权直接处理验证被认证的用户是否被允许做某事,通常基于用户在系统中的角色(这属于权限范畴)。

除了选项 3 之外,最有可能为前面列表中的所有项目创建一个有效的用例。根据 Eric Evans 的说法,我们应该寻找将核心域从支持核心域的域中分离出来的方法。在我们的例子中,对于这个例子,核心域是索赔本身,围绕索赔的各种关注点,即验证、资格审查、用户授权检查,以及索赔提交过程中发生的任何其他操作。当面临一个将影响应用中定义的结构和模块的重要决策时,很难决定实现最佳结果的最佳方案。在这种情况下,我通常会求助于我认为正确的东西,比如 DDD 哲学中的定义和抽象。对于这个场景,我想到的是 Evan 对抽象核心的定义,如下所示:

即使是核心领域模型通常也有如此多的细节,以至于很难传达全局...因此:确定模型中最基本的不同概念,并将它们分解到不同的类、抽象类或接口中。设计这个抽象模型,使其表达重要组件之间的大部分交互。将这个抽象的整体模型放在它自己的模块中,而专门的、详细的实现类留在它们自己的由子域定义的模块中。

—埃里克·埃文斯

那么,为了我们的目的,在我们的医疗索赔领域模型中,什么会被认为是“最与众不同的概念”?

  • 提交索赔

  • 验证索赔

    • 提交前验证

      • 检查适当的文件

      • 验证 CPT 代码+ CPT 组合

      • 验证表单提交的数据

    • 提交后验证

      • 患者资格刮刀
  • 估算索赔金额

  • 授权和认证问题

我认为我们应该将索赔作为这个项目的核心领域,子领域是提交、验证和索赔金额估计过程。在不同的上下文中,我们有应用的身份验证/授权部分。这最好定义为通用子域(业务中的一个概念,其存在是为了促进关键的业务关注,但不是业务关注本身)。这些子域是“通用的”,因为它们是可重用的、解耦的组件,在整个代码库中的许多地方都会被调用。图 6-6 显示了我们将使用的最终结构。

img/488324_1_En_6_Chapter/488324_1_En_6_Fig6_HTML.png

图 6-6

我们应用关注点的最终分离

此时,我们已经将应用分成了更易于管理的部分,并定义了各种子域,这些子域是核心域不可或缺的一部分,有助于将系统中的声明转换为提供者的工资。

模块

在 DDD,有一个模块的概念,它是一组特定类的边界,这些类基于领域中的概念逻辑地分组在一起,并以无处不在的语言命名。模块直接对应于领域中的项目,既作为构造应用领域层的手段,又作为表达通用语言结构的手段。模块应该与其他模块松散耦合,但是模块中的类本身应该与同一模块中的其他类紧密结合。

模块如何与有界上下文相关联?图 6-7 显示了 DDD 战略设计的层级。

img/488324_1_En_6_Chapter/488324_1_En_6_Fig7_HTML.jpg

图 6-7

DDD 组织和结构的战略分解

我们可以看到,核心域是顶级关注点,核心域由各种子域表示,每个子域都有对应的有界上下文。有界的上下文本身再次被分解,并被分成各种模块,这些模块一起表示构成子域中所表达的思想的逻辑。在我们的例子中,图 6-8 显示了模块结构的可能配置。

img/488324_1_En_6_Chapter/488324_1_En_6_Fig8_HTML.jpg

图 6-8

在我们的 claims 示例项目中,模块、有界上下文、子域和核心域的结构

Note

这本书没有详尽、完整地列出构建这个示例项目的每一行代码,而是给出了用 Laravel 翻译应用各个部分的可能方法。关于这个项目的完整代码清单和书中所有其他的源代码,请访问 Apress 网站。

现在我们已经介绍了一些架构设计解决方案,并且有了一个适合应用和领域模型需求的解决方案,我们可以开始使用 Laravel 构建组件本身,并开始看到我们的应用成形。

创建 Laravel 组件

让我们看看是否可以将它分解成类,我们将在不同的模块中使用这些类来完成我们需要做的事情。要做到这一点,我们需要对领域有一个清晰的概念,这样我们最终创建的模型才能用文字的方式表达它。如果您已经定义了模块的一般结构,并且知道它们所处的有界上下文,那么在定义更细粒度的结构(类)时,最好的起点是确定每个上下文中需要的模型,并在模块的范围内定义它们,注意使类名与无处不在的语言中的术语一致。由于它将在应用中的任何地方使用,以确定对域中给定资源的授权,我们将从关注通用子域开始,在本例中,是用户和角色。

用户

现在,我们知道会有不同的用户在不同的时间使用系统,这些用户中的每一个都必须有一个登录名和一个定义好的角色,这样我们的系统就知道如何识别每一个用户,以及他们能做什么和不能做什么以及能看到什么。Laravel 附带了一个标准的User类,它扩展了另一个User类,而后者又从雄辩的基础model扩展来定义管理用户的各种设施。父类User使用各种特征来访问 Laravel 的认证和授权特性。

关于如何继续的第一个想法可能是创建额外的User类,它们也从基类(别名为Authenticatable),扩展而来,然后为每种类型的User类提供它们自己的可用方法和属性的子集。它看起来有点像图 6-9 。

img/488324_1_En_6_Chapter/488324_1_En_6_Fig9_HTML.jpg

图 6-9

应用中所需的各种用户类的初始设计

这种设计有许多问题,列举如下:

  • 有一个额外的User类,它似乎对我们的应用或领域没有明确的目的。这是相当尴尬的,并留下了误解的空间。

  • 事实上,我们违反了关注点分离和 Demeter 定律,因为我们混淆了系统中用户的概念和系统中权限的概念,而它们显然是不同的。

  • 我们将认证和授权问题与User定义混在一起,并创建了两个额外的类,它们也扩展了同一个父类。问题是“角色”的明确概念已经丢失。我们应该认为用户有一个或多个角色,而不是用户是这些角色中的一个(这意味着它确实属于内联的User定义)。

  • 当执行身份验证或权限检查时,我们将不断地在三个User类之间周旋,而不是只有一个User类可以传递和管理。对我来说那听起来像是一个巨大的痛苦!

  • 这种设计根本不可扩展。如果我们需要添加额外的用户类型(或者更准确地说,用户角色),那么我们就不得不在系统中创建额外的类和对象,每个类和对象都有自己的实现,并在系统的其余部分进行有效性检查。

这种设计的最大问题可以在列表的第三和第五项中找到。用户和角色的概念应该在同一个模块中分开,而不是在同一个类中。我们应该将这些问题相互分离,并明确这两组类。

话虽如此,图 6-10 显示了之前设计的一个更好的版本,考虑到了我们将用户和角色模型分开的决定(没有双关的意思)。

img/488324_1_En_6_Chapter/488324_1_En_6_Fig10_HTML.jpg

图 6-10

设计用户及其相应角色的更好方法

正如你在图 6-10 中看到的,我们现在已经将“用户角色”的概念封装在它自己的类中,并且我们已经定义了几个常量来反映系统中使用的各种角色。这种设计非常灵活,因为我们可以将任何我们想要的角色附加到一个特定的User对象上。它也是可伸缩的,因为我们可以在不添加类的情况下向系统添加角色。我们能够满足系统当前和未来的需求,同时将系统中的类的总数保持在尽可能低的水平。我们在正确的轨道上!然而,还有一个问题:我们不希望角色的概念泄露到User对象中,也不希望用户的概念泄露到Role对象中。我们如何完成这样的事情?我现在不会给你所有的细节,但是我会给你一个提示:我们可以使用一个数据透视表作为角色表和users表之间的中介,实际上被称为user_role表(或role_user)。

The Cost of Bad Design

优秀的程序员既是艺术家也是科学家。我们将需求钉在一门科学上,并使用我们的画图构造这些问题的各种解决方案(在这种情况下,它将是 DDL 或 PHP,取决于您如何看待它)。当我们认识到设计或模型逻辑的清晰性时,就像我们在这里所看到的用户和角色的分离,我们应该总是试图在我们的应用的画布中捕捉知识:设计。好的设计在努力;更差的设计更难。想想看:一个好的设计几乎没有开销,因为你不必改变一堆东西来补偿应用不同部分的另一个变化,因为程序员遵循适当的标准和最佳实践来设计他们的软件,其中之一就是严格遵守关注点的分离。在一个设计糟糕、没有经过深思熟虑或规划不当的系统中,你不可能获得这些很酷的好处。修复和维护一堆垃圾(有人称之为真正的 web 应用,只是碰巧被黑客攻击到足以使其在技术上“工作”的程度)的成本远远超过了您要处理的成本、挫折、头痛、架构更改和维护水平,直到最后有一天您不得不(或者更确切地说,不得不)从头重写它,或者采用更高级的解决方案,如反腐败层,来解决糟糕的设计留下的问题。无论糟糕的设计的原因是否与范围蔓延、对领域的误解、对被误解的领域的错误陈述有关,或者完全缺乏关于管理领域的较低级业务规则和政策的经验和知识,一个设计糟糕的系统都要复杂得多、困难得多、昂贵得多,对于像我们这样聪明的开发人员来说根本不是一个选择。

我花时间为你精心创作了一首诙谐的诗,它将帮助你记住设计的主要焦点应该是领域,以及如何最好地将其建模为软件(尽管,正如伏尔泰曾经说过的,“诙谐的说法证明不了什么……”).

抓紧时间,

敲定设计,

但是不要浪费时间,

关于你发现的问题,

如果他们不在域中,

现在,把它们留在身后。

我试图说明的另一点是,基础设施和应用层的问题不应该影响领域层;领域层应该影响基础设施和应用层。

设计建模

为了继续建模过程,我们需要解决一个由 Laravel 的默认名称空间和目录结构引起的问题。问题是,它们并不是面向一个干净的、分离的架构,这个架构完全表达了我们的领域模型的意图。这是我们将在下一章研究的内容,下一章将着重于创建一个标准组件来映射我们的应用中需要的许多用户角色。我们的应用被认为是一个企业 web 应用,这意味着我们必须正确地跟踪、管理和检查所有试图访问受保护的服务(例如搜索病人)或受保护的实体(及其相应的字段)的用户,以确保他们被允许调用特定的服务或选择、修改或删除给定的实体(在 API 的上下文中称为资源)。这方面的一个例子是制定一项政策,只允许搜索属于给定诊所或提供商的患者,他们在医疗系统中注册为患者的主治医生。

结论

在本章中,我们看了一下本章和后续章节中使用的示例声明应用的各个领域级方面。我们讨论了医疗服务提供者目前面临的一个实际问题,即以“旧方式”完成索赔提交,以获得对合格患者服务的补偿。由于对来自系统的索赔进行了严格的审查,许多索赔由于一些错误(或缺少信息)而被延迟支付。唯一的验证工作是用肉眼手工完成的。

我们的应用解决了所有这些问题,并为进入系统的所有索赔提供了各种检查和平衡的解决方案,因此返回的提交数量要少得多,其中 99%在第一次尝试时就被接受。该应用作为企业级应用存在,需要用户角色和服务等组件,例如以细粒度方式进行身份验证和授权,以便通过登录和凭据支持系统中的所有用户。提交过程本身由各种规模较小的过程组成,这些过程相互协作以产生预期的结果。我们尝试了一种可能的组件(模块)结构,使用了一些通用的最佳实践,最重要的是,使用领域中的核心概念,按照领域驱动的设计来制作一个粗略的模型结构。我们看了一些概念,如有界上下文、核心域和一般子域。

七、领域的建模和实现

现在我们对领域在现实世界中的工作方式有了一些了解,我们可以开始开发领域关注点,并创建一些基本的领域对象,这将帮助我们产生一个覆盖所有核心需求的解决方案。

Note

本章中包含的领域模型对象的目录将会随着本书在第八章中继续这个项目而改变,当我们有了一些在代码中表示我们领域的更好方法的额外见解时。

定义主索赔模型

首先,让我们定义我们将在系统的其余部分使用的主Claim模型,如图 7-1 所示。

img/488324_1_En_7_Chapter/488324_1_En_7_Fig1_HTML.jpg

图 7-1

索赔模型原型的首次尝试

Claim模型有两个部分;顶部的一个定义了索赔的属性。我们需要为属性所代表的类创建模型和迁移。).目前,属性是 FQHC 向提供商付款所需数据的一部分。在 UML 的底部是一个方法列表,这些方法对索赔状态做各种事情,或者拉入也是需求的一部分的附加数据(例如,getDocuments()获得给定索赔的所有相关文档)。

应用中有许多涉及声明的部分也需要建模。有时,最好从系统的高层次视图开始,这样就可以弄清楚类之间的关系。要做的一个重要区别是,我们在系统中包含了两种实际类型的用户,ProviderPatient,它们在模型中明确地用于域逻辑,而不是身份验证或授权(这将在应用层)。如果我们试图在这个类中建模安全问题(这将公然违反关注点分离原则),我们应该导入User类。我们还需要在我们的类别映射中使这种包含显式化。

图 7-2 显示了在一个声明中的高级概述。

img/488324_1_En_7_Chapter/488324_1_En_7_Fig2_HTML.jpg

图 7-2

索赔类别的属性

请注意,有两组类,每组都包含在一个边界内,该边界将类组与应用的其余部分分开,对应于我们在前一章中设计的两个模块:Auth 模块和 Claim Submission 模块。实际的Claim 模型可以在索赔提交模块中找到,并充当任何封闭类的看门人。

如果您还没有猜到,我们正在将索赔提交模块中的类聚合起来,用Claim模型作为聚合根。声明集合边界内的任何封闭类都只能通过声明本身来访问。例如,与索赔一起提交的ProgressNote只能通过$claim->progressNote访问。ClaimStatus;也是如此,它是通过$claim->status访问的。现在,如果您查看另一个模块,它被表示为驻留在同一父名称空间下的一组常规类。围绕它画出的边界并不表示一个集合,而只是一组相关的类。Claim模型显式地使用了ProviderPatient模型,既用于身份验证/授权,也作为断言将索赔提交到审查队列所需的需求的一种方式。让我们使用 Artisan 命令来充实模型,如清单 7-1 所示。

php artisan make:model App\\Models\\Claim -a
php artisan make:model App\\Models\\Patient -a
php artisan make:model App\\Models\\Practice -a
php artisan make:model App\\Models\\Provider -a
php artisan make:model App\\Models\\ProgressNote -a
php artisan make:model App\\Models\\Document -a
php artisan make:model App\\Models\\ClaimStatus --migration

Listing 7-1Artisan Commands to Create the Domain Models of Our System

清单 7-1 中的命令,除了最后一个,为它们对应的模型创建以下每一个:

  • 位于App\Models的模型类

  • 位于database/migrations的迁移类

  • database/factories中的一个试验工厂

  • app/Http/Controllers中的一个控制器

让我们从最简单的开始,定义我们的ClaimStatus类和支持它的迁移,如清单 7-2 和 7-3 所示。

// ddl/app/Models/ClaimStatus
<?php
...
class ClaimStatus
{
     const PENDING_REVIEW = 1;
     const REVIEWER_APPROVED = 2;
     const CORRECTION_NEEDED = 3;
     const BILLER_CORRECTION_NEEDED = 4;
     const BILLER_APPROVED = 5;

     public $table = 'claim_status';
     protected $fillable = ['*'];
}

Listing 7-2The ClaimStatus Class

注意,我们省略了将created_atupdated_at时间戳添加到状态表的代码,考虑到这是一个静态引用(即查找)表,这些代码是没有用的。

// ddl/database/migrations/{YOUR_TIMESTAMP}_create_claimstatus_table
<?php
...
use App\Models\ClaimStatus;
function up()
{
     Schema::create('claim_status', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name');
            $table->string('slug');
            $table->string('code');
     });

     ClaimStatus::create([
          'id' => 1,
           'name' => 'Pending Review',
          'slug' => 'pending_review',
           'code' => 'PENDING_REVIEW'
     ]);

     ClaimStatus::create([
          'id' => 1,
          'name' => 'Reviewer Approved',
           'slug' => 'reviewer_approved',
          'code' => 'REVIEWER_APPROVED'
     ]);

     ClaimStatus::create([
          'id' => 1,
           'name' => 'Correction Needed',
          'slug' => 'correction_needed',
           'code' => 'CORRECTION_NEEDED'
     ]);

     ClaimStatus::create([
          'id' => 1,
           'name' => 'Biller Correction Needed',
          'slug' => 'biller_correction_needed',
           'code' => 'BILLER_CORRECTION_NEEDED'
     ]);

     ClaimStatus::create([
          'id' => 1,
           'name' => 'Biller Approved',
          'slug' => 'biller_approved',
           'code' => 'BILLER_APPROVED'
     ]);
}

Listing 7-3ClaimStatus Migration File

Note

我们在ClaimStatus模型中定义了一组常量,以便于将其与应用的其他部分区分开来。现在,我们不必记住 ID 为 1 的ClaimStatus表示一个PENDING_REVIEW状态,我们可以直接引用App\Models\ClaimStatus::PENDING_REVIEW.同样需要注意的是我们在迁移运行时添加的记录——它们都对应于我们在顶部设置的模型常数。

本文中不会列出所有的模型和迁移,但是您可以在 GitHub repo 中找到这个例子和其他例子的所有代码。我没有提供Practice, ProgressNote,Document的文本定义,因为它们是琐碎的实现——请在线查找这些类的定义。然而,我将内联定义Claim, Provider,ClaimStatus类,因为它们是独一无二的,并且对当前的讨论非常重要。

让我们完成Provider类(请访问网站获取移植代码)。为此,请查看清单 7-4 。

// ddl/app/Models/Provider
<?php
...
Class Provider extends Model
{
     public $table = 'providers';

     protected $fillable = ['fname', 'lname', 'address', 'practice_id', 'npi_number'];

     public function practice()
     {
          return $this->hasOne(Practice::class, 'practice_id', 'id');
     }
}

Listing 7-4The Provider Eloquent Model

Provider类相当简单。有一些标准标识符,包括姓名和地址,以及他们工作的诊所和国家提供者标识符(NPI),这是美国每一位执业医生在政府注册后在美国行医的 ID。我们在这里定义的唯一关系是对应的Practice(我们用在$fillable数组中指定的practice_id字段明确了这一点)。

现在我们可以通过使用关系将Claim模型中的模型联系在一起,如清单 7-5 所示。

// dll/app/Models/Claim.php
<?php
// ...
namespace App\Models;
class Claim
{
     public $table = 'claims';
     protected $fillable = ['cpt_code_combo_id', 'provider_id', 'patient_id', 'progress_note_id', 'date_of_service', 'status_id'];

//relations:
     public function provider()
     {
          return $this->hasOne(Provider::class);
     }

     public function patient()
     {
          return $this->hasOne(Patient::class);
     }
     public function progressNotes()
     {
          return $this->hasMany(ProviderNote::class);
     }
     public function cptCodeCombo()
     {
          return $this->hasOne(CptCodeCombo::class);
     }
     public function status()
     {
          return $this->hasOne(ClaimStatus::class, 'status_id', 'id');
     }
}

Listing 7-5The Claim Model

清单 7-6 展示了Claim模型的移植。

// dll/database/migrations/{YOUR_TIMESTAMP}/_create_claim_table.php
<?php
...
class CreateClaimsTable extends Migration
{
     Schema::create('claims', function (Blueprint $table) {
           $table->bigIncrements('id');
           $table->integer('status_id')->unsigned();
           $table->integer('provider_id')->unsigned();
           $table->integer('patient_id')->unsigned();
           $table->integer('cpt_code_combo_id')->unsigned();
           $table->integer('progress_note_id')->unsigned();
           //define foreign keys:
           $table->foreign('practice_id')
                 ->references(Practice::class)
                 ->on('id');
           $table->foreign('provider_id')
                 ->references(Provider::class)
                 ->on('id');
           $table->foreign('patient_id')
                 ->references(Patient::class)
                 ->on('id')
           $table->foreign('cpt_code_combo_id')
                 ->references(CptCodeCombo::class)
                 ->on('id');
           $table->foreign('progress_note_id')
                 ->references(ProgressNote::class)
                 ->on('id');
           $table->foreign('status_id')
                 ->references(ClaimStatus::class)
                 ->on('id');
           $table->datetime('date_of_service');
           $table->timestamps();
     });
}

Listing 7-6Migration File for the Claim Model

在这一点上,我们已经勾勒出了声称存在所需的基本模型。我们尚未讨论通过提交和验证流程促进索赔所需的任何结构,也未对流程中涉及的所有不同步骤和状态的管道进行建模。我们将在本书的后面部分讨论所有这些内容。这些问题在 DDD 和拉腊维尔的技术水平和经验方面更先进。我保证我们会到达那里。

让我们谈谈总量。一个集合基本上是一个对象,它由分离的、单独的对象组成,这些对象使用一组组件组合在一起,这样它们可以被记录、分解和重构,并在面对领域中复杂对象的代码实现时作为一个可能的解决方案。聚合有助于在持久化数据时防止数据不一致,方法是采用一些使它们有用的原则。

  • 原子数

  • 一致性

  • 完整

  • 持久性

确保这一点的一种方法是,当系统要求您需要随时跟踪给定域对象的状态时,使用事务来管理一组事件或域模型的持久性。事件记录就是一个例子。当应用中发生了其他上下文想要知道的有趣的事情时,事件的持久性和重新创建就很难作为一个大块来管理。

通过使对象成为一个具有适当边界的集合,您可以采用一些技术,使其在持久性级别上更容易保存和加载。在我看来(我希望你也是如此),在我们的示例模型中,聚合非常适合索赔对象;然而,好像在书中有他们自己的章节,我将把细节留到以后。

实现用户和角色设计

既然我们已经确定了设计,让我们编码出一个原型。首先是数据库。Laravel 中的数据库问题位于 Laravel 项目目录的/database文件夹中。该目录中有几个子文件夹:

  • migrations/:数据库经历的增量转换,以保持一致性并访问 concertive 提供的一系列开箱即用的功能,这样您通常不必直接接触数据库,而是依靠简单的 Artisan 命令php artisan migrate

  • factories/:生成虚拟数据的工厂,这些虚拟数据在应用中作为模型使用,并用于单元测试。

  • seeds/:运行另一个命令时可以插入的测试数据:php artisan db:seed

我们需要构建代表系统中不同角色的模型。下面是生成我们需要的模型和迁移的命令,因此我们可以表示角色的概念:

php artisan make:model Role --migration

这将产生类似如下的内容:

Model created successfully.
Created Migration: 2019_10_03_033254_create_roles_table

哇,看看这个。我们能够用一个命令生成模型和相应的数据库表。我们先修改迁移;然后我们可以构建我们的领域模型。打开ddl/db/migrations/*_create_roles_table,,其中*是命令运行时的时间戳,然后找到方法up()。然后修改这个方法,看起来像清单 7-7 。

   public function up()
    {
        Schema::create('roles', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name');
            $table->string('slug');
            $table->timestamps();
        });
    }

Listing 7-7Roles Migration File

基本上,在清单 7-7 中,我们定义了我们的roles表,每个角色对应一个定义为 BIGINT 数据类型的角色 ID,它是自动递增的,并被设置为表的主键。接下来的两个字段是对应于角色名称和一个 slug 的字符串类型,因此我们可以很容易地在一个 HTTP 查询中表示它们(例如,/fqhc-biller/billClaim)。最后,我们将基本时间戳created_atupdated_at添加到表格中以供参考。

在我们实际运行迁移命令之前,让我们定义我们的模型(稍后我将向您展示为什么)。查看列表 7-8 。

// ddl/app/Role.php

<?php
namespace App;

use Illuminate\Database\Eloquent\Model;

class Role extends Model
{
    const ROLE_ADMIN = 1;
    const ROLE_PRACTICE = 2;
    const ROLE_PROVIDER = 3;
    const ROLE_BILLER = 4;

    protected $fillable = ['name', 'slug'];
}

Listing 7-8Role Domain Model: Role.php

当我们在设计模型时,我们提前知道值将是什么,那么我们应该决定一个标识,系统可以用它来传递不同的角色类型,我们作为开发人员可以用在Role模型中定义的常数来引用它。这些值将在整个应用中保持一致。我们在执行数据库迁移之前进行建模的原因是,前面显示的 id 和角色名称的值需要与数据库中的roles表中定义的值保持一致。也就是说,roles表显然必须保存与存在于Role模型中的对应表相同的值。

$fillable数组保存了一个字段列表,这些字段允许由雄辩的各种方法自动生成(例如,如果我们试图将一个值插入一个不在这个列表中的字段,那么使用create()) --auto以这种方式将数据插入数据库将会失败)。把$fillable想象成一个白名单。

首先,我们在迁移类中导入新定义的Role模型,这样我们可以使用更易于阅读的语法(Role::ROLE_ADMIN).重新打开Role模型,并将其附加到文件顶部的use语句列表中:

// ddl/app/Role.php
use App\Models\Role;

让我们将清单 7-9 中的内容添加到角色迁移文件中up()方法的末尾。

public function up()
{
     //...
     Role::create([
            'id'   => Role::ROLE_ADMIN,
            'name' => 'Administrator',
            'slug' => 'admin'
        ]);

        Role::create([
            'id'   => Role::ROLE_PRACTICE,
            'name' => 'Practice',
            'slug' => 'practice'
        ]);

        Role::create([
            'id'   => Role::ROLE_PROVIDER,
            'name' => 'Provider',
            'slug' => 'provider'
        ]);

        Role::create([
            'id'   => Role::ROLE_BILLER,
            'name' => 'Fqhc Biller',
            'slug' => 'fqhc-biller'
        ]);
}

Listing 7-9Modified Migration to Include Standardized IDs, Names, and Slugs

因此,我们在这里所做的是将预定义的角色添加到数据库中,这正是我们在Role模型中建立它们的方式。现在,我们有了一组记录在案的角色,可以附加到我们的用户,以确定应用中各个点的授权,并且我们有了一种简单的方法,可以使用我们在模型中定义的常量来引用它们。现在,当我们运行php artisan migrate命令时,我们将创建数据库表并且用我们在清单 7-9 中定义的角色填充它。

我们还需要修改User类来包含新的角色,就像我们在清单 7-10 中所做的那样。

// ddl/app/User.php
//import the Role class so we can use it here:
use App\Models\Role;
// add the following as a new method on the User class:
public function roles()
{
     return $this->belongsToMany(Role::class, 'role_id', 'user_id');
}

Listing 7-10Modifying

the User Model to Support the New Roles We Defined

要完成这个例子,我们还有很多工作要做。其中一些需要对 DDD 和 DDL 内部发现的更高级的主题和技术有更深入的理解。这就是为什么我们将在几章的课程中涵盖所有这些内容。我们已经使用系统的知识,使用雄辩作为 ORM 来勾画模型。我们有我们的基本领域模型集,我们从雄辩的Model类扩展而来,给我们超能力,让我们能够更容易地管理和更好地促进它们,操纵它们,保持它们,或者破坏它们。

当你把雄辩的特性和 DDD 的技术结合起来,你会得到一个丰富的领域模型。它之所以丰富,是因为该模型直接对应于业务本身所基于的底层业务规则和策略。该模型将反映对核心领域的深刻见解和理解。这些模型很容易相互区分,就像它们在现实世界中一样,并且很容易被公司或部门的所有成员识别。这(并不奇怪)是因为它已经被公司的领域专家很好地建立、迭代地改进并完全同意。这是 DDD 的终极目标。

现实检查:现实世界中的成功

项目进行到这一步并不容易。在我看来,使用框架比不使用要好,使用 Laravel 比其他任何框架都好。至少,我想给你一些你可以在自己的过程中使用的例子。在接下来的几章中,我们将深入讨论 DDD 过程,以及如何用 Laravel 实现它们。

我们的索赔示例的主要目标是构建一个解决方案,该解决方案将接受传入的数据,在属性级别和对象级别对其进行验证,并将索赔作为一个集合进行跟踪,以便可以独立保存索赔的各个部分,并且可以从索赔在任何给定时间所处的状态获得状态。我们还应该在某种事务日志中记录我们正在做的事情,这既是为了调试的目的,也是为了关于应用或其活动部分的简单信息。我们需要以一种模块化的方式设计它,并且在适当的位置有适当的边界来分隔或分组域对象,以试图反映它们的真实世界结构。我们需要能够识别由其他对象组成的对象,并且我们应该能够以一种简单的方式重建这样一个对象。

也就是说,我们在设计中遇到了一些障碍: Laravel 的默认目录结构。 Laravel 建立在一个整体结构中,因为我们已经选择使用 PSR-4 自动加载 composer,目录结构与类所在的名称空间完全相同。我们不可能在一个目录中定义这个声明示例所需的所有模型和对象…有多个部分对应于使其工作的领域中的各种组件。我们有必要摆脱一个适合所有人的单一目录解决方案。尽管它完成后在技术上仍然是单片的,但它将使我们处于向微服务或六边形架构发展的有利位置。微服务对组件分离产生了更深远的影响,因为它要求将领域逻辑分割成单个、自封装的“服务”,这些服务分布在几个服务器(传统方式)或多个容器(使用 Docker & Kubernetes)。

结论

虽然本章中介绍的体系结构是一种稳定的体系结构,在当今的开发中广泛使用,但它的设置非常复杂,需要对各种与开发运营相关的概念和专业知识有较高的理解和应用知识,才能正确设置虚拟专用服务器(VPS)、企业级网络和子网,并建立正确的防火墙规则和路由规范。在本书中,我们不打算构建一个分布式系统,但是我们要做的是上一步……将我们的业务逻辑封装到由领域结构(我们称之为有界上下文和模块)规定的自然边界中,这样它们可以分布在不同的节点上。这最终成为一个领域模型,其内部活动部分完全分离,可以独立开发,由不同部门管理,并且可以独立部署,而不需要部署整个代码库。微服务或面向服务的架构还有许多其他好处——不胜枚举。

回到手头的问题——我们需要一种简单的方法来重构 Laravel 附带的名称空间和目录,以便我们可以在我们的领域模型中采用关注点分离,并有一个坚实的主干来帮助我们构建通过我们在本章和前面章节中讨论的管道提交和处理索赔所需的剩余功能,以及管理用户类型和角色——确保系统中的权限、策略和受保护资源仅由允许使用它们的用户设置和访问。下一章将致力于这样做……到下一章结束时,我们将为我们的各种模块和类建立一个架构,在此基础上实现更复杂的特性。

对我们来说幸运的是,Laravel 有内置的设施来定制行为、结构以及我们需要定制的几乎所有其他方面,以满足我们独特的项目要求和指导方针。我们将在下一章探讨这一点以及更多!

八、模块化 Laravel

在前一章中,我提到我们在开发索赔处理应用示例时遇到了某种障碍,我们从本书开始就一直在慢慢定义这个示例。我们正在构建的应用有许多不同的有界上下文、一个通用子域和一堆不同的模块,这些模块封装了我们在应用中需要的各种特性和组件。如果我们在新的 Laravel 安装中使用现成的标准目录结构,我们很快就会发现,一旦我们在实现未来的功能请求时开始在应用中构建更复杂的内容,事情就会变得混乱、分散,并且几乎无法维护。

我们计划让我们的应用有较长的生命周期,这意味着它需要能够承受变化。在任何软件项目中,变化都有不同的形式。特性被添加或删除,初始版本中应该包含的内容的优先级也在不断变化。更不用说范围蔓延,这本身就是一个很难处理的问题,尤其是在结构化的团队环境中工作时。如果我们将应用的所有逻辑放在一个单一的名称空间/目录结构中,我们将没有强大的团队动力,也不知道在给定的时间里谁在处理应用的哪些部分。多个部门将花费更多的时间来解决开发过程中出现的问题,而这些时间将更好地用于完成工作。此外,应用的部署必须一次完成,这为错误创造了空间,并且当完全不同的部分被修改时,还会导致应用的其他不相关部分无缘无故地中断。这是无法预测的,会导致面向用户的站点第一次出现错误,因为整个应用必须部署来修复一个 bug。

这些都是合理的可能性,在现实世界中,发生的速度有时比你所能掌控的要快。为了防止这种事情发生,我建议花足够的时间来考虑这些特性对我们整个系统的影响,并适当地计划出满足这些需求的组件结构。通过适当地分离我们的代码,以符合从域模型派生的标准,我们正在为成功做准备,因为我们可以在域模型中定义的相应目录和名称空间中独立地构建应用的不同方面。因为模块和类是用通用语言中的术语命名的,所以不存在哪个模块做什么的问题。

在本章中,我们将花一些时间来看看 Laravel 附带的名称空间结构,这样在修改它之前,您可以很好地理解这个结构。这也将让我们知道需要为我们自己的项目需求修改什么。索赔项目将被分解成相应的部分,这将允许我们将核心域模型分离成它的各种组件,并形成一个目录和名称空间方案,它将满足项目定义的需求。这种最初的尝试并不完美,但它将为你获得一些关于如何改变 Laravel 默认行为的知识打下坚实的基础。它还将为您提供一些关于按照有界上下文分割项目和识别任何通用子域的指导方针。

在后面的章节中,随着我们对领域本身以及 Laravel 的内部工作方式和默认机制的更多了解,我们将细化我们的结构并重构名称空间和结构。

Laravel 的默认结构

我曾经问过 Laravel 框架的创建者 Taylor Otwell,他是否在编写框架时考虑到了领域驱动设计的概念和实践。他回答说,他在构建框架时没有将 DDD 纳入其中,而是依靠自己的经验和专业知识来构建最终成为世界上最流行的基于 PHP 的 web 框架。

在看到 Laravel 开箱即用的目录结构后,答案并不难理解(你可以在图 8-1 中看到股票根目录)。对于简单的应用来说,这种结构没有任何问题,但是对于域驱动的应用来说,这是行不通的。

img/488324_1_En_8_Chapter/488324_1_En_8_Fig1a_HTML.png img/488324_1_En_8_Chapter/488324_1_En_8_Fig1b_HTML.png

图 8-1

Laravel 的默认目录结构

如果我们打开相应名称空间结构中的文件,我们会发现它符合几乎所有现代 PHP 框架都使用的 PSR-4 自动加载标准。让我们分解 Laravel 的默认名称空间以及它们包含的内容。

默认结构

让我们来看一下 Laravel 用来查找文件的默认名称空间(如表 8-1 所示)。一旦我们对 Laravel 的工作方式有了更好的理解,我们就可以开始充实扩展 Laravel 所需的修改。总而言之,我们将更好地理解 Laravel 的结构,以及我们可以保留和修改它的哪些方面。

表 8-1

Laravel 中的名称空间及其在应用中的对应层

|

目录

|

命名空间

|

描述

|
| --- | --- | --- |
| /app/Http/Controllers | App\Controllers\ | 框架中的所有控制器 |
| /app/Http/Requests | App\Http\Requests\ | 应用中的所有请求 |
| /app/Http/Middleware | App\Http\Middleware\ | 所有中间件 |
| /app/Http/Jobs | App\Http\Jobs\ | 所有工作 |
| /app/Policies | App\Policies\ | 授权策略(基于模型) |
| /app/Providers | App\Providers\ | 应用中的所有服务提供商 |
| /app/Events | App\Events\ | 生成的事件 |
| /database |   | 基础设施问题(数据库) |
| /database/seeders |   | 包含在每个表的基础上在数据库中创建虚拟记录的类 |
| /database/factories |   | 通过从一组标准中生成新的实例,为单元测试系统模型提供工厂 |
| /database/migrations |   | 一个重要的目录,包含数据库的所有变更和创建规范 |
| /resources/views |   | 存放刀片视图文件 |
| /routes |   | 申请途径 |
| /tests | Tests\Unit | 单元测试 |

现在很清楚,我们需要的是一个新的应用结构,一个满足所有先前指导方针的结构,一个专注于有效表达领域的结构。目录的名称表明,目录中的大部分代码很可能与应用层相关,这是有意义的,因为它是一个整体应用的应用框架,该应用不应该由一个以上的部门或几个程序员在给定时间内分发、共享或开发。对于领域驱动的设计,我们想要的是一个尽可能接近实际领域的目录和名称空间结构,这是我们不可能以系统的应用为中心,用目录的方式来完成的。对于我们在前一章中构建的索赔应用的用例来说,名称空间太普通了。

我们希望能够查看该结构,并很好地了解应用实际上做了什么,因为目录结构对应于底层的业务模型,而且目录是以无处不在的语言中定义的术语命名的。这一部分很关键,值得重复:

  • 使模块、类、子域、域以及所有其他与域相关的组件的名称空间反映出在通用语言中达成一致的术语。

通过遵循这个简单的实践,项目的目录和名称空间结构将根据业务域的构造方式被真实地建模,因此,查看项目的根文件夹的任何公司成员都将容易理解。应用由域关注点分解。应用中的模块代表业务本身运行的相同格式,它们使用公司无处不在的语言中的术语进行分离和命名。

幸运的是,Laravel 足够灵活,能够支持我们可以处理的几乎任何目录结构,不管我们在工作的特定领域中可能遇到的复杂程度如何。我们可以利用 Laravel 强大的扩展特性来重新连接名称空间,使之更加领域驱动,这是一个很好的观点。

正如您将在本章后面发现的,尽管 Laravel 的构建不一定没有考虑 DDD 的概念和程序,但这并不意味着它没有使用最佳实践和可靠的标准。具体来说,这里展示的最佳实践是开放/封闭原则。开放/封闭原则基本上意味着一个应用、程序或框架应该对修改封闭,但对扩展开放。这相当于我们应该能够改变 it 的行为或结构,而不必修改构成应用内部设施的核心文件,相反,我们应该有一个预先确定的方法来扩展应用的行为,通常是通过使用附加类或修改高级服务类,甚至修改配置文件或常量。Laravel 可以让你做到这一点。

服务提供商

Laravel 与目录和名称空间结构相关的配置可以在框架源代码的深处找到...不太适合直接修改它,因为它位于vendor/文件夹中。Laravel 使用称为服务提供商的设施来配置系统的各个方面。服务提供者是配置您的应用的中心位置,是注册各种服务、创建服务定义中所需的各种类的实例或配置服务容器的好地方,服务容器为 Laravel 中用于应用层和基础设施层的核心服务提供动力,它们也是我们将用来配置应用的域层的地方。

Note

有关服务提供商以及如何在 Laravel 中使用服务提供商的更多信息,请查看第四章,或参考位于 https://laravel.com/docs/6.0/providers 的 Laravel 文档。

一般来说,服务提供者用于定义服务容器内的各种注册、绑定、单件和别名,这些服务容器可用于创建复杂的、结构良好的和精心制作的服务定义,这些服务定义可在整个系统中使用。在我们开始这些修改的编码部分之前,让我们定义我们希望我们的结构如何寻找索赔项目。

索赔申请的结构

回到我们在前一章关注的索赔示例,我们需要一种方法来组织我们的领域层的结构,同时仍然记住其他层。

Note

尽管我们决定将“索赔估计”的概念作为一个单独的有界上下文,但是由于大量的补充信息,我们将不会在本书的后面部分重点关注该领域的建模,因为一次阅读这些信息太枯燥了。我们将在后面的章节中探讨应用的索赔评估特性的先决条件(例如 CPT 代码组合、支付代码表以及各个 CPT 代码应该如何分组)。现在,让我们关注索赔的提交和验证问题。

我们需要为我们的应用设计一个更好的结构,一个指示业务关注点的结构,它的类和模块是有意命名的,用无处不在的语言定义术语。

构造名称空间和目录的准则

在 DDD 中描述了一套指导原则,这将有助于确保我们在定义应用目录及其名称空间的主干结构时使用最佳实践,该指导原则在 Eric Evan 的蓝皮书中首次正式介绍和强调。

  • 对模块、类、有界上下文和名称空间使用通用语言中的名称。

  • 不要根据特定的模式或构建块来命名任何东西(例如实体、工厂等)。);坚持使用通用语言中的术语。我的观点是,如果它们被包含在一个与无处不在的语言中的一个概念相关的文件夹中,作为一种在技术层面上分离功能的手段,那么它们将是很好的,但是只限于从自然语言的领域中定义的边界的范围内。

  • 以这样一种方式创建名称空间,使得其他名称空间中的各种类或组件之间的耦合非常少。做到这一点的一个好方法是坚持关注点分离原则,使用领域中的术语。

  • 随着对项目需求或领域知识的深入了解,重构名称空间和目录名以包含新的见解,就像我们重构代码本身一样。

  • 避免使用商业产品名称作为名称空间,因为它们变化太频繁。

最重要的是,作为给定业务模型(及其自然结构)基础的概念和隔离应该驱动架构和领域模型的设计。无论您在哪个领域工作,适用于业务本身的有机模型和过程都应该指导关于应用结构的方向和决策,以及实体的选择和它们之间的界限。当领域的各个方面被适当地划分到构成应用所提供的全部功能的各种有界限的上下文和模块中时,并且当这些功能按照它们所代表的真实世界的业务对象进行建模时,我们会看到许多好处,如下所示:

  • 代码是领域模型的清晰表示,这很可能涉及到开发人员和领域专家的共同努力。

  • 领域的每个部分位于代码中的什么位置,在什么上下文中细粒度的部分对应于什么粗粒度的上下文,这是显而易见的。

  • 在哪里放置额外的模块或类的问题更容易回答,因为系统有定义良好的模块,这些模块很容易通过无处不在的语言中的术语来识别。

  • 它使我们有可能将我们的应用过渡到一个更加隔离、定义更加清晰的微服务或六边形架构。

  • 我们可以彼此独立地开发每个有界上下文(甚至在模块级别),这意味着它们可以使用不同的语言,并且可以由单独的小组或部门来处理。

  • 尽管在部署时您仍然需要部署整个代码库,但是应用作为独立的组件将更容易管理,并且您将更接近于将其转换为完全分布式的架构(如微服务)。

索赔属于哪一类?

我们将不得不修改这个结构,使其不那么通用,而更具体地针对我们的索赔处理示例。由于我们还没有设计到可以开始绘制构建块组件的状态,我们将需要用代码实现概念验证,我们将使用我们所知道的来勾画出一个粗略的目录和名称空间结构,记住我们可以总是并且应该总是重构模型及其实现,以反映该领域中获得的任何知识。

我们对这款应用了解多少?我们知道有界的上下文,我们知道子域,我们对我们需要的模块有一个粗略的了解。我们需要清楚明确地区分有界上下文,将它们彼此以及与应用的其他部分适当地分开。这种分离应该基于领域的轮廓,或者存在于业务领域中的概念或部门的自然分割。我们还想记住,我们使用的是分层架构,并且层本身也提供了足够的分离手段。让我们从我们的有界上下文开始(注意,现实中有更多,但是到目前为止,在本书中我们将集中于我们在第六章中描述的三个)。见图 8-2 。

img/488324_1_En_8_Chapter/488324_1_En_8_Fig2_HTML.jpg

图 8-2

我们索赔处理器的有界环境

简单回顾一下,流程如下:

  1. 索赔通过提供商(或诊所)的办公室进入系统,仅允许由PracticeProvider用户类型输入。

  2. 填写初始表单后,在实际提交给审核小组进行审核之前,会出现自动流程,以确保索赔符合所有有效标准,能够提交给审核人员,此时索赔已“提交”这些流程包括以下内容:

    • 验证患者/提供者注册

    • 确保所有必需的文件都附在索赔中

    • 验证患者资格

    • 查找 paycode 表并验证 CPT 代码组合(我们将在本书后面讨论)

  3. 虽然在此模型中还没有表达出来,但审查流程是提交索赔后接下来要做的事情,此时,在将索赔的所有数据发送到 FQHC 进行付款报销之前,都要经过审查者的手动验证。

通用子域

authorization/authentication 上下文是一个通用子域,因为它跨越了所有其余的上下文,并深入到模块本身的核心。这些有时被称为横切关注点(也就是说,一个应用的特定部分足够通用,可以作为一个给定软件问题的事实上的标准解决方案,跨所有其他有界的上下文来实现)。虽然它们不是项目的主要焦点,但是它们在系统中扮演着一些重要的角色,这是系统正常运行所必需的。像这样的问题应该被隔离并封装到它们自己的名称空间目录中。

Note

一般情况下,通用子域使用第三方现成的解决方案来实现,在这些解决方案中,构建或维护它的成员被认为是通用子域所属领域的专家。这方面的一个很好的例子是 Laravel 决定将框架过去通过 Artisan make:auth命令提供的授权框架的 UI 部分 86 化,以支持使用专门做这件事的第三方供应商:管理用户的授权和认证、创建和管理组、创建和执行权限检查、创建用户角色,以及完成现代授权管理系统支持的任何其他功能。厂商是 Auth0 ( https://auth0.com/ )。

以下是一些跨领域问题的例子:

  • 日志:错误可能发生在我们系统的任何一层。通常,某种形式的记录器有一个单独的实现,在整个应用的许多地方都可以使用,包括用于调试和信息目的的所有三个架构层。

  • 安全性:认证(“登录”)本身是一个通用子域。封装在该关注点中的功能负责管理用户、权限和角色,因为它们被用作在系统中实施安全策略的标准方式。

  • 通知:我们的应用必须为应用中的每个交付机制(API 端点、网页等)启用通知。)这样我们就可以在用户提出请求后,向用户传达(或不传达)应用的状态和状况/良好状态。

从现在开始,我们将把安全问题建模为一个独立的“通用子域”,如图 8-3 所示。

img/488324_1_En_8_Chapter/488324_1_En_8_Fig3_HTML.jpg

图 8-3

新形成的有界上下文(左)和它们对 Auth 的使用,Auth 是一个通用子域,也包含域为每个用户类型实现的用户类

不要失去视线

既然我们已经尝试了在上下文层次上分离关注点,我们就有了一个粗略的草案,从各方面来看,这是系统可能的最高层次的视图,至少对于这次讨论来说是这样。然而,请记住,您创建领域驱动设计的最终目标,简而言之,等同于构建一个模型,在这个模型中,边界将各种组件分开,以便它们反映真实世界中对应组件的结构和上下文。总是试图让领域和领域的模型保持步调一致。我们希望设计一个应用,它能揭示意图,有意义,并与他们存在的要实施的策略和业务规则相关。我们希望能够在模型中捕获领域的所有古怪之处,最终,通过进行频繁的提交,一次狭隘地关注单个任务,遵循编码指南和最佳实践,并采用从持续迭代范例中借用的概念,努力改进模型以尽可能地符合真实世界的领域。

对于任何关于架构设计的粗略草案或松散规范,我们需要记住的是向后工作,看看给定的设计是否仍然满足该领域的条件和要求,并以简单、结构化和明确的方式恰当地表示它。让我们快速浏览一下这个领域,看看我们创建的有界上下文是否与我们到目前为止建立的模型一致。通过重新检查我们的工作并确保设计符合领域的需求,我们为成功做好了准备,特别是当我们的项目有一个完整的 CI/CD 管道设置为在每次提交时自动运行时。此外,自动运行的测试套件(通常与 CI/CD 管道放在一起)甚至可以进一步稳定软件项目。

DDizing Laravel

在本章的持续时间里,我们将讨论一个潜在的解决方案来实现一个利用 Laravel 框架的能力的架构,同时保持 DDD 所建议的概念和对领域模型的关注。

通过分层架构

为了创建这个 DDD 友好的 Laravel 应用,我们必须包含一个新的名称空间和目录结构,因为默认的名称空间和目录结构不能满足我们的需要(过于单一)。对于我们的第一次尝试,我们将采用分层架构模式,并尝试将 monolith 划分为与我们现在应该习惯看到的三个架构层相对应的各种组件:域、应用和基础设施层。

定制 Laravel 以满足我们的需求(与 DDD 概念和实践相关)的第一步是运行当前的股票目录/名称空间,并将每个目录/名称空间分类到相应的层中。我们已经开始了这个过程(见表 8-1 )。这是这样做的结果,但首先,快速回顾一下每一层包含的内容:

  • 应用层

    • 负责编排、组织和封装域行为并控制数据访问
  • 基础设施层

    • 通过实现领域层中定义的抽象接口(通过依赖倒置),处理持久性机制和日志记录等问题
  • 域层

    • 任何 DDD 项目的核心和灵魂(也应该是每个 web 开发项目的核心和灵魂),包含了使业务实际运行的核心和基础

表 8-2 列出了 Laravel 默认架构中的各层。

表 8-2

根据每个目录所属的层定义股票结构

|

目录

|

建筑学的

|

描述

|
| --- | --- | --- |
| /app/Http/Controllers | 应用 | 控制器接受进入系统的请求,并通过一个单一的、明确定义的结构(称为 API)响应不同的交付机制。控制器可以被认为是外部请求和内部域过程之间的仲裁者。 |
| /app/Http/Requests | 应用 | 封装请求,并将它们与交付机制和域/基础设施问题分开。 |
| /app/Http/Middleware | 应用 | 中间件在请求/响应周期中的特定时间运行,通常执行应用层中的操作。 |
| /app/Jobs | 领域 | 作业可以被认为是“命令”,或者是封装在单个Job类中的狭义任务。 |
| /app/Policies | 应用 | 系统的策略在请求级别控制对每个模型的访问;认证问题是应用层的问题。 |
| /app/Providers | 应用 | 我们将使用服务提供者来配置我们的自定义目录结构,并为第三方软件包提供一种配置方式。 |
| /app/Events | 领域 | 当领域层中发生了特定的事情时,事件将被触发,并将作为该动作的记录,该动作可能会触发其他动作。 |
| /database | 基础设施 | 数据库目录包含所有数据库问题和模式配置,它们在基础结构层中运行。 |
| /database/seeders | 基础设施 | 种子是数据库的测试记录。 |
| /database/factories | 基础设施 | 数据库测试工厂。 |
| /database/migrations | 基础设施 | 迁移包含一个详细的、每次更改的数据库模式更改记录。 |
| /resources/views | 应用 | 这也可以放入一个单独的层,称为“视图层”或“表示层”,通常只包含 UI 问题 |
| /routes | 应用 | 路由定义了可以进入的内容和 URI 的配置,最终导致控制台命令运行或网页显示 |
| /tests | * | 在一个构造良好的系统中,通常会找到覆盖应用中每一层的测试 |

在表 8-2 中需要注意的是,大多数目录都封装了应用问题(即属于应用层)。当您考虑到 Laravel 是一个“应用框架”这一事实时,这是有意义的换句话说,在典型的 web 开发项目中,它在整个系统中处理尽可能多的应用级关注点,因此,默认结构中的应用关注点构成了目录和名称空间的大部分是正确的。

事实上,从 Laravel 的文档中可以看出,它打算为您提供最实用的解决方案,以解决最常见的应用级别的问题,这样您就可以专注于对任何 DDD 应用来说最重要的事情:领域。它允许应用的“连接点”具有灵活性。我指的是将各种组件连接在一起的代码,比如服务提供者和依赖注入。

也就是说,我认为这里最好的方法是让 Laravel 做它擅长做的事情,即管理各种各样的应用问题。我们将重新构造名称空间的布局,在应用中分割出各种有界的上下文。我们将保留根应用文件夹,这是 Laravel 的默认项目文件夹,但只保留适用于整个应用并且不适合我们声明的有界上下文的文件和目录。这些包括服务提供者、策略、控制台内核和负责分配中间件组和中间件路由的App\Http\Kernel

我们将按照以下步骤对其他所有内容进行分析:

  1. 分析项目根目录中的组件、类或模块,并确定其所在的层。

  2. 确定每个组件属于哪个有界的上下文(或者它们是否出现在所有上下文中),或者它们是否应该留在通用的app/文件夹中,因为它们属于整个系统而不是单个上下文。

  3. 将项目从它们在根项目目录中的默认位置移动到它们在一个或多个有界上下文中各自的位置(在某些情况下,我们将不得不复制应该包含在每个模块中的结构的一部分,作为属于它们相应上下文的独立构造)。

  4. 一旦我们确定了一个稳定的结构,我们将对配置和服务提供者进行必要的修改,让 Laravel 知道在哪里可以找到与系统中各种端点相对应的代码。

  5. 修改composer.json文件,以包含在上一步中创建的新名称空间结构的自动加载,并创建必要的提供者,以将我们的项目代码与 Laravel 的内部机制挂钩。

  6. 不断重新审视核心需求,以确保我们的设计满足系统的需求和要求。

开始吧。

步骤 1:分析项目根目录结构

如果我们采用一个标准的 Laravel 安装,并仔细检查它的默认目录结构,我们很可能总是会发现结构的某些部分,要么是某个特定项目不需要的,要么是对于我们所需要的结构来说过于死板。Laravel 中的标准结构并不是模块化的,而是将其大部分“项目代码”塞在/app目录中。对于我们这些想要使用 DDD 实践来构建应用的人来说,这并不理想。

一个好的第一步是分析 Laravel 中默认的目录结构,并将其与您项目的需求进行比较。这可以让您了解如何修改名称空间结构以符合您的需求。例如,如果您正在构建一个五页的信息网站,只有一个联系表单,可能还有一些内嵌的图形,您很可能不需要 Laravel 提供的大量组件,也不需要(或想要)为这样一个琐碎的项目实现 DDD。另一方面,您可能会发现项目的范围相当复杂,最好用更正式的名称空间结构来表示,因此您可能倾向于将app/目录分成更易于管理的层(即应用、域、基础设施和接口层)。

步骤 2:确定每个有界上下文需要哪些组件

看起来我们实际上正在处理两个有界上下文以及一个通用子域,我们将遍历默认结构,并确定在每个有界上下文中需要哪些(声明提交、声明验证)以及哪些属于通用子域(授权/认证)。在表 8-3 中需要注意的是,我们在第二列中确定的有界上下文不一定反映给定的结构或名称空间。例如,索赔提交上下文(因此用户可以通过浏览器与应用交互)和 Auth 子域(因此用户可以登录、注销和注册)将需要 Views 组件。

表 8-3

在我们的索赔示例中,每个有界上下文中需要的组件

|

拉勒韦尔分量

|

索赔上下文

|

说明

|
| --- | --- | --- |
| → Http→控制器→请求→中间件 | 提交索赔索赔验证认证 | 所有的 web 应用都有相同的基本流程:接收请求,返回响应。这些组件驱动这一基本需求,因此我们需要它们用于所有的上下文。 |
| →工作 | 提交索赔索赔验证 | 作业将封装特定领域的知识,并提供执行特定的一次性操作的方法。 |
| →政策 | 认证 | 尽管策略围绕给定的模型,但一般来说,对身份验证的关注是对应用的关注,这是有意义的,因为应用层是域层的直接客户端。 |
| →供应商 | 通用(应用/)提交索赔索赔验证认证 | 应用的所有层都需要服务提供商,包括驻留在/app中的通用服务提供商。每个 BC 也有自己的,并且有一个内置的授权关注点:AuthServiceProvider。 |
| →事件 | 通用(应用/)提交索赔索赔验证认证 | 事件将发生在应用的所有层:领域事件、授权事件和系统范围的事件。 |
| → Artisan 命令 | 提交索赔索赔验证 | 对于各种一次性任务和定期维护,控制台命令非常方便。我们希望我们的两个 BC 都有一个Console\Command名称空间。 |
| →例外 | 通用(app/)提交索赔索赔验证认证 | 异常可能在应用的任何层随时发生,包括一般的系统问题和身份验证错误。 |
| →视图 | 提交索赔认证 | 我们最初需要输入数据的视图来创建索赔以及登录/注销 UI。验证组件不应该需要它,因为它的功能发生在后端。 |
| →路线 | 提交索赔索赔验证认证 | 为了允许从外部资源访问我们的应用,我们需要路由。我们还需要能够将事情路由到后端,所有的业务连续性,我们正在从应用中删除默认的通用路由文件。 |
| →测试 | 提交索赔 o 单位 o 功能索赔验证 o 单位通用/授权 | 所有 BC 都有某种形式的测试,或者是单元测试,或者是单元测试和功能测试。我们可能有也可能没有授权、认证或其他通用组件的测试。当我们这样做的时候,我们可以为他们腾出空间。 |

这并不表示在每个相应的上下文中都应该有一个views/文件夹。我们将在本章的后面详细介绍我们的应用的结构。

表 8-3 是一个目录,可以这么说,默认结构与我们试图实现的内容之间的关系,将很好地作为每个上下文需要哪些组件的通用指南,稍后将用于确定支持它的名称空间和目录结构。在的大蓝皮书 (Eric Evans)中,他指出,通常,框架会强迫你进入一种特定的风格,或者把你限制在它们自己采用的结构中,这使得按照我们想要的方式实现事情变得更加困难...DDD 建议的方式。

然而,我的目的是在一个两者相辅相成而不是互相争斗的框架内展示 DDD 的一种可能的实现。Laravel 将主要用于其设施和主干特性,为应用层中的应用级问题提供答案。这将使我们能够集中精力实现 DDD 定义的模式和实践,而不必过多地担心应用问题或“夹缝”中的地方,这些地方经常是导致开发和维护失败和浪费时间的原因。

Tip

DRY 原则在编程界是一件大事。如果你还不知道 DRY 原则是什么,它是一个简单的缩写,意思是“不要重复自己”这是一条很好的建议,也是我们在开发软件时应该一直尝试采纳的建议。

步骤 3:重组项目目录

这本书的这一部分很难写,仅仅是因为有太多不同的方法去设计一个领域驱动的结构,没有一个答案是 100%确定的方法来创建系统的架构。所以,我认为最好用索赔的例子来记录我自己的经历。首先,我想简单介绍一下 claims 应用的背景,它启发了本文中使用的示例。

A Brief History of the Claim Application

我工作的应用由我们的团队继承,最初是作为一种管理与联邦监管的 Medi-Cal 计划相关的索赔提交流程的方法而开发的,从本质上增加了自有医疗机构可以支持的患者就诊次数,并允许医疗提供者更加专注于他们最擅长的事情:诊断和治疗患者。离岸团队利用了 Laravel 框架,但是离岸团队最初建立的整体架构结构设计得很差(如果它实际上是设计的在任何种类的战略规划的意义上),并且实现是臃肿的。关注点分散在整个应用中,对代码或语法标准的采用没有任何一致性,等等。然而,尽管很糟糕,它还是起作用了。

在作为一个创收的企业对医疗提供商的 web 应用在市场上进行初步构建和概念验证后,所有者决定雇用一个内部开发团队来接管该项目,目标是实现长期可持续性,并能够快速高效地推出新的业务功能。因为该公司是一家初创公司,他们面临着大多数初创公司在开发软件时的典型需求:在整个应用开发过程中,需要随时添加或删除功能。虽然这一愿景最终成为了现实,但最初当第一个内部团队进入代码库时,他们的大部分时间都在灭火(例如,修复 bug)。

由于离岸团队缺乏经验以及其他问题,使得与十人开发团队进行远程工作成为一个更大的挑战(如语言交流障碍),一些概念被误解,其他概念被歪曲或毁容。这经常会留下一堆代码,使存储库变得臃肿;“工作”代码几乎正常工作。修复 bug 通常意味着应用停止运行,我们会赔钱。放下一切,修复应用。当这种情况发生时,团队中的每个开发人员都跳上他们的计算机,在浏览了一连串的堆栈跟踪、异常转储和日志文件之后,开始相互交换想法,看问题可能是什么以及如何在尽可能少的时间内最好地修复它。

我想说的是,我工作了一年多的应用,作为这个例子的基础,绝不是以领域驱动的方式构建的,甚至也不像任何优秀的开发人员所认为的“最佳实践”在解决领域问题的代码的不同部分周围,边界通常是模糊的,因此我们的任务后来变成了将领域的各个部分重构为单独的新结构,这些新结构或多或少地被设置为好像它们是微服务(即,独立的、领域驱动的模块,在领域的每个部分周围有适当放置的屏障),然后我们使用反腐败层模式在同一个整体应用中实现这些微服务,作为将新的自包含上下文集成到原始代码库的一种方式。原始客户端仍然用于相同的调用,只是这些调用都必须更改为指向在我们的反腐败层中建立的新上下文。

我将要向你们展示的将反映我自己的经历,将突出我所面临的考验和错误,并将展示我所决定的最终结构。这将最好地反映以特定领域的形式设计和建模软件的真实世界的经验,并显示在提出一个可行的解决方案方面期望什么,该解决方案将做的不仅仅是“工作”它最终将为在其上构建任何其他未来组件提供基础,并且它可以被分解为独立的微服务,这相当容易,因为它们是根据核心域的自然边界构建的,这些边界将一个域概念组与另一个域概念组隔离开来。那个项目的目标是最终达到一个分布式系统,这是一件困难的事情;然而,您在近期(例如发现缺失的空白或领域中未知的角落)和长期(例如独立部署各种组件并让不同的团队、部门或完全用不同的编程语言开发它们的能力)都获得了回报。

框架应用关注点

既然我们已经有了对每一层中的组件进行分类的明确方法,我们需要想出一个同样好的方法来根据它们各自的有界上下文来组织它们,我们将通过目录结构和名称空间选择来完成。

Tip

请记住,这不是最终的结构,而只是一个粗略的草案。稍后,我们将移动东西并重构这个结构,以便更好地组织。当我们在项目中引入六边形方法时,就会出现这种情况。

让我们看看项目结构在修改之后,但在安装我们自己的有界上下文和域层之前是什么样子,如图 8-4 所示。

img/488324_1_En_8_Chapter/488324_1_En_8_Fig4a_HTML.png img/488324_1_En_8_Chapter/488324_1_En_8_Fig4b_HTML.png

图 8-4

索赔项目的新目录和名称空间结构

Note

关于剧透,请参见本书网站或 Apress.com 的该项目的知识库。

在图 8-4 中,我们基本上展示了相对于几页前我们在表 8-2 中定义的应用的修改结构。这里介绍的重新安排在架构上为我们做了一些事情。

  • 我们有一个更加清晰的关注点分离,因为我们使用 Laravel 自带的设置,将所有应用于系统的应用范围的组件作为一个整体(而不是我们的有界上下文)保存在项目的根目录中...大多数情况下。

  • Laravel 解决应用问题的默认组件仍然在默认的/app文件夹中,并且对应于App\名称空间,这使得我们可以更容易地识别给定类的作用和位置。

  • 所有的领域问题都被整齐地封装在/src文件夹中,我们需要在我们的composer.json文件中设置这个文件夹,这样它就能识别我们的领域层的新名称空间:Claim

  • 我们已经将 Laravel 附带的几个目录移到了我们的域层的边界内(比如JobsEvents等)。).

然而,这种结构也有缺点,因为在软件开发中,几乎所有其他看起来完美的东西都有缺点。我想到的主要缺点是架构似乎有点分散。然而,这样做的原因可能是因为实现这一点的开发人员不习惯于 Laravel 风格的目录结构,并且一开始试图记住所有这些文件夹以及它们的作用可能会有点困难。我觉得多一点经验和对框架的使用将有助于减轻这一点。

我们设计的结构反映了 Laravel 的结构。除了添加了src/文件夹之外,它几乎包含了自框架安装以来就存在的所有其他组件。我们只是对项目根目录下的文件夹做了一些更改,这些文件夹通常存在于传统的 Laravel 应用中,并以 DDD 的名义重新使用。这个决定将帮助我们保持应用的秩序,并允许我们直接关注核心领域模型和领域层中存在的应用的各个方面,并在我们着手开发各种将拼图拼在一起的部件时为我们带来回报。因此,即使整体结构看起来有点忙,也有一个疯狂的方法,这将随着我们继续关注我们的领域而展开。

附加目录

我们需要更多的目录来实现系统的各个方面。我认为最好是在维护我们赖以构建架构的核心领域驱动焦点,同时仍然允许框架承担应用关注的重担之间找到一些中间地带,这是它做得最好的。我们将使用 Laravel 的标准目录和名称空间名称,尽管我们可能会将它们重新定位或复制到其他有界的上下文中。在不使初始结构过于僵硬的情况下,我们需要为这些组件腾出空间,如下面的列表所示:

  • 控制台(命令):应用范围的 Artisan 命令。

  • 异常:应用范围的异常。

  • Http。

    • 控制器。

      • Auth。
    • 中间件:标准的 Laravel 中间件。

    • 请求:在应用范围的上下文中不需要。

    • 移动到域层。

  • 事件:应用中的全局事件。

  • 监听器:全局事件的全局监听器。

  • 模型:目前唯一适用于应用的模型是User模型,我们可以稍后决定迁移它。

  • 观察者:适用于整个系统的观察者。

  • 策略:策略与域对象相关,并根据给定的模型命名。移动到域层。

  • 提供者:在高层次上配置系统的 Laravel 标准提供者。

请注意,我们正在为应用于全局上下文(或系统/应用范围的上下文)的应用的移动部分蚀刻出一个全局上下文。我们将使用这个结构作为有界上下文的模板,但是请记住,我们不希望只是为了维护约定或遵守框架名称限制而添加文件夹。就软件开发而言,简单总是比复杂好,小总是比大好,这表明细粒度组件通常比粗粒度组件好。然而,真正重要的是对象和类的粒度级别是否反映了底层领域的粒度级别。如果我们构建一个简单的单页面应用,其中包含一个在提交时发送到后端的表单和另一个用于确认消息的页面,我们可能不需要几个小组件(包括代表提交的表单数据的Form模型,可能通过 IP 地址进行跟踪)。另一方面,如果我们从头开始构建一个电子商务平台,我们将需要一个非常复杂的模型阵列来容纳事情发生时发生的所有逻辑(用户查看产品并为该产品选择所需的属性和变化;用户将产品添加到购物车,然后进行结账;等等。).无论您决定创建的模型和组件有多粗糙或多精细,如果您将它们建模为现实生活中的业务规则和底层策略(从业务领域发展而来,并以无处不在的语言命名),就不会出错。

领域问题

让我们深入到src/文件夹,把我们最初的域结构放在一起。请记住,此时的最终目标是设计一个布局,让我们以领域为中心的组件能够独立生存和开发。我们将尽我们所能,利用我们目前能够获得的关于核心领域(声明)的知识。我们打算利用各种实践并保持高质量,同时依靠基本原则来做出决策。

目录结构:索赔提交上下文

对于领域模型中的每一层,每个有界上下文都有一个文件夹:ApplicationInfrastructureDomain。因为我们在很大程度上保留了/app文件夹,因为它是在一个普通的 Laravel 安装中,所以我们现在基本上可以认为这个文件夹是框架所关注的。/app现在包含了 Laravel 特有的物品,这将使我们在以后需要时更容易添加更多的物品。这使得src/文件夹成为与域相关的系统的实际“模型层”。然而,在领域的保护伞下,作为一个整体,需要一个清晰的边界来围绕它的子层次架构问题,这就是为什么我选择为每一层包含一个目录(图 8-5 )。这也将支持长期增长,因为任何特定的代码片段都应该立即存在于何处,这一点很清楚。

img/488324_1_En_8_Chapter/488324_1_En_8_Fig5_HTML.png

图 8-5

提交受限的上下文结构

关于我们构建系统布局的方式,需要注意的另一点是,我们利用了无处不在的语言中的术语,并且只为模式或“类型”(即工厂、控制器等)创建分离。)位于直接属于域(并从 UL 中提取)的父名称空间之下,并且实际上属于域模型中的给定上下文。

因此,我们在图 8-5 中所做的是将每一个标准关注点(用 Laravel 的术语来说)分解到有界上下文中的相应层。请记住,我们希望每个 BC 都独立于其他 BC(松散耦合),以便有一天我们可以将它们拆分成独立的微服务,这样它们甚至可以放置在不同的服务器上,或者由不同的部门或团队开发。这相当于有界上下文需要包含所有的标准组件,使其能够独立运行。当它们达到这一点时,它们被认为是松散耦合的、可重用的组件,甚至可以单独部署或用不同的语言编写。

以下是关于图 8-5 中结构的一些附加说明:

  • 之前,我们将策略确定为一个应用问题;我们在提交上下文中包含索赔策略,因为我们希望我们的上下文尽可能独立,并且与应用的其余部分分离。我们希望将它们放在各自的上下文中,这与该策略适用的模型相同。例如,ClaimPolicy 将存在于提交上下文中,因为Claim域模型也存在于其中。

  • 我考虑过创建一个Database文件夹,它位于有界上下文的基础设施层中,只存放该上下文的数据库问题(迁移、种子、测试工厂),但是,现在,我决定保留标准的根/database文件夹的位置不变,并在其中存放所有数据库问题。我对此的理由是,迁移等是系统范围的过程,可能会跨越许多上下文,这将很难单独管理每个上下文自己的database/文件夹,试图分割域逻辑是徒劳的。这是应用的一个区域,不应该在有限的上下文中分割,而应该留在全局的、应用范围的上下文中。

  • 索赔提交上下文中的基础设施包含了我们将在本书后面访问的许多模式。这个想法是,我们在我们的Domain\Contracts名称空间中定义接口(用于与持久性相关的存储库、工厂或类),然后在Infrastructure\名称空间中实现它们,这允许我们维护一个域驱动的方法,并保留“D”的实体(依赖倒置)。基础设施支持模型,处理持久的问题,并为域层的居民提供跨请求操作的方法(由于 HTTP 除了会话之外没有默认的状态感)。

  • 在有界上下文的每一层中都有一个Service目录,以允许它们是自包含的,并且彼此适当地分开。有关不同层中不同服务的复习,请参阅第一章。以下是您可能会在每种情况下发现的一些示例:

    • Application\Service

      • laimLoggingService

      • ProviderNotificationService

      • EmailNewsletterService

    • Domain\Service

      • ClaimSubmissionService

      • ProviderRegistrationService

      • PaycodeSheetVerificationService

    • Infrastructure\Service

      • FilterSpecificationService
  • 尽管我们根据目录包含的模式来命名目录,但是我们是在有限的上下文范围内这样做的。我们仍然保持着实现和底层业务领域之间的连续性,并且在将来会继续这样做,用无处不在的语言中表达的术语来命名事物。

目录结构:声明验证上下文

索赔验证上下文将具有与索赔提交上下文相同的核心结构。一个明显的区别是,验证上下文根本不需要自己的一组刀片视图或表示层,因为它将用于自动验证,并且在索赔数据出现问题或违反索赔提交的前置和后置条件时,将简单地向提交上下文返回某种类型的特定Exception类,然后提交上下文将处理异常并通过其表示层通知用户。

步骤 4:更新配置

为了使我们之前设计的结构实际工作,我们需要让 Laravel 知道在我们的有界上下文中哪里可以找到各种组件,因为我们编写的大部分代码最终将存在于这两种上下文之一:声明提交或声明验证。

app/Providers/routeserviceprovider . PHP

因为我们采用了新的目录来存储有界上下文的各种路由,所以我们需要告诉 Laravel 在哪里加载这些路由,以及我们希望如何为我们的域层构造路由。我们在app/Providers/RouteServiceProvider.php文件中做任何特殊的路由配置(清单 8-1 )。

<?php
namespace App\Providers;
class RouteServiceProvider extends ServiceProvider
{
    /**
     * This namespace is applied to your controller routes.
     *
     * In addition, it is set as the URL generator's root namespace.
     *
     * @var string
     */
    protected $namespace = 'App\Http\Controllers';
    protected $submission_namespace = 'Claim\Submission\Application\Http\Controllers';
    protected $submission_dir = 'src/Claim/Submission/Application/Routes/’;

    /**
     * Define your route model bindings, pattern filters, etc.
     *
     * @return void
     */
    public function boot()
    {
        parent::boot();
    }

    /**
     * Define the routes for the application.
     *
     * @return void
     */
    public function map()
    {
       $this->mapApiRoutes();
       $this->mapWebRoutes();
    }
    /**
    * Define the "web" routes for the application.
    *
    * These routes all receive session state, CSRF protection, etc.
    *
    * @return void
    */
    protected function mapWebRoutes()
    {
        Route::middleware('web')
           ->namespace($this->namespace)
           ->group(base_path('routes/web.php'));

        Route::middleware('web')
           ->namespace($this->submission_namespace)
           ->prefix('submission')
           ->group(base_path($this->submission_dir . 'web.php'));
    }

    /**
     * Define the "api" routes for the application.
     *
     * These routes are typically stateless
     *
     * @return void
     */
    protected function mapApiRoutes()
   {
      Route::prefix('api')
             ->middleware('api')
             ->namespace($this->namespace)
             ->group(base_path('routes/api.php'));
      Route::prefix('submission/api')
             ->middleware('api')
             ->namespace($this->submission_namespace . '/Api')
             ->group(base_path($this->submission_dir . ‘api.php’));
    }
}

Listing 8-1Modified RouteServiceProvider, with Updates Shown in Bold:/ddl/app/Providers/RouteServiceProvider.php

Note

如果你一直跟随,你将需要通过 Laravel 命令artisan route:clear清除你的路线缓存,并且总是可以通过artisan route:list.验证你的路线是否被 Laravel 选取。如果运行 clear cache 命令给你一个你不认识的错误,请在本章末尾再次尝试运行它。

本质上,除了加载 Laravel(在/routes内)附带的标准路由之外,我们在这里所做的是添加对我们的域相关上下文的支持,并说明在哪里可以找到应用的定制路由。mapWebRoutes()方法通过Route facade 提供的namespace()函数给出一个定制的路由名称空间。路由名称空间定义了特定名称空间的位置,该名称空间将是属于该名称空间(和有界上下文)的控制器将位于其下的容器。在我们的例子中,我们正在配置提交上下文的路由,所以我们希望将这个名称空间设置指向我们为这个有界上下文定义的主Controller名称空间:Claim\Submission\Application\Http\Controllers。我们指定它位于目录src/Claim/Submission/Application/Routes/中。现在,当我们在一个路由文件中定义我们的路由时,就像前面提到的文件名为web.php的目录中的路由一样,我们可以指定这个语法来引用控制器,这样就可以很容易地配置我们的应用的路由结构:SubmissionController@index

它指向类Claim\Submission\Application\Http\Controllers\SubmissionController并调用包含在index()方法中的逻辑,该方法被设置为位于文件src/Claim/Submission/Application/Http/Controllers/SubmissionController.php中。

路由前缀只是应用主 URL 之后的 URI 的一部分。我们所做的是配置路由文件中定义的路由(web 路由的web.php和 API 路由的api.php),分别以进程/submission/submission/api开始。然后,我们将路由前缀和我们通过路由外观使用其group()方法给它们的名称空间组合在一起,传递一组应该应用链式配置的有效路由作为其参数,这是我们在相应的路由文件web.phpapi.php中定义的,如下所示:

<?php
// configured to run SubmissionController::index() when the route
// "/submission" is hit with a GET HTTP request
Route::get('/', 'SubmissionController@index');

// configured to run SubmissionController::submit() when the route
// "/submission/submit" is hit with a POST HTTP request
Route::post(‘/submit’, ‘SubmissionController@submit’);

app/Providers/eventserviceprovider . PHP

Laravel 的事件系统使用 Symfony 的事件组件,并提供了一个额外的配置层,用于配置事件和侦听器的位置,以及关于应用中事件和侦听器的其他功能。我们想告诉 Laravel 这些事件的侦听器在哪里,我们可以通过将这个方法添加到EventServiceProvider类来做到这一点,这将覆盖它在父类中定义的默认方法(清单 8-2 )。

/**
     * Get listener dirs that should be used to discover Events.
     *
     * @return array
     */
    protected function discoverEventsWithin()
    {
        return [
            $this->app->path('Listeners'),
            $this->app->path(base_path(
                 'src/Claim/Submission/Application/Listeners')),
            ];
    }

Listing 8-2Updates to the EventServiceProvider to Specify the Location of the Listeners

步骤 5:创建新的 ClaimSubmissionProvider

随着我们在整本书中继续这个例子,我们将需要一个地方来放置我们的更通用级别的配置和定制,这些配置和定制是特定于特定的有界上下文的。我已经决定在每个 BC 的Application目录中放置一个Providers名称空间,以便每个目录可以包含多个提供者,按照领域模型的既定业务需求进行分隔,并以无处不在的语言命名。

Note

因为我们使用了在composer.json中指定的完全分离的名称空间,Artisan 命令行工具的make:*命令集将无法正常工作,无法在域上下文的边界内创建组件。为了解决这个问题,您可以发出make:*命令而不指定生成文件的 FQDN,然后将它移动到您需要的位置并修改名称空间,或者简单地创建一个新文件并复制预先存在的提供者的内容并在需要的地方进行修改。为了简单起见,我选择了创建-粘贴-编辑方法,而不是后者。

在名称空间Claim\Submission\Application\Providers\ClaimSubmissionProvider中创建一个新的.php文件,并将清单 8-3 中所示的代码放入其中。

// ddl/src/Claim/Submission/Application/Providers/
     ClaimSubmissionProvider.php

<?php

namespace Claim\Submission\Application\Providers;

use Illuminate\Support\ServiceProvider;

class ClaimSubmissionProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }

    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        $this->loadMigrationsFrom(__DIR__ .
            '/../../Infrastructure/Database/migrations');
     //$this->loadTranslationsFrom(__DIR__.'/../resources/lang',
            'domain-driven-laravel');
     // $this->loadViewsFrom(__DIR__.'/../resources/views',
             'domain-driven-laravel');
     //$this->loadMigrationsFrom(
            __DIR__.'/../database/migrations');
     // $this->loadRoutesFrom(__DIR__.'/routes.php');
    }

    public function register()
    {
         $this->mergeConfigFrom(__DIR__.
            '/../config/claim_submission.php',
            'domain-driven-laravel');
    }
}

Listing 8-3The New ClaimSubmissionProvider Service Provider

Class

到目前为止,我们的服务提供商在其引导方法中只包含一个设置,这就是上下文迁移所在的位置。在这种情况下,我们将迁移保存在Infrastructure文件夹中,在Database名称空间内,并使用标准的 Laravel 命名约定,用于框架提供解决方案的标准数据库问题(其中之一是通过将数据库模式变更递增地记录到所谓的数据库迁移中来前滚和回滚数据库模式变更的能力)。

还要注意,我在这里加入了一些额外的方法,作为我们调整 Laravel 默认行为的其他方法的参考。它们被注释掉是因为我们还不太需要它们,但是例如,当我们想要将视图和翻译拆分到它们单独的上下文中,或者甚至拥有不同的语言文件来支持国际用户时,只需简单地改变服务提供商的引导方法就可以做到这一点。最后,在清单 8-3 中,您会注意到 register 方法只是从config/claim_submission.php文件中加载任何已定义的配置值。有关配置的更多信息,请查阅第四章。

Utilizing Third-Party Packages, Frameworks, Tools

在当今这个时代,我们经常会看到各种不同的技术被独立开发出来,但最终都聚集在一起,提供一个完整的工作应用。公司通常只有精选的几个基础技术栈来作为公司软件架构的基础:Solaris、Linux/Unix、Windows 和其他一些技术。核心堆栈的选择应该基于系统的需求、基础设施的支持以及开发团队的经验。如果每个人都非常了解 PHP,并且该项目需要一个基于 web 的架构,该架构可以从一个框架中构建以节省时间和成本,那么开源是更好、更实用的方法。但是,如果您希望在技术体系中获得支持,包括客户支持和软件维护/更新/功能,并且您正在寻找易于使用的产品,那么基于 Windows 的技术体系可能是您的最佳选择。

创业公司倾向于采用开源开发,因为它广泛、廉价且有效。有开源解决方案适合几乎所有常见的开发和互联网需求。很多时候,一个架构的成功实现支持一个狭隘的基于利基的业务策略的特定需求,包括找到预构建的库、框架、工具或任何其他东西,这些东西在没有大量开销的情况下增加业务价值和/或加速开发过程并提高最终产品的质量。然后在实现业务逻辑时利用这些第三方库的功能,并将它们组合在一起以实现一些共同的目标或构建一组功能。

尽管这是以一种或另一种形式作为开发 web 应用软件的手段基本上跨越整个行业的循环,但我们必须小心不要混淆该领域的意图。与此同时,许多这样的包和框架,包括 Laravel,都是按照高质量标准和现代最佳实践构建的,因此利用它们并不一定是件坏事。归根结底,这些包的可扩展性如何,以及修改代码的库存分布以满足您的需求有多容易(这就是开源的伟大之处!).我选择 Laravel 作为我的标准 web 开发框架,因为除了其他原因之外,它简单明了,易于扩展和定制。

第六步:后退一步

在这一点上,我们已经勾画出了我们的 claims 示例项目的边界,并且很好地理解了如何划分领域以使其更易于管理。然而,这种架构远非完美。如果您已经注意到,每个有界上下文中的架构层之间的界限经常是模糊的,因为某些组件应该放在哪里并不总是显而易见的。这就好像我们在和拉勒维尔战斗,迫使它屈从于我们的意志。

向前发展,我们需要寻找我们可以架构系统的方法,以便领域仍然是最高优先级,但是使用我们的领域层可以扩展的 Laravel 组件、特征和接口。通过这种方式,我们保持了一种专注于领域的方法来制作应用,同时还能获得 Laravel 提供的最大好处。

问题在于分层架构的封装规则。它声明域不能依赖于它自己层之外的任何东西。本质上,这意味着,因为我们有一些 Laravel 类或接口想要在领域层的边界内扩展,我们将被迫从不同的层扩展到我们的领域模型中。事实上,当我们后退一步,看看我们正在处理的整个范围时,我们发现在我们迄今为止已经建立的架构中,确实没有合适的地方可以容纳任何存在于/vendor文件夹中的第三方代码或框架库(并且默认情况下没有包含在股票 Laravel 安装中)。但是,当我们试图在一个简单的 UML 图中对此建模时,我们可以看到,对于每一条指向错误方向(即“远离”域层)的线,确实存在一个依赖关系,这表明当依赖关系应该将指向域层时,域依赖于位于域层之外的代码和组件,因此域外的代码依赖于域模型中的类(图 8-6 )。

img/488324_1_En_8_Chapter/488324_1_En_8_Fig6_HTML.jpg

图 8-6

依赖朝着错误的方向发展

打破规则?

为了给我们的应用形成一个更好的架构,我们将不得不打破一条纯粹主义者极力保护的规则。我来解释一下。

我们需要灵活地思考和理解应用的开发。Laravel 是一个框架。这本身就违背了 DDD 的一个基本观点,即框架在领域驱动的项目中使用起来过于严格。作为开发人员,我们需要理解软件和 web 开发的本质,我们很难真正“从零开始”构建任何东西我自己都不知道上一次我从一个空目录和一个空白 PHP 文件开始编写整个程序是什么时候,而不仅仅是一个脚本或一次性的。简单的事实是,纯粹主义者的心态只能存在于完美的世界中。鉴于这个世界远非完美,我们需要在思维过程和观点上更现实一点。

我们为应用创建新结构的第一次尝试在技术上是可行的,但这远不是一个优化的解决方案。使 Laravel 对我们有价值的组件需要一个地方,以便我们的代码可以利用和扩展它。显然,这个地方是/vendor文件夹,我们在/vendor文件夹中引用的项目都有一个结构化的名称空间,位于不同的包中。然而,当您考虑到,在分层的架构中,领域层(也称为业务规则和“低级”策略,对应于您正在使用的实现)应该是自封装的。领域层中的任何东西都不应该知道、使用或依赖于它自己的层之外的任何东西。然而,它可以依赖并使用位于域层中的类或对象。那么,我们应该如何使用任何第三方包、库,甚至框架,而不让领域层内的项目利用其层外的任何代码呢?

有人可能会说,解决这个难题的一个可能的办法是,要么最小化(或者全部移除)任何第三方依赖,或者更荒谬的是,在你的项目中根本不依赖框架。这显然不是一个非常可行的解决方案。软件应该被扩展、构建,并且最好在其他项目中重用,以提供一些给定的功能集,从而避免为需要相同功能的每个项目重写逻辑。无论如何,这就是我们的想法,并且在完美的世界中也是如此,但是现实是,软件重用通常是一件具有挑战性的事情,而且更具挑战性的是,在尽可能利用外部代码的同时,以一种可扩展的、灵活的、与应用的其他部分松散耦合的方式来实现。

事实上,这是现代软件开发领域的驱动概念,被称为快速应用开发。快速开发的思想是使用尽可能多的第三方包、库和框架来实现您的特性、业务逻辑和您需要系统在功能上做的任何事情,从而释放您的时间和精力来关注更高层次的问题和对系统成功至关重要的重要的、必需的部分(这里指的是领域模型)。所有第三方代码都可供您用来构建许多其他应用共有的特性和功能。例如,在 GitHub 和开源包中有很多关于软件中常见问题的库,包括日志、ORM 问题或 CRUD 包。这些都是为了易于使用,并允许您依赖第三方库,这样您就不必为这些常见的问题重新发明轮子。

另一个可能的解决方案是反转依赖关系,就像依赖关系反转原理一样。这个原则的一般概念被分解成两个独立的规则。

  • 抽象不应该依赖于细节。细节(具体的实现)应该依赖于抽象。

  • 高层模块不应该依赖低层模块。两者都应该依赖于抽象(例如接口)。

因此,这对我们来说意味着我们可以使用接口(抽象)来使依赖指向另一个方向(向内指向域模型)。对于我们的使用来说,问题在于没有一种明确的方法来从领域模型中提取抽象;它们应该是自包含的,并且拥有与该层中的领域相关的所有知识。此外,因为我们希望在我们的领域模型中利用雄辩的框架,反转依赖关系仍然会给我们留下一个知道领域层之外的事情的领域模型。

进化范式

在过去的二十年中,我们所见证的框架、库、包和工具的爆炸已经产生了足够的影响,完全改变了我们编写代码的方式;然而,自 20 世纪 80 年代和 90 年代以来,我们思考代码的方式并没有太大的改变。当然,我们更加意识到我们的代码所产生的影响,并因此更加小心地选择为使软件工作而创建的功能的最佳位置,并且我们已经学会了在成功项目中经常发生的决策中灵活地让领域专家和利益相关者参与进来。然而,我们思考软件的方式基本上和很久以前一样。自 20 世纪 60 年代以来,面向对象编程就一直存在,并且仍然是用于构建大多数现代软件和 web 应用的最流行的通用范例。四人帮于 1994 年发布的设计模式处女作仍然是所有这些最新和最伟大的框架和“不能没有”的包背后的驱动力,这些框架和包使我们的生活更容易,我们的成功潜力更大。

在我看来,过去几十年发生的事情只是 web 开发编程所经历的自然进化过程;我们只是在最近才采用了那些很久以前发现的标准,但这些标准在今天的 web 开发行业中仍然适用。最近,在前端 web 开发实践和语言的发展中可以看到另一个例子。Vue 之类的框架。JS、Angular 和 React 都源于 70 年前发现的编程范例。虽然看起来前端 web 开发已经呈现出一种新的形式,但实际上所有正在发生的事情与 2000 年左右一般 web 开发所发生的事情是一样的:它正在发展。编程语言的数量在过去 20 年左右也出现了爆炸式增长,其目的通常是成为工具,专门满足随着 web 开发需求而出现的新发现的业务需求。

围绕网络开发的思维过程也发生了类似的事情。纯粹主义者的心态有着良好的意图,并且决不是错误或坏的,但是在我看来,作为一个对任何特定技术、语言、过程或架构方向的“纯粹主义者”,在我们知道如何做某事的当前方式和完成同一件事的新的(通常是改进的)方式之间设置了一个精神障碍,尽管是潜意识的,导致我们作为开发人员的发展能力停滞不前。正如我们所知,我们对开发的理解(理论上)是基于我们不得不花费时间和精力去理解的最佳实践,甚至花费更多的时间和精力在我们的应用中有效地使用。通常,我们努力实现的这种理解恰恰会阻碍我们利用行业的下一个进步。当然,这些进步采取了许多不同的形式,从长期受欢迎的语言的新版本(PHP 7.0、JavaScript ES6 和 HTML 5,仅举几个例子)到引入全新的开发过程思维方式(Angular 和 Vue)。JS),但它们的最终结果最终都是一样的:一个工作软件、web 应用、API 接口,或者更高级的东西,比如以 Google RPC (gRPC)协议缓冲区为中心的微服务架构。您可以在 https://grpc.io 找到更多关于协议缓冲区的信息。

*现在,这并不是说设计模式、坚实的原则以及其他关于软件架构和 web 开发的标准和实践不会存在太久。相反,理解这些类型的通用概念对于成为一名优秀的开发人员至关重要,如果使用正确,它们将为创建一个长寿、高质量的软件铺平道路。但是,我们必须小心的是,在我们个人的 web 开发实践中,我们完全服从于单一的关注点或范式。很久以前,人们认为世界是平的。我敢打赌,如果你回到那个时代,提出地球是圆的,你会被嘲笑,因为你没有受过教育的论点而被嘲笑,甚至可能被杀。尽管地球确实是圆的,但当时的心态是它是平的。在古希腊人引入这一理念后,其他人花了相当长的时间(我可以想象)才赶上并接受这一理念。web 开发也是如此。这需要时间,许多实验,许多尝试和失败,才能让整个社区接受并采用一种新的不同的标准。

思想的转变

我们必须对我们的思维进行的最大改变涉及到依赖倒置原则和领域封装实践,这是领域驱动设计的核心。重要的是,我们将我们的领域模型封装在某种概念边界中,该边界将领域的关注点与处理领域层对象的应用或基础结构的关注点分开(无论是持久化它们、初始化它们、重新创建它们、记录它们,等等)。).

有各种方法来处理架构 web 应用。例如,在野外(或者每当开始一项新工作或者继承一个遗留代码库时),往往会发生的情况是,项目的开发不是由最佳实践指导的,而是严格遵循著名的“让它工作就好!”范例。在创业公司中,经常会发生的情况是,无论他们建立了什么样的系统来为他们赚钱,或者为投资者提供概念证明,让他们将钱投入到业务中,这些系统都是用拼凑起来的代码构建的,这些代码很乱,很难维护,甚至更难教给其他人。快速应用开发范例与这一概念并行运行:从尽可能多的预构建内容开始开发过程,然后定制您需要的内容以创建所需的功能,拼凑(此时)“粗略的”业务策略和实践,这些策略和实践实际上仍在应用开始时定义和建立,将其推出到生产中,清洗并重复。

从技术或编程的角度来看,这种方法本身没有任何问题;毕竟,编程游戏的名字就是代码可重用性和抽象。当代码库增长时,问题通常会出现,并且在某一点上在其自身基础设施的压力下崩溃,从而使应用变得无用,或者随着业务需求变得越来越复杂,增加对应用进行修改和添加的时间。在这种情况下,拥有软件的公司通常会被迫做出一个关键的决定,要么整个重构系统,要么两害相权取其轻,从头开始创建整个系统。后一种选择已经在编码行业中被一次又一次地证明几乎总是失败,因为当你从头开始时,你必须用这个新的应用解决你用旧的应用解决的所有问题、争论和故障。

我们都应该更容易接受新的想法,不要让我们对事物如何运作的现有理解或我们对做某事的最佳方式的知识干扰新的和更好的思维过程、工具、概念和实践。我们的数字生态系统中不断发展的实践和标准使我们的行业不那么具有可塑性,当然,这个行业是一个不断重新定义和重塑自己以满足现代业务需求不断增长的行业。

回到我们手头的问题,我们可以做的是打破规则,让我们的领域实体扩展雄辩的 base Model类,理解我们只是使用它作为一种手段,允许领域层内的模型和事物通过扩展它们或通过特征包含它们来利用框架内的特性,而不是其他。如果我们坚持只依赖框架提供的外部类或特征,那么我们可以保持应用的整体结构完整,仍然清晰地将各种关注点划分到相应的层中,仍然在层之间建立边界,同时仍然利用 Laravel 的功能。

结论

在最坏的情况下,不符合当前标准和最佳实践的架构师和开发人员将“确定”整个系统是糟糕的,他们必须从头开始重写它——基本上是为了重写它。然后,尽管从逻辑上看,第二次实现可能会更快,但下一个版本注定会有与上一个版本相同的问题和缺点。原因可能各不相同。

  • 难以建模的过于复杂的领域

  • 缺乏设计满足所有项目要求的质量体系所需的适当经验的架构师

  • 缺乏一般软件开发经验(低水平或高水平)的开发人员

  • 缺乏领域经验的开发人员(尽管您显然不希望让新人负责整个系统或部分系统的架构设计)

  • 阻碍进步的公司政治

  • “专家”是公司工程团队的一部分,他们阻止技术进步和新的、改进的流程和标准

  • 其他团队成员不愿意改变他们自己的方式来学习新的、经常改进的开发实践

  • 团队中的一些成员(甚至是一个成员)不愿意接受自动化过程,即使它可以节省时间、精力和金钱

  • 思想狭隘

  • 没有学习的能力或愿望

  • 还有很多很多其他人…

这种软件开发方法效率不高;然而,它可能实际上看起来并没有那么糟糕,因为,在那个时候,找到足够的包和库在您的应用中使用,然后将它们粘合在一起,通常构建起来非常快(这就是为什么它被称为快速应用开发)。这在某种程度上是一种危险的误解,当企业因为前面列出的原因成长并成功时,这种误解会产生深远的后果。

减轻这种情况的一种方法是使用好的设计原则和设计模式以及抽象和接口,这实际上等同于好的架构。我们发现,当我们在构建系统时采用良好的原则和标准时,我们允许自己在某种意义上灵活地计划变化。通过使用接口和/或抽象类来定义应用中发生变化的各个部分,我们让代码对扩展开放,但对修改关闭。以这种方式,通过简单地扩展该接口并将其插入到应用中,其他代码可以在我们的应用中交互和使用。像这样的事情可能会变得复杂,但是正确使用和理解 Laravel 的服务容器,包括如何设置实现,以便在满足某些内部条件时自动加载和运行。服务容器功能强大,为我们提供了一定程度的灵活性,这对于管理依赖关系和创建基于上下文操作的定制依赖注入配置非常有用。有关服务容器的更多信息,请参见第四章。

在这一章中,我们给出了应用结构的一个起点。虽然各层之间的各种概念和界限在这一点上可能有点模糊,但我们可以利用我们所拥有的东西来共同打造必要的功能,以使我们的应用起步。这种结构远非完美,我们将在本书的其余部分进行修改,当它有意义时。只要它有助于阐明应用的意图,只要我们能够利用框架完成所有 web 应用中的常见任务,我们就会这样做。

但是我们不能忽视这个领域。虽然这一章是大量涉及 Laravel 相关的章节,但是我们应该始终保持领域是最重要的东西的观念,我们前进的最好方式是允许领域驱动应用的构造。通过允许我们的模型实现雄辩的基类,并通过实现诸如可调度事件、服务和 Laravel 作业之类的功能,我们可以访问 Laravel 的所有特性,而不会违反太多规则。我们将开始看到的是,我们在本书中介绍的分层架构只是构建应用(如用 DDL 创建的应用)的一种方式。有更好的方法来分离事物,我们将在后面探索。现在,我们有了一个可以工作的结构,这将为我们构建项目所需的初始功能提供基础。稍后,我们将开始开发一种更好的方法,通过利用用例和六边形架构来构建组件。*

九、领域驱动的索赔提交方法

这一章将包含我们正在烹制的领域驱动晚餐的主要内容(主菜)。因此,我将包括一些关于Claim域的额外细节,这样我们可以在整本书中继续使用这个例子。我们将继续改进应用,逐步增加价值。这是 DDD 中游戏的名字。我们开始吧!

其他背景知识

为了给我们仍然需要执行的各种验证提供解决方案(在对象级别上),您将需要一些关于过程如何构造的附加信息。在现实世界中,您将不得不在一段时间内做许多事情来培养足够的知识,以适应需求的工作,包括与领域专家交谈,培养一种无处不在的语言,并在代码和任何其他关于领域的交流中实现该语言。此外,一旦应用上线并被真实用户使用,您必须制作原型,完善它们,并处理任何不可预见的错误或问题。

CPT 代码

这就是我们索赔项目中事情变得有点复杂的地方。对医疗索赔提交有效的医疗计费和编码标准非常复杂。以下是 CPT 代码的定义(来自 MedicalBillingAndCoding.org):

“CPT 代码用于描述医疗保健提供者对患者进行的测试、手术、评估和任何其他医疗程序。可以想象,这套代码非常庞大,包括成千上万个医疗程序的代码。”

CPT 代码的“CPT”部分代表“当前程序术语”他们没有拿 CPT 代码列表的大小开玩笑,它包含了医生可以对病人做的几乎所有医疗程序。该名单由美国医学协会维护( https://www.ama-assn.org/ )。表 9-1 提供了规定 CPT 代码类型的不同范围。

表 9-1

CPT 代码程序类别范围

|

CPT 代码范围

|

种类

|
| --- | --- |
| 00100 到 01999;99100 到 99140 | 麻醉 |
| 10021 到 69990 | 外科手术 |
| 70010 到 79999 | 放射学 |
| 80047 转 89398 | 病理学和实验室 |
| 90281 转 99199;99500 到 99607 | 医学 |
| 99201 到 99499 | 评估和管理 |

如您所见,CPT 代码范围分为六个主要的过程类别。此外,还有 CPT 代码类别,将每个 CPT 代码分为不同的组。

  • CPT 第一类:最大的代码体,由提供者通常用来报告其服务和程序的代码组成

  • CPT 类别 II :用于绩效管理的补充跟踪代码

  • CPT 类别 III :用于报告新兴和实验性服务和程序的临时代码

为了更好地说明类别和类型实际代表的内容,以下是一些常见 CPT 代码的示例,描述了几种不同情况下的各种面对面咨询:

  • 99202 :代表一个新的门诊病人就诊,接受以问题为中心的医疗治疗

  • 99213 :代表已建立的针对问题的医疗治疗的患者就诊

  • 99221-2l3 :表示新患者初始医院护理的代码范围

  • 99281-85 :急诊就诊

CPT 代码对 Medi-Cal 索赔的计费和处理很重要的另一个方面是支付给提供者的金额(这是联邦 FQHC 为患者接受治疗和医疗服务的费用)。当业内专业人士谈到这种关系时,他们要么称之为薪资单要么称之为费用表

CPT 代码组合的概念源于这样一个事实,即通常情况下,到医生办公室的典型就诊需要多个 CPT 代码,这些代码对应于给予患者的各种治疗。正是这种多个 CPT 的组合,FQHC 用来确定支付给提供治疗的提供者的价格。值得注意的是,工资代码表(又名费用表)是由维护 CPT 编码标准的同一个组织而不是制定的。

Note

要查看按手术类别分类的 CPT 代码的完整列表,请查看 https://coder.aapc.com/cpt-codes/?_ga=2.39310822.419811336.1574365287-1004373537.1574365287 提供的编码器搜索工具。它只能给你有限的信息,除非你花一大笔钱购买软件,为你查找 CPT 代码,并允许你用纯文本搜索程序代码。

表 9-2 提供了行业中使用的几种常见 CPT 代码的示例。

表 9-2

样本 CPT 代码及其费用结构,用于确定医生为医疗患者提供服务的报酬

|

CPT 代码

|

描述

|

年龄

|

速度

|
| --- | --- | --- | --- |
| Ninety thousand six hundred and fifty-four | 流感病毒疫苗,裂解病毒,不含防腐剂,供皮内使用 | 19+ | $19.00 |
| Ninety-nine thousand three hundred and eighty-four | 对个体进行初步综合预防医学评估和管理,包括年龄和性别适宜的病史、检查、咨询/预期指导/风险因素减少干预,以及实验室/诊断程序的安排;新病人;青少年(12 至 17 岁) | 12–17 | $25.00 |
| Ninety-nine thousand two hundred and thirteen | 代表已确定的患者就诊,以便进行以问题为中心的医疗治疗 | 12–17 | $50.00 |
| 2000 年 | 物理评估 | 12–17 | $30.00 |

在表 9-2 中,我们发现了一些 CPT 代码及其相应的程序描述或涉及内容、治疗适用的患者年龄(或年龄范围)以及支付给提供者的服务费。请注意,这些 CPT 代码的费率是针对单个 CPT 代码的。我们也有多个 CPT 代码(称为 CPT 组合)直接影响提供商获得的金额的情况。以下是一个组合的例子,其中两个单一的 CPT 代码适用,并描述了作为患者治疗接受的一套服务。CPT 组合在医疗索赔中也有特定的赔付率。

CPT 代码组合的索赔示例

举个例子,假设有一个 CPT 组合来描述一个场景,一个 18 岁或 18 岁以下的病人去看医生,为秋天去新学校做准备。由于病人想参加体育运动,他需要体检以及学校要求的标准免疫接种。

Note

由于真实世界 CPT 代码描述符中使用的基础医学术语的复杂性,我没有包括一堆不相关的医学术语和/或解剖课来解释 CPT 代码的实际含义,而是编造了一些任意的代码来为您提供一些关于应用目的的上下文。代码并不重要——重要的是我们理解这个系统的基本概念,这样我们就可以正确地解决验证环境中引入的问题。

  • 对患者进行医学评估,确定其为“确诊”患者(在检查时已经在系统中注册)。

    • CPT 代码:99213
  • 患者接受了公立学校要求的甲型和乙型肝炎标准疫苗接种。

    • CPT 代码:99384
  • 给病人做了身体检查,这样他明年就可以参加体育运动了。

    • CPT 代码:2000F

如果您参考表 9-2 ,您可能会认为得出所述索赔的估计索赔金额的合理方法是将每个给定 CPT 代码的各种单独费率相加(如前面的列表所示),这将得出 124 美元(从表 9-2 得出)。然而,生活并不是那么简单。支付代码表规定了支付给完成服务和治疗的提供者的金额,无论是单个程序还是组合程序。我们现在不会太担心这个,因为估算索赔金额的过程是在不同的上下文和章节中处理的。在验证索赔本身的过程中,我们需要确保以下几点:

  • 各个 CPT 代码都是有效的

  • CPT 代码组合是有效的,并且存在于各自的提供商中

另一个需要注意的重要事项是,CPT 代码通常有一个特定的相关年龄范围,包含这些类型代码的声明必须符合年龄要求和限制;如果他们不这样做,程序将无效,索赔将不会得到支付。我们希望在提交索赔时包括一些验证检查,以便我们可以在任何错误到达 FQHC 进行最终索赔验证和向提供商付款之前捕捉到它们。

显然,CPT 代码对每个相关人员来说都是一件痛苦的事情,尤其是诊所或服务提供者办公室的接待员,他们实际上是在开账单、编码和创建索赔。

Note

这是一个已知的痛点,这正是软件旨在自动化、隔离和缓解的问题类型。

背景信息说完了,让我们开始编码我们将在系统中使用的模型,因为它们与我们的领域模型的计费和编码部分相关。

开发雄辩的模型

我们知道我们必须为系统中所有程序所共有的静态信息创建模型。具体来说,CPT 代码必须包括描述 CPT 代码的各种属性,并将其与许多其他 CPT 代码区分开来。我们还必须将 CPT 代码组合建模为单独的雄辩模型,以保持系统中哪些 CPT 代码组合有效的静态知识。

CPT 代码和提供商

对于提供者来说,CPT 代码代表了用于治疗患者的基本程序,这当然是他们作为医疗从业者或医疗实践的血统(双关语不是故意的!).联邦医疗系统采用严格的补偿结构,要求在提供者获得服务报酬之前,必须完成对患者的手术。“严格”结构以 CPT 代码组合的形式出现。在提交报销申请期间,提供商在我们的应用中选择特定的 CPT 代码,然后系统验证这些代码,以确保在该提供商的支付代码表中存在匹配的组合。我们应该如何在我们的系统中对此建模?我们能做些什么来说明代码组合是多个单个 CPT 代码?请记住,在实际提交索赔之前,提供商希望看到索赔中列出的代码的估计索赔金额。在构建模型时,我们需要考虑一些问题。

  • 模拟单个 CPT 代码

  • 模拟由多个 CPT 代码组成的单个 CPT 代码组合

CPT 代码和索赔

提交的索赔将包含所有相关的患者数据、链接的文档和其他重要信息,这些信息涉及他们在给定的一天对给定的患者执行的程序和治疗。CPT 代码也必须出现在索赔上,但是最好的方法是什么呢?将这一条添加到关注列表中:

  • 创建一种将多个 CPT 代码与索赔关联的方法

CPT 代码和薪资代码表

支付代码表保存了提供商为每个给定的 CPT 代码组合支付的实际金额。许多提供商有一个薪资代码表;但是,在给定的薪资代码表上只能指定一个唯一的提供商。我们需要在薪资代码表的建模中包含这一约束。我们如何在域模型中建模 CPT 代码和 CPT 代码组合将决定我们应该如何设计 paycode 表。这样做的原因是,支付代码表包含支付给提供者的基于每个 CPT 代码组合的金额,而不是单个 CPT 代码级别的金额。你甚至可以说,这是一种依赖关系,我们在构建薪资代码表模型时必须解决这个问题。

  • 创建一种在每个 CPT 代码组合的基础上存储每个提供商费率的方法

Note

我们将对我们的设计中需要包含的组件进行细分,以满足系统的当前需求;然而,我不会浪费页面空间,因为这本书的 Git 存储库中已经在线提供了一堆 PHP 源代码。当然,我们将讨论设计中的所有组件,它们如何适应整体架构,以及如何着手设计一个模型驱动和领域聚焦的系统。为了让您更好地理解事物是如何组合在一起的,我在任何有意义的地方都包含了相关的源代码,希望能够简化设计或澄清该领域中任何模糊的概念。要查看完整的源代码,请访问 GitHub 在线资源库。

CPT 代码和 CPT 代码组合

尽管从所有技术角度来看,CPT 代码都可以被认为是一个值对象,但是从 Laravel 的角度来看,这个概念本身所带来的含义有些模糊。这实际上是 DDL 没有好的解决方案的一个领域。我将在后面的章节中讨论这些含义。

CPT 代码结构和翻译

现在,让我们继续使用雄辩的 ORM 在 Laravel 应用的上下文中对应用的 CPT 代码部分进行建模。我们将从建模一个基本的 CPT 代码开始。为此,我们知道所有的 CPT 代码都是不同的,这由它们唯一标识它们的code属性来表示(例如 99213)。CPT 代码属于不同类别的代码,以及不同类型的程序(手术、放射、医疗访问和检查等)。).我们希望在我们的模型中捕获这些信息。表 9-3 是数据库模式的第一次尝试。它包括手术的英文描述、CPT(1、2 或 3 中的类别表)、手术组(类型)以及是否为儿科代码(即,对 3 岁以下儿童进行的手术)。表 9-3 中描述的设计捕获了关于单个 CPT 代码的所有所需数据。

表 9-3

给定 CPT 代码的示例数据库行

|

编号

|

描述

|

密码

|

种类

|

|

是儿科

|
| --- | --- | --- | --- | --- | --- |
| one | 扩大的、以问题为中心的就诊,既可以是门诊就诊,也可以是门诊就诊 | Ninety-nine thousand two hundred and thirteen | 1 | 评估和管理 | Zero |

我们给它一个自动递增的id字段,这样我们可以在整个系统中跟踪代码。该字段是一个主键,因此我们可以使用它来创建 CPT 组合,并在提供者选择当天为给定患者完成的程序时,允许在索赔中引用单个 CPT 代码。图 9-1 显示了 CPT 模型。

img/488324_1_En_9_Chapter/488324_1_En_9_Fig1_HTML.jpg

图 9-1

cpt_code、cpt_code_combo 和中间查找表的 UML 图

我们面临的下一个问题是对 CPT 代码的组合进行建模。

在设计应用的这一部分时,我们需要记住的一件事是我们存储代码组合的方式。我们有几个选择:

  1. cpt_code_combos表的一列中存储一个逗号分隔的单个 CPT 代码列表

  2. 创建一个数据透视表,它将连接各种 CPT 代码以形成 CPT 代码组合(如前面所示)

当面临诸如此类的决策时,它有助于抓住每一种可能性,并通过它影响(触及)的潜在需求或用例进行循环。例如,考虑一个提供者提交索赔的用例。他们看到的只是一个带有下拉列表的表单域和一个自动完成的各种 CPT 代码列表,按名称列出。他们将从下拉菜单中选择一个或多个代码;然后,他们希望在提交索赔之前看到估计的索赔金额。为了获得估计的索赔金额,我们需要在应用中使用一种机制来查询 paycode 表,该表将 CPT 代码(通过 CPT 代码组合)和提供者联系在一起,指定每个代码的金额。然后,估计的金额将是使用索赔上的提供者 ID 和 CPT 代码组合查询该支付代码表的结果。

回到如何对 CPT 代码组合建模的问题,我们将不得不查询数据库以找出给定的 CPT 代码组合是否存在,然后在 paycode 表中为该代码组合选择相应的记录。那么,是用前面列表中选项 1 描述的解决方案更容易做到这一点,还是选项 2 更好呢?好了,让我们通过现在已经阐明的方式来运行它们,我们将在应用中使用它们。

  1. 在 CPT 代码组合中的单个字段中存储一个单独的 CPT 代码列表看起来会使在一个组中选择它们的过程更容易,但是想想这样做的含义:它们在数据库字段中的存储方式必须在每一行中都完全相同,包括 CPT 代码 id 之间的间距。这也让客户来确保(这通常不是最好的方法)组合的精确语法。另一个要考虑的问题是是否以任何特定的顺序列出它们,同样要记住,客户端将负责保持数据库使用的格式的 CPT 代码的顺序。然而,基于相应的 CPT 代码查询表中的组合会非常容易,我们可以在一个数据库调用中完成。

  2. 虽然这种解决方案没有将组合本身和组成组合的代码存储在同一个模型和数据库表中的好处,但它有许多预期的好处:它减少了为单个代码(如果它们在一个字段中)维护任何类型的结构的需要,而且我们不必担心解析 CPT 代码字符串来找到它们属于哪个组合。另一方面,由于我们得到了要处理的单个 CPT 代码,所以确定代码属于哪个组合(如果该组合存在)的过程要比只查询逗号分隔的 CPT 代码 id 的单个字段复杂得多,这主要是因为 CPT 代码本身可能属于多个组合。例如,通常情况下,一次标准的就诊会与作为该次就诊的一部分而完成的附加程序一起提交,但根据表示就诊的代码后面的代码,它们的账单会有所不同。

当选择适当的逻辑方式来处理表示这个特定用例中涉及的特定元素时,可能还有其他的考虑。根据给出的当前信息,尽管选项 2 比第一个解决方案更好,但选项 1 提供的简单性是解决当前问题的直接方法。拥有一个逗号分隔的 CPT 代码列表将通过限制计算估计索赔金额所需的表和跨表(甚至跨数据库)查询的数量来简化 CPT 代码组合的保存和检索。然而,如果我们选择走这条路,它会带走我们在雄辩模型中获得的所有功能,因为没有关系。

这个问题的答案是使用所描述的两种解决方案。我们希望用正确的关系正确地设置我们的雄辩模型,在这种情况下,这将是带有透视表(查找表)的多对多关系。口才提供了一个简单的界面来设置这一点,我们将在后面的章节中详细描述。我们还需要有一种方法来找到给定的单个 CPT 代码的特定 CPT 代码组合,这是我们仅使用选项 2 无法做到的。在 CPT 组合表中有一个字段允许我们查询 CPT 代码集引用的组合。

清单 9-1 实际上显示了模型类的代码,不考虑逗号分隔的 CPT 代码。

// ddl/Claim/Submission/Domain/Models/CptCode.php

<?php
namespace Claim\Submission\Domain\Models;

use Illuminate\Database\Eloquent\Model;

class CptCode extends Model
{
    public $table = 'cpt_codes';

    protected $guarded = ['id'];

    public function cptCodeCombos()
    {
        return $this->belongsToMany(CptCodeCombo::class,
        'cpt_code_combo_lookup');
    }
}

Listing 9-1Initial Version of the CptCode model, with a Many-to-Many Relationship to the CptCodeCombo Model

注意在清单 9-1 中,我们已经为CptCodeCombo类以及指定的透视表cpt_code_combo_lookup指定了多对多关系。当然,我们仍然必须用这个名称创建数据透视表,这与我们在迁移时所做的一样,从 CPT 代码和 CPT 代码组合中指定 id。要了解迁移,请查看在线资源库。现在,让我们继续为 CPT 代码组合建模,看起来像清单 9-2 。

<?php
namespace Claim\Submission\Domain\Models;
use Claim\Submission\Domain\Models\CptCode;
use Illuminate\Database\Eloquent\Model;
class CptCodeCombo extends Model
{
    public $table = 'cpt_code_combos';
    protected $guarded = ['id'];
    public function cptCodes()
    {
        return $this->belongsToMany(CptCode::class, 'cpt_code_combo_lookup');
    }
}

Listing 9-2The Inverse Relationship to the CPT Code Model

CPT 代码组合的数据库模式相当简单,因为我们只需要它成为引用多个 CPT 代码的容器。此时,我们将包括一个 ID 字段以及描述和注释列(只是为了给表提供更多的上下文),但是没有这些字段也可以。表 9-4 提供了 CPT 代码组合的模式。

表 9-4

CPT 代码组合表的示例

|

身份

|

描述

|

笔记

|
| --- | --- | --- |
| one | 免疫接种后的重点问题访问 | 一些随意的笔记 |

要实现这一点,我们需要的另一件事是数据透视表,它包含两个表的 id,这样我们就可以将不同的 CPT 代码 id 与一个 CPT 代码组合相关联,看起来就像 Table 9-5 。

表 9-5

将任意数量的 CPT 代码链接到一个 CPT 代码组合的数据透视表中的示例记录

|

编号

|

cpt_code_id

|

cpt_code_combo_id

|
| --- | --- | --- |
| one | one | one |
| Two | Two | one |

有了我们创建的设置,我们就可以做一些事情,比如找出特定组合的单个 CPT 代码。

$cptCodeCombo = CptCodeCombo::find(1);
$cptCodes = $cptCodeCombo->cptCodes->toArray();
print_r($cptCodes);

这将为我们提供以下结果:

Array
(
    [0] => Array
        (
            [id] => 1
            [description] => Expanded, problem focused visit either as
            an in-office visit or an outpatient visit.
            [code] => 99213
            [category] => 1
            [group] => Evaluation and Management
            [is_pediatric] => 0
            [pivot] => Array
                (
                    [cpt_code_combo_id] => 1
                    [cpt_code_id] => 1
                )
        )
    [1] => Array
        (
            [id] => 2
            [description] => Immunizations for Influenza
            [code] => 90605
            [category] => 1
            [group] => Immunizations
            [is_pediatric] => 0
            [pivot] => Array
                (
                    [cpt_code_combo_id] => 1
                    [cpt_code_id] => 2
                )
    )
)

如果您仔细看看前面显示的结果数组,courage 已经包含了我们自动创建的透视表,除了相应模型中的ManyToMany关系中的表名之外,没有指定任何内容。这是一种根据实体和模型与系统中其他模型的关系来描述实体和模型的强大方法。这也将允许我们找到特定 CPT 代码所属的所有给定 CPT 代码组合。

$cptCode = CptCode::find(1);
$cptCodeCombos = $cptCode->cptCodeCombos->toArray();
print_r($cptCodeCombos);

这会产生以下输出:

Array
(
    [0] => Array
        (
            [id] => 1
            [notes] => In office exam + Immunization shots
            [description] => ...some description
            [pivot] => Array
                (
                    [cpt_code_id] => 2
                    [cpt_code_combo_id] => 1
                )
        )
    [1] => Array
        (
            [id] => 2
            [notes] => TEST
            [description] => fasdfasdf
            [pivot] => Array
                (
                    [cpt_code_id] => 2
                    [cpt_code_combo_id] => 2
                )
        )
)

正如您所看到的,concertive 看到所提供的关系有一个数据透视表,并自动检测它,除了我们在为cpt_code_combo_lookup表编写迁移时选择的表名之外,不需要指定任何东西。这是非常有益的,因为它不仅允许我们查询从它所表示的数据中导出的逻辑关系,而且还减少了在我们的领域层中构建额外的模型来解释数据透视表本身的麻烦。拉弗尔为我们做了这一切。大多数时候,我们必须自己构建和管理它,并创建无数特定的查询来获取我们需要的数据,以完成我们正在进行的任何任务。如果在您的数据透视表中有您需要的额外数据,当您取出它们所引用的相应数据库对象(在本例中,是cpt_code_idcpt_code_combo_id)时,您总是可以访问虚拟数据透视表模型,这是由雄辩在后台通过返回模型上的pivot属性创建的。

$cptCodeCombo = CptCode::find(1);
$cptCodeCombos = $cptCode->cptCodeCombos->pivot->description;

在前面的表中,描述位于数据透视表中,如果您需要一种粒度方式来描述数据库中每条记录在每个关系的上存在的内容,那么可以使用这个表。一种不太细粒度的方法是将描述存储在cptCodeCombos本身中,以描述关系(可能由几个其他关系组成),从而赋予包含与多个其他模型的关系的模型以意义。

这一切都很好,但是仍然没有完全解决从 CPT 代码列表中选择一个组合的问题。处理这个问题的一个方法是采用几页前选项 1 中描述的解决方案,让cpt_codes值从最小到最大排列,并用逗号分隔;然后,我们可以在 SQL 查询中使用 group concat。我们将在第十章继续这个讨论,并探索这个需求的细节。

工资代码表(又名费用表)

Paycode sheets 可视为给定 CPT 代码集(输入 CPT 代码组合)的每个提供商的费率集。简而言之,paycode sheet 是提供商为 CPT 代码组合描述的各种服务结算的金额。基本上,它是提供商的服务的价值。支付代码表特定于每个提供商,通常存在于 FQHC 的上下文中,而后者又由实践组成,每个实践都有许多提供商。我们需要在软件中对此建模。下面是我们的应用中与其他模型的关系:

  • 任何给定的薪资单上都有许多提供商。

  • 有数千种可能的 CPT 代码组合(平均值为 2500)。

  • 基于 CPT 代码组合,每个提供者为他们的服务支付给定的费率,该 CPT 代码组合捕获针对特定索赔给予患者的程序。

当像这样分解时,我们可以假设我们正在处理大量需要捕获和建模的数据。我们可以这样估计:

Total # of Providers in a given FQHC Center:    800
Total # of CPT Code Combinations               2500
Total # of FQHC Centers                           5
---------------
10,000,000 (Ten Million Rows)

请记住,每个中心都有自己的支付代码表,其中包含每个提供者的记录,每个提供者都有一个商定的费率,用于支付每个单独的程序,由一个或多个 CPT 代码组合表示。因此,CPT 代码组合本身包括多个单一的 CPT 代码。哇!这听起来很复杂(图 9-2 )。在这种时候,拿出记号笔(或者如果你和我一样,用老式的钢笔和 Moleskine 笔记本)并画出图表,总不会有什么坏处。

img/488324_1_En_9_Chapter/488324_1_En_9_Fig2_HTML.jpg

图 9-2

应用中主要组件之间的关联及其与薪资单的关系

当您根据事物与系统中其他对象的关系对它们进行建模,并选择特定的关键字来描述这些关系(“拥有许多”、“被许多人拥有”等)。),不仅从组成系统模型的各个部分来理解整个系统模型变得容易得多,而且我们可以使用雄辩的强大关系方法作为描述前面描述的那些方法的机制,使用相同的术语来编写代码,使其成为一个英语句子。这真的很酷的原因是,记住像“一个中心有一张工资单”这样的英语句子比记住一堆特定的方法调用要容易得多(清单 9-3 )。

<?php

namespace Claim\Submission\Domain\Models;

use Illuminate\Database\Eloquent\Model;

class Center extends Model
{
     public function paycodeSheet()
     {
          return $this->hasOne(PaycodeSheet::class);
     }
}

Listing 9-3An Easy-to-Read Model Class Center That Contains a “Has One” Relationship to a PaycodeSheet

正如您所看到的,由Model类提供的hasOne()方法是正确捕获模型中中心和工资代码表之间的关系所需要的。清单 9-3 中的center清单读起来非常简单,听起来确实像一个英语句子:

"Center对象与PaycodeSheet.有一对一的关系"

然而,如果您想找出哪些中心有给定的工资代码表呢?你只需要在PaycodeSheet模型上创建关系(列表 9-4 )。

<?php

namespace Claim\Submission\Domain\Models;

use Illuminate\Database\Eloquent\Model;

class PaycodeSheet extends Model
{
     public function center()
     {
          return $this->belongsToOne(Center::class);
     }
}

Listing 9-4A Sample FQHC Center Class with the Relationship to the PaycodeSheet Class Explicitly Defined

在清单 9-4 中,我们简单地添加了一个新方法,其名称对应于关系引用的Model类。这个听起来也像英语句子:

“一个PaycodeSheet属于一个center.

出于讨论的目的,为了降低复杂性,我们将把范围限制在只为单个 FQHC 的单个薪资代码表建模。在较高的层次上,不需要深入存储每个索赔的 CPT 代码组合的细节,我们可以得出数据库模式的粗略草案。

以下是一些额外的注意事项:

  • 数据如何进入系统,以何种格式进入。

  • 何时使用数据传输对象(d to)来模拟返回给使用 API 的客户端的响应。

  • 文档!使用 SwaggerHub 之类的工具对 dto 和应用的各种端点进行建模。

列表中的最后一项对于有多个开发人员(例如前端开发人员和后端开发人员)的项目有最大的好处,因为这两个开发人员可以单独处理项目,而不需要另一个开发人员完成。它基本上作为一种工具来分离开发工作,并确保围栏的一边不会与另一边的变更冲突。

就工资代码表而言,我们在这一点上不太关心 CPT 代码组合在索赔中的存储。我们只是想模拟出 paycode 表,以便它最终可以用于评估给定提供者的一组程序的成本,并(稍后)确定索赔的估计金额(这将在提交上下文中进行;然而,它实际上发生在提交索赔之前)。让我们继续为工资代码表建模数据模式。

Tip

为了简洁起见,除了我们到目前为止已经讨论过的内容,我不会再详细讨论工资代码表的所有细节和方面;然而,当在软件中建模真实世界的对象时(可以说这是我们作为开发人员的大部分工作),最好将特定的数据模式留到建模结束时,这时您已经获得了某种具体的、可用的模型,该模型很好地对应于它所存在的领域,包括值对象、实体、服务等。这是一种比试图先充实数据库模式更好的方法,因为在开发过程中模式很可能会多次改变。除非万不得已,否则忽略数据模式是一个好习惯。如何存储我们应用的数据永远不应该是项目的主要关注点。保持对领域建模的最高优先级,以便它尽可能地符合业务模型。这并不是说模式对应用来说不是一个非常重要的方面。有趣的是,在您的项目投入生产并成功运行后不久,团队可能会意识到最重要的是数据本身,因为应用的其余部分只是以不同的视图和格式管理数据的一种方式。

薪资单数据库模式

首先必须澄清的是,我们如何从 CPT 代码列表中访问正确的 CPT 代码组合。我们现在可以做的是在其中一个表上添加一个包含逗号分隔的 CPT 代码的字段。最好的地方是它实际上最相关的表,也就是cpt_code_combo

通常,数据库中有重复数据是一件坏事;然而,在领域驱动的设计方面,以一种适合你所需要的方式正确地建模软件胜过这种最佳实践。在这种情况下,可以使用这个逗号分隔的列表,因为它允许我们以我们需要的方式查询它,以便提取将成为估计索赔金额的数据,这是包含在系统中的一个重要问题。提供者总是希望在提交索赔之前看到估计支付的金额,因此这是一个必须包含的功能。

也就是说,图 9-3 显示了薪资代码表的粗略数据架构,其中cpt_code_combo表更新后包含了一个新字段,用于逗号分隔的 CPT 代码列表。请记住,这种情况将来可能会改变。例如,如果这种字段是某种类型的结构化格式,我们可能会对它的内容感觉更好(JSON 类型的字段可能是对这种设计的改进)。

img/488324_1_En_9_Chapter/488324_1_En_9_Fig3_HTML.jpg

图 9-3

用于存储薪资代码表的数据库模式,包括所涉及的其他实体以及包含所涉及的 cpt 代码列表的新 csv_cpt_codes 字段

这是一种非常简单的方法,可以在系统中对支付代码表进行建模,从而明确区分代码、代码组合、提供商以及提供商从完成每个组合中获得的收益。让我们浏览一下我们的笔记,看看这个建议的模式是否解决了系统关于工资代码表和应用本身与工资代码表交互的所有需求。

  • CPT 代码以 CPT 代码组合的形式与索赔相关联。

  • 支付代码表由许多不同的cpt_code_combos值组成,这些值是基于每个提供商设置的。

  • 我们将 CPT 代码保存在两个地方;尽管这通常不被认可,但该架构的初始草案将在给定的 CPT 代码组合记录中使用逗号分隔的 CPT 代码列表,从而使我们能够执行以下操作:

    1. 获取包含在 CPT 代码组合中的 CPT 代码

    2. 获取包含单个 CPT 代码的 CPT 代码组合

    3. 从指定的 CPT 代码列表中获取 CPT 代码组合

清单 9-5 显示了构成 paycode sheet 概念的雄辩模型及其与我们应用中其他模型的关系。

<?php

namespace Claim\Submission\Domain\Models;

use Illuminate\Database\Eloquent\Model;

class PaycodeSheet extends Model
{
   public function provider()
    {
        return $this->hasOne(Provider::class);
    }

    public function cptCodeCombos()
    {
        return $this->hasOne(CptCodeCombo::class);
    }

    public function center()
    {
        return $this->hasOneThrough(Center::class, Provider::class);
    }
}

Listing 9-5The Paycode Sheet Model

这是一个非常简单的模型,应该没有什么好惊讶的。关于工资代码表,我们可以做以下句子:

“一个支付代码表属于一个提供商,而该提供商又是一个 FQHC 的成员,并且还拥有一个与之相关的 CPT 代码组合。”

还有一些我们还没有想到的事情:如果一个支付代码表,在模型术语中,是许多可能的程序组合(CPT 代码组合)中的一个,以及为这些程序支付的费率(估计的索赔金额)。

在创建了PaycodeSheet模型之后,我们需要对它所涉及的其他模型进行修改(我们通过类方法添加了关系的模型:Provider, CptCodeComboCenter)。我们还将在清单 9-6 中添加Center类及其与其他模型的关系。

<?php

namespace Claim\Submission\Domain\Models;

use Illuminate\Database\Eloquent\Model;

class Provider extends Model
{
   public function patients()
    {
        return $this->hasMany(Patient::class);
    }

    public function paycodeSheet()
    {
        return $this->hasOne(PaycodeSheet::class);
    }

    public function practice()
    {
        return $this->belongsTo(Practice::class);
    }
}

Listing 9-6The Updated Provider Model

Provider类非常简单明了。注意清单 9-7 中的paycodeSheet()方法定义了提供者和支付代码表之间的关系。关于Center模型唯一有趣的事情是它与Provider.HasManyThrough关系。让我们看看中心模型可能是什么样子:

<?php

namespace Claim\Submission\Domain\Models;

use Illuminate\Database\Eloquent\Model;

class Center extends Model
{

    public function practices()
    {
        return $this->hasMany(Practice::class);
    }

    public function providers()
    {
        return $this->hasManyThrough(Provider::class, Practice::class);
    }
}

Listing 9-7The New Center Model

我们将更详细地讨论口才的关系类型,并定义这些关系的逆关系。现在,您需要认识到的所有事实是,存在属于单个中心的多个提供者,并且这种关系被捕获为“中心通过它们的实践有许多提供者。”这两条语句给出了相同的结果,如下所示:

$providersInCenter = Center::first()->providers;

//is the same as writing :
$providersInCenter Center::first()->practices()
                                  ->get()
                                  ->map (function(Practice $p) {
                                  return $p->providers; })
                                  ->all();

HasManyThrough关系仅仅是获得相关记录的捷径。这为我们省去了创建 SQL 查询的麻烦,该查询通过providers表上的INNER JOIN获取行,并通过practices表收集我们需要的行(清单 9-8 )。

<?php

namespace Claim\Submission\Domain\Models;

use Illuminate\Database\Eloquent\Model;

class Practice extends Model
{
    public function center()
    {
        return $this->belongsTo(Center::class);
    }

    public function providers()
    {
        return $this->hasMany(Provider::class);
    }
}

Listing 9-8The Updated Practice Class

我们在这里定义关系的方式包括在相关模型上的反向方法。在清单 9-8 中,Practice模型“有许多”提供者,这意味着外键属于更远的模型(providers.practice_id)。另一方面,Practice模型属于一个中心,所以我们可以预期关系 ID 在更近的模型上(practice.center_id)。当我们定义两个模型之间的反向关系时,它允许我们在任一方向上进行查询。

$center = Practice::first()->center; //returns a Center object

$practices = Center::first()->practices //returns a Collection of
                                        //Practice objects

结论

在这一章中,我们了解了关于 CPT 代码和医疗保健计费系统的更多细节和特定领域的概念、实践和一般知识。当我们在现实世界中建模时,如果你有一个精心挑选和利用的编码技术工具带(比如在 DDD 发现的),那么这样做就容易得多;真正强大的是当它们以一种有效的方式与一些将概念实现为真正的工作代码的方法结合在一起时(比如你用 Laravel 得到的)。Laravel 中的建模变得更简单,因为它使用了流畅的界面和命名良好的方法,这些方法的链形成了真正的英语句子。还有什么比用简单的英语更好的方式来描述你的模型和它们之间的关系呢?这样,对于概念或模型是什么或做什么就不会有误解。我们将在后面的章节中更深入地探讨这些主题。

十、领域驱动的声明验证方法

在这一章中,我们将通过正式应用领域驱动设计的概念和实践来更加熟悉它们,我将解释这些实践如何与现实世界中的应用相关联,尤其是与 Laravel 项目相关联。然而,这些核心策略大多适用于比框架更广泛的范围。它们中的许多都适用于任何项目,不管它是用什么语言或框架构建的。事实上,当抽象出您的领域的模型时,最好将焦点放在领域本身,而不是随之而来的技术问题上。技术问题可以推迟,这在编程中是一件好事,因为推迟的时间越长,您对项目投入的精力就越多,然后您就可以做出最好的决策。技术关注点旨在为您提供关于如何解决与您的领域核心相关的复杂问题的想法,以便您可以正确地创建一个可用的模型,该模型密切模拟该领域内的业务对象和实践。

我将主要使用我们在本书前面介绍和阐述的索赔提交项目中的例子。具体来说,我们将尝试确定验证需求,并讨论与声明模型相关的前置条件和后置条件,以及如何在代码中最好地实现这些内容(剧透警告:通过将它们包含在条件适用的实体或类中)。我们还将回顾上下文图:它们是什么,如何使用它们,以及用于描述上下文图中关系的各种模式。这样做,我们将获得系统架构的高层次视图,这将允许我们在其他环境中做出更好的决策。

Note

本章中的大多数例子都来自于索赔验证上下文。

拉勒维尔适合的地方

Laravel 为您提供了一种易于使用的方法,来创建应用中那些与领域无关的部分,这些部分消费、维护、处理或以其他方式接触领域层中的对象;这些内容包括日志记录、缓存、数据库到对象的表示、响应生成、请求验证等等,这些内容太长了,无法包含在文本中。

当然,最终,DDD 的概念都只是如何以一种最终可以在代码中修饰的方式来制定一个好的领域设计的策略。所有与核心领域相关的脏活累活都是你的责任。然而,DDD 确实使这个领域的疯狂变得更易于管理,更易于包含和描述,因为从一般的观点来看,这些模式并不适用于任何特定的行业或领域,而是适用于在软件中建模商业问题。它们是一组经过试验和测试的模式和方法,可以产生一个领域的最佳模型。也许你正在构建的是尚不存在的东西——一些新的、开创性的网络应用。太好了!然而,这并不意味着您必须完全从头开始构建一切——仅仅是领域部分。我们可以使用 Laravel 将领域层中的一切联系在一起,以提供应用成功所需的功能。

我们应该努力从功能上分解相关领域的业务流程、约束和逻辑,并将所有内部工作和实体抽象成单独的部分,以最适合业务中发生的真实操作的方式进行分解。只有这样,我们才能以一种真正有意义和精炼的方式,与领域专家就系统的(可能是许多)类和对象达成理解和一致,这种方式最好地抓住了应用构建的意图和底层业务概念,以促进/自动化/消除混淆。显然,我们不会等到达到这一点才开始编码特性。建模、实现和现实世界的可用性(或不可用性)之间总是有一个来回,这导致域和处理其各个方面的代码是分离的(这种类型的分离实际上是不好的)。必须检查模型和领域现实的分离,这就是为什么 DDD 建议 CI/CD 和重构一起培养模型的真正有意义的表示。专注于领域模型,让 Laravel 的特性和结构成为一种粘合剂,将一切联系在一起,创建一个功能齐全的实用应用,该应用实际上对业务有用,并解决业务问题(这些是应用最初出现的原因)。

我们将讨论 DDD 如何提出一系列构建模块(也称为 DDD 的技术方面),包括实体、价值对象、工厂、存储库、服务和领域事件等。当这些构建块与灵活的设计和提炼结合使用时,它们可以帮助产生一个领域模型,该模型是业务对象本身以及它们和作用于它们的过程之间的关系的实际和现实的轮廓。将代码与模型紧密地联系起来是赋予模型意义的,也是使模型相关的。我们将在这里触及这些主题,并在本书的后面深入探讨其中的一些。我们还将讨论验证和约束对领域模型的影响,以及 Laravel 提供了哪些工具来快速解决这些问题。

简单回顾一下

领域驱动设计,在高层次上,在软件开发项目中有两个主要的关注点。

  • 主要焦点是模型的设计、实现、持续集成和重构。

  • 任何复杂的领域设计都是基于模型的。

因为主要的焦点是在模型上,领域驱动设计的好处将几乎立即开始实现,甚至在软件实际发布之前。这怎么可能?通过培养一种适当的无处不在的语言并在业务范围内采用它,你可以开始看到你的设计和业务本身发生的好事情,因此它被各部门和员工作为描述领域的统一语言使用。它将是该领域中用来指代文档、过程、架构结构、类、实体以及任何可以被认为是该领域一部分的事物的语言。

简单地通过编码(实现)设计获得的知识经常是有启发性的,因为它可以指出领域模型中的地方,在那里设计可能在概念上是错误的,或者甚至可能没有正确地构造,因此不适合它被放置的上下文。每当对领域中的任何技术或与领域的内部逻辑相关的东西有所了解时,这种知识应该在设计和实现中被捕获(我这样说是为了适应在代码编写之前和之后发生的这种了解)。在本章中,我们将通过一些例子来说明如何做到这一点。

我们希望始终避免的是,用任何框架、模式或其他类似组件中的概念和元素来命名事物的诱人做法,这些概念和元素通常描述系统中的某个东西是什么。我们应该总是选择用无处不在的语言中的术语来命名系统的任何和所有方面,包括架构结构和名称空间,这样我们就可以根据每件事情做什么来将这些名称实现到软件中。尽管领域中的某个对象或概念看起来很适合这些模式或预定义结构中的一种,但是更好的方法是允许领域和核心业务逻辑成为通用语言的来源,并允许领域模型中的对象根据通用语言进行命名。

所有的东西在纸上看起来都不错——想法和粗略的计划是一个很好的起点。它们是最终产品的潜在组成部分,并且它们可以有一个完整的、经过深思熟虑的解决方案的外观。然而,您真的永远不会知道,直到您进一步进入知识发现阶段,或者直到您开始编码项目。正是在这一点上,您最有可能找到如下内容:

  • 领域模型的不准确定义。

  • 不需要或未使用的域对象。

  • 不可行的领域概念组合或糟糕的模式实现。

  • 太宽泛(范围太广)需要分开的组件和概念。

  • 过于狭窄的组件和概念,或者应该分离的组件和概念。

  • 仅部分(或根本不)代表基础领域的设计。

  • 功能分解概念中的范围问题。

  • 职责过多或过少的类或上下文。

  • 设计和实现之间明显的概念差异。

  • 无处不在的语言中的项目没有在应用于其他组件或类的命名约定中使用。

  • 该架构没有像它应该的那样表达模型的意图和分离。

  • 领域模型中存在许多其他不清楚的地方,并且/或者对于给定定义的范围存在误解/混淆。

  • 设计不良的模型已经进入了实现。

  • 规则和约束不明确。

如果你颠倒一下这个列表,你会发现一个高质量的、领域驱动的设计的描述,它封装了领域知识,以一种清晰的、功能性的方式表现了潜在的领域。大概是这样的:

  • 领域模型有领域专家一致同意的精确定义。

  • 细分良好的组件很好地对应了底层业务流程。

  • 有一个经过深思熟虑的设计,在不同的部分有适当的界限。

  • 每个组件的范围都非常适合支持其自身(以及任何已确定的依赖项)

  • 领域模型中的过程和结构是领域的文字反映。

  • 服务和上下文是自封装的,公开了一组高度内聚的元素,这些元素在内部耦合到同一分组中的其他项目,但是松散地耦合到其他服务和上下文。

  • 责任在每一个职业或环境中都被恰当地转移了。

  • 实现反映了模型,并以有意义和有见地的方式表示了基础领域,清楚地展示了意图。

  • 系统中的所有组件、类或其他任何东西都是按照约定的通用语言命名的。

  • 该架构是松散耦合的,但仍然表现出高度的内聚性。

  • 所有先前的误解或冷漠已经被消除,它们隐藏的细节已经变得清晰。

  • 系统的设计为系统的实现提供支持(设计和模型彼此紧密配合,达到了相互补充的程度)。

  • 所有的规则和约束都有明确的定义。

建模问题和解决方案空间

考虑系统元素的一种方式是从两个“空间”的角度来考虑,领域中的任何东西都可以属于这两个“空间”:

  • 问题空间:在 DDD,问题空间由领域模型中涉及的各种领域和子域来表示。这是系统中需要解决的一切,比如需求。我们的索赔项目中的例子包括索赔提交、索赔验证和不同用户角色的权限。

  • 解空间:这由有界上下文和上下文图来表示。示例包括处理所有索赔提交的验证需求的自定义后端和处理用户授权的 Auth 通用子域。

保留一个记录这两个空间的列表或图表(在文档中的某个地方,以便整个团队都可以查看)可能会有所帮助。制作一个简单的“T”形图,列出你领域的问题空间中的所有问题,以及你将要实施来解决这些问题的相关解决方案。这将有助于保持对事物的正确认识,并确保软件的需求确实得到了满足。图 10-1 显示了一个与索赔项目相关的一些(不是全部)关注点的例子(注意,我们没有涵盖现实应用中存在的每一个关注点),取自索赔验证上下文。

img/488324_1_En_10_Chapter/488324_1_En_10_Fig1_HTML.jpg

图 10-1

索赔验证上下文的索赔示例项目中的问题空间和解决方案空间

这个图表非常简单,包括一些关于我们在开发这个应用时所面临的各种问题的简要总结,以及一些关于每个问题的可能解决方案的相应要点。

交付机制

根据系统的几个方面,在 Laravel 和任何其他 web 应用中有不同的方法来交付响应。其中一个方面是交付机制,它可以是单个类,也可以是多个类一起向外部世界提供域的服务和逻辑。在 web 应用中,这通常是通过一个控制器和一个视图来完成的,该视图被编译为 HTML 并作为对客户端请求的响应发送到浏览器,但是您可能拥有或需要额外的机制来响应 API 请求(资源、资源控制器和转换器)、命令行请求(Artisan 命令)或带有另一个 sms 消息的 SMS 文本消息请求。

无论您为您的应用实现什么样的交付机制,它们都应该与域模型相分离。域层中的任何东西都不应该关心响应是如何传递给系统的,或者请求是如何进入系统的;在这些事情上我们可以依靠拉弗尔。这样做,我们可以节省构建基础代码的时间,这些代码将作为管理域对象以及与域层中其他对象的交互的“管道”。

继续索赔模式

为了更好地理解 DDD,我们将从设计索赔项目的模型时停止的地方继续。图 10-2 提供了我们正在构建的内容的复习。

img/488324_1_En_10_Chapter/488324_1_En_10_Fig2_HTML.jpg

图 10-2

索赔模型

该模型与系统中的其他实体和业务对象有一些关系。一个索赔显然有一个提交索赔的提供者和一个完成程序的患者。此外,该特定患者的持续医生进度记录与索赔一起保存,以及执行服务的日期。还有 CPT 代码,它描述了给予患者的特定治疗,FQHC 使用它作为他们实际支付给提供者的费用。基本上,这意味着这种情况下的索赔可交付的。没有索赔,谁也拿不到钱。当我们从这些方面考虑时,我们甚至可以将这种情况下的可交付成果与软件开发人员为了获得报酬而需要的可交付成果进行比较:工作的、可用的软件。如果我们将有问题的东西发布到生产中,我们几乎会立即意识到这一点,因为大量充满敌意的电子邮件和联系支持票的数量都呈指数级增长。这就是为什么我们要确保我们已经正确地测试和编码了我们的系统,以避免在运输未经测试或不稳定的产品时可能出现的所有错误和停机时间。

我们需要像对待软件一样对待索赔,因为最终要由 FQHC(经过 FQHC 计费用户的验证和签署)来确定我们的索赔是否符合联邦法律规定的要求,只有这样他们才会向服务提供商支付费用。我们需要确保我们的可交付物没有瑕疵,以防止付款延迟,这就是我们的应用的目的。

定义范围

这一部分涵盖了整个体系结构和设计的各个部分,包括声明验证上下文中包含的关注点。图 10-3 显示了它包括的内容。

img/488324_1_En_10_Chapter/488324_1_En_10_Fig3_HTML.jpg

图 10-3

关于索赔项目的关注点,我们将在本章中讨论

图 10-3 中描述的项目主要包括验证所有必需的文件和输入数据,确保所有其他要求(如患者资格)正确无误,以及验证 CPT 代码组合对该提供商有效。请记住,每个提供者都有自己的支付代码表,其中描述了他们可以使用的 CPT 组合以及他们为某个程序支付的金额。还有一个问题是,根据薪资代码表和索赔的 CPT 组合的计算来估计索赔的支出。

这里的总体目标是消除错误源,否则这些错误源可能是由服务提供者或接待员的错误造成的。在我们实际向系统提交索赔之前,我们需要确保所需的数据都在那里并且有效(在这种情况下,索赔的下一站将是人工索赔审查过程)。在将索赔提交给 FQHC 之前,该流程需要一名团队成员亲自核实索赔中的所有数据是否正确。

确认

验证是软件开发的一个重要方面;它们以约束一个更加无限的宇宙中的无限数量的项目的形式提供了某种理智。具体来说,它们使我们能够确保我们拥有的任何数据都是有效和准确的,这样我们就不必因为用户错误、打字错误或任何其他错误而回去“重做”,如果不正确,这些错误可能会导致糟糕的事情发生。

对于我们的索赔模型,有几件事情我们需要验证,这样我们就可以认为索赔是“有效的”(至少对于自动验证检查来说),并将索赔转移到审查过程中。据我们所知,我们在本书前面设计的索赔模型具有大多数所需数据的关系,因此我们可以假设我们将要验证索赔对象中生命的大多数数据。但是,列表上的一些项目表明,这些数据实际上应该属于系统中的其他模型。

这方面的一个例子是病人的资格。我们可以将资格附加到索赔本身,这很好,可能会满足我们的需要。然而,在我看来,资格更多地与病人而不是索赔有关。这方面的一个例子是患者的资格(图 10-4 )。

img/488324_1_En_10_Chapter/488324_1_En_10_Fig4_HTML.jpg

图 10-4

让患者对象持有资格数据而不是索赔

这感觉比把资格放在索赔本身更自然。现在,虽然我们正在核实索赔数据,但我们要获得资格并确保患者有资格接受护理,所要做的事情如下:

if ($claim->patient->eligibility->isEligible()) {
     //patient is eligible
}

因此,我们所做的就是遍历关联以找到我们正在寻找的数据,然后根据这些数据做出决策或采取行动。完成了,对吗?

然后,我们意识到患者的资格可能会从一个时期到下一个时期发生变化。然而,对于图 7-4 中的设计来说,这不应该是一个问题,因为这只是在系统中保存对该患者最近一次资格检查的结果。每当该患者的资格状态有更新时,我们只需更新数据库中的相关记录。因为我们将资格放在它自己的封装模型中,并声明与它和患者模型的一对一关系,所以我们的索赔已经有了可用于验证患者是否有资格接受护理的数据。

使用验证请求

正如本书前面所描述的,使用 Laravel 请求来指定传入(请求)数据的数据类型有助于低级验证,否则从头实现起来将是一项相当乏味的工作。请求允许我们抽象出传递机制,通过这种机制,数据以特定的路由为目标流入系统,正如我们在前面章节中了解到的,这只是一个到控制器的映射,或者是到 routes 文件的闭包中包含的一些逻辑的映射。当然,这仅允许我们验证低级约束,例如:

  • 验证数据库中存在的记录

  • 验证数值字段是否在给定范围内

  • 验证参数在可接受的列表内

  • 验证作为输入传入的参数的类型

重申一下,所有的验证文档都可以在 https://laravel.com/docs/6.x/validation .找到,你可能想用代码验证的任何东西,Laravel 都有约束。Laravel 还附带了一个Validation组件,可以对其进行定制,以满足尚不可用的验证需求。

在一个请求中,您可以指定请求中的传入数据必须遵循的任何规则,以便请求能够到达在rules()方法中的 route 中指定的控制器(正如我们在本书前面所讨论的)。当在请求中设置规则时,它被称为表单请求,因为它们主要用于验证来自 HTTP 表单的请求。然而,您可以使用Validation facade 实现定制验证需求或预定义验证,如下所示:

$validator = Validator::make($request->all(),
    'title' => 'required|unique:posts|max:255',
    'body' => 'required',
]);

请记住,传递给make()方法的不一定是Request对象,而是任何键/值数组。一旦设置了验证器,您可以使用以下命令检查验证是否通过:

if ($validator->fails()) {
     return redirect('/page')
          ->withErrors($validator)
          ->withInput();
}

如果您想将错误快速显示到会话中(也就是在前端的屏幕上显示给用户,同时保留原始输入),可以使用一个withInput()方法。

当我们验证索赔的用户输入数据时,我们可以利用它来形成所有标准的基本验证。例如,我们的索赔提交模型的第一道防线应该是检查数据的有效性。我们需要能够验证患者的详细信息(姓名、出生日期、医疗 ID 等。)、所需患者文档的状态和存在、索赔中包含的进度注释的存在以及索赔的有效服务日期(这意味着索赔中的患者的治疗在去年内完成)。利用 Laravel 的验证系统,我们将能够处理提交索赔所需的大部分输入验证。

索赔的用户界面很可能会被分成不同的屏幕,以便用户更容易输入数据,而不会在一个屏幕上塞满提交索赔所需的所有数据。但是,前端/UI 将处理用户在屏幕上看到的大部分验证(因此错误可以被记录到会话中,并在屏幕上显示给用户)。我们处理这种情况的方法是,将进入系统的初始索赔请求作为单个请求发送到我们的应用中。这将允许我们将验证约束放在单个类中,并通过在相应的控制器方法中键入提示请求来自动调用它们。清单 10-1 展示了这可能是什么样子。

ddl/Claim/Domain/Submission/App/Http/Requests/ClaimSubmissionRequest

<?php

namespace Domain\Submission\App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ClaimSubmissionRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }
    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
        'claim.patient.first_name' => 'required|text|min:2',
        'claim.patient.last_name' => 'required|text|min:2',
         'claim.patient.dob' => 'required|date',
        'claim.patient.medical_number' => 'required|integer',
        'claim.progress_notes' => 'required|min:1',
        'claim.patient.documents.identification' =>
              'required|file',
         'claim.patient.documents.application' => 'required|file'
    ];
    }
}

Listing 10-1An Implementation of the Request When a Claim Is Submitted, Including the Validation Rules

我特意省略了与前端相关主题的讨论。但是,为了让应用正常工作,我们可以有一个请求来为每个字段建模简单的验证,我们必须想办法向用户显示索赔的各个页面,并在用户从一个屏幕移动到下一个屏幕时跟踪他们在表单中的输入。这可以使用 session 甚至LocalStorage,来完成,但是必须跨几个不同的页面手动管理表单上每个输入的状态是非常乏味的(这可能包括用于自由形式响应的文本框,如进度注释,多选选项列表的复选框,可能还有几个下拉框,以及其他输入)。您可以使用所谓的道具钻取,它基本上将属性向下传递到 UI 并返回到后端,但这也带来了问题,因为每当表单中使用的任何道具发生变化时,您都必须这样做;当你在处理一堆道具时,它会很快变得混乱。我建议您查看脸书的 React 库( https://reactjs.org/ ),因为它具有特殊的编程特性,可以在前端管理应用状态,处理跨页面和跨组件状态,以及 React/Redux、上下文和钩子的组合。我不会在这里深入讨论,但是谷歌是你最好的朋友。

Laravel 验证为简单的验证检查、原始类型验证、字符长度等提供了一个易于使用的现成解决方案。我在清单 7-2 中定义的规则是为了让您了解预定义的 Laravel 验证的威力。从 DDD 的角度来看,这些验证应该是索赔提交域上下文中应用层的一部分。这就是类驻留在Domain\Submission\App\Http\Requests名称空间中的原因。验证提供了一种方法来抽象出一大堆开销代码,直接用于验证我们在图 7-3 中列出的简单约束。

这一个请求类负责索赔提交期间所需的大部分基本验证。还有一些额外的验证需要定制逻辑,这也涉及到索赔提交的关注点;然而,它们实际上属于索赔验证上下文,而不在索赔提交上下文中(该上下文处理诸如索赔状态跟踪、索赔变更历史以及在提交过程中跟踪索赔等问题)。

  • 确保给定索赔的患者实际上属于该提供者的检查。

  • 声明中包含的 CPT 代码组合需要通过两种方式进行验证。

    1. 每个单独的 CPT 代码都需要验证是否存在于我们的系统中(这将依赖于静态数据库表进行查找)。

    2. 验证在申请中选择的组合实际上是属于提交申请的提供商的一组有效的个人 CPT 代码(这将依赖于提供商的支付代码表来验证 CPT 组合是否有效)。

向索赔模型添加验证

按照传统 DDD 的描述,在任何模型上进行的验证最好保留在模型本身中(如果你关心的是验证模型的单个属性)。保存模型中有效的或者直接应用于该模型的约束的最佳位置应该尽可能地靠近该模型。为了从整体上验证一个对象,最好将验证逻辑从模型本身中分离出来,因为它们是不同的关注点。一个关注点是业务实体本身及其封装逻辑,另一个关注点是验证该实体。

验证服务日期

例如,索赔必须在服务日期(DOS)一年内提交的约束是一个重要的全局验证。有什么地方比我们可以保证每次创建索赔的新实例时都会调用这个严格要求的地方更好呢?换句话说:在索赔本身。这是它看起来的样子。

在清单 10-2 中,我们明确声明在索赔的上下文中存在一个业务不变量,即索赔的服务日期在去年的某个时候。如果没有,它将抛出一个异常。通过将我们的验证定义到声明模型中,我们使它变得显式——使它成为“验证”机制的原因是,检查是在构造函数中对客户端强制进行的。

// /ddl/Claim/Submission/Domain/Models/Claim.php
<?php
namespace Claim\Submission\Domain\Models;
//use statements
class Claim extends Model
{
     const DOS_MAX_AGE = '1 year';

     public function __construct(array $attributes = [])
     {
          parent::__construct($attributes);
          $this->checkDateOfService();
     }
     private function checkDateOfService()
     {
          $dos = new \DateTIme($this->dos);
          $expiration = new \DateTime(static::DOS_MAX_AGE);
          if ($dos > $expiration) {
               throw new DateOfServiceExpiredException();
          }
     }
//the remaining functions describing the Claim's relationships
}

Listing 10-2The Claim Model with the Date of Service Validation Handled Explicitly in the checkDateOfService() Method

起初,这似乎解决了问题,但是我们还没有考虑到我们在任何给定时间拥有的索赔模型实例可能已经存在,并且其服务日期比索赔到期日期晚了一年多。在这种情况下,如果我们试图将旧的声明实例化为模型,可能会包含在一些历史汇总中以生成详细的报告,那么前面的解决方案实际上会产生问题。有许多方法可以解决这个问题。让验证接近它所验证的东西是有意义的,但是这不是实际上将整个架构推向一个单一的应用吗?我们希望能够尽可能地抽象出任何细节,这样模型本身就可以是一个纯粹的、丰富的模型,能够捕捉领域的意图。在这些细节中,肯定会有对模型创建的验证约束,但如果声明已经存在,就没有必要了。

一种符合关注点分离思想的处理方法是将模型从验证模型的逻辑中分离出来。您想要这样做的原因是,组成对象的代码很可能以不同于验证它的代码的节奏发展(变化)。这里你可以利用的一个好处是利用了 concillator 的模型生命周期事件,这些事件会在扩展 concillator 基类的每个模型上自动触发。您可以使用这些事件来挂钩附加的逻辑,并且因为它们是在模型生命周期中的不同点发出的,所以您可以非常灵活地定义在什么时候执行该逻辑。以下是由口才自动激发的事件:

  • Retrieved

  • Creating

  • Created

  • Updating

  • Updated

  • Saving

  • Saved

  • Deleting

  • Deleted

  • Restoring

  • Restored

您可以在应用生命周期的任何时候挂钩这些事件。对于我们的用例,我们可以使用Creating事件来挂钩并提供在对象实际保存到数据库之前运行的逻辑。这将是注入我们的业务不变量的完美地方。为了封装这样一个概念,即已经创建了一个声明,应用的其余部分将对其作出反应,我们应该创建一个领域事件来将所有事情联系在一起。

Note

使用 Laravel 的 Artisan 命令通过前缀make构建基本结构是一个好主意。问题是我们的域驱动的名称空间存在于\Claim中,它不被命令支持(至少就我所知)。这些命令将构建出组件,并仅将其保存在根App\中的相应名称空间中。目前不支持主App\之外的定制名称空间。一个变通办法是运行一个make命令,然后将结果文件移动到它在域中的位置,在我们的例子中是Claim\Submission\Domain\Events\ClaimCreated.。生成这个类的命令如下:

php artisan make:event ClaimSaved
<?php

namespace Claim\Submission\Application\Events;

class ClaimSaved
{
     use Dispatchable, InteractsWithSockets, SerializesModels;
     public $claim;
     public function __construct(Claim $claim)
     {
          $this->claim = $claim;
     }
}

我们已经将该类作为索赔提交上下文中的一个事件,并通过其名称空间表明它应该被视为一个应用问题,因为事件本身不应该包含任何真正的业务逻辑,而是应该严格地在“容器对象”的意义上使用,这些对象在指定的时间点被触发,在我们的例子中是指任何时候有一个claim实例被创建。

如您所见,这只是一个普通的 ol' event 类,它接受一个Claim实例作为它的构造函数,通过在声明中公开访问它,使它对这个类的侦听器可用。我们通过挂接在正确的时间点(在索赔创建期间)触发的雄辩事件,使该事件自动触发。

class Claim extends Model
{
   /**
     * The event map for the model.
     *
     * @var array
     */
    protected $dispatchesEvents = [
        'saving' => UserSaved::class,
    ];

}

对声明模型的这一小小的改变允许我们覆盖雄辩的基础Model类的$dispatchesEvents属性,这仅仅是一个映射,即哪个雄辩的生命周期事件触发我们的哪个领域事件,应用的其余部分将被设置来使用和响应。在这种情况下,保存生命周期事件将触发一个UserSaved领域事件。

既然我们已经设置了事件,这样它将只在任何将它保存到数据库的尝试中运行,我们需要一个地方来放置服务日期的实际验证逻辑。我们可以使用传统的Listener;然而,Laravel 带有一种特殊类型的监听器,称为观察器。通过使用观察器,我们可以在一个地方附加多个监听底层域对象变化的监听器。这对我们来说是完美的,实际上也非常符合 DDD 方案,因为观察者生活在领域层,直接关注领域中的对象及其变化。我们可以制作一个ClaimObserver,如清单 10-3 所示。

<?php
namespace Claim\Validation\Domain\Observers;

use Claim\Submission\Domain\Models\Claim;
class ClaimObserver
{
     const DOS_MAX_AGE = '1 year';

     public function saving(Claim $claim)
     {
          $dos = new \DateTIme($claim->dos);
          $expiration = new \DateTime(static::DOS_MAX_AGE);
          if ($dos > $expiration) {
               throw new DateOfServiceExpiredException();
          } else {
               return true;
          }
     }
/* additional class methods pertaining to the fired model event*/
     public function creating(Claim $claim)
     {
          // some other broad-scoped validation checks
          // occurring on a create + save operation
     }
}

Listing 10-3An Example ClaimObserver Class with the Logic That Validates the Date of Service Before It Is Saved to the Database

这种方法的唯一问题是,观察者包含常量“1 年”作为服务日期索赔的允许范围。放在观察者里感觉不太对。这个设置最好放在特定键下的.env文件中,这样整个应用都可以访问它。请记住,只有当在相应模型的生命周期中某个特定事件需要发生全局的事情时,观察器才是有用的。我们选择在前面的例子中使用一个,因为我们需要“服务日期不能超过自提交索赔之日起一年”的全局约束,以便每次都应用,但只能在将记录保存到数据库之前应用。例如,它不会应用于查询选择的任何模型,表明它们已经存在于数据库中。这是一个相当干净和简单的解决方案,您可以针对多个验证需求重复使用。

附加验证

如果您希望能够存储任何重要的逻辑,而这些逻辑在每次模型遇到通过 observer 类上的方法指定的特定生命周期事件时都必须运行,那么使用 observer 非常有用——在我们的例子中,是针对模型中的单个属性(服务日期)的验证检查。对于模型上特定属性的验证,我们可以做很多事情,这取决于用例。

  • 如果我们要使用验证器来验证通过表单请求进入应用的给定属性,我们可以在它被路由到的控制器中编写验证逻辑,如下所示:
public function store(Request $request)
{
    $validatedData = $request->validate([
        'cptCodes' => 'required|array',
        'body' => 'required',
    ]);
    // The claim is valid
}

或者,正如我们在整本书中多次看到的,您可以将这些封装到一个单独的Request对象中,并将其传递给控制器函数。

  • 如果验证需要在特定的时间点发生,或者在其他事情发生之前/之后发生,或者如果我们想要给验证本身添加一些结构,我们可以构建一个验证器,它将处理我们需要以 OOP 和可靠的方式验证的各种细节。

  • 我们可以回顾一些想法,重新考虑我们在模型本身中保留验证约束的那个想法,然后通过推理它们过于紧密地联系在一起而不能分离,从而讨论我们的方法来摆脱关注点的良好分离,然后添加验证检查以确保验证器只在创建时运行。这种策略的一个更好的版本是使用雄辩的生命周期模型事件,并利用模型生命过程中的任何时间点。

  • 我们可以创建一个验证服务,只负责验证它被配置为支持的特定对象类型;此外,我们可以利用我们将在接下来的几页中构建的体系结构来促进服务。这是这个列表中最复杂的想法,但它也带来了最大的好处(也是最大的设置工作)。您可以建立一个Service类来保存对这些基本对象的引用,然后编写代码来有效地利用它,并通过一个封装良好(最好是文档完善)的 API 向外界公开该功能。由于这个解决方案提供的复杂性,我实际上只有在我有一组复杂的对象,并且我可能需要验证单个属性以及整个对象的情况下才会这样做。这种增加的复杂性需要更复杂的方法。

当面临决策时,请始终重新审视您正在处理的环境,以确定当前问题和提供的解决方案所涉及的范围。将焦点放在领域行为上,而不一定是它们隐含的技术细节。在这一点上,我们实际上要做的事情如下:

  • 验证关于患者及其提交的表单和文档的信息的表单输入要求(我们通过之前创建的请求来处理)。

  • 验证传入申请上指定的 CPT 代码组合,以确保其有效并存在于提供商的支付代码表中。

  • 验证患者的资格(只是验证部分,而不是整个 web scraper 我们将在稍后构建)。

构建抽象验证器

对于单个属性,Laravel 中的验证最好在一个Request类中完成,手动使用 Laravel 的Validator组件,或者作为简单的类方法从模型的构造函数中启动验证。我们现在想要做的是能够完整地验证一个Claim对象。因为一个声明由几个更小的部分组成,每个部分都需要有效,整个声明才能被认为是有效的。我们还希望使验证器可重用,这样下次我们必须在应用的其他地方使用一些定制的验证逻辑时,我们可以使用相同的代码。

要制作一个可重用的组件,根据组件的功能分解组成组件的各个项目是很有帮助的。为了验证,我们有几个关键人物。

  • 验证程序接口

  • 实际验证逻辑(也称为验证处理程序)

验证器的验证接口非常简单。我将ValidationHandler的这个接口放在了Validation\Infrastructure\Contracts名称空间中,因为它们都严格处理通用验证,因此不包含实际的业务逻辑;因此,我认为它们应该放在Validation上下文中的域层之外。从技术上来说,这是一个基础设施问题,我认为这是最好的选择,但实际上,您可以将它们放在最适合您的应用和您的架构风格的任何地方。清单 10-4 展示了ValidationHandler界面的样子。

<?php

namespace Claim\Validation\Infrastructure\Contracts;

interface ValidationHandler
{
     public function handleError($error);
     public function validate();
     public function getModel();
}

Listing 10-4ValidationHandler Interface

ValidationHandler接口中,handleError()方法接收一个原始错误(因为我们还不知道应用中可能发生的具体错误),并将包含由于错误而发生的任何动作或事件。validate()方法是验证逻辑的核心所在。让我们通过创建一个新的子类来实现ValidatorInterface契约。为了使实现的核心逻辑对所有子类都可用,我们将使类成为抽象的。这不能用接口来完成,因为接口包含简单的函数签名,这些签名必须由实现类来定义,因此不包含实际的逻辑(清单 10-5 )

<?php

namespace Claim\Validation\Infrastructure\Validators;

use Claim\Validation\Infrastructure\Contracts\ValidationHandler;

abstract class AbstractValidator
{
     private $validationHandler;

     public function __construct(ValidationHandler $validationHandler)
     {
          $this->validationHandler = $validationHandler;
     }

     public function handleError($error)
     {
          $this->validationHandler->handleError($error);
     }

     abstract public function validate();
     abstract public function getModel();
}

Listing 10-5An Abstract Validation Class

这里显示的抽象类没有实现任何接口;然而,它的设置使得它在技术上可以实现,这样,实现了ValidationHandler接口的子类将已经在抽象类中有了所需的函数,用指定为abstractvalidate()方法来强制子实现定义其特定的验证逻辑。我们还包含了一个抽象的getModel()方法,这样我们就可以询问哪个验证器正在验证哪个模型。

实现声明的验证器

当实现一个验证器时,请记住,索赔和系统中的其他模型之间需要存在正确的关系,以及任何附加的属性级验证函数(isValid())。这显示在清单 10-6 中。

<?php

namespace Claim\Validation\Infrastructure\Validators;

use Claim\Validation\Infrastructure\Validators\AbstractValidator;
use Claim\Validation\Infrastructure\Validators\Handlers\ValidationHandler;
use Claim\Submission\Domain\Models\Claim;

class ClaimValidator extends AbstractValidator
{
     private $claim;
     private $validationHandler;

     public function __construct(Claim $claim,
                               ValidationHandler $validationHandler)
     {
          parent::__construct($validationHandler);
          $this->claim = $claim;
     }

     public function getModel()
     {
          return Claim::class;
     }

     public function validate()
     {
          if (!$this
               ->claim
               ->documents()
               ->exists()
               ){
               $this->handleError('missingDocuments');
          }

          if (!$this
               ->claim
               ->eligibility()
               ->exists()
               ){
               $this->handleError('missingEligibility');
          }

          if (!$this
               ->claim
               ->cptCodeCombo()
               ->exists()
             ||
             !$this
               ->claim
               ->cptCodeCombos()
               ->isValid()) {
               $this->handleError('invalidCptCombo');
          }
     }
}

Listing 10-6AbstractValidator Class

最后,我们需要实际的ValidationHandler来处理特定于错误的功能(清单 10-7 )。

<?php

namespace Claim\Validation\Infrastructure\Validators\Handlers;

use Claim\Validation\Infrastructure\Validators\AbstractValidator;
use Claim\Submission\Domain\Models\Claim;

class ClaimValidationHandler implements ValidationHandler
{
     public function handleError($error) {
          $method = 'handle' . ucfirst($error) . 'Error';
          if (method_exists($this, $method)) {
               return $this->{$method};
          }
     }

     protected function handleMissingDocumentsError() {
          //handle documents missing error
     }

     protected function handleMissingEligibilityError() {
          //handle missing eligibility error
     }

     protected function handleInvalidCptComboError() {
          //handle invalid cpt combos error
     }

     public function getModel()
     {
          return Claim::class;
     }
}

Listing 10-7Example ValidationHandler for Claims

在清单 10-7 中,ClaimValidationHandler类除了处理在验证声明的内部属性或整个对象(实际上来自于ClaimValidator对象,而不是ClaimValidationHandler)时可能出现的各种错误之外,没有任何其他的顾虑或考虑。在这一点上,我们已经将我们的Validation方面和错误消息分离到它们自己的类中。在清单 10-7 中,与模型相关的每一个可能的错误都有自己的错误消息,当(你猜对了)有错误时就会显示出来。ClaimValidator对象通过ClaimValidator上的handleError()方法调用ClaimValidationHandler来分派适当的错误,允许您将错误消息定制成您需要的非常细粒度(或不太粒度)的验证。

然而,为了让这个设置工作,我们需要对Claims模型做一个小的添加(清单 10-8 )。

<?php

namespace Claim\Submission\Domain\Models;

// ...

class Claim extends Model
{
     public function validate(ClaimValidationHandler $validationHandler)
     {
          (new ClaimValidator($this, $validationHandler))->validate();
     }

     /* other methods */
}

Listing 10-8Updated Claim Model to Account for the Decoupled Validation

在前面的例子中,我向您展示了一种创建可重用的基本Validator的方法,它不直接依赖于 LaravelValidation组件的,而是依赖于雄辩模型来进行健全性检查。

有一种更好的方法可以做到这一点,这样我们就不必编写所有的底层管道来自动地促进和管理使用哪个验证和验证处理程序。这些事情已经在内心解决了。实现验证的方式取决于您的特定用例;然而,如果你不想从头开始创建一个完整的验证库,Laravel 的Validation组件是强大且可扩展的。

一个很好的起点是创建一个规则,这是您想要在验证器堆栈上放置的某种类型的约束,由 Laravel 验证器运行,并在必要时出错。您可以通过运行以下命令来创建一个新的Rule类,然后更新文件的物理位置和名称空间:

php artisan make:rule CptComboExistsInPaycodeSheet

清单 10-9 展示了我们对它进行调整后,这个类的样子。

<?php

namespace Claim\Validation\Infrastructure\Rules;

use Claim\Submission\Domain\Models\Claim;
use Claim\Submission\Domain\Models\PaycodeSheet;
use Illuminate\Contracts\Validation\Rule;

class CptComboExistsInPaycodeSheet implements Rule
{
    /**
     * The Claim being validated
     * @var Claim
     */
    protected $claim;

    /**
     * Create a new rule instance.
     *
     * @return void
     */
    public function __construct(Claim $claim)
    {
    $this->claim = $claim;
    }

    /**
     * Determine if the validation rule passes.
     *
     * @param  string  $attribute
     * @param  mixed  $value
     * @return bool
     */
    public function passes($attribute, $value)
    {
      $cptCodeCombo = $this->claim->cptCodeCombo;
      $code = PaycodeSheet::where('provider_id', $claim->provider_id)
                   ->where('cpt_code_combo_id', $cptCodeCombo->id)
                   ->first();
     return $code !== null;
    }

    /**
     * Get the validation error message.
     *
     * @return string
     */
    public function message()
    {
        return 'No Entry Found In Provider's Paycode Sheet';
    }
}

Listing 10-9The Rule Class for Checking If a CPT Code Combo Is a Valid Record Within That Provider’s Paycode Sheet

CptComboExistsInPaycodeSheet类在其构造函数中接受一个参数,即claim。多亏了 Laravel 的Facade,在我们的应用中所有扩展了雄辩的Model类的模型上都可以使用,我们可以用几行代码(或者没有换行符的单行代码)运行一个查询,在paycode_sheets表中找到一个匹配的记录,这是由PaycodeSheet模型实现的。passes()从 Laravel 验证器接收$attribute$value参数。$attribute是字段名,$value是被验证的内容。它还添加了一个额外的where子句,将范围缩小到一个 CPT 代码组合,从而向您确认该组合的记录确实存在于该特定程序的提供商的 paycode 表中。该方法需要返回一个布尔值,表明所提供的输入已经通过验证,或者返回 false,表明相反的情况。我们在这里需要检查的只是它是否存在,所以我们获取返回集合中的第一条记录(因为从雄辩查询返回的所有内容都包装在雄辩的Collection对象中),并执行一个简单的谓词,如检查结果并返回truefalse.

为了验证系统中的几乎任何东西,您可以拥有任意数量的Rule对象,最好的部分是您可以将这些规则直接插入到一个Request对象中,作为正常的验证需求。如果您手动创建验证器,那么使用之前定义的规则的方式应该是这样的:

<?php

// ...
use Claim\Validation\Domain\Rules\CptComboExistsInPaycodeSheet;

$request->validate([
     'claim.cpt_code_combo' => [
           'required',
          new CptComboExistsInPaycodeSheet($this->claim)
     //other validation requirements for claim…
     ]
]);

或者,要对到达的Request对象使用新规则,只需将rules()方法内的验证添加到返回的数组中。

function rules()
{
     return [
          'claim.cpt_code_combo' => [
               'required',
               new CptComboExistsInPaycodeSheet($this->claim)
          ],
          // ...
     ];
}

这提供了一种清晰简洁的方式来定义需要在任何需要验证的地方运行的各种规则。我们还清楚地将规则和验证从它们验证的域对象中分离出来,并将它们放在应用的基础设施层中。

有几种方法可以利用 Laravel 提供的验证组件,这取决于您的特定需求或环境。在软件开发中,通常利用第三方代码和库来处理应用所需的可重复的常见任务,从而节省您的时间和精力。正确嗯,不总是这样。在您真正需要实现它们之前,预配置的包是很好的。通常,只有当您意识到它不支持您的特定需求时,您才最终扩展包并定制类和它们之间的互连,以便它适合您的项目所需的业务需求。在完成所有这些所花费的时间里,无论如何你都可以从头开始写整本书!

这里的问题是,库、包和框架都施加了一些限制和不同级别的约束,对于特定的用例来说,这些限制通常过于严格。然而,有一种方法可以以一种解耦但内聚的方式设计共享代码库,这极大地扩展了其使用的可能性。这就是开源的伟大之处,对吗?明白了。改变它。重新分配。其他人增强了它。冲洗并重复。我当然指的是和开/闭原理有很大关系,也就是 SOLID 中的“O”。通过保持我们的代码对扩展开放但对修改关闭,我们允许最大程度的定制,同时仍然保持项目的主干完整,因此我们可以依赖它作为插入任何定制扩展的手段。我们通过为应用的“活动部分”创建接口来做到这一点。我们将在以后的章节中探讨这一点。

结论

在开发过程中,通常会对您的领域所需的对象和过程施加某些限制、验证和规则。在 DDD,有一种帮助澄清领域模型中隐含的、可能难以跟踪的概念(或子过程或例程)的常见实践,那就是显式编程。这意味着当在领域中的模型上执行或编写操作时,我们应该尽力不要想当然;任何存在于特定模型或特定上下文中的验证或限制都应该是明确定义的、易于识别的类或函数,这些类或函数有意地用从通用语言中借用的术语来命名。

Tip

根据经验,如果概念存在于领域和核心业务逻辑中,那么它应该包含在领域模型中;如果概念存在于领域模型中,那么它应该包含在无处不在的语言中。无处不在的语言中的任何东西都应该作为一个明确的、揭示意图的接口包含在模型中。

当然,我们可以从头开始创建一个手写验证系统,它确实可以工作;然而,它所需要的代码量足以将它排除在可能的解决方案之外。为了节省我们自己手动重写一个类似的验证库的时间和无聊,我们可以依靠 Laravel Validation组件来为我们做这些脏活,留给我们唯一实际的逻辑,也就是验证本身。您可以在整个应用的任何地方使用它;它是完全解耦的,放在我们的基础设施层中的一个单独的名称空间中。它被明确命名,并清楚地指出它是什么和它做什么。

这里的要点是,Laravel 作为其基本特性的一部分提供了验证的主干,允许我们在代码的不同位置扩展和添加逻辑。在本章中,我提供了使用组件的可能方法的例子,其中一些可能不一定是现实规模上的最佳方法,但用于添加上下文和演示概念对应的各种概念。就 DDD 而言,我们已经开始了一个完整的声明验证上下文,不仅封装了事物的数据属性方面,还封装了关系上下文。

十一、上下文映射

在这一章中,我们将看看有界上下文之间不同的通信方式。从高层次来看,上下文映射是一种识别应用的不同有界上下文之间的实际互连方式的方法。

领域模型和 ORM 实体

尽管 Laravel 和大多数现代框架一样,并没有真正集成任何类型的开发流程或过程,但是如果你以一种聪明的方式去做,那么获得一个可靠且高效的开发流程的工作是非常值得的。虽然这是框架的意图,以便它可以适用于更广泛的用户,但如果您没有花时间进行建模,就开始编程并不理想。诚然,对于个人来说,“过度工程化和工程化不足”的微妙尺度很容易向任何一方倾斜;有一个团队抵消了这个敏感的边界线,因为有更多的人致力于解决问题,人多总是比一个好。从经验来看,我可以告诉你,即使你在辩论中固执己见,最好还是对你同事的想法保持开放的心态。如果你花时间听听其他人对这个或那个的看法,那么最糟糕的事情可能是你从另一个角度看问题,在这种情况下,你很可能会更多地了解你的同事是如何思考的,甚至可能向你展示一种对你来说是新的、可能比你的旧方法更好的方法。你永远不知道。

下面是一个示例场景,其中我和一个同事正在讨论一个与富域模型和 ORM 实体相关的问题。如本书前几章所述,一个丰富的领域模型被细化为代表对应用和业务的成功都很重要的真实世界的对象,它非常关注领域中对象的行为,这些行为可以在它们所对应的实际模型的伪全局上下文中指定,这意味着模型本身捕获并封装了领域逻辑的各个方面,通常是以前置和后置条件、实体级验证或直接应用于实体和模型的约束的形式。我会注意到鲁本是一个受人尊敬的高级 Symfony 开发人员,我当然也是 Laravel 的开发人员。然而,我们当时正在进行的项目是用 Symfony2 和 Doctrine ORM 编写的,我们正在用 Symfony4 重写它(用新的、很酷的 Symfony Flex 组件)。

鲁本:嘿,杰西!我想和你谈谈你在上周的会议上提到的,你认为最好将业务逻辑放在实体本身内部。你能给我解释一下你的意思吗?

杰西:可以。我的理解是,最好是我们将与每个实体相关的领域逻辑尽可能地推向它,以便耦合它并为领域模型提供更多的上下文。胖模特,瘦控制器,对吧?

Ruben:是的,但事实是 ORM 是为了抽象它所表示的数据,所以它可以被建模为 DTO,并转换为我们应用的 API s 所需的各种结构和格式,以便与我们已经实现的自定义 API 框架一起正常工作。这意味着,就 ORM 而言,实体严格地说是数据库字段和 PHP 中面向对象模型之间的直接映射。

杰西:我能理解。然而,这可能会把我们引向一种被称为贫血域模型的东西…或者其中的成员充当简单的数据容器,像 dto 一样被传递。我们希望任何重要的约束或前提条件尽可能地接近它们所适用的对象。

鲁本:对,有道理。然而,实体是建立每个实体与其他实体之间关系的基础。getters 和 setters 分别]作为访问和更改给定实体的受保护属性的手段。因为有些表有相当多的 ORM 必须映射的字段,所以实体类会变得很长,特别是当我们决定将业务逻辑移到实体本身时。杰西:好的,你的意思是组成数据库中实体属性的代码应该与你通过注释映射到实体的方式非常匹配?这几乎只是使实体相同的一个标准 DTO,不是吗?**

鲁本:(LOL)嗯,我想在某些方面你是对的...除了 dto 的结构可能与实体相同,甚至可能与实体具有相同的字段之外。这两点对我来说都很有意义。

杰西:我也是!

近距离观察上下文映射

我们可以更进一步,将问题空间中的每个问题分类到系统的域/子域中,并将解决方案空间中的每个解决方案分类为属于某个有界上下文。一旦我们有了某种形式的整体解决方案,我们就可以开始根据每个有界上下文与其他 BC、域、子域、模块和/或一般子域的关系来描述设计。我们的应用的子系统之间的关系图在 DDD 被称为上下文图,这是一个有界上下文以及它们与应用其他部分的交互的图。每个子系统(模块/域/子域/有界上下文)与其他子系统的关系可以通过许多不同的模式进行分类。

在我们进入上下文映射的本质之前,您应该首先理解为什么它们甚至是“一个东西”让我们回顾一下为什么在现代 DDD 驱动的开发过程中使用它们。

不同的有界语境=不同的普遍语言

许多支持整个组织或企业级软件的大规模应用对组成其领域模型的许多上下文方面进行了如此明确的分离,以至于每个方面都存在一种单独的通用语言。这意味着 UL 中相同的术语或短语可以在不同的时间出现在多个地方,并且根据当时使用的有界上下文而具有不同的含义。

例如,以术语产品为例。产品是一个非常高级的术语,它没有任何特殊性或细节来暗示讨论中的产品是哪种产品。现在想象一个仓库范围内的产品。仓库中的“产品”可以有不同的含义,这取决于它当时处于哪个阶段。当产品第一次进入仓库时,它们被装在送货卡车的货盘上。在这方面,有一些特定的数据点在流程的接收阶段可能非常重要(如果工人要有效地对仓库中的产品进行分类和存储,以提高生产效率)。我所指的数据点是这样的:

  • 产品的托盘编号(例如:WSH99)

    • 这样可以跟踪每个货盘。
  • 产品的一般商品代码(例如:女式 _ 鞋)

    • 这是产品所属的类型事物的内部编号(相对于一个更大的产品组,通过数量获取)。
  • 产品的收货数量和仓库预计要交付的数量,以便会计部门可以确保他们物有所值

  • 产品的批次来源 ID(例如:399483982340)

    • 这是产品到达的批次的识别号。这与特定的提货单的标识符相匹配,提货单作为卡车交付到仓库的收据。

在前面的列表中,您应该注意到数据点本身更倾向于一组产品,而不是单个产品的物理细节。这是因为,在产品批次(货盘)到达时,需要以一种大粒度到细粒度的方式对它们进行统计。当卡车到达仓库,产品到达地板时,卡车司机只打算等你清点交付的数量,并确保每一个大颗粒件都得到清点(通过根据给定的装箱单/采购订单清点托盘和批次)。如果你必须坐在那里一个接一个地数每一件产品,卡车司机会在那里一整天!

在较大尺寸物品的初始“登记”被考虑之后,它们通常必须被分解成仓库可以容易地销售、跟踪和交付的较小的子部分。在这一点上,交付的更细粒度的物品(货盘上的物品)然后被人工验证,除非检测到缺陷或异常,否则产品被添加到库存中并被放置在货架上,准备出售、挑选和包装(流程中的下一个阶段)。在销售过程的这一部分,产品本身的各个方面被深入到一个更细粒度的上下文中。这些方面可能包括以下内容:

img/488324_1_En_11_Chapter/488324_1_En_11_Fig1_HTML.jpg

图 11-1

先前描述的耐克空军一号的条形码

  • 单个产品的物理特性(例如:Nike Air Force 1 Low’07 黑色/黑色)

    • 品牌、款式、型号、颜色、尺寸等。
  • 一个 UPC,代表相对于宇宙中其他部分的产品(例如:UPC 883412741101)

    • 2D 和 3D 条形码非常适合传达这种信息(正如我们在本书前面已经了解到的);见图 11-1 。
  • 产品附带的采购订单的行号(也在最初接收阶段指定,前面已详述)

    • 这还需要实际的采购订单 ID 来引用该产品所属的采购订单上的行项目。

在销售流程的这个阶段,商品实际上只会被再次触摸,直到有人从网上商店购买它们,在那里它们进入流程的下一个阶段:挑选和包装。在这种情况下,任何中型到大型仓库都很可能有一个专门的角色,专门负责流程中的拣货和包装部分。此时最重要的是找到商品在仓库中的物理位置(由在卡车码头处理商品接收的人员确定),从货架上挑选正确的商品,将它们适当地包装到运输容器中,并打印出箱子目的地的运输标签。以下几个方面适用于任何处于游戏阶段的特定产品:

  • 产品在仓库中的实际位置(例如:3B 区第 13 岛,快速挑选项目#98)。

  • 产品的描述,以便提货人可以验证他们正在抓取正确的商品。

  • 因为只有在将物品添加到订单中并正确开具发票(并付款)后,才会运送物品,所以购买的总金额必须反映所有物品的总售价,加上任何额外的运费(例如,购买者选择支付 UPS 次日送达的额外费用)。这方面涉及的重要数据点是价格、数量、运输公司(UPS/FedEx/USPS)、买方选择的运输方式以及最后添加的任何适用税。

就产品生命周期的运输部分而言,当它在被运出之前经过仓库中的各个阶段时,这个特定过程中所需的数据比先前阶段的数据更细粒度。促进这部分流程所需的数据围绕着实际的产品本身,例如 UPC、物理外观、品牌/制造/型号、颜色以及使其区别于其他类似项目的事物。当我们从一个更高的层次来看待这件事时,我们可以清楚地看到不同的无处不在的语言,这些语言围绕着不同过程的每个关注点而形成,它们共同使系统工作。图 11-2 显示了该示例的视图。

img/488324_1_En_11_Chapter/488324_1_En_11_Fig2_HTML.jpg

图 11-2

公共仓库中的不同上下文表示同一术语在通用语言中的不同用法,或者更具体地说,表示“产品”的概念

从图 11-2 中可以看出,作品“产品”在不同的语境中有不同的含义。每个上下文都可以(并且可能应该)包含它自己的无处不在的语言。术语产品在不同的上下文中有不同的含义。虚线椭圆内还有一些概念,表明它们在多个组件(上下文)之间共享。

当构建前面描述的这样一个系统时,最好的方法并不总是最清晰、最容易或最明显的方法——尤其是当您从高层次构建系统时。一个好的起点是像我们已经做的那样勾画出所有的东西,集中在领域模型中可以画线的地方,以分割驱动应用的各种概念和组件(并组成领域模型本身)。

上下文映射的概念实际上就是一系列常见的模式,人们可以通过限制不同上下文共享的代码来实现跨各种上下文边界的功能。我们对领域模型的边界做得越独立,我们的情况就越好;游戏的名称在很大程度上限制了每个 BC 与其他 BC 之间的依赖性,同时仍然提供允许组件作为一个应用运行的功能,同时仍然限制每个组件所依赖的共享资源的数量(即,上下文及其关系上下文之间的交互越少,您的情况就越好)。

这些关系中的每一个都有其相关的任何其他关系的上游或下游,这取决于它们对其他子系统的依赖程度,这些子系统可能是正常运行所必需的。如果我们有两个子系统,子系统和子系统 b,并且子系统位于子系统 b 的下游,这表明子系统 b 的运作直接影响子系统 a 的整体成功。相反,子系统 b 中的项目不一定受子系统的影响。

这是设计中要包含的重要信息,因为要对一个复杂的域建模,我们应该对各种有界上下文以及它们之间的关联/关系有一个高层次、大规模的视图(或映射),如域模型所指示的,这样我们就可以更好地理解每个有界上下文对应用其余部分的影响,以及什么上下文与什么其他上下文以及它们之间的关系。我在上一段中提到的 DDD 定义的语境模式给了我们一组合理的关系,可以帮助定义语境到语境的交流,除了一些极端的情况或超复杂的领域,没有其他的可能。我们将在本章回顾这些模式,并使用它们来构建一个基本的上下文图,它将揭示我们的应用的边界、与这些边界的关系,以及每个上下文如何影响(上游)或不影响(下游)其他的。我们将使用该上下文图作为决策背后的驱动力,这些决策涉及哪些边界有哪些通信点以及如何我们将构建一些连接边界的通信路径,以便我们可以从我们已分解到他们自己的单独层中的精炼域对象为我们的用户创建真正的功能。

从更广的角度来看,当您考虑领域层及其对象、类和接口参与者是如何产生的:功能分解时,这是完全有意义的。一旦我们有了模型的粗略草图(这通常表明在功能分解过程中取得了一定程度的成功),我们就需要以某种形式重新组合它们,以创建特定于应用底层领域的可用功能和特性。然而,我们需要小心我们将不同的关注点放在系统中的什么位置(以及每个关注点应该放在哪个层),并确保它们的结构与 it 建模的领域紧密相关。

上下文映射还用于显示每个上下文之间共享的数据的分解,以及给定上下文在应用中相对于系统全局视图的位置。图有助于传达上下文图中的信息,我建议您保留一个上下文图,供整个团队使用,进行更改,甚至有助于透视整体架构以及单个有界上下文对其余有界上下文的依赖性。

有界上下文关系

有界上下文与其他上下文的关系可以分为几个模式,这些模式描述了关系的中心概念以及每个上下文对域模型中其他子系统的上游/下游从属关系。通常的做法是使用这些关系来勾画当前系统的“地形”(如果有当前系统的话),作为一种方法来对域内系统的这些方面以及它们与其他子系统、模块或有界上下文的交互进行分类。通常情况下,旨在测试每个上下文中的所有接口以及它们之间的接触点的自动化测试套件被证明是一种无价的资源,可以保持一定程度的确定性,即每个上下文都提供了另一个上下文所需要的东西,反之亦然。

在下一节中,您将找到上下文映射模式的列表和每个模式的简要说明,您将深入了解合作模式,以及它如何应用于与患者资格和获得资格的方法相关的提交流程的关注点。有时子系统实际上是物理项目,它们之间有某种类型的关系,也可以使用这些上下文映射模式来描述。

合作关系

这种类型的关系发生在两个团队、两个项目和/或两个子系统之间,它们相互依赖以获得各自的成功。如果一个项目失败了,两个项目都会失败,反之亦然。与另一个项目有合作关系的项目必须有协调的计划会议以及定义良好的工作流,用于处理两个项目的集成,并得到两个团队的同意。团队应该小心地相互密切协作,以满足两个项目的开发需求,这种方式将允许每个项目满足其特定的目标和要求,以便两个项目都能成功。可能不要求两个团队或系统都非常了解对方的细节,但是要求根据另一个系统来维护每个系统,以便另一个系统的影子可以与第一个系统适当地集成。这个需求就是协调计划会议,不断地重构和细化每个系统,以便它仍然保持两个系统之间的平衡,以及它们自己的独立系统,并且根据需要不断地集成两个项目的特性和代码,以保持两个系统的同步。

索赔申请中的伙伴关系示例

在我们的索赔项目中(我们将在本章和后面的章节中继续开发),在索赔提交上下文和患者资格上下文之间存在一种伙伴关系。两者相互依赖才能成功。

患者资格工具利用刮刀来确定给定患者是否有资格接受医疗服务提供者的护理。如果没有索赔上下文,这可能仍然是有用的,但在提交索赔的上下文中,它不会为提供者提供任何额外的时间节省或其他此类好处来提交索赔,部分原因是,如果没有我们的系统,提供者的办公室将被迫回到提交索赔的打印和传真方法(这种方法效率不高,容易出现小错误,可能会导致延迟支付为该患者提供治疗的提供者,并且在患者最后一次就诊期间,患者的资格可能已经改变)。这为治疗那些没有资格接受治疗的患者打开了大门,因此他们不会因为这次就诊而获得任何报酬。

另一方面,索赔提交上下文不能以自动化的方式完成,而这正是我们最初开始构建应用时想要支持的。如果诊所的提供者或接待员必须在进行其他与索赔提交相关的活动(FQHC 接受索赔所必需的)的中途停下来,以便他们可以登录到 Medi-Cal 联邦患者资格检查系统并手动验证患者的资格,那么理论上永远无法实现提交索赔这一棘手问题的完全自动化。无法验证患者是否合格,因此将完全依赖于提交索赔的提供者办公室的准确性,这不是我们可以信任用户去做的事情。然而,一个自动抓取器为您抓取数据,然后自动更新声明以包含该事实,这将是解决这一棘手问题的一种相当直接的方法。

界定伙伴关系中两种情况的界限

为了从总体的角度管理项目的整体成功,我们需要有足够的边界来为每个上下文提供功能和支持。在前一种情况下,边界已经存在(因为它们在不同的上下文、不同的名称空间和不同的文件夹中)。现在剩下要做的是设计这两个上下文如何相互通信以实现某个目标。图 11-3 将有助于澄清这两种环境之间的这些方面的交流;它描述了在这一点上完全分离的两个系统,以及它们将使用和操作以提供适用解决方案的各种核心方面和元素。

img/488324_1_En_11_Chapter/488324_1_En_11_Fig3_HTML.jpg

图 11-3

索赔提交和资格刮刀上下文及其核心结构/构造的高级视图,使其能够发挥作用

正如我们所看到的,尽管图中包含了特定于每个上下文的模型,但是两个上下文都有一个最外层的域服务,它将提供一种访问上下文内部功能的方法。这意味着我们不必从索赔提交上下文中直接管理Eligibility对象。相反,提交上下文可以只调用EligibilityScraperService,它将通过抓取 Medi-Cal 网站的数据来处理请求,然后以提交上下文可以使用的格式返回结果,此时它将使索赔通过所需的资格检查(这被视为系统中接受索赔的一个要求),并允许索赔继续前进到索赔审查阶段。

*在数据方面,如果您向前看图 11-4 ,您将看到患者资格数据结构的数据部分的可能实现。患者医疗状态的合格性直接与患者相关,而不是与索赔相关,因为在领域模型中,这是更自然的“适合”,大声说出来更有意义。这在图 11-4 中并不明显。事实上,看起来可能不是这样,因为PatientEligibility模型是分开的。当您发现自己处于这样的情况下,其中表示每个模型的数据位于与该模型的行为不同的上下文或设置中时,最好回顾一下系统的需求,以便您可以确定需要进行分离。如果是,那么我们仍然可以通过将实际模型的代码驻留在这些上下文中的一个特定位置,然后创建类似 DTO 的东西用于其他上下文,来将每个上下文中的依赖项数量保持在最小。通过使用 d to,我们仍然可以提供其他有界上下文需要知道的数据(这是因为它们在上下文映射中的相互关系),同时仍然保持封装在给定上下文中的行为的分离。图 11-4 显示了上一张图的迭代,以更好地细化沟通边界,并使模糊或隐含的任何沟通方面变得清晰。

img/488324_1_En_11_Chapter/488324_1_En_11_Fig4_HTML.jpg

图 11-4

两个上下文以及边界之间的通信被明确定义,并使任何阅读该图的人都能注意到

图 11-4 仅仅是一个粗略的可能的解决方案,它解决了如何在有界的上下文之间传递数据和封装行为的问题。

Note

这遵循了在 DDD 被称为意图揭示接口的实践,并且当你将所有应用的命名约定基于无处不在的语言中包含的名称和概念时,这是一件比较容易完成的事情。这是另一个投资适当时间来培养和完善将成为无处不在的语言的项目和商业概念的原因。

这种特殊的设计只是手头问题的一种潜在解决方案,它绝不是具体的,并且在设计实现之前可能会改变几次(此时,实现本身很可能会在某些相关领域问题的解决方案空间的架构或设计中显示出漏洞或裂缝)。

关于我们将如何实现两个上下文之间的通信,这仍然有许多细节悬而未决,我们将在后面详细讨论。现在,下面的部分提供了您可能在典型的上下文图中找到的其余模式的分类,尽管它们没有像 Partnership 模式那样详细描述。

共享内核

领域驱动设计中的共享内核是一种模式,其中一个有界上下文与另一个上下文具有某种类型的共享代码库,使得它们之间的关系(以及对它们中任何一个的任何更改)仅在严格审查下发生。通常,这些上下文可以由不同的团队管理。在这种情况下,两个团队都需要就上下文的准确修改达成一致;否则,一个意想不到的变化在理论上可能打破这两种背景。

一般来说,由于上下文之间的代码重用量,DDD 通常不推荐共享内核实现。这是一个重要的区别。一般游戏的对象是代码复用;然而,对于分布式系统来说,代码重用实际上是一件坏事。它们之间的代码重用和应用上下文越多,就越难将它们从其他上下文的功能中分离出来,并且存在越多的依赖性,这些依赖性也必须为两个不同的上下文进行更新和维护。

客户/供应商开发

在这个模式中,涉及到两个团队(因此有两个有界的上下文),一个团队充当另一个团队的下游组件(上游组件影响下游,而不是相反)。上游团队可以在不影响下游的情况下修改他们的代码,只需要通过仔细的实现和自动化测试套件(通常与一些 CI/CD 解决方案或服务相结合)。

遵奉者

上下文是上下游关系;然而,上游团队没有动力去满足下游团队的需求(例如,它可能作为服务从更大的供应商那里订购)。下游团队决定遵从上游团队的模型,不管它发生了什么。上游的变更很可能会影响下游的变更,但是最终上游的变更基本上是“法律”,下游的团队没有选择,只能遵从它。

分道扬镳

这是多个有界上下文可能出现的最佳情况。它们之间的交互是结构化的和有限的,就跨功能性而言,从一个上下文到另一个上下文没有太多的依赖。两个上下文(或所有上下文)在开发中可以自由地分道扬镳,只有当涉及第一个 BC 的代码(无论是在请求中还是在响应中)被修改时,才需要对方 BC 的合作。除此之外,每一种情况都可以选择自己的发展道路,并且可以在每一种情况下做出决定,而不需要咨询其他情况。这些上下文基本上被认为是独立的,甚至可能以多个较小的应用的形式存在,这些应用通过明确定义和发布的交互方式相互连接,以形成一个功能完整的应用。

结论

不同的模式可以使用称为上游或下游的概念来识别,这些概念详细描述了每个 BC 之间的依赖关系实际上是向哪个方向流动的。共享内核和伙伴关系等模式在相互依赖方面严重依赖于 BC。理想情况下,组成应用的 BC 应该以分离方式模式表示的方式存在。这意味着两个上下文可以彼此独立地开发,而不用担心破坏另一个上下文。对于模块可以分离的应用,唯一必须与对方 BC 协作的代码是向对方 BC 发送或接收传输的实际代码(这包括任何实际直接使用 BC 的代码)。*

十二、DTO、实体和值对象

在前一章中,我们研究了 DDD 的上下文映射思想,以及为什么在你的架构中通过它们的有界上下文来分布组件是一件好事,以及为什么在上下文之间尽可能少的交叉依赖是一件好事。每个业务连续性对所有其他业务连续性的依赖越少,我们的情况就越好,我们的业务连续性(和应用)就变得越独立。在现实世界中,一个大型企业应用可以被拆分到极致,让整个团队专注于它的每一个有限的上下文。在这一章中,我们将关注 DDD 附带的特定构件,如 dto、实体和值对象,并讨论如何在 Laravel 中创建和管理它们。

在软件开发世界中,实体和值对象对于当今存在的(几乎)每个应用都是常见的,并且可以被认为是系统的重要方面。我们在系统中表示实体和值对象的方式应该尽可能地符合它们在现实生活中的存在方式。那么,这个模型就是一种业务规则、约束、实体或值的翻译版本,这些业务规则、约束、实体或值与一个真实的概念或结构相关联,这个概念或结构在领域的业务流程中的某个地方被利用,并且在我们的应用中的代码中被建模。在一个典型的领域驱动的应用中,模拟现实世界中的同类的类通常包含应用中的大部分业务逻辑。这些是领域模型中的一等公民;因此,它们以一种字面上和直接的方式反映了领域的业务规则,这不会让你感到惊讶;因此,这些公民被认为是“肥胖”的一方。我们将在本章中探讨这些想法,但最终会得出一个老的编程口头禅,我在上面加了一点额外的东西:“胖模型,瘦控制器,瘦服务。”可以说,实体应该包含您的大部分业务逻辑,因此驻留在领域层中。我在整本书中使用术语模型实体来表示同一件事。

尽管有一些不同的方法来对实体和值对象建模,但是将这些概念引入到典型的 Laravel 应用中,会在被认为是 DDD 的最佳实践和 Laravel 开箱即用的标准操作方式之间产生一些不一致,这最终会导致建议的 DDL 实践出现一些问题。我们将定义这些问题,并探讨我们必须做出的各种选择,这些选择涉及到我们无法解决的问题,以及“每个人都赢”的预期结果两个选择很简单:DDD 或拉勒维尔。在某些情况下,我们不能同时拥有这两者的原因是,在关于数据库的知识泄露到域层的方式中,关注点的分离存在明显的不一致性,导致实现知道太多关于数据库细节的细节,以至于最终没有完全符合 DDD 的应用。每当我们使用雄辩术时,这种不一致就会发生。

造成这种情况的主要原因是,雄辩是基于活动记录模式,而标准的 DDD 实践利用 ORM 功能的数据映射模式。我们将在后面的章节中更深入地讨论这一点。另一种选择是简单地接受这样一个事实,即我们的一些类将固有地知道我们的领域层中的数据库细节,这是一个很小的牺牲,考虑到它在与 concertive 一起工作时支持的所有功能。我们将采用第二种选择,我将在本章和以后的章节中证明这一决定的合理性。

然后,我们探索实体和值对象,看它们如何使用 Laravel 和口才实现。我们还将讨论数据传输对象(dto)。您可以将这些对象视为我们的实体的定制版本,这些实体是专门为返回给某个客户端(应用外部或内部)而设计的,并且已经过预格式化,以适应客户端使用它们的上下文。使用 dto 的一个常见原因是因为您不想传递一个实际的实体,这对我们来说意味着传递一个雄辩的模型。这有许多原因,我们将在本章中探讨。

我们将继续在 Laravel 框架的上下文中探索这些技术 DDD 概念,并看看如何使用该框架实现它们。我们开始吧。

DDD 和拉腊维尔不一致

DDD 是一个非常明确的地方。显式命名约定使我们更有可能以肯定的方式预测封装在给定类或对象中的行为和功能。名称应该直接来源于概念、业务规则,当然,还有领域中定义的无处不在的语言。应用中各种结构的名称应该尽可能地让未来的开发人员明白,他们必须弄清楚某个东西做什么以及它为什么在那里。

在我看来,Laravel 和口才都是隐含的,主要是因为每个项目的设计目标都允许用户以最快、最简单的方式用尽可能少的代码来完成某件事情。Laravel 成功做到这一点的很大一部分原因是它广泛采用和使用了 Facade 模式。这种模式是一种简单的方法,通过将功能放在一个 facade(或该集合功能的单个入口点)中,动态地跨许多不同的类和对象利用功能。在 Laravel 中,外观基本上看起来像简单的静态方法。然而,他们跑得比这更深。我们不会在这里深入讨论外观的细节,但是我们会在本书的后面进行更深入的讨论。

拉勒维尔和 DDD 之间的一个不一致之处,你马上就能看出来,就是这种显性和隐性的对立。由于 Laravel 框架的性质,许多功能存在于(有时)不太明显的地方。让事物以隐含的方式运行淡化了类、对象或模块的作用,因为它与领域相关。这使得找到一段给定代码的目的或意义变得更加困难(不深入代码并跟踪一堆对象调用和堆栈跟踪)。)

口才真的没什么不同。举个例子,一个典型的雄辩模型,从雄辩提供的抽象Model类扩展而来(清单 12-1 )。

<?php

namespace Claim\Sumbission\Domain\Models;

use Illuminate\Database\Eloquent\Model;
class Provider extends Model
{
     public $table = 'providers';

     protected $guarded = ['npi_number', 'practice_id'];
}

Listing 12-1An Example Child Class of Eloquent’s Abstract Model Class

在清单 12-1 的代码中,除了能够识别模型应该表示哪个表之外,您能告诉我这个模型第一眼看上去有哪些属性吗?不。你可以告诉我两个字段npi_numbernpi_number是受保护的,这意味着当创建它们的新实例时,它们的值不能被自动赋值(这在持久性上相当于在数据库中创建一个新记录),但是从查看Provider类来看,属于模型的实际字段是未知的。

为了实际推导出这个模型包括哪些特定的字段,您可以做一些不同的事情。

  • 在数据库 GUI 中打开表格(或者在 MySQL 控制台中进行手动describe table查询

  • 启动 Tinker 会话(php artisan tinker),运行命令(new Provider())->getAttributes(),并查看结果

可能还有其他发现模型属性的方法,但关键是没有办法仅仅通过查看Provider类来确定它们。换句话说,您可以说这些属性对于实际的Provider类是隐含的。这违背了 DDD 的许多方面,因为即使我们忽略了这一事实,仍然存在这样的问题,即雄辩利用了活动记录模式,所以存储在$attributes数组中的所有属性都仅仅是存在于数据库表中的字段

为什么这是一个问题?因为我们混淆了领域和数据库的关注点,这在领域驱动的设计中是非常不被接受的。当我们考虑将应用或框架改进为更加面向 DDD 时,确实没有明确的方法可以解决这个问题。

  • 我们可以为所有开发人员制定一个新的规则,只需将给定模型中的所有字段放在各自的$fillable数组中;然而,这破坏了$fillable$guarded数组的意图。

  • 我们可以将所有的属性注入到模型的构造函数中,只有当它们存在于一组给定的字段中时,我们才可以显式地将这些属性分配给类成员变量,但是这将使我们需要使用它们的任何地方的模型的实例化变得复杂。

  • 我们可以在Model类中使每一个属性都成为一个已知的、已定义的、已类型化的成员变量,但是这对我们真的没有什么好处,因为在内部,雄辩术使用这个主$attributes数组来实现它的许多(如果不是大部分)我们不想失去的特性。

这些解决方案都不符合要求,因为它们都有弊大于利。这使我们在开发关于 DDL 的应用时处于尴尬的境地,因为确实没有好的解决方案。取而代之的是留给我们一个地狱般的决定:我们是否因为找不到一个好的方法来明确定义一个给定的域对象在其相应的Model类中的所有属性而放弃整个项目,或者我们是否接受这样一个事实,即通过使用雄辩作为我们的 ORM 及其活动记录实现,我们在技术上将数据库和域层的关注混合在一起?

如果你还没有猜到,我们不会选择第一个选项,因为如果我们猜到了,我会马上停止写这本书的其余部分。因此,我们将采用第二种方案——经过改进。现在,模型上的属性确实没有(也不会)在我们的Model类中显式定义;然而,这并不意味着我们至少不能使用注释来记录模型中的字段。这种方法将允许我们明确地记录(而不是定义)模型中的字段,并为开发人员提供一种合理的方式来解释模型背后的含义。另一方面,我们在类顶部创建的注释块本身需要维护,并且在数据库中的表发生变化或我们添加新字段时进行更新。这就出现了一个小问题,因为我还没见过多少开发人员随着给定类的每次更改而不断更新他们的注释,或者在这种情况下,数据库表。解决这个问题的最好方法是将注释放在容易被注意和更新的地方:在类顶部的 PHP 文档块中。这看起来有点像清单 12-2 。

<?php

namespace Claim\Sumbission\Domain\Models;

use Illuminate\Database\Eloquent\Model;
/**
 *  Provider : A medical doctor
 *  {@property array $attributes
 *     first_name varchar(50)
 *     last_name varchar(60)
 *     npi_number varchar(10)
 *     practice_id integer(11) not null
 *     paycode_sheet_id integer(11) not null
 *       ...}
*/
class Provider extends Model
{
     public $table = 'providers';

     protected $guarded = ['npi_number', 'practice_id'];

     //
}

Listing 12-2A Version of an Eloquent Model Class Similar to Listing 12-3, with an Added Docblock Explicitly Suggesting the Individual Attributes of the System

我承认这不是最理想的解决方案,但是一旦我们决定允许一小部分数据库问题泄漏到域层,我们就可以最大限度地利用雄辩术,我们马上就会发现这一点。

价值对象

Eric Evans 是这样描述值对象的:

“一个表示领域的描述性方面而没有概念同一性的对象称为值对象。值对象被实例化以表示设计元素,我们只关心它们是什么,而不是它们是谁或它们是什么。”

—埃里克·埃文斯

顾名思义,值对象是业务对象,其身份严格依赖于对象的值,而不是实体上的显式 ID 字段。这意味着它们与同类型的其他对象的区别仅在于它们的值。值对象本质上很简单,尽管它们表示的实际业务对象可能很复杂,这取决于领域。

值对象的酷之处在于它们是不可变的。一旦实例化,就不能修改。虽然这一开始听起来可能会适得其反,但实际上这是一个值得拥有的特性,因为我们总是可以保证我们最初实例化的对象总是相同的。如果我们想改变对象或它的一个属性,我们只需用新的对象替换那个对象。这使得值对象在用于表示领域中的业务对象时非常便宜和有用。哪些类型的业务概念可以用值对象来表示?很高兴你问了!查看表 12-1 中的一些日常价值物品示例。

表 12-1

领域中的业务概念和代表它们的示例值对象

|

相关业务概念

|

示例值对象

|
| --- | --- |
| 测量、量化或描述 | 为被认为是“钱”的不同部分声明单独的值对象会给你一个干净、独立的接口来描述任何名义金额的钱。详见 https://martinfowler.com/eaaCatalog/money.html 。该模式中的类包括以下内容:Amount (float $amount)``Currency (string $isoCode)``Money (Amount $amt, Currency $cur) |
| 除非被替换,否则不能改变的对象 | 通常,在一个域中,日期字段应该是不可变的,也就是说它不应该改变。一个例子是在银行交易中;交易的日期应该保持不变。$date = new DateTimeImmutable('now'); |
| 使用对象数组而不是对象 id 数组(这由 concertive 为我们处理,但值得一提) | 典型的博客应用:而不是:$post->comment_ids (returns array<int>),``$post->author_id (returns int)使用值对象:$post->comments (returns array<Comment>),``$post->author (returns Author) |
| 具有值相等性:当在比较两个对象的内部属性的equals方法或任何其他比较逻辑中出现两个值对象相等时 | Comparing Equality``class Money {``public function equals(Money $mo){``if($this->currency ===``$mo->currency &&``$this->amount ===``$mo->amount) {``return true;``} else {``return false;``}``}``} |
| 可替换性:值对象是不可变的,不能修改它们的属性,而是在需要这种改变时替换整个对象 | Replace Value Objects``$string = strtoupper('hello');``//returns 'HELLO'``strtoupper()方法和许多其他内置的 PHP 函数返回新的对象/数据,并对其执行所请求的操作。 |
| 无副作用的行为:减轻一个特定的类或方法可能具有的没有暗示或明确的副作用 | Computations on Object Values Should Return a New Value Object``class Money {``public function add(Money $money){``if ($this->currency ===``$money->currency) {``return new self($money->amount``+ $this->amount,``$this->currency);``}``}``} |

Laravel/口才中的值对象

我们已经接受了这样一个事实,即雄辩的属性没有被明确定义或映射,并且明白如果我们想使用 Laravel 和雄辩构建一个领域驱动的设计,我们就必须处理 ORM 的这个特性。事实上,接受框架的一个缺点(关于纯 DDD 实现)使得创建值对象变得相当简单。我们可以利用雄辩的访问器和赋值器轻松地将对特定属性的调用(通过Model的外观)转换成适当的值对象。然而,这有一个问题,即允许对象本身的属性被改变,这不是值对象应该如何操作的。另一件要考虑的事情是,value 对象中的属性类型不是强制的,因为 concertive 允许我们通过直接在模型实例上设置它,简单地用任何其他值覆盖模型的任何值。当我们在模型中创建和利用值对象时,这两个都是问题,因为值对象在我们的领域模型中保持一定程度的一致性。以下面为例,给定一个名为Address的值对象和一个Patient实体(模型):

$address = new Address('101 W. Broadway, San Diego, CA. 91977');
$patient = Patient::find(234);
$patient->address = $address;
$patient->save();

在前面的代码中,我们实例化了一个新的Address对象,用我们传递给它的构造函数的一组原始字符串值来定义它的身份。但是后来,这种情况发生了:

$patient->address = "Type Not Enforced";

我们已经成功地用原始字符串覆盖了patientAddress类型。我们如何设置自己,以便模型类上的值对象使用正确的数据类型来执行?我们可以为Model上的属性实现一个赋值函数,然后在赋值函数方法的签名中输入参数提示,如清单 12-3 所示。

<?php

class Patient
{
  // ...
     public function setAddressAttribute(
                    Address $address) {
          $this->attributes['address'] = $address;
     }
}

Listing 12-3A Sort of “Bumper Rail” in the Form of a Mutator Method on the Model That Enforces the Types of Value Objects When They Are Actually Set on a Patient Object

在清单 12-3 中,我们使用以下格式创建了一个赋值函数:

set + Address + Attribute
|     |         |
"set" + {attributeName} + "Attribute"

每次在模型上设置属性时,我们包含在 mutator 方法体中的逻辑都会运行。因此,在使用我们之前展示的新的 mutator 方法尝试时,在一个Claim实例上错误地设置了cptCodeCombo属性的代码实际上会出错。

$claim->address = "This will throw an exception";

这段代码将抛出一个无效类型异常,因为期望保存在address属性中的类型是address类型,它不接受字符串或任何其他数据类型作为Claim模型上的address属性。看起来一切都很好,对吧?

嗯,不完全是这样,因为仍然存在这样的问题,即 concertive 用来跟踪模型的特定属性的属性数组(它的大部分特性都基于该属性数组)仍然与数据库中存储的内容相关。对于我们的目的来说,这意味着即使我们可能已经将对象设置为正确的address实例,但是每当从数据库中检索到相同的记录时,该类型将是一个原始的 PHP 类型,这是因为雄辩处理数据库类型转换的方式。如果在应用中设置了对象的属性,那么一切都会很好,并且这些属性会根据 mutator 方法的类型提示中指定的类型进行类型检查;然而,当口才去获取存储的行,我们有这个问题的属性被返回作为一个原始值,而不是一个值对象。

Note

这样做的原因是因为调用了一个内部的口才方法,每当口才从数据库中检索到一些东西时就会发生:setRawAttributes(),它看起来像清单 12-4 。当从数据库和客户端获取和返回数据时,它被 concertive 使用,并绕过任何可能在模型上定义的赋值函数或访问函数。

   /**
     * Set the array of model attributes. No checking is done.
     *
     * @param  array  $attributes
    * @param  bool  $sync
    * @return $this
    */
   public function setRawAttributes(array $attributes, $sync=false)
   {
       $this->attributes = $attributes;
        if ($sync) {
           $this->syncOriginal();
      }
        return $this;
   }

Listing 12-4Method That Sets the Attributes to Whichever Primitive Types Were Specified in the $attributes Array When Fetching from the Database

我们在清单 12-4 中设置它的结果有可能导致一些不可预见的副作用,这在领域驱动设计中是非常糟糕的,因为它们有可能导致不一致的模型状态。我发现了一种解决这个问题的方法,即只允许将特定类型的数据设置到相关的属性中(就像我们之前对address属性所做的那样);然而,雄辩实际上是通过那个方法检索对象,其结果是原始类型,比如在调用函数时指定的intstring。这将有助于确保模型的内部状态保持完整和一致。

第一件事是为那个特定的属性创建一个新的访问器方法,它将把属性的原始值(由于setRawAttributes()调用)转换成我们想要表示它的值对象,如清单 12-5 所示。

<?php

//within the Patient Model

public function getAddressAttribute($address)
{
     return new Address($address);
}

Listing 12-5An Accessor Method for the Address Value Object on the Patient Model

concertive 中的访问器方法与赋值器具有相同的整体结构,不同之处在于它们的签名和方法体。

get + Address + Attribute
|     |         |
"get" + {attributeName} + "Attribute"

我们还将修改我们以前版本的setAddressAttribute()方法,以包含适当的检查,从而能够仅使用基本类型来存储患者的地址属性值。我们严格使用这种方法,因为我们需要在将模型保存到数据库时绕过雄辩的过程——具体来说,就是调用setRawAttributes()的部分,并放弃通过模型上的 mutators 对所有属性进行任何类型检查。这是另一个与标准 DDD 构建的应用“背道而驰”的事情,因为我们正在修改域模型,以符合应用 ORM 实现的标准流程。一般来说,这是不被允许的。然而,考虑到与雄辩和拉勒维尔一起工作的环境,我相信这是一个很小的代价;此外,这种方法的优点在于,它是一种简单、严格和明确的方法,可以定义我们希望对象从数据库中输出的类型。这是一种加强类型提示的方式。清单 12-6 展示了它可能的样子。

<?php

// In Patient model
public function setAddressAttribute(Address $address) {
    $this->attributes['address'] = (string)$address;
}

Listing 12-6A Better Way of Defining an Address’s Mutator on the Patient Model

这只是应用中值对象的一个例子,还有很多这样的例子。在这一章的后面,我们将在我们的索赔应用中识别其他的对象,这些对象将成为有价值的对象。

序列化值对象

如果你的值对象需要被序列化为数组或 JSON,你必须包含一个__toString方法,并用JsonSerializable特征设置它,就像清单 12-7 中的类一样。这在技术上是可行的;然而,这种解决方案并不理想,因为我们不想重复所有的样板代码来序列化我们在系统中创建的每个值对象。根据 https://www.ntaso.com/author/ntaso/ 的说法,程序员“Chris on Code”想出了一个特性,以支持值对象的方式封装处理属性。

<?php
class Address implements JsonSerializable
{
     private $value;

     public function __construct($address)
     {
          $this->value = $address;
     }

     public function getValue()
     {
          return $this->value;
     }

     public function __toString()
     {
          return (string)$this->value;
     }

     public function jsonSerialize()
     {
          return $this->__toString();
     ]
}

Listing 12-7A Possible Representation of a Value Object That Supports Serialization and Converting Its Value to a String

该特征看起来类似于清单 12-8 。

<?php

trait CastsValuesToObjects
{
     protected function castAttribute($key, $value)
     {
          $castToClass = $this->getValueObjectCastType($key);
          if (!$castToClass) {
               return parent::castAttribute($key, $value);
          }
          //or else create a value object:
          return $castToClass::fromNative($value);
     }

     public function setAttribute($key, $value)
     {
          $castToClass = $this->getValueObjectCastRType($key);
          if (!$castToClass) {
               return parent::setAttribute($key, $value)
          }

          //Enforce type defined in $casts
          if (! ($value instanceof $castToClass)) {
               throw new InvalidArgumentException("Attribute '$key'
               must be an instance of '$castToClass'");
          }

          return parent::setAttribute($key, $value->getNativeValue();
     }

     public function getValueObjectCastType($key)
     {
          $casts = $this->getCasts();
          $castToClass = isset($casts[$key]) ? $casts[$key] : null;
          if (class_exists($castToClasss)) {
               return $castToClass;
          }
          return null;
     }
}

Listing 12-8A Trait for Value Objects That Enforces the Type of Attribute

前面的特征可以这样使用:

class Patient extends Model {
     use CastsValueObjects;
     protected $casts = [
          'Address' => Address::class
     ];
}

在前面的实现中,不再需要在每个模型的基础上分别定义赋值函数和访问函数。唯一的问题是,值对象本身需要包含一些方法,使其与CastsValuesToObjects特征兼容。值对象的接口看起来如清单 12-9 所示。

<?php

interface ValueObject
{
     public static function fromNative($value);
     public function equals(ValueObject $object);
     public function __toString();
     public function getNativeValue();
}

Listing 12-9Interface for Value Object, Must Implement This to Be Compatible with the Aforementioned Trait

这个接口有助于确保所有值对象都定义了这些基本的功能,我们可以使用这些功能来确定这些对象在任何给定时间的类型转换和本机值。当使用这个接口时,您可以使值对象的构造函数protected,确保实例化一个对象的唯一方法是使用静态工厂方法fromNative(),这将有助于为它们的创建提供一些一致性,确保某些东西不能绕过预期的工厂而直接实例化该对象。

清单 12-10 展示了一个EmailAddress值对象的接口实现。

<?php
final class EmailAddress implements ValueObject, \JsonSerializable
{
     private $value;

     private function __construct($value)
     {
        $filteredValue = filter_var($value, FILTER_VALIDATE_EMAIL);
        if ($filteredValue === false) {
        throw new \InvalidArgumentException("Invalid argument
                $value: Not an email address.");
        }

        $this->value = $filteredValue;
     }

     public function fromNative($value)
     {
          return new static($value);
     }
     public function equals(ValueObject $obj)
     {
          if (\get_class(static) !== \get_class($obj)) {
               return false;
          }

          return $this->getNativeValue() === $obj->getNativeValue();
      }

     public function getValue()
     {
          return $this->value;
     }

     public function __toString()
     {
          return (string)$this->value;
     }

     public function jsonSerialize()
     {
          return $this->__toString();
     }

     public function getNativeValue()
     {
          return $this->value;
     }
}

Listing 12-10An Implementation of the ValueObject Interface Defined in Listing 12-9

注意,我们在前一个类private.上创建了构造函数,这是因为我们不希望对象从外部被实例化。通过构造函数public,我们强迫客户使用fromNative()方法。这通常被称为工厂方法模式。

关于清单 12-9 中的代码需要注意的最后一件事是,我们将不得不为系统中创建的每个值对象以相同的方式复制几乎每个方法。最好把那些部分扔进一个抽象类,这样可以让我们重用代码,而不是重复自己;此外,我们还获得了额外的好处,即能够覆盖抽象类上的任何方法,只要我们认为适合我们正在创建的特定值对象。清单 12-11 显示了一个更好的方法。

<?php
namespace App\ValueObjects;
abstract class AbstractValue implements ValueObject, \JsonSerializable
{
     public function fromNative($value)
     {
          return new static($value);
     }
     public function equals(ValueObject $obj)
     {
          if (\get_class(static) !== \get_class($obj)) {
               return false;
          }
          return $this->getNativeValue() === $obj->getNativeValue();
      }

     public function getValue()
     {
          return $this->value;
     }

     public function __toString()
     {
          return (string)$this->value;
     }

     public function jsonSerialize()
     {
          return $this->__toString();
     }

     public function getNativeValue()
     {
          return $this->value;
     }
}

Listing 12-11An Abstract Class That Encapsulates Code to Facilitate Value Objects

在将该逻辑推入抽象类之后,我们的实际值对象本身被大大简化了,并且只需要扩展这个单一的类,它反过来定义方法以满足它实现的契约。值对象现在可以像清单 12-12 一样简单。

<?php

use AbstractValue;

namespace App\ValueObjects;

final class EmailAddress extends AbstractValue
{
     private $value;

     public function __construct($value)
     {
           $filteredValue = filter_var($value, FILTER_VALIDATE_EMAIL);
           if ($filteredValue === false) {
                throw new \InvalidArgumentException("Invalid argument $value: Not an email address.");
     }

           $this->value = $filteredValue;
     }
}

Listing 12-12Simplified Value Object Extending the New Abstract Class We Just Created

这一切都很好,但是对于这个实现还有最后一点需要指出。只要我们在扩展雄辩的基类,我们就会一直将对数据库持久性的关注和对领域模型的关注混合在一起。在现实世界中,这样的负面影响是可以接受的,尽管在理论上和讨论中经常会被人反对。当创建一个域驱动的 Laravel 应用时,我们必须愿意在这个过程中遭受一些打击并做出一些牺牲,这肯定是其中之一。总的来说,与实体相比,值对象更容易维护和使用,这主要是因为它们不像实体那样需要完整的对象生命周期(这增加了很多开销,因为代码中有时会出现繁琐的细节),所以尽可能使用它们。

实体

域驱动设计中的实体是拥有自己的标识的任何对象,这样标识可以用于确定它与所有其他相同类型对象的唯一性(与值对象相反,值对象使用对象的值来确定唯一性)。这个身份可能来自多个可能的地方,最常见的是我们在自己的应用中建立的实体的身份。

实体是系统中的模型。实体可以保存对值对象的引用(就像我们之前看到的那样),但反过来不行(值对象不能保存对实体的引用)。实体可以是单一的、独立的形式(比如一个Patient对象,它引用了一个Address值对象,并且引用了其他实体,比如病人的主治医生,这将是一个Provider类的实例)。它们也可以以这样一种方式建立,将它们相互依赖的部分封装成一种易于识别和使用的形式,这种形式只有一个进入内部对象的单一入口。这就是所谓的聚合

一个实体的身份(我是诗人,以前不知道!)意味着经受住时间的考验以及以这样一种方式进行的修改,即无论经过多少时间或者对该实体的内部状态或属性进行了多少修改,该身份都将保持不变。实体是领域驱动设计的基本构件。表 12-2 提供了在本书的整个过程中我们一直在开发的声明系统中的一些实体及其相应的值对象的例子。

表 12-2

索赔应用中的示例值对象和实体

|

实体

|

价值对象

|
| --- | --- |
| Patient | Address, Medi-Cal Eligibility, Email Address |
| Provider | Address, NPI Number, Pay-Per-Visit Amount, Practice Address, Email Address |
| Practice | Address |
| Claim | Estimated Claim Amount, Progress Notes |

与不能改变并且只能被其他值对象替换的值对象相比,实体可以在对象的整个生命周期中被修改和更新多次,但是实际的身份永远不会改变。

定义实体身份

为实体生成标识的最简单方法是将整个实体标识过程委托给持久性机制。

  • 持久性机制生成一个身份。

  • 客户端生成一个身份。

  • 应用生成一个身份。

  • 另一个有界的上下文提供了一个身份。

持久性机制生成身份

在 Laravel 的通常情况下,出于本书的目的,我们将依靠最常见的方式来生成标识:MySQL 的主键上的AUTO_INCREMENT数据类型。这种方法的主要缺点是,在我们实际持久化对象之前,我们不会有实体的 ID。

除了这个固有的问题之外,我们使用 Laravel 的事实意味着我们可以在不抛出任何异常的情况下做如下事情,允许您在没有任何类型检查或约束的情况下基本上持久化空白的空对象:

$patient = new Patient();
$patient->save();

这种自由是不好的,会导致模型处于不一致的状态。你也没有太多办法来防止这种事情发生。这也意味着您可以将一个非持久化的、未验证的对象传递给应用的不同部分,在您真正尝试持久化该对象之前,您不会看到任何出错的迹象。这是使用基于活动记录模式的 ORM 的一个缺点(我们将很快讨论)。

客户端生成身份

有时,实体的身份将来自使用域模型的客户机。通常,对于在大范围内对该实体普遍唯一的标准化标识符来说就是这种情况。最常见的例子是一本书,它有一个普遍接受的标识符,称为国际标准书号(ISBN)。ISBNs 的长度是 10 或 13 位,取决于出版日期。一个带有相应 ISBN 的示例Book实体可以类似于清单 12-13 。

<?php
// use statements + namespace ...
class Book extends Model
{
     public $table = 'books';
     public $fillable = ['title'];

     public function setIsbnAttribute($isbn)
     {
          if (!strlen($isbn) == 10 || !strlen($isbn) == 13) {
               throw new InvalidIsbnLengthException();
          }
     }
}

Listing 12-13Example of the Client Providing an Identity: A Book and ISBN

注意,我是而不是在这个实体的$fillable属性中包含 ISBN,因为即使它已经存在并且所有的书都已经附带了(与我们必须自己生成和跟踪的身份相反),我们仍然希望在保存一本书的 ISBN 时以某种方式强制长度不变。这可以用一个 mutator 函数来完成,但是如果它在$fillable数组中列出,基本上可以跳过。

这个类可以工作,但是你能看到我们可能错过的东西吗?我们忽略了通过使 ISBN 成为可以独立重用的值对象,而不是作为原始的整数或字符串值被限制在Book实体的范围内,来为模型增加额外的一致性的机会。查看列表 12-14 。

<?php
//...
use App\ValueObjects\AbstractValue;
class ISBN extends AbstractValue
{
     public function __construct($value)
     {
          if (!strlen($value) == 10 || !strlen($value) == 13) {
               throw new InvalidIsbnLengthException();
          }
          //other ISBN validation checks
          $this->value = $value;
     }
}

Listing 12-14The ISBN Concept as a Value Object

然后我们可以更新我们的Book模型来合并新的值对象,如清单 12-15 所示。

<?php

namespace App\Models;

use App\ValueObjects\ISBN;

class Book extends Model
{
     public $table = 'books';
     public $fillable = ['title', 'isbn'];

     public function isbn()
     {
          return $this->hasOne(ISBN::class);
     }
}

Listing 12-15The Updated Book Model with the Included ISBN Value Object as a Relation

既然我们已经使 ISBN 成为一个值对象,我们可以继续将它包含在Book模型的$fillable数组中,因为我们知道该对象将被值对象的构造函数预先验证。使用这种设置可能如下所示:

$isbn = ISBN::fromNative('0123456789');
$book = Book::create([
     'title' => 'Domain Driven Laravel',
      'isbn'  => $isbn
]);

这段代码看起来非常简洁,易于理解。我们已经在 value 对象的构造函数中明确定义了所有的验证需求,并且可以保证设置给Book对象的 ISBN 属性的长度为 10 或 13 个字符。

应用生成身份

有时,关于如何处理实体身份的决定是由应用决定的。确定这种身份的常用方法是使用 UUID 场。UUID 代表“通用唯一 ID”,由连字符分隔的一系列字符组成;根据 RFC 4122 ( https://tools.ietf.org/html/rfc4122 ),UUID 充当系统中具有相同类型的任何给定实体的唯一身份。标准有不同的格式,根据应用的需要和项目的要求而有所不同。

  • UUID1 :根据当前时间生成

  • UUID3 :基于名称+ md5 哈希

  • UUID4 :随机

  • UUID5 :基于名称+ SHA1 散列

无论您选择哪种方法作为您的标识符,如果您在接下来的 100 年里每秒钟生成 10 亿个 uuid,那么您很可能永远不会遇到生成与过去生成的标识符相同的标识符的冲突或意外事件!我想说,我们在这方面已经做了很多工作,并且基本上可以高度肯定地保证,对于一个给定的实体,生成两个相同的 UUID 数几乎是不可能的。

*无论如何,一旦你处于 UUID 对应用中的一个模型有用的情况,我推荐使用专门针对 PHP 的经过测试的 UUID 生成器的实现: https://github.com/ramsey/uuid

要将此包添加到 Composer(并使其自动加载并在应用的任何其他地方可用),请运行以下命令:

composer require ramsey/uuid

在将这个包添加到您的应用中之后,您现在必须决定将利用这个包为您的实体生成标识的代码放在哪里。虽然我已经声明,一般来说,在 Laravel 应用的上下文中使用存储库几乎是没有意义的,但这实际上是一个这样的情况,存储库将派上用场,并提供一个干净简单的接口来生成这些实体的身份。

我们将通过一个例子来说明对于需要 UUID 的系统来说,一个新的实体是什么样子的。我们将使 ID 本身成为一个独立的值对象,以使概念在域模型中更加明确,并表明它所在的实体的标识符有一些重要的东西。

Note

在这本书和现实生活中,我坚持用而不是为系统中的每个标识符创建值对象。这是因为正常的模型/实体(我在整本书中交替使用)按照 ORM 的要求包含了一个 ID 字段,所以对我来说,这是隐含的。然而,在应用实体标识符使用一个单独的包(比如 UUID)的情况下,对于域模型来说,通过为标识符实际创建一个单独的类并将其作为标识符合并到实体中,使标识符成为一个显式的概念是足够重要的,这一点我们将在下面讨论。

对于下一个例子,让我们假设我们正在构建一个购物车,用于销售书籍的电子商务网站(完全错过了他们学习开源软件的那一天的课程或工作)(即书店),并且必须建立一个Cart对象的身份机制,以便用户可以保存购物车以备后用,并将其与其他用户的购物车区分开来。因为我们的应用负责生成身份,所以我们选择使用 UUID 格式,并将依靠它的结构来确保一个标准格式,该格式将在我们的系统边界内提供购物车的唯一性。我们需要一个不在Cart实体中的位置来放置这个逻辑,如清单 12-16 所示。

<?php
namespace YarnsAndGobyl\Domain\Repositories;

use YarnsAndGobyl\Domain\Models\Cart;

interface CartRepository
{
     public function nextIdentity();
     public function add(Cart $cart);
     public function remove(Cart $cart);
}

Listing 12-16Interface for the Cart Repository

只有当你有不同版本的 ORM,或者由于某种原因需要在你的应用中支持两种不同的 ORM 时,这个接口才是必要的,虽然可能性不大,但也是可能的。在这种情况下,您可以将清单 12-15 中的接口实现为DoctrineCartRepositoryInMemoryCartRepositoryElasticCartRepository等。如果不是这种情况(在本例中不是),为了避免创建另一个接口,您可以直接在 plain 'ol PHP 类中实现这些方法。该接口确实为将来的决策增加了一定程度的灵活性,并且应该在实际应用中实现之前得到团队的同意。与编程中的大多数事情一样,这种性质的事情有其优点和缺点。

无论如何,清单 12-17 展示了实现的接口。

<?php

namespace YarnsAndGobyl\Infrastructure\Repositories;

use YarnsAndGobyl\Domain\Repositories\CartRepository;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\Exception\UnsatisfiedDependencyException;

class EloquentCartRepository implements CartRepository
{
     public function nextIdentity()
     {
          try {
               $uuid = Uuid::uuid4();
               return $uuid->toString();
          } catch (UnsatisfiedDependencyException $e) {
               dd("Exception Occurred : " . $e->getMessage());
          }
     }

     public function add(Cart $cart)
     {
          //implement add functionality ...
     }

     public function remove(Cart $cart)
     {
          //implement remove functionality ...
     }
}

Listing 12-17Partially Implemented CartRepository from the Previous Interface

无论如何,我们将创建一个单独的CartId类,它将作为cart'的标识符,并且包含利用我们通过 Composer 安装的 UUID 第三方包的代码。就像我们在清单 12-14 中为Book模型使用 ISBN 标识符一样,我们将使CartId成为一个值对象,它将被Book实体作为建立身份的一种方式来获取。我们将对构造函数使用同样的策略,指定它是私有的,就像我们在清单 12-18 中所做的一样。

<?php
namespace YarnsAndGobyl\Infrastructure\Identity\Cart;

use Ramsey\Uuid\Uuid;

class CartId extends AbstractValue
{
     private $id;

     private function __construct($id = null)
     {
          $this->id = $id ? Uuid::uuid4()->toString();
     }

     public function create($uuid = null)
     {
          return new static($uuid);
     }
}

Listing 12-18A CartId Object Class That Will Serve as the UUID Mechanism We Established for the Cart Entity

相应的Cart对象可能看起来像清单 12-19 。

<?php

namespace YarnsAndGobyl\Domain\Models\Cart;

use Illuminate\Database\Eloquent\Model;
use YarnsAndGobyl\Infrastructure\Identity\Cart\CartId;

class Cart extends Model
{
     public $table = 'carts';

     public function cartId()
     {
          return $this->hasOne(CartId::class, 'id', 'uuid');
     }
}

Listing 12-19An Example Cart Entity with a UUID Identity Generation Mechanism Incorporated via the CartId Class

另一个有界的上下文生成身份

这通常被认为是在系统中识别实体的最危险和最复杂的方法,因为它依赖于在实体所处的环境之外实际存在和运行的事件和过程。对于处理这种情况的最佳方法有不同的理论。

由不同的有界上下文生成的身份的一个例子是身份服务。一个典型的面向处理给定系统(或企业)的用户和角色的识别和管理的 BC 可以包含在一个单独的、独立的有界上下文中,该上下文甚至可以与应用解耦并存在于其自己的微服务中。

在这个身份上下文中有一个用户的概念,一个这样定义的实体。还存在一些在这个User模型上操作的标准功能,并且它向系统中的其他有界上下文公开了有限数量的这种功能。

现在,假设我们有一个单独的有界上下文,可能是一个讨论板站点上的组上下文,它将不同的用户成员组织在一起,这样他们就可以轻松地共享内容、想法、聊天和其他形式的媒体。所有这些都是在组页面上完成的,只有该组的成员才能看到和参与。对于每个组,有三种不同类型的用户:管理员用户,对组拥有超级用户权限,包括内容、成员、禁令等。;版主用户,有权删除或编辑组网站上的内容;和普通会员用户,他们只能评论和参与讨论以及共享媒体内容。

从安全角度来看,身份服务应该处理所有相关的授权和身份验证过程,以确定登录的用户就是他们所声称的那个人,并根据每个用户在组和站点本身中的角色来管理他们的权限。然而,组上下文还需要知道每个用户的不同角色,以便它可以确定谁可以在组页面上做什么。

那么问题就变成了:我们把识别组中每个用户的逻辑放在哪里,并且检查他们是否有权限在任何给定的时间做他们请求做的任何事情。我们是否将相关的数据、代码和功能复制到组上下文中,以便它可以使用驻留在身份上下文中的信息,以便它可以决定用户访问和其他什么?

简短的回答是,不。干=不要重复自己。有一个更好的方法来做到这一点,很可能是一个更好的方法,我没有想到,也没有包括在本书中。我将让您来找出下一个最新和最好的方法,来处理跨系统中不同模块或服务的跨上下文通信和数据共享;然而,我将提出一种处理这个问题的方法,它不涉及在两种上下文中重复您的代码。

第一件事是识别这个场景中的实体。显然,用户可以被认为是一个实体,因为它包含一个 ID,将单个实例User与任何其他实例User分开。无论怎么看,属于一个组的成员用户仍然是一个User对象。管理员用户和版主用户也是如此。它们都意味着不同的东西,并且对于特定的组具有不同的访问级别,但是它们都是技术上的用户,或者至少是某种受限类型的用户。这给了我们一些提示,它们可能最好被分类为角色,每个用户应该有一个或多个这样的角色。角色本身不需要成为它们自己的实体,因为它们真正的区别仅仅在于它们所对应的值,并且不需要任何种类的域对象生命周期。另一种思考这些角色的方式是,它们本质上是静态的,一旦它们被初始设置,它们很可能在将来不会被改变。例如,除了Admin(或 root)之外,我们不太可能想称呼一个管理角色。一旦我们设定了,我们就不管它了。

这种性质的对象最好用值对象来表示,类似于我们通过值对象接口将值对象附加到实体本身的方式,该接口允许我们轻松地将它们作为关系项集成到实体中。然而,这里的关键概念是,组上下文是这些值对象将生活的地方,而不是身份上下文。我们可以从概念上认为这如图 12-1 所示,其中User实体位于身份上下文中,而组位于组上下文中。

img/488324_1_En_12_Chapter/488324_1_En_12_Fig1_HTML.jpg

图 12-1

身份语境和群体语境的互动

在组上下文中有一些值对象,它们标识了我们的应用支持的角色,因为它们与整个组上下文相关。我们可以在一个UserGroup之间建立一个关系,这将是一个多对多类型的关系,关系的拥有方在User模型上。许多用户可以属于许多组,许多组拥有许多用户。

Note

尽管我在这里标识的上下文是组上下文,但它实际上位于根名称空间Discussion中。

这是口才如何闪耀的绝佳例子。由于其流畅的本质和可链接的上下文,雄辩提供了一种独特而直接的方法来指定关系:它们几乎可以直接在整个英语句子中建模。你还能说得多直白?即使是不知道如何编程的人也可以看看这两个类,并对它们之间的关系给出一个模糊的描述。看看清单 12-20 就知道我指的是什么了。

<?php
namespace Identity\Domain\Models\Users;

use Discussion\Domain\Models\Groups\Group;

class User extends Model
{
     protected $fillable = ['email','username'];

     public function adminOf()
     {
          return $this->belongsToMany(Groups::class,'admins_groups');
     }

     public function memberOf()
     {
          return $this->belongsToMany(Group::class,'members_groups');
     }

     public function moderatorOf()
     {
          return $this->belongsToMany(Group::class,
               'moderators_group');
     }

     public function addAsMemberOf(Group $group)
     {
          $this->memberOf[] = $group;
          $group->addMember($this);
     }

     public function addAsAdminOf(Group $group)
     {
          $this->adminOf[] = $group;
          $group->addAdmin($this);
     }

     public function addAdModeratorOf(Group $group)
     {
          $this->moderatorOf[] = $group;
          $group->addModerator($this);
     }
}
?>

Listing 12-20Example of a User Entity

清单 12-21 显示了该组的模型。

<?php

namespace Discussion\Domain\Models\Groups;
//use statements

class Group extends Model
{
     protected $fillable = ['username','email','accountType'];

     public function admins()
     {
          return $this->belongsToMany(User::class, 'id', 'admin_of');
     }
     public function members()
     {
          return $this->belongsToMany(User::class, 'id', 'member_of');
     }

     public function moderators()
     {
          return $this->belongsToMany(User::class, 'id', 'moderator_of');
     }

     public function addMember(User $user)
     {
          $this->members->save($user);
     }

     public function addAdmin(User $user)
     {
          $this->admins->save($user);
     }

     public function addModerator(User $user)
     {
          $this->moderators->save($user);
     }

}

Listing 12-21Example of a Group Entity and the Relation It Has to the User Entity

然后是定义我们希望系统知道的三个不同角色的问题,它们被创建为值对象:MemberAdmin,Moderator。为了在域模型中给出更明确的定义,并更好地理解值对象所代表的概念,我们应该创建一个接口,作为我们在系统中实际创建的内容的高级概念:属于特定用户的角色。这是这个新角色概念的一个简单界面:

<?php

namespace Discussion\Domain\Contracts;

interface RoleInterface
{
     public function getRoleName();
}

注意清单 12-20 中角色的接口实际上是在组(讨论)的边界内创建的,而不是在身份上下文中。即使您创建了一个空白的接口类,当必须将新的功能和特性添加到应用中时,您仍然在为将来的成功做准备,这些功能和特性与系统中定义角色的方式有关。现在,我们可以为清单 12-22 中所示的三个新角色实现该接口。

<?php

namespace Discussion\Domain\Models\Groups;

use Discussion\Domain\Contracts\RoleInterface;
//additional use statements

class Member extends AbstractValue implements RoleInterface
{
     private $email;

     private $userId;

     private $username;

     private function __construct(Email $email, UserId $userId, Username $username)
     {
          //any invariant checks
          $this->email = $email;
          $this->userId = $userId;
          $this->username = $username;
     }

     public static function getRoleName()
     {
          return "Member";
     }
}

Listing 12-22A Value Object “Member” That Will Serve as a Role of a Standard Member of a Group

请注意,在前面的类中,我们将名为UserId的值对象传递给构造函数,而不是类型为User.的实际对象。这是为了防止逻辑从身份上下文泄漏到组上下文(Discussion名称空间),如果我们传递的是一个User实体而不是值对象,我们就会这样做。

当然,您可以通过同样的方法实现剩下的两个角色,只是稍微修改一下,分别加入管理员角色和版主角色。这个实现的其余代码可以在网上找到。为了简洁起见,我没有在这里包括它。

我们可以从图 12-1 中识别出的最后一块拼图是这个奇怪的Translator类,它似乎位于两个上下文之间,以某种方式为我们在组上下文中定义的值对象注入了活力。这个 translator 类旨在解决用户和成员、管理员和版主之间的翻译问题。我们基本上需要一种方法来将User对象(它们是实体)转换成值对象(就像我们在组上下文中为三个不同的系统角色创建的三个对象)。这需要自动发生以使其工作;特别是,每当我们从组上下文中检索到与User实体的关系时,我们都需要进行这种转换。因为Group实体和User实体都不应该承担这个责任,所以我们将不得不创建一个域服务来提供将所有东西联系在一起所需的功能。

尽管我们将在接下来的几章中深入研究服务,但我们的目标是创建一个简单的服务来为我们处理翻译。因为我们保持着分离系统关注点的步伐,所以我们希望将这个新服务放在与Group实体相同的有界上下文中,在本例中,这个实体就是Discussion名称空间。我们将从组上下文中的访问一个名为的特定组的成员或管理员的集合,并且我们希望避免在该组上下文中暴露User实体(位于身份上下文中)。避免这种情况将有助于防止一个模型的逻辑进入另一个模型。看起来像清单 12-23 。

<?php
namespace Discussion\Domain\Services\Groups;

use Discussion\Domain\Models\Groups\Admin;
use Discussion\Domain\Models\Groups\Member;
use Discussion\Domain\Models\Groups\Moderator;
use Identity\Domain\Models\Users\User;

class UserToGroupTranslator
{
     /**
     * Translates a user to a member
     */
     public function toMember(User $user)
     {
          return new Member($user->id, $user->email, $user->username);
     }

     /**
     * Translate a user to an Admin
     */
     public function toAdmin(User $user)
     {
          return new Admin($user->id, $user->email, $user->username);
     }

     /**
     * Translate a user to a moderator
     */
     public function toModerator(User $user)
     {

          return new Moderator($user->id, $user->email,
               $user->username);
     }
}

Listing 12-23An Example Translator for User Objects (Entity) to Role Objects (Value)

翻译器的代码非常简单。给它一个User对象,并获取一个特定于组上下文的值对象。一旦这就位,我们只需要修改我们的组实体来使用它,如清单 12-24 所示。

<?php

use Discussion\Domain\Services\Groups\UserToGroupTranslator;

class Group extends Model
{
     protected $fillable = ['username','email','accountType'];

     public function __constrcut(GroupId $groupId, Name $name, Slug $slug)
     {
          $this->setId($groupId);
          $this->setName($name);
          $this->setSlug($slug);

          $this->admins = new Collection();
          $this->members = new Collection();

          $this->usersToGroupTranslator = new UserToGroupTranslator();
     }

     public function users()
     {
          return $this->hasMany(Users::class);
     }

     public function getMembersAttribute()
     {
          return $this->members->map(function($user) {
               return $this->userInGroupTranslator->toMember($user);
          });
     }

     public function getAdminsAttribute()
     {
          return $this->admins->map(function($user) {
               return $this->userInGroupTranslator->toAdmin($user);
          });
     }

     public function getModeratorsAttribute()
     {
          return $this->moderators->map(function($user) {
               return $this->userInGroupTranslator->
                    toModerator($user);
          }
     }
     // ... other related methods for the Group model ...
}

Listing 12-24Updated Group Entity 

请注意,为了自定义清单 12-23 中的类返回管理员或成员或版主集合的方式,通过几个不同的访问器方法,这些方法实际上接受值对象的原始集合,并通过我们之前构建的转换器进行映射。在这样做的时候,使用这种设置的外部世界(客户端)甚至不会意识到任何这样的转换已经发生,这是最好的情况。

这确实需要一些额外的工作和思考,但这里的结论是,我们可以通过尽可能实现值对象并创建某种转换器来使用来自其他有界上下文(如实体)的结构,这种转换器可以从实体中获取值对象所需的数据,而无需跨越使用它们的有界上下文的边界。我们也很好地坚持了关注点的分离。

数据传输对象

数据传输对象(d to)顾名思义就是要传输到前端或用于为整个应用中的非结构化数据添加结构的对象。一般来说,我们不希望在我们的应用中传递完全成熟的实体或传递到我们的前端,因为这样做会破坏层的封装。应用层应该是唯一可以直接利用域中对象的层。相反,我们希望为应用的前端提供完成工作所需的最少数据。通常,完全实例化的实体对象是多余的,我们不希望将实体的功能或行为暴露给应用中不需要它的其他部分。取而代之的是,我们创建一个淡化了的“哑的”普通的 ol' PHP 对象,它包含实体本身拥有的和前端需要的所有数据,但是不发送额外的行为或细节。

就结构化数据而言,我们将非结构化数据称为用普通 PHP 数组表示的数据。它看起来像下面这样:

$myArray = [
     'name' => "Jesse",
      'title' => "Web Developer",
      'dob' => "09/14/1987"
];

作为域服务的客户端,我们显然知道阵列中会有哪些数据,因为我们正在创建这些数据。然而,域服务本身被留下来进行各种验证和isset()检查,以验证阵列中的数据是预期的数据。

class SomeController
{
     public function displayPerson($person): string
     {
          $person = $person['name']; /* we can't just use this as is
                                        Because we cannot guarantee
                                        that the 'name' key even
                                        Exists inside the array */
     }
}

相反,如果我们用一个 DTO 来表示一个Person对象,而不是一个数组(它是非结构化的)或一个成熟的实体,我们就可以在屏幕上显示这个人了。

class Person
{
     public string $name;
     public string $title;
     public \DateTime $dob;
}

class SomeController
{
     public function displayPerson(Person $person): string
     {
          $name = $person->name;
          $title = $person->title;
          $dob = $person->dob;
          //do stuff
     }
}

在前面的控制器方法中,我们可以保证在Person对象上有一个name属性,以及它具有的其他属性,并且我们可以直接使用它们而没有任何后果,因为没有任何行为附加到 d to,只有数据。使用 dto 有许多好处。

  • 它们允许我们像前面一样键入提示对象,而不是使用数组。

  • 通过对 DTO 上的属性进行特定类型化,我们可以确保它们包含应该包含的数据,而无需进行额外的检查或验证。

  • dto 可以被静态分析和自动完成,而数组不能。

  • 结构化数据比数组更容易处理,也更明确。

从一个框架到另一个框架,甚至跨语言,dto 真的没有太大的变化。它们通常是简单的类,只保存代表数据库中某个实体的数据。然而,我发现了一个非常酷而且非常有用的包,它为您提供了创建 dto 的工具和方法,这些工具和方法简单明了,并且为应用增加了价值。

添加 Spatie 的数据传输对象包

要在您的系统上安装此 DTO 助手包,请发出以下命令:

composer require spatie/data-transfer-object

这为我们提供了一个基本的DataTransferObject类,它为您的应用提供了各种创建和管理 dto 的工具。例如,要使用这个包创建一个 DTO,您可以扩展基类,对于我们的Person对象,它看起来像下面这样:

class Person extends DataTransferObject
{
     public string $name;
     public string $title;
     public string $dob;
}

我意识到这个类看起来和前面的类没有太大的不同,但是这个类的不同之处在于,要实例化它,你只需向构造函数传递一个键控属性数组。

$person = new Person([
     'name' => 'Jesse',
      'title' => 'Web Developer',
      'dob' => '09/14/1987'
]);

这不是很棒吗?你甚至不必指定一个构造函数。这个小东西有能力以数组或对象的形式检索其中的值。

$name = $person['name'];
//is the same as
$name = $person->name;

根据我们在 DTO 中指定的类型,自动对该对象实例化中指定的值进行类型检查。我们不再需要担心检查我们的 DTO 的类型,只要我们或者像前面显示的那样显式地类型暗示属性(这需要 PHP 7.4),或者只要它们是用do块类型暗示的(对于不支持具有原始类型的属性的内联类型的 PHP 早期版本)。

这个软件包还有很多其他功能,我强烈建议您在 https://github.com/spatie/data-transfer-object 查看所有功能。这些特性包括管理 DTO 集合、将嵌套数组类型转换为对象、在对象上创建不可变属性(或者使整个对象本身不可变),以及使用 helper 函数来帮助您以几乎任何您需要的方式管理和简化 d to。我推荐你使用这个软件包来完成所有与 DTO 相关的任务。

结论

实体和值对象是实现领域驱动设计的必要构件。实体更加复杂,并且有一个必须管理的生命周期。通常这意味着跟踪实体的内部状态。另一方面,值对象是表示域模型中元素的简单对象,这些元素通过它们包含的而不是显式标识符(例如 ID 字段)来唯一区分,实体就是这种情况。我们在创建值对象时使用了一个特征,使它们更容易在应用中使用,也更容易创建或替换。

在本文中,实体和模型可以互换使用。实体应该拥有系统中的大部分业务规则和业务逻辑。记住这一点的一个好方法是练习保持一个“胖模型”和一个“瘦控制器”,这意味着控制器应该只作为促进交付机制的一种手段(比如接受一个请求对象和与客户机“握手”),并且模型应该包含业务逻辑,而不是控制器。

有许多机制用于赋予实体身份。其中客户端提供身份,应用提供身份,持久性生成身份,另一个有界上下文提供身份。最后一个是最复杂的,有时需要各种值对象和一个翻译器在值对象和存在于另一个有界上下文中的实体之间进行调解。

d to 是只包含数据而不包含行为的实体的简化表示,可用于向非结构化数据添加结构,例如多维数组中包含的数据。它们还为前端组件提供一组特定的数据,这些数据可以用来完成工作。这减轻了我们传递成熟实体的需要(我们希望尽可能避免这样做),而是提供一个简单的、显式定义的对象,只包含手头任务所必需的属性。Spatie 发布了一个帮助创建、促进和管理 dto 的包,它附带了一系列很酷的帮助器方法和附加功能,可以在开发 dto 时节省时间和代码,例如自动类型检查、嵌套数组到对象的转换以及用于实例化它们的自包含工厂。建议您在自己的项目中尝试一下这个包。

总的来说,实体是您的领域模型中最重要的方面之一,因为它们以文字的方式表示底层领域的实际元素。因此,在创建实体并明确定义它们与同一系统中其他实体的关系时,应该小心谨慎。*

十三、领域事件

应用中会发生很多事情。有些比其他的更有趣。 Events 捕获这些信息,将其打包成清晰的格式/结构,并依靠广播机制或调度程序将事件发送给应用的其余部分。我们已经对第五章中的事件有所了解。本章将在这些信息的基础上,为您提供额外的上下文和专门针对领域事件的讨论。

哪些组件实际上收到关于特定事件的通知是基于订阅者或观察者或 PubSub 模式(它们的意思大致相同)。在应用的上下文中,使应用作为一个整体运行的所有活动部分和片段之间的通信在现代开发中是至关重要的。例如,如果一个新用户在系统中注册,我们可能会调度一个UserHasRegistered事件,其中包含一些重要的数据来描述它以及被调度的事件所涉及的其他内容。

在这一章中,我们将讨论三种类型的事件:应用事件、领域事件和基础设施事件。我们将涉及应用和基础设施事件,但本章的大部分内容将集中在领域事件上。我将解释领域事件对拉韦尔和 DDD 意味着什么,并解释我们如何以一种简单明了的方式实现领域事件,这种方式遵循 DDD 的一般经验并使用拉韦尔。Laravel 内置了许多支持创建和调度事件的组件,事实证明这些组件对领域驱动的设计非常有用。

最后,我们将继续我们的索赔应用的设计,添加领域事件的概念,并看看它们在索赔提交的上下文中哪里是有用的。我将向您展示如何以及何时利用领域事件,以及如何使用它们来集成有界的上下文。

本章第一部分中使用的例子是基于一个更加标准化和正式的 DDD。这样您就可以对事件的结构和侦听器的使用有一个大致的了解。在本章的后面,我们将通过一些可能的方法来实现领域事件,使用 Laravel 的组件以及一个第三方包来创建和处理领域事件,这些包可以插入到我们的雄辩模型中,允许您直接处理模型(以及数据库)。此外,我们将回顾事件源,如何以及为什么使用它,以及事件源架构在 Laravel 中的可能实现。

事件的价值

不同类型的事件基于它们执行的层。应用事件由 Laravel 的事件调度程序处理,并在整个框架中使用,以便于将信息传播到组成该应用的不同组件,这些组件位于不同的有界上下文中。这些包括特定于框架的事件和侦听器。最后一类事件是本章的重点;领域事件是您所在的域的自定义事件,需要设置自定义事件和侦听器。它们传递关于各种订阅组件的信息,以便这些组件能够以(或多或少)自动化的方式对事件做出反应。领域事件是领域模型实现的核心,因为该模型促进并传播了特定的知识,即在应用的其他地方已经发生了一些有趣的事情。

示例:会计软件

例如,以一个基于网络的会计系统为例。该系统的一个特点是,它能实时自动核对包含所有交易的中央分类账。每当一笔交易被记录在一个以账户为基础的分类账中时,一笔匹配的交易必须被记录在中央分类账中。这有利于银行家查看银行的当前资产,以确定有多少可用于投资,而不是等待 24 小时,以便在账户更新之前在适当的分类账中更新余额和交易。假设有两个有界的上下文处理这个特性。

  • 账户分类账核算上下文:处理实时更新基于账户的分类账

  • 中央分类账会计环境:处理匹配交易从任何基于账户的分类账到中央分类账的实时过账,并在每次交易过账后核对分类账

这两个上下文显然需要相互通信,以便匹配的交易可以实时地发布到中央分类帐。管理通信的一种方法是使用领域事件来封装每个事务的所有事务数据,同时仍然考虑每个上下文的边界。然后,该事件将被分发给对侦听该事件感兴趣的订阅组件(侦听器也称为事件处理程序)。该事件应该与发送或接收它的任何特定上下文分离;这类似于分布式架构的工作方式。

一种方法是依赖事件调度程序,它简单地接收一个事件,然后将它调度给订阅的侦听器。在前面的场景中,account ledger 上下文将通过事件调度程序调度一个事件,然后事件调度程序将接受该事件并将其传播到系统的其余部分,这将包括已经向事件调度程序注册以侦听该事件的任何组件。事件调度程序充当分布式中枢,以分离和分布式的方式将应用的不同组件连接在一起。这允许您在组件之间进行编程式交互,同时在应用或网络级别保持它们的逻辑和上下文分离。事件也是微服务架构中的一个关键组件,是或多或少将各个部分粘合在一起的东西。

回到我们的示例会计软件场景,基于帐户的上下文会向事件调度程序发送类似于NewTransactionWasRecorded的事件。另一个上下文(central ledger 上下文)必须已经向事件调度程序注册,才能侦听该特定事件。因此,一旦从第一个上下文发送了事件,事件调度程序就会获取它并将其分发给订阅组件,其中包括中央帐户上下文。中央帐户上下文从事件调度程序接收传播的事件,然后相应地采取行动。将事件发送给调度程序的上下文不知道也不关心其他哪些上下文正在监听它。已经完全脱钩了。此外,接收上下文不一定知道事件实际上来自哪里,只知道它已经发生了。

事件中封装了诸如受影响的帐户之类的东西,以及任何其他与交易相关的数据,因此任何侦听组件都很清楚事件中涉及了什么。然后,中央会计环境将在其自己的分类帐上创建匹配的交易,从而使分类帐随着每一笔交易实时更新。在现实世界中,你可能会有一堆不同的账户,银行每天最有可能有几千或几万笔交易入账。由于可能有多个基于帐户的分类帐将事件发送到中央分类帐,因此它们都可以遵循相同的流程,只需将相同的事件分派给事件分派器,事件分派器将处理该事件,并将其(以及附加的事件数据)传递给订阅通过事件分派器接收它们的任何上下文。这是一个很好的例子,说明了领域事件如何将本地有界上下文与应用/服务/网络外部的上下文联系起来。事件也是六边形结构内部运作的关键。

消息队列

事件总线或消息队列是一种合并位于不同网络上的有界上下文的方式,比如 RabbitMQ 或亚马逊 SQS 之类的异步消息队列。其工作方式是,您只需设置应用在队列中触发事件,而不是事件调度程序,因为实际上,从高层次的角度来看,队列只是云中的一个高端事件调度程序,附带了一系列附加功能。但是,即使您是通过队列发送事件而不是在本地处理它,您可能仍然希望在系统中保留一个记录,以便进行分析和历史记录。这可以通过所谓的投影来完成,其中某个事件在最初被触发后,将匹配的投影事件发送到系统中的不同接收器,该接收器通过事件监听器处理逻辑,这将包括 MySQL 数据库上的写投影,以记录事件发生。因为这两个事件按顺序一个接一个地发生(即同步),这被称为最终一致性,正如您可能已经猜到的,它在操作的两端都起作用(很可能通过持久性机制、排队系统或缓存服务器——甚至可能是 Elasticsearch)。

命名事件

你猜对了,事件应该总是按照领域中无处不在的语言来命名。因为事件基本上是对过去发生的事情的记录,所以您应该努力使所有事件都以过去时态命名,例如:

  • UserHasRegisteredUserRegistered

  • BlogPostWasPublishedBlogPostPublished

  • PatientHasScheduledAppointmentPatientScheduledAppointment

  • SomeProcessHasStartedSomeProcessStarted

  • AnotherProcessHasStoppedAnotherProcessStopped

一些开发人员更喜欢较短的语法,这种语法更快更漂亮,并且仍然传达了一个事实,即无论它是什么都已经发生了。使用哪一种完全取决于你的个人偏好,只要你坚持使用来自普遍语言的命名约定,并且你给事物命名就好像它们已经发生了一样(事实就是这样)。就我个人而言,我更喜欢用更长的方式来命名它们,因为它比另一种方式更清晰、更明确。(例如,PatientScheduledAppointment很可能是一个我们都知道的实体,因为它听起来像是一个东西,而不是一个描述。)

领域事件:声明

回到我们正在进行的索赔处理应用,让我们列出在通过系统提交索赔的正常过程中发生的一些重要事情,并创建一些事件来描述它们(表 13-1 )。这些被称为领域事件,因为它们直接对应于领域相关的问题。

表 13-1

索赔申请中的领域关注点及其相应的事件

|

领域关注

|

潜在事件

|
| --- | --- |
| 系统中登记了一名新患者并指定了一个新的主要供应商。 | PatientWasRegistered``PatientUpdatedPrimaryProvider``PatientDocumentsUploaded |
| 新的提供商在系统中注册,并被添加到系统中现有的薪资代码表中。 | ProviderWasRegistered``ProviderAddedToPaycodeSheet``ProviderUpdatedCptCodeGroups |
| 已提交索赔。 | ClaimWasSubmitted``ClaimWasUpdated |
| 索赔已由索赔审核人审核并批准。 | ClaimWasReviewed``ClaimStatusUpdated``ClaimWasApproved |
| 索赔已由索赔审核人审核,并被标记为需要更正。 | ClaimWasReviewed``ClaimStatusUpdated``ClaimNeedsCorrection |
| 该患者被验证为有资格获得福利,并保存到索赔中。 | PatientEligibilityVerified``ClaimWasUpdated |
| 索赔已被批准支付,然后将估计的索赔金额支付给拥有索赔的提供商。 | ClaimWasReviewed``ClaimStatusUpdated``ClaimApproved``BillerHasApprovedClaim``ProviderWasPaid``ClaimWasClosed |

请注意,在表 13-1 中,我选择了基于受被触发事件影响的实体的事件名称以及关于该事件的描述。有些事件的名称中有一个WasHas,有些没有,但是所有的事件都描述了过去发生的事情,并且可以直接与应用的某个特定部分相关联。这些实体都是基于无处不在的语言中的术语,一般来说,这是一个很好的迹象,表明我们在保持代码中的实现接近领域本身方面是正确的,并且我们正在对它进行充分的建模。相反的情况也是如此:含义不清楚或者不代表在通用语言中发现的项目的事件名称可能是事件不是正确地从领域中派生出来的或者它们对领域建模错误的标志。

服务和事件

应用服务是外部和领域逻辑之间的中间人。它们通常接受某种形式的请求,并将该请求转换成域层可以理解和运行的命令。通常,它们会对标量值进行操作,并将它们转换成业务对象,以便应用可以处理它们或在域层中进一步利用它们。使用像 Laravel 这样的框架,只要您在Request对象中指定传入的参数和验证,就可以为您抽象出交付机制。

应用服务的一个例子(对于正在进行的索赔应用来说)是一个在系统中注册的新病人。这个服务可能只处理一件事,即注册一个病人,但是可能会有额外的逻辑运行以响应一个新的病人注册。除了在我们实际发出一个PatientWasCreated事件之前注册一个新病人所需要的所有步骤之外,我们可能有一些逻辑,我们可以设置在该事件实际触发时执行,在本例中就是在创建病人时执行。首先,您需要定义一个控制器来接受一个输入请求,这个请求是专门为这个请求构造的(可能是RegisterPatientRequest,然后它会被交给控制器)。见清单 13-1 。

<?php
namespace App\User\Application\Http\Controllers;

use App\User\Application\Requests\RegisterPatientRequest;
Use App\User\Application\Services\PatientRegistrationService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;

class PatientRegistrationController
{

     private $registrationService;

     public function __construct(PatientRegistrationService $registrationService)
     {
          $this->registrationService = $registrationService;
     }

     public function register(RegisterPatientRequest $request)
     {
          $patientDetails = $request->get('patient.details');
          $patientDocuments = $request->get('patient.documents');
          $patientEligibility =
               $request->get('patient.initial_eligibility');
          $registeredProvider = $request->get('patient.provider');
          $consentForm = $request->get('patient.consentForm');

          return new JsonResponse(
                    $this->registrationService->execute(
                         $patientDetails,
                         $patientDocuments,
                         $patientEligibility,
                         $registeredProvider,
                         $consentForm
                    )
          );
     }
}

Listing 13-1Example Application Service of New Patient Being Registered in the System

关于前面的例子,有一些事情需要注意。

  • 呈现的名称空间只是构造对应于该有界上下文的逻辑片段的一种方式;然而,如果让领域模型中的领域和概念有一个额外的定义为名称空间的类别会更清楚,那么您肯定会选择这样的东西:

  • 资源控制器定义了系统中的实际资源,包括病人是什么和病人做什么,可以用来代替普通的控制器,在这种情况下,您可能还想使用路由模型绑定。

App\User\Application\Requests\Patient\RegisterPatientRequest
App\User\Application\Services\Patient\PatientRegistrationService
App\User\Application\Http\Controllers\Regsitration\PatientRegistrationController

总的来说,清单 13-1 中的代码简单地用依赖注入设置控制器,并自动注入我们需要用来完成请求的实际服务。除了请求本身之外,控制器完成工作所需的任何内容都应该在构造函数中进行类型提示,并分配给私有成员变量以备后用。然后,在被调用的实际路由方法中(在本例中是register()),注入特定于封装服务处理请求所需的所有输入的请求。但是,请注意,该方法只接收请求,从请求对象中提取数据,并将数据传递给构造函数中提示的服务类型,将逻辑完全委托给该服务,并向客户端返回一个响应实例(在本例中是一个JsonResponse),该响应实例可以由内置的响应机制优雅地返回到前端。

虽然这听起来像是控制器在做大量的工作,但它实际做的工作很少。它的工作是完成以下任务:

  1. 接受请求(即与客户握手)

  2. 将完成请求所需的实际工作委派给服务或作业

  3. 返回响应(即确认成功[200]或错误消息[4/500])

在清单 13-2 中,您可以看到一个这样的服务的实现,它可以被认为是更正式的 DDD 方法。

<?php
namespace App\User\Application\Services;

class RegisterPatientService
{
     private PatientRepository $patientRepository;
     private DocumentUploadService $docService;
     private PatientPrimaryService $patientPrimaryService;
     private PatientEligibilityService $patientEligibilityService;
     private EventDispatcher $eventDispatcher;

     public function __construct(
          PatientRepository $patientRepository,
          DocumentUploadService $docService,
          PatientPrimaryService $patientPrimaryService,
          PatientEligibilityService $patientEligibilityService,
          EventDispatcher $eventDispatcher,
          )
     {
          $this->patientRepository = $patientRepository;
          $this->docService = $docService;
          $this->patientPrimaryService = $patientPrimaryService;
          $this->patientEligibilityService = $patientEligibilityService;
          $this->eventDispatcher = $eventDispatcher;
     }

     public function execute( PatientDetails $patientDetails,
                              array $patientDocuments,
                              PatientEligibility $patientEligibility,
                              Provider $registeredProvider,
                              ConsentForm $consentForm)
     {
          // EX: Step 1 - Create & persist the Patient entity:
          // run any business logic required for validation:
          if ($this->validatePatientDetails($patientDetails)) {
               $nextID = $this->patientRepository->nextId();
               $patient = new Patient($nextId, $patientDetails);
               $this->patientRepository->persist($patient);

               $this->eventDispatcher->dispatch(new PatientWasCreated($patient));
          }
          // ... validate the rest of the inputs ...
          // ... dispatch the remaining events ...
     }

     public function validatePatientDetails(PaymentDetails $paymentDetails);
     {
          //domain validations - although this could be better placed
          //within the model itself as a precondition, it still works
          if ($valid) {
               return true;
          }
          return false;
     }

     // similar validation methods would follow
}

Listing 13-2Service That Registers Patients

前面的服务基本上是一个伞状服务,它合并了其他服务,充当一种门面,因为有一个单一的入口点封装了一组内部服务,所有这些服务都需要运行来注册病人。该服务将完成工作所需的服务和对象注入到构造函数中,并将数据注入到execute()方法中。这种方法的参数仅仅是简单的 dto,表示新患者注册所需数据的各个方面。使用对象比使用数组更容易,dto 以面向对象的方式很好地描述了数组中的数据。我将向您展示这个类的一个更好的版本,它以一种更优雅的方式完成同样的事情。

现在,只需理解在应用中有多个服务在做各种事情,这些事情可以统称为在系统中注册新患者的任务。清单 13-2 中突出显示了第一个任务,它包括通过从存储库中获取一个新的身份来实际创建一个新的Patient实体,将其持久化到数据库中,然后调度一个事件来指定该特定子任务的成功运行。随着服务继续在每个子任务的execute方法中启动,它们都跟着启动,并使用传入的输入参数做一些事情(比如上传文档、为病人选择主治医生等。)然后分派一个事件来捕获每个子任务发生的历史。

清单 13-3 展示了PatientWasCreated事件可能的样子。

<?php
namespace App\User\Domain\Events;

use Illuminate\Queue\SerializesModels;
use App\User\Domain\Models\Patient;

class PatientWasCreated extends DomainEvent
{
     use SerializesModels;

     public PatientDetails $patientDetails;

     public function __construct(PatientDetails $patientDetails)
     {
          $this->model = Patient::class;
     }

     public class getEventBody()
     {
          return (string)$this->patientDetails;
     }
}

Listing 13-3Example Domain Event That Gets Fired When a New Patient Is Created in the System

这个活动非常简单。这个事件扩展了DomainEvent父类,它将抽象出事件如何从事件中持久化。我们将逻辑放入一个抽象的父类中,为我们提供了一种将事件保存到数据库表或消息队列中的方法。事件通过某种机制以连续的方式存储,该机制与事件存储一起工作以保持它。抽象类DomainEvent包含了这种机制,我将在本章的“DDL 中的事件”部分给你一个例子。事件主体被设置为从getEventBody()方法返回的任何内容,在前面的例子中,该方法必须有一个名为PatientDetails的 DTO,并且 DTO 必须支持一个__toString()方法,该方法将事件主体正确地转换为存储在数据库表的event_body字段中的字符串。该字段应包含事件本身的所有相关数据,包括任何其他相关实体或与事件相关的受影响数据库行的行 id。

当然,正如我们在前一章中所学的,我们不需要从头开始创建这些类。相反,我们可以将事件及其监听器放入EventServiceProvider类的$listen数组中,然后运行 Artisan 命令php artisan events:generate。这将为我们定义的事件及其相应的侦听器创建基本的类结构,我们已经将它们包含在数组中。更好的是,我们可以通过设置应用的EventServiceProvidershouldDiscoverEvents方法,让所有的事件自动被发现。参见第五章了解如何操作的详细信息。

有一种更好的方法可以做到这一点,但就对象访问而言,这种方法的安全性稍差一些,因为它涉及到让事件的属性可以公开访问。这样,处理事件持久性的机制可以在对象上运行简单的get_object_vars()json_encode结果,并在数据库表上的event_body字段中持久化 JSON 编码的数据,以及该对象的 ID。在几天或几个月的时间里,会有许多行引用同一个eventId和不同的event_body

事件监听器

其他事件是关联的事件侦听器,它们根据从事件调度程序调度的事件而行动。一个事件 监听器是一个相当简单的概念,尽管事件监听器包含的逻辑可能很复杂,这取决于事件的性质。相同的基本格式也适用于侦听器:注入完成手头任务所需的任何依赖项,然后将实际执行的逻辑放在一个handle()方法中,该方法由父类提供,并在侦听器中被覆盖。

清单 13-4 展示了一个监听器的例子,这个监听器监听我们之前在例子中调度的PatientWasCreated事件。注意,我们是根据它做了什么,而不是它是什么来命名监听器的。这个特定的监听器会将患者添加到一个 Elasticsearch 集群中。这将允许管理用户、提供者和审查者能够快速搜索所有的患者记录,也许通过自动完成功能。

<?php
namespace App\User\Domain\Listeners;
use App\User\Domain\Events\PatientWasCreated;

class AddPatientToElasticsearch
{
     private EsRepository $esRepository;
     public function __construct(ESRepository $esRepository)
     {
          $this->esRepository = $esRepository;
     }

     public function handle(PatientWasCreated $event)
     {
          //get data to event store (database)
          $patientDetails = $event->getEventBody();
          //store them in Elasticsearch via a call to its repository
          $this->esRepository>addToIndex('patients',$patientDetails);
          //reindex the patient index
          $this->esRepository::reindex('patients');
     }
}

Listing 13-4Event Listener Triggered by the PatientWasCreated Event

这是一个事件侦听器的简单示例,它可以侦听从应用服务调度的PatientWasCreated事件,该应用服务在每次系统中创建新患者时运行。基于事件在各自的handle()方法中定义的返回值,这对于每个事件都是特定的。

总之,这个例子让您对事件和事件处理程序有了一个大致的了解,但是它们缺少一些 Laravel 的语法糖和 Laravel 和 oracleat 中的一些很酷的特性,这些特性可以用来创建容易理解的代码,这些代码是按照领域的真实意图和过程建模的。但是这个例子看起来比前面的代码更漂亮,并且正确地对域进行了建模。

DDL 中的事件

现在,您已经有了一些关于领域事件和事件监听器的基础知识,我们可以用 Laravel 和口才来检查一个可能的实现,在我看来,它更简单、更健壮。此外,它仍然足够明确,可以依赖于类型检查,并遵循 DDD 关于关注点分离和领域驱动设计的重要方面。我认为,大量添加的代码、类或组件很容易使领域模型的设计变得复杂,导致关注点从领域转移到维护臃肿的代码库,这不是我们想要的。我们需要一种方法来开发可读的、健壮的,并且(最重要的是)充分代表底层领域概念的代码。

如果我们仔细想想,大多数被分派的事件都是由于(或伴随着)系统中某个模型的更新或更改。我们不需要手动放置逻辑来调度事件,而是可以使用我们系统中每个模型类都包含的雄辩生命周期事件(所有模型类都应该扩展雄辩的父类Model)。通过这种方式,我们已经有了一组事件,我们可以观察到这些事件,以便在事件触发时挂钩到我们需要运行的任何附加逻辑。您应该还记得上一章中关于雄辩模型中事件的讨论,但是为了更新,这里列出了每个模型中发生的可用事件:

  • retrieved

  • creating

  • created

  • updating

  • updated

  • saving

  • saved

  • deleting

  • deleted

  • restoring

  • restored

我们可以使用一个观察器来挂接我们的附加逻辑,以便在任何这些事件发生时运行(我们在本书的前面已经讨论了一个例子)。当您想要将正在收听的事件分组到同一个模型中时,观察器是很好的选择。我们可以使用这些事件的另一种方式是告诉 Laravel,我们希望每当这些事件之一触发时运行一个自定义事件。你可以通过一个名为$dispatchesEvents的属性在模型内部完成这项工作,如清单 13-5 所示。

<?php
namespace App\User\Domain\Models;
use App\User\Domain\Events\PatientWasCreated;
use Illuminate\Foundation\Auth\User as Authenticatable;
class Patient extends Authenticatable
{
     protected $dispatchedEvents = [
          'created' => PatientWasCreated::class,
          'updated' => PatientWasUpdated::class
     ];
}

Listing 13-5Listening to an Eloquent Lifecycle Event

在我们建立了我们想要收听的雄辩事件和我们想要调度的自定义事件之间的链接后,我们可以继续将逻辑附加到应用,当事件通过标准监听器实际发生时,正如我们在清单 13-4 中所做的。在前面的例子中,我们告诉 Laravel,无论何时触发与Patient对象相关的雄辩事件“created ”,我们都希望与它一起触发PatientWasCreated事件。这将允许我们使用与清单 13-4 中相同的监听器,因为监听器并不关心是什么导致了事件的触发;它只关心事件被激发。实现这种东西的所有监听器逻辑都不会改变。

然而,在领域驱动设计的上下文中,从技术上讲,这些事件与框架紧密耦合,并且发生在应用级别,它们是同步的。也就是说,这些生命周期事件是而不是领域事件。然而,Laravel 允许您将一个生命周期事件“转发”到一个我们定义的自定义事件,然后这个事件将与生命周期事件一起被触发。我们可以将这个自定义事件设置为一个领域事件,并且我们可以附加通常包含在自定义领域事件中的同一个领域事件侦听器。实际上被抽象的是如何何时事件被触发,这两者都不应该与监听器有任何关系。因此,我认为依赖雄辩的生命周期事件是好的,因为它们所做的只是根据给定模型的变化来激发事件。模型当然是领域层的一部分,与模型相关的事件也可以用领域层中的监听器监听。只要您选择用来在应用的其余部分中促进领域知识的机制是直接模仿与领域相关的对应物,并且您用无处不在的语言命名您的事件和侦听器,您仍然可以实现领域驱动的设计。

持续领域事件

领域事件只有在保存到事件存储中时才有用,这样它们就可以作为特定域对象的一种历史记录。Event sourcing 则更进了一步,允许您重放从实体生命周期开始到 BBC 当前状态对事件采取的每一个动作。回放的事件直接表示实体的内部状态,以及在对象的整个生命周期中对此状态所做的任何更改。

将领域事件保存到事件存储中是必不可少的。如前所述,领域事件通常扩展一个抽象类,该类隐藏了通过某种类型的事件存储来处理持久性的逻辑。当在 Laravel 中实现领域事件时,有许多内置的契约(接口)和特征,我们可以在相应的模型上指定,这可以帮助我们更好地促进它们。它们包括以下内容:

  • Illuminate\Contracts\Broadcasting\ShouldBroadcast:使我们能够将事件推送到消息队列或事件总线。该契约要求在任何实现类上定义一个额外的方法,broadcastOn(),该方法应该返回事件将被调度到的通道。

  • Illuminate\Support\Facades\Broadcast\InteractsWithSockets:允许你通过 socket 连接实现广播事件(比如Pusher)。

  • 允许容易地序列化/反序列化雄辩模型。

  • Illuminate\Support\Arr\Queueable:包含将事件分派到队列的功能。

首先,让我们定义一个基类,所有事件都将从这个基类扩展。与更广泛使用的将事件持久化到事件存储的方法不同,这种方法包括具有将事件持久化到其中所需代码的父类,以便子类可以调用$this->save(),我们将使用 traits 来处理持久化任务,以便我们可以将这些细节隐藏在其他类易于访问和使用的地方。我们还将使用一个基类,但这只是为了标识的目的,除了格式化类名的 helper 方法之外,不包含任何东西。这样,应用的其余部分可以使用这个类作为类型提示来指定某种类型的领域事件。清单 13-6 是一个非常简单的抽象类,所有的领域事件都将从它扩展而来。

<?php
namespace App\Common\Domain\Events;
abstract class DomainEvent
{
     /** The model which the event corresponds to */
     public Model $entity;

     /** The user that initiated the event */
     public User $user;

     /**
     * Returns the result of string replace of '_' to '.'.
     * @return string
     */
     public function getName(): string
     {
          return str_replace('_', '.', snake_case((new
                \ReflectionClass($this))->getShortName();
     }
}

Listing 13-6Abstract Domain Event Class

抽象类上的字段都是公共的,并且只表示被认为是领域事件所需的两件事情:被持久化的实体和发起事件的用户。此外,还有一个getName()方法,它格式化事件的名称,以便在我们收集事件时用作标识符。具体来说,我们将按名称字段分组,以便比解码实体(也称为事件体)更快地查找,它将在数据库中进行 JSON 编码。在对 JSON 进行任何查询之前,我们必须对其进行解码,并从中提取 ID,这太麻烦了,根本不用担心。相反,我们依靠抽象类来保存实体,然后将ID字段作为公共成员变量添加到类中。

稍微提前考虑一下,因为我们不打算把持久化机制放在基类内部,所以我们需要决定把它放在哪里。我建议把这样的东西扔进事件可以用来自救的特征中(没有双关的意思)。我们可以依靠一个 Laravel 作业来保存实际的保存逻辑。在这里利用一个特征只是将持久性合并到事件中的一种简单方法,并且可以被任何其他需要它的类或组件重用。我们拿出存储事件的功能这一事实是分离关注点的逻辑方法。以清单 13-7 为例。

<?php

namespace App\Common\Domain\Traits;

use App\Common\Jobs\SaveDomainEvent;
use App\Common\Domain\Events\DomainEvent;

trait Saveable
{
     public function save()
     {

          dispatch(new SaveDomainEvent(DomainEvent $this));
     }
}

Listing 13-7Trait Used to Persist Events to the Database

清单 13-7 中的特性相当简单:每当对它调用save()方法时,它将分派一个SaveDomainEvent任务来处理持久性功能。这个特征唯一有趣的部分是,我将$this作为参数传递,不管这个特征用在哪个类中,它都将结束。为了强调这个特征应该只在事件类中使用,我们在SaveDomainEvent作业中键入 hintDomainEvent类,接下来您将会看到。

清单 13-8 向您展示了一个当提供者和患者在系统中被链接时被触发的示例事件(提供者是患者的新主治医师)。

<?php
namespace App\Common\Domain\Events;
use App\Common\Jobs\SaveDomainEvent;
use App\Jobs\Job;
use App\Common\Traits\Saveable;
use App\Common\Infrastructure\Repositories\DomainEventRepository;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\SerializesModels;

class SaveDomainEvent extends Job implements ShouldQueue
{
     use InteractsWithQueue, Saveable;

     private DomainEvent $event;

     private Model $entity;

     public function __construct(DomainEvent $event)
     {
          $this->event = $event;

          if (property_exists($event, 'entity')) {
               $this->entity = $this->event->entity;
          }
     }

     public function handle(DomainEventRepository $eventRepository)
     {
     return $eventRepository->createFromData([
          'event_id' => Uuid::uuid4()->toString(),
          'event_body' => json_encode(array_filter(array_except(
               get_object_vars($this->event),['entity'])
          )),
          'eventable_type' => $this->entity ?
               get_class($this->entity) : null,
          'eventable_id' => $this->entity ?
               $this->entity->getKey() : null,
          'event_type' => $this->event->getName(),
          'user_id' => $this->event->user ?
               $this->event->user->getKey() : null
     ]);
}
}

Listing 13-8Job That Houses the Functionality Used to Persist Domain Events

从上一个作业实现的接口中,我们可以合理地推断出这个作业支持一个可排队的消息传递系统,并且包含用于广播事件的通道的名称。构造函数接受领域事件的实例,它是通过事件存储保存的。handle()方法接受任何必须注入才能完成工作的依赖项,在前面的例子中,它包括设置将被持久化到数据库中的events数组。它对应用生成的密钥使用标准的 UUID 格式来区分任何其他事件。

出于我们的目的,事件存储是一个关系数据库表(很可能是 MySQL)。然而,也有其他的选择,比如 Redis、Elasticsearch、Firebase 和其他一些。无论您的应用最终使用哪个事件存储,都要记住这个事实:事件存储(表、索引等。)与其他表(或索引等)相比会变得相当大。作为一名开发人员,这意味着您应该确保,无论您将事件数据存储在何处或如何存储,它都应该与应用的其他数据隔离开来。

Tip

存储领域事件的最佳位置是在一个单独的服务器实例上(或者通过其他方式将领域事件数据与应用的其他部分隔离开来)。领域事件表会变得很大,尤其是粗粒度的事件系统,最终会导致严重的速度变慢,甚至导致应用或数据库服务器故障。为您和您的团队省去日后解决此问题的麻烦。

包含事件数据的数组具有以下字段:

  • event_id:UUID 格式的domain_events主键。

  • eventable_id:对应于Event类上指定的$entity属性的 ID,也是eventable_type字段描述的模型内的 ID。

  • event_body:存储在表中的实际事件数据,用于描述除事件主题(实体)之外的数据。我们可以这样做,因为我们将所有属性都设置为public

  • eventable_type:属于实体类的多态关系键。

  • event_type:事件的名称。

  • user_id:发起事件的用户的 ID。

分解流程

为了让您更好地了解这种机制实际上是如何工作的,清单 13-9 展示了一个来自domain_events表的示例事件,它将是封装在DomainEventRepository::createFromData()方法中的机制的结果。这是一种更好的设计领域事件的方法,可以和之前的SaveDomainEvent类一起工作。它是在一个名为ClaimWasSubmitted的事件之后建模的,并且在应用中验证并提交索赔时发出。

<?php
namespace Claim\Submission\Domain\Events;

use App\Events\Event;
use App\Common\Domain\Events\SaveDomainEvent;
use App\Common\Domain\Traits\Saveable;
use Claim\Submission\Domain\Models\Claim;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
class ClaimWasSubmitted extends DomainEvent implements ShouldBroadcast
{
    use SerializesModels, Saveable;

    public Claim $claim;

    public User $user;

    /**
     * Create Event
     */
    public function __construct(Claim $claim): void
    {
        $this->claim = $claim;
        $this->user = $user;
        $this->entity = $claim;
    }

    /**
     * Broadcast on channel 'domain_events'
     */
    public function broadcastOn()
    {
        return ['domain_events'];
    }
}

Listing 13-9An Event Fired Whenever There Is a New Claim Submission

在清单 13-9 中,我们定义了一个相当简单的类,带有一些公共属性和一个定义好的广播通道来与其他服务或应用通信。我们有一个已定义的实体属性,它被设置为事件的主题,在本例中是一个声明,所以它被设置为$claim。public $user属性是提交声明的用户,是事件本身的基础,但不是事件的直接主体。如果有一个名为UserUpdatedPassword的事件被触发,那么$entity属性将被设置为$user。一个NewProviderCreated事件将把$provider属性作为$entity。你明白了。

这里要带走的主要东西是如何存储事件。由于抽象类DomainEvent,我们公开的任何属性都将被解析、JSON 编码并存储在数据库的event_body字段中。事件的主题($entity属性)具有保存在eventable_type字段中的类名和保存在$eventable_id字段中的实体的相应 ID。domain_events表中的 ID 字段都是整数(大整数),除了event_id是表的主键,是一个带格式化 UUID 的字符串。表 13-2 显示了该表中的一行可能的样子。

表 13-2

domain_events 表中的示例数据库行

|

字段名

|

字段值

|
| --- | --- |
| Id | ED7BA470-8E54-465E-825C-99712043E01C |
| event_body | {"id":91977,"fname":"Jesse","lname":"Griffin","role_id":3,"address":"3230 Sweetwater Springs Blvd.","city":"Spring Valley","zip":"91977", "state":"CA","created_at":"2020-01-20 16:20:00", "updated_at":"2020-01-20 16:20:20"} |
| eventable_type | Claim\Submission\Domain\Models\Claim |
| eventable_id | 9140202 |
| user_id | 426 |
| event_type | Claim\Submission\Domain\Events\ClaimWasSubmitted |

在表 13-2 中,您可以看到清单 13-8 中ClaimWasSubmitted事件描述的事件的持久化结果。正如承诺的那样,event_type类描述了事件的主题,一个声明,以及存储在eventable_id字段中的该类型的相应 ID。event_body类包含与事件相关的额外数据(由事件类中的公共属性定义),在前面的例子中,它是发起事件的用户的json_encoded字符串。为了描述哪个事件实际上创建了该数据,我们可以查看event_type字段,在本例中,它是清单 13-8 中的类。这似乎是多余的,但是user_id字段包含同一个用户的user_id值;然而,event_type字段引用的并不总是一个User模型,但是总有一个用户与事件相关联。

DTOs(技术数据中心)

数据传输对象 (DTO)是一个简单的对象,通常用 getters 和 setters 访问和设置公共属性。他们的主要目标只是为非结构化数据提供结构。非结构化数据包括多维数组之类的东西,如下所示:

$data = [
        'id' => 91977,
        'fname' => 'Jesse',
        'lname' => 'Griffin',
        'role_id' => 3,
        'address' => '3230 Sweetwater Springs Blvd.',
        'city' => 'Spring Valley',
        'zip' => '91977',
        'state' => 'CA',
        'created_at' => '2020-01-20 16:20:00',
        'updated_at' => '2020-01-20 16:20:20'
];

这个数组中有一组标准的数据,可以用作某个方法或函数的参数。这很好,而且会起作用,但是在域驱动的设计中并不理想,因为如果不执行print_r()或将其转储,就无法一眼看出数组中有什么。

public function doSomeThingCool(array $data)
{
     $this->data = $data;

     // OR
     foreach ($data as $d) {
          //what now?
     }
}

如果不遍历数组或使用array_keys或类似的东西,你就无法从逻辑上推断出它的内容。定义这种数据结构的一种更简单、更明确的方法是将它转换成 DTO,这可以在清单 13-10 中找到。

class Data
{
     private $id;
     private $fname;
     private $lname;
     private $role_id;
     private $address;
     private $city;
     private $state;
     private $zip;
     private $created_at;
     private $updated_at;

     public function getId(): int
     {
          return $this->id;
     }
     public function setId(int $id): self
     {
          $this->id = $id;
          return $this;
     }

     public function getFname(): string
     {
          return $this->fname;
     }
public function setFname($fname): self
     {
          $this->fname = $fname;
          return $this;
     }
     /* remaining getters and setters */
}

Listing 13-10Example DTO Created in Place of an Unstructured Array

这个 DTO 非常简单,除了作为一个保存数据的容器以及访问和修改数据的方法之外,没有任何其他功能,这两个功能都是我们通过雄辩的Model类免费获得的。模型和 DTO 之间的唯一区别是,模型直接与数据库表相关联,因为 concertive 使用活动记录模式。

Spatie ( https://spatie.be/open-source )提供了一个包,名为用于 Laravel ( https://github.com/spatie/data-transfer-object )的数据传输对象。它使创建 dto 变得容易;然而,这也是有代价的:当涉及到对象和属性定义时,您在便利性上获得的东西,您在明确性上放弃了。清单 13-11 展示了如何用这个包创建一个 DTO 对象的例子。请注意,清单 13-11 使它比清单 13-10 中的 DTO 类更漂亮,因为您不必编写所有那些枯燥冗长的 getter 和 setter 方法。

<?php
//some namespace
use Spatie\DataTransferObject;

class Data extends DataTransferObject
{
    public $id;
    public $fname;
    public $lname;
    public $role_id;
    public $address;
    public $city;
    public $state;
    public $zip;
    public $created_at;
    public $updated_at;
}

Listing 13-11A DTO Extending Spatie’s Abstract DataTransferObject Class

清单 13-11 中的例子让您能够设置和获取在子类上定义的每个公共属性,就好像它们每个都有一个 getter 方法和一个 setter 方法。这个类的一个实例可以这样构造:

$data = new Data([
        'id' => 91977,
        'fname' => 'Jesse',
        'lname' => 'Griffin',
        'role_id' => 3,
        'address' => '3230 Sweetwater Springs Blvd.',
        'city' => 'Spring Valley',
        'zip' => '91977',
        'state' => 'CA',
        'created_at' => '2020-01-20 16:20:00',
        'updated_at' => '2020-01-20 16:20:20'
]);

然后,您可以像这样使用数据:

echo $data->fname;
echo $data->role_id;
echo %data->state;
...

您还可以向类中添加静态创建方法,从而使实例化变得快速而容易。

class Data
{
     // ...
     public static function fromRequest(Request $request)
     {
          return new self([
               'fname' => $request->fname,
                'lname' => $request->lname,
                'State' => $request->state,
               // ...
          ]);
     }
}

还支持 dto 集合,当您处理多个 dto 时,这为您提供了创建多个 dto 的额外能力。有关在您自己的代码中使用此包的更多信息和示例,请参见联机文档。

结论

领域事件是任何长期存在且分布良好的应用的必备部分,也是任何现代 web 应用的核心部分。它们还允许有界上下文之间的通信,并且是分布式架构和微服务的关键。在 Laravel 中,事件是在代码库中本地处理的,并且可以使用event() helper 方法从代码中的任何位置触发。这使得管理事件和向任何新的或遗留的系统添加事件变得更加容易,因为您不必担心传递事件调度程序(或者使用依赖关系来注入单独的事件存储库)。在 DDL 中,我们可以建立一个抽象类,就像我们在本章前面所做的那样,并使用一个独立的 Laravel 作业作为保存机制。这为事件创建了一个通用的基础,并且代码可重用于您以后可能需要添加到系统中的任何事件。领域事件的持久性使得审计应用的数据或者跟踪给定模型在其生命周期中的任何和所有更改成为可能。dto 有助于保持数据的结构化,并且可以通过 Spatie 包简化,我建议您尝试一下。

十四、仓库

Laravel 是一个相当独特的框架,因为它抽象出了一个新应用通常需要的许多细节,如路由、事件管理和数据库访问,这样我们就可以专注于更重要的任务——这些任务实际上使我们的应用从同类应用中脱颖而出。同样的原则存在于 DDD,只是它以不同的方式出现。在 DDD,模式和构建块是几乎任何系统的基础组件,并且都集中在核心领域本身,而不是陷入(有时)铺天盖地的细节海洋中。拉勒维尔和 DDD 之间的这种相似性,在某种程度上,是让两者的结合如此强大的原因:他们都着手抽象掉已经建立、重建和改造了无数次的单调、重复的任务,以便让你专注于开发该领域。

在这一章中,我们将探索一个这样的抽象,它被证明是一个有用的模式,它将管理如何存储和检索域对象的责任分离出来,并提供一个结构,允许客户机处理相同类型对象的集合。有两种主要类型的存储库(至少在 web 开发和 DDL 的环境中)。

  • 基于集合的存储库:作为一种处理同一对象集合的方法的存储库

  • 基于查询的存储库:处理与特定领域对象相关的复杂定制查询的存储库

我们实现存储库的方式是使用一种与编程中常见的“正常”方式有些不同的方法。这也是 DDD 和 DDL 的原理有些不同的地方,因为在 Laravel 中,有这个集合的概念。在拉勒维尔的收藏就像是服用了类固醇的数组。有两种类型的集合,常规 Laravel 集合和雄辩集合,后者提供了一些与应用中的雄辩模型相关的附加方法和机制。

在这个讨论中值得一提的原因是,雄辩的集合基本上可以替代您真正需要在模型上运行的大多数SELECT查询。提供了各种功能的预构建方法,允许您在每个扩展父类Model的雄辩对象中使用 stock。集合提供了一种简洁优雅的方式来筛选从雄辩方法(和相应的数据库查询)返回的一堆相似的对象。唯一的区别是,当您使用从Model类继承的许多方法之一查询口才时,您会收到一个口才集合作为响应。这个集合将保存您从子级雄辩类上的数据库查询返回的结果,使得通过雄辩提供的开箱即用的模型类和数据库抽象来链接不同的方法来查询您的数据库变得容易。基本上没有必要为您的领域模型中的每个对象创建单独的Repository类,以便能够以干净统一的方式处理这些对象的集合——使用一个强大、流畅的接口。接下来,我们将更深入地讨论集合及其有用性,但是这里要指出的一个关键点是,我不提倡存储库处理任何这样的对象“集合”,因为没有必要——Laravel 为您提供了这一点。要利用这种能力,唯一需要做的是一个小的学习曲线,因为集合提供了流畅的界面,所以这个学习曲线不是很陡。

收集

集合比普通的 PHP 数组有很多好处,Laravel 提供了一个漂亮、干净、流畅的接口,允许对集合进行过滤、映射、创建、组合,以及其他各种各样的简单工作,这都要归功于Illuminate\Support\Collection类(或者是用于雄辩集合的Illuminate\Database\Eloquent\Collection类)。最棒的是,每个集合方法都会总是返回另一个集合对象。这使得集合中的链接成为处理对象列表的强大而动态的方法(这通常是通过为系统中的每个模型实现基于集合的存储库来完成的(听起来工作量很大,不是吗?).

雄辩集合真正伟大的地方是能够以如此简单、直接的方式遍历关系对象(或对象的集合)。您可能已经从本书的早期示例中注意到了这一点,但是这里有一种方法可以从索赔模型中收集所有单独的 CPT 代码,而不依赖于原始的 SQL 查询和 PHP 数组的迭代/遍历。例如,我们可以使用像each()这样的集合方法,通过查询 CPT 组合与 CPT 代码的关系,遍历返回的集合中的所有对象。要引用一个模型与另一个模型的任何特定关系,我们只需要引用我们在模型类中设置的方法的名称,而不需要实际调用该方法。这里有一个例子:

$claim = Claim::findOrFail($id);
$cptCodes = $claim->cptCodeCombo->cptCfodes;
$cptCodes->each(function($cptCode) {
     echo $cptCode->description;
});

粗体项目表示正在进行关系遍历。这使得遍历关系对象变得简单而强大。

使用这个界面查询关系也非常容易。如果我想在系统中获得给定患者的所有索赔,我可以做如下事情:

$claims = Claim::where('patient_id', $patientId);

如果我想遍历这些声明并打印出屏幕上显示的 CPT 组合,只需添加另一个收集方法。

$claims = Claim::where('patient_id', $patientId)
               ->each(function($claim) {
                    echo $claim->cptCodeCombo;
          });

作为另一个例子,如果我需要获得系统中每个 CPT 代码的中所有描述的列表(Collection),我可以对调用all()方法返回的集合使用map()方法(由于 Laravel 集合的链接能力),这将遍历集合中的所有对象,并从作为简单回调结果返回的内容中创建一个新的集合,该集合接受特定模型的集合的单个对象。

$descriptions = CptCode::all()->map(function($cptCode) {
     return $cptCode->description;
});

这段代码基本上是说,“给我一个系统中每个CptCode的所有描述的列表。”有许多收集方法可供你随意使用——太多了,无法在本书中描述。有关集合实例中所有可用方法的完整参考,请访问 https://laravel.com/docs/6.x/eloquent-collections

同样非常有用的是,您可以动态地对给定对象的任何遍历集合进行内联查询。对于一个更高级的示例,假设我正在为 FQHC 构建一个报告,其中包含将要支付但尚未支付给提供者的索赔金额,以确定与该患者在给定时间段内(例如,在过去四周内)的医疗需求相关的费用。我们需要系统中的所有声明,对于一个特定的病人,当前状态为PENDING_REVIEW(在前一章中,我们在声明状态机中将它构建到这个系统中)。然后,如果索赔发生在过去四周的给定日期范围内,我们将合计每项索赔的总估计金额。使用 Laravel 附带的 Carbon 包来处理日期范围,清单 14-1 显示了可能的样子。

<?php

use Carbon\Carbon;

$startDate = Carbon::parse('-4weeks')->toDateTimeString();
$endDate = Carbon::parse('today')->toDateTimeString();

$totalAmountOfPatientsClaims = Claim::where('patient_id', $patientId)
               ->where('claim.state', PendingReview::class)
               ->whereBetween('submitted_at', [$startDate, $endDate])
               ->pluck('estimated_claim_amount')
               ->get()
               ->sum();

Listing 14-1An Example of Chained Method Calls Using Laravel’s Collection Component

这一行代码说,“给我上个月提交的属于 ID 为patientId且处于PendingReview状态的患者的所有索赔;然后抓取每一个对应的estimated_amount值,相加,返回结果。”我们在一行代码中完成了收集报告所需的适当数据的任务!这是非常强大的。

Laravel 提供了许多其他方法,这些方法有助于过滤、排序、分页和其他遍历关系和关联的方式,减少它们,直到找到您想要的东西。有一种趋势是将超级特定的查询方法放在存储库中。唯一的问题是,随着应用的规模和复杂性的增长,存储库也将增长,直到您剩下的是不真正抽象任何东西,而仅仅充当 SQL 容器和/或特定对象查询的类。有一种更好的方法,通过使用存储库和规范模式的组合,使用一个Criterion对象来描述您到底在寻找什么。

例如,清单 14-1 中的代码可以放在一个名为ClaimRepository的仓库中,使用一个名为getPendingReviewClaimsForPatient()的方法;然而,看看我们刚刚做了什么。我们已经开始了一种趋势,将超级特定的方法放在存储库中,这种趋势将继续下去,直到我们有了长得离谱的复杂名称来表示不同标准的各种组合,这些标准是满足需要在给定的域模型上运行的特定查询的需求所必需的。如果这种趋势继续下去,将会有更多这种高度集中的方法,这些方法基本上将应用所依赖的 SQL 和过滤机制硬编码到一个Repository类中。由于存储库通常专注于单个实体或模型,因此它的存储库将包含与该实体相关的所有特定查询逻辑。这很容易失去控制,并导致以这种方式编写的典型存储库变得太大,因为需要所有特定的查询逻辑来完成应用的各种操作和关注点。

存储库提供的好处是它们允许数据映射层和领域层之间的清晰分离。它们基本上允许持久层的更加面向对象的视图。不要将它们与数据访问对象(Dao)相混淆,Dao 只是简单的数据存储容器,除了保存与系统中的单个实体相关的数据,并通过 getters 和 setters 提供对这些属性的访问。dto 是相似的。它们几乎是所有编程中最无聊的对象。

表 14-1 比较了一些功能的传统存储库实现,我们期望像 claim 应用这样的应用需要在某一点或另一点上实现这些功能,并将其与 Laravel 和雄辩术用于解决相同问题的方式进行比较。

表 14-1

Laravel 和雄辩的方面,它们取代了通常在典型存储库中找到的相应概念

|

功能/描述

|

知识库方法

|

laravel/雄辩的内联等价物

|
| --- | --- | --- |
| 通过 ID 查找记录。 | findBy($id)ofId($id) | Model::find($id) |
| 保存实体或模型的单个实例(UPDATECREATE)。 | $model = new Model();``$model->setField(``'field','value');``$repository->save(``$model) or saveAll( $model) | $model = new Model();``$model->field = $x;``$model->save(); |
| 按 ID 删除一条记录或按多个 ID 删除多条记录。 | $ids = []; //ids``foreach($ids as $id){``$repository->remove(``$id) or removeAll($ids) | $model = Model::find($id);``$model->delete();``Model::whereId('id', $ids)``->get()->each(function($model) { $model->delete(); }) |
| 筛选数据或执行聚合计算,可能作为某些计算的一部分示例:创建一个属于给定供应商的患者列表,这些供应商不再有资格获得 Medi-Cal 福利。 | 调用一个超级特定的方法,只传入一个 ID 和一个排序子句:$result = $claimRepository->``findAllIneligible``PatientsFromListOf``Providers($providers)之前调用的实际存储库方法要么构造一个原始查询,要么调用多个子例程来推断所需的数据。 | 通过相应模型上的 facade 执行内联操作,然后通过传入返回真值测试结果的闭包来链接过滤器:$result = Claims::where``('patient_id', $patientId)->``whereIn('provider_id', $providerIds)->``get()->filter(``function($claim) {``return $claim->patient->``isEligible();``}) |
| 查找去年提交的给定患者(基于每个提供者)的所有 CPT 代码。 | $providers = $claimRepository->``findAllProviderClaimsForPatientWithinLast``Year($patientId)这里也是一样:要么构造原始 SQL,要么使用查询构建器将数据连接在一起,或者调用多个方法:$claims = $claimRepository ->findBy('patient_id'``, $patientId)``->addWhere('submitted``_at', 'BETWEEN",``[Carbon::parse(``"today -1 year")->``toDateTimeString(),``Carbon::parse(``"today")->``toDateTimeString()])``->getResults();``foreach ($claims as $claim) { $cptCodeCombos[] = $cptComboRepository``->find($claim->``getId())``->getResults()); }``foreach ($cptCodeCombos as $cptCode) {``$cptCodeRepository``->find($cptCode``->getId())``->getResults(); } | 和前面一样:用雄辩的牛逼特性来描述你想要的数据;然后,它不是显式地告诉它如何检索数据,而是找出脏活累活,将结果集以一个很好的集合的形式返回给你,这个集合可以被进一步处理、简化、映射,或者可以使用集合做其他任何事情:Claim::where("patient_id"``,$patientId)->``whereBetween(``"submitted_at",[``Carbon::parse("today``- 1 year")-> toDateTimeString(),``Carbon::parse``("today")->``toDateTimeString()``])->get()->cptCodeCombo ->cptCodes; |
| 查找由分布在不同模型关系中的查询结果组成的聚合数据,通过迭代产生某种查询密集型机制。 | 这个小盒子里放不下太多的代码,但是随着时间的推移,解决方案将包括在存储库上调用越来越窄的方法调用,最终变得太细粒度,无法在最初建立的上下文之外重用或使用。 | 使用 Laravel 的查询构建器创建一个复杂的查询,它抽象了查询的细节,允许您只关注重要的内容(参数的值)。 |

表 14-1 显示了传统存储库提供的许多好处,这些好处来自于附加越来越多的具体的粒度方法,这些方法将数据向下钻取到精确的格式或结构,以满足应用不断增长的复杂性。最终,这些存储库将变得完全不可重用,并且根本不是适应应用所需的大量数据库数据的最佳解决方案,因为在某个时候,您可能会以诸如GetCountOfProvidersWithRegisteredPatientsWithinLastYear()findMostOftenUsedCptCodesForPracticeWithinDateRange($sdate, $edate)之类的方法名结束。

另一方面,口才在它的Collection类中有许多通用的、可重用的方法,通常可以减少手动提取所需数据和创建长方法名的麻烦,长方法名将使你的Repository类变得丑陋不堪(更不用说在一行中抛出一堆林挺错误/关于过多字符的警告),只不过是一个单行的、通常是内联的解决方案,它使你能够用普通的英语操作来描述你的结果集,这些操作可以方便地放在定义良好的 fluent 接口后面。它能够通过抽象出通常是手动构建的、存储库中的原始查询的内部工作来做到这一点。

存储库还做得很好的是对使用它的代码隐藏底层持久层。这样做的一个常见用途是允许多个持久层实现一个单一的存储库接口,如图 14-1 所示。

img/488324_1_En_14_Chapter/488324_1_En_14_Fig1_HTML.jpg

图 14-1

同类型模型的一些不同持久化方法的例子

在图 14-1 中,UML 图向我们展示了一个虚构的ClaimRepositoryInterface契约的三个实现,所有的实现都集中在表达接口中每个需要的方法在它们自己的持久层的上下文中。当涉及到存储库时,这是一种被广泛教授的方法,并且在一些特定的用例中可能是需要的;然而,在 web 开发的大部分时间里,这通常是多余的。如果您正在使用 Laravel 和 concertive 来管理您的数据库对象,那么您无论如何都可以免费获得前面的类中封装的大多数功能。如果您确实想实现对象的 Redis 缓存以实现更快的查找和更好的用户体验,它很可能不会使用与实际保存对象的机制相同的方法。当一个对象被持久化时,需要对它做各种事情,比如为数据库(我假设是关系数据库)中的记录生成下一个可用的 ID,或者确保没有违反外键,但是这个过程在域对象的生命周期中只发生一次,即在它被创建的时候。

在 Redis 中,不需要做任何事情,因为它是一个简单的键值对象存储。我们实际上不需要ClaimRepositoryInterface中的一两个方法,也许是save()claimOfId($id),来实现 Redis 缓存机制。事实上,这可能更适合基础设施服务,也许是以 Laravel 工作的形式(我们将在第 X 章中探讨)。我们需要的唯一真正的逻辑将发生在之后,在*我们已经向关系数据库添加了一个新的持久化记录。一个观察者或者有说服力的Model事件将会非常有用,因为我们可以简单地监听(或者观察)在Claim模型上的creating事件,只要逻辑简单地获取新模型的数据库表示,并将其改编(或者翻译)成 Redis 可以理解的东西。这将是用于在 Redis 中存储数据的save()方法,该方法将由一个HSET命令组成。为 Redis 数据库实现一个完整的Repository子类是没有必要的,我们最终会为我们不会使用的方法提供定义(方法体为空),这样类在技术上就能遵守接口所要求的契约。

也就是说,在由 Laravel 和口才支持的 web 应用的上下文中,我更好地使用了存储库。

ORMs 与原始 SQL

起初,这似乎是一个不受欢迎的限制,限制了您对由雄辩的 ORM 支持的数据能做什么和不能做什么,但在我看来,情况并非如此。对关系数据库执行大量的原始 SQL 查询,并将这些查询放入一个以Repository结尾的类中,这不是一个好主意。首先,以这种方式创建的存储库将缺乏存储库模式想要提供的任何真正的好处,反而变得更像一个“逻辑盒”——一个简单的、只保存东西的类。另一方面,很容易使您自己和您的应用暴露在诸如 SQL 注入、会话劫持、SQL 数据挖掘、模式映射和其他恶意攻击之类的攻击之下,因为您忘记了在允许对应用中运行的每个查询执行查询之前手动检查用户的输入并确定它没有恶意。使用 ORM 而不是原始 SQL 的第三个原因是允许您的应用自由地将系统中的各种模型和实体表示为对象而不是数据。面向对象编程的目标是允许以多态和实用的方式抽象和扩展类或接口的对象的行为,从而使代码本身变得丰富。接下来我们讨论富域模型。

即使您不想使用完整 ORM 的特性和功能,您仍然可以使用 Laravel 的 plain DB facade(至少)为您的基础设施添加足够的安全措施,以防止对您的数据的恶意攻击,就像我前面列出的那样。参见清单 14-2 了解如何在将用户输入发送到数据库之前对其进行净化;为了防止 SQL 注入袭击,你可以这样做(不需要雄辩的帮助)。

<?php

//somewhere in an infrastructure or domain service class definition

protected $argument;

public function __construct(string $argument)
{
     $this->argument = $argument;
}

protected function doSomeStuff()
{
     $argument = $this->argument;
     $results = DB::select(
          DB::raw(
               "SELECT * FROM some_table WHERE some_col = :argument"
          ),[ 'argument' => $argument])
     );
}

Listing 14-2Securing Queries

富域与贫域

虽然我之前在书中提到过,但是值得记住什么是富领域模型,以及为什么它应该是任何领域模型的最终目标。与贫血的域模型相反,贫血的域模型最终使组件和模型变成仅仅是带有一堆已定义的 getters 和 setters 的数据容器,富域模型以清晰优雅的方式描述域,真实地表示每个模型在域中表达的概念和行为,并明确地陈述其目的、关系以及对系统其余部分的影响, 以及根据功能与应用中存在的已建立的通信方法的接口方式(可能通过路由定义或域服务接口)来定义访问功能的可能方式。

当我们定义了一个“丰富的”领域模型时,我们就成功地创建了一种以软件的形式表达给定领域的方法,描述了底层领域的本质。行为被清晰地定义,关注点被恰当地分离,粒度的正确使用已经被应用到域模型对象中,因此它们可以充分而优雅地表示域。当然,由于对已定义的通用语言的依赖(在与领域专家和开发人员进行了一轮又一轮的交流之后,它甚至应该作为如何构建和分离您正在构建的系统的体系结构的基本指南),您总是可以将此作为一种“规则”来评论您给定领域的模型的结构、设计和实现。始终参考 UL 中定义的概念和术语,作为检查您的实现是否真正遵循了领域核心方面的要求的一种方式。

规范

存储库是公认的 DDD 模式,它为应用提供了一种方法,可以将单个模型(实体)中涉及的域逻辑与管理关系数据库时存在的过滤、查询和遍历问题分离开来,并以可重用的、面向对象的代码的形式提供了一种强大的抽象,该代码以一种结构化的、强大的方式检索数据并返回结果,可以对扩展开放,对修改关闭。

有了 Laravel,我们可以免费获得所有的集合方法,这些方法可以(在很大程度上)取代软件开发领域常见的任何传统的面向集合的存储库。这并不是说存储库本身是无用的,因为当与规范模式结合使用时,它们可以服务于更有益的目的。在 Laravel 应用中,将存储库的概念与规范模式相结合,比将存储库用于管理给定对象类型的集合或创建许多长名称的查询(这些查询只是返回特定于满足单个数据请求(或格式)所需的不同粒度的数据库记录)更合适。

相反,如果我们可以通过简单地描述我们想要的结果来指定我们需要收集的数据,这将会非常好,这些结果会自动转换为正确的查询并在数据库上执行,在一个漂亮的封装对象中返回结果数据集,然后我们可以在不需要转换或修改它的情况下传递它。如果我们能够以一种“堆叠”的方式重用这些组件,以便我们能够简单地添加额外的约束作为显式和流畅接口的额外方法,那将是非常好的。在您的应用中完成这些令人惊叹的事情的一个方法是利用Collection组件提供的功能和易用性,结合使用雄辩模型的一个受人喜爱的特性,这在 Laravel/雄辩界被称为模型范围。模型范围可以用来连接提供给该模型的规范,以允许通过存储库的接口执行该逻辑。如果这一切听起来令人困惑,我们接下来将更详细地讨论它。

定义的规格

让我们快速回顾一下我们希望我们的应用能够做什么。我们不是直接使用存储库来查询数据库,而是使用许多特定的、细粒度的方法来封装查询数据库的逻辑,然后遍历这些结果来查找特定的数据,我们希望能够以这样一种方式来描述我们的数据,即通过接受规范接口的存储库来查询模型,然后使用实现该接口的子类来执行我们需要运行的各种查询和 SQL。我们仍将使用存储库,但是以一种比软件开发中更优雅、更少静态的方式。

一个规范基本上是一个 criterion 对象——一个定义的谓词检查针对单个域模型类(类型)运行,甚至跨多个模型运行,这些模型封装了在指定一些特定数据集时涉及的确切标准,如果我们需要相同的数据集,例如,在我们寻找相同的结构、格式或数据集,但需要该数据集反映来自数据库的当前数据时,允许它们可重用,也许这样它可以刷新 UI 并向用户显示最新的信息。谓词只是一些返回布尔响应的函数。存储库要做的是接受一个规范接口的实例,然后根据规范谓词的结果查询数据库,处理持久层细节并执行规范描述的操作。它可能会将规范翻译成 SQL 或特定的 ORM 查询,甚至在返回结果之前遍历内存中的对象集合,但重要的是要注意存储库是如何被使用的。

为什么要使用规格?

通过将业务规则封装在一个类中,并提供一个易于使用的 API 来向应用公开该类的行为,我们看到了许多好处。

  • 它提高了代码的可重用性,因为当我们需要相同的数据时,我们可以使用相同的规范。

  • 应用不需要知道业务规则是如何实施的,因为它包含在规范对象中。

  • 如果业务规则本身发生变化,您只需在一个地方进行修改。

  • 它允许“堆叠”多个不同的规范来创建更加定制的复杂查询。

前面列表中的第四项是最重要的。我们希望能够一起使用两个或更多的规范,方法是将它们传递给一个存储库方法,并接收一个满足所有规范的结果集合。

规范和存储库

假设我们想要定义一个规范来查找在给定的日期范围内提交给应用的所有索赔,并且这些索赔的状态也是 Reviewer Approved。图 14-2 显示了一种可能的解决方案。

img/488324_1_En_14_Chapter/488324_1_En_14_Fig2_HTML.jpg

图 14-2

以规范模式的实现为特色的存储库

Note

这里描述规范的例子不应该用于日期范围查询,因为它们最好在查询实际运行时处理,以避免多次命中数据库。这里的例子是为了演示的目的,但是我们将在后面的“真实世界”场景中解决这个问题——这样你就不会被晾在一边。

Repository方法query()接受一个ClaimSpecificationInterface接口的实例。LatestClaimSpecification是实现这个接口的类,适合在Claim模型上操作。该接口只需要一个方法specifies(),它接受一个Claim对象作为参数并返回一个布尔值,该值作为谓词检查来决定传入的对象(在本例中是一个声明)是否真正满足规范中的谓词约束。这个特定的规范包含确定索赔的提交日期是否在给定范围内的逻辑。请参见清单 14-3 和 14-4 中的示例,了解代码如何寻找先前的架构。

<?php
namespace Claim\Submission\Infrastructure\Repositories;

use Claim\Submission\Domain\Contracts\ClaimRepositoryInterface;
use Claim\Submission\Domain\Contracts\ClaimSpecificationInterface;

class ClaimRepository implements ClaimRepositoryInterface
{
     public function query(ClaimSpecificationInterface $specification)
     {
          return Claim::get()->filter(
               function (Claim $claim) use ($specification) {
                    return $specification->specifies($claim);
          }
     }
}

Listing 14-4The Repository for Claims, Including the query() Method to Support Specifications, but with a Problem

<?php

namespace Claim\Submission\Domain\Contracts;
use Claim\Submission\Domain\Models\Claim;
use ClaimSpecificationInterface;

interface ClaimRepositoryInterface
{
    public function query(ClaimSpecificationInterface $specification);
}

Listing 14-3The Interface for the Repository Class

在清单 14-4 中,我们在我们的存储库中定义了query()方法,该方法接受特定于声明的规范,并通过过滤系统中的所有声明,在每个 Claim模型上执行包含在规范中的谓词检查,直到它过滤掉任何不符合规范设置的需求。您能发现前面代码的问题吗?显然,如果索赔表中有超过一百万或更多的记录,前面的代码必须筛选所有记录才能找到符合规范的记录,这并不理想。随着声明表的增长,通过过滤器运行所有声明对性能的影响将会很明显。

尽管围绕这个问题的可能解决方案因情况而异,但对系统影响较小的一种方法是,在通过规范中的谓词检查之前,限制实际过滤的记录数量。我们将在后面更深入地讨论这一点。

假设我们希望在系统中找到在某个日期范围内提交的所有索赔。我们使用雄辩,使用类似清单 14-5 中的代码的内嵌语句,构建了一个可以为我们做这件事的东西。

<?php

$claimsSubmittedLastYear = Claim::whereBetween(
     "submitted_at", [
     Carbon::parse("today-1 year")->toDateTimeString(),
     Carbon::parse("today")->toDateTimeString()
   ]
)->get();

Listing 14-5Sample Inline Mechanism to Retrieve Claims Submitted Within the Last Year

尽管这段代码在实际运行方面非常好,但它并不是最可重用的逻辑,因为“内联”做事通常等同于一次性的、特定的功能,这很方便,因为我们可以执行它并立即获得结果,而不会打破封装的界限。

更好的方法

实现这一点的更好方法是创建一个可以运行的谓词函数,该函数将返回一个布尔值,然后将它提供给存储库,在适当的持久层中处理该逻辑。这样,只要简单地引用包含谓词的规范,就可以在任何需要的地方重用逻辑。清单 14-6 是图 14-2 中其余类的代码。

<?php

namespace Claim\Submission\Domain\Contracts;

use Claim\Submission\Domain\Models\Claim;

interface ClaimSpecificationInterface
{
     public function specifies(Claim $claim);
}

Listing 14-6Specification Interface for Claim Specifications

因为我们希望我们的规范是可重用的,所以我们希望将每个规范限制为一个谓词检查。在这个例子中,我们需要完成两项任务:选择从给定日期开始提交的记录,并按照状态过滤这些记录。因此,我们应该制定两个单独的规范:一个用于日期范围,一个用于状态。清单 14-7 显示了用于查找最新声明的代码的一个更可重用的版本,它由一个实现该规范接口的类组成。

<?php
namespace Claim\Submission\Infrastructure\Specifications;

use Claim\Submission\Domain\Contracts\ClaimSpecificationInterface;

class LatestClaimSpecification implements ClaimSpecificationInterface
{
     private $since;

     public function __construct(\DateTimeImmutable $since)
     {
          $this->since = $since;
     }

     public function specifies(Claim $claim)
     {
          return $claim->submitted_at > $this->since;
     }
}

Listing 14-7The Concrete Claim Specification Class, Containing the Logic to Determine If a Claim Has Been Submitted Within a Given Range

清单 14-7 中的规范意味着接收一个参数$since,它是DateTimeImmutable的一个实例,因为在这个上下文中我们使用$since日期只是为了进行检查,所以不需要修改。

清单 14-8 展示了如何在一个真实的项目中使用前面的代码。

<?php
use Claim\Submission\Infrastructure\Repositories\ClaimRepository;
$claimRepository = new ClaimRepository();

$latestClaims = $claimRepository->query(
     new LatestClaimSpecification(
          new \DateTimeImmutable('-30 days')
     )
);

Listing 14-8The Client Code for the Previous Implementation

清单 14-8 中的代码将在ClaimRepository的查询方法中执行规范中定义的实际操作;这反过来执行声明集合的实际过滤,为每个声明返回一个布尔值,这是我们提供给LatestClaimSpeceification的构造函数的值(在本例中,正好是 30 天前)。可以对任何其他谓词检查重复这个过程,以创建特定的声明集合。

查询和性能

这种实现仍然有一个问题:如果数据库中有该模型的大量记录,可能会使系统运行缓慢。按照我们设置的方式,Claim facade 通过get()方法获取所有记录,该方法在对它们进行操作之前将数据库中的每个声明加载到内存中。对于大型数据集来说,这可能需要很多时间。

对于这个问题的解决方案,您最初的想法可能是放弃整个规范的想法,简单地采用第一个有效的方法,比如清单 14-5 中的内联方法。为了使用规范实现的好处,我建议您抵制这种冲动(我在“为什么使用规范?”).当然,如果您正在为一家初创公司工作,并且想要快速获得一些东西,您可能更喜欢走更短、更直接的路线,并且简单地将不同的收集方法链接到模型外观的末尾。然而,这种方法以重复代码的形式带来了技术债务,在应用的其他地方重复了规范中提供的逻辑,并且由于缺乏分离的关注点和没有明确定义概念而混淆了领域模型。根据项目的不同,对于您的特定需求或情况,这可能是可以接受的,也可能是不可以接受的,但是,在我看来,当您有时间第一次以正确的方式做某事时,为什么不呢?就时间、金钱和资源而言,科技债务可能会变成一头相当昂贵的野兽。

继续重构代码

因为我们知道没有完美的软件,好的软件只有在对代码和领域模型本身进行多次重构之后才会出现,所以让我们再看一下前面的例子。让我们考虑另一种可能的方法来解决前面描述的问题。目前,通过以规范类的形式提供选择标准,代码被设置为在存储库中运行查询。问题出在我实现存储库的方式上。更具体地说,雄辩的get()方法在这里使用不当,因为这是在过滤数据之前实际运行查询的方法。我们需要一种方法在迭代之前限制返回结果的数量。我们可以用类似于submittedWithinRange($startDate, $endDate)的附加存储库方法来实现这一点。清单 14-9 展示了如何将结果集限制在一个更小的范围内。

<?php
//ClaimRepository
public function submittedWithinRange($startDate, $endDate)
{
     Return Claim::whereBetween(
          "submitted_at", [
               Carbon::parse("today-1 year")->toDateTimeString(),
               Carbon::parse("today")->toDateTimeString()
          ]
     );
}

Listing 14-9Adding a Method to the Repository That Can Be Extended Further

前面代码中的主要区别在于,我们没有使用get()方法返回查询结果,而是返回了一个类型为QueryBuilder,的对象,这是一个已经配置但尚未运行的中间对象。这里的想法是在调用get()之后继续跟踪约束、过滤器或其他与集合相关的方法,它总是返回一个有说服力的Collection对象。这是值得重复的

Tip

通过直接外观使用的雄辩模型(看起来是静态方法,但不是),甚至任何通过查询构建器运行的查询,在调用get()后总是会返回雄辩集合。

这是一个强大的功能,因为它允许我们在应用中的任何给定点(或多个点)将给定模型的所有查询条件(以及该模型可能具有的任何关系)以递增的方式链接在一起,然后(当准备就绪时)通过get()运行完全构建的查询,并将结果数据集作为集合返回,然后可以对其进行处理、排序或过滤,所有这些都在一个单独的代码中完成。

查询生成器的高级用法

例如,假设我们正在编译一个必须每月运行一次的报告,该报告(出于某种原因)提供了一个活跃用户的地址列表,这些用户都是男性,至少有 x 岁,并且在某个虚构的博客应用中至少发表过一篇文章。清单 14-10 展示了这可能是什么样子。

$usersAddr = User::with('address') //join on address relation
          ->where('is_active', true) //returns QueryBuilder object
          ->where('age', '>', $startingAge)
          ->where('gender', $gender)
          ->where(function ($query) use ($request) {
               $query->whereHas('posts', function ($query) use
                    ($request) {
                         $query->where('is_published', $published);
                    });
               })
          ->get()    //fetches result and returns a Collection
          ->address; //grabs the ‘address’ relation--included via
                     //the call to with() in the first line

Listing 14-10Complex Query Using a Facade Inherited from Eloquent’s Abstract Model Class

清单 14-10 中的查询是“查询活跃男性用户的列表,包括他们的地址,这些用户至少有 x 岁,并且在系统中至少有一篇发表的文章,将结果返回到一个只包含每个人地址的集合对象中,并将其存储在一个名为$usersAddr的变量中。”所有这些都在一行 PHP 代码中!这可能是一种很有吸引力的方法,可以根据您当时正在构建的任何功能直接使用,并且它肯定是一种方便、快速和富有表现力的方法。然而,请记住:权力越大,责任越大。

我们非常不恰当地将一些不同的关注点混合在一起:数据库关注点(由查询表示,包括调用get()——选择标准之前的任何内容),遍历结果模型对象以浏览它们并提取每个匹配用户的地址的方法,以及将结果集合转换为普通的 ol’PHP 数组。我们已经创建了一段不可重用的一次性代码,它可能会卡在存储库方法中,还有一个这样的应用包含的数百个其他方法。如果这是你想要的,很好——你完成了!

如果没有,那么可能是时候认识到,将关注点的混合分离成更小的功能块会更好,增加了重用的可能性,并创建了一个更好的架构来构建额外的功能。使用这种方法获得的另一个好处是,代码十有八九会更容易被其他开发人员阅读和理解,因为以这种方式创建的查询会变得冗长而复杂,降低了代码的可读性。

一个可以创建更清晰的关注点分离和更好的代码可重用性的解决方案是使用雄辩的作用域,我们还没有转换它,但是将在以后的章节中详细描述。现在,我们只需要知道一个作用域是一个方法,它可以被添加到任何接受一个QueryBuilder对象和任何可选参数的雄辩模型中,将特定的约束添加到那个QueryBuilder对象中,并直接返回它,以便在执行最终的 SQL 之前可以进一步追加它。一个QueryBuilder对象是一种雄辩的通用 SQL 抽象层,它增强了雄辩的查询能力和数据库抽象。

  • →向名为scopeIsActive()User模型添加一个新的范围,并定义表示活动用户的约束。

  • →在User模型中添加两个新的作用域,名为scopeIsMale()scopeIsFemale(),在其中分别定义表示各自的约束。

  • →在名为scopeHasPublished()User模型中添加一个新的作用域,该作用域接受一个用户的 ID 并返回一个子查询语句的结果,该子查询语句指示该用户在系统中是否有任何“已发布”的帖子

  • →在名为scopeIsAtLeastAge()User模型中添加一个新的作用域,该作用域接受类型为DateTimeImmutable$age参数,该参数将基于每个用户的年龄约束查询。

  • →将新的行为封装在新的存储库方法中,但是根据普遍存在的语言来命名方法,以便完全捕获新行为提供的目的和意图。

我们将在第 X 章的后面看一下用于创建作用域的代码。使用前面提供的解决方案,UserRepository中的新函数将看起来像清单 14-11 中的代码。它包含我们的核心查询,但实际上还没有执行它,允许进一步添加到结果 SQL 中。通常还包括对get()的调用,以便存储库返回实际数据,而不是 QueryBuilder 对象。

// in UserRepository

public function getPublishedMales(\DateTimeImmutable $isAtLeastAge)
                                   : QueryBuilder
{
     $users = User::with('address')
               ->isActive()
               ->isMale()
               ->isAtLeastAge($age)
               ->hasPublished();
}

Listing 14-11Repository Method Containing Our Core Query

在清单 14-11 中,您可以看到我们对之前描述的静态的一行程序进行了许多改进。

  • 我们的代码是解耦的,允许我们通过调用名称是域驱动的方法(无处不在的语言)来逐步构建适当的查询。

  • 不用深究实现的细节,我们一眼就能清楚地理解代码实际做了什么。

  • 我们将特定于模型的有意义的约束放在了适当的位置:模型本身(胖模型!).

  • 我们有可重用的作用域,表明它在领域中的用途。如果我们想知道用户是否在应用的其他地方发表了文章,我们可以将hasPublished()添加到查询构建器方法或User facade。如果我们需要检查用户是否处于活动状态(这种情况可能一直会发生),我们可以将isActive()方法改为全局作用域,并一直应用它,除非我们使用对withoutGlobalScopes()的调用来指定其他方式。

  • 我们的新实现更容易进行单元测试,因为我们可以简单地用一组已知的、预先确定的数据调用我们添加到Claim模型中的每个新的作用域方法,并验证结果,而不是试图测试清单 14-10 中的一行超级查询。

  • 最后但并非最不重要的一点,因为 concertive 中每个定义的作用域都返回一个新的QueryBuilder对象,并且我们指定对getPublishedMales()方法的调用结果将是一个QueryBuilder对象,所以我们仍然没有真正运行我们的查询,这允许我们在执行查询之前添加额外的约束。另一种常见的方法是让存储库查询为您调用get(),并返回一个实际的数据集合,这也很好(我们只需要将返回类型提示修改为Collection类型)。

简单地说,我想说明的要点是,从数据库中收集和处理结果的最好、最有效的方法是尽可能将所有内容包含在查询本身的上下文中(也就是调用get()之前的代码)。如果构造得当,SQL 运行起来比 PHP 中遍历集合内的模型对象要快得多——事实上,比光速还要快。

一个好的方法是使用模型的外观从一行直接查询开始(如果可能的话),就像清单 14-10 中展示的那样。一旦你有了它的功能,分解任何可能在应用的其他地方再次使用的代码部分,并把它们放入一个作用域、全局作用域或相应模型的其他类似方法中,这样它们可以简单地附加到QueryBuilder对象上,在执行前添加约束。一定要用通用语言中合适的术语来命名应用中的所有类、方法和其他任何东西。对于需要跨各种服务、协议或类执行的更复杂的查询,请尝试将功能分成更小的部分,特别注意以明确和直接的方式实现领域本身的业务规则和概念。如果某个特定的功能块感觉不太对劲,看看你把它们分开的方法,确保你没有误判领域上下文的边界,或者把它(它们)做得太细或者不够细,以便以后可以重用和扩展。

在为查询本身建立了约束和细节并将逻辑放入可以重用的合适组件或方法之后,下一步是关注返回数据集的实际过滤,这不需要数据库,而是通常涉及迭代通过一个Collection对象来创建一个新的集合对象或增加集合中的数据,然后可能将其转换为数组或 JSON。

正如我前面说过的,您越能利用查询(通过查询构建器或口才),您的性能就越好。规范提供了另一种“选择”或“过滤”数据的方法,并且在查询运行之后(调用get()之后)使用最有好处,从而不会影响性能。图 14-3 显示了这种方法中所涉及的参与者的图表。

img/488324_1_En_14_Chapter/488324_1_En_14_Fig3_HTML.jpg

图 14-3

用于查询和处理从数据库中检索的关于给定模型的数据的典型解决方案中涉及的各种过程

此图显示了功能的哪个部分属于哪个组件以及它们的使用顺序。首先通过雄辩模型的 facade 设置查询,这将生成一个相应的数据库 SQL 查询,该查询在调用get()方法时运行。这可以在返回结果Collection对象的存储库中完成。从那里,客户端代码,可能是一个作业或域服务,或者甚至是应用层中的一个组件,将把集合返回给它们,然后可以通过规范进一步过滤。

该图确实有一个含义,那就是它假设,无论出于什么原因,由于与域对象本身之间的交互相关的复杂程度和/或它们的边界在模型中相对于那些对象的位置,您无法从单个查询中收集到您需要的一切。然而,只要有可能,就要尝试在数据库查询中包含尽可能多的过滤、排序和选择逻辑,而不是直接查询存储库,特别是从数据库中获取一些收集的中间结果集,并在 PHP 中对它们进行迭代,但只有在领域认为有必要时才这样做。

聚合和存储库

因为聚合隐藏了内部组件,不会被外部访问,所以组成聚合的单个类通常不需要自己的存储库,而是通过存储库上的一些关系方法来访问,这些方法是专门为通过聚合根提取由聚合封装的对象而设计的。清单 14-12 展示了一个例子。

<?php

//ClaimRepositoryInterface.php
public function getProgressNotes($claimId): Collection;

public function formatDateOfService($format='Y-m-d h:i:s'):
                                   \DateTimeImmutable;

public function getEstimatedClaimAmount($claimId): float;

Listing 14-12Example Methods Added to the ClaimRepository to Support Managing the Claim as an Aggregate

这很好,并且可以在现实世界的实现中工作,但是,真的,我在以前的接口中没有看到任何特殊的东西,而这些特殊的东西不能通过直接遍历与该模型相关联的关系对象来立即处理。清单 14-13 展示了一个这样的例子。

<?php
$claim = Claim::find(123);
$progressNotes = $claim->progressNotes->toArray();
$dateOfService = \DateTime::format($claim->dateOfService, 'm-d-Y');
$estimatedClaimAmount = $claim->estimated_claim_amount;

Listing 14-13Corresponding Eloquent Methods Matching the Repository Interface

再一次,似乎包含清单 14-12 中确定的方法的存储库实现与清单 14-13 中的三行做同样的事情是不必要的。我们可以认为这些方法应该放在同一个类中,以便对相关的功能进行分组;然而,它们中的每一个都已经存在,并且可以通过我们的域对象从其扩展而来的雄辩的抽象Model类获得。访问模型上的关系就像调用关系的名称一样简单,就好像它是模型上的一个属性一样。如果您想要遍历一个关联,然后对该关联的结果执行额外的逻辑(比如在一对多关系上持久化额外的项),您可以简单地调用该关联,就像它是模型上的一个方法一样,然后将结果视为将额外的功能链接在一起的一种方式,如清单 14-14 所示。

<?php
//Saving relations to a Claim model

$claim = Claim::find(123)
          ->cptCodes()
          ->save([22,45,47]);

//Query relations of Claim model
//find a Claim's progress notes and chain additional query operations

$claim = Claim::where(function ($query) use ($provider) {
     $query->whereHas('progress_notes', function ($query) use
          ($provider) {
               $query->where('provider_id', $provider->id);
     });
});

Listing 14-14Example Operations Involving Relations to the Claim Object

有口才的储存库没用吗?

本章中的示例 blog 应用用于创建一种方法来编译一组关于假想系统用户的特定数据。具体来说,我们需要一个属于所有用户的地址列表,这些用户都是男性,有一定的年龄,至少有一个帖子,并且当前的状态是“活跃的”。为了减少我们在第一个解决方案中遇到的任何性能错误或瓶颈,我们想尝试将所有的约束合并到查询本身中(通过QueryBuilder),以便将繁重的操作卸载到数据库服务器上,这一点我们已经在清单 14-1 中使用范围和一个额外的存储库方法做了很多。

总而言之,关于在 Laravel 中使用传统的存储库,您可以在没有它们的情况下生活,在很大程度上,这要归功于内置集合(从使用 concertive 进行的每个查询中返回)、本地和全局范围(我们将在第 X 章中深入讨论),以及模型本身包含的外观(从抽象的Model类继承而来)。facade 提供了一种快速、现实和直接的方法来完成几乎所有可以用 SQL 和正确设置的关系数据库(如 MySQL 或 MariaSQL)完成的事情。更重要的是,给定模型的范围约束与模型保持——因为范围所做的是接受一个QueryBuilder对象,该对象本身可能包含一个或多个已经附加到查询构建器上的约束(以便至少预加载一些记录)。示波器的安装和使用通常非常快速和方便。不应该创建规范来处理数据库本身的迭代。把它留给专门为处理迭代而创建的东西吧,它的性能比 PHP 高得多。然而,它们在领域驱动的设计中确实有作用,因为它明确了它所指定的标准的目的,当我们用与领域相关的对应物直接对应的名称来命名规范时,这可以被证明是有用的。

然而,存储库并不是完全无用的。当您的应用实际上利用了多个数据库管理系统(即持久层)时,使用它们会很有好处,应用需要适应这些系统才能正常工作。使用各种存储库接口来设置这一点相当简单,这些存储库接口将由与应用交互所需的每一个持久层来实现(例如,对于一个Claim模型)。可能有几个存储库,例如SqlClaimsRepositoryRedisClaimsRepositoryElasticClaimsRepository和/或InMemoryClaimsRepository等。您可能希望以这种方式定义的每个接口都有单独的存储库,并通过模型将它们分开,这不仅是为了分离关注点,而且从面向对象的角度来看,因为如果我们在多个模型中混合存储库功能,最终会发生的情况是,一些存储库类将不可避免地实现接口所需的方法,而这些方法对于给定的模型是不需要的。

总结一下存储库:除非领域需要必须同时运行的多层持久性机制,否则任何标准实现都不需要传统的存储库,无论是面向集合的还是面向持久性的。参见清单 14-15 中显示的许多现代 web 和非 web 应用中使用的基本的、通用的存储库接口。

<?php

interface RepositoryInterface
{
    public function all();
    public function create(array $data);
    public function update(array $data, $id);
    public function delete($id);
    public function show($id);
}

Listing 14-15A Common Repository Interface

让我们比较一下这个接口所需要的方法和它们使用雄辩的实现,提供相同的功能;见表 14-2 。

表 14-2

一个公共的存储库接口与其相应的雄辩的表示相比

|

仓库接口上的方法

|

雄辩的对手

|
| --- | --- |
| $repository->all(); | Model::all()Model::get() |
| $repository->create(``$repository->getNextId(),``$data=[]) | Model::create($data=[])或者$model->fill($data=[])或者$model = new Model($data=[]) |
| $repository->update($id, $data=[]) | Model::update($data=[])或者$model->association = $x;``$model->save();或者$model->someAssociation()``->save($someAssociation) |
| $repository->delete($id)或者$repository->delete | Model::delete($id)或者Model::destroy($ids=[]) |
| $repository->findAllBy($ids=[])或者$rows=$repository->where('id', 'IN',``[1,2,3]);``if (!empty($rows)) {$row=$rows[0];} | Model::find($id=[])或者$row=$model->whereIn('id',[1,2,3])``->get()->findOrFail() |

的确,右边的雄辩专栏中提供的大部分功能已经内置到抽象的Model类中,并且可供每个雄辩模型使用(通常通过我们的领域模型实现的抽象的Model类提供的外观)。

我开始列出在 Laravel 应用中使用存储库实现的可能原因,但是随着我的深入,我意识到这些原因中的大部分很容易被一个或多个雄辩的特性所否定。例如,下面列出了一些最容易接受的使用存储库的理由:

  • 对特定模型进行定制的、过于复杂的查询。另一方面,这些类型的查询可能更好地放置在实体(模型)本身中,以使它们尽可能接近它们所对应的代码(将相关的功能分组是设计软件的一种极好的方式,只要您将以相同的速度变化的逻辑放在一起)。

  • 使用它们作为访问聚集分组内的内部对象的手段,否则使用传统的雄辩技术是无法获得该内部对象的。另一方面,在 concertive 中遍历关联是非常容易和有效的,以至于它有可能取代选择/检索内部聚集对象的需要,如果需要的话,您可以使用模型工厂来重新构建(我们将在下一章深入讨论工厂)。

  • 当您有多个使用不止一种存储技术的持久化机制(例如 Redis、Elasticsearch、MySQL 等)时。),您可以为每个模型、每个存储机制创建一个单独的RepositoryInterface,这将有助于抽象出它们实现中的任何差异,同时允许它们的相似性被定义并正确地封装在父接口中。

  • 为了在单个域模型的上下文中实现某种定制的缓存机制,可以使用存储库。

结论

存储库模式是许多软件项目中广泛使用的一种模式。在传统的纯 PHP 应用中(我已经有四五年没见过它了),有理由使用存储库,因为它提供了封装复杂查询或遍历一个域模型与其他域模型的关系的简单方法。现在,我们已经有了像雄辩和 Laravel 这样的工具和框架,这些工具和框架具有许多旨在提供遍历领域模型及其关系的简单方法的特性。

然而,就像软件开发中的其他事情一样,它也有起有落。使用雄辩提供的强大功能的缺点是,它太容易使用,而不考虑分离关注点或以清晰明了的方式恰当地封装逻辑的相似部分。随着时间的推移,这些类型的事情会混淆领域模型中的概念,使类的目的变得不那么明显,这两种情况都是不可取的。减轻这种情况的一种方法是通过一个规范模式实现显式定义的(并且无处不在命名的)标准对象,该规范模式涉及一个谓词来确定一个给定的对象是否满足规范中的标准,尽管这种方法也有其问题,即性能。

我们讨论了一些可能的用例以及雄辩提供的功能的例子。我们对模型库中常见的方法进行了一些不同的比较,并找到了提供相同结果的可行的解决方案。因为 concertive 中的每个查询都返回一个类型为Collection的实例,所以我们能够将查询的各种条件和约束链接在一起,这些条件和约束有可能用定制的内联 concertive 方法链替换存储库。尽管我们将在本书的稍后部分深入探讨雄辩术,但还是有必要通过几个例子来吊起你的胃口。

总之,在利用口才的应用中,存储库是无用的吗?嗯,我不能(也不会)肯定地说,因为答案真的取决于你所处的情况和项目的要求和需要。然而,在你我之间,我自己很少实现一个存储库,除了在同一个应用中用相似的方法分离出与多个持久层相关的逻辑。如果您注意放置该逻辑的位置,并且避免在给定模型上任何需要遍历或约束数据库中的对象集合的地方通过 facade 方法进行内联雄辩查询,那么您很有可能使用 concertive 来完全取代使用存储库。如何做到这一点取决于具体情况,但最好是坚持项目中无处不在的语言所隐含的概念、命名约定和分隔。*

十五、工厂和集合

在这一章中,我们将讨论工厂、工厂方法和集合,以及它们在应用中的用途。在深入研究聚合之前,您应该了解一些先决条件,这将使您更好地理解它们做什么,以及为什么它们是领域驱动设计的技术部分中最难正确理解的概念之一。我们将探索工厂和工厂方法对于总体设计的价值,并探索我们可以在 Laravel 和口才中做的一些很酷的事情,以使代码更容易理解和更简洁。

在此之前,让我们先了解一些核心知识,这些知识将有助于您了解聚合。我所指的主要概念包括以下内容:

  • 处理

  • 交易的特征

      • 原子数

      • 一致性

      • 隔离

      • 持久性

  • 数据不一致

  • 工厂

  • 总计

  • 强制不变量

一些健脑食品

我再怎么强调这一点也不为过:一个现代的 web 应用是由许多不同的软件技术组成的,每一种技术都被分割成各自的领域。一如既往,今天的软件技术总是越来越好,越来越专业化。企业过去花费数万美元创建的组件现在只需花费其中的一小部分就可以完成。这之所以可能是因为开源运动。事实证明(谁会想到),开源软件已经对我们做生意的方式,甚至我们的生活方式产生了持久的影响。我们可以利用开源软件来实现我们的业务需求,并且我们可以直接在我们的应用中使用它,而无需支付一分钱的许可证或订阅费,这样可以节省时间和金钱,因为您不再需要重新发明轮子。我的意思是,你很可能不得不花钱请人帮你把它连接到你的系统或应用栈上,当然,除非你是一个开发人员,但是真正的“开发”时间可以集中在正确地获得领域模型上。去开源!

改善 PHP 的无状态性

如果您打算保存用户的设置和配置,以便下次更快地加载应用(或者您可能希望实现某种跟踪功能来判断您的访问者在哪个国家),您可以选择创建一个 cookie,该 cookie 可以保存在客户端计算机上,然后在用户访问站点时加载。或者,如果您有一些对时间敏感的数据,例如允许应用用户访问应用的某些特定区域的 JWT 令牌,您可能希望将该令牌保存在 HTTP 头中(例如承载令牌),可能包含在授权头中的每个请求中。或者,如果您有一些只适用于该特定访问的数据,比如在线订单,您可以将这些数据保存在会话中。重点是,PHP 本身是无状态的,一般来说 web 也是无状态的(由于客户端-服务器模型),但是我们可以通过一些技巧和一些经过深思熟虑的代码,用现代 Web 应用的基本设施创建优雅的即兴方法。

应用很便宜,数据很贵

我们有一个保存数据的数据库,这些数据为我们的网站或应用提供内容,并且在大多数情况下提供价值。你看,一个应用(基本上)只是围绕着一个典型的数据结构(由数据库模式、索引定义,或者如果你使用 NoSQL 持久化方法,文档)。我们可以改变应用一千次,它仍然可以工作,只要我们在开发时考虑到数据库结构/模式。

下面的例子不是一个场景或“假设…”虚构的讨论,但实际上是真实的。就拿 http://Slashdot.org 这个网站来说。Slashdot 是一个非常古老、非常著名、非常受欢迎的新闻公告栏类型的网站,它发布来自用户的关于(主要是)技术和与技术相关的事物的讨论、评论和反馈(尽管它现在有许多跨越过多兴趣的类别)。作为 Slashdot 的(新)首席开发者,让我告诉你,我陷入了的混乱。我发现这个网站实际上是用 Perl 构建的。不仅如此,它实际上是 Perl 的某个分支(出于某种原因)被编译到了网站 http://Slashdot.org 。我一生中从未听说过这样的事。编译 Perl?那是什么?

有趣的是,“编译”部分意味着一个独立的、专有的编程语法,这让我想起了某种类型的 4GL,它实际上决定了编译什么,并且是整个应用编译过程的主要驱动因素。当然,我的第一个想法是,“让我们重建这该死的东西,从零开始,把它做好。”然后我看了看代码。天哪,这真是太复杂了,对于一个每月有超过 300 万访问者的网站,几乎没有任何关于其当前实现或部署的文档。在我被雇佣并开始使用它的时候,这个应用的最初开发者已经离开很久很久了。事实上,在它最终落入我现在工作的公司手中之前,它已经被买卖过几次。很少有人问我关于网站如何工作的问题,你应该调用哪个函数来实现 X 事件,或者几乎没有其他的事情。在进一步了解了这个项目之后,我开始明白,这个网站的长期用户,那些首先对它的成功和受欢迎程度负责的人,坚持认为这个网站的核心外观、外观、感觉和功能与他们现在的一样或相似。有趣的是,他们希望网站的外观、感觉和功能保持和过去 18 年一样。事实和真相是,用户在网站上想要什么,用户就在网站上得到什么,因为用户就是网站

Slashdot 过去是、现在是、将来也是——在很大程度上与其他规模和活跃用户相似的网站相同——就是这些用户。任何公共应用都是如此。老实说,这是我职业生涯中第一次目睹一个流行网站的用户发号施令的场景!在他们自己从另一家公司购买之前,我们公司已经从另一家拥有该网站多年的公司那里购买了该网站;我们真的受到每天访问我们网站的成千上万忠实用户的支配。埃隆·马斯克(Elon Musk)就是这样一个人,他在 Twitter 上直接引用了 Slashdot 的文章和讨论。如果我们激怒了他们(就像微软已经做的并且仍然经常对它的用户做的那样),他们肯定会跳出来加入我们竞争对手的网站,很可能是一去不复返;之后,收视率会下降,公司甚至会失去在网站上的投资,至少可以说这是一笔可观的投资。在某种程度上,这让我崩溃了,我开始有一段时间不想去工作了。

有一天,我突然意识到,应用只是一个“外壳”,它本质上是对网站、应用或当今存在的几乎任何其他类型的软件都重要的唯一真实的东西。为 Slashdot.org 网站提供动力的编译后的 Perl 代码正在做它被设计用来做的事情:从数据库中提取数据,并系统地将其转化为用户可以消化和响应的东西(通过评论)。然而,由于源代码的复杂性(以及最初对其工作原理缺乏了解),网站的维护是一场噩梦(尤其是对我来说)。变更通常一次需要几周时间,并且很难确定地预测站点上的任何变更需要多长时间才能投入生产。

这种理解导致了该网站的未来计划的突破。维护和更新的高成本是无法忍受的,我们决定简单地重建网站,从长远来看更容易保持。我们想要一个网站,随着时间的推移,它一定会长寿,容易更新和维护。我们选择了 Laravel 框架。我们采用了网站当前运行的现有 HTML、CSS 和 JavaScript,并将其重构到我们新的后端。是的,我建议我们使用 Laravel 框架,因为它是众所周知的、有良好文档记录的、得到良好支持的,并且(最重要的)得到良好维护的。最重要的是,我们保留了数据库中的原始数据,尽管我们随着时间的推移重构了模式,使其更加可靠和易于维护,但我们迁移了自网站首次向公众开放以来数据库中的所有数据。这次迁移包括所有用户的设置、帖子、评论和其他任何相关内容。我们将旧的普通 JavaScript + HTML 4 翻译成用 React 构建的高度可伸缩和更加灵活的前端,它与后端 Laravel 实现对话。网站的模板、功能和外观保持不变,因为我们只是将旧的外观“移植”到新的代码中,网站的用户根本不知道他们正在使用一个全新的重构系统。对他们来说,这是同一个网站。

我们是怎么做到的?我们依赖于我们所知道的质量、开源软件和 Laravel 包形式的源代码来完成我们的投标,换句话说,处理 Web 上几乎所有其他应用共有的、常见的、通常是艰苦的机制和组件。我们依靠 Laravel 提供指导框架、支持工具、帮助社区和精心编写的文档,使框架按照我们需要的方式为我们工作,从而使它成为我们自己的。

从这个故事中得出的中心观点是,无论您决定如何构建应用,任何系统最重要的方面都是数据。如果要正确设置任何东西,数据库模式应该在列表的顶部。在数据库结构(模式)上投入足够的时间来使其正确是值得的,因为您永远不知道何时会有一天您想要在长期成员不知道发生了任何变化的情况下改变后端。您可以随时替换、重建或重构应用。与这些应用生成和使用的数据相比,这些任务是廉价的。我们希望采取适当的措施来确保我们的数据保持一致的状态。交易是帮助确保这一点的一种方式。

处理

在《PHP 中的域驱动设计》一书中,作者用下面的话定义了事务的一般概念:

“事务是所有数据库系统的基本概念。事务的要点在于它将多个步骤捆绑成一个要么全有要么全无的操作。这些步骤之间的中间状态对其他并发事务是不可见的,如果发生某种故障导致事务无法完成,那么这些步骤根本不会影响数据库。”

—Buenosvinos,Soronellas,和 Akbary,PHP 中的域驱动设计

因此,我们有一套操作,我们需要要么全部成功,要么全部失败,但不是每一种都有一部分。这是因为数据库的一致性。数据库一致性可以被认为是数据库相对于系统其余部分的准确性和最新性的完好程度的度量。应用、数据库、服务器和浏览器(以及一大堆其他东西)在微妙的平衡中工作。因为 PHP 是无状态的,所以必须采取措施来确保您可以跨请求保持应用数据的状态。我们有各种各样的工具可以使用,在某些特定的情况下可能会有所帮助。

最常见的交易证明是银行账户。在每个账户的每笔交易中,有一些规则,可以说,必须遵循这些规则才能实际过账到任何账户。我们现在将讨论一些对事务至关重要的共同特征。

原子数

原子性是一种描述方式,即使单个事务中可能涉及不同的查询,它们要么全部成功,要么全部失败,从而保证数据库即使在有错误时也保持一致的状态。

例如,假设我们有两个账户,Account_A 和 Account_B,它们有相同的货币价值,比如 500 美元。

| 账户 _A | $500 |
| 账户 _B | $500 |

然后,假设我们想将 100 美元从 Account_A 转移到 Account_B,这必然会在系统中创建两个相反的操作:一个是将 Account_A 减少 100 美元,另一个是将 Account_B 增加 100 美元。

交易 1 :减少账户 A 100 美元

| 账户 _A | 500 美元-100 美元= 400 美元(待定) |
| 账户 _B | $500 |

交易 2 :增加 Account _ B by 美元

| 账户 _A | 500 美元-100 美元= 400 美元(待定) |
| 账户 _B | 500 美元-100 美元= 600 美元(待定) |

请注意,在每个事务之后,都有一个尚未执行的挂起事务。当第二个事务事件被设置时,两个事务仍然处于挂起模式。只有当每个账户都被验证在其账户中具有相应的金额并且有足够的资金给第二个账户时,每个交易才会同时被执行。

这样做是为了确保不会有一个事务实际上执行了,而另一个没有执行。例如,如果只执行了第一笔交易,而没有执行第二笔交易,第二天就会有一些愤怒的电话和电子邮件,因为会有 100 美元在系统中根本没有入账。这是数据差异,是数据不一致的一种形式,因为 Account_A 将被扣除 100 美元,但 Account_B 将保持不变,仍为 500 美元。这就是原子性的含义:要么都执行,要么都不执行。在其中一个事务失败的情况下,数据库将经历一种机制来防止数据丢失,这种机制被称为回滚(在数据库的生命周期中及时后退)。

一致性

通过使用事务,我们可以使数据库始终保持恒定状态,即使在事务或回滚过程中也是如此。数据要么全部是旧值,要么全部是新值,但不能是两者的混合。要么全部,要么一个都没有。一致性也必须存在于领域模型中的代码和操作中。这就是领域驱动的设计如此重要的原因:它以一种几乎精确的方式反映了它所建模的领域中的过程,并且数据库应该以一种应用易于交流、使用和命令的方式建模。

然而,不仅仅是在数据库级别,事务才是重要的。一个域对象上的典型业务操作可能跨越几个事务,每个事务可能都与不同的表或数据库相关。

就请求、响应和客户机-服务器模型而言,互联网的本质是无状态的。为了创造良好的用户体验,作为开发人员,我们的工作是管理必要的事物状态,使网站的功能正常工作,并且看起来好像是有状态的。正如前面在“改善 PHP 无状态性”中所描述的,我们有许多工具可以用来管理应用的状态和它周围的数据。

因此,始终有两股力量在起作用:应用本身和它所操作的数据。我们可以在几乎任何框架或 PHP 脚本中轻松地使用数据库事务,因为有一些工具,如 Laravel 的口才,Symfony 的学说,以及作为这些和其他类似工具基础的整个 PDO PHP 库,这太棒了!但是,请考虑以下情况。

数据库事务基本上是在不同的表上运行一组多个查询,这些查询对数据库的更改要么全部发生,要么都不发生。然而,这意味着数据库事务实际上是一种保存由多个查询描述的单个事务的方法。这一点之所以相关,是因为在现实世界中,一家公司的业务流程可能跨越许多事务的整体,所有这些业务流程都需要应用于数据库事务的相同的“全有或全无”规则。这里的问题是,单独使用数据库,我们一次只能指定一个事务。我们如何确保以原子的方式处理多个数据库事务的事务组?仅仅使用数据库,我们真的做不到——无论如何效率都不高。

深入挖掘:应用和数据库级的一致性

答案是应用。应用负责管理业务问题在现实世界中实际封装的大量事务,依靠数据库来执行(可能是许多)数据库事务,以便能够完全表达应用和数据存储上下文中的流程。根据情况,我们可以使用数据库来处理大部分工作,因为我们绝对需要数据库事务原子性;然而,数据库事务本身是由应用创建、管理和触发的,这意味着需要应用级别的事务。

从大的方面来看,这意味着 web 应用既是代码又是数据。它们都是网站或应用对任何人有用所必需的。当然,该规则也有一些例外,比如静态网站、通过 REST 接口利用 API 作为其“数据库”类型组件的脚本,或者只是发布给定领域内所有公司的目录列表的网站,目的是让这些公司购买许可证以位于列表的顶部。这个网站可以由 UI 的模板组成,组成应用内容的数据可以通过一种一次性的、随需应变的服务来获取,一旦用户偶然发现(不是故意的)特定类别网站的登录页面,就会调用该服务。在这种情况下,您实际上可以获取您需要的数据,可能为了缓存的目的而存储它,并通过 web 应用按需显示它。这只是一个例子,但这是一个可行的和低成本的在线营销或搜索引擎优化为基础的公司解决方案。

我在职业生涯中见过的大多数应用都使用数据库来保存数据,根据业务需求,可以使用任何数量的现代数据库技术(其中大多数是开源的):Elasticsearch、MySQL、Postgres、MSSQL、Redis、Firebase、Mongo DB、Propel...你明白我的意思了。

因此,要想在隧道的尽头得到一个可用的软件,有两件事情是必须的:应用和数据库。这就是数据库一致性很重要的原因;同样重要的是应用的核心功能、逻辑和流程的一致性,以及在软件中对真实情况建模时与底层领域的一致性。如果我们想要长期成功,我们应该在我们工作的任何项目中注意这两个问题。

隔离

就数据库一致性和 ACID 而言,隔离是指这样的事务,即事务本身中执行的任何查询或单个操作都不能影响同一事务中的任何其他查询。例如,当记录了在两个帐户之间转移资金的交易时,会运行两个查询:一个帐户增加,另一个帐户减少。当这两个查询在数据库事务的范围内时,我们可以确定它们将同时发生,而不会影响另一个,直到事务完成并提交到数据库。这是原子的和孤立的,有助于保持我们的数据一致。除了事务内的查询相互隔离之外,事务以相同的方式操作:作为独立且完全分离的操作,以可预测的原子方式修改数据库。

现在,假设我们选择在没有事务的情况下实现相同的场景,并且只是连续运行两个查询。嗯,第一个查询增加了第一个帐户将成功运行,因为任何给定的帐户可以有多少没有上限。然而,在这个会计系统(以及所有会计系统)中实现了一个业务规则,该规则限制了一个帐户的余额可以降到 0 美元以下多少(如果无论如何都允许的话,但是假设这个规则没有在域模型中明确定义)。因此,执行第二个查询,并尝试将第二个帐户减少第一个帐户增加的金额,这将正确执行,并对第二个帐户执行余额修改。你能看到我描述的场景有什么潜在的问题吗?

问题在于第二个账户可能没有足够的钱来支付转账到第一个账户的金额。因为查询是在安全和有保证的交易范围之外执行的,所以第一帐户被记入第二帐户不能提供的金额。所以现在,第二个账户出现了负结余。假设应用以某种方式检测到这种异常,一分钟后尝试进行相反的交易,以使账簿中的余额正确。结果是,Johnny Gambler 那天失去了所有的钱,他正看着手机,期待着政府支票打到他的账户上,结果他从自己的账户(在这种情况下,是第一个账户)中提取了相同的金额,这样这笔金额就从他的账户中扣除了。一般来说,这里的问题是,第二个账户现在有一个赤字余额,如果不在第一个账户上创造另一个赤字余额,这个赤字余额就无法逆转。

假设他们已经修复了软件中的业务逻辑,该逻辑将在向第一个帐户转账之前检查以确保第二个帐户中的余额可用,但仍然拒绝实现简单的数据库事务以确保事务的原子性得到尊重。所以,下一次,同样的情况发生了,请求需要在账户之间转移一些钱。一切都很顺利,第一笔交易执行成功,第一个账户增加了交易中指定的金额。然而,幸运的是,在第一个查询执行之后,运行事务的服务器的电源就中断了,这个过程停止了,并且被系统遗忘了。在这种情况下,问题是我们现在有一个处于不一致状态的数据库,我们甚至不会知道这一点,直到我们运行每月报告,表明数字没有增加,或者直到有人打电话抱怨他们没有收到他们的钱。

所有这些都可以通过一个简单的数据库事务来避免,因为事务内部的独立查询都保证单独运行,不会影响其他查询,同时,要么同时运行,要么根本不运行。这是我们确保数据一致性的方法。事务在许多应用中使用,但不应该过度使用,因为当并发请求被发送到同一台服务器上执行时,它们会降低系统速度或产生锁定问题。

持久性

ACID 首字母缩写词的最后一部分代表持久性,是事务的特征,对应于一旦事务运行(包括其中的所有查询),数据本身就受到保护,不会受到电源故障和系统崩溃的影响。这在基础架构级别上意味着数据已经被持久化到硬盘上,并且已经建立了某种类型的冗余,以确保在持久化级别上发生硬盘崩溃或硬件错误时有更高的恢复机会。

对常见任务使用第三方代码

要知道,我们都希望从头开始构建一些优秀的软件,按照我们认为合适的方式进行设计,并最终能够帮助推动由最新、最棒的代码制成的新产品取得成功。但这几乎不是现实。在日常生活中,有来自老板的压力,项目经理盯着你,有人站在你旁边敲他们的脚,以及损失预防和会计部门总是试图“最小化”的费用报告。我们并不总是能够从头开始设计一个新系统。那么,我们必须做些什么来为自己的成功做准备,而不是重新发明轮子,并且仍然拿出一个高质量的、可维护的、长寿命的软件呢?在我看来,做到这一点的最佳方式是依靠你所知道的最佳实践和标准,专注于核心领域和应用特性,坚持使用无处不在的语言,并依靠走过相同道路的其他人的帮助。当我们选择高质量的库、包和开源代码并加以利用、定制和“自制”时,我们获得了大量的时间和生产力,因为这些第三方工具将针对所有现代 web 应用所需的常见的、非领域的、琐碎的东西,如缓存管理、数据库层、ORM、应用框架、日志记录工具、文件存储管理、即插即用的所见即所得界面,或者您可以在几分钟内制作自己的完整的预构建应用和博客。(我说的当然是 WordPress 或者 Drupal。)你接下来的项目很可能需要这些东西中的一些或全部,这没关系。只要我们对集成到应用中的所有第三方代码的质量做出明智的决定,我们就可以从中受益。

当然,主要的好处是减少我们自己重新构建这些系统或者重新发明轮子(或者重新迭代)的需要。当我们将这些常见的任务“外包”给其他开源代码时,我们可以更长时间、更努力地关注任何软件应用最重要的方面:领域。在本书中,我们将经常为 Laravel 开发有用的第三方库和包。

工厂

像存储库一样,工厂是领域层的一部分,但不是代表底层业务的模型的一部分。存储库封装了我们持久化模型的方式,而工厂封装了构建或实例化一个对象、一组对象或集合的逻辑。聚合尤其不能被如何创造自己的关注所拖累,就像它们不应该知道如何坚持自己一样。在 DDD,解决这些问题的工具分别是工厂和仓库。

图 15-1 提供了一个工厂的视觉效果。

img/488324_1_En_15_Chapter/488324_1_En_15_Fig1_HTML.jpg

图 15-1

订单工厂示例

在这里,客户端向带有id参数的 API 端点(带有 HTTP GET 动词)发送一个请求,该请求被转发给ApiAdapter类的getOrder()方法,将一些 JSON 编码的数据返回给 API 控制器,然后该控制器调用OrderFactory::create()方法,该方法实际上构建了Order对象,并最终将其返回给客户端。可能需要调用不止一个ApiAdapter来取回正确的数据。例如,在不可能或最初没有包含关系的聚合中(可能在遗留应用上),OrderFactory类会在Order对象的创建中包含它们。

在我看来,只有在以下情况下才需要像前面那样的独立工厂:

  • 涉及多个模型。

  • 因为一些奇怪的原因,关系是不存在的。

一个成熟的Factory对象中包含的大部分逻辑通常可以放在聚合根的构造函数方法中,但是也可以作为一个基于聚合根的工厂方法来实现。创建域对象时最大的关注点是真正的业务不变量被恰当地建模,并且以给出域值的实现的方式与域一致。几乎在任何业务或领域模型中,都有前置条件、后置条件和不变量需要保护。工厂可以在这方面提供帮助;然而,我在现实项目中使用的大多数工厂都是工厂方法模式。在使用 Laravel 作为框架时,我个人并没有过多地使用抽象工厂模式。

ddl 中的聚合

如果我们没有尝试 DDD,我们使用 Laravel 的事实使得创建和使用集合变得相当容易。但是,事实就是如此,试图在 DDD 描述的正常环境和实践中对骨料进行建模会导致整个过程存在一些缺陷。就像任何其他事情一样,拥有一个活动记录模式也是有代价的,因为在一种情况下,你可以使用关系和雄辩提供的所有很酷的东西,但在另一种情况下,我们实际上永远无法将模型从基础设施中分离出来。我试图向您证明,在现实世界的开发过程中,这种耦合是值得的,因为我们经常会因为“只要完成它”的心态而点燃导火索。我们仍将设计聚合,但我们的版本将在许多方面略有不同,这允许在 Laravel 和口才中有更好的“流动”或进展,因为主干将被证明是非常有用的。

设计骨料时

您可以采用一些简单的规则,使系统中聚合的设计和实现更容易、更简化。

围绕真正的业务不变量进行设计聚合

只有在帮助解决领域模型中的特定业务问题时,聚合才是有用的,这通常涉及保护领域模型对象的一些不变量。在采购订单汇总中,可能有一个基本的业务规则,它规定一个 PO 中至少应该有一个行项目,以便能够被会计部门批准。这将是一个业务不变量,应该在聚合中的某个地方建模为一个明确的概念,我们将在本章中看到一个例子。必须在采购订单聚合中建模的另一个可能的不变量可能是一个最大允许金额,如果要获得批准,PO 中的行项目的总和必须保持在该金额之下。系统中的不变量需要在数据库和应用中保持对象的有效、一致的顺序。

设计小骨料

较小的聚合比较大的聚合更受青睐,主要是因为跨越较大上下文的较大对象中的对象的复杂性增加了。更困难的是这样一个对象的持久性。因为聚合通常在事务的上下文中持久化,持久化过程的复杂性随着事务执行中涉及的查询越来越多而增加。如果数据库锁定可以防止数据中的不一致,那么系统的多个用户在应用上做同样的事情可能会导致性能影响以及系统的不一致或意外行为。

示例聚合

例如,让我们来看一个电子商务应用,它有一个Order的概念,这是系统中的一个模型,包含许多OrderLine,如果我们要为这样的东西设计一个模型(记住总体设计的基本规则),我们可以从列出真正的业务不变量开始。

  • 一个Order必须至少包含一个OrderLine实例(没有双关的意思),这样它才能进入结帐过程的下一个阶段(在本例中是计算销售税和将它添加到订单总额中所需的操作)。

  • Order模型跟踪总金额,即该订单中所有OrderLine实例的总和加上销售税,并将其用于多种用途(例如在用户购物时向用户显示总额,并在结账过程结束时进行金融交易)。这本身不是一个不变量,但这是模型设计的一个因素,导致了一个不变量:一个Order必须跟踪总金额,这需要随时更新,因为用户在购物时会看到这个数字。因此,为了保持订单总数的一致性和不断更新,我们必须有一个机制,在每次添加、删除或更新OrderLine时执行重新计算。

  • 前一个不变量的另一部分包括销售税,它被添加到订单总数中,并在每次更新、删除或创建OrderLine时被更新,因为它基于OrderLine实例总数的百分比。虽然与前一个相似,但为了更好地分离关注点,我们应该将它建模为Order对象上的独立不变量。

将订单模型创建为聚合

让我们给这个例子一个简单的草图。我们将从Order模型开始,因为它是集合中最重要的对象。现在,我们将只对订单建模,不使用我们之前列出的不变量(清单 15-1 )。

<?php

namespace Ecommerce\Domain\Models\Orders\Order;

use Illuminate\Database\Eloquent\Model;
use Ecommerce\Domain\Models\{Payment\PaymentId,Shipping\ShippingId, Cart\CartId, Billing\BillingId};

class Order extends Model
{
     protected float $total=0.00;

     protected ShopperId $shopper;
     protected CartId $cartId;
     protected ShippingId $shippingId;
     protected PaymentId $paymentId;

     protected $fillable = ['shopper_id','cart_id','payment_id', 'shipping_id'];

     public function __construct(Shopper $shopper, CartId $cartId, Payment $paymentId=null, ShippingId $shippingId=null)
     {
          parent::__construct();
          $this->shopperId = $shopperId;
          $this->cartId = $cartId;
          $this->billingId = $billingId;
          $this->shippingId = $shippingId;
     }

     public function orderLines()
     {
         return $this->hasMany(OrderLine::class);
     }
}

Listing 15-1An Example Entity Representing an Online E-commerce Order

在前面的例子中,我们有一个基本的标准类,它扩展了雄辩的抽象类Model。它的构造函数中有许多值对象,对应于一个在线Order的不同数据点:订购的购物者的 ID、订单的账单数据的 ID、对应于Order的目的地地址的运输模型的 ID,以及用于创建订单的购物车对象的 ID。我们有billingIdshippingId的默认值,因为直到结账的最后一部分,当用户将它们输入 web 支付表单时,我们才知道它们。此外,我们有一个相关的OrderLines对象,它定义了与Order模型的hasMany()关系。我们在类中还没有任何不变量。我们还使用了group use语句,这是 PHP 自版本 7 以来的一个特性。现在,让我们以类似的方式创建我们的OrderLine模型(清单 15-2 )。

<?php

namespace Ecommerce\Domain\Models\Orders\Order;

use Illuminate\Database\Eloquent\Model;
use Ecommerce\Domain\Models\{Product\ProductId, Order\OrderId};

class OrderLine extends Model
{
     protected Order $order;
     protected Product $product;
     protected int $quantity;

     protected $fillable = ['product_id', 'orderLineAmount', 'order_id', 'quantity'];

     public function __construct(Product $product, int $quantity, Order $order)
     {
          parent::__construct();
          $this->product = $product;
          $this->quantity = $quantity;
     }

     public function order()
     {
          return $this->belongsTo(Order::class);
     }

     public function product()
     {
          return $this->hasOne(Product::class);
     }
}

Listing 15-2An Example Entity Representing a Single Line Item on the Order Entity

示例OrderLine类中定义了与Product模型、Order模型和quantity的关系,对应于订单上存在的特定product的金额。如果我们像现在这样使用这些类,它可能看起来像这样:

//create order object
$order = new Order($shopperId, $cartId);
//create product object & quantity of that product
$product = Product::find(420);
$quantity = 3;

$orderLine = OrderLine::create($product, $quantity);
$order->orderLine->associate($orderLine);
$order->save();

如果您一直在关注本章中描述的聚合的特征,那么您可能已经发现了前面的实现中的一个问题。我们直接访问聚合的内部对象,这不是我们应该创建聚合对象的方式。我们不希望在聚合的边界内直接实例化任何对象,这是我们在前面的代码中明确要做的。

一个好的简单方法是在聚合根上使用一个命名工厂方法,该方法将接受正确定义OrderLine对象所需的参数,然后将它与Order本身相关联。然而,具体到 DDL 和 concertive,每个扩展抽象Model类的模型都将有一个 facade,允许开发人员静态调用它,从而通过任何阻止这种直接实例化的尝试(例如,OrderLine::create()总是可以被调用,只要我们是扩展模型)。那么,我们能做的最好的事情就是创建一个命名工厂,适当地记录它,并给开发人员留下注释,表明它应该在任何情况下都被用作实例化OrderLine对象的手段。它被称为名为 factory 的,因为方法的名称对应于被实例化的实体。我们不能使用$order->orderLine()方法,因为它已经存在于 concertive 中,允许对与定义它的相关的类进行查询。相反,我们选择了addOrderLine()。参见清单 15-3 中的示例。

<?php

//namespace & use cases

class Order extends Model
{
     //methods and property definitions

     public function addOrderLine(Product $product, int $qty)
     {
          $orderLine = OrderLine::create($product, $qty);
          $this->orderLines()->associate($orderLine);
          $this->save();
     }
}

Listing 15-3Updated Order Class with a Named Factory Method, addOrderLine()

现在我们可以使用 aggregate 根类来实例化我们的 aggregate 的内部对象。

$order = new Order($shopperId, $cartId);
$order->addOrderLine($product, $qty);

这种方法更适合聚合,并且遵循聚合设计的基本规则,因为我们不再关心实例化一个OrderLine对象,用数据填充它,然后将它与客户端 cod 中的Order相关联。相反,我们只需要调用Order类上的命名工厂,让它处理订单行的设置和保存。然而,在我们的实现中还有另一个违反基本集合设计的地方:持久性。聚合对象应该由数据库事务以“全有或全无”类型的交易来持久化。这样,我们可以确保Order对象和它的内部对象是一致的,即使在断电或不相关的系统故障的情况下。这在这里没什么大不了的,因为我们所要做的就是在Order模型上延迟save()方法(这是在将OrderLine关联到Order对象之后立即完成的),并将其推迟到结帐之前的步骤。在这个简化的例子中,我们没有过多的要求,假设在我们的领域模型中,只有当用户对订单中的订单行感到满意并点击页面上指定的 Checkout 按钮时,Order才准备好付款和发货。一旦点击了这个按钮,就会触发一个事件,告诉事件侦听器和应用一个订单已经准备好可以结账了(我们甚至可以将它建模为一个状态,但不是在这里)。这将通过重新计算增加的销售税和运费的总额来继续该过程。

然后,还有不变量要考虑,这我们还没有做到。为了拯救树(或者眼睛,如果你正在阅读这本书的电子版),清单 15-4 展示了解决不变量和解决Order集合的事务持久性问题的潜在方法。

<?php

//use cases & namespaces
use Ecommerce\Domain\Models\Orders\OrderStatus;
use Illuminate\Support\Facades\DB;

class Order extends Model
{
    //methods and property definitions
    public $orderLines = [];
    const  TAX_RATE = .10;

    private $status = OrderStatus::ORDER_STARTED;

    /**
    * Invariant #2 & #3 are protected here
    */
    public function addOrderLine(Product $product, int $qty=1)
    {
        $orderLine = new OrderLine($product, $qty);
        $price = $product->price;

        foreach ($qty as $q) {
            $this->total += ($price +
                (static::TAX_RATE * $price));
        }

        $this->orderLines[] = $orderLine;
    }

    /**
     * Invariant #1 is protected here
     */
    public function startCheckout()
    {
        if (!empty($this->orderLines) &&
               (count($this->orderLines) > 0)) {
               //save the order lines within a transaction so we can
               //guarantee the state of the order stays consistent
               DB::transaction(function() {
                    foreach ($this->orderLines as $orderLine) {
                       $this->associate($orderLine);
                }
                $this->save();
            });
            /*start checkout process with a job or service.
            In theory, this would also change the status of the
            Order to something like OrderStatus::CHECKOUT_STARTED*/
            }

            return new JsonResponse("Order must have at least one Order Line before Checkout can begin", 500);
     }
}

Listing 15-4Updated Order Class, with Invariant Protection Included

在清单 15-4 的例子中,我们有一个相当简单的类,该类有一个声明为静态常量的TAX_RATE变量,它是以十进制形式表示的,每当有新的OrderLine添加到订单中时,我们必须添加到订单总数中的税额。当我们向订单中添加产品时,不是创建并持久化一个新的OrderLine对象(这是OrderLine对象上的 facade 方法orderLines所做的),而是简单地将它们存储在一个数组中供以后处理。这个简单的变化将允许我们推迟真正向表中写入单独的订单,直到最后,当用户完成购物并且Order进入应用的结帐部分。只有在那时,我们才真正使用雄辩的门面方法associate()OrderLine实例推送到Order对象,该方法处理多对多关系的持久性。此时,我们才调用 save 方法,正式将记录写入ordersorder_lines表,并结束应用的订购部分。这一部分是在该类的startCheckout()方法中完成的,该方法在向前移动之前检查以确保在orderLines数组中至少有一个项目。还有一个$status类成员变量,用于跟踪Order对象的正确状态。在结帐过程开始时,该状态可能会发生变化,以表明发生了状态转换,如果需要告诉应用的其余部分发生了这种情况,则很可能会在该过程中引发一些事件(可能会将订单存储在某种缓存中,以防用户决定离开该页面,该页面会在用户稍后返回站点时重新加载订单)。我们在一个事务中调用 store orderLines和订单本身,这样我们可以保持数据库的完整性。

我们可以选择在域层的某些服务中进行这种计算,但是这些操作非常接近根模型(聚合根),在本例中是Order模型,因此在模型中进行操作是有意义的。这部分是因为如果我们将逻辑放在服务中,我们将为流程的发生建立一个隐含的依赖关系,因为开发人员将永远记住调用该服务,而不仅仅是用雄辩的手动操作(使用雄辩的外观非常容易)。通过约束和保护模型上的不变量,我们可以轻松地将订单行保存在事务范围内,确保不会将任何内容保存到事务之外的数据库中,并且只在订单的结帐阶段开始时执行。

这个版本更加圆滑,并且有更好的关注点分离。例如,我们不再需要用OrderLine对象的概念来关注客户端代码。我们所要做的就是传递OrderLine类的构造函数需要的参数,但是客户端代码并不知道这一点!它只需提供已经可用的数据(即产品和数量)。另一件要注意的事情是,我们已经从产品中去除了任何销售税的概念,让它完全在Order类中处理,这很容易通过修改TAX_RATE常量的值来改变。这在OrderLine对象中创建了更少的数据。我们可以对这段代码做的一个升级是进一步将这个功能从startCheckout()方法中的其余逻辑中分离出来,但这不是必需的。

清单 15-5 提供了一个可能的使用示例。

<?php

//create order object
$order = new Order($shopperId, $cartId);

//create product object & quantity of that product
$product = Product::find(420);
$quantity = 3;

//we no longer have to worry about the orderLine object at all!
//instead, we just pass in the data we already have...
$order->addOrderLine($product, $qty);
$order->addOrderLine($product2, $qty2);
$order->addOrderLine($product3, $qty3);

//user clicks on the "Checkout" button:
if ($order->startCheckout()) {
     dispatch(new RunCheckout($order));
} else {
     //return some response indicating to the frontend the issue,
     //which would presumably display a notification to the user
}

Listing 15-5Usage Example (Client Code) for the Previous Implementation

另外需要注意的是,因为我们将OrderLine的概念封装在一个聚合中,所以对OrderLine对象的任何访问都必须通过Order聚合根来完成。如果我们仔细想想,这非常有意义,因为没有必要或要求在Order类之外修改OrderLine。所有的OrderLine实例都属于一个Order,这就是为什么我们将Order类作为聚合根。理解了这一点,如果我们想要更新一个OrderLine的数量、替换一个OrderLine或者完全删除它,我们就需要在那个聚合根上添加方法。这意味着额外的代码,如清单 15-6 所示。

<?php
//namespace & use statements

class Order extends Model
{
     //properties and method definitions

     /**
     * @param $sequence : The location of the order line in the array
     */
     public function removeOrderLine($sequence)
     {
          if (isset($this->orderLines[$sequence])) {
               unset ($this->orderLines[$sequence]));
          }
     }

     public function updateQuantity($sequence, $newQuantity)
     {
          if (isset($this->orderLines[$sequence])) {
               //get the product that corresponds to that order line:
               $product = $this->orderLines[$sequence]->product;
               //remove the orderLine completely from the array:
               unset($this->orderLines[$sequence]);
               //add the new orderLine to the array:
               $this->addOrderLine($product, $newQuantity);
          }
     }

}

Listing 15-6Additional Methods for the Order Class Needed to Modify Existing OrderLines

在清单 15-6 中,我们有两个额外的方法,一个更新订单行的数量,另一个删除订单行。这看起来相当不错;然而,它缺少了一个重要的部分,这将使整个系统无法使用。你能指出我们忘记包括的是什么吗?

总金额!通过更改订单行的数量或删除一个订单行,我们基本上需要更新订单的总金额(包括为每个订单添加的税)。请记住,对于这个示例状态中列出的不变量,订单的数量需要随时更新,因此我们需要再次修改Order类以包含该逻辑(清单 15-7 )。

<?php

//namespace & use statements

class Order extends Model
{
     //properties and method definitions

     /**
     * @param $sequence : The location of the order line in the array
     */
     public function removeOrderLine($sequence)
     {
          if (isset($this->orderLines[$sequence])) {
               $orderLine = $this->orderLines[$sequence];
               $totalAmountDelta = $orderLine->product->price +
                    ($orderLine->product->price * static::TAX_RATE);
               $this->total -= $totalAmountDelta;
               unset ($this->orderLines[$sequence]));
          }
     }

     public function updateQuantity($sequence, $newQuantity)
     {

          if (isset($this->orderLines[$sequence])) {
               //get the product that corresponds to that order line:
               $orderLine = $this->orderLines[$sequence];
               $product = $orderLine->product;
               //remove the orderLine completely from the array:
               $totalAmountDelta = $product->price + ($product->price
                    * static::TAX_RATE);
               $this->amount -= $totalAmountDelta;
               unset($this->orderLines[$sequence]);
               //we dont have to worry about adding the product's
               //tax because that logic is already in addOrderLine():
               $this->addOrderLine($product, $newQuantity);
          }
     }
}

Listing 15-7Additional Methods for the Order Class Needed to Modify Existing OrderLines

这样看起来更好!现在,每次使用Order作为聚合根对OrderLine进行更改时,我们都会更新总金额,并且在OrderLine中,每个产品包含的销售税也是不变的。

这里仍然有一个疏忽。聚合根应该有一个全局可访问的身份——它们确实有——并且有一个围绕其他模型的边界。此外,它们中的每一个都应该只能从聚合根访问,而不能从全局上下文访问,这很难停止,但通过聚合根上的命名工厂方法变得显而易见,这就是我们最初在清单 15-3 中所做的。我们利用了Order类上的一个工厂方法来创建各种LineOrder对象,这些对象需要用来表示现实生活中Order的各个方面(这是建模的定义)。然而,我们很快发现这种方法缺少一些我们必须拥有的东西,以确保Order aggregate: transactions 中的数据一致性。我们从addOrderLine()方法中移除了对associate()save()的调用,并使用一个原始数组来保存非持久化的OrderLine对象(并且与Order无关)。然后,在结帐时,我们将实体持久化到数据库的实际代码放在startCheckout()方法中,使用一个事务来确保不同目标表的记录的一致性,将转换后的模型写入这些表。

活动采购

事件源是一个大而深的主题,我不会在本章中深入讨论,但在本书的后面,我们将通过一个简单的场景,使用 Laravel 的包 EventSauce ( https://eventsauce.io/ )使用事件源建模。它是高度可定制的,并为您提供了定制其行为方式的几乎每个方面的灵活性,以满足您的需求。一般来说,事件源是一个极其复杂的考验,大多数 web 应用项目都不推荐使用它,因为向现有应用添加事件源或基于事件源启动一个新的应用需要原始开销和技术诀窍。如同生活中的任何事情一样,事件采购也伴随着取舍。大多数应用不需要事件源提供的复杂程度。当您面临以下问题时,应该使用它:

  • 在领域模型中跟踪实体生命周期中的每一个变化的需求

  • 需要对您的模型执行审计

  • 需要一个异步解决方案来处理各种数据和服务的大量请求

  • 当需要在聚合系统中使用事务来保持数据库和应用中的数据一致时

结论

在本章的开始,我们介绍了一些事务的基本理论,它们是如何使用的,以及它们如何帮助保持数据库中的数据一致,并确保数据库的更新是以原子的方式完成的。事务是持久化聚合中涉及的基本概念。可以用缩写 ACID 来记住事务的特征,ACID 代表原子性、并发性、隔离性和持久性。

我们讨论了集合对象和对象工厂的特征。大多数时候,一个工厂方法实际上是使一个集合设计工作及其所有不变的检查和平衡所需要的。聚合基本上是一个边界,其中包含了封装在所谓的聚合根后面的几个不同的模型类。聚合根是“预先”模型,任何访问聚合内部对象的请求都必须经过这个模型。我们需要小心不要直接访问内部对象,尽管实际上这是无法避免的。我们可以通过在聚合根上提供方便的方法来减轻这一点,这些方法将为我们处理访问或修改内部对象,从而保持聚合根中包含的不变量和业务规则。这在高层次上是有意义的,因为我们不需要直接访问聚合的内部部分,而是依赖于聚合根模型中的操作。

我们看了一个可能的聚合的例子,Order聚合,在其聚合边界内有一个内部的OrderLine对象和一个充当聚合根的Order对象。工厂帮助我们保持边界完整,并且通常聚合应该只在必要的地方使用,因为它们会导致用于对域建模的代码的开销和维护的增加,以及增加不必要的复杂性,这会将注意力从域模型转移到使聚合在应用的其余部分工作上,或者甚至将聚合保存在数据库中。复杂的应用值得使用聚合和事件源。

十六、服务

我把这一章留到了本书的这个地方,因为您需要一些原始知识来充分利用服务。创建服务应该谨慎而精确,因为服务层有变得不完善的趋势,这是当太多的业务逻辑放在其中时经常发生的事情,通常会导致核心价值对象和实体(最重要的事情是正确的)被剥夺所有与现实领域中存在的关注点和业务逻辑对称一致的行为。这不是一件好事,因为它基本上使我们的值对象和实体充当纯粹的数据容器,而不是它们实际应该充当的角色:将行为和数据封装在其中的对象,以便它们可以更好地建模和表示领域。

在这一章中,我们将回顾我在本书第一章中介绍的三种类型的服务的例子。然后,我们将探讨当服务层被过度使用时出现的相关问题,以及我们如何避免走向一个贫血的领域模型。答案部分在于养成不首先对数据建模的习惯,这是许多开发人员似乎自然而然会做的事情,因为这是他们最初学习的方式。这不是一个好的做法,因为当我们从数据的角度考虑事情时,我们基本上是在添加具体的结构,如果不是因为其他对象对数据的行为使它变得有趣,这些结构将是一个类上枯燥的静态结构或属性。相反,当对一个领域建模时,试着考虑模型的行为将如何运作,以及它需要执行什么操作来满足领域的关注。根据无处不在的语言来命名这些行为,只有当它们确实不符合实体或值对象的清单时,才在服务中放置这些行为。

我们还将探索 Laravel 作业的特征,这些特征使它们成为可能被定义为“服务”的简单实现可以从应用中的任何地方分派作业,将作业放在一个作业堆栈上,安排在某个时间运行,并将其发送给队列工作器,该工作器处理封装在其中的独立逻辑(也包含在 Laravel 中)。那种工作完全可以作为一种服务。我们将讨论在一个Job类上使用一些不同特征作为 DDL 上下文中服务的替代品的可能性。

服务入门

对我来说,一个服务可能需要跨多个类或对象来定义,这些类或对象参与建立服务功能所需的输入或修改。无论是应用、基础设施、UI 还是领域关注点,在构成应用整体的逻辑部分之间划分界限并不总是简单明了的——正如有人可能会说,他们在 Laravel 中的“应用服务”实际上是一个具有特定验证需求的请求,以及一个将该请求交付给域内服务或组件的控制器。或者,应用服务可以是属于单个类的独立服务,例如,SignupService

服务对于捕获不符合实体或值对象的通常职责的业务流程非常有用。然而,我认为服务经常被过度使用,以弥补新手设计或缺乏经验的开发人员或团队采用的方法。这并不是说服务在现实世界中没有一席之地。比如看微服务。对于许多不同的技术相关公司来说,它们几乎是当今行业中事实上的标准。但是,我们不是从这个角度来谈论服务。微服务之所以不同,可以说是因为运营的规模不同。它们是比我们在本书中要涵盖的更广泛的概念,因为它们封装了许多其他嵌套的组件和逻辑。我所指的服务属于这三类中的一类(正如我在本书开头提到的)。

  • 应用服务:这些服务对通过某种请求传递的原始值进行操作,将它们转换成域指令,这些指令被分派给域服务或其他对域对象进行操作的组件。考虑一个 Laravel 请求,它抽象了接受来自应用外部的输入所需的这种交付机制。与控制器一起,它们完成了“标准”应用能够或将要完成的任务,使应用与外部世界之间的交互成为可能。

  • 基础设施服务(infra structure services):通常,这些服务处理基础设施问题,比如登录或发送电子邮件。在 DDL 的情况下,基础设施问题可以被认为是与 Laravel 框架的任何交互,以及持久性机制。

  • 域服务:这些服务只在域对象上操作,如域所要求的值对象或实体。就 DDD 而言,领域服务主要由业务逻辑组成,这些业务逻辑在我们为表示应用中的实体而建立的雄辩模型上运行。

同样,关键是创建一个轻量级的、瘦的、无状态的服务,并且只有当工作不太适合实体或值对象时才创建它。在下面的例子中,我们将构建获取一个Claim对象并将其正式提交到系统中所需的服务。我们将使用一个服务来处理这个操作,因为“提交声明”的概念不太适合实体或值对象。我们将使服务成为一个无状态的操作,并且在其中只包含提交索赔所需的东西。这里需要注意的是已经对Claim对象进行了验证和检查,以确保它 100%有效并准备好提交。我们将只关注将索赔提交到系统中的应用部分。我们将使用领域事件来通知应用的其他部分关于提交的信息,这样其他有界的上下文就可以以它们自己的方式做出反应。

提醒您一下,索赔是由一个提供商提交的,必须对其进行多次验证,以使其符合状态PENDING_REVIEW。当我们回顾应用的验证上下文时,我们在本书的前面讨论了大部分验证。我们核实了如下情况:

  • 索赔是在去年提交的。

  • 提交索赔的提供商已在索赔中正确链接到患者。

  • 索赔要求附有程序代码。

  • 这些程序代码是有效的,并且属于该提供商的有效薪资代码表。

要更详细地了解索赔进入系统所需的验证,请查阅第八章。现在,我们需要构建一个实际处理索赔提交的服务。我们将把这个例子放到 DDL 的上下文中。这意味着我们将利用一份简单的工作来做我们的脏活。请记住,一项工作可以作为一项服务来使用,主要是因为它所使用的特征。那些特征是DispatchableInteractsWithQueueQueueableSerializesModels。它们允许 Laravel 中的作业由队列工作器进行排队。Dispatchable特性允许通过助手函数dispatch()分派任务。我们可以从应用的任何地方调用这个助手。特性InteractsWithQueueQueueable允许将作业推入队列,并提供检查作业状态的方法。SerializesModels特征允许我们将一个雄辩的模型直接传递给我们作业的构造器,当它实际上被放入队列工作器的栈中时,它将被序列化并被优雅地反序列化。我们将使用 Laravel 的Job组件作为我们服务类的基础。这种方法最好的一点是,任何类型的服务都可以从工作中获得。

我们将构建的第一个服务是SubmitsClaims作业。这项工作唯一关心的是运行我们之前在ClaimValidationHandler中设置的验证,这并不太难,因为我们已经在方法validate().下将处理程序合并到了Claim模型中,因此,我们所要做的就是接收我们已经创建的请求ClaimSubmissionRequest,它包含了所有细化级别的需求。您可以使用 Laravel 的Request组件和它的Validation组件来指定所有类型的验证约束。

这是一个需要考虑的重要概念。在这里,我们有机会将交付机制为控制器或回调(在路由文件中指定的任何一个)提供的数据的几乎所有约束和要求联系起来。交付机制本身将通过我们在请求中指定的约束来处理传入数据的运行。一旦它命中控制器方法,它就已经生效了。您不能或不想放在请求上下文中的所有验证(如域验证)都可以在控制器中处理,这可以像清单 16-1 一样简单。

<?php

use Claim\Validation\Domain\Rules\ClaimHasProviderAttached;
use Claim\Validation\Domain\Models\ClaimDateOfServiceIsValid;

$request->validate([
     new ClaimHasProviderAttached(),
     new ClaimDateOfServiceIsValid()
]);

Listing 16-1Example Usage of Laravel Rules

注意,在清单 16-1 中,我们没有将我们的规则命名为类似于ClaimWasSubmittedWithinOneYear的名称,这将迫使我们运行只允许一年服务日期的约束,我们选择了名称ClaimDateOfServiceValid,这与我们的领域驱动设计焦点非常一致,并以它命名。如果我们曾经想要改变索赔被认为有效的日期范围,我们将不得不在Validation上下文中创建另一个规则,这将有更多的代码和更多的工作要维护,并创建更多的地方来改变。相反,我们通过用领域中的一个基本概念来命名规则,来保持与领域和无处不在的语言的一致性。

应用服务程序

让我们创建一个应用服务,它将接受类型为ClaimSubmissionRequest的请求,对其运行任何验证,然后将处理声明的提交和持久性的实际工作转发给一个域服务。为了创建这个应用服务,我们将使用一个标准的 Laravel 控制器,它将接收输入请求(由作为我们的交付机制的ClaimSubmissionRequest抽象),处理验证的运行,分派一个域服务(这将是一个 Laravel 作业),并返回一个响应。当我们将数据发送到我们的服务时,可以使用一个简单的 DTO 来抽象所讨论的数据传输(声明)(在这种情况下相当于分派一个作业)。参见清单 16-2 中的示例。

<?php

namespace Claim\Submission\Application\Http\Controllers;

use Claim\Validation\Domain\Rules\ClaimHasProviderAttached;
use Claim\Validation\Domain\Models\ClaimDateOfServiceIsValid;

class SubmitClaimController
{
     public function submit(ClaimSubmissionRequest $request)
     {
          $request->validate([
               new ClaimHasProviderAttached(),
               new ClaimDateOfServiceIsValid()
     ]);

     $claimDto = new ClaimDTO($request->all());

     $response = $this->dispatch(new SubmitClaim($claimDto));

     return new JsonResponse($response, 200);
     }
}

Listing 16-2Example Application Service Calling Our Domain Service

如果你不记得ClaimSubmissionRequest到底是什么样子,请查看清单 16-3 。

<?php

namespace Domain\Submission\App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ClaimSubmissionRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
         'claim.patient.first_name' => 'required|text|min:2',
         'claim.patient.last_name' => 'required|text|min:2',
         'claim.patient.dob' => 'required|date',
         'claim.patient.medical_number' => 'required|integer',
         'claim.progress_notes' => 'required|min:1',
         'claim.patient.documents.identification' => 'required|file',
         'claim.patient.documents.application' =>  'required|file'
     ];
     }
}

Listing 16-3A Request Object for Submitting the Claim

前面的请求对象封装了提交索赔所需数据的必填字段和详细约束。在这种情况下,控制器和请求一起构成了应用服务。它抽象了交付机制,以便我们可以专注于处理请求。在我们的例子中,这相当于实例化一个简单的 DTO,它只是一个数据容器,保存我们从(有效)请求中提取的所有数据,然后从控制器中调度一个域服务,为我们处理脏工作。实际上,我不想麻烦地列出ClaimDTO对象的代码清单,因为它只是一个普通的 ol' PHP 对象,claim 对象上的每个字段都有 getters 和 setters。

Note

需要考虑的是,既然我们已经选择利用 Laravel 的 HTTP 请求/响应周期作为我们的应用服务的框架,我们就剩下了众多的对象,它们一起工作来完成我们需要它做的事情。一些应用(例如简单的应用)可能更适合采用更统一的方法,将服务的核心逻辑以及围绕它的各种关注点放在一个类中,这样更容易找到和调试。然而,如果应用的规模不断增长(90%的情况都是这样),那么随着越来越多的问题混合在一起,对该服务的维护和更新将变得更加麻烦。一般来说,我认为最好将关注点与领域的组织内联分离开来(这反映了无处不在的语言中的术语,在开发软件时应该总是将其作为参考点)。

然而,通过这种方式,我们已经注意到了许多问题,这些问题对于促进这样的应用是绝对必要的。对于进入应用的请求,我们有自己的交付机制,由路由器处理的此类请求的路由,由我在本书前面介绍的ClaimPolicy类维护的请求的安全性,以及关注点的清晰分离。此外,在调用该应用服务时,我们可以保证传递给它的索赔数据由我们的验证有界上下文进行验证。我得说我们做得很好。我们直接用领域中的概念来命名事物,这些概念来源于无处不在的语言,这对于真实地捕捉也很重要。

这是创建应用服务的一种方式——通过使用 Laravel 的内置工具和流程,将交付机制从作用于它的代码中抽象出来。如果您想创建一个更独立的应用服务,您可以在应用的正常 HTTP 进程之外使用一个单独的类,它可以处理您需要实现的任何应用问题。做到这一点的一个好方法是使用命令模式。这种模式允许您根据一个接口编写应用服务,以便以后进行简单的修饰(这在处理事务时很有用),并强制在同类型的其他服务之间很好地分离关注点。您可以将以这种方式生成的每个服务视为一个命令,它可能带有自己的处理程序(因为最好的做法是将请求中的数据与执行该请求的代码分开)。或者,您可能想将这些东西分离出来,用一个不同的Command对象保存数据(没有行为),编写一个接受Command对象的处理程序类,并使用命令中的数据执行特定于那个Command的给定功能(命令的实际运行发生在处理程序类中)。

你可以使用许多开源包来奠定你的命令/处理程序模式的基础,比如 Tactician,由联盟制作的非凡包( https://tactician.thephpleague.com/ )。由 ThePhpLeague 的人制作的包和库保证是经过测试的,并且在许多软件项目中有很多开发人员的支持和使用。您可以相信这些包将会达到它们的目的,我强烈推荐您在需要的时候使用它们。

命令从命令行运行(不要与 Artisan 命令混淆)。它们有助于捕捉用户的意图。它们可以被认为是来自应用外部的动作,直接表达了用户的真实意图。命令本身不应该包含业务逻辑,而应该只包含关于“做什么”的指令,而命令处理程序包含“如何做”的指令清单 16-4 展示了一个使用 Tactician 库的示例应用服务。注意,这个服务几乎完全符合 Laravel 的 HTTP 周期中的工作方式(使用一个定制的 Laravel 响应,细节是根据我们的特定用例定制的,还有一个控制器)。

<?php

namespace App\User\Services;

class SignupUserCommand
{
     protected $username;

     protected $password;

     protected $role;

     public function __construct(Username $username, Password $password, Role $role)
     {
          $this->username = $username;
          $this->password = $password;
          $this->role = $role;
     }
}

Listing 16-4Stand-Alone Command

注意,前面的命令没有任何功能,只是处理命令所需数据的基本存储容器。清单 16-5 显示了实际的处理程序,以及它的调用代码。

<?php

namespace App\Services\Users;

class SignupUserHandler
{
     public function handleSignup(SignupUserCommand $command)
     {
          //core application logic goes here
          echo "User " . $command->username . " was signed
               up!";
     }
}

//somewhere in the code

use League\Container\Container;
use League\Tactician\Handler\Mapping\ClassName\Suffix;
use League\Tactician\Handler\Mapping\MapByNamingConvention;
use League\Tactician\Handler\Mapping\MethodName\
     HandleLastPartOfClassName;

//configure Tactician's middleware to support the naming
//convention derived from the project's Ubiquitous Language
$container = new Container();
$container->add(SignupUserCommand::class);

$handleMiddleware = new League\Tactician\Handler\
     CommandHandlerMiddleware(
          $container,
          new MapByNamingConvention(
               new Suffix('Handler'),
               new HandleLastPartOfClassName()
     )
);

$commandBus = new \League\Tactician\CommandBus
      ($handlerMiddleware);

//in a controller
$command = new SignupUser();
$command->username = $request->username;
$command->password = $request->password;
$command->role = Role\Moderator::class;

$command->handle($command);

Listing 16-5A Command Handler

Tactician 的工作方式是通过中间件“插件”来完成一切,包括它的配置。这是为了在为您自己的项目编写命令和处理程序时实现最大的可扩展性。在前面的清单中,我们用一个定制的命名约定启动了 Tactician,以支持领域模型中无处不在的语言。在前面的清单中,我们已经配置了 Tactician 来查找对应于命令名最后一部分的处理程序。然后,它将为您实例化的任何命令自动定位正确的处理程序。如需了解有关该产品包的更多信息,请访问他们的网站。

无论您选择如何实现应用服务,都要采用具有最佳关注点分离的方法和最适合您的领域模型的方法,以便为您的领域提供最佳结果。让所有服务保持无状态、精简,并专注于与同一个实体或关注点相关的单个任务或一组任务。

域服务本身不用担心验证组成声明的数据,可以专注于创建声明,并可以调度基础设施层中的功能来持久化对象(这可以使用 concious 的 facades 内联完成,也可以位于单独的类或对象中,或者可以由存储库处理,具体取决于您的应用的需求)。

基础设施服务

这些服务与应用基础设施级别的问题有关,这些问题与日志记录、持久性以及类似的事情有关。基础设施服务是支持其层之外的其他服务和关注点的服务。作为一个例子,考虑我们之前创建的应用服务,在给定用户名和密码的情况下,在系统中注册一个新用户。假设我们想要包含一个在注册过程中运行的密码散列机制。假设我们想要实现几种不同的密码散列机制,并且我们想要能够选择在运行时使用哪种实现。创造这个的最好方法是什么?

我们可以决定为密码散列机制实现一个独立的接口模式或一个策略模式。第一步是定义某种接口来表达密码散列机制的一般概念。下面是一个简单的界面:

<?php
namespace App\Contracts;
interface PasswordHash
{
     public function hash(): string;
     public function setPlainPassword(string $plain) : void;
}

为了防止所有的子类都必须实现setPlainPassword()方法,我们可以创建一个抽象类来实际实现接口,然后让子类来扩展它。

<?php
use App\Contracts\PasswordHash;
class AbstractPasswordHash implements PasswordHash
{
     public string $plain;

     abstract public function hash(): string;

     public function setPlainPassword(string $plain): void
     {
          $this->plain = $plain;
     }
}

下面是上一个接口的可能实现,使用 MD5 哈希机制:

<?php
use App\Contracts\PasswordHash;

class Md5PasswordHash extends AbstractPasswordHash
{
     public function hash(string $plainPassword): string
     {
          return md5($plainPassword);
     }
}

这是 bcrypt 机制的另一个例子:

<?php
use App\Contracts\PasswordHash;
class BcryptPasswordHash extend AbstractPasswordHash
{
     public function hash(string $plainPassword): string
     {
          return bcrypt($plainPassword);
     }
}

我们可以使用 Laravel 的服务容器轻松地将这些连接起来,然后在调用哈希机制之前配置应用使用特定的容器。这可以在配置中完成,配置很容易更改,因为设置位于一个位置且仅位于一个位置。另一种方法是动态配置要执行的机制,并使设置更接近使用它的代码,这与用户注册过程是内联的(可能在一个控制器中,该控制器将决策传递给执行散列的服务,以便使服务不知道我们决定使用哪个散列策略)。清单 16-6 展示了这种策略的服务容器配置。

//inside the AppServiceProvider's boot() method:
$this->app->bind('HashingMechanism', function() {
     switch (config('hash.password')) {
          case 'md5':
               return new Md5PasswordHash();
               break;
          case 'bcrypt':
               return new BcryptPasswordHash();
               break;
     }
});

Listing 16-6Binding the Hashing Mechanism to the Service Container

在前面的清单中,我们将一个在闭包中定义的实现绑定到服务容器,该实现基于放在相应配置文件中的配置值,该配置文件或者返回在HASHING_MECHANISM键下的.env文件中指定的值,或者设置一个静态默认值。

//inside the /config directory, in a "hash.php" config file
'password_hash' => env('PASSWORD_HASH', 'md5'),

这样做使我们能够通过简单地修改.env文件,而不是库中的任何代码,来改变我们想要用来散列密码的机制的类型。这符合开放以扩展/封闭以修改实体的原则。然而,这个确切的例子可以有所保留:它作为参考很好,但是在 Laravel 应用的上下文中可能没有必要实现这样的东西,因为我们可以只使用Hash facade 并直接利用 make 方法。这种方法确实以一种明显的、非侵入性的方式促进了关注点的良好分离。

域服务

领域服务主要在领域对象上操作,并促进对实现领域支持模型所需的核心功能至关重要的业务流程。回到索赔示例,域服务可以用于验证索赔是否已准备好提交(它将存在于验证上下文中),检查患者是否在主要提供者处注册,提交索赔,以及启动屏幕截图来验证资格。一次性脚本作为服务工作得很好,这可能是因为需要回填数据或对数据库进行定制修改以修改其数据。我们将为提交索赔的问题构建一个可能的解决方案,如清单 16-7 所示。

<?php

namespace Claim\Submission\Domain\Claim\Services;

use Claim\Validation\Domain\Services\
    PatientEligibilityScraper;
use Claim\Validation\Infrastructure\
    Validators\ClaimValidationHandler;
use Claim\Submission\Domain\Services\Estimate\ClaimEstimator;
use Claim\Submission\Domain\Models\Claim;
use Claim\Submission\Domain\ValueObjects\Signature;
use Claim\Submission\Domain\Events\ClaimWasSubmitted;
class SubmitClaim extends Job implements ShouldQueue
{
     use Queueable, InteractsWithQueue, SerializesModels, DispatchesJobs;

     protected Claim $claim;

     protected Signature $signature;

     protected PatientEligibilityScraper
          $patientEligibilityScraper;

     protected ClaimEstimator $claimEstimator;

     protected ClaimValidator $claimValidator;

     public function __construct(Claim $claim,
          Signature $signature,
          PatientEligibilityValidator
               $patientEligibilityValidator,
           PatientEligibilityScraper
               $patientEligibilityScraper,
          ClaimEstimator $claimEstimator)
     {
          $this->claim = $claim;
          $this->signature = $signature;
          $this->patientEligibilityScraper =
               $patientEligibilityScraper;
          $this->claimEstimator = $claimEstimator;
          $this->claimValidationHandler =
               app()->makeWith(ClaimValidationHandler::class, $claim);
     }

     public function handle(): void
     {
          //run standard validations (see Chapter 8)
          $this->validate();

          $claim = $this->claim;

          if (!is_null($claim->progressNotes) &&
               $claim->checkDateOfService() &&
               $claim->userCanSubmitClaim(auth()->user())) {
               //claim is now considered validated
               $provider = $claim->primaryPhysician;
               $cptCodes = $claim->cptCodeCombos
                                 ->cptCodes
                                 ->toArray();

               //get the estimated amount of claim
               $claim->estimatedAmount = $this->claimEstimator
                    ->estimate($provider, $cptCodes);

               //delegate scrape operation for eligibility
               $patient = $claim->patient;
               $claim->patientEligibility = $this->patientEligibilityScraper
                         ->scrape($patient);
               $claim->signature = $signature;
               $claim->state->transitionTo(PendingReview:: class);
               $claim->save();

               //send an event notifying listeners that a
               //new claim has been entered into the system
               event(new ClaimWasSubmitted($claim));
          }
     }

     private function validate(): void
     {
          try {
               $this->claim
                    ->validate(
                         $this->claimValidationHandler);
          } catch (MissingDocumentsException $e) {
               //log & throw error
          } catch (InvalidCptCodeException $e) {
               //log & throw error
          } catch (MissingEligibilityError $e) {
               //log & throw error
          } catch (PatientNotRegisteredWithProvider $e) {
               //log & throw error
          }
     }
}

Listing 16-7Example SubmitClaim Domain Service

前面使用的claimValidator是对服务容器调用makeWith()的结果,表明该服务的构造函数中存在容器无法自动解决的依赖关系,需要手动提供。在大多数情况下,这意味着所讨论的类或服务具有运行时依赖性。我们通过向ClaimValidator传递所需的$claim对象来解决这个问题,它看起来像这样(如果你忘记了):

class ClaimValidator extends AbstractValidator
{
     private $claim;
     private $validationHandler;

     public function __construct(Claim $claim, ValidationHandler $validationHandler)
     {
          parent::__construct($validationHandler);
          $this->claim = $claim;
     }

     /** see the end of chapter 8 for a full listing */
}

然而,我们选择将对验证声明本身的逻辑的调用放在Claim对象中,这样它就尽可能靠近使用它的代码。在我们运行了包含在ClaimValidationHandler中的验证之后,我们确保提交到系统中的索赔的其他要求得到满足。这包括检查 progress notes 字段是否存在,检查服务日期是否在可接受的范围内,以及检查提交报销申请的用户是否确实获得了授权。在这些检查之后,我们可以假设索赔是有效的,并继续这个过程。

我们使用另一个服务ClaimEstimator,来确定索赔将支付给提供者的估计金额(如果 FQHC 接受的话)。该服务获取索赔上出现的提供商和 CPT 代码,进行一些计算,并返回一个金额(以美元计),然后我们将该金额存储在索赔中。在这之后,我们委托给另一个服务ClaimEligibilityScraper,该服务将运行一个逻辑,该逻辑将抓取站点并返回一个资格检查的屏幕截图,该截图将确定索赔中出现的患者是否有资格接受护理。截屏将是人工审查者在人工审查阶段必须检查的另一件事,这是索赔过程的下一步。声明也需要有一个授权签名,我们已经将它封装在一个Signature值对象中,并附加到声明中(也将被审查)。最后但同样重要的是,我们将索赔的状态(我们在本书前面设置的)转换为PENDING_REVIEW,然后我们向应用的其余部分发出一个事件,这样监听器就可以响应一个新索赔已经进入系统的事实。

最酷的部分是,我们甚至不必担心或关心什么是听事件。我们只需发出附有相应事件数据的事件,在这种情况下是声明,框架将处理分派。

测试服务

我们在第九章中讨论了这个场景,当时我们编写了关于一个声明可能处于的状态的测试,并且我已经展示了一个可靠测试的基本需求,它确保了从没有状态(或者一个DRAFT状态)到有状态PENDING_REVIEW的转换。考虑到这一点,我不会在文本中包含任何代码,但是你可以随时在线查看知识库,查看为领域模型中的类和对象编写的测试,也可以参考第九章。我确实想留给您一些额外的想法,关于您可以对您的代码执行的可能的测试,以确保它做它应该做的事情(并且不弄乱过程中的任何其他事情)。Laravel 附带了 PHPUnit,它为测试应用提供了很多很酷的助手类和组件。它还附带了允许您在前端测试应用的类,不像 Selenium 和 Google WebDriver 那样复杂,但它仍然有很好的用途。考虑一下服务的测试会是什么样子。例如,本章开头描述的应用服务(利用 Laravel 的 HTTP 组件的服务)可以用多种不同的方式进行测试,比如创建一个功能测试来整体测试一个特定的类(比如SubmitClaim服务)或者一个单元测试,它可以像为每个项目方法编写一个测试方法一样细粒度,但是这对于测试来说是多余的。事实上,编写测试(以及它们的复杂性)会让你陷入困境,以至于你很难从中受益。相反,要专注于编写充分覆盖应用中主要关注点的测试。集成测试非常适合这个目的,因为它们旨在测试各种领域相关代码的不同结果的组合结果——这些部分对应用的健康至关重要,对核心领域的整体功能也很重要。

结论

不管有没有框架,服务在开发应用时都有一席之地。最好用它们来代替那些不被认为是或不能被认为是实体或值对象的东西。在应用中为这些项目创建一个瘦服务层,这些项目根据各自所属的层进行分组。但是请记住,您在服务中包含的实际业务逻辑越多,就越容易转向一个缺乏活力的服务层,这是一件坏事。避免这种情况的最简单的方法是在实体和值对象中表达大多数与业务相关的功能。

有三种不同类型的服务,应用、域和基础设施,它们都有特定的用途。应用服务用于抽象交付机制,并负责将传入的请求转换为应用可以理解的内容。域服务直接处理域对象,并简化系统中不适合值对象和实体的过程。基础设施服务的任务是处理诸如日志记录、持久性甚至密码散列机制等问题。

十七、六边形驱动开发

六边形架构基本上是另一种思考、组织和建模应用的方式(相对于传统的 MVC 模式架构)。在六边形体系结构中,我们将域模型本身包含在应用的核心中(就像它应该包含的那样),其中的层处理围绕它的域模型中对象的简化和管理(应用层)。最后,还有一个接口层,它包装了所有的东西,并通过使用端口来建立客户端需要满足的接口,为请求进入应用内部并与之交互提供了一种方法。那些满足端口契约的实现被称为适配器

饭桶

Eric Evans 将六边形结构的工作方式与我们细胞膜允许各种分子进出细胞的方式联系起来。这些分子通过闸门或膜内允许它们通过的通道向两个方向流动。类似地,在六边形架构中,六边形的每个边都可以被认为是细胞膜的通道,代表限制什么可以进出细胞(或应用)的各种端口。在这种情况下,分子代表实现某种目的(在这种情况下是请求和响应)的适配器(具体化)。您可以将端口视为“抽象”,将适配器视为“具体化”端口基本上定义了适配器为应用的各种设施实现的接口。这些接口确保我们的应用可以使用特定的方法,而不管该接口的实现如何。每个请求进入系统的方式取决于请求的类型。我们可以通过使用一个易于使用的 Laravel 请求来抽象处理请求所需的交付机制(该请求是在给定时间从该机器/实例上存在的 PHP 超级全局变量自动构造的)。将它与六边形联系起来就相当于放置了一种主要的“输入”边,这种“输入”边是特定于您的项目需求的任意多边形。可能只有三个或四个侧面对应于系统的各种“中心”关注点,这很可能不会从围绕六边形架构和实现增加的复杂性中受益。另一方面,它甚至可能更复杂,需要形状的附加边。

Note

名称 hexa gonal 实际上并不意味着端口可以有六个边。这种类似六边形的结构实际上根本没有提到数字 6,而是意味着更多地被认为是一个圆形(而不是像分层架构那样的层次结构);它代表“外”和“内”,而不是“上”或“下”。

一个适配器可以代表端口中定义的接口的无限数量的具体实现。一般来说,它们要么向内朝着中心(域模型)行进,要么向外远离中心(响应)。图 17-1 包含与六边形架构相同的概念,只是用圆圈表示。

img/488324_1_En_17_Chapter/488324_1_En_17_Fig1_HTML.jpg

图 17-1

圆形六边形建筑

如果这个数字看起来很熟悉,那是有原因的。我在第一章中加入了一张类似的图片。这只是一个更高级的建模架构,允许更大的灵活性,并且比分层架构更容易测试。因为端口基本上是进出系统的请求/响应的接口,所以您可以根据需要创建任意多个实现端口接口的适配器。这允许将应用代码的几乎任何部分(域模型之外)换成同一端口接口的其他实现,而不必更改应用内使用这些接口的客户机代码。hexagonal 的另一个好处是,它允许您推迟架构设计决策,直到您对其核心有了更好的理解,对应用的真正需求有了更多的了解。

Note

端口也可以是命令或查询总线。在这种情况下,驱动适配器可能只是正在使用的实际CommandQuery的实现,并被注入到控制器中,控制器构造具体的命令或查询,并将其传递到相关的总线。

顺便说一下,如果你想知道为什么之前的形状不是六边形而是圆形,那是因为实际的形状或边数完全是任意的。

通过这种松散耦合的设置,我们可以很容易地将领域关注点与系统的所有其他方面隔离开来,这也是领域驱动设计中的一个中心焦点,允许我们关注最重要的东西——业务逻辑。这种隔离使领域模型成为关注的中心,并将系统的输入和输出放在整个系统架构的边缘。

六边形的外部边缘只是一组指定的输入和输出方式(请求和响应),由端口通过接口(也称为抽象)定义,由适配器实现(具体实现),以促进与位于模型更深层次的应用内部的交互。六边形上的一条边(或圆周内的一点)属于从外部世界到我们的应用的通信的单个入口点(即,每条边都有一个与外部通信的理由)。

接下来,我们将讨论应用的另一个可能层,它位于六边形或圆形的最外侧,由端口(和适配器)组成,负责接受来自外部的请求,并将该请求路由到需要进入内部的位置。它被称为基础设施层,并不总是需要被定义为除其他三层之外的独立层,但是,随着代码库的复杂性随着时间的推移而增长,当我们增加复杂性时,正确建立这样一个层会被证明是有价值的。

就六边形架构而言,图 17-1 中描绘的最外面的区域对应于负责接受请求的层。这是有意义的,因为在 Laravel 中,可以通过定义与进入系统的请求类型相关的特定端点来实现应用的一组访问点。

  • routes/web.php:对应于浏览器请求的端点

  • routes/console.php:对应于 CLI 的端点(Artisan 命令)

  • routes/broadcast.php:对应于广播请求的端点

  • routes/api.php:对应于内部或外部 API 请求的端点

六角形的建筑带来了什么

六边形方法在正确执行时会带来以下积极的好处:

  • 可维护性

  • 减少技术债务

  • 更轻松的进步

  • 对代码的更改不会影响其他代码

  • 更容易、更快速地添加功能,只需更少的代码就能使它们发挥作用

  • 更多分离的组件

  • 非常少的重复代码

技术债务

技术债务是在项目中积累的任意数量的工作(以开发时间的形式),当决策制定得太快,并且太不注意破坏应用中任何已经存在的特性时。每次我们被迫对旧的、令人厌恶的遗留软件(自然,企业完全依赖它作为其收入流的主要部分)进行“紧急”修复,并且几乎总是包括在应用的随机部分添加相同的低质量代码风格,我们都在增加项目的整体技术债务,并最终导致软件的最终消亡。这是因为随着新的特性不断被添加到现有的代码中,在基础设施级别将所有东西结合在一起的核心基础最终会在自身的重量下崩溃。

技术债务在代码库中累积的一个原因是不适当的架构基础,这可能会很快使推出功能的过程陷入停顿。这样做的原因与前面提到的遗留系统的情况相同:理论上,我们将在坏代码的上面堆积好的代码,并且因为不是所有的错误都在软件测试期间被正确地解决,许多这样的问题很可能只在实时会话期间被发现(可能来自您的一个用户)。

有一些策略和技术可以用来防止这种技术债务感染系统。它们中的大多数都是管理大多数(好的)现代 web 开发项目和应用的最佳实践。

  • 基础(Foundation):在你开始在基础上增加新的类和组件之前,这是非常重要的。并不是说它必须是完美的,但是足够的讨论、会议和试验(很可能导致失败)应该根据需要发生,以创建应用的核心基础和结构的起点。正确识别应用的有机分离点(以域、核心域、子域、有界上下文和模块的形式)就属于这一类(如果我可以直接借用域驱动设计上下文中的术语的话)。

  • 可维护性:易于维护领域模型对于领域驱动设计和六边形架构都很重要,因为这是不可避免的。不管您在哪个领域工作,在某些时候都需要对核心模型进行重构。在第一次尝试中,您有很小的机会能够获得正确的领域模型。可维护性发挥作用的地方在于,在不影响组成系统的其他组件的情况下,更改代码并使其适应系统的新需求是多么容易。稍后,我们将讨论如何保持应用的长期可维护性,但简单的回答是,我们需要它能够容易地改变。可维护性应该是(或者说必须是)一个长期目标,随着时间的推移,这个目标的复杂性会增加。

  • 封装变化:在前一项的基础上展开,任何系统都注定会发生变化。这可能包括改变网页的标题模板上的文本,以彻底修改旧的、过时的过程(同时继续在其上构建新的功能),这些新的功能来自先前添加到代码库的内容。重要的是我们实际上如何着手进行这些改变。最好的方法不仅是在组件/类/集合/名称空间之间画出边界,而且要把一起变化的东西和另一个变化的东西放在同一个模块或组件中。我们甚至可以更进一步,甚至把同时发生变化的拼图的各个部分放在一起,与不同时间发生变化的部分分开。

易于修改

应用和软件总的来说有变化的趋势。如果您以前从未从事过遗留系统的工作,那么您可能不知道修改或扩展多年前编写的旧的和过时的代码会带来什么样的恐惧。然而,对于最新的软件,您应该努力维护清晰分离且结构正确的代码,这些代码通过它们在域中的相应位置进行封装。

我的意思是将变化的部分与不变化的部分分开,并在与代码的其余部分相比以相同速度变化的部分之间做出更具体的区分。将常量分离到它们自己的模块或类中,这样我们就不会有一个以上的理由去改变代码。通过适当的名称空间和目录层次来组织事物,我们使代码更容易阅读、修改,并且在生产中出现问题时更容易修复。更不用说,在粒度级别上测试系统的单个工作部件的能力有所提高。要在代码库中达到这样的水平,接口非常有用。

抽象和具体化

当我们讨论关于六边形设计的抽象和具体化时,我们分别指的是端口和适配器。抽象是存在于组成应用的代码层之间的接口。它们在六边形上的位置是任意的,没有多大关系。重要的是定义正确的接口,以允许与外部世界的应用进行交互。图 17-2 从外部/内部视角展示了该模型的示意图。

img/488324_1_En_17_Chapter/488324_1_En_17_Fig2_HTML.jpg

图 17-2

六角形的基本方案,包括一些输入请求(浏览器和 API)

在图 17-2 中,我决定用一个六边形来表示应用,以及两种不同类型的输入(请求):一个普通用户从网络浏览器访问应用,一个外部应用或外部脚本通过 API $_GET请求访问应用。他们与应用的第一个交互点发生在最外层,我们称之为接口层

与 Laravel 相关,这一层将由各种路由、控制器、请求和中间件组成,我们称之为应用层。这一层的原因不仅在于接受和路由输入请求,还在于以某种方式将其转换为应用可以理解的内容,并按照路由中的指定将请求传递给接收方。例如,图 17-2 中的 web 请求可能是点击我们应用中某个页面的结果,甚至是一个直接请求。

  • GET /index.php HTTP/1.1

  • 用户代理:Mozilla/5.0(Macintosh;英特尔 Mac OS X 10_12.6) Applewebkit…

  • 主持: www.blog.continuousiteration.com

  • 接受语言:美国

  • Accept-Encoding : gzip,deflate

  • 连接:保持活动状态

这是一个独立的请求;它不需要主体部分,因为它是一个简单的 GET 调用,末尾没有查询字符串。这是原始 HTTP,它是互联网使用的语言,几乎所有的通信都使用它,但是我们的应用不使用原始 HTTP。通过 API 请求调用时,情况变得更加复杂。

  • GET {/api/v1/events/message?campaigns = 110001 _ 10001&event = click }

  • HTTP/1.1

  • 主持: https://api.sparkpost.com

  • 授权:6302 CB 8 BD 662d 5189 e 051 CEA 48 AE 35153 c 366326

  • 接受:申请/json

  • 内容类型:多部分/形式数据;boundary =-webkitformboundary 7ma 4 ywxktrzu 0 GW

  • -【WebKit builder 7ma4ywxxzgjw】

幸运的是,我们使用的是 Laravel 框架,它包含了正确的抽象和实现,可以使用 HTTP,并将传入请求中指定的所有内容自动转换为我们可以使用的内容,这些内容可以作为标准依赖项以普通 ol' PHP 对象(POPO)的形式传递,这是 Laravel 的请求组件。我们还从这个组件中得到了向我们的路由、请求或两者添加验证的能力。route 组件在最外层处理验证,它被转换成一个请求对象,并根据请求中指定的验证进行检查,然后它将(现在已经过验证的)Laravel 请求转发给一个控制器对象。

在前面的页面中,我们已经多次讨论了这个请求周期,但是从中可以得出一些重要的结论:请求如何进入应用的内层(域层和核心业务逻辑)并不重要。这是因为,在原始请求到达路由器并被 Laravel 验证之后,它已经可以在应用的其余代码中使用了。简而言之,就是翻译成 PHP 对象。在底层,Symfony 的 HTTP Foundation 组件已经包装了特定于 Laravel 的 helper 方法,为框架提供了额外的功能和能力。许多其他的类、特征和接口组成了处理原始 HTTP 请求的框架部分,将它们转换成一组数据,其余的代码可以对这些数据进行操作。

回到接口和具体化,清单 17-1 展示了一个端口的例子,可以在Illuminate\Foundation\Http\Kernel接口中找到。

<?php
namespace Illuminate\Contracts\Http;
interface Kernel{
    /**
     * Bootstrap the application for HTTP requests.
     *
     * @return void
     */
     public function bootstrap();
    /**
     * Handle an incoming HTTP request.
     *
     * @param  \Symfony\Component\HttpFoundation\Request
       $request
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function handle($request);
    /**
     * Perform any final actions for the request lifecycle.
     *
     * @param  \Symfony\Component\HttpFoundation\Request
       $request
     * @param  \Symfony\Component\HttpFoundation\Response
       $response
     * @return void
     */
    public function terminate($request, $response);
    /**

     * Get the Laravel application instance.
     *
     * @return \Illuminate\Contracts\Foundation\Application
     */
    public function getApplication();
}

Listing 17-1A Possible Port-Like Interface That Could Be Implemented with an Adapter

这基本上定义了一个使用框架必须实现的接口。它是 Symfony 最初开发的基本 HTTP 组件的翻版,通过在代码中建立一种清晰的交互方式,并将原始 HTTP 请求转换为框架可以识别和使用的内容,基本上支持了业内大多数现代框架(Drupal、PrestaShop、Laravel、Symfony)。使用这个接口,我们可以编写任何我们想要的代码,只要它满足所需的方法和类型提示。我们甚至可以通过坚持接口中的规范,立即对应用的特性进行编码(即使没有完全工作的数据库),并且它将全部工作(假设我们的实现是正确的)。这就是所谓的接口编码,而不是实现编码。清单 17-2 显示了这个端口的 Laravel 适配器。请注意,我们要理解的不是功能,而是与应用整体相关的高级概念(即接口和实现)。还有,这只是真实类的一部分;完整的实现可以在 https://github.com/laravel/framework/blob/6.x/src/Illuminate/Foundation/Http/Kernel.php 找到。

<?php
namespace Illuminate\Foundation\Http;
use Exception;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Contracts\Http\Kernel as KernelContract;
use Illuminate\Foundation\Http\Events\RequestHandled;
use Illuminate\Routing\Pipeline;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Facade;
use InvalidArgumentException;
use Symfony\Component\Debug\Exception\FatalThrowableError;
use Throwable;
class Kernel implements KernelContract
{
    /**
     * The application implementation.     *
     * @var \Illuminate\Contracts\Foundation\Application
     */
    protected $app;
    /**
     * The router instance.
     *
     * @var \Illuminate\Routing\Router
     */
     protected $router;
    /**
     * The bootstrap classes for the application.
     *
     * @var array
     */
    protected $bootstrappers = [/* … */]
     ...See full code listing on Laravel’s website or API Docs

Listing 17-2A Portion of Laravel’s Core Kernel Implementation (Adapter) for the Interface in Listing 17-1 (Port)

这只是实现类的一小部分,但是我们仍然可以看到这里发生了什么。在高层次上,构造函数接受当前运行的应用的一个实例(App类)和一个Router实例。我已经在类handle()中包含了第一个方法,向您展示了一个请求是如何生成响应的。这是任何 web 框架的全部目标:接收请求并返回响应。在这两者之间发生的任何事情都是我们必须构建的应用的实际部分。为了开发软件,框架提供给我们的是接口和实现,它们可以被连接、扩展等等。

设计和执行合同有什么用?

很高兴你问了!通过在整个原理图中使用接口(端口)作为核心结构,我们增加了系统设计的灵活性。我们在应用中添加了关键点,这样我们实现的组件可以相对容易地用不同的实现(适配器)替换。这里有一个思考这个问题的好方法:

  • 一个契约定义了将来可能被替换的应用需求,以便提高代码灵活性和/或应用中可能同时存在多个实现。合同是一个港口。

  • 具体实现是满足给定契约要求的解决方案。这些解决方案是适配器。

因此,从理论上讲,如果您希望您的应用是可重用的,或者您正在编写一个 API 甚至一个包供其他应用使用,您可以自由地使用端口(接口),这将允许应用层之间的通信(当请求向六边形的中间移动并再次返回到客户端时)。这带来了六边形方法的另一个特征:“输出”端口,即应用发出请求的端口,比如数据库端口。理论上,我们可以将我们的六边形分成两个半球:一个用于处理来自外部客户端的用户交互(也称为传入请求),另一个用于处理应用向外部服务发出的请求(图 17-3 )。

img/488324_1_En_17_Chapter/488324_1_En_17_Fig3_HTML.jpg

图 17-3

六边形被分成两个核心半球:UI 和基础设施

看到图 17-3 显示整个半球专用于应用的基础设施部分,您可能会感到惊讶。当我们谈到 DDD 时,我们了解到基础设施问题可以封装到它自己的层中,但在六边形中,它包含了六边形之外的几乎一半区域。这是 DDD 和 HA 之间的主要区别:DDD 根据特定的层将组成应用的片段与代码相关联,每一层都有一个直接的倾向,即什么类型的代码存在于其中,就像它的专业一样;然而,六边形只是将组成应用的组件从外部和内部联系起来。当我们考虑不同的设备、服务、浏览器或其他输入作为请求进入应用的方式时,我们还应该考虑另一方面(基础设施),这涉及到应用调用自身外部的外部系统。

这种分离是如何在代码中实现的,代码是如何建立如图 17-3 所示的边界的?答案当然是接口。我们可以在基础设施层旁边的任何需要的地方放置接口,用于我们期望进入我们的应用的所有类型的输入(这些将是端口),然后为每个不同的类型(即浏览器、CGI 等)实现该接口。),也就是适配器。在六边形的另一边,我们将照着做,为我们向外界发出的请求类型创建一个端口,然后将端口的接口实现为可以轻松插入框架的适配器。

普通请求在六边形架构中如何工作

HA 中当然存在标准的请求/响应周期,但是它实际上的工作方式可能与您想象的不同。一个典型的请求可以跨越多个端口,每个端口都有相应的适配器,由端口的契约实现,并且通常会调用两个半球的功能(这需要成功完成请求/响应周期)。图 17-4 显示了一个请求在高可用性中的高级外观。

img/488324_1_En_17_Chapter/488324_1_En_17_Fig4_HTML.jpg

图 17-4

六边形体系结构中请求/响应循环的“流程”

港口

从图 17-4 中可以得出的主要概念是,请求首先触及 UI(界面层)中的代码,通过应用的核心,触及后端的基础设施代码(在本例中,它调用外部 MySQL 数据库),后者运行已被基础设施代码翻译成 SQL 查询的逻辑,并将结果返回给应用,在应用中,结果再次被转换成浏览器可以呈现且用户可以看到的内容。图中没有包括所有的工作部分,但是粗线表示了这种请求的控制流。

在这个请求中涉及到多个端口,可以在六边形内的每个逻辑层的边界上找到这些端口。在 PHP 中,端口通常是在业务逻辑内部创建的接口,但也可以是一组接口和/或 dto。

适配器

适配器有两种类型:主(驱动)适配器和次(从动)适配器。在 Laravel 中,适配器通常是控制器、命令总线或传递给命令总线的查询。适配器满足由端口构成的接口。例如,控制器将输入端口接口的提示,以及为了完全实现契约所需的任何依赖关系。然后,控制器将生成的代码和依赖项更深地注入到应用层,在应用层中,逻辑实际上被委托给域层(业务逻辑),响应返回给发出请求的客户端。

代码示例

对于一个使用控制器的适配器的实现的例子,通过使用命令总线作为操作的主干来处理域对象的委托和舞蹈,查看清单 17-3 中的代码。

<?php

//use statements & namespace
class HomeController extends BaseController {
    /**
     * @var App\Adapters\CommandBus\CommandBus
     */
    private $bus;

    public function __construct(CommandBus $bus)
    {
        $this->bus = $bus;
    }
    public function createTicket()
    {
        $command = new CreateTicketCommand( Input::all() );

        try {
            $this->bus->execute($command);
        } catch(ValidationException $e) {
            return Redirect::to('/tickets/new')->withErrors(
            $e->getErrors() );
        } catch(\DomainException $e) {
            return Redirect::to('/tickets/new')->withErrors(
            $e->getErrors() );
    }
    return Redirect::to('/tickets')->with(['message' =>
        'success!']);
    }
}

Listing 17-3Example of a “Driven” Adapter

此外,我希望包含一个简单操作的高级透视图,该操作涉及外部客户端向应用发出请求,应用向数据库发出请求,然后将结果返回给客户端。

在图 17-5 中,我们有一个终端用户向我们的应用发出某种请求,该应用以路由器为初始接触点。

img/488324_1_En_17_Chapter/488324_1_En_17_Fig5_HTML.jpg

图 17-5

通过实现满足端口(端口未示出)描述的要求的 ORM 适配器,利用六边形架构从数据库获取数据的请求操作

路由器(使用端点)将请求路由到路由文件中指定的控制器(在我们的例子中,对于 Laravel 来说很可能是routes/web.php)。控制器实际上是一个适配器,它与 Laravel 中的Request组件一起使用,根据一组规则验证传入的数据,然后它以我们的应用可以看到和使用的形式将经过验证的数据发送给控制器。它将外部请求转化为内部对象,可以传递给我们想要的任何类或函数。控制器和一般的大多数驱动适配器实际上包装了一个端口的接口,而不是实现它。控制器将使用其构造函数中的一个对象进行实例化,该对象实现了相应端口的契约。这与驱动适配器(在基础设施端)的情况不同,因为它们实际上直接实现了端口的接口,而不是包装它。

在图 17-5 中,我们可以看到对面还有另一个适配器(辅助适配器),它是在基础设施边界定义的一个端口的实现。无论实现了什么,这个端口描述的接口都将是那个端口的适配器。在这种情况下,我们创建了一个 ORM 适配器,它附着在那个端口的接口上(这个端口在图中没有显示,但却是隐含的),它连接到雄辩的 ORM 并允许应用访问数据库,如图 17-5 所示。

这里有一个可能的代码解决方案。让我们从让一切正常工作所需的几个端口开始。我们将定义一个可以处理任何 ORM 的通用接口,假设所有接口和与应用的连接点都已正确建立,如清单 17-4 所示。

<?php
namespace App\Domain\Contracts\Persistence\Ports;
use QueryCapabilities;
interface ExternalOrmConnection
{
     public function connect();
     public function defineQueryCapabilities(
          QueryCapabilities $query)
}

Listing 17-4General Port for Establishing an ORM Connection in the Application

使用这种方法,我们可以创建一个满足端口描述的接口的适配器,允许我们(理论上)将任何 ORM 连接到我们的应用。这是一个开始,但并不是我们实现实际功能所需要的全部:一种通过 orange 查询数据库的方法。我们有一个connect()方法,它执行任何配置逻辑,并包含与预期 ORM 建立连接所需的代码。还有一个defineQueryCapabilities()方法,它接受一个QueryCapabilities的实例来描述 ORM 连接的可能性。可以说,我们实际上希望将功能从连接中分离出来,但是它们是如此的相关,以至于我将其中一个作为依赖关系包含在另一个中。让我们创建一个接口,封装我们知道将会需要的基本查询(或任何此类实现的功能)。见清单 17-5 。

namespace App\Concerns\Infrastructure\Persistence\Ports;
interface QueryCapabilities
{
     public function select($statement);
     public function delete($statement);
}

Listing 17-5Example QueryCapabilities Port

理论上,在这一点上,我所要做的就是实现图 17-5 中定义的接口,将任何 ORM 连接到系统。只要我们基本上将接口中的方法代理到我们正在处理的具体实现(教条、雄辩、推进等)。)这样那些方法就被转发到 ORM,我们可以自由地使用任何我们想要的 ORM。

当然,这是非常有限的,这里只是作为一个例子。清单 17-6 展示了符合前两个端口的一些可能的适配器。

<?php
class EloquentOrm implements QueryCapabilities,
     ExternalOrmConnection
{
     public function select($statement) {
          return DB::select($statement)->all();
     }
     public function delete($statement)
     {
          return DB::delete($statement);
     }

     public function connect()
     {
          //connection logic
     }
}

class DoctrineDbalOrm implements QueryCapabilities,
     ExternalOrmConnection
{
     public function delete($statement)
     {
          $stmt = $this->connect()->delete($statement);
          return $stmt->execute();
     }

     public function select($statement)
     {
          $stmt = $this->connect()->prepare($statement);
          return $stmt->fetchAll();
     }
     public function connect()
     {
          return $this->getConnection(‘default’);
     }
}

Listing 17-6Example Implementations of the Two Port Interfaces Described Earlier

当然,这个例子只考虑了对应于六边形的一条边的单个关注点,在这个例子中,是持续边。然而,添加已定义端口的接口实现的一般过程基本上保持不变:在应用的域层创建一个接口,该接口将定义它所在的边缘所关注的一般结构。一旦定义了高级接口,就可以(也应该)在应用中任何需要注入接口的地方使用该接口的类型提示。请记住,编码到接口,而不是实现。通过这样做,您可以以一种清晰、易于理解的方式添加功能的特定部分,因为该接口的子接口所需要的只是满足约定(由方法名和签名组成)。通过创建封装给定端口的特定实现的适配器来实现这一点。这种设置的美妙之处在于,您不必更改处理子类的周围代码。因为我们已经决定对一个接口进行编码,所以所有的类型提示将与任何为此目的创建的新适配器完美地一起工作,这是在端口中定义的。记住让端口为整个应用的入口点和出口点定义接口。他们根本不应该了解具体的实现。这就是为什么你在构造和形成物体的方式上有如此大的灵活性。

用例

用例是六边形架构中的基本元素,它与要开发的特性相关(并完整地描述了该特性)。用例只是描述使用特定功能的特定原因或动机的场景陈述(它也可以描述上下文)。

用例属于应用层,通常以应用服务的形式出现,它包含用例,并注入了其他依赖项,以便它可以执行用例指定的任何功能。通常,用例在被翻译成代码之前被写成英语句子,并遵循一些基本格式。下面是一个格式化用例的通用方法:

  • 作为{角色/用户类型},我想{描述}。

以下是一些例子:

  • 作为一名医疗服务提供者,我希望能够通过姓名或生日快速查找患者。

  • 作为一名医疗账单开具者,我希望能够对分段数据进行报告,并对给定分段中的所有医疗账单进行累计总计。

  • 作为一名管理员,我希望能够轻松登录和注销不同的用户帐户。

这种格式清楚地表明了谁在请求工作以及任务中包含了什么。显然,在编码开始之前,不仅仅需要额外的细节,但是这种格式为开发人员和非开发人员提供了一种描述新特性的简单方法。

在 Laravel 中实现六角形

在 Laravel 中,应用了非 Laravel 应用中存在的相同的基本原则:用封装每层逻辑的边界接口在架构级别上分离关注点。依赖关系指向内部,端口(接口)被留下来定义其边界内逻辑上下文的底层需求。通常,我们可以使用 Laravel 契约来实现这样的边界,这种方法效果很好,因为您可以使用 Laravel 的服务容器(涉及配置服务提供者内部的依赖关系)简单地将给定的接口自动连接到该接口的特定实现。这将把一个特定的子实例(完全实例化并准备好运行,其配置也可以由服务容器作为系统中的一个单独对象来确定)注入到父接口有类型提示的任何地方。这一切都会自动发生。

清单 17-7 展示了一个很好的、简洁的例子,展示了如何在应用中实现事件(假设您想要自己的跨应用事件定制解决方案)。它位于六边形的Event边,可以被认为是一个“实干家”适配器,因为它的核心是一个调度程序。

<?php
namespace App\Events;
use Illuminate\Events\Dispatcher as DispatcherInterface;
class Dispatcher implements DispatcherInterface {

    /**
     * @var \Illuminate\Events\DispatcherInterface
     */
    private $dispatcher;
    public function __construct(LaravelDispatcher $dispatcher)
    {
        $this->dispatcher = $dispatcher;
    }

    public function dispatch(Array $events)
    {
        foreach($events as $event)
        {
            $this->dispatcher->fire($event->name(), [$event]);
        }
    }
}
?>

<?php
namespace App\Events;
interface EventInterface {
    /**
     * Return the event name
     * @return string=
     */
    public function name();
}

?>

<?php namespace App\Events;
trait Eventable {
    protected $queuedEvents;
    public function flushEvents()
    {
        $events = $this->queuedEvents;
        $this->queuedEvents = [];

        return $events;
    }
    public function raise($event)
    {
        $this->queuedEvents[] = $event;
    }
}

Listing 17-7An Example Solution That Fits the Event Side of the Hexagon

使用清单 17-7 中描述的设置,用户只需实现位于App\Events名称空间中的EventInterface,就可以通过使用Eventable特征轻松地向任何需要分派事件的类添加行为(可能是向消息队列或命令总线)。清单 17-8 就是这样一个事件的例子。

<?php
namespace Hex\Tickets\Events;
use App\Models\Ticket;
use App\Events\EventInterface;
class TicketCreatedEvent implements EventInterface {
    /**
     * @var \Hex\Tickets\Ticket
     */
    private $ticket;
    public function __construct(Ticket $ticket)
    {
        $this->ticket = $ticket;
    }
    /**
     * Return the event name
     * @return string
     */
    public function name()
    {
        return 'ticket.created';
    }
}

Listing 17-8An Implementation of an Event as Defined by the Interface (Port) Described in the EventInterface Interface

结论

虽然我们没有深入探讨六边形架构的具体细节,但是如果这个想法引起了你的兴趣,那么我鼓励你对六边形架构进行更多的研究,并开始尝试这个范例,因为它非常有用。Hexagonal 更侧重于“进”而不是“出”,而不是传统的“自上而下”的方法(如在层次结构中)。它描述了一组边界,这些边界封装了表示为端口(基本上只是接口)的核心知识,这些端口位于六边形(或圆形)内每一层的边界。术语六边形并不意味着在描述给定应用的所有关注点时只涉及六个边。可以有更多或更少,这取决于应用的环境。适配器是实现接口的对象,因此,由于它们所实现的接口认为它们需要结构,所以可以不加考虑地创建适配器。Laravel 中的 Hexagonal 有一个积极的好处,那就是能够使用 Laravel 的核心契约库,并且在与 Laravel 的服务容器(绑定)并行使用时可以实现很棒的事情。如果你想更深入地研究六角形建筑,可以看看 https://fideloper.com/hexagonal-architecturehttps://madewithlove.com/hexagonal-architecture-demystified

十八、DDL 在现实世界中的应用

我们已经回顾了很多关于遵循领域驱动设计原则开发 Laravel 应用的信息。在这一章中,我们将把这些知识应用到我所面临的各种现实世界的问题中(无论是独自还是在团队中),并寻找这些问题的可能解决方案。我们将以领域驱动的方式来讨论它们,这种方式将重述我们在整本书中学到的概念。我们将把这些概念放到上下文中,这样您就可以从使用 Laravel 和 DDD 中获得最大收益。通过在每一层的边界点提供接口(从六边形架构借用的概念),我们可以使用严格的对象结构,根据属于同一类别的更广泛的概念来定义我们的应用的行为和功能的一般性。这些类别的示例包括:

  • 事件:从系统中的任何地方广播自定义事件。事件被实现为驱动适配器,因为它们需要被调度,但是它们可以有一个驱动部分来处理从适当的接收者接收这些事件,这是一个更被动的过程。

  • 数据库:访问数据存储以获取运行应用所需的数据。这落在六边形的持续边缘旁边。

  • 用户界面:可通过浏览器从图形界面使用。

  • 命令、控制器和其他“驱动”问题:来自应用外部的输入请求,作为应用执行的驱动力。

在这一章中,我们将经历一个简单的场景,并勾画出一个粗略的解决方案;然后我们将重构它以增加更多的深度,并重新审视在 Laravel 中制作 API 时所涉及的细节。

真实世界的例子:估计索赔

如果您还记得,患者与提供者有免费或减价支付的预约(假设他们有资格接受护理),提供者必须以特定的方式提交索赔,然后他们才能从联邦政府的 Medi-Cal 部门获得服务报酬。我们的系统提供了一种简单的方式来提交索赔,并具有内置的验证和确认检查,保证满足赔偿要求。

支付给提供者的金额部分取决于为患者提供的服务(通过 CPT 代码获取和记录)以及提供者的特定支付代码表。预计的索赔金额是在将索赔提交给系统之前必须计算的金额,以便提供商(或接待员)可以查看并确保金额合理。我们将根据应用的概念(不一定仅仅是代码)以最适合领域和应用的方式来构建这个功能。

首先,我们需要一个类来表示索赔估计是什么,因为索赔的值基本上是不可变的。(这意味着估算的金额不会改变,尽管计算出的金额本身会随着输入的变化而变化,如 CPT 代码的变化。)因此,索赔估计是一个价值对象。清单 18-1 一个简单的类捕获了这些知识,由一个 DTO 表示。

<?php

namespace Claim\Submission\Domain\ValueObjects\Estimates;

class Estimate
{
     private float $amount;
     private $codes = [];

     public function __construct(float $amount, array $codes=[])
     {
          $this->amount = $amount;
          $this->codes = $codes;
     }

     public function create($amount, array $codes=[]): Estimate
     {
          return new self($amount, $codes);
     }

     public function setAmount(float $amount)
     {
          $this->amount = $amount;
          return $this;
     }

     public function setCodes(array $codes): Estimate
     {
          $this->codes = $codes;
     }

     public function amount(): array
     {
          return is_null($this->amount) ? null : (float)
               $this->amount;
     }

     public function codes(): float
     {
          return $this->codes;
     }
}

Listing 18-1Basic Class Representing a Claim Estimate

在清单 18-1 中,我们基本上有一个简单的 DTO,其中包含一个静态帮助器方法,该方法返回一个准备就绪的索赔评估实例。这真的没有什么特别的,尽管要注意名称空间:我们与域保持一致,并根据域的构造方式来调整事物。这听起来像是领域驱动的设计!前面的$codes成员变量是一个数组,包含在访问期间完成的特定的、单独的 CPT 代码。$amount变量将保存计算出的值,并且将是一个原始浮点型。请记住,Estimated类只是用于计算金额的实际输入的记录,不包括任何类型的行为——只是数据。

额外要求

在本书的前面,我提到了提供者可以设置两种潜在的支付“类型”来决定他们的报酬的可能性。

  • 每次就诊付费:这是每次就诊向提供者支付的固定金额,无论就诊期间完成了什么程序。

  • 按程序付费:这种类型的支付需要在提供者的PaycodeSheet上查找报销申请上的 CPT 代码。

我们如何将这一方面加入到索赔估计金额的计算中?显然,我们可以假设付款类型被记录在数据库中的某个地方(比如providers表中的payment_type_id字段或类似的东西)。如果不是,我们将不得不为这个字段编写一个迁移,并且很可能在创建Provider帐户时填充它,这给我们留下了额外的麻烦,即必须用新的payment_type_id字段的值来填充当前的Provider帐户记录。我们还需要创建一个迁移来创建payment_type_id工作所需的查找表。我们可以将实际值放入同一个迁移中(该表将被命名为payment_types)。

假设我们已经完成了在应用中实现这种支付类型概念的所有细节,现在需要做的就是正确计算索赔的估计金额。由于我们注意到关注点的分离,我们决定将任何输入验证放在一个 Laravel 请求中,该请求被传递给一个控制器方法。我们还决定这个操作需要它自己的端点,所以我们决定给它自己的名称空间Claim\Submission\Domain\Models\Estimate,这个名称空间将存放与评估本身相关的任何值对象。因此,索赔的估计可以简单地认为是一组 CPT 代码加上一个美元金额,它代表了给定索赔的最终估计。它不知道实际确定成本的计算(这可能相当复杂)。我们可以并且应该将一个PaymentType的知识封装成一个模型。

<?php

namespace Claim\Billing\Domain\Models\Payment;

use Illuminate\Database\Eloquent\Model;

class PaymentType extends Model
{
     const PER_VISIT = 1;
     const PER_PROCEDURE = 2;
     /* ... */
}

这为应用代码的其余部分提供了一个很好的参考点,因为开发人员不必依赖于内存,也不必回忆每次访问付费类型实际上对应于 ID 1。我们可以简称为PaymentType::PER_VISIT

索赔评估服务

我们现在剩下的任务是创建计算估计索赔金额的逻辑。首先要考虑的应该是找出该逻辑的最佳位置。由于这是一个特定于领域的任务,我们可以选择将代码放在领域服务中。域服务专门在域级别上运行,不应该有任何与域不直接相关的问题。诸如促进请求和响应循环以运行计算的应用关注点应该从服务中分离出来,允许它专注于完成一个特定的任务。

畴层

如果您还记得的话,领域层是任何软件应用中包含特定底层领域模型的核心业务逻辑的地方,而构建应用就是为了表示该模型。就索赔项目而言,“域服务”的一个示例是应用中计算 FQHC 向提交索赔的提供商支付的预期金额的部分。查看清单 18-2 以获得这种服务的示例实现。

//Claim\Submission\Domain\Services\ClaimEstimator.php

<?php

namespace Claim\Submission\Domain\Services;

use Claim\Submission\Domain\Models\Providers\Provider;
use Claim\Submission\Domain\Models\PaycodeSheets\PaycodeSheet;
use Claim\Submission\Domain\Models\Payment\PaymentType;
use Claim\Submission\Domain\Models\Payment\PaymentData;
use Claim\Submission\Domain\Models\CptCodes\CptCodeCombo;
use Claim\Submission\Domain\ValueObjects\Estimate\Estimate;
use Claim\Submission\Domain\Services\Payment\ClaimPaymentService;
Use Claim\Submission\Domain\Exceptions\
     ComboNotFoundInPaycodeSheetException;

class ClaimEstimator
{
     protected Provider $provider;

     protected CptCodeCombo $cptCodeComboRepository;

     protected PaycodeSheet $paycodeSheetRepository;

     protected ClaimPaymentService $claimPaymentService;

     public function __construct(
          PaycodeSheetRepository $paycodeSheetRepository,
          CptCodeComboRepository $cptCodeComboRepository,
          ClaimPaymentService $claimPaymentService}
     {
          $this->paycodeSheetRepository = $paycodeSheetRepository;
          $this->cptCodeComboRepository = $cptCodeComboRepository;
          $this->claimPaymentServices = $claimPaymentServices;
     }

     public function estimate(Claim $claim, array $codes): float
     {
          $provider = $claim->primaryProvider();
          $estimateDate = $claim->estimateDate()->toDateTimeString();

          //we need to take into account the two different payment
          //types described above : Per-Procedure and Per-Visit
          $paymentType = $this->findPaymentData($provider);

          if ($paymentType === PaymentType::PER_VISIT) {
               return $provider->fee_per_visit * $provider->bonus;
          } else {
               return $this->calculatePerProcedureEstimate(
                    $provider, $claim, $codes);
          }
     }

     public function findPaymentData(Provider $provider): PaymentData
     {
          //this way we can add additional payment types with ease:
               return PaymentType::fromRequest ($provider->paymentType);
     }

     public function calculatePerProcedureEstimate(
          Provider $provider, Claim $claim, $codes=[])
     {
          if (!empty($codes)) {
               $cptCodeCombo = $this->cptCodeComboRepository
                                     ->findComboFromCodes($codes);

               $paycodeSheet = $this->paycodeSheetRepository
                                     ->byProvider($provider->id);

               $estimatedAmount = $this->claimPaymentService
               ->lookupPriceForCombo(
                         $paycodeSheet, $provider, $cptCodeCombo);
               if (!is_float($estimatedAMount)) {
                    throw new \ComboNotFoundInPaycodeSheetException;
               }

               return new Estimate($estimatedAmount, $codes);
          }
     }
}

Listing 18-2The Domain Service That Will Handle the Calculation of the Claim Estimate

这将意味着在PaymentType模型上创建一个额外的方法,当提供适当的构造时,该方法将返回其自身的一个实例。本质上,这个方法的签名和它的构造函数方法是一样的,如清单 18-3 所示。

<?php

//inside the Estimate value object (Listing 18-1)
use Claim\Submission\Domain\Models\Payment\PaymentType;

class PaymentType extends Model
{
     const PER_VISIT = 1;
     const PER_PROCEDURE = 2;

     public static function fromRequest(int $payType): PaymentType
     {
          return new self($payType);
     }
}

Listing 18-3PaymentType Entity in the System, Allowing Us to Call a Static Method on the Class and Return a Fully Instantiated Object of Itself

这有一个额外的好处,即使用易于记忆的常量变量(不可变的)作为PaymentType类的构造函数的参数,还有一个好处是通过名称而不是数字来引用(PER_VISIT比 1 更容易记忆)。注意,我们已经正确地输入了清单 18-2 中的所有内容。除了采用这种编程风格获得的其他非显而易见的好处之外,还有一些好处,例如减少了开发人员疏忽和运行时的错误数量,以及由于特定化而提高了性能,因为即时编译器(PHP 引擎)在推断参数和变量的类型和值时减少了猜测。起初,你可能看起来没有节省多少计算能力;然而,完成这项工作所需的 CPU 周期越少,你的总体情况就越好。

在清单 18-2 中,我们发现了一些有趣的事情。首先,我们将依赖项很好地作为构造函数参数列出,可以通过 Laravel 的服务容器(通过其依赖项注入机制)自动注入每个依赖项的正确实例。在estimate()方法中,我们委托给另一个方法来获得适当的PaymentType来设置给定的提供者,它使用我们在清单 18-3 中包含的好的帮助器方法作为内联实例化相当静态的值的手段,在某种程度上有点像一个特别的快捷方式。

一旦确定了支付类型,服务将在每次访问费率类型的情况下立即计算估计值,或者在提供者被分配了每次程序支付类型的情况下,需要额外的计算和逻辑来得出估计值。CptCodeCombo需要使用传入的单个 CPT 代码进行查询,以便能够查询PaycodeSheet模型,并且当给定传入索赔上的提供者时,能够导出指定的金额。成员方法calculatePerProcedureEstimate()处理每个过程的计算;然而,如果您注意到,该方法真正做的只是将某些任务委托给构造函数中注入的一个依赖项(另一个名为ClaimPaymentService的域服务)。这清楚地分离了基于影响索赔金额的不同变量计算索赔金额的实际关注点:支付代码表、提供者和代码本身,它们可能会在复杂性和代码行方面发生变化和/或增长。如果将来需要另一个PaymentType,将它集成到当前系统中的成本将是最小的,因为我们已经将这种灵活性内置到了我们的域级组件中。

同时,ClaimPaymentService域逻辑将封装处理估算索赔所涉及的脏活的过程。需要注意的是,尽管我们似乎只是在将我们需要完成的任务委派给其他对象时“推卸责任”,但我们是在支持我们的长期、更高层次的目标,即构建和维护一个其逻辑和域过程很容易推理的应用。代码本身直接反映了编写代码的开发人员对领域的理解程度。通过将流程的大块分割成更小的组件,我们给自己一个更好的代码可重用性的机会,并且让未来的开发人员更容易理解。

警告绝对有过度工程这种东西,它可能会发生,而你直到后来才注意到它。您可以采用几个原则来减少这种情况的发生,例如您不需要它(YAGNI)或者做最简单的工作(DTSTTCPW),但是防止它的真正关键是允许域指导您的架构和编程决策。尽可能将您的决策建立在与领域相关的基础上,并且总是试图在您的代码和它所解决的业务问题之间保持一条相关的线索。

在这一点上,处理查找以确定索赔的估计支付金额的实际实现是任意的,所以我不会在文本中特别包括它;你可以在这本书的在线知识库上看到它。

应用层

既然我们已经解决了索赔估计的领域问题(并且通过领域服务在领域模型中形式化了它们相应的含义),我们仍然需要考虑应用级别的细节。更具体地说,我们将讨论将外部请求传递给应用代码(传入请求)的机制,以及创建将其返回给请求方(传出响应)的方法。dto 有助于确保交付机制(两种方式)被封装到与特定功能保持一致的结构中。

HTTP 请求

让我们定义一个简单的请求来启动我们之前定义的域进程,我们在清单 18-4 中做了这些。

//Claim\Submission\Application\Http\Requests\Estimates\
//EstimateRequest.php

<?php

namespace Claim\Submission\Application\Http\Requests\Estimates;

use App\Http\Requests\Request;

class EstimateRequest extends Request
{
     public function authorize()
     {
          return true;
     }

     public function rules()
     {
          return [
               'claim' => 'exists:claims,id'
          ];
     }
}

Listing 18-4Request to Get a Claim’s Estimated Value

清单 18-4 中的请求相当简单,你应该不会感到惊讶,因为我们在本书前面已经讨论过类似的类。在请求的rules()方法中,我们只有一个必需的参数:声明。

请记住,在这一点上,所有信息都已经输入到索赔中,除了我们现在正在设置的应付给索赔提供者的估计金额之外,其他信息都是完整的。我们可以从索赔本身中提取出计算成本所需的任何相关信息,因此我们不必包括例如提供商或个人 CPT 代码。事实上,包含这些内容的额外要求是重复的,因为我们应该能够安全地假设声明中存在的 CPT 代码是有效的,并且在达到这一点之前已经在代码中的其他地方得到了验证。$claim变量将包含我们需要的一切,因为它已经在数据库中,我们可以依靠它的状态来确定索赔处于流程的哪个阶段(幸运的是,我们已经在第九章中设置了索赔状态)。

最后那部分听起来有说服力吗?对我来说确实如此,当我在加州美丽的埃尔卡洪(El Cajon)当时工作的地方坐下来设计这个系统时,这正是我最初的想法。直到后来,我才恍然大悟。当我最初整理前一段时,我没有考虑功能性。我从纯技术的角度考虑这个问题,因为我的设计很大程度上基于这样一个事实,即一个完全实例化的Claim对象可以并且将包含创建索赔估计所需的所有相关数据。然而,如果我把注意力放在需要完成的功能上,我就会明白我们试图做的是得到一个索赔的估价。这是一个不需要索赔本身的操作,只需要从索赔中选择一些数据。通过将声明本身耦合到声明估计请求,我实际上将负责估计声明值的机制耦合到了声明本身,而不是正确地指定组件运行所需的最小依赖(构造函数注入)。

这产生了一个小问题,直到后来系统成熟时才被发现,当时请求允许系统中的所有专业用户(非患者)能够根据一组任意的、用户提供的参数运行索赔估计。然而,因为我们本质上已经将整个估计 enchilada 直接耦合到了一个声明,所以我们不可能与这样一个没有Claim对象的上下文兼容。在这一点上,我们不可能决定在到达索赔估计端点时强制前端以某种方式建立一个完整的Claim对象,所以这是不可能的。我们真的必须重构代码,使它适应我们构建它的当前环境之外的情况;这不是一件容易的事,而且很少是这样。熟能生巧。最佳实践成就最佳。

长话短说,我应该坚持设计的功能方面,在这种情况下,将涉及分解请求中所需的内容,以正确地获得响应中返回的结果。最后,我需要做的只是计算一个估计的数量,这需要正确地注入这样一个东西所需的下列依赖项:

  • 供应商

  • 工资单

  • CPT 代码组合

    • 从 CPT 代码数组中导出

下一节将更详细地讨论根据各种环境对系统设计进行建模。

Modeling Contexts

我想包含一个简短的切线讨论,它存在于现实生活的开发中,是任何实时或接近实时的应用经常关注的问题。也就是语境。领域驱动的设计是基于构建模块的概念,或者可以用来导出工作模型或架构的模式,该工作模型或架构捕获了它所代表的领域的全部意图。例如,这里有一个假设的场景。

假设我们想要额外的功能,允许我们覆盖索赔上的原始 CPT 代码组合,也许是在您想要执行“快速编辑”类型的更改的情况下,这种更改独立于索赔提交表单,允许您覆盖索赔上输入的原始代码。这对于审核者在验证原始值有错误来源(很可能是通过直接联系提供者的办公室)后对索赔进行快速一次性更新可能是有用的。在这种情况下,我们可以将有问题的声明作为参数传递给请求,同时传递的还有他们想要用来更新该声明的新代码数组。

然而,这个“一次性”CPT 代码更新的上下文与我们直接从索赔提交上下文中修改整个Claim对象的上下文是不同的。因此,这就引出了一个问题:即使这两个上下文确实在修改相同的数据,它们应该在系统中有自己独立的实现吗?

Laravel 的酷之处在于为像PatchClaimRequest甚至UpdateChangeRequest这样的请求配置验证是多么简单。帮助完成该任务的请求的rules()方法可以是这样的,并且基本上满足我们所有的验证需求。详见清单 18-5 。

<?php
public function rules()
{
     return [
          'claim'   => 'exists:claims,id',
           'cptCodes'   => 'array',
          'cptCodes.*' => 'exists:cpt_codes,id'
     ];
}

Listing 18-5A Would-Be Rules Configuration That Could Be Used to Validate the Patching of an Already Created Claim

当然,这是假设所需的功能在系统中被视为独立的代码片段。这种方法的好处是与索赔提交屏幕上的“完全更新”相对应,如果您还记得的话,索赔提交屏幕上有一个相当长的需求和验证列表,这些需求和验证是针对传入的索赔运行的,因此它甚至可以到达流程的这个阶段。

在某种程度上,将这个请求与处理修补整个索赔的请求分开,似乎等同于必须在多个位置维护相同的代码。然而,当我们考虑到后端代码基本上是相同的,并且我们实际上可以将修改声明(PatchClaim)的任务放在一个作业中,然后该作业可以被分派到一个工作队列中时,我们发现我们在某种意义上并没有真正重复代码,违反了 DRY 原则。我们实际上正在对这种内联能力进行建模,以将索赔上的 CPT 代码修改为与第一次创建索赔时不同的上下文

在这种情况下,我认为最好用不同的请求来表示两种上下文,在这两种上下文中,可以在给定的声明中修改、添加或删除 CPT 代码,因为在某些时候,我们可能需要知道这些编辑实际上是在哪里进行的, 如果我们将两种上下文合并到同一个请求中,这将是不可摧毁的(更不用说由于必须重新调整“CPT 代码可以在独立于索赔提交表单的屏幕上更新”这一新场景的所有内容而使验证成为一场噩梦)。 通过 POST 请求处理索赔创建的机制很可能与处理更新索赔的补丁请求的机制不同。这是故意的。更新索赔时,创建索赔的 POST 请求中所需的许多(如果不是大部分)验证在修补请求中是不需要的。我会将这两个单独的用例放在一起考虑,因此我会认为额外的上下文应该单独建模。

控制器

让我们回到考虑给定索赔的估算方面。

协调前面的域逻辑的控制器相当简单,看起来类似于清单 18-6 (一个粗略的草稿)。

//Claim\Submission\Application\Http\Controllers\EstimateController.php

<?php

namespace Claim\Submission\Application\Http\Controllers\Estimates;

use App\Http\Controllers\Controller;
use Claim\Submission\Application\Http\Requests\Estimates\
     EstimateRequest;
use Claim\Submission\Domain\Models\Claims\Claim;
use Claim\Submission\Domain\Services\ClaimEstimator;
use Claim\Submission\Application\Exceptions\MissingProcedureException;
use Claim\Submission\Application\Responses\EstimateResponse;

class EstimateController extends Controller
{
     protected Claim $claim;

     public function estimate(EstimateRequest $request,
          ClaimEstimator $claimEstimator, Claim $claim)
     {
          $this->claim = $claim;
          $this->authorize('view', $claim);

          try {
               $amount = $claimService->estimate(
                    $claim, $request->cptCodes);
          } catch (MissingProcedureException $exception) {
               logger()->error("Could not estimate given claim");
               return $this->handleMissingProcedure();
          }

          return EstimateResponse::createFromEstimate(
               Estimate::create(
                    $this->estimatedAmount($amount),
                    $request->cptCodes
          );
     }

     public function handleMissingProcedure()
     {
          return response()->json(['errors' => [
               "Unknown CPTCode Combo present for Claim or Paycode
                    Sheet not defined for Provider on Claim: " .
                    $this->claim->id
               ]], 422);
     }
}

Listing 18-6Basic Estimate Controller Following the Same Standards We’ve Been Employing Throughout the Book

这个控制器做它应该做的事情:接受请求并返回响应。它很可能(在技术上)行得通,但从目前的形式来看,它确实有改进的空间。当审查这段代码时,例如在一个拉请求中,我将在审查注释中首先包括以下内容:

  • 控制器通常没有属于类中单个方法的成员变量(例如,添加到类顶部并注入到estimate()方法中的$claim成员变量)。如果有的话,成员变量应该保留给控制器工作所需的服务或其他依赖项。其他任何事情都可能是代码味道,表明控制器中发生了太多的业务逻辑(或者任何与此相关的事情)。

  • estimate()本身在函数体中包含了太多的逻辑。控制器要做两件简单的事情:接受请求并返回响应。

  • 计算评估所涉及的逻辑最好表示为可以排队的作业。

考虑到前面的三个注释,我们决定取出当前驻留在控制器中的所有业务逻辑,最终得到一个干净的控制器,它通过以作业的形式分派特定的业务逻辑胶囊来表达清晰的意图。清单 18-7 展示了我们重构后的控制器和新任务。

//Claim\Submission\Application\Http\Controllers\EstimateController.php
<?php

namespace Claim\Submission\Application\Http\Controllers\Estimates;

use App\Http\Controllers\Controller;
use Claim\Submission\Application\Http\Requests\Estimates\
     EstimateRequest;
use Claim\Submission\Domain\Models\Claims\Claim;
use Claim\Submission\Domain\Services\ClaimEstimator;
use Claim\Submission\Domain\Jobs\Claims\EstimateClaimAmount;
use Claim\Submission\Application\Responses\EstimateResponse;

class EstimateController extends Controller
{
     protected ClaimEstimator $claimEstimator;

     public function __construct(ClaimEstimator $claimEstimator)
     {
          $this->claimEstimator = $claimEstimator;
     }

     public function estimate(EstimateRequest $request, Claim $claim)
     {
          $claim = $this->claim;
          $cptCodes = $this->cptCodes;

          $this->authorize('view', $claim);

          dispatch(new EstimateClaimAmount($claim, $this->cptCodes);

          //refresh the Claim since we dispatched it to the queue
          $claim = $claim->fresh();

          //create a response by fetching the new estimate from DB
          return (!is_null($claim->estimate_id)) ?
               EstimateResponse::createFromEstimate(
                     Estimate::find($claim->estimate_id))
               : response()->make(['success' => 'false'], 500);
     }
}

Listing 18-7The Refactored Version of the Controller in Listing 18-6

从前面的代码示例中调用的新的EstimateClaimAmount作业可能看起来像清单 18-8 。

//Claim\Submission\Domain\Jobs\Claims\EstimateClaimAmount.php

<?php

namespace Claim\Submission\Domain\Jobs\Claims;

use Claim\Submission\Domain\Models\Claims\Claim;
use Claim\Submission\Domain\Services\ClaimEstimator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class EstimateClaimAmount extends ShouldQueue
{
     protected Claim $claim;

     protected $cptCodes = [];

     protected ClaimEstimator $claimEstimatorService;

     public function __construct(,
          Claim $claim, $cptCodes=[])
     {
          $this->claim = $claim;
          $this->cptCodes = $cptCodes;
     }

     public function handle(ClaimEstimator $claimEstimator)
     {
          $claim = $this->claim;
          $cptCodes = $this->cptCodes;

          try {
               $amount = $claimEstimator->estimate(
                    $claim, $cptCodes);
          } catch (MissingProcedureException $exception) {
               logger()->error("Could not estimate given claim");
               throw new MissingProcedureException("ERROR MSG");
          }

          $estimate = Estimate::create(
               $amount,
               $this->cptCodes
          );

          $claim->estimate_id = $estimate->id;
          $claim->save();
     ]
}

Listing 18-8The New Job Encapsulating the Details of Creating an Estimated Amount for the Given Claim

Tip

无论何时你需要某个 Laravel 组件,无论是作业、控制器、请求等等。,您应该总是从提供的 Artisan 命令开始,为您的组件生成一个空白存根,而不是每次都手工键入整个内容。在前一种情况下,我们可以使用以下内容生成此作业:

php artisan make:job \\Claims\\Submission\\Domain\\Jobs\\Claims\\EstimateClaimAmount

清单 18-8 中的代码应该相当简单。我们创建了一个作业来封装索赔计算的过程,以获得索赔的估计值。我们已经在这个作业的handle()方法中注入了主依赖项(ClaimEstimator),由服务容器自动解析。如果对象需要额外的逻辑来实例化,您可以使用容器的bindMethod()来自定义如何构建作业。您可以向自己选择的服务提供商抛出这样的东西:

$this->app->bindMethod(::class.'@handle', function
     ($job, $app) {
          //custom instantiation logic goes here...
          return $job->handle($app->make(EstimateClaimAmount::class));
});

还要注意,我们已经将实际的运行时数据对象注入到构造函数中。这实质上是为队列设置作业。真正接受它的是handle()方法。

基础设施层怎么了?

似乎我们已经从索赔评估过程中省略了整整一层。为什么会这样?Laravel 的工作方式和 web 应用的自然顺序使得与某部分代码相关的基础设施实际上与它周围的代码一起存在内联,而不是分离到它自己的特定层中。这在 Laravel 应用中经常出现,因为 Laravel 提供了以动态方式执行例行数据库查询的方法。

这并不是说基础设施层不会拥有标准的东西,比如存储库或查询构建器来支持应用中的模型。这些类型的东西最好放在专用的基础设施层中;我只是想让您注意,Laravel 中的基础设施层几乎可以看作是 Laravel 本身。正因为如此,基础设施代码分散在应用的大部分中,特别是在实际上一些事情的类中(比如六角形架构中的驱动适配器),比如 jobs。它以一种非介入的、方便的方式根植于其他层次,这并不总是符合领域驱动设计的要求。DDD 在解决商业问题时更多地采用结构化的、正式的方法。因为我们很早就决定使用一个框架,所以我们必须小心不要滥用它的力量,并且为了得到正确的领域模型,让您在代码中做出的决定严重依赖(如果不是完全依赖)您正在为之制作模型的领域的需求和功能方面。

关于建筑的一些笔记

前面的例子需要经过几个周期的重写、测试、重构和更多的测试才能达到现在的高度。没有好东西来之不易!以我们存储估计值的方式为例。我们不是将估算值保存在索赔本身中(作为与估算值相对应的浮点数或十进制值),而是将估算值单独存储在不同的模型中,然后将该模型的 ID 保存在索赔中。这可能看起来有点非正统。然而,当我们退后一步,从功能的角度来看这个场景时,我们发现这种方法很好地符合领域需求,即能够在没有对特定声明的引用的情况下计算估计值。这将意味着在数据方面完全分离索赔和索赔的估计数额;因此,我们创建了一个单独的模型类来封装索赔估计的概念。

让我们只考虑需要为要计算的估计值导出的数据点,这些数据点将是反映在患者身上完成的程序的相应 CPT 代码。有趣的是,尽管这个功能的名称是索赔估算,但它的“索赔”部分与估算金额的实际计算没有太大关系。该估计实际上是根据 paycode 表(它需要通过提供者的 ID 来查找)、提供者的 ID(正如刚才提到的)以及在就诊期间完成的 CPT 代码(程序)来计算的。这些都不是真正的索赔本身。它们都是与索赔相关的对象。

如果我们创建了一个接受了一个Claim对象的作业,然后取出该对象中运行估算所需的值,我们肯定会节省一点打字的时间,因为我们会让作业的handle()方法只需要一个参数就可以完成它的工作(这也便于记忆)。

class EstimateClaim extends ShouldQueue
{
     // ...
     public function handle(Claim $claim)
     {
          //do the work...
     }
}

我们现在为自己制造的问题在于Claim模型与索赔估算逻辑的耦合。为什么这是一件坏事?Claim是一个相当大的物体,并且在很大程度上是系统中最重要的物体。就我们的验证上下文而言,它包含许多其他对象、集合、值和数据,在它达到“有效”状态之前需要做大量的工作。

这种耦合显然迫使我们总是拥有一个准备就绪的Claim对象,我们将该对象传递给Estimator服务以获得估计值。现在您可以看到这种方法是如何在应用的其他地方完全可重用的。这就是前面讨论的假设特性的情况,其中一个Biller用户或Provider用户可以在不实际传入一个Claim对象的情况下检索一个索赔估计值,或者一个会计用户想要仔细检查为一个对系统来说相对较新的特定 CPT 代码组合所做的支付是否实际上被设置为检索正确的估计值。它们可能没有一个Claim对象可以传入,因为在这个上下文中根本就没有对象。只有 CPT 代码组合、提供者和该提供者对应的PaycodeSheet参与创建该评估。如果你把它进一步简化,你会发现这个函数的真正输入是提供者和相关的 CPT 代码(可以通过ClaimEstimator服务查找PaycodeSheet,给定 CPT 代码数组的 CPT 代码组合也可以)。以下示例显示了我们的handle()方法中这一微小但有影响的变化:

class EstimateClaim extends Job extends ShouldQueue
{
     // ...
     public function handle(Provider $provider, $codes)
     {
          $cptCodeCombo = CptCodeCombo::fromCodes($codes);
          $paycodeSheet = PaycodeSheet::byProvider($provider);
     }
}

byProvider()fromCodes()方法是Model类上的简单方便的方法,但是它们也可以很容易地(尽管不那么优雅)成为一个原始的查询构建器链,甚至是一个对预定义的存储库方法的调用。

我们遗漏了什么?事件!

当在系统中实际创建一个评估时,它的其他部分可能需要知道这已经发生。例如,也许会计部门依靠这些估计来预测未来几个月的销售和费用。还可能有实时报告,其中前端读取预测使用作业队列(如 Laravel)或第三方包来反映后端写入预测。

既然是这样,首先想到的应该是事件!事件正是为了这个目的而产生的:通知事件的任何侦听器应用中发生了有趣的事情,从而使任何侦听组件能够添加额外的逻辑,或者用自定义域或应用逻辑对事件做出反应。它们制作起来非常简单,并且应该只反映正确表达系统内已经执行的特定动作所需的最少量的数据。您可以使用 Artisan 创建一个简单的事件存根,就像我们过去所做的那样。

php artisan make:event \\Claims\\Submission\\Domain\\Events\\Claims\\ClaimWasEstimated

要更深入地了解在 Laravel 中创建事件,尤其是领域事件,请查看第十一章。

结论

我们将提醒自己,我们正在使用一个框架,我们作为开发人员的工作依赖于 Laravel 的流程和助手工具中封装的功能的利用,而不是从零开始并向您展示一个没有外部依赖性的天真解决方案。以下是整个框架中最常用和最受欢迎的工具:

  • 集合:就支持集合而言,雄辩和 Laravel 有共同之处,这意味着它们本质上是用相同的低级集合抽象构建的,因此对集合方法的每次调用都返回一个新的集合方法,并预先应用了所需的排列。这使得传递对象集合变得非常简单,并且对我们构建规范非常有用。

  • 口才 作用域:作用域基本上只是建立在口才QueryBuilder类之上的语法糖。它们可以被认为是某种“迷你规范”,因为它们描述了一组特定的数据,要么从零开始,要么从一个预过滤的QueryBuilder对象开始,然后向其添加约束(过滤)逻辑,但是在同一模型的上下文中。其方式是通过跟踪一个QueryBuilder对象中的所有过滤和查询细节。使用QueryBuilder对象包含在数据库中查询模型的标准是有意义的。规范本身将消耗Criteria对象来产生一个结果。

  • 口才模型:口才使用的抽象Model类有大量的功能和特性;我劝你自己去查一下( https://github.com/illuminate/database/blob/master/Eloquent/Model.php )。

  • 查询构建器:这是构建雄辩术的底层基础设施。它提供了使用 MySQL 的完整抽象,可以处理复杂的查询而无需编写原始 SQL。然而,对于查询构建器上可用的抽象方法无法完成的情况,您总是可以使用DB::raw() facade 调用来求助于原始 SQL 查询。

  • 助手方法:构建 API 驱动的应用时最广泛使用的助手方法有event()dispatch()app()。请确保尽可能少地使用request()response()助手方法,因为它们往往意味着代码中缺少对系统中的值对象的正确封装,或者具有动态(错误)响应和请求,而这些响应和请求应该封装到 Laravel 请求或响应中,表示某种处理请求和响应的统一方式,从而促进整个系统的一致性。

十九、结论和其他想法

我们在这本书里已经涵盖了相当多的信息。我试图给你一个关于如何以领域驱动的设计方式开发软件的实用知识,这是用 Laravel 实现的。我向您介绍了许多场景,并讨论了一些可能的解决方案,以及可能的故障点。我在书中展示的所有例子都直接取自我开发网络软件的经历。在这一章中,我将回顾一些我们还没有涉及到的或者我们只是简单地提到过的概念。

在这些感兴趣的领域中,您会发现以下内容:

  • 架构:以领域驱动的方式构建系统核心骨干的一些备选策略。我们将使用我从头构建的示例应用作为支持结构来展示一个伪假设场景,该场景与跟踪和管理所有订单、销售、跟踪和事务处理的核心实现部分相关,这些都是常见仓库管理系统的典型流程。

  • 拥抱 Laravel :使用定制的CollectionsQueryBuilder对象在域级别利用 Laravel(而不是使用雄辩的作用域将它们“内联”到模型中,这将作用域直接耦合到模型中,并且不可重用)。您可以使用快捷方式在模型中无缝地使用这些东西,但是我们必须确保在使用这种快捷方式时,不要破坏模型本身的完整性,也不要创建模糊基础领域的新模型。

然后,我将分享关于实现域驱动的 Laravel 应用的其他想法。这将引导我们进行一些总结性的思考,总结我们已经能够用 Laravel 和领域驱动设计做什么,以及我们为最初解决领域问题所做的选择和实现如何影响应用的未来构造和重构。

架构考虑

有许多方法可以架构一个系统,但是只有几种方法可以正确地确保域模型正确地反映底层核心域以及其中包含的内容。说起来容易做起来难。

在这一节中,我们将勾画出一个可能的架构,当它被实现时,将满足系统的需求。我们将根据领域驱动的设计概念来确定这个架构的某些方面。我们将定义系统空间,以适当地确定领域的不同方面,我们可以用它来开始拼凑系统的整体架构。我们将关注体系结构和系统组件之间的关系,而不是实际编写底层代码来实现我们设计的设计。具体来说,我们将通过一个示例应用来管理典型仓库的所有方面,包括接受订单、履行订单和装运订单;接收库存商品;以及在典型业务流程的每一步跟踪产品的位置(更多细节即将推出—敬请关注)!

仓库管理系统

在第三章中,我们使用了一个类似于仓库管理系统的东西,只是在一个特定的环境中(一个在线鞋类零售商的仓库)。这个例子将是更高层次的,将涵盖更广泛的领域。它基于我在过去几年中完成的一个项目。这非常相关,从这个例子中可以学到很多东西。

让我们想象一下,你被聘为一个自由项目的首席开发人员,负责构建一个包罗万象的仓库管理系统。这意味着它可以处理仓库所做、销售和跟踪的许多方面(如果不是大部分的话)。

从构成我们系统中每个单独仓库的核心的数据的角度来看,功能必须以这样一种方式存储,即允许跨系统中的所有对象轻松访问它——以便数据可以以一种清晰和可管理的方式跨组件共享。从应用的角度来看,您需要一种方法来将这些功能分解成更小的部分,以便它们可以独立地工作,但它们又以一种内聚的方式结合在一起,无缝地提供应用中丰富的功能集。

您与项目经理会面,检查所有不同的功能组,以正确地定义什么将作为应用的域模型的一部分。在这次会议中,您提出了一个在较高层次上描述的应用要包含的所需功能的列表,看起来像下面的过程:

  • 第三方订单管理系统接受订单,这应该在我们这边转化为销售订单

  • 完成新订单,从货架上挑选商品到装箱

  • 将订单装运给销售订单上列出的买方(从仓库发出)

  • 接收来自供应商的进货,包括跟踪库存水平和产品位置管理

为了使项目的需求更加清晰,你提出了如图 19-1 所示的高层图表。

img/488324_1_En_19_Chapter/488324_1_En_19_Fig1_HTML.jpg

图 19-1

完成仓库管理应用所需的功能集的草图

图中实线代表硬性关系,虚线代表隐含关系。这些是在构建模型设计时需要考虑的高级功能类别。通过进一步的讨论,您能够确定该系统中需要的几个核心上下文,以及描述这些上下文的分解的一些子项。

  • 指令结束

    • 销售订单

    • 库存调整(暂时“锁定”某种产品的数量)

    • 挑选,打包

  • 接收商品(采购商品)

    • 采购订单

    • 库存调整

  • 储存商品

    • 产品管理

    • 仓库位置跟踪

    • 储存

  • 运输商品

    • 包装

    • 库存调整(“锁”被释放,数量减少)

对于该系统的要求,这一点更加具体。三个黑色圆点描述了仓库的主要关注点。订单执行包括在每次销售中创建销售订单的概念(这可以由支付网关的成功交易来确定)。然后完成订单,这包括从货架上挑选商品,包装箱子,并将其运出。还需要有一种方法来跟踪进来的商品,它通过采购订单做到这一点,并且随着我们的进行,必须在主库存清单中进行充分的跟踪和计数。最后要考虑的是如何以提高效率的方式在仓库中正确存储商品,这一过程被称为入库。每个产品都必须被跟踪,在库存中被计算,然后根据一些标准系统放置在存储架上,以便在仓库系统中跟踪它们的移动。

从这个列表中,您可以确定需要存在于系统中的核心上下文,以便在新的领域模型中捕获业务模型。因为您精通领域驱动的设计,所以您知道领域模型的组织结构本身应该尽可能地按照它所代表的真实业务流程来建模。考虑到这一点,您确定该系统的四个核心环境如下:

  • 订单(接收)

  • 接收(传入)

  • 存储(内部)

  • 运输(发货/可交付)

Note

可以认为存储上下文在技术上是接收的一部分。我将它作为自己的一部分添加到这里,因为以可管理和直观的方式存储这些项目具有挑战性,并且为了运行可能会包含很大一部分逻辑。

系统的某些关键方面支持前面列出的核心领域。这些方面可以被认为是通用子域,因为它们适用于一个以上的上下文(即,跨组件共享),并且或多或少是先前核心域的支持结构。以下是我们需要在该系统中考虑的一些通用子域的示例:

  • 库存管理(由订单、接收和装运上下文使用)

  • 产品管理(由所有其他有界上下文使用)

  • 位置跟踪(用于存储和接收,以及提货和包装)

  • 我们将通过其接收销售订单的外部订单系统

知道了概念的图形化表示如何使它们更容易理解,你很快就能设计出一个图表来显示有界上下文,以及一般子域,以及它们如何适用于每个上下文,如图 19-2 所示。

img/488324_1_En_19_Chapter/488324_1_En_19_Fig2_HTML.jpg

图 19-2

核心上下文和子域之间关系的分解

我们可以推断出,订单上下文需要了解产品管理和库存管理子域。接收上下文将具有与订单上下文相似的知识(也称为依赖性)。存储上下文依赖于产品管理和位置跟踪子域,而运输上下文依赖于所有三个子域。作为一名出色的首席开发人员,您有一种强烈的愿望,要封装术语及其定义,这些术语及其定义将在以后用作各种组件本身的标识符,通常被称为系统的通用语言。这可以用来帮助阐明有界的上下文以及它们之间的各种交互,并且可以被认为是一种上下文图的“草图”。

保持领域的焦点

我们已经规划了实现管理仓库的应用所需的各个部分,这是一个良好的开端。我们必须继续注意我们与领域中的对象的关联,以确保它们直接来自领域本身,而不是来自对它的假设。确保这种领域相关性的一个明确而可靠的方法是尽可能按字面意义命名相应的组件,并保持每个模型的定义尽可能按字面意义,因为它们是在核心领域中定义的。允许领域驱动架构,而不是假设和拥有不准确的信息。

了解了所有这些之后,你决定实现表 19-1 中列出的命名约定,以解决我们之前已经确定的明显问题。

表 19-1

组成示例仓库应用的命名组件

|

组件名称和上下文

|

组件问题

|

属国

|
| --- | --- | --- |
| 命令 | 与订单相关的问题,包括库存检查、账单/地址信息、订单行、交易和创建销售订单(针对离开仓库的产品)。 | 产品管理、库存管理 |
| 接受 | 与跟踪进入仓库的产品相关的所有逻辑,包括仓库内位置放置、库存调整以及与产品管理组件的交互。 | 产品管理、库存管理、位置跟踪 |
| 保管 | 创建和维护某种仓库跟踪系统所需的所有逻辑,以允许快速补货和快速履行订单。 | 产品管理、位置跟踪 |
| 船舶 | 订单执行最后阶段涉及的逻辑。包括根据订单细节从货架上挑选产品,将产品包装到盒子中,并打印出运输标签。库存变化也必须考虑在内。 | 产品管理、库存管理、位置跟踪 |

请注意我们是如何命名这些组件的(使用无处不在的语言中标识的术语,理想情况下,我们已经与项目保持同步)。这里的术语与领域中的术语相同,字面上的意思与它们在仓库管理上下文中的意思相同。这种方法就是有意的字面设计。

Tip

为了培养一种有意义的无处不在的语言,你需要与最了解领域的人——领域专家——进行多次讨论。随着项目的进展,随着在领域或领域模型中获得新的洞察力,您可以(并且应该)重构您的术语定义。

至此,可以说,我们已经有了作为应用主干的核心概念。它们是应用提供的功能的主要部分,因此需要额外的支持机制才能发挥作用。这些以跨组件使用的各种专门支持子域的形式出现(因此必须以一种允许轻松集成或将工作委托给系统中其他组件的方式来开发)。因为这些实际上不是常规的子域,而是被认为是一般的子域,所以我们应该始终努力在系统的核心组件和对它们进行操作的设施之间实现松散耦合,同时,促进诸如维护库存水平、更新订单状态或挑选和包装订单以准备发货等操作所需的内聚机制。

我们的系统能够足够灵活地处理组件之间的各种交互(特定领域组件之间的内部对象交互)是至关重要的。我们将很快制定出一个可能的目录结构的粗略草案,但是值得注意的是,将域驱动的架构迁移到更分布式的架构(如微服务)是多么简单,而不是从单一应用开始,然后迁移到微服务甚至六边形架构。这不是不可能的,但是转换本身可能会变得相当复杂,因为概念必须重新思考,界限必须重新划定,关注点需要组合或分离,以便它最有意义。

命名空间和目录

我们需要做的最后一个分解来分离应用的关注点可能是所有分解中最重要的一个:在模块级别上的领域分解,它直接对应于(尽可能字面上)底层业务领域中存在的概念、关系和结构。现在我们已经正确定义了有界上下文,我们可以决定将域分成哪些模块(包括它的目录和名称空间结构)。一般来说,但不总是这样,最好的做法是将每个子域与一个域模块对齐,并且该模块应该直接从项目的通用语言中命名。

从我们目前的角度来看(还没有考虑基础设施),我们将创建App\Domain\名称空间,它们将作为系统的最高级别父类。App\是我们放置促进和指导领域层所需的逻辑的地方。然而,现在让我们关注领域层本身。

我们首先根据之前确定的有界上下文创建子名称空间(记住,我们还必须修改我们的composer.json文件来添加新的Domain根名称空间)。

在表 19-2 中列出的结构中,我们可以将每个父名称空间的内容视为一个容器,其中包含了特定于其相应有界上下文的所有内容(即,创建新销售订单所涉及的逻辑将发生在Order模块中,而将产品放在货架上以便稍后可以快速找到的动作存在于Storage模块中)。

表 19-2

初始名称空间/目录结构

|

命名空间

|

目录

|

组件

|
| --- | --- | --- |
| Domain\Receiving(或Receivements) | /src/Warehouse/Domain/Receiving(或Receivements) | Receiving(来话) |
| Domain\Ordering(或Orders) | /src/Warehouse/Domain/Ordering ( Orders) | Orders(来话) |
| Domain\Storing(或Storage) | /src/Warehouse/Domain/Storage(或Storage) | Storage(内部) |
| Domain\Shipping(或Shipments) | /src/Warehouse/Domain/Shipping/(或Shipments) | Shipping(传出) |

那么,对于我们的系统运行所依赖的通用子域,我们应该把它们放在哪里呢?嗯,它们仍然是域的一部分,所以我们可以将它们保留在Domain\ root名称空间中,或者由组件类型分隔,或者在一个附加层中(带有类似于Domain\Support\的前缀)。

到目前为止,应用的一种可能的名称空间结构如下:

Warehouse
└── Domain
    ├── Ordering
    ├── Receiving
    ├── Shipping
    ├── Storing
    └── Support
        ├── InventoryManager
        ├── LocationManager
        └── ProductManager

在这个结构中,我们有一个清晰的父名称空间集合,它对应于底层业务的结构。在任何给定的时间,都有一种简单的方法来验证您在规划领域层的轮廓时是否处于正确的道路上。如果您的域模型的原始设计与底层业务结构在接近文字的层次上相关,并且您选择的名称直接来自系统中无处不在的语言,那么您就在正确的轨道上。

因为 Laravel 以某种方式运行(通过将应用的不同部分组织成各种组件,这些组件允许域层与 Laravel 的默认机制提供的其余功能集成),所以我们将坚持使用应用和框架中使用的默认组件。这包括作业(可以分派队列并异步运行)、策略(通过管理谁可以查看、修改或创建模型实例来保护模型的安全性)、雄辩模型(封装模型的特定行为和属性)以及 Laravel 附带的各种其他组件。然而,我们将只使用任何特定的组件,如果我们首先确定它是必要的。我建议您不要为应用的域层创建任何类型的默认目录/名称空间结构,原因有很多(主要的一个原因是遵守 YAGNI——您可能不需要它)。向项目中添加并非 100%必要的类和接口会适得其反,因为这会向应用添加需要维护和保持最新的代码行。项目目录中额外的文件和文件夹只会增加应用的整体复杂性,所以尽量避免将任何不需要的东西放在项目目录中。

查看图层

在我们深入探讨我们的选择之前,让我们花几分钟回顾一下架构的不同层,以及每一层应该属于哪种类型的逻辑。图 19-3 显示了这些层在我们基于 DDD 的应用中的分解。

img/488324_1_En_19_Chapter/488324_1_En_19_Fig3_HTML.jpg

图 19-3

应用的每一层及其各自的关注点

图 19-3 根据组成应用的层以及每层的主要关注点(焦点)对应用进行了分解。这里没有什么新的东西,但是请注意,concertive 与应用的每个中心层一起运行,可以被认为是它自己的独立层(而以前您可能认为 concertive 中的功能位于基础结构层中)。

绘制架构图

现在,我们将开始规划我们之前确定的每个模块需要的东西。例如,我们可以假设(但实际上不会为其创建代码,除非后来认为有必要)大多数模块至少需要以下内容:

  • Models/:领域模型(业务对象)

    1. 实体

    2. 价值对象

  • Repositories/:访问这些型号的/ Management

  • Factories/:抽象更复杂的领域对象的构建的逻辑位置

  • Aggregates/:作为应用中独立单元的实体和值对象的组合位置

  • Services/:作为业务逻辑的一部分出现并且不能包含在实体或值对象中的功能和过程

图 19-4 用图形表示了这一点。

img/488324_1_En_19_Chapter/488324_1_En_19_Fig4_HTML.jpg

图 19-4

应用及其层的可视化表示,以及我们可能在每个层中找到的一些通用组件

图 19-4 显示了应用的高层次视图,其中涉及的各个层被分成各自的泳道。图中不带阴影的项目表示接口或抽象,带阴影的项目表示此类接口的实现。我将中间件作为一个特殊的项目,因为它本身不是一个类,而是一个更底层的概念。从顶部开始,接口层将支持进入我们系统的各种类型的请求,无论是 API 请求还是来自浏览器的请求。我们将很快更深入地讨论接口层和应用层。

在很大程度上,映射关注于系统的领域层方面,因此,它指出了在领域层中的单个模块的上下文中可能会找到哪些类型的对象。通常情况下,我们需要模型作为我们的实体和集合的基础(以便利用雄辩提供的特性),价值对象和集合形成业务领域中存在的基本对象,以及用于对不符合实体或价值对象的正常形状的流程或过程建模的服务。

因为我们正在使用 Laravel 构建我们的应用,所以将这样的服务建模为 Laravel 作业或实现QueueableDispatchable契约的其他类型的域级结构是有意义的,这样它们就可以被推到异步队列中,这通常可以在整个应用中提供高级别的响应。在 Laravel 中最简单的方法是创建一个实现ShouldQueue, InteractsWithQueue和/或SerializesModels特征的Job类(或Command类),然后确保您的配置设置正确,以支持众多受支持队列中的一个,然后一声,您就可以开始了。

Tip

当构建作业来封装系统中的业务逻辑时,请记住,一旦它们被推送到异步堆栈上,它们将不会返回任何值。这意味着,在通过 worker 队列运行的作业或命令的上下文中,对实体所做的任何更改只有在从数据库刷新模型属性时才能检测到。这也意味着作业运行所需的任何数据或依赖项都必须在其构造函数中传递,以便在特定于作业的逻辑运行之前获得这些数据或依赖项。

同样在图 19-4 中,请注意Repository组件被放置在域和基础设施层之间,由实现而不是接口来表示(如阴影区域所示)。这样做的原因是,在一个典型的项目中,您将在应用的领域层中定义给定存储库的接口,可以将其命名为类似于Warehouse\Domain\Orders\Contracts\OrderRepositoryInterface的名称。

这将由基础设施层中的一个类来实现,可能叫做Warehouse\Infrastructure\Orders\Repositories\InMemoryOrderRepository

可以说,在领域层中定义的存储库接口的实现实际上应该位于应用层而不是基础设施层中,因为存储库本身与模型有着密切的联系,所以它可以被视为一个应用服务(位于应用层中),同时也可能属于领域层之外。这也是为什么抽象的Repository接口是在领域层中定义的。(这取决于你,可能不值得花很多时间讨论。)

在图的底部,我在图 19-4 中包含的位于基础设施层的四个文件夹是为了表示保存应用运行所需的重要数据的各个位置。之所以将它们列在基础设施层的上下文中,是因为我们选择了 Laravel 作为构建系统其余部分的底层框架。大多数情况下,这些文件夹存储应用和框架的各个部分使用的某种类型的缓存数据。

请记住,图 19-4 是给你一个可能的架构,你可以用它来构建你的应用。遵循软件的原则和最佳实践是可以的(也是推荐的),软件可以作为一种架构组合在一起。然而,我们想要的是更深思熟虑的东西。让我们看看我们的仓库应用如何适应这个体系结构,以及需要对体系结构进行哪些修改,以允许域作为系统中的底层成分充分发展,这只能通过让域“驱动”来实现。我们将只关注系统中的一个特定模块,即Order模块。图 19-5 展示了一个例子。

img/488324_1_En_19_Chapter/488324_1_En_19_Fig5_HTML.jpg

图 19-5

使用我们之前设计的松散耦合设计的应用模块示例。请注意,所有依赖项都指向域层内部,并且在每个边界交叉处都有接口

Caution

图表很棒,UML 可以用一种易读的格式传递大量信息。然而,不要犯试图绘制系统中所有事物的图表的错误。你将会被保持所有图表最新所需的维护费用所淹没。如果它们不是最新的,那么它们对我们就没用了。将图表作为工具箱中的一项,你可以用它来灌输更高级或更复杂的问题和过程。抵制把所有东西都画出来的冲动。

在图 19-5 中,我们有一个关于Orders领域模块的仓库管理应用架构的概要。从(INT = interface)的顶部开始,我们已经完全折叠了接口层,并且有了一些应用级别的服务,以便于处理域层和应用层之间存在的应用问题。

如果没记错的话,应用层是直接处理域层中的元素的。例如,应用服务通常是域模型对象的直接客户。在图 19-5 中,我们可以将任何利用域对象来完成某些任务的控制器和命令视为应用层的一部分。如果您注意到了,我们仍然遵守依赖规则,并且所有表示依赖的箭头都指向领域层,而不是远离领域层。

这些命令(或控制器)方法很可能会分派任何作业和服务,或者将需要运行的任何其他逻辑委托给其他组件。通过这些构造,外部世界可以与我们的应用内部进行通信。我们希望在应用中实现关注点的强分离(根据Warehouse域),同时保持这些不同部分的一致(和松散)耦合。实现这一点的更好的方法是将充分描述业务级概念的接口放在一个不同层中的另一个类可以实现的方式中?在这样做的过程中,我们实现了高度的分离,因为我们将业务逻辑本身作为一个抽象,在域模型中表示业务中的一个概念。

在图 19-5 中,你会看到这种模式的几个例子。例如,域层中的OrderRepository框是一个接口,其中有一组声明的方法,任何实现类都必须为这些方法提供定义。这些方法将与Order模型相关,可能包括如下功能:

  • getLineItems(int $orderId): array

  • getOrdersByCustomer(int $customerId): Customer

  • getAverageOrderTotalBetween(string $date1, string $date2)

  • : int

因为这些接口概括了领域中的正式流程,所以我们可以将这样的接口放在领域层中,并在不同的层中实现接口。在OrderRepository的例子中,我们在我们的领域层中为它定义了契约,而实现存在于基础设施层中(如前所示,EloquentOrderRepository)。通过遵循将业务和领域概念或过程开发为一组可由任意数量的客户端实现的一致接口的实践,每个客户端都将拥有自己的定制逻辑或关注点,我们的设计支持开箱即用,因为我们可以保证该给定接口的所有客户端(例如OrderRepository接口的EloquentOrderRepository实现)都将拥有在其中定义的指定方法。这是基本的面向对象编程。

图 19-5 中最后要注意的是集合的位置和它所连接的相应元素。众所周知,在领域驱动的设计中,聚合代表了一种它们自己和边界内的类之间的边界线。这增加了模型的复杂性,但通常比试图在没有聚集根的情况下建模要简单得多。由于复杂性的增加,聚合通常没有任何内置逻辑来保存或从数据存储中检索这些对象。相反,一个集合的“构建”方面通常被放置在一个专用的工厂类中,如图 19-5 所示。

Tip

图 19-5 中的Factory接口位于领域层,其实现位于应用层。我这样做的原因是因为应用层中的类和对象是域对象本身的直接客户,而一个工厂存在的唯一目的就是把一个单一的集合对象放在一起,这当然属于这一类。在现实世界中,您可能会发现存在于基础结构层而不是应用层的实现。这是偏好使然,没太大关系,只要你始终如一,坚持自己的决定。

典型的工厂类应该通过尊重集合的边界来重组集合。工厂实例化聚合的所有必需部分(位于聚合根的边界线内的对象),并且,由于没有更好的词,将其打包成所请求的聚合对象并返回它。通过这种方式,我们基本上已经将数据库中的数据转换成了一个成熟的 PHP 对象,我们可以在应用中使用它,这样我们就可以以面向对象的方式与它们进行交互。然后,我们剩下的关注点是从数据库中保存和检索聚合数据,这最好留给存储库(我们在本书的整个过程中已经非常深入地讨论过了)。

进入应用层

从 DDD 的角度来看,为系统中存在的每个域层模块包含一个应用层模块是很常见的。对于前面的例子,我们可以在Application名称空间下创建一个名称空间,其名称与其对应的域层名称相同:Orders

在图 19-6 中,我们设计了一个我们的系统将基于的架构,考虑到我们利用 Laravel 框架作为系统的主干,同时仍然保持系统最重要的规则不变(也就是说,允许领域本身驱动应用的开发)。该图略有不同,因为它以比用 DDD 创建的传统架构更直接的方式包含了框架。

img/488324_1_En_19_Chapter/488324_1_En_19_Fig6_HTML.jpg

图 19-6

我们的仓库项目的应用层的焦点透视。基础设施层未示出

就像我们有一个名为Orders的专用域模块一样,我们也应该在应用层提供一个匹配的模块。正如您所看到的,Laravel 包含的或多或少的“标准”组件显示为在实际的Order模块的外部(也在基础设施层内,这里没有显示)。更有可能的是,我们将需要一个控制器来处理传入的请求,从而在系统中创建、选择、更新或删除订单。还包括雄辩的知识库(它可以更好地位于基础设施层,在图 19-6 中没有显示),其实现最终位于领域层,以及任何第三方系统,我们可以利用它来支持应用的各个方面。

例如,团队可能决定不希望从内部管理应用的身份验证,而是希望将登录和创建新用户的过程委托给其他人。假设我们认为 SaaS 是最好的选择。在网上做了一些研究后,我们发现了一个叫做 Auth0 的东西,这是一个完全集成的系统,以一种清晰的方式处理用户的所有方面(或只是登录部分),支持最先进的静态加密技术,因此我们可以放心(没有双关语)用户的数据在任何时候都尽可能安全。为了将这个服务构建到我们的系统中,我们需要修改默认的LoginController来利用一个定制的存储库类(可能是一个Auth0Repository?)封装了与 Auth0 后端集成所需的逻辑(通过 API)。一个额外的要求是,您必须在LoginController上包含一个方法,该方法将充当“监听器”类型的回调,以便 Auth0 在认证完成后(无论是通过还是失败)进行调用。在这种情况下,Auth0Repository最好位于应用层,因为它是一个应用问题。然而,如果我们正在处理一个存储库,它管理一个聚集对象的保存/检索,那么实现它的合适位置应该是在基础设施层,我们将在下面强调这一点。

基础设施层

最后但同样重要的是,我们有基础设施层,它包含了大部分的 Laravel 默认对象、配置和其他类似的对象,我们称之为基础设施层。正如我们对应用和域层所做的那样,我们将在基础架构层内创建一个名为Orders的新名称空间,它将容纳任何不符合应用或域层要求的订单相关代码。参见图 19-7 中的示例。

img/488324_1_En_19_Chapter/488324_1_En_19_Fig7_HTML.jpg

图 19-7

仓库应用突出显示的基础结构层

基础设施层非常简单,但是它会随着我们添加功能而增长。如前所述,它还可能包括存储库,这些存储库封装了特定模型的存储/检索知识,或者在我们的领域层中的集合。像往常一样,我们将为对象创建一个接口。假设我们(出于某种原因)需要一个只处理内存(RAM)中的数据和对象的存储库。我们可以使用一个InMemoryOrderRepository,它将实现位于域层的OrderRepository接口。实现本身将存在于接口层中,如图 19-7 所示。你能注意到图 19-7 有什么奇怪的地方吗?如果没有,图 19-8 给出了提示。

img/488324_1_En_19_Chapter/488324_1_En_19_Fig8_HTML.jpg

图 19-8

图 19-8 中描述的架构的问题区域

域层中有一些元素对基础结构中的另一个元素有指向外的依赖关系,如果我们记得域逻辑位于何处(朝向中间)以及依赖关系应该以哪种方式指向(向内)域层,这应该会让您觉得有问题。让我们通过查看图 19-9 来重温这个概念。

img/488324_1_En_19_Chapter/488324_1_En_19_Fig9_HTML.jpg

图 19-9

分层体系结构中不同层的复习

因为我们已经选择了 Web 作为编程表达的媒介和画布,在其上以漂亮的代码形式巧妙地描绘艺术,这些代码代表了背后的科学,所以必须考虑实现标准的领域驱动设计。在这种情况下,DDD 的某些方面,如依赖于自身以外的任何东西的域层,通常在 DDD 的上下文中是严格禁止的,但在世界上最强大和最受欢迎的框架之一中似乎在一定程度上是可以接受的。

如果我们观察 Laravel 的构造方式,我们可以看到,例如,Model类扩展了基础Model类,从技术上讲,基础Model类位于基础设施层(位于/vendor目录中)或者可以在它自己的层内(见图 19-4 ),但是无论哪种方式,领域层在技术上仍然依赖于它自身之外的东西。我们如何着手提供满足依赖规则的解决方案?

如果你是从“颠倒依赖关系”的角度来思考问题的,那么你是对的!我们可以做的是在基础设施层中创建一个抽象类来实现一个接口,从而将依赖关系的方向颠倒过来,指向中间的域层。参见图 19-10 中的示例。

img/488324_1_En_19_Chapter/488324_1_En_19_Fig10_HTML.jpg

图 19-10

反转模型类的依赖关系

拥抱拉勒维尔

对于本章的最后一节,我们将使用以前的示例(如医疗索赔应用)作为参考点,来讨论这些总结主题。

将对象作为一个整体来验证

另一方面,如果您需要额外的定制逻辑作为验证过程的一部分运行(这正是我们在 Claim Validator 中所需要的),那么验证对象(或集合)组合的上下文可以通过实现一个定制的Validation服务来解决,该服务将利用我们刚刚创建的可重用的Validator组件。然后,为了让应用中的其他组件能够轻松访问它,您可以使用 Laravel 的服务容器将别名绑定到您的服务。

为对象组合验证创建验证器服务

要创建一个服务,您需要考虑几件事情,建模和架构师。

  1. 服务真的需要吗?某种价值对象或实体就足够了吗?

    →如果您正在构建的东西不适合某个实体或对象的上下文,服务可能是一个不错的选择。

  2. 这项服务到底要完成什么?

    →当您清楚地确定服务的目标以及它应该为整个应用完成什么时,它有助于缩小范围。

  3. 服务完成工作需要哪些组件、类、服务、对象、实体和数据?

    →定义服务范围之外的代码的任何关系、依赖性或其他关联是有益的。

  4. 这项服务在哪里最好?在什么上下文或模块中?

    →除了确定文件的物理放置位置,它还通过定义服务将位于哪个层来帮助为服务提供上下文。

  5. 如何测试服务?

    →始终致力于创建单元测试和(尽可能)完整的自动化测试套件。

对于我们关于声明验证的特定上下文,我们将假设由于其复杂性而需要该服务,尽管实际上这可能是多余的(步骤 1)。该服务将处理 CPT 代码组合的验证,验证该组合是否可供提供者在其 paycode 表上使用,建立与索赔一起提交的正确文档,并验证患者是否有资格接受护理(步骤 2)。我们将需要访问索赔本身,以及涉及索赔的任何内容(属性和关系),以及 CPT 代码模型、工资代码表(我们仍需定义)和患者资格服务,我们不会构建这些服务,只是验证它是否已为索赔中的患者运行(步骤 3)。一般来说,验证是一个基础设施问题,因为它不一定与领域本身有任何关系,而是与确保给定的模型(在我们的例子中,是Claim模型)处于有效状态有关(步骤 4)。这显然是一个很好的关注点分离,所以它属于基础设施层。就测试而言,一个简单的单元测试覆盖服务中涉及的大部分代码就足够了(步骤 5)。

现在我们对实际构建的内容有了更多的了解,我们可以开始创建一个名为ClaimsValidationService.php的新文件,该文件位于与其有界上下文和模块相对应的名称空间内。该服务将处理任何复杂的验证逻辑,以验证声明的其余部分。此时,大部分验证需要在我们已经通过实现我们在ClaimSubmissionRequest类中指定的规则处理过的索赔提交上运行。我们还没有涉及的一个问题是验证给定的 CPT 代码组合是否存在,并且是否在提供商的 paycode 表中列出。

在我们的服务中,我们可以使用本章中定义的Validation类来构建验证器。我们甚至可以使用我们之前构建的验证器来处理 CPT 代码组合和 paycode 表的另一个验证;然而,我选择使用服务来建模,这样您就可以看到当存在复杂的结构和/或跨越各种检查的多个前置条件/后置条件时如何进行验证,以确保一致性。像这样复杂的验证应该属于专门的服务。为了避免创建低级组件来正确连接这样的服务(即,具有正确的依赖关系并位于正确的上下文/模块中)的麻烦,我们可以转而依赖 Laravel 的Validation组件,甚至对其进行扩展以更好地满足我们的需求。因此,我们将利用 Laravel 的Validation组件。

论作为不一致实践的 Web 开发…

至少可以说,我承认 web 开发是不稳定的。真的没有很多共享资源,尤其是在过去。承认,随着软件工程的原则和实践慢慢进入 web 开发社区,在过去的几十年里事情已经变得更好了。虽然这是真的,但现实是 HTTP 还是一样的。web 编程语言(如 PHP)所基于的底层技术是一种不稳定的底层架构,它源于世界上最古老的协议之一:客户机-服务器模型。增加了会话来缓解这个问题,HTTP 2.0 提供了对原始实现的急需的重构;然而,在我看来,会话只是解决页面跳转之间共享状态的(更大的)问题的权宜之计,HTTP 2.0 并没有非常广泛,也没有像大家想象的那样流行。在全球范围内采用 HTTP 2.0 规范是肯定会发生的,但就目前情况来看,这是一条缓慢的道路。

最重要的是,坚持标准和最佳实践

随着时间的推移,随着应用复杂性的增加,无论使用哪种框架,维护大规模的代码库都变得越来越困难。分离关注点变得更具挑战性,因为要在代码中的逻辑段之间画出分界线。永远记住让领域成为应用开发的驱动力,并努力按照为该上下文设置的无处不在的语言来命名事物。尽可能准确地表达领域。

在本书的前面,我提出了这样一个论点,即除非您计划拥有多个数据库平台,并且您必须在任何给定的时间保持对所有这些平台的访问,否则并不真正需要存储库。不属于这种情况的一个实例是指定由标准描述的特定模型的合格对象集,该标准针对该模型的数据库数据采用条件和约束。在这里,存储库作为一种抽象描述标准本身的方式是有用的。

说所有这些是一回事,但让我们围绕根据标准描述数据集的想法来考虑一些背景。因为我们都知道接口是封装变更及其各种实现的具体细节的方式,所以我们决定最好首先使用一组接口来描述标准的整个概念(这对于在代码中记录想法非常有用,使新来者更容易快速理解契约是什么以及它的用途)。让我们为“标准”的概念建立一个基本的接口,我们可以用它来过滤和约束我们的数据。我们将使用我们的好朋友索赔处理应用作为清单 19-1 中的底层平台。

<?php

namespace Claim\Submission\Domain\Contracts;

interface CriteriaHandler
{
     /** Skip any applied criteria during processing */
      public function skipCriteria(bool $status=true): Criteria;

     /** Return the currently configured criteria */
      public function getCriteria() : array;

     /** Immediately run the passed in criteria and return results */
     public function getByCriteria(Criteria $criteria): array;

     /** Add some criteria to the set of criteria to be applied */
      public function pushCriteria() : array;

     /** Apply any pushed criteria */
      public function applyCriteria();
}

Listing 19-1Example Interface to Manage Criteria

在第一个清单中,我们有一些实现Criteria接口所需的方法,包括跳过当前迭代的任何推送标准的功能,将一个或多个标准对象添加到堆栈中,并添加一个助手方法来立即运行传递到getByCriteria()方法中的标准并返回结果(不处理堆栈)。还有一种方法可以一次运行整个标准堆栈(已经被推入堆栈的内容),applyCriteria。这很好地描述了成为一个支持Criteria的对象对我们的应用意味着什么。记下责任。

因为我们希望在我们的标准实现中包含一组丰富的功能,并且因为除了方法和签名之外,我们不能在接口中放置任何东西,所以我们可以创建一个抽象类来容纳跨子类的公共功能。无论如何,不要在需要附加到存储库实现的另一组类(或特征)中这样做,为了类类型的保存,我们可以做的是在基本存储库类中实现Criteria接口。接下来就是创建一个子类,为任何这样的模型扩展这个基本存储库,我们希望能够通过指定Criteria来查询该模型下的数据。在我们这样做之前,让我们确保在我们的系统中为一个基本的Repository类定义一个接口。为了明确标准存储库在一般意义上应该具有的功能,我们将创建接口,就好像我们在它的Model类的每一个实例上都没有口才一样(这意味着我们创建存储库来容纳底层模型被查询的方式)。为了简洁起见,我将省略清单 19-2 中的 PHP 文档块。

<?php

namespace Claim\Submission\Domain\Contracts;

interface Repository
{
     public function all(array $columns = ['*']);
     public function paginate(int $perpage=1, array $columns = ['*']);
     public function find(int $id, array $columns);
     public function findBy(string $field, $value, $columns=["*"]);
     public function findAllBy(string $field, $value, $columns=["*"]);
     public function findWhere(string $where, columns=[“*”]);
     public function findOrFail(int $id, $columns=[“*”];
}

Listing 19-2A Description of a Repository Object Without the Functionality of Eloquentat Is Provided in All Models

这个接口有点长,但是它包含了关于模型数据的所有基本需求,包括创建、读取、更新和删除(CRUD)等功能。如果我们把雄辩的 ORM 从等式中去掉,那么我们就需要以一种方式管理模型的数据,这种方式可以很容易地重复,并且可以在代码库的其余部分中任何需要的地方使用。这正是所列出的接口在存储库类的封装中所提供的。

还要注意我们在接口中定义这些方法的方式。这样做的方式是利用原始值,并且不包括对系统中任何其他类的引用。这是理想的,因为依赖关系越少,问题就越少;然而,这并不总是一个可行的解决方案。有时,事物只能存在于另一个已定义的类或接口的上下文中,并且必须包含在新接口的定义中。在定义接口时,尽可能坚持原始值,就像我们在清单 19-3 中所做的那样。

<?php

namespace Claim\Submission\Domain\Repository;

use Claim\Submission\Domain\Contracts\Repository as
     RepositoryInterface;
use Claim\Submission\Domain\Contracts\CriteriaHandler;

use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Container\Container as App;
use Illuminate\Http\Exception\HttpResponseException;

abstract class BaseRepository implements RepositoryInterface,
                                     CriteriaHandler
{
     /** Specify the underlying model class */
     abstract public function model(): Model;

     /** Service Container */
     private App $app;

     /** The underlying model class name*/
     protected string $model;

     /** The current stack of criteria */
     protected Collection $criteria;

     /** Switch to skip criteria */
     protected bool $skipCriteria = false;

     /** Prevent overwriting same criteria in stack */
     protected bool $preventCriteriaOverwriting = true;

     public function __construct(App $app, Collection $collection)
     {
          $this->app = $app;
          $this->criteria = $collection;
          $this->resetScope();
          $this->makeModel();
     }

     public function all(array $columns = [“*”])
     {
          $this->applyCriteria();
          return $this->model->get($columns);
     }

     public function query()
     {
          return $this->model;
     }

     public function find($id, $columns=[“*”])
     {
          $this->applyCriteria();
          return $this->model->findOrFail($id, $columns);
     }

     public function findBy($attribute, $value, $columns=[“*”])
     {
          $this->applyCriteria();
          return $this->model->where($attribute, ‘=’,
               $value)->first($columns);
     }

     public function (Criteria $criteria)
     {
          $this->model = $criteria->apply($this->model, $this);
          return $this;
     }

     public function applyCriteria()
     {
          if ($this->skipCriteria === true) {
               return $this;
          }

          foreach ($this->getCriteria() as $criteria) {
               if ($criteria instanceof Criteria) {
                    $this->model = $criteria->apply(
                         $this->model, $this);
               }
          }
          return $this;
     }
}

Listing 19-3An Implementation of a Repository as Defined by the Repository and Criteria Interfaces

在清单 19-3 中,我们在一个抽象类中有一个RepositoryCriteria接口的通用实现。让我们通过查看图 19-11 来快速查看一下我们目前定义的结构。

img/488324_1_En_19_Chapter/488324_1_En_19_Fig11_HTML.jpg

图 19-11

当前实现的 UML

图 19-11 描述了一个抽象类实现多个契约(接口)的典型场景。该类不能被直接实例化,而是意味着从扩展而来,以便子类能够“储备”您在抽象类中提供的一组默认功能。现在,根据我们当时的需求,我们可以选择通过为每个接口实现独立的子类来进一步分离关注点,如图 19-12 所示。

img/488324_1_En_19_Chapter/488324_1_En_19_Fig12_HTML.jpg

图 19-12

我们的存储库和标准接口的更加复杂和灵活的版本

我们现在对整个对象模式有了一个额外的深度层,因为它获取的知识不仅是我们正在构建的东西的特定知识,更重要的是,是领域的特定知识。这样,当与一个Criteria对象结合使用时,我们已经表达了一个存储库的能力,并且我们已经在域的层次上这样做了。这种方法可能是有效的,但是作为一种折衷,可能会导致需要维护的额外类的激增,并且很可能为需要这种能力的每个域对象复制这些额外的类。通过将子类的创建限制在严格的按需基础上,可以避免这种情况,并且最终可以得到一些潜在可重用组件的丰富实现。这就是游戏的名字!尽可能地干燥,这与可重用代码的概念是一致的,并且当您在系统中添加这种严格的关注点分离时,它会变得更加强大。假设应用确实需要这些功能来完成它的工作(并且您或您的团队没有通过定义这种超详细、细粒度的微对象来过度设计它),请确保它们不会以一组命名很长的接口和抽象结束,这些接口和抽象没有真正地将组件作为一个整体来传达,也没有提供它存在的任何理由,即它解决它想要解决的问题的能力。

就代码而言,从清单 19-1 到 19-2 的变化将是最小的,并且只要您编码到一个接口,而不是一个实现,使用这些接口的应用的任何其他部分(通常是应用或域服务)将不必修改。

Tip

值得以更独特的形式重复:

代码到接口,而不是实现。

为了实现这一点,您应该正确地键入任何消费代码,这些代码依赖于接口本身的任何实现的功能。只要领域被清楚地定义,并且你依赖它来指导你在这个和所有其他应用问题上的决定,这应该是自然而然的事情。

这就是对接口而不是对实现进行编码的好处:它允许更大的灵活性,并且增加了在应用的其他地方重用的机会(但这并不能保证)。接口本身可以由无限数量的子类来实现,每个子类指定更细粒度的细节。这是有道理的,因为使用这样一个抽象结构和明确的关注点分离的整个概念是能够定义一个接口的特定实例(也就是适配器),这些实例描述了对象的“模式”(如果你愿意的话)中的特定规则,而这又是我们在接口(也就是端口)中定义的。这使我们能够以一种从一般到特殊的方式创建对象,对象的粒度封装在实现类的范围内。

关于这些图,最后要注意的一点是为什么它们是这样分开的。在定义包含在代码中的功能的概念线和边界的计算中,有哪些元素通过文件系统中的名称空间和物理位置进行划分?

Tip

简而言之,当定义分离应用关注点的界线时,总是尝试在域之后对它们建模,并根据它们的变化率对它们进行分组。以相同频率变化的物体应该放在一起。

如果我们试图将在同一类、模块或接口中以不同速率变化的事物与以该速率变化的其他事物保持在一起,这将在我们修改组件的任何部分或其结构时得到回报,因为它对依赖于它的代码几乎没有影响(如果有的话)。

结束语

领域驱动的设计是解决现代商业问题的实用方法,因为它保持了对领域的关注。在 web 应用世界中,当领域代码达到一定的复杂程度时,几乎总是会使用框架。这有多种原因,包括不必为应用层包含的每个机制重新发明轮子的好处。通过利用 Laravel 作为实现领域驱动设计的一种手段,我们可以设计出满足项目需求的应用,并确保我们的应用的生命周期。当我们热衷于遵循软件开发中的最佳实践和现代标准时,我们发现应用的维护变得更加简单,重构也是如此。扩展应用的功能可以相对容易地实现,并且可以以领域模块的形式出现,这使得根据领域本身封装功能逻辑(例如,Order模块、Ship模块等)变得容易。,来自我们本章开头的仓库应用)。

继续读。继续编码。继续想。保持一致。知识就是力量,缺乏知识就是无知。不要做后者。

第一部分:怪异的组合

第二部分:领域驱动方法

第三部分:DDD 的技术方面

第四部分:Laravel 上的 DDD

posted @ 2024-08-03 11:22  绝不原创的飞龙  阅读(13)  评论(0编辑  收藏  举报