九、领域驱动的索赔提交方法
这一章将包含我们正在烹制的领域驱动晚餐的主要内容(主菜)。因此,我将包括一些关于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 模型。
图 9-1
cpt_code、cpt_code_combo 和中间查找表的 UML 图
我们面临的下一个问题是对 CPT 代码的组合进行建模。
在设计应用的这一部分时,我们需要记住的一件事是我们存储代码组合的方式。我们有几个选择:
-
在
cpt_code_combos
表的一列中存储一个逗号分隔的单个 CPT 代码列表 -
创建一个数据透视表,它将连接各种 CPT 代码以形成 CPT 代码组合(如前面所示)
当面临诸如此类的决策时,它有助于抓住每一种可能性,并通过它影响(触及)的潜在需求或用例进行循环。例如,考虑一个提供者提交索赔的用例。他们看到的只是一个带有下拉列表的表单域和一个自动完成的各种 CPT 代码列表,按名称列出。他们将从下拉菜单中选择一个或多个代码;然后,他们希望在提交索赔之前看到估计的索赔金额。为了获得估计的索赔金额,我们需要在应用中使用一种机制来查询 paycode 表,该表将 CPT 代码(通过 CPT 代码组合)和提供者联系在一起,指定每个代码的金额。然后,估计的金额将是使用索赔上的提供者 ID 和 CPT 代码组合查询该支付代码表的结果。
回到如何对 CPT 代码组合建模的问题,我们将不得不查询数据库以找出给定的 CPT 代码组合是否存在,然后在 paycode 表中为该代码组合选择相应的记录。那么,是用前面列表中选项 1 描述的解决方案更容易做到这一点,还是选项 2 更好呢?好了,让我们通过现在已经阐明的方式来运行它们,我们将在应用中使用它们。
-
在 CPT 代码组合中的单个字段中存储一个单独的 CPT 代码列表看起来会使在一个组中选择它们的过程更容易,但是想想这样做的含义:它们在数据库字段中的存储方式必须在每一行中都完全相同,包括 CPT 代码 id 之间的间距。这也让客户来确保(这通常不是最好的方法)组合的精确语法。另一个要考虑的问题是是否以任何特定的顺序列出它们,同样要记住,客户端将负责保持数据库使用的格式的 CPT 代码的顺序。然而,基于相应的 CPT 代码查询表中的组合会非常容易,我们可以在一个数据库调用中完成。
-
虽然这种解决方案没有将组合本身和组成组合的代码存储在同一个模型和数据库表中的好处,但它有许多预期的好处:它减少了为单个代码(如果它们在一个字段中)维护任何类型的结构的需要,而且我们不必担心解析 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_id
和cpt_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 笔记本)并画出图表,总不会有什么坏处。
图 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 类型的字段可能是对这种设计的改进)。
图 9-3
用于存储薪资代码表的数据库模式,包括所涉及的其他实体以及包含所涉及的 cpt 代码列表的新 csv_cpt_codes 字段
这是一种非常简单的方法,可以在系统中对支付代码表进行建模,从而明确区分代码、代码组合、提供商以及提供商从完成每个组合中获得的收益。让我们浏览一下我们的笔记,看看这个建议的模式是否解决了系统关于工资代码表和应用本身与工资代码表交互的所有需求。
-
CPT 代码以 CPT 代码组合的形式与索赔相关联。
-
支付代码表由许多不同的
cpt_code_combos
值组成,这些值是基于每个提供商设置的。 -
我们将 CPT 代码保存在两个地方;尽管这通常不被认可,但该架构的初始草案将在给定的 CPT 代码组合记录中使用逗号分隔的 CPT 代码列表,从而使我们能够执行以下操作:
-
获取包含在 CPT 代码组合中的 CPT 代码
-
获取包含单个 CPT 代码的 CPT 代码组合
-
从指定的 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, CptCodeCombo
和Center
)。我们还将在清单 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 显示了一个与索赔项目相关的一些(不是全部)关注点的例子(注意,我们没有涵盖现实应用中存在的每一个关注点),取自索赔验证上下文。
图 10-1
索赔验证上下文的索赔示例项目中的问题空间和解决方案空间
这个图表非常简单,包括一些关于我们在开发这个应用时所面临的各种问题的简要总结,以及一些关于每个问题的可能解决方案的相应要点。
交付机制
根据系统的几个方面,在 Laravel 和任何其他 web 应用中有不同的方法来交付响应。其中一个方面是交付机制,它可以是单个类,也可以是多个类一起向外部世界提供域的服务和逻辑。在 web 应用中,这通常是通过一个控制器和一个视图来完成的,该视图被编译为 HTML 并作为对客户端请求的响应发送到浏览器,但是您可能拥有或需要额外的机制来响应 API 请求(资源、资源控制器和转换器)、命令行请求(Artisan 命令)或带有另一个 sms 消息的 SMS 文本消息请求。
无论您为您的应用实现什么样的交付机制,它们都应该与域模型相分离。域层中的任何东西都不应该关心响应是如何传递给系统的,或者请求是如何进入系统的;在这些事情上我们可以依靠拉弗尔。这样做,我们可以节省构建基础代码的时间,这些代码将作为管理域对象以及与域层中其他对象的交互的“管道”。
继续索赔模式
为了更好地理解 DDD,我们将从设计索赔项目的模型时停止的地方继续。图 10-2 提供了我们正在构建的内容的复习。
图 10-2
索赔模型
该模型与系统中的其他实体和业务对象有一些关系。一个索赔显然有一个提交索赔的提供者和一个完成程序的患者。此外,该特定患者的持续医生进度记录与索赔一起保存,以及执行服务的日期。还有 CPT 代码,它描述了给予患者的特定治疗,FQHC 使用它作为他们实际支付给提供者的费用。基本上,这意味着这种情况下的索赔是可交付的。没有索赔,谁也拿不到钱。当我们从这些方面考虑时,我们甚至可以将这种情况下的可交付成果与软件开发人员为了获得报酬而需要的可交付成果进行比较:工作的、可用的软件。如果我们将有问题的东西发布到生产中,我们几乎会立即意识到这一点,因为大量充满敌意的电子邮件和联系支持票的数量都呈指数级增长。这就是为什么我们要确保我们已经正确地测试和编码了我们的系统,以避免在运输未经测试或不稳定的产品时可能出现的所有错误和停机时间。
我们需要像对待软件一样对待索赔,因为最终要由 FQHC(经过 FQHC 计费用户的验证和签署)来确定我们的索赔是否符合联邦法律规定的要求,只有这样他们才会向服务提供商支付费用。我们需要确保我们的可交付物没有瑕疵,以防止付款延迟,这就是我们的应用的目的。
定义范围
这一部分涵盖了整个体系结构和设计的各个部分,包括声明验证上下文中包含的关注点。图 10-3 显示了它包括的内容。
图 10-3
关于索赔项目的关注点,我们将在本章中讨论
图 10-3 中描述的项目主要包括验证所有必需的文件和输入数据,确保所有其他要求(如患者资格)正确无误,以及验证 CPT 代码组合对该提供商有效。请记住,每个提供者都有自己的支付代码表,其中描述了他们可以使用的 CPT 组合以及他们为某个程序支付的金额。还有一个问题是,根据薪资代码表和索赔的 CPT 组合的计算来估计索赔的支出。
这里的总体目标是消除错误源,否则这些错误源可能是由服务提供者或接待员的错误造成的。在我们实际向系统提交索赔之前,我们需要确保所需的数据都在那里并且有效(在这种情况下,索赔的下一站将是人工索赔审查过程)。在将索赔提交给 FQHC 之前,该流程需要一名团队成员亲自核实索赔中的所有数据是否正确。
确认
验证是软件开发的一个重要方面;它们以约束一个更加无限的宇宙中的无限数量的项目的形式提供了某种理智。具体来说,它们使我们能够确保我们拥有的任何数据都是有效和准确的,这样我们就不必因为用户错误、打字错误或任何其他错误而回去“重做”,如果不正确,这些错误可能会导致糟糕的事情发生。
对于我们的索赔模型,有几件事情我们需要验证,这样我们就可以认为索赔是“有效的”(至少对于自动验证检查来说),并将索赔转移到审查过程中。据我们所知,我们在本书前面设计的索赔模型具有大多数所需数据的关系,因此我们可以假设我们将要验证索赔对象中生命的大多数数据。但是,列表上的一些项目表明,这些数据实际上应该属于系统中的其他模型。
这方面的一个例子是病人的资格。我们可以将资格附加到索赔本身,这很好,可能会满足我们的需要。然而,在我看来,资格更多地与病人而不是索赔有关。这方面的一个例子是患者的资格(图 10-4 )。
图 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 代码组合需要通过两种方式进行验证。
-
每个单独的 CPT 代码都需要验证是否存在于我们的系统中(这将依赖于静态数据库表进行查找)。
-
验证在申请中选择的组合实际上是属于提交申请的提供商的一组有效的个人 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
接口的子类将已经在抽象类中有了所需的函数,用指定为abstract
的validate()
方法来强制子实现定义其特定的验证逻辑。我们还包含了一个抽象的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
对象中),并执行一个简单的谓词,如检查结果并返回true
或false.
为了验证系统中的几乎任何东西,您可以拥有任意数量的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)
- 这是产品到达的批次的识别号。这与特定的提货单的标识符相匹配,提货单作为卡车交付到仓库的收据。
在前面的列表中,您应该注意到数据点本身更倾向于一组产品,而不是单个产品的物理细节。这是因为,在产品批次(货盘)到达时,需要以一种大粒度到细粒度的方式对它们进行统计。当卡车到达仓库,产品到达地板时,卡车司机只打算等你清点交付的数量,并确保每一个大颗粒件都得到清点(通过根据给定的装箱单/采购订单清点托盘和批次)。如果你必须坐在那里一个接一个地数每一件产品,卡车司机会在那里一整天!
在较大尺寸物品的初始“登记”被考虑之后,它们通常必须被分解成仓库可以容易地销售、跟踪和交付的较小的子部分。在这一点上,交付的更细粒度的物品(货盘上的物品)然后被人工验证,除非检测到缺陷或异常,否则产品被添加到库存中并被放置在货架上,准备出售、挑选和包装(流程中的下一个阶段)。在销售过程的这一部分,产品本身的各个方面被深入到一个更细粒度的上下文中。这些方面可能包括以下内容:
图 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 显示了该示例的视图。
图 11-2
公共仓库中的不同上下文表示同一术语在通用语言中的不同用法,或者更具体地说,表示“产品”的概念
从图 11-2 中可以看出,作品“产品”在不同的语境中有不同的含义。每个上下文都可以(并且可能应该)包含它自己的无处不在的语言。术语产品在不同的上下文中有不同的含义。虚线椭圆内还有一些概念,表明它们在多个组件(上下文)之间共享。
当构建前面描述的这样一个系统时,最好的方法并不总是最清晰、最容易或最明显的方法——尤其是当您从高层次构建系统时。一个好的起点是像我们已经做的那样勾画出所有的东西,集中在领域模型中可以画线的地方,以分割驱动应用的各种概念和组件(并组成领域模型本身)。
上下文映射的概念实际上就是一系列常见的模式,人们可以通过限制不同上下文共享的代码来实现跨各种上下文边界的功能。我们对领域模型的边界做得越独立,我们的情况就越好;游戏的名称在很大程度上限制了每个 BC 与其他 BC 之间的依赖性,同时仍然提供允许组件作为一个应用运行的功能,同时仍然限制每个组件所依赖的共享资源的数量(即,上下文及其关系上下文之间的交互越少,您的情况就越好)。
这些关系中的每一个都有其相关的任何其他关系的上游或下游,这取决于它们对其他子系统的依赖程度,这些子系统可能是正常运行所必需的。如果我们有两个子系统,子系统和子系统 b,并且子系统位于子系统 b 的下游,这表明子系统 b 的运作直接影响子系统 a 的整体成功。相反,子系统 b 中的项目不一定受子系统的影响。
这是设计中要包含的重要信息,因为要对一个复杂的域建模,我们应该对各种有界上下文以及它们之间的关联/关系有一个高层次、大规模的视图(或映射),如域模型所指示的,这样我们就可以更好地理解每个有界上下文对应用其余部分的影响,以及什么上下文与什么其他上下文以及它们之间的关系。我在上一段中提到的 DDD 定义的语境模式给了我们一组合理的关系,可以帮助定义语境到语境的交流,除了一些极端的情况或超复杂的领域,没有其他的可能。我们将在本章回顾这些模式,并使用它们来构建一个基本的上下文图,它将揭示我们的应用的边界、与这些边界的关系,以及每个上下文如何影响(上游)或不影响(下游)其他的。我们将使用该上下文图作为决策背后的驱动力,这些决策涉及哪些边界有哪些通信点以及如何我们将构建一些连接边界的通信路径,以便我们可以从我们已分解到他们自己的单独层中的精炼域对象为我们的用户创建真正的功能。
从更广的角度来看,当您考虑领域层及其对象、类和接口参与者是如何产生的:功能分解时,这是完全有意义的。一旦我们有了模型的粗略草图(这通常表明在功能分解过程中取得了一定程度的成功),我们就需要以某种形式重新组合它们,以创建特定于应用底层领域的可用功能和特性。然而,我们需要小心我们将不同的关注点放在系统中的什么位置(以及每个关注点应该放在哪个层),并确保它们的结构与 it 建模的领域紧密相关。
上下文映射还用于显示每个上下文之间共享的数据的分解,以及给定上下文在应用中相对于系统全局视图的位置。图有助于传达上下文图中的信息,我建议您保留一个上下文图,供整个团队使用,进行更改,甚至有助于透视整体架构以及单个有界上下文对其余有界上下文的依赖性。
有界上下文关系
有界上下文与其他上下文的关系可以分为几个模式,这些模式描述了关系的中心概念以及每个上下文对域模型中其他子系统的上游/下游从属关系。通常的做法是使用这些关系来勾画当前系统的“地形”(如果有当前系统的话),作为一种方法来对域内系统的这些方面以及它们与其他子系统、模块或有界上下文的交互进行分类。通常情况下,旨在测试每个上下文中的所有接口以及它们之间的接触点的自动化测试套件被证明是一种无价的资源,可以保持一定程度的确定性,即每个上下文都提供了另一个上下文所需要的东西,反之亦然。
在下一节中,您将找到上下文映射模式的列表和每个模式的简要说明,您将深入了解合作模式,以及它如何应用于与患者资格和获得资格的方法相关的提交流程的关注点。有时子系统实际上是物理项目,它们之间有某种类型的关系,也可以使用这些上下文映射模式来描述。
合作关系
这种类型的关系发生在两个团队、两个项目和/或两个子系统之间,它们相互依赖以获得各自的成功。如果一个项目失败了,两个项目都会失败,反之亦然。与另一个项目有合作关系的项目必须有协调的计划会议以及定义良好的工作流,用于处理两个项目的集成,并得到两个团队的同意。团队应该小心地相互密切协作,以满足两个项目的开发需求,这种方式将允许每个项目满足其特定的目标和要求,以便两个项目都能成功。可能不要求两个团队或系统都非常了解对方的细节,但是要求根据另一个系统来维护每个系统,以便另一个系统的影子可以与第一个系统适当地集成。这个需求就是协调计划会议,不断地重构和细化每个系统,以便它仍然保持两个系统之间的平衡,以及它们自己的独立系统,并且根据需要不断地集成两个项目的特性和代码,以保持两个系统的同步。
索赔申请中的伙伴关系示例
在我们的索赔项目中(我们将在本章和后面的章节中继续开发),在索赔提交上下文和患者资格上下文之间存在一种伙伴关系。两者相互依赖才能成功。
患者资格工具利用刮刀来确定给定患者是否有资格接受医疗服务提供者的护理。如果没有索赔上下文,这可能仍然是有用的,但在提交索赔的上下文中,它不会为提供者提供任何额外的时间节省或其他此类好处来提交索赔,部分原因是,如果没有我们的系统,提供者的办公室将被迫回到提交索赔的打印和传真方法(这种方法效率不高,容易出现小错误,可能会导致延迟支付为该患者提供治疗的提供者,并且在患者最后一次就诊期间,患者的资格可能已经改变)。这为治疗那些没有资格接受治疗的患者打开了大门,因此他们不会因为这次就诊而获得任何报酬。
另一方面,索赔提交上下文不能以自动化的方式完成,而这正是我们最初开始构建应用时想要支持的。如果诊所的提供者或接待员必须在进行其他与索赔提交相关的活动(FQHC 接受索赔所必需的)的中途停下来,以便他们可以登录到 Medi-Cal 联邦患者资格检查系统并手动验证患者的资格,那么理论上永远无法实现提交索赔这一棘手问题的完全自动化。无法验证患者是否合格,因此将完全依赖于提交索赔的提供者办公室的准确性,这不是我们可以信任用户去做的事情。然而,一个自动抓取器为您抓取数据,然后自动更新声明以包含该事实,这将是解决这一棘手问题的一种相当直接的方法。
界定伙伴关系中两种情况的界限
为了从总体的角度管理项目的整体成功,我们需要有足够的边界来为每个上下文提供功能和支持。在前一种情况下,边界已经存在(因为它们在不同的上下文、不同的名称空间和不同的文件夹中)。现在剩下要做的是设计这两个上下文如何相互通信以实现某个目标。图 11-3 将有助于澄清这两种环境之间的这些方面的交流;它描述了在这一点上完全分离的两个系统,以及它们将使用和操作以提供适用解决方案的各种核心方面和元素。
图 11-3
索赔提交和资格刮刀上下文及其核心结构/构造的高级视图,使其能够发挥作用
正如我们所看到的,尽管图中包含了特定于每个上下文的模型,但是两个上下文都有一个最外层的域服务,它将提供一种访问上下文内部功能的方法。这意味着我们不必从索赔提交上下文中直接管理Eligibility
对象*。相反,提交上下文可以只调用EligibilityScraperService
,它将通过抓取 Medi-Cal 网站的数据来处理请求,然后以提交上下文可以使用的格式返回结果,此时它将使索赔通过所需的资格检查(这被视为系统中接受索赔的一个要求),并允许索赔继续前进到索赔审查阶段。*
*在数据方面,如果您向前看图 11-4 ,您将看到患者资格数据结构的数据部分的可能实现。患者医疗状态的合格性直接与患者相关,而不是与索赔相关,因为在领域模型中,这是更自然的“适合”,大声说出来更有意义。这在图 11-4 中并不明显。事实上,看起来可能不是这样,因为Patient
和Eligibility
模型是分开的。当您发现自己处于这样的情况下,其中表示每个模型的数据位于与该模型的行为不同的上下文或设置中时,最好回顾一下系统的需求,以便您可以确定需要进行分离。如果是,那么我们仍然可以通过将实际模型的代码驻留在这些上下文中的一个特定位置,然后创建类似 DTO 的东西用于其他上下文,来将每个上下文中的依赖项数量保持在最小。通过使用 d to,我们仍然可以提供其他有界上下文需要知道的数据(这是因为它们在上下文映射中的相互关系),同时仍然保持封装在给定上下文中的行为的分离。图 11-4 显示了上一张图的迭代,以更好地细化沟通边界,并使模糊或隐含的任何沟通方面变得清晰。
图 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_number
和npi_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";
我们已经成功地用原始字符串覆盖了patient
的Address
类型。我们如何设置自己,以便模型类上的值对象使用正确的数据类型来执行?我们可以为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
属性所做的那样);然而,雄辩实际上是通过那个方法检索对象,其结果是原始类型,比如在调用函数时指定的int
或string
。这将有助于确保模型的内部状态保持完整和一致。
第一件事是为那个特定的属性创建一个新的访问器方法,它将把属性的原始值(由于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 中的接口实现为DoctrineCartRepository
、InMemoryCartRepository
、ElasticCartRepository
等。如果不是这种情况(在本例中不是),为了避免创建另一个接口,您可以直接在 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
实体位于身份上下文中,而组位于组上下文中。
图 12-1
身份语境和群体语境的互动
在组上下文中有一些值对象,它们标识了我们的应用支持的角色,因为它们与整个组上下文相关。我们可以在一个User
和Group
之间建立一个关系,这将是一个多对多类型的关系,关系的拥有方在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
然后是定义我们希望系统知道的三个不同角色的问题,它们被创建为值对象:Member
、Admin,
和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 时节省时间和代码,例如自动类型检查、嵌套数组到对象的转换以及用于实例化它们的自包含工厂。建议您在自己的项目中尝试一下这个包。
总的来说,实体是您的领域模型中最重要的方面之一,因为它们以文字的方式表示底层领域的实际元素。因此,在创建实体并明确定义它们与同一系统中其他实体的关系时,应该小心谨慎。*