Laravel 领域驱动教程(四)
十三、领域事件
应用中会发生很多事情。有些比其他的更有趣。 Events 捕获这些信息,将其打包成清晰的格式/结构,并依靠广播机制或调度程序将事件发送给应用的其余部分。我们已经对第五章中的事件有所了解。本章将在这些信息的基础上,为您提供额外的上下文和专门针对领域事件的讨论。
哪些组件实际上收到关于特定事件的通知是基于订阅者或观察者或 PubSub 模式(它们的意思大致相同)。在应用的上下文中,使应用作为一个整体运行的所有活动部分和片段之间的通信在现代开发中是至关重要的。例如,如果一个新用户在系统中注册,我们可能会调度一个UserHasRegistered事件,其中包含一些重要的数据来描述它以及被调度的事件所涉及的其他内容。
在这一章中,我们将讨论三种类型的事件:应用事件、领域事件和基础设施事件。我们将涉及应用和基础设施事件,但本章的大部分内容将集中在领域事件上。我将解释领域事件对拉韦尔和 DDD 意味着什么,并解释我们如何以一种简单明了的方式实现领域事件,这种方式遵循 DDD 的一般经验并使用拉韦尔。Laravel 内置了许多支持创建和调度事件的组件,事实证明这些组件对领域驱动的设计非常有用。
最后,我们将继续我们的索赔应用的设计,添加领域事件的概念,并看看它们在索赔提交的上下文中哪里是有用的。我将向您展示如何以及何时利用领域事件,以及如何使用它们来集成有界的上下文。
本章第一部分中使用的例子是基于一个更加标准化和正式的 DDD。这样您就可以对事件的结构和侦听器的使用有一个大致的了解。在本章的后面,我们将通过一些可能的方法来实现领域事件,使用 Laravel 的组件以及一个第三方包来创建和处理领域事件,这些包可以插入到我们的雄辩模型中,允许您直接处理模型(以及数据库)。此外,我们将回顾事件源,如何以及为什么使用它,以及事件源架构在 Laravel 中的可能实现。
事件的价值
不同类型的事件基于它们执行的层。应用事件由 Laravel 的事件调度程序处理,并在整个框架中使用,以便于将信息传播到组成该应用的不同组件,这些组件位于不同的有界上下文中。这些包括特定于框架的事件和侦听器。最后一类事件是本章的重点;领域事件是您所在的域的自定义事件,需要设置自定义事件和侦听器。它们传递关于各种订阅组件的信息,以便这些组件能够以(或多或少)自动化的方式对事件做出反应。领域事件是领域模型实现的核心,因为该模型促进并传播了特定的知识,即在应用的其他地方已经发生了一些有趣的事情。
示例:会计软件
例如,以一个基于网络的会计系统为例。该系统的一个特点是,它能实时自动核对包含所有交易的中央分类账。每当一笔交易被记录在一个以账户为基础的分类账中时,一笔匹配的交易必须被记录在中央分类账中。这有利于银行家查看银行的当前资产,以确定有多少可用于投资,而不是等待 24 小时,以便在账户更新之前在适当的分类账中更新余额和交易。假设有两个有界的上下文处理这个特性。
-
账户分类账核算上下文:处理实时更新基于账户的分类账
-
中央分类账会计环境:处理匹配交易从任何基于账户的分类账到中央分类账的实时过账,并在每次交易过账后核对分类账
这两个上下文显然需要相互通信,以便匹配的交易可以实时地发布到中央分类帐。管理通信的一种方法是使用领域事件来封装每个事务的所有事务数据,同时仍然考虑每个上下文的边界。然后,该事件将被分发给对侦听该事件感兴趣的订阅组件(侦听器也称为事件处理程序)。该事件应该与发送或接收它的任何特定上下文分离;这类似于分布式架构的工作方式。
一种方法是依赖事件调度程序,它简单地接收一个事件,然后将它调度给订阅的侦听器。在前面的场景中,account ledger 上下文将通过事件调度程序调度一个事件,然后事件调度程序将接受该事件并将其传播到系统的其余部分,这将包括已经向事件调度程序注册以侦听该事件的任何组件。事件调度程序充当分布式中枢,以分离和分布式的方式将应用的不同组件连接在一起。这允许您在组件之间进行编程式交互,同时在应用或网络级别保持它们的逻辑和上下文分离。事件也是微服务架构中的一个关键组件,是或多或少将各个部分粘合在一起的东西。
回到我们的示例会计软件场景,基于帐户的上下文会向事件调度程序发送类似于NewTransactionWasRecorded的事件。另一个上下文(central ledger 上下文)必须已经向事件调度程序注册,才能侦听该特定事件。因此,一旦从第一个上下文发送了事件,事件调度程序就会获取它并将其分发给订阅组件,其中包括中央帐户上下文。中央帐户上下文从事件调度程序接收传播的事件,然后相应地采取行动。将事件发送给调度程序的上下文不知道也不关心其他哪些上下文正在监听它。已经完全脱钩了。此外,接收上下文不一定知道事件实际上来自哪里,只知道它已经发生了。
事件中封装了诸如受影响的帐户之类的东西,以及任何其他与交易相关的数据,因此任何侦听组件都很清楚事件中涉及了什么。然后,中央会计环境将在其自己的分类帐上创建匹配的交易,从而使分类帐随着每一笔交易实时更新。在现实世界中,你可能会有一堆不同的账户,银行每天最有可能有几千或几万笔交易入账。由于可能有多个基于帐户的分类帐将事件发送到中央分类帐,因此它们都可以遵循相同的流程,只需将相同的事件分派给事件分派器,事件分派器将处理该事件,并将其(以及附加的事件数据)传递给订阅通过事件分派器接收它们的任何上下文。这是一个很好的例子,说明了领域事件如何将本地有界上下文与应用/服务/网络外部的上下文联系起来。事件也是六边形结构内部运作的关键。
消息队列
事件总线或消息队列是一种合并位于不同网络上的有界上下文的方式,比如 RabbitMQ 或亚马逊 SQS 之类的异步消息队列。其工作方式是,您只需设置应用在队列中触发事件,而不是事件调度程序,因为实际上,从高层次的角度来看,队列只是云中的一个高端事件调度程序,附带了一系列附加功能。但是,即使您是通过队列发送事件而不是在本地处理它,您可能仍然希望在系统中保留一个记录,以便进行分析和历史记录。这可以通过所谓的投影来完成,其中某个事件在最初被触发后,将匹配的投影事件发送到系统中的不同接收器,该接收器通过事件监听器处理逻辑,这将包括 MySQL 数据库上的写投影,以记录事件发生。因为这两个事件按顺序一个接一个地发生(即同步),这被称为最终一致性,正如您可能已经猜到的,它在操作的两端都起作用(很可能通过持久性机制、排队系统或缓存服务器——甚至可能是 Elasticsearch)。
命名事件
你猜对了,事件应该总是按照领域中无处不在的语言来命名。因为事件基本上是对过去发生的事情的记录,所以您应该努力使所有事件都以过去时态命名,例如:
-
UserHasRegistered或UserRegistered -
BlogPostWasPublished或BlogPostPublished -
PatientHasScheduledAppointment或PatientScheduledAppointment -
SomeProcessHasStarted或SomeProcessStarted -
AnotherProcessHasStopped或AnotherProcessStopped
一些开发人员更喜欢较短的语法,这种语法更快更漂亮,并且仍然传达了一个事实,即无论它是什么都已经发生了。使用哪一种完全取决于你的个人偏好,只要你坚持使用来自普遍语言的命名约定,并且你给事物命名就好像它们已经发生了一样(事实就是这样)。就我个人而言,我更喜欢用更长的方式来命名它们,因为它比另一种方式更清晰、更明确。(例如,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 中,我选择了基于受被触发事件影响的实体的事件名称以及关于该事件的描述。有些事件的名称中有一个Was或Has,有些没有,但是所有的事件都描述了过去发生的事情,并且可以直接与应用的某个特定部分相关联。这些实体都是基于无处不在的语言中的术语,一般来说,这是一个很好的迹象,表明我们在保持代码中的实现接近领域本身方面是正确的,并且我们正在对它进行充分的建模。相反的情况也是如此:含义不清楚或者不代表在通用语言中发现的项目的事件名称可能是事件不是正确地从领域中派生出来的或者它们对领域建模错误的标志。
服务和事件
应用服务是外部和领域逻辑之间的中间人。它们通常接受某种形式的请求,并将该请求转换成域层可以理解和运行的命令。通常,它们会对标量值进行操作,并将它们转换成业务对象,以便应用可以处理它们或在域层中进一步利用它们。使用像 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),该响应实例可以由内置的响应机制优雅地返回到前端。
虽然这听起来像是控制器在做大量的工作,但它实际做的工作很少。它的工作是完成以下任务:
-
接受请求(即与客户握手)
-
将完成请求所需的实际工作委派给服务或作业
-
返回响应(即确认成功[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。这将为我们定义的事件及其相应的侦听器创建基本的类结构,我们已经将它们包含在数组中。更好的是,我们可以通过设置应用的EventServiceProvider的shouldDiscoverEvents方法,让所有的事件自动被发现。参见第五章了解如何操作的详细信息。
有一种更好的方法可以做到这一点,但就对象访问而言,这种方法的安全性稍差一些,因为它涉及到让事件的属性可以公开访问。这样,处理事件持久性的机制可以在对象上运行简单的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) |
| 保存实体或模型的单个实例(UPDATE和CREATE)。 | $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 所示。
图 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 显示了一种可能的解决方案。
图 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()添加到查询构建器方法或Userfacade。如果我们需要检查用户是否处于活动状态(这种情况可能一直会发生),我们可以将isActive()方法改为全局作用域,并一直应用它,除非我们使用对withoutGlobalScopes()的调用来指定其他方式。 -
我们的新实现更容易进行单元测试,因为我们可以简单地用一组已知的、预先确定的数据调用我们添加到
Claim模型中的每个新的作用域方法,并验证结果,而不是试图测试清单 14-10 中的一行超级查询。 -
最后但并非最不重要的一点,因为 concertive 中每个定义的作用域都返回一个新的
QueryBuilder对象,并且我们指定对getPublishedMales()方法的调用结果将是一个QueryBuilder对象,所以我们仍然没有真正运行我们的查询,这允许我们在执行查询之前添加额外的约束。另一种常见的方法是让存储库查询为您调用get(),并返回一个实际的数据集合,这也很好(我们只需要将返回类型提示修改为Collection类型)。
简单地说,我想说明的要点是,从数据库中收集和处理结果的最好、最有效的方法是尽可能将所有内容包含在查询本身的上下文中(也就是调用get()之前的代码)。如果构造得当,SQL 运行起来比 PHP 中遍历集合内的模型对象要快得多——事实上,比光速还要快。
一个好的方法是使用模型的外观从一行直接查询开始(如果可能的话),就像清单 14-10 中展示的那样。一旦你有了它的功能,分解任何可能在应用的其他地方再次使用的代码部分,并把它们放入一个作用域、全局作用域或相应模型的其他类似方法中,这样它们可以简单地附加到QueryBuilder对象上,在执行前添加约束。一定要用通用语言中合适的术语来命名应用中的所有类、方法和其他任何东西。对于需要跨各种服务、协议或类执行的更复杂的查询,请尝试将功能分成更小的部分,特别注意以明确和直接的方式实现领域本身的业务规则和概念。如果某个特定的功能块感觉不太对劲,看看你把它们分开的方法,确保你没有误判领域上下文的边界,或者把它(它们)做得太细或者不够细,以便以后可以重用和扩展。
在为查询本身建立了约束和细节并将逻辑放入可以重用的合适组件或方法之后,下一步是关注返回数据集的实际过滤,这不需要数据库,而是通常涉及迭代通过一个Collection对象来创建一个新的集合对象或增加集合中的数据,然后可能将其转换为数组或 JSON。
正如我前面说过的,您越能利用查询(通过查询构建器或口才),您的性能就越好。规范提供了另一种“选择”或“过滤”数据的方法,并且在查询运行之后(调用get()之后)使用最有好处,从而不会影响性能。图 14-3 显示了这种方法中所涉及的参与者的图表。
图 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模型)。可能有几个存储库,例如SqlClaimsRepository、RedisClaimsRepository、ElasticClaimsRepository和/或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 |
然后,假设我们想将 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 提供了一个工厂的视觉效果。
图 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。我们有billingId和shippingId的默认值,因为直到结账的最后一部分,当用户将它们输入 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 方法,正式将记录写入orders和order_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 的上下文中。这意味着我们将利用一份简单的工作来做我们的脏活。请记住,一项工作可以作为一项服务来使用,主要是因为它所使用的特征。那些特征是Dispatchable、InteractsWithQueue、Queueable和SerializesModels。它们允许 Laravel 中的作业由队列工作器进行排队。Dispatchable特性允许通过助手函数dispatch()分派任务。我们可以从应用的任何地方调用这个助手。特性InteractsWithQueue和Queueable允许将作业推入队列,并提供检查作业状态的方法。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服务)或者一个单元测试,它可以像为每个项目方法编写一个测试方法一样细粒度,但是这对于测试来说是多余的。事实上,编写测试(以及它们的复杂性)会让你陷入困境,以至于你很难从中受益。相反,要专注于编写充分覆盖应用中主要关注点的测试。集成测试非常适合这个目的,因为它们旨在测试各种领域相关代码的不同结果的组合结果——这些部分对应用的健康至关重要,对核心领域的整体功能也很重要。
结论
不管有没有框架,服务在开发应用时都有一席之地。最好用它们来代替那些不被认为是或不能被认为是实体或值对象的东西。在应用中为这些项目创建一个瘦服务层,这些项目根据各自所属的层进行分组。但是请记住,您在服务中包含的实际业务逻辑越多,就越容易转向一个缺乏活力的服务层,这是一件坏事。避免这种情况的最简单的方法是在实体和值对象中表达大多数与业务相关的功能。
有三种不同类型的服务,应用、域和基础设施,它们都有特定的用途。应用服务用于抽象交付机制,并负责将传入的请求转换为应用可以理解的内容。域服务直接处理域对象,并简化系统中不适合值对象和实体的过程。基础设施服务的任务是处理诸如日志记录、持久性甚至密码散列机制等问题。