具有Entity Framework Core的DDD样式的实体

本文介绍如何将域驱动设计(DDD)原理应用于Entity Framework Core(EF Core)映射到数据库的类,以及为什么这样做可能有用。

TLDR


DDD方法有很多优点,但主要的是DDD在实体类内部传输创建/修改操作的代码。 这大大减少了开发人员误解/解释用于创建,初始化和使用类实例的规则的机会。

  1. 埃里克·埃文斯(Eric Evans)的书和他的演讲没有太多有关此主题的信息:
  2. 为客户提供用于获取持久性对象(类)和管理其生命周期的简单模型。
  3. 您的实体类应明确声明它们是否可以更改,如何更改以及按照什么规则进行更改。
  4. 在DDD中,存在聚合的概念。 聚合是相关实体的树。 根据DDD规则,使用聚合的工作应通过“聚合根”(树的根本质)进行。

埃里克(Eric)在演讲中提到了知识库。 我不建议使用EF Core来实现存储库,因为EF本身已经实现了存储库和工作单元模式。 我将在另一篇文章中详细介绍“ 使用带有EF Core的存储库值得吗?”

DDD样式的实体


首先,我将以DDD样式显示实体代码,然后将其与通常如何创建带有EF Core的实体进行比较(译者注。作者称“通常”为贫血模型)。在此示例中,我将使用Internet数据库书店(亚马逊的简化版本。”数据库结构如下图所示。

图片

前四个表代表有关书籍的所有内容:书籍本身,作者,评论。 下面的两个表在业务逻辑代码中使用。 在另一篇文章中详细描述了该主题。
本文的所有代码均已上传到GitHub上GenericBizRunner存储库。 除了GenericBizRunner库代码外,还有一个使用GenericBizRunner来处理业务逻辑的ASP.NET Core应用程序示例。 有关更多信息,请参见文章“ 用于处理业务逻辑和Entity Framework Core的库”
这是对应于数据库结构的实体代码。

public class Book { public const int PromotionalTextLength = 200; public int BookId { get; private set; } //… all other properties have a private set //These are the DDD aggregate propties: Reviews and AuthorLinks public IEnumerable<Review> Reviews => _reviews?.ToList(); public IEnumerable<BookAuthor> AuthorsLink => _authorsLink?.ToList(); //private, parameterless constructor used by EF Core private Book() { } //public constructor available to developer to create a new book public Book(string title, string description, DateTime publishedOn, string publisher, decimal price, string imageUrl, ICollection<Author> authors) { //code left out } //now the methods to update the book's properties public void UpdatePublishedOn(DateTime newDate)… public IGenericErrorHandler AddPromotion(decimal newPrice, string promotionalText)… public void RemovePromotion()… //now the methods to update the book's aggregates public void AddReview(int numStars, string comment, string voterName, DbContext context)… public void RemoveReview(Review review)… } 

寻找什么:

  1. 第5行:设置对所有声明为私有的实体属性的访问权限。 这意味着可以使用构造函数或本文后面介绍的公共方法来修改数据。
  2. 第9和10行。相关集合(来自DDD的相同集合)提供对IEnumerable <T>而不是ICollection <T>的公共访问。 这意味着您不能直接在集合中添加或删除项目。 您将必须使用Book类中的专用方法。
  3. 第13行。EF Core需要无参数的构造函数,但它可以具有私有访问权限。 这意味着其他应用程序代码将无法使用非参数构造函数(转换器的注释。除非您仅使用反射来创建实体),否则就无法绕过初始化并创建类的实例。
  4. 第16-20行:创建Book类实例的唯一方法是使用公共构造函数。 该构造函数包含初始化对象的所有必要信息。 因此,保证了对象处于有效状态。
  5. 第23至25行:这些行包含更改书本状态的方法。
  6. 第28-29行:通过这些方法,您可以更改相关实体(聚合)

在第23-39行的方法中,我将继续调用“提供访问权限的方法”。 这些方法是更改​​实体内的属性和关系的唯一方法。 最重要的是Book类是“封闭的”。 它是通过特殊的构造函数创建的,只能通过具有适当名称的特殊方法进行部分修改。 这种方法与EF Core中创建/修改实体的标准方法形成了鲜明的对比,在标准方法中,所有实体都包含一个空的默认构造函数,并且所有属性都声明为public。 下一个问题是,为什么第一种方法更好?

实体创建比较


让我们比较一下从json接收多本书籍中的数据并基于它们创建Book类实例的代码。

一个 标准方法


 var price = (decimal) (bookInfoJson.saleInfoListPriceAmount ?? DefaultBookPrice) var book = new Book { Title = bookInfoJson.title, Description = bookInfoJson.description, PublishedOn = DecodePubishDate(bookInfoJson.publishedDate), Publisher = bookInfoJson.publisher, OrgPrice = price, ActualPrice = price, ImageUrl = bookInfoJson.imageLinksThumbnail }; byte i = 0; book.AuthorsLink = new List<BookAuthor>(); foreach (var author in bookInfoJson.authors) { book.AuthorsLink.Add(new BookAuthor { Book = book, Author = authorDict[author], Order = i++ }); } 

b。 DDD风格


 var authors = bookInfoJson.authors.Select(x => authorDict[x]).ToList(); var book = new Book(bookInfoJson.title, bookInfoJson.description, DecodePubishDate(bookInfoJson.publishedDate), bookInfoJson.publisher, ((decimal?)bookInfoJson.saleInfoListPriceAmount) ?? DefaultBookPrice, bookInfoJson.imageLinksThumbnail, authors); 

书类构造函数代码

 public Book(string title, string description, DateTime publishedOn, string publisher, decimal price, string imageUrl, ICollection<Author> authors) { if (string.IsNullOrWhiteSpace(title)) throw new ArgumentNullException(nameof(title)); Title = title; Description = description; PublishedOn = publishedOn; Publisher = publisher; ActualPrice = price; OrgPrice = price; ImageUrl = imageUrl; _reviews = new HashSet<Review>(); if (authors == null || !authors.Any()) throw new ArgumentException( "You must have at least one Author for a book", nameof(authors)); byte order = 0; _authorsLink = new HashSet<BookAuthor>( authors.Select(a => new BookAuthor(this, a, order++))); } 

寻找什么:

  1. 1-2行:构造函数强迫您传递正确初始化所需的所有数据。
  2. 第5、6和17-9行:该代码包含对业务规则的多次检查。 在这种特殊情况下,违反规则被视为代码中的错误,因此,在违反情况下,将引发异常。 如果用户可以解决这些错误,也许我会使用返回Status <T>的静态工厂(注释转换器。我会使用Option <T>或Result <T>作为更通用的名称)。 状态是一种返回错误列表的类型。
  3. 第21-23行:在构造函数中创建BookAuthor绑定。 可以使用内部访问级别声明BookAuthor构造函数。 这样,我们可以防止在DAL之外创建关系。

您可能已经注意到,在两种情况下,用于创建实体的代码量大致相同。 那么为什么DDD样式更好呢? DDD样式在以下方面更好:

  1. 控制访问。 意外的财产变更不包括在内。 任何更改都可以通过具有相应名称的构造函数或公共方法进行。 显然发生了什么事。
  2. 对应于DRY(请勿重复)。 您可能需要在几个地方创建Book实例。 赋值代码在构造函数中,您不必在多个地方重复它。
  3. 隐藏复杂性。 Book类具有两个属性:ActualPrice和OrgPrice。 创建新书时,这两个值应相等。 在标准方法中,每个开发人员都应该意识到这一点。 在DDD方法中,Book类开发人员足以了解它。 其余人员将了解此规则,因为它是在构造函数中明确编写的。
  4. 隐藏聚合创建。 在标准方法中,开发人员必须手动创建BookAuthor的实例。 在DDD样式中,此复杂性封装为调用代码。
  5. 允许属性具有私有写访问权限
  6. 使用DDD的原因之一是锁定实体,即 不要提供直接更改属性的功能。 让我们比较一下使用DDD和不使用DDD的更改操作。

物业变更比较


EDD Evans将DDD样式的实体的主要优点之一称为:“它们传达有关对象访问的设计决策”。
注意事项 翻译者。 原始短语很难翻译成俄语。 在这种情况下,设计决策是关于软件应如何工作的决策。 这意味着讨论和确认了决定。 使用带有正确初始化实体和方法的构造函数的代码,这些实体和方法使用正确的名称来反映操作的含义,从而明确地告诉开发人员,某些值的分配是有意进行的,并非错误地进行,也不是另一个开发人员或实现细节的想法。
我理解此短语如下。

  1. 显而易见如何修改实体中的数据以及应一起更改哪些数据。
  2. 当您不应该修改实体中的某些数据时,请使其明显。
让我们比较两种方法。 第一个例子很简单,第二个例子更复杂。

1.更改出版日期


假设我们要先处理一本书的草稿,然后再出版。 在草稿撰写时,将设置一个估计的发布日期,该日期很可能会在编辑过程中更改。 为了存储发布日期,我们将使用PublishedOn属性。

一个 具有公共属性的实体


 var book = context.Find<Book>(dto.BookId); book.PublishedOn = dto.PublishedOn; context.SaveChanges(); 

b。 DDD样式实体


在DDD样式中,属性的设置器被声明为私有,因此我们将使用专门的访问方法。

 var book = context.Find<Book>(dto.BookId); book.UpdatePublishedOn( dto.PublishedOn); context.SaveChanges(); 

这两种情况几乎相同。 DDD版本甚至更长一些。 但是仍然存在差异。 在DDD样式中,您肯定知道可以更改发布日期,因为存在一种带有明显名称的方法。 您还知道您不能更改发布者,因为Publisher属性没有适当的更改方法。 该信息对于从事书籍课的任何程序员都是有用的。

2.管理书籍的折扣


另一个要求是我们必须能够管理折扣。 折扣包括新价格和注释,例如“本周结束前50%!”

此规则的实现很简单,但不太明显。

  1. OrgPrice属性是没有折扣的价格。
  2. ActualPrice-图书的当前价格。 如果折扣有效,则当前价格与OrgPrice的区别在于折扣的大小。 如果不是,则属性的值将相等。
  3. 如果应用了折扣,则PromotionText属性必须包含折扣文本;如果当前未应用折扣,则必须为null。

这些规则对于实施它们的人来说是显而易见的。 但是,对于另一个开发人员,例如,开发UI来增加折扣。 将AddPromotion和RemovePromotion方法添加到实体类将隐藏实现细节。 现在,另一个开发人员拥有带有相应名称的公共方法。 使用方法的语义很明显。

看一下AddPromotion和RemovePromotion方法的实现。

 public IGenericErrorHandler AddPromotion(decimal newPrice, string promotionalText) { var status = new GenericErrorHandler(); if (string.IsNullOrWhiteSpace(promotionalText)) { status.AddError( "You must provide some text to go with the promotion.", nameof(PromotionalText)); return status; } ActualPrice = newPrice; PromotionalText = promotionalText; return status; } 

寻找什么:

  1. 第4-10行:需要添加PromotionalText注释。 该方法检查文本是否为空。 因为 用户可以纠正此错误,该方法将返回错误列表以进行纠正。
  2. 第12、13行:该方法根据开发人员选择的实现设置属性值。 AddPromotion方法的用户不必知道它们。 要添加折扣,只需输入:

 var book = context.Find<Book>(dto.BookId); var status = book.AddPromotion(newPrice, promotionText); if (!status.HasErrors) context.SaveChanges(); return status; 

RemovePromotion方法要简单得多:它不涉及错误处理。 因此,返回值就是无效的。

 public void RemovePromotion() { ActualPrice = OrgPrice; PromotionalText = null; } 

这两个示例彼此非常不同。 在第一个示例中,更改PublishOn属性非常简单,因此标准实现就可以了。 在第二个示例中,对于没有使用Book类的人来说,实现细节并不明显。 在第二种情况下,具有专用访问方法的DDD样式隐藏了实现细节,并使其他开发人员的工作更加轻松。 同样,在第二个示例中,代码包含业务逻辑。 尽管逻辑量很小,但是我们可以将其直接存储在访问方法中,如果方法使用不正确,则可以返回错误列表。

3.使用汇总-财产收集评论


DDD仅允许通过根与该单元一起使用。 在我们的例子中,Reviews属性会产生问题。 即使将setter声明为私有,开发人员仍然可以使用add和remove方法添加或删除对象,甚至可以调用clear方法来清除整个集合。 在这里,新的EF Core功能( 支持字段)将为我们提供帮助。

支持字段使开发人员可以封装实际集合,并提供对IEnumerable <T>接口链接的公共访问。 IEnumerable <T>接口不提供添加,删除或清除方法。 在下面的代码中是一个使用后备字段的示例。

 public class Book { private HashSet<Review> _reviews; public IEnumerable<Review> Reviews => _reviews?.ToList(); //… rest of code not shown } 

为此,您需要告诉EF Core,从数据库读取数据时,需要写入一个私有字段,而不是一个公共属性。 配置代码如下所示。

 protected override void OnModelCreating (ModelBuilder modelBuilder) { modelBuilder.Entity<Book>() .FindNavigation(nameof(Book.Reviews)) .SetPropertyAccessMode(PropertyAccessMode.Field); //… other non-review configurations left out } 

为了处理评论,我在书本类中添加了两种方法:AddReview和RemoveReview。 AddReview方法更有趣。 这是他的代码:

 public void AddReview(int numStars, string comment, string voterName, DbContext context = null) { if (_reviews != null) { _reviews.Add(new Review(numStars, comment, voterName)); } else if (context == null) { throw new ArgumentNullException(nameof(context), "You must provide a context if the Reviews collection isn't valid."); } else if (context.Entry(this).IsKeySet) { context.Add(new Review(numStars, comment, voterName, BookId)); } else { throw new InvalidOperationException("Could not add a new review."); } } 

寻找什么:

  1. 第4-7行:我故意不使用EF Core从数据库加载实体时使用的私有无参数构造函数中的_reviews字段进行初始化。 这使我的代码可以确定是否使用.Include方法(p => p.Reviews)加载了集合。 在公共构造函数中,我初始化了该字段,因此在使用创建的实体时不会发生NRE。
  2. 第8-12行:如果未加载Reviews集合,则代码应使用DbContext进行初始化。
  3. 第13-16行:如果该书已成功创建并且包含ID,则我将使用另一种技术来添加评论:我只需在Review类的实例中安装外键并将其写入数据库。 我的书的第3.4.5节对此进行了详细说明。
  4. 第19行:如果我们在这里,则代码逻辑存在某种问题。 所以我抛出一个异常。

我为所有仅加载根实体的情况反向设计了所有访问方法。 如何更新单位取决于方法。 您可能需要加载其他实体。

结论


要使用EF Core创建DDD样式的实体,您必须遵守以下规则:

  1. 创建公共构造函数以创建正确初始化的类实例。 如果在创建过程中用户可以纠正错误,请不要使用公共构造函数,而是使用返回Status <T>的工厂方法来创建对象,其中T是要创建的实体的类型
  2. 所有属性都是属性设置器。 即 所有属性在类外部都是只读的。
  3. 对于集合导航属性,声明后备字段,并且公共属性类型声明IEnumerable <T>。 这将防止其他开发人员无法控制地更改集合。
  4. 代替公共设置器,为所有允许的对象更改操作创建公共方法。 如果操作因用户可以修复的错误而失败,则这些方法应返回void;如果可以,则返回Status <T>。
  5. 实体责任范围很重要。 我认为最好将实体限制为更改类本身以及聚合中的其他类,而不是外部。 验证规则应限于检查实体创建和状态更改的正确性。 即 我不检查诸如股票余额之类的业务规则。 为此有一个特殊的业务逻辑代码。
  6. 状态更改方法应假定仅加载了聚合根。 如果某个方法需要加载其他数据,则必须自行处理。
  7. 状态更改方法应假定仅加载了聚合根。 如果某个方法需要加载其他数据,则必须自行处理。 这种方法简化了其他开发人员对实体的使用。

使用EF Core时DDD实体的优缺点


我喜欢任何模式或体系结构的关键方法。 这就是我对使用DDD实体的看法。

优点


  1. 使用专门的方法更改状态是一种更清洁的方法。 这绝对是一个很好的解决方案,因为正确命名的方法可以更好地揭示代码意图,并使显而易见的内容可以更改和无法更改。 此外,如果用户可以修复方法,则方法可以返回错误列表。
  2. 仅通过根来更改聚合也很有效
  3. Book和Review类之间的一对多关系的详细信息现在对用户隐藏了。 封装是OOP的基本原理。
  4. 使用专门的构造函数,可以确保创建实体并保证正确初始化实体。
  5. 将初始化代码移至构造函数会大大降低开发人员无法正确解释应如何初始化类的可能性。

缺点


  1. 我的方法包含对EF Core实施的依赖。
  2. 甚至有人称其为反模式。 问题在于,现在主题模型的实体依赖于数据库访问代码。 就DDD而言,这是不好的。 我意识到,如果没有这样做,我将不得不依靠调用者来知道应该加载什么。 这种方法打破了关注点分离的原则。
  3. DDD迫使您编写更多代码。

在简单的情况下,例如更新书籍的出版日期,真的值得吗?
如您所见,我喜欢DDD方法。 但是,我花了一些时间来正确地构建它,但是目前该方法已经确定,并且正在将其应用到我正在从事的项目中。 我已经设法在小型项目中尝试这种风格,并且对此感到满意,但是在大型项目中使用它时,还没有发现所有优点和缺点。

我决定允许在实体模型实体方法的参数中使用特定于EFCore的代码的决定并不简单。 我试图防止这种情况,但是最后我得出的结论是,调用代码必须加载很多导航属性。 如果不这样做,那么更改将不会应用而不会出现任何错误(尤其是一对一关系)。 这对我来说是不可接受的,因此我允许在某些方法(但不能使用构造函数)中使用EF Core。

另一个不利方面是DDD迫使您为CRUD操作编写更多的代码。 我仍然不确定是否继续食用仙人掌并针对所有特性编写单独的方法,或者在某些情况下,值得摆脱激进的清教徒主义。 我知道只有一个马车和一辆装满CRUD的小卡车,很容易直接写出来。 只有在实际项目上的工作才会显示哪个更好。

本文未涵盖的其他DDD方面


这篇文章太长了,所以我在这里结束。 但是,这意味着仍然有很多未公开的材料。 我已经写过一些东西,关于我将来会写的东西。 这是剩下的东西:

  1. 业务逻辑和DDD。 多年来,我一直在业务逻辑代码中使用DDD概念,并且使用EF Core的新功能,我希望可以将某些逻辑转移到实体代码中。 阅读文章“再次使用实体框架(Core和v6)进行业务逻辑层的体系结构”
  2. DDD和存储库模式。 Eric Evans建议使用存储库以抽象化数据访问。 , «» EF Core – . 怎么了 - .
  3. DBContext' / (bounded contexts). DbContext'. , BookContext Book OrderContext, . , « » , . , .

GitHub上的GenericBizRunner存储库中提供了本文的所有代码该存储库包含一个示例ASP.NET Core应用程序,该应用程序具有用于修改Book类的专用访问方法。您可以克隆存储库并在本地运行应用程序。它使用内存中的Sqlite作为数据库,因此它应在任何基础结构上运行。

发展愉快!

Source: https://habr.com/ru/post/zh-CN432410/


All Articles