域驱动设计:实践中的价值对象和实体框架核心

在Habré上,不仅撰写了很多有关域驱动设计的文章,包括有关体系结构的一般性文章,以及有关.Net的示例。 但是与此同时,经常很少提及该体系结构中像Value Objects这样重要的部分。

在本文中,我将尝试发现使用实体框架核心在.Net Core中实现值对象的细微差别。

在猫下,有很多代码。

一点理论


域驱动设计的体系结构的核心是 -要开发的软件所应用的主题领域。 这是应用程序的整个业务逻辑,通常与各种数据交互。 数据可以有两种类型:

  • 实体对象
  • 值对象(以下称为VO)

实体对象在业务逻辑中定义一个实体,并且始终具有一个标识符,通过该标识符可以找到该实体或将其与另一个实体进行比较。 如果两个实体具有相同的标识符,则这是相同的实体。 几乎总是在变化。
值对象是一种不可变的类型,其值在创建过程中设置,并且在对象的整个生命周期中都不会改变。 它没有标识符。 如果两个VO在结构上相同,则它们是等效的。

实体可以包含其他实体和VO。 VO可以包括其他VO,但不包括实体。

因此,域逻辑应仅与Entity和VO配合使用-这保证了其一致性。 基本数据类型,例如字符串,整数等。 通常,它们不能充当VO,因为它们可以简单地违反域的状态-这在DDD框架中几乎是一场灾难。

一个例子。 在各种手册中,经常让每个人都讨厌的Person类如下所示:

public class Person { public int Id { get; set; } public string Name { get; set; } public int Age { get; set; } } 

简单明了-标识符,名称和年龄,您在哪里可以犯错误?

而且这里可能存在一些错误-例如,从业务逻辑的角度来看,名称是强制性的,名称不能为零长度或超过100个字符,并且不应包含特殊字符,标点符号等。 年龄不能小于10或大于120岁。

从编程语言的角度来看,5是一个完全正常的整数,类似地是一个空字符串。 但是域已经处于错误状态。

让我们继续练习


至此,我们知道VO必须是不可变的,并且包含对业务逻辑有效的值。

通过在创建对象时初始化readonly属性来实现免疫。
值的验证发生在构造函数(Guard子句)中。 希望使验证本身可以公开使用-以便其他层可以验证从客户端(同一浏览器)接收到的数据。

让我们为姓名和年龄创建一个VO。 此外,我们将任务复杂化了-添加一个将FirstName和LastName组合在一起的PersonalName,并将其应用于Person。

名称
 public class Name { private static readonly Regex ValidationRegex = new Regex( @"^[\p{L}\p{M}\p{N}]{1,100}\z", RegexOptions.Singleline | RegexOptions.Compiled); public Name(String value) { if (!IsValid(value)) { throw new ArgumentException("Name is not valid"); } Value = value; } public String Value { get; } public static Boolean IsValid(String value) { return !String.IsNullOrWhiteSpace(value) && ValidationRegex.IsMatch(value); } public override Boolean Equals(Object obj) { return obj is Name other && StringComparer.Ordinal.Equals(Value, other.Value); } public override Int32 GetHashCode() { return StringComparer.Ordinal.GetHashCode(Value); } } 


人名
 public class PersonalName { protected PersonalName() { } public PersonalName(Name firstName, Name lastName) { if (firstName == null) { throw new ArgumentNullException(nameof(firstName)); } if (lastName == null) { throw new ArgumentNullException(nameof(lastName)); } FirstName = firstName; LastName = lastName; } public Name FirstName { get; } public Name LastName { get; } public String FullName => $"{FirstName} {LastName}"; public override Boolean Equals(Object obj) { return obj is PersonalName personalName && EqualityComparer<Name>.Default.Equals(FirstName, personalName.FirstName) && EqualityComparer<Name>.Default.Equals(LastName, personalName.LastName); } public override Int32 GetHashCode() { return HashCode.Combine(FirstName, LastName); } public override String ToString() { return FullName; } } 


年龄
 public class Age { public Age(Int32 value) { if (!IsValid(value)) { throw new ArgumentException("Age is not valid"); } Value = value; } public Int32 Value { get; } public static Boolean IsValid(Int32 value) { return 10 <= value && value <= 120; } public override Boolean Equals(Object obj) { return obj is Age other && Value == other.Value; } public override Int32 GetHashCode() { return Value.GetHashCode(); } } 


最后是人:

 public class Person { public Person(PersonalName personalName, Age age) { if (personalName == null) { throw new ArgumentNullException(nameof(personalName)); } if (age == null) { throw new ArgumentNullException(nameof(age)); } Id = Guid.NewGuid(); PersonalName= personalName; Age = age; } public Guid Id { get; private set; } public PersonalName PersonalName{ get; set; } public Age Age { get; set; } } 

因此,我们无法创建没有全名或年龄的人。 同样,我们不能创建“错误”名称或“错误”年龄。 一个好的程序员肯定会使用Name.IsValid(“ John”)和Age.IsValid(35)方法在控制器中检查接收到的数据,并在数据不正确的情况下将这一情况通知客户。

如果我们在模型中的任何地方制定一条仅使用实体和VO的规则,那么我们将保护自己免受大量错误的侵害-错误的数据根本不会进入模型。

坚持不懈


现在,我们需要将我们的数据保存在数据仓库中,并根据需要获取它。 我们将使用实体框架核心作为ORM,数据仓库为MS SQL Server。

DDD明确定义:持久性是基础结构层的一个子类,因为它隐藏了数据访问的特定实现。

该域不需要了解有关持久性的任何信息,这仅决定存储库的接口。

持久性包含特定的实现,映射配置以及UnitOfWork对象。

是否值得创建存储库和工作单元有两种意见。

一方面-不,没有必要,因为在Entity Framework Core中,这已经全部实现。 如果我们具有基于数据存储的DAL->业务逻辑->表示形式的多级体系结构,那么为什么不直接使用EF Core的功能。

但是DDD中的域并不依赖于数据存储和所用的ORM,这些都是实现的精妙之处,它们封装在Persistence中,其他任何人都不会感兴趣。 如果我们将DbContext提供给其他层,那么我们将立即公开实现细节,将其紧密绑定到所选的ORM并获得DAL-作为所有业务逻辑的基础,但事实并非如此。 粗略地说,域不应注意到ORM发生了变化,甚至不应注意到持久性丧失了一层。

因此,域中的人员存储库接口:

 public interface IPersons { Task Add(Person person); Task<IReadOnlyList<Person>> GetList(); } 

及其在持久性中的实现:

 public class EfPersons : IPersons { private readonly PersonsDemoContext _context; public EfPersons(UnitOfWork unitOfWork) { if (unitOfWork == null) { throw new ArgumentNullException(nameof(unitOfWork)); } _context = unitOfWork.Context; } public async Task Add(Person person) { if (person == null) { throw new ArgumentNullException(nameof(person)); } await _context.Persons.AddAsync(person); } public async Task<IReadOnlyList<Person>> GetList() { return await _context.Persons.ToListAsync(); } } 

似乎没有什么复杂的,但是有一个问题。 开箱即用的Entity Framework Core仅适用于基本类型(字符串,整数,日期时间等),并且对PersonalName和Age不了解。 让我们教EF Core了解我们的价值目标。

构型


Fluent API最适合在DDD中配置实体。 属性不合适,因为域不需要了解有关映射细微差别的任何信息。

使用基本配置PersonConfiguration在Persistence中创建一个类:

 internal class PersonConfiguration : IEntityTypeConfiguration<Person> { public void Configure(EntityTypeBuilder<Person> builder) { builder.ToTable("Persons"); builder.HasKey(p => p.Id); builder.Property(p => p.Id).ValueGeneratedNever(); } } 

并将其插入DbContext中:

 protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.ApplyConfiguration(new PersonConfiguration()); } 

制图


编写本材料的部分。

目前,有两种或多或少的便捷方法可将非标准类映射到基本类型-值转换和拥有的类型。

价值转换


此功能出现在Entity Framework Core 2.1中,使您可以确定两种数据类型之间的转换。

让我们为Age编写转换器(在本节中,所有代码都在PersonConfiguration中):

 var ageConverter = new ValueConverter<Age, Int32>( v => v.Value, v => new Age(v)); builder .Property(p => p.Age) .HasConversion(ageConverter) .HasColumnName("Age") .HasColumnType("int") .IsRequired(); 

简单简洁的语法,但并非没有缺陷:

  1. 无法转换为null;
  2. 无法将单个属性转换为表中的多个列,反之亦然;
  3. EF Core无法将具有此属性的LINQ表达式转换为SQL查询。

我将更详细地讨论最后一点。 向存储库中添加一个方法,该方法返回给定年龄的Person列表:

 public async Task<IReadOnlyList<Person>> GetOlderThan(Age age) { if (age == null) { throw new ArgumentNullException(nameof(age)); } return await _context.Persons .Where(p => p.Age.Value > age.Value) .ToListAsync(); } 

年龄是有条件的,但是EF Core将无法将其转换为SQL查询,并且到达Where()时,它将把整个表加载到应用程序内存中,只有使用LINQ,它才能满足条件p.Age.Value> age.Value。 。

通常,“值转换”是一个简单而快速的映射选项,但您需要记住EF Core的此功能,否则,在某些时候,查询大表时,内存可能会用完。

所属类型


拥有的类型出现在Entity Framework Core 2.0中,并取代了常规Entity Framework中的“复杂类型”。

让我们将年龄设置为拥有类型:

 builder.OwnsOne(p => p.Age, a => { a.Property(u => u.Value).HasColumnName("Age"); a.Property(u => u.Value).HasColumnType("int"); a.Property(u => u.Value).IsRequired(); }); 

还不错 拥有类型不具有值转换的某些缺点,即第2点和第3点。

2. 可以将一个属性转换为表中的几列,反之亦然

您需要的PersonalName,尽管语法已经有点重载了:

 builder.OwnsOne(b => b.PersonalName, pn => { pn.OwnsOne(p => p.FirstName, fn => { fn.Property(x => x.Value).HasColumnName("FirstName"); fn.Property(x => x.Value).HasColumnType("nvarchar(100)"); fn.Property(x => x.Value).IsRequired(); }); pn.OwnsOne(p => p.LastName, ln => { ln.Property(x => x.Value).HasColumnName("LastName"); ln.Property(x => x.Value).HasColumnType("nvarchar(100)"); ln.Property(x => x.Value).IsRequired(); }); }); 

3. EF Core 可以将具有此属性的LINQ表达式转换为SQL查询。
加载列表时添加按姓氏和名字排序:

 public async Task<IReadOnlyList<Person>> GetList() { return await _context.Persons .OrderBy(p => p.PersonalName.LastName.Value) .ThenBy(p => p.PersonalName.FirstName.Value) .ToListAsync(); } 

这样的表达式将正确地转换为SQL查询,并且在SQL Server端而不是在应用程序中执行排序。

当然,也有缺点。

  1. null的问题尚未消失。
  2. “拥有的类型”字段不能为只读,并且必须具有受保护的或私有的设置器。
  3. 拥有的类型被实现为常规实体,这意味着:
    • 它们有一个标识符(例如shadow属性,即它不出现在domain类中);
    • EF Core跟踪拥有类型中的所有更改,与常规实体完全相同。

一方面,这根本不是值对象应具有的功能。 它们不能有任何标识符。 不应跟踪VO的更改-因为它们最初是不可变的,因此应跟踪父实体的属性,而不是VO的属性。

另一方面,这些是可以省略的实现细节,但同样,请不要忘记。 跟踪更改会影响性能。 如果在选择单个实体(例如,按ID)或较小列表时不明显,然后对“重”实体的大列表(许多VO属性)进行采样,则由于跟踪,性能下降将非常明显。

简报


我们弄清楚了如何在域和存储库中实现值对象。 现在该使用所有内容了。 让我们创建两个简单的页面-带有“人”列表和用于添加“人”的表单。

没有Action方法的控制器代码如下所示:

 public class HomeController : Controller { private readonly IPersons _persons; private readonly UnitOfWork _unitOfWork; public HomeController(IPersons persons, UnitOfWork unitOfWork) { if (persons == null) { throw new ArgumentNullException(nameof(persons)); } if (unitOfWork == null) { throw new ArgumentNullException(nameof(unitOfWork)); } _persons = persons; _unitOfWork = unitOfWork; } // Actions private static PersonModel CreateModel(Person person) { return new PersonModel { FirstName = person.PersonalName.FirstName.Value, LastName = person.PersonalName.LastName.Value, Age = person.Age.Value }; } } 

添加操作以获取“人员”列表:

 [HttpGet] public async Task<IActionResult> Index() { var persons = await _persons.GetList(); var result = new PersonsListModel { Persons = persons .Select(CreateModel) .ToArray() }; return View(result); } 

检视
 @model PersonsListModel @{ ViewData["Title"] = "Persons List"; } <div class="text-center"> <h2 class="display-4">Persons</h2> </div> <table class="table"> <thead> <tr> <td><b>Last name</b></td> <td><b>First name</b></td> <td><b>Age</b></td> </tr> </thead> @foreach (var p in Model.Persons) { <tr> <td>@p.LastName</td> <td>@p.FirstName</td> <td>@p.Age</td> </tr> } </table> 


没什么复杂的-我们加载了列表,为每个列表创建了一个数据传输对象(PersonModel)

人员并发送到相应的视图。

结果


更有趣的是Person的添加:

 [HttpPost] public async Task<IActionResult> AddPerson(PersonModel model) { if (model == null) { return BadRequest(); } if (!Name.IsValid(model.FirstName)) { ModelState.AddModelError(nameof(model.FirstName), "FirstName is invalid"); } if (!Name.IsValid(model.LastName)) { ModelState.AddModelError(nameof(model.LastName), "LastName is invalid"); } if (!Age.IsValid(model.Age)) { ModelState.AddModelError(nameof(model.Age), "Age is invalid"); } if (!ModelState.IsValid) { return View(); } var firstName = new Name(model.FirstName); var lastName = new Name(model.LastName); var person = new Person( new PersonalName(firstName, lastName), new Age(model.Age)); await _persons.Add(person); await _unitOfWork.Commit(); var persons = await _persons.GetList(); var result = new PersonsListModel { Persons = persons .Select(CreateModel) .ToArray() }; return View("Index", result); } 

检视
 @model PersonDemo.Models.PersonModel @{ ViewData["Title"] = "Add Person"; } <h2 class="display-4">Add Person</h2> <div class="row"> <div class="col-md-4"> <form asp-action="AddPerson"> <div asp-validation-summary="ModelOnly" class="text-danger"></div> <div class="form-group"> <label asp-for="FirstName" class="control-label"></label> <input asp-for="FirstName" class="form-control" /> <span asp-validation-for="FirstName" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="LastName" class="control-label"></label> <input asp-for="LastName" class="form-control" /> <span asp-validation-for="LastName" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Age" class="control-label"></label> <input asp-for="Age" class="form-control" /> <span asp-validation-for="Age" class="text-danger"></span> </div> <div class="form-group"> <input type="submit" value="Create" class="btn btn-primary" /> </div> </form> </div> </div> @section Scripts { @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} } 


必须对传入数据进行验证:

 if (!Name.IsValid(model.FirstName)) { ModelState.AddModelError(nameof(model.FirstName), "FirstName is invalid"); } 

如果不这样做,那么在创建具有错误值的VO时,将引发ArgumentException(记住VO构造函数中的Guard子句)。 通过验证,可以很容易地向用户发送一个值不正确的消息。

结果


在这里,您需要做一个小题外话-在Asp Net Core中,使用属性是一种常规的数据验证方法。 但是在DDD中,由于以下几个原因,这种验证方法不正确:

  • 属性功能可能不足以进行验证逻辑;
  • 任何业务逻辑,包括用于验证参数的规则,都是由域专门设置的。 他对此拥有垄断权,所有其他层面都必须考虑到这一点。 可以使用属性,但您不应依赖它们。 如果属性跳过不正确的数据,那么在创建VO时我们将再次获得异常。

返回AddPerson()。 数据验证之后,将创建PersonalName,Age和Person。 接下来,将对象添加到存储库并保存更改(提交)。 在EfPersons信息库中不要调用Commit非常重要。 存储库的任务是仅对数据执行某些操作。 提交仅在外部(确切地说是程序员决定)时进行。 否则,当在某个业务迭代的中间发生错误时,就有可能出现这种情况-一些数据被保存而另一些则没有。 我们收到的域名处于“损坏”状态。 如果Commit在最后完成,那么如果发生错误,事务将简单地回滚。

结论


我举例说明了值对象的实现,以及实体框架核心中映射的细微差别。 我希望这些材料将有助于理解如何在实践中应用域驱动设计的元素。

Complete PersonsDemo项目源代码-GitHub

如果PersonalName或Age不是Person的必需属性,则该材料没有公开与可选(可空)值对象进行交互的问题。 我想在本文中对此进行描述,但是它已经有些过载了。 如果对此问题感兴趣,请在评论中写,继续。

对于一般“美丽的体系结构”的爱好者,尤其是“领域驱动的设计”的爱好者,我强烈推荐企业工艺资源。

关于.Net的正确体系结构构建和实现示例,有很多有用的文章。 那里借鉴了一些想法,并在“战斗”项目中成功实施,并在本文中得到了部分体现。

还使用了所有权类型值转换的官方文档。

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


All Articles