领域驱动视频(五)

时间:2022-02-23 04:48:03

5.1 简介

在这个领域驱动的设计基础模块中,您将了解到Repositories(存储库),这是域驱动设计的另一个关键模式。

5.2 目标

我们将从定义什么是存储库开始,然后我们将提供一些与它们一起工作的技巧,以及讨论它们的一些好处。有不同的方法来定义存储库和围绕其使用的大量辩论。我们将讨论其中的一些要点。然后,我们将再次打开Visual Studio,并向您展示如何在兽医调度应用程序中实现存储库。

5.3 Repositories简介

我认为存储库模式是目前为止最流行的在领域驱动设计之外的DDD元素。在许多应用程序中,它们都是很有价值的,可以简化数据访问并强制分离关注点。当我开始学习存储库并在自己的软件设计中实现它们时,它对我的应用程序架构产生了巨大的影响。除了自动化测试实践之外,它还迫使我考虑将关注点与我在软件中添加的每个方法和行为分开。
这让我不禁停下来思考。好吧,这是真的。就我个人而言,我喜欢这种模式,我发现它让我更容易编写好的、可测试的代码。我们将讨论在DDD应用程序中使用存储库,但是如果您想了解更多关于模式本身的知识,您可以查看设计模式库。
任何系统都希望在系统重启之间,可以对系统状态都有某种持久化存储,如数据库。许多应用将大量的精力关注在查询、获取、和翻译数据对象到模型对象上的机制。。。。对数据源的特殊访问也促进了开发人员查询他们想要的任何一点的数据,任何时候都是这样,而不是考虑去使用聚合。这使得通过强制执行它们的不变量来管理聚合的一致性变得相当困难。在最好的情况下,执行模型完整性的逻辑会分散在许多查询中,而最坏的情况是,它根本就没有完成。应用模型的第一个设计和关注点分离意味着将持久性行为引入到它自己的抽象集合中,我们将其称为存储库。

领域驱动视频(五)

只有特定的对象,特别是聚合根,应该可以通过全局请求获得。存储库提供这种访问方式,并且不允许直接访问非聚合对象,除非通过它们的聚合根。这为您提供了约束数据过载的能力,因此可以在整个应用程序中避免大量的随机数据访问代码。

领域驱动视频(五)

当您考虑应用程序中的一个对象的生命周期时,应该考虑两个案例。在第一个例子中,您有一些不需要持久化的对象。这些对象被创建,执行一些工作,然后它们被销毁。在第二种情况下,您有一些需要持久化的对象。这些对象有一个稍微复杂一点的生命周期,因为在创建对象之后,它必须以它上次保存时的状态重新组合。然后,它可以执行应用程序需要它做的任何工作,在此之后,它可能需要将它的状态保存到一些持久性存储中,然后才最终被销毁。
您可以使用存储库来管理持久性对象的生命周期,而对象不需要知道它们的持久性。我们称这些对象为持久化无知(persistence ignorant)。在他的书《领域驱动设计》中,Eric Evans谈到了一些存储库。他们可以总结说,“存储库表示某种类型的所有对象作为概念上的集(原文是set)…具有更复杂的查询功能的集合(原文是collection)。”

5.4 存储库技巧、好处和指导

在设计存储库时,您应该记住以下一些基本的指导。

领域驱动视频(五)

首先,存储库应该有一个对特定类型对象的集合的错觉。您将会将对象添加到集合中,删除它们,并从集合中检索对象,但这是一个集合的错觉,这一点很重要,要记住。当您与存储库交互时,这些是您将调用的方法类型:添加、删除和检索。你的调用代码并不关心数据存储库如何执行这些操作,所以在存储库中你会有一个响应检索方法的代码,出去到一个数据库中并获取指定的数据,但它可以是获取内存中的已经存在的数据,或者它可能是从你的电脑里的一个文本文件中抓取数据。
存储库的另一个重要建议是通过一个众所周知的全局接口来设置对Repository的访问,这样开发人员可以以最熟悉的常见模式来与存储库交互。这是我们在解决方案中使用的一个简单的存储库接口。我们的存储库直接实现了它。根据您的软件的大小和复杂性,您可能有几层接口。举个例子,如果您预期有多个用于不同限界上下文的Schedule聚合的存储库,您可能需要一个ISchedule存储库接口,它不仅实现低级接口,而且还定义了一些其他的方法或属性,这些方法或属性是每个Scheduler存储库所必需的,而不必考虑它可能存在于的限界上下文。

领域驱动视频(五)

因为我们的存储库就像一个集合,您需要方法来添加和删除对象,以封装底层数据插入和删除操作。我们在IRepository中定义了这些定义。由每个具体的实现来定义添加和删除实际上是如何工作的。
虽然IRepository涵盖了所有存储库的最小公分母,但请不要犹豫在您的存储库类中添加指定条件的查询方法。这些应该返回符合指定标准的完全实例化的对象。例如,在我们的Schedule 存储库库中,我们知道我们会经常想返回一个包含指定日期的预约的Scheduler聚合对象,因此,为什么不直接在存储库中使用这种方法:即让使用存储库的开发人员更容易获得特定时间的Scheduler,而不是Appointment,所以这个GetScheduledAppointmentsForDate方法我们可以控制如何执行查询。在我们的样例中,我们使用Entity Framework来实现数据访问,所以我们将最好的Entity Framework专家放在这个任务上。当然我说的并不是我。现在,每个使用存储库的人都可以从这个人的专业知识中获益,我在这种情况下,他们不必找出编写逻辑的最佳方法。我们还避免在整个应用程序中使用多个、随机编写的方法来执行这个特定的查询。

领域驱动视频(五)

除了这些用于实现存储库的具体技巧之外,您还应该记住这些更重要的技巧。

领域驱动视频(五)

首先,确保为需要直接访问的聚合根提供存储库,然后让客户更加关注在模型上,同时将所有对象存储和过度关注委托给存储库。存储库可以为我们的应用程序添加一些好处。首先,它们为我们所有的持久化关注点提供了一个公共的抽象,这为客户提供了一种非常简单地获取模型对象和管理其生命周期的方法。它还也促进了关注点的分离。领域逻辑和用户界面都可以独立于应用程序使用的数据和后端数据源。存储库的公共接口非常清楚地传达了我们的设计决策。只有某些对象应该被直接访问,所以存储库提供并控制此访问。
另一个重要的好处是,存储库使我们更容易测试代码。他们减少对外部资源的紧密耦合,比如数据库,而这通常会使单元测试变得困难,但是将存储库与客户端以及领域逻辑代码分开,这意味着我们可以很容易地改进这个应用程序的数据访问,调整性能,添加缓存行为等等。当数据访问的代码都封装在一个或多个众所周知的类中,这会让我们的应用更加的简单和安全。我们将查询和一些其他的逻辑调优的时候,我们实际上已经经历了很多的重构。请记住,您的客户端代码可能不知道您的存储库的实现,但是开发人员不能。开发人员了解如何实现特定的存储库非常重要,否则它们会遇到许多不同的问题。我们这里谈论的不仅是正在实现存储库的开发人员,还包括了使用存储库的开发人员。
与存储库一起工作的一些常见的存储库问题可能是N + 1查询错误。

领域驱动视频(五)

在这里,为了显示数据库中的行列表,您将调用一个查询来获取列表,然后有一些查询与该列表的计数相同,以逐个获取每个项。另一个我经常看到的是当人们获取相关数据时。与实体框架他们使用立即加载或延迟加载,尤其是延迟加载,有很多开发人员不知道会发生什么,只是因为它很容易和他们使用它,然后就会遇到各种各样的问题。
这取决于您的数据是如何构建的,有时如果您试图获取一个或两个属性,这些属性在一个数据表中的特定列中表示,如果你把整个行拉回来,你可能会得到比所需要的数据量更多的数据,这可能包括几十个列和大量的实际数据,因此,这些都是需要了解底层数据是如何持久化的,以及如何实现存储库的实现,以及这些东西是如何工作的,这些都可以在应用程序中产生巨大的影响。

5.5 比较Repository和Factory

领域驱动视频(五)

需要考虑的一点是,乍一看,存储库和工厂似乎很相似,因为它们有相似的职责。在这两种情况下,我们都使用这些模式来获取我们想要处理的对象。然而,工厂只涉及创建新对象,而存储库则用于查找和更新现有对象。
在我们的应用程序中,它们可能还没有出现在内存中,但是它们存在于其他的持久化中,我们只是在重新构造它们。这是可能的,事实上,对于存储库来说,使用工厂作为创建对象的一部分并不少见。请记住,与持久化有关的是Repository。

5.6 用IRepository T还是不用IRepository T?

领域驱动视频(五)

对于简单的实体和在存储库上具有标准的CRUD操作集的聚合体来说,这是很有意义的。然而,对于不太标准的聚合,如我们的Scheduler,实现这些常见的操作可能是没有意义的。最初,当我们使用Appointment(预约)作为我们的聚合根时,它对Appointment Respository(预约的存储库)非常有意义,是可以实现IRepository接口,就像您在这里看到的那样。
一旦我们有了创建一个Scheduler对象来建模日程安排的顿悟,然后将它作为我们的聚合根来使用,我们就会重新考虑如何在应用程序中实现这个存储库。我们实际上有一些激烈的讨论怎么样使得Scheduler Respoitory具有意义,我们最终选择实现两个方法,一个用给定的诊所和日期来检索所有的Appointmenmt(预约),另一个是:更新对于Scheduler的改变,包括其包含的所有预约的更改,当然,随着应用程序的增长,我们期望添加额外的方法来获取预约。

5.7 DDD中的通用存储库

领域驱动视频(五)

如果您选择创建一个通用的存储库接口,这并不意味着您将全面实现它。您可能只选择为聚合根创建实现,这将符合DDD的建议。但是,你可以创建一个T类型的实现类,然后之后任何类型的实体来使用它。

领域驱动视频(五)

下面是具体的

领域驱动视频(五)

在我们的client-patient管理环境中,我们实际上有这种类型的通用存储库。回想一下,这个上下文非常简单,主要使用简单的CRUD操作,因此它没有以DDD建模的理由。在这种情况下,拥有一个可重用的通用存储库对我们的解决方案非常有效,因为我们不需要为每个实体创建一个单独的存储库,但这也意味着任何客户端代码都可以使用这个存储库来获取任何实体的数据。

领域驱动视频(五)

我们绝对不想在DDD实现中使用它,因为它意味着它可以绕过聚合根来与数据交互。如果您确实喜欢使用通用存储库实现的代码重用,那么可以使用一个标记接口来防止对您的聚集的内部访问过多,而这个标记接口这可能只是扩展了实体接口。然后,您可以更新您的通用存储库来需要这个接口,而不是与任何实体一起工作。此时,客户端代码将无法实例化非根实体的泛型存储库,因此我们可以使用我们的存储库来限制对来自模型客户端的非根实体的访问(我们看下面的样例,很清楚的看到,由于我们对于非聚合和聚合都做了父类的抽象,因此我们可以对泛型进行简单的限制,即可保证在聚合中不会去直接访问成员属性)。

领域驱动视频(五)

5.8 我们应用中的存储库

领域驱动视频(五)

在我们的兽医预约应用程序中,我们定义了我们刚才看到的ISchedule存储库,并将其存储在预约调度绑定上下文的核心内。接下来是ISchedule存储库的实现(下图),它位于限界上下文内的基础设施的部分中的一个叫做AppointmentScheduling.Data工程里。这个工程负责这个特定的有界上下文的所有数据访问。请记住,Steve和我已经对ISchedule存储库做出了明确的决定,只公开了一个更新的方法和一个用诊所和日期进行定制化查询的方法,因此,这是我们在这里仅有的两种方法。

领域驱动视频(五)

这是update方法,我们要在这个方法做持久化操作,所以这里有一些与我们所使用的对象关系映射器像关联的持久化代码---也就是说这些代码与我们的Entity Framework相关。我们在这里所做的是我们将存储在我们每个对象中的状态属性,我们在类中有一个自定义枚举称为TrackingState,所以在每个对象中,我们可以设置对象的状态,然后我们将信息转移到实体框架,让实体框架意识到对象状态目前是什么。

领域驱动视频(五)

领域驱动视频(五)

我还想指出,更新Scheduler聚合实际上只是意味着更新聚合中的预约。注意,我们没有对Scheduler的本身状态做任何事。这是因为我们不需要更新schedule和持久层。它的持久属性,即ScheduleId和ClinicId,不应该真正改变。
比方说如果我们正在创建一个新的诊所,因此,我们不会在这个Schedule聚合上下文和Schedule存储库中操作任何的Scheduler,风马牛不相及嘛!!但是在预约中我们并不确定它们的状态,因此,我们遍历各个预定,并以我们应用的TrackingState来作为参考,我们让Entity Framework知道这个特定的预约状态应该是什么,然后当我们调用SaveChanges时候,Entity Framework将为那些预定继续运行相应的插入、更新、删除语句。


另外的一个方法是GetScheduleForDate(下图). 该方法这里发生了很多事,其中的逻辑是----这可能看起来有点复杂,因为我们没有明确的导航属性,对于我们想要看到的关于我们用户界面的schedule的一些信息。

领域驱动视频(五)

在这里,您将看到存储库真正为团队所使用,因为我们以我们真正想要领域工作的方式设计的领域。我会带您快速串以便来知道这是怎么回事,因为这不是Entity Framework的课程,但重要的是,这表明我们按照我们想要的方式设计了这个领域,而不用担心我们如何去持久化它。

领域驱动视频(五)

当我们到达存储库时,我们发现,噢,是的,因为导航属性并没有在领域模型中引用,这虽然是一个明确的选择,但这意味着我们需要做一些额外的忍者工作和对象关系映射器。我们知道ClinicId,我们知道我们想要过滤的Scheduler的日期,所以我需要做的就是使用使用ClinicId去查询出ScheduleId,然后使用ScheduleId、ClinicId和日期创建一个新的Scheduler实例(上图的方法)。
一旦我们得到了它,然后我们可以去问实体框架,查询所有时间处于我们的查询条件之内的预约出来。但是我还是在一次又一次的做着不止一次的查询,导致这种问题的原因就是因为缺少导航属性。在用户界面上,我们希望能够显示client的名字(name)、patient(就是看病的动物)的name(名称)以及预约的类型,但是这些信息并不在预约中。这个预约中只有id,所以我实际上在运行另一个查询来拉回一个字符串列表,这些字符串会给我每一个预约的信息。而我们所属的这个查询操作是发生在GetAppointmentHighlights 方法里的。现在为了演示,我把T SQL嵌入在这里,这样你们就能看到我做了一些特殊的事情来获取这些信息,然后将它推回到我的主方法。当然我通常会把它放入存储过程中,但是一旦我得到数据返回,然后我们将这些数据合并到所有的预约中,然后我们可以将所有的预约信息全部加载到应用程序的任何部分中。

5.9 重构为了更好的分离

Steve,还记得上次我们看AddExistingAppointments 方法时,我们说过要重构它。我们想让它可以用于存储库,但是我们不想把它作为我们的Schedule类的公共接口的一部分,所以我们为什么不现在就开始干呢。当然,这不应该太难。

领域驱动视频(五)

在Schedule 中,注意我们没有办法从外部的方法中设置预约,因此,当我们从存储库构造这个时,我们需要一些方法来提供预约,因此,刚开始时候,我说我们创建了一个叫AddExistingAppointments 的方法,我们知道我们只需要从存储库调用它即可。相反,我们现在要做的是让增加预约变为创建一个Scheduler所必须做的事情,所以我们要把它添加到构造函数中。好的,然后一旦在构造函数中,我们就不需要再做了。完全正确(如下图,我们为本来没有appointments的构造器加入了一个新的入参)。

领域驱动视频(五)

我们将不得不对存储库逻辑的工作方式做一个小的修改,但它不会太糟糕,所以让我们来快速地处理这个问题(键入)。我们可以在构造函数中接受预约,因此在存储库中我们调用构造器的时候只需要把所需要的数据作为参数传递进去即可,既然我们要摆脱这个方法,我就会提前把它注释掉,所以我们不再有AddExistingAppointments 这个方法,这将会破坏我们的存储库,但是我们将能够快速修复它。

①修改GetAppointmentHighlights,我们先看之前的方法:

领域驱动视频(五)

修改后,方法名字变了,而且,返回值也只是Guid的SchedulerId。

领域驱动视频(五)

②修改主方法:

领域驱动视频(五)

首先是有以前的获取Scheduler对象变为了获取Scheduler的主键。
然后呢,由于我们已经改造了Scheduler的构造器,所以我们只需要将参数appointment传递进去即可,我们这一步是最后返回里做的。

好的,重构到此结束,下面解释一下DateRange这个值对象的createOneDayRange方法:

领域驱动视频(五)

首先它是一个工具方法,让我们创建一个跨越一天的时间间隔。
通过这种改造,我们不仅省了代码,而且意图也没有丢掉。反正就是很棒,懒得翻译了。
剩下的对话基本就是:重构的作用很大~~,更多的信息还是去看Evans的DDD的第九章吧。

5.10 词汇表

在领域驱动设计,记住这一点是非常重要的:一个存储库是专门设计用来持久化发生在聚合中的任何事,但是,对于DDD来说,重要的是,您不需要为聚合内的实体创建存储库,您只需要将重点放在聚合根上。
你刚才看到我在存储库中保存预约,那时因为Scheduler是聚合的根,所以这也是DDD让你感觉与以往不认同的地方。
当我们在处理事务的时候,需要遵循ACID的原则。如果您曾在过去的数据库中工作过,您应该熟悉,但是让我们快速地回顾一下它们。我们希望我们的事务与数据库是原子的,这意味着要么整个事务发生,要么没有,例如,有些成员属性已经被持久化,而另一些属性则没有。我们希望它应该是一致的,这意味着数据的约束被应用了,这就是我们刚刚讨论的在聚合根下应用不变量,以保证数据一致性。

5.11 资料

DDD IDDD