Skip to main content

2017 年 9 月
第 32 卷,第 9 期

数据点 - 更适合 DDD 的 EF Core 2.0

作者 Julie Lerman | 2017 年 9 月 | 获取代码: C#    VB

Julie Lerman如果一直关注本专栏,可能已经注意到,有好几篇文章都提到过,要在生成依赖域驱动设计 (DDD) 模式和指导原则的解决方案时实现实体框架 (EF)。尽管 DDD 侧重的是域,而不是数据暂留方式,但在某种情况下,仍需要数据能够流入流出软件。

在 EF 过往迭代中,借助它的模式(传统或自定义),用户不用花费太多力气,就可以直接将不复杂的域类映射到数据库。一般情况下,我的指导原则一直都是,如果 EF 映射层负责将精心设计的域模型放入数据库和从中取出,而无需创建额外的数据模型,这就已经足够了。不过,如果发现自己在调整域类和逻辑,以便它们可以更适用于实体框架,这就是危险信号,表明是时候创建数据暂留模型,然后从域模型类映射到数据模型类。

让我有点惊讶的是,这些关于 DDD 和 EF 映射的文章发布已久。我是在四年前撰写的系列文章“域驱动设计编码:以数据为中心的开发秘诀”,共分为三期,分别在 2013 年 MSDN 杂志的 8 月、9 月和 10 月刊中发布。下面是第一期的链接,其中收录了整个系列文章的链接: msdn.com/magazine/dn342868

曾有两个专栏专门介绍了 DDD 模式,并说明了 EF 在域类与数据库之间轻松建立或无法建立映射的情况。自 EF6 起,最大问题之一是,无法封装“子”集合,进而也无法保护“子”集合。使用已知模式保护集合(这通常意味着使用 IEnumerable)并不符合 EF 要求,EF 甚至没有认识到导航属性应属于模型。我和 Steve Smith 在创建 Pluralsight 课程“域驱动设计基础知识”( bit.ly/PS-DDD) 时花了很多时间思考这个问题。最后,Steve 想出了一个绝妙的解决方法 ( bit.ly/2ufw89D)。

EF Core 最终在版本 1.1 中解决了这个问题,我在 2017 年 1 月的专栏文章中介绍了这一新功能 ( msdn.com/magazine/mt745093)。EF Core 1.0 和 1.1 还化解了其他一些 DDD 约束,但仍遗留下一些缺口,最主要是无法映射域类型中使用的 DDD 值对象。EF 一开始就可以执行此操作,但这项功能一直都未被引入 EF Core。不过,EF Core 2.0 即将推出,届时此限制也将不复存在。

在本文中,我将介绍与许多 DDD 概念保持一致的可用 EF Core 2.0 功能。EF Core 2.0 更适合利用这些概念的开发者。借助它,开发者也许可以初次接触这些概念。即使不打算采用 DDD,也仍可以受益于它的许多精彩模式! 现在可以使用 EF Core 执行更多相关操作。

一对一更智能

在 Eric Evans 的“域驱动设计”一书中,他写道,“双向关联意味着,只有同时考虑两个对象,才能理解它们。如果应用程序不要求遍历两个方向,添加一个遍历方向可以减少相互依赖,并能简化设计。” 遵循这一指导原则确实在我的代码中避免了副作用。EF 一直能够处理一对多和一对一的单向关系。事实上,在撰写这篇文章时,我发现长期以来自己都有一个误解,误认为需要两端的一对一关系会强制生成双向关系。不过,之前确实需要明确配置所需的关系,但现在 EF Core 中则不需要这样做,极端情况除外。

在 EF6 中,一对一关系有一项不利的要求,即依赖类型中的关键属性必须兼当返回给主要实体的外键。这样一来,就会被迫以不寻常的方式设计类,即使已习惯使用它,也不例外。由于 EF Core 现已开始支持惟一外键,因此现在一对一关系的依赖端可以包含显式外键属性。拥有显式外键让人感觉自然得多。在大多数情况下,EF Core 都应该可以根据存在的外键属性,正确推断出关系的依赖端。如果由于某极端情况而无法正确推断,那么需要添加配置,我很快就会在重命名外键属性时介绍具体做法。

为了阐明一对一关系,我将使用常用的 EF Core 域,即来自电影“七武士”的类:

public class Samurai {
  public int Id { get; set; }
  public string Name { get; set; }
  public Entrance Entrance { get; set; }
}

public class Entrance {
  public int Id { get; set; }
  public string SceneName { get; set; }
  public int SamuraiId { get; set; }
}

现在,借助 EF Core,Samurai 和 Entrance(电影中角色的首次出现)这一对类被正确识别为单向一对一关系,其中 Entrance 为依赖类型。我既不需要在 Entrance 中添加导航属性,也不需要在 Fluent API 中进行任何特殊映射。由于外键 (SamuraiId) 遵循约定,因此 EF Core 能够识别这种关系。

EF Core 推断出,在数据库中,Entrance.SamuraiId 是指回 Samurai 的唯一外键。我需要费劲记住的是(因为我需要不断提醒自己),EF Core 不是 EF6! 默认情况下,.NET 和 EF Core 会将 Samurai.Entrance 视为运行时的可选属性,除非已设置域逻辑来强制规定必须使用 Entrance。自 EF4.3 起,便可以受益于验证 API,它能够响应类或映射中的 [Required] 注释。不过,在 EF Core 中,没有(尚无?)可用于处理这一特定问题的验证 API。但却有其他与数据库相关的要求。例如,Entrance.SamuraiId 是不可以为 null 的整数。如果尝试插入没有填充 SamuraiId 值的 Entrance,EF Core 就不会捕获无效数据,这样 InMemory 提供商也就暂时不会投诉。不过,关系数据库应该会因约束冲突而抛出错误。

然而,从 DDD 角度来看,这并不是真正的问题,因为不得依赖暂留层来指出域逻辑中的错误。如果 Samurai 需要 Entrance,这应是一项业务规则。如果不能有孤立的 Entrance,这也应是一项业务规则。因此,不管怎样,域逻辑应包含验证。

对于我之前提到的极端情况,请参见下面这个示例。如果依赖实体(例如,Entrance)中的外键不遵循约定,可以使用 Fluent API 通知 EF Core。如果假定 Entrance.SamuraiId 是 Entrance.SamuraiFK,可以通过下列代码阐明 FK:

modelBuilder.Entity<Samurai>().HasOne(s=>s.Entrance)
  .WithOne().HasForeignKey<Entrance>(e=>e.SamuraiFK);

如果两端都必须有关系(也就是说,Entrance 必须有 Samurai),可以在 WithOne 后添加 IsRequired。

可以进一步封装属性

DDD 会引导用户生成聚合(对象图),其中聚合根(图中的主要对象)控制图中的其他所有对象。也就是说,要编写代码,以防其他代码误用或滥用规则。封装属性以确保它们不会被随机设置和读取(后者较为常见)是保护图的重要措施。在 EF6 及更低版本中,始终可以让标量和导航属性拥有私有资源库,并在 EF 读取和更新数据时仍被 EF 识别,但要将属性设置为私有属性很难。Rowan Miller 的一篇帖子介绍了一种在 EF6 中这样做的方法,并收录了指向一些早期解决方法的链接 ( bit.ly/2eHTm2t)。另外,在一对多关系中,没有可以真正保护导航集合的方法。已有很多文章介绍过后一个问题。现在,不仅可以轻松地在 EF Core 中使用包含支持字段(或推断的支持字段)的私有属性,还可以真正地封装集合属性,这都要归功于对映射 IEnumerable<T> 的支持。因为我之前提到的 2017 年 1 月专栏文章介绍了支持字段和 IEnumerable<T>,所以本文不会重复介绍相关详情。不过,由于这对于 DDD 模式非常重要,因此在本文中还是要注意一下。

虽然可以隐藏标量和集合,但还可能非常希望封装另外一种类型的属性,即导航属性。导航集合受益于 IEnumerable<T> 支持,但私有导航属性(如 Samurai.Entrance)不能被模型理解。不过,可以将模型配置为理解聚合根中隐藏的导航属性。

例如,在下面的代码中,我将 Entrance 声明为 Samurai 的私有属性(我甚至没有使用显式支持字段,尽管可以在需要时使用)。可以使用 CreateEntrance 方法新建 Entrance(在 Entrance 中调用工厂方法),但只能读取 Entrance 的 SceneName 属性。请注意,我使用的是 C# 6 null 条件运算符,以防在 Entrance 尚未加载时出现异常:

private Entrance Entrance {get;set;}
public void CreateEntrance (string sceneName) {
    Entrance = Entrance.Create (sceneName);
  }
public string EntranceScene => Entrance?.SceneName;

按照惯例,EF Core 不会对此私有属性作出假定。即便使用支持字段,也不会自动发现私有 Entrance,更无法在与数据存储交互时使用它。这是一项精心推出的 API 设计,有助于避免潜在副作用。但仍可以进行显式配置。请注意,当 Entrance 为公共属性时,EF Core 能够理解一对一关系。不过,由于它是私有属性,因此必须先确保 EF 知晓这一关系。

在 OnModelCreating 中,需要添加 HasOne/WithOne 流畅映射,让 EF Core 注意到这一关系。由于 Entrance 是私有属性,因此不能使用 Lambda 表达式作为 HasOne 的参数。相反,必须使用类型和名称来描述属性。WithOne 通常需要使用 Lambda 表达式,指定返回给配对另一端的导航属性。不过,Entrance 并没有 Samurai 导航属性,只有外键。那也没关系! 可以将参数留空,因为 EF Core 现在有足够信息,能够弄明白:

modelBuilder.Entity<Samurai> ()
  .HasOne (typeof (Entrance), "Entrance").WithOne();

如果使用支持属性(如 Samurai 类中的 _entrance),情况又如何?具体如下列更改所示:

private Entrance _entrance;
private Entrance Entrance { get{return _entrance;} }
public void CreateEntrance (string sceneName) {
    _entrance = _entrance.Create (sceneName);
  }
public string EntranceScene => _entrance?.SceneName;

EF Core 会弄明白,需要在具体化 Entrance 属性时使用支持字段。这是因为,正如 Arthur Vickers 在与我的 GitHub 长对话(我当时正在了解相关信息)中解释的一样,如果“有支持字段,而没有资源库,EF 只会使用支持字段,[因为]没有其他资源可供使用。” 因此一切迎刃而解。

如果此支持字段的名称未遵循约定(例如,将它命名为 _foo),需要执行元数据配置:

modelBuilder.Entity<Samurai> ()
  .Metadata
  .FindNavigation ("Entrance")
  .SetField("_foo");

现在,更新数据库和查询将能够解决这种关系问题。请注意,若要使用预先加载,需要对 Entrance 使用字符串,因为它无法被 Lambda 表达式发现;例如:

var samurai = context.Samurais.Include("Entrance").FirstOrDefault();

可以使用标准语法与筛选器等的支持字段进行交互,如“支持字段”文档页 ( bit.ly/2wJeHQ7) 的底部所示。

现支持值对象

值对象是重要的 DDD 概念,因为它们支持将域模型定义为值类型。值对象没有自己的标识,属于将它用作属性的实体。以字符串值类型为例,它由一系列字符组成。因为即使更改一个字符都会改变字词的含义,所以字符串不可变。若要更改字符串,必须替换整个字符串对象。DDD 会引导用户考虑在已确定一对一关系的任何位置上使用值对象。有关值对象的详细信息,可以学习我之前提到的“DDD 基础知识”课程。

EF 一直支持通过 ComplexType 类型添加值对象。可以定义不含键的类型,并将此类型用作实体属性。这就足以触发 EF 将它识别为 ComplexType,并将它的属性映射到实体映射到的表中。然后,可以将此类型扩展为具有值对象的必需功能,如确保类型不可变,并能在确定等同性和重写哈希时评估每个属性。我经常从 Jimmy Bogard 的 ValueObject 基类中派生出自己的类型,以便快速采用这些属性。

人员姓名是通常用作值对象的类型。可以确保,只要有人要在实体中添加人员姓名,始终可以遵循一系列通用规则。图 1 展示了简单的 PersonName 类,其中包含完全封装的 First 和 Last 属性,以及一个返回 FullName 的属性。此类旨在确保始终提供姓名的两个部分。

图 1:PersonName 值对象
public class PersonName : ValueObject<PersonName> {
  public static PersonName Create (string first, string last) {
    return new PersonName (first, last);
  }
  private PersonName () { } 
  private PersonName (string first, string last) {
    First = first;
    Last = last;
  }
  public string First { get; private set; }
  public string Last { get; private set; }
  public string FullName => First + " " + Last;
}

我可以在其他类型中将 PersonName 用作属性,并继续在 PersonName 类中充实更多逻辑。相对于本文中的一对一关系,值对象的优势在于,编码时无需维护关系。这是标准的面向对象的编程。它就是另一个属性。在 Samurai 类中,我添加了这种类型的新属性,设置了私有资源库,并提供了另一种 Identify 方法来取代资源库:

 

public PersonName SecretIdentity{get;private set;}
public void Identify (string first, string last) {
  SecretIdentity = PersonName.Create (first, last);
}

在低于 EF Core 2.0 的版本中,没有类似于 ComplexTypes 的功能,因此只有在单独的数据模型中添加,才能轻松使用值对象。EF 团队并没有在 EF Core 中重新实现 ComplexType,而是创建了名为“已拥有实体”的概念。此概念利用另一项 EF Core 功能,即影子属性。现在,已拥有实体被识别为拥有它们的类型的附加属性,EF Core 了解它们如何在数据库架构中进行解析,以及如何生成涉及此类数据的查询和更新。

EF Core 2.0 约定不会自动发现,新增的这一 SecretIdentity 属性是要被合并到暂留数据的类型。需要明确告知 DbContext,Samurai.SecretIdentity 属性是使用 OwnsOne 方法的 DbContext.OnModelCreating 中的已拥有实体:

protected override void OnModelCreating (ModelBuilder modelBuilder) {
  modelBuilder.Entity<Samurai>().OwnsOne(s => s.SecretIdentity);
}

这样一来,PersonName 的属性就会被迫解析为 Samurai 的属性。当代码使用 Samurai.SecretIdentity 类型,并导航到 First 和 Last 属性时,这两个属性会解析为 Samurais 数据库表中的列。EF Core 约定会以 Samurai 中的属性名 (SecretIdentity) 和已拥有实体属性名来命名它们,如图 2 所示。

Samurais 表架构(包括值属性)
图 2:Samurais 表架构(包括值属性)

现在可以识别 Samurai 的密钥名称,并使用如下代码来保存它:

using (var context = new SamuraiContext()) {
  var samurai = new Samurai { Name = "HubbieSan" 
  samurai.Identify ("Late", "Todinner");
  context.Samurais.Add (samurai);
  context.SaveChanges ();
}

在数据存储中,“Late”会暂留到 SecretIdentity_First 字段中,“Todinner”会暂留到 SecretIdentity_Last 字段中。

然后,就可以直接查询 Samurai:

var samurai=context.Samurais .FirstOrDefaultAsync (s => s.Name == "HubbieSan")

EF Core 会确保填充 Samurai 中生成的 SecretIdentity 属性,然后我就可以通过发出请求来查看标识:

samurai.SecretIdentity.FullName

EF Core 要求必须填充作为已拥有实体的属性。如果下载示例,便会看到我是如何设计 PersonName 类型以适应此要求的。

简单类可简化课程

本文介绍的是简单类,它们以最简方式利用 DDD 实现代码的一些核心概念,以便大家可以了解 EF Core 是如何响应这些构造的。已经看到,EF Core 2.0 能够理解单向的一对一关系。它可以暂留完全封装了标量、导航和集合属性的实体中的数据。它还支持在域模型中使用值对象,并能暂留这些对象。

在本文中,我力求确保类不复杂,略去了可以更正确地约束使用 DDD 模式的实体和值对象的附加逻辑。下载的示例和 GitHub 上的示例 ( bit.ly/2tDRXwi) 也都使用了简单类。其中包含简单版本和高级分支。在高级分支中,我对此域模型加强了控制,并向聚合根 (Samurai)、相关实体 (Entrance) 和值对象 (PersonName) 应用了一些额外的 DDD 做法,因此大家可以用更真实的视角审视 EF 2.0 Core 是如何处理 DDD 聚合表达式的。在即将发布的专栏文章中,我将介绍此分支中应用的高级模式。

请注意,我使用的 EF Core 2.0 发行版本略低于最终发行版本。虽然我列出的大部分行为都是可靠的,但在 2.0.0 发布之前,仍有可能会进行细微调整。


Julie Lerman 住在佛蒙特州的丘陵地区,担任 Microsoft 区域主管、Microsoft MVP、软件团队导师和顾问。可以在全球的用户组和会议中看到她对数据访问和其他主题的介绍。她的博客地址是 thedatafarm.com/blog。她是“Entity Framework 编程”及其 Code First 和 DbContext 版本(全都出版自 O’Reilly Media)的作者。通过 Twitter 关注她: @julielerman 并在 juliel.me/PS-Videos 上观看其 Pluralsight 课程。