数据点

实体框架为什么向我的数据库再次插入已有对象?

Julie Lerman

下载代码示例

Julie Lerman就在为本期专栏的主题构思的时候,有三位朋友通过 twitter 和邮件问我,实体框架为什么向他们的数据库再次插入已有对象。看来,我不用为本期专栏写什么而头疼了。

由于实体框架具有状态管理能力,因此当它处理图形时,其实体状态行为并不总是符合你的期望。我们来看一个典型示例。

假定有两个类:Screencast 和 Topic 类,且为每个 Screencast 对象分配一个 Topic 对象,如图 1 所示。

 

图 1 Screencast 和 Topic 类

public class Screencast {   public int Id { get; set; }   public string Title { get; set; }   public string Description { get; set; }   public Topic Topic { get; set; }   public int TopicId { get; set; } } public class Topic {   public int Id { get; set; }   public string Name { get; set; } }

如果我想要检索 Topic 的列表,并将其中一个对象分配给新的 Screencast 对象然后保存(整个操作集都包含在一个上下文中),整个过程不会有任何问题,如下例所示:

using (var context = new ScreencastContext()) {   var dataTopic = context.Topics.FirstOrDefault(t=>t.Name.Contains("Data"));   context.Screencasts.Add(new Screencast                                {                                  Title="EF101",                                  Description = "Entity Framework 101",                                  Topic = dataTopic                                });   context.SaveChanges(); }

于是,数据库中就会插入一个 Screencast 对象,并且具有指向所选 Topic 的相应外键。

如果你是在客户端应用程序中工作,或是在上下文跟踪所有活动的单个工作单元内执行这些步骤,那么上述处理方式可能正是你期望的。 不过,如果您正在处理已断开连接的数据,那么其处理方式将会迥然不同,结果也可能会让许多开发者大吃一惊。

在断开连接的场景中包含图形的处理方式

我在处理引用列表时通常采用的一种模式是使用独立的上下文,当保存任何用户修改时该上下文将不再处于可访问范围内。 这对 Web 应用程序和 Web 服务来说是常见的情景,但也可能发生在客户端应用程序中。 下面的例子使用一个存储库来存储引用数据,通过下面的 GetTopicList 方法来检索 Topic 的列表:

public class SimpleRepository {   public List<Topic> GetTopicList()   {     using (var context = new ScreencastContext())     {       return context.Topics.ToList();     }   }  ...
}

然后你可以将这些 Topic 对象以列表形式展现在一个 Windows Presentation Foundation (WPF) 表单中,以便让用户可以新建 Screencast 对象,例如图 2 所示的表单。

A Windows Presentation Foundation Form for Entering New Screencasts
图 2 用来输入新 Screencast 对象的 Windows Presentation Foundation 表单

然后,在客户端应用程序中(如图 2 所示的 WPF 表单),将下拉列表中选定的条目赋给新 Screencast 对象的 Topic 属性,代码如下:

private void Save_Click(object sender, RoutedEventArgs e) {   repo.SaveNewScreencast(new Screencast                 {                   Title = titleTextBox.Text,                   Description = descriptionTextBox.Text,                   Topic = topicListBox.SelectedItem as Topic                 }); }

此时 Screencast 变量是一个包含了新建的 Screencast 和 Topic 实例的图形。 将该变量传递给存储库的 SaveNewScreencast 方法,即可将此图形添加到新建的上下文实例中并随即保存到数据库,如下列代码所示:

public void SaveNewScreencast(Screencast screencast) {   using (var context = new ScreencastContext())   {     context.Screencasts.Add(screencast);     context.SaveChanges();   } }

对数据库活动进行分析,我们发现以上代码不仅向数据库插入了 Screencast 对象,而且在此之前,还向 Topics 表插入了关于 Data Dev 主题的一行新记录,即使该主题已经存在:

    exec sp_executesql N'insert [dbo].[Topics]([Name]) values (@0) select [Id] from [dbo].[Topics] where @@ROWCOUNT > 0 and [Id] = scope_identity()',N'@0 nvarchar(max) ',@0=N'Data Dev'

这种行为使许多开发者感到困惑。 发生这种情况的原因是,当你调用 DBSet.Add 方法(即 Screencasts.Add)时,不仅根实体的状态标记为“Added”,图形中上下文之前未知的所有实体的状态也都标记为 Added。 尽管开发者可能注意到 Topic 对象已经有一个 Id 值,但实体框架则以其 EntityState (Added) 状态为准,无视已有的 Id,仍然为该 Topic 对象创建一条 Insert 数据库命令。

虽然许多开发者可能会预测到这种行为,但是还有许多人并不了解。 在后一种情况下,如果你没有对数据库活动进行分析,可能不会意识到发生了什么,直到下次你(或用户)在 Topics 列表中发现重复条目才知道出了问题。

注: 如果你对实体框架如何插入新记录不太了解,可能会对上文所述的 SQL 中的 select 语句感到好奇。 它是用来确保实体框架能够取回新创建的 Screencast 记录的 Id 值,以便在 Screencast 实例中设置此值。

当加入整个图形时,这不仅只是个问题

我们来看看另一种可能发生此问题的场景。

如果不向存储库传递图形,而是让存储库方法将新建的 Screencast 和选定的 Topic 同时作为请求参数,会怎么样? 这样一来,不再是添加整个图形,而是添加 Screencast 实体,然后设置其 Topic 导航属性:

public void SaveNewScreencastWithTopic(Screencast screencast, Topic topic) {   using (var context = new ScreencastContext())   {     context.Screencasts.Add(screencast);     screencast.Topic = topic;     context.SaveChanges();   } }

在本例中,SaveChanges 的行为与已添加图形的行为没什么两样。 您可能已经熟悉如何使用实体框架的 Attach 方法将未跟踪的实体附加到上下文。 在本例中,实体的初始状态是 Unchanged。 但在这里,当我们把 Topic 赋给 Screencast 实例而非上下文时,实体框架会把它看成是未识别的实体,而实体框架对无状态的未识别实体的默认处理方式是将其标记为 Added。 这样一来,Topic 将在调用 SaveChanges 时被再次插入数据库。

我们可以对状态进行控制,但这需要对实体框架的行为有更深入的理解。 例如,如果你准备将 Topic 直接附加到上下文,而不是附加到状态为 Added 的 Screencast 对象,那么其 EntityState 状态的初始值将会是 Unchanged。 此时将 Topic 赋值给 screencast.Topic 将不会引起状态变化,因为上下文已经意识到 Topic 的存在了。 下面是展示这一逻辑的修改后的代码:

using (var context = new ScreencastContext()) {   context.Screencasts.Add(screencast);   context.Topics.Attach(topic);   screencast.Topic = topic;   context.SaveChanges(); }

还有另外一种处理方法:不调用 context.Topics.Attach(topic),而是代之以在此前或此后设置 Topic 的状态,明确地将其状态设置为 Unchanged:

context.Entry(topic).State = EntityState.Unchanged

如果在上下文意识到 Topic 的存在之前调用上述代码,会导致上下文附加该 Topic,并随即设置其状态。

尽管上述这些做法是处理该问题的正确模式,但我们不会自然而然地想到这么做。 除非你已经预先了解实体框架的这种处理方式,并知道所需的代码模式,否则你可能会更倾向于编写看起来符合正常逻辑的代码,然后在实际运行中遇到这个问题,只有到这时候你才会开始研究到底出了什么事。

避免麻烦,使用外键

但还有一种简单得多的方法,利用外键属性,可以避免这种迷惑/混淆(原谅我的俏皮话)。

与其设置 Topic 这个导航属性并且不得不为其状态操心,不如只设置 TopicId 属性,因为你确实可以在 Topic 实例中访问到它的值。 这是我经常给开发者建议的做法。 甚至在 Twitter 上,我也看到这样的问题: “为什么实体框架会插入已经存在的数据?”而我在回复中经常猜对了: “你是不是在对新建实体设置导航属性,而没有用外键? J”

因此,让我们回顾一下 WPF 表单中的 Save_Click 方法,并改为设置 TopicId 属性而非 Topic 导航属性:

repo.SaveNewScreencast(new Screencast                {                  Title = titleTextBox.Text,                  Description = descriptionTextBox.Text,                  TopicId = (int)topicListBox.SelectedValue)                });

此时,发送给存储库方法的 Screencast 就不再是图形,只是单个实体。 实体框架可以用该外键属性来直接设置表的 TopicId。 这样一来,对实体框架来说,为包含 TopicId 值(在本例中,其值为 2)的 Screencast 实体创建一个 insert 方法就简单了(而且更快了):

    exec sp_executesql N'insert [dbo].[Screencasts]([Title], [Description], [TopicId]) values (@0, @1, @2) select [Id] from [dbo].[Screencasts] where @@ROWCOUNT > 0 and [Id] = scope_identity()', N'@0 nvarchar(max) ,@1 nvarchar(max) ,@2 int',   @0=N'EFFK101',@1=N'Using Foreign Keys When Setting Navigations',@2=2

如果你想把这段构造逻辑限制在存储库内,而且不想让用户界面开发者操心外键的设置,可以把 Topic 的 Id 和 Screencast 指定为存储库方法的参数,如下所示:

public void SaveNewScreencastWithTopicId(Screencast screencast, int topicId) {   using (var context = new ScreencastContext())   {     screencast.TopicId = topicId;     context.Screencasts.Add(screencast);     context.SaveChanges();   } }

我们需要担心的不止于此,还需要考虑到,开发者可能还会设置 Topic 导航属性。 换言之,即使我们想用外键来避免 EntityState 问题,但万一 Topic 实例是图形的一部分怎么办?例如以下所示 Save_Click 按钮的另一种代码实现:

repo.SaveNewScreencastWithTopicId(new Screencast     {       Title = titleTextBox.Text,       Description = descriptionTextBox.Text,       Topic=topicListBox.SelectedItem as Topic     },   (int) topicListBox.SelectedValue);

不幸的是,这将让你回到问题的原点: 实体框架将 Topic 实体看成是图形,并将该实体与 Screencast 一起添加到上下文中,即使已经设置了 Screencast.TopicId 属性也是如此。 而且 Topic 实例的 EntityState 再次造成了混淆: 实体框架将插入一条新的 Topic 记录,并在插入 Screencast 记录时用该值作为新记录的 Id。

避免这一问题的最安全方法,是在设置外键的值时将 Topic 属性设置为 null。 如果有其他用户界面要使用存储库方法,而您又无法确保只会用到已有的 Topic,那么你甚至可能想在这种可能的情况下新建一个 Topic 传递过去。 图 3 展示了为完成这一任务而再次修改的存储库方法。

图 3 旨在防止向数据库意外插入导航属性的存储库方法

public void SaveNewScreencastWithTopicId(Screencast screencast, int topicId) {   if (topicId > 0)   {     screencast.Topic = null;     screencast.TopicId = topicId;   }   using (var context = new ScreencastContext())   {     context.Screencasts.Add(screencast);     context.SaveChanges();   } }

此时我的存储库方法就可以应对若干种场景,甚至还提供了相应的逻辑,可以提供新的 Topic 并传递给该方法。

用 ASP.NET MVC 4 基架生成的代码来避免这一问题

尽管断开连接的应用程序天生存在这个问题,但如果你用 ASP.NET MVC 4 基架来生成视图和 MVC 控制器,就可以避免导航实体被重复插入数据库的问题。

鉴于 Screencast 与 Topic 以及 TopicId 属性(该属性是 Screencast 类型中的外键)之间是一对多关系,基架在控制器中生成以下 Create 方法:

public ActionResult Create() {   ViewBag.TopicId = new SelectList(db.Topics, "Id", "Name");   return View(); }

这段代码构建了一个 Topic 列表,命名为 TopicId(与外键属性同名),并将其传递给视图。

基架也在 Create 视图的标记中包含了以下列表:

    <div class="editor-field">   @Html.DropDownList("TopicId", String.Empty)   @Html.ValidationMessageFor(model => model.TopicId) </div>

 

当该视图将数据提交回来时,HttpRequest.Form 中包含了一个名为 TopicId 的查询字符串值,该值来自 ViewBag 属性。TopicId 的值是 DropDownList 中选定条目的值。因为查询字符串的名称与 Screencast 的属性名匹配,所以 ASP.NET MVC 模型绑定将使用所创建的 Screencast 实例的 TopicId 属性值作为方法参数,如图 4 所示。

The New Screencast Gets Its TopicId from the Matching HttpRequest Query-String Value图 4 新的 Screencast 从匹配的 HttpRequest 查询字符串值来获取其 TopicId 值

为了检验这一点,你可以将控制器的 TopicId 变量改为其他名字,例如 TopicIdX,然后在视图的 @Html.DropDownList 中对“TopicId”字符串作同样修改,则查询字符串值(现在是 TopicIdX)将被忽略,screencast.TopicId 的值将为 0。

这时,将不会有 Topic 实例通过管道传递回来。因此 ASP.NET MVC 默认根据外键属性,从而避免了向数据库重复插入已有的 Topic。

这不是你的错!断开连接的图形太复杂了

尽管实体框架的开发团队在一版又一版的更新升级中做了大量工作,使断开连接的数据处理起来更容易,但它仍然是个让许多并不熟知实体框架预期行为的开发者为之气馁的问题。在 Rowan Miller 和我共同编著的《Programming Entity Framework: DbContext》(实体框架编程:DbContext)一书(O' Reilly Media,2012)中,我们花了一整章讨论断开连接的实体和图形。而且在制作近期的一集 Pluralsight 课程时,我额外增加了 25 分钟的时间,专门讲解断开连接的图形在存储库中的复杂性。

用图形进行数据查询和交互是非常方便的,但要建立图形与现有数据的关系时,外键是不可或缺的朋友!请查阅我在 2012 年 1 月的专栏文章“设法应对缺少的外键”(msdn.microsoft.com/magazine/hh708747),其中也讨论了不用外键的一些编程陷阱。

在下一期专栏文章中,我将继续探索如何减轻开发者在断开连接的场景中与图形打交道所遇到的痛苦。那期专栏是本主题的第二部分,将集中讨论如何在多对多关系和导航集合中对 EntityState 进行控制。

Julie Lerman 是 Microsoft MVP、.NET 导师和顾问,住在佛蒙特州的山区。 您可以在全球的用户组和会议中看到她对数据访问和其他 Microsoft .NET 主题的演示。 她是《Programming Entity Framework》(2010) 以及“代码优先”版 (2011) 和 DbContext 版 (2012)(均出自 O’Reilly Media)的作者,博客网址为 thedatafarm.com/blog。 请关注她的 Twitter:twitter.com/julielerman

衷心感谢以下技术专家对本文的审阅: Diego Vega (Microsoft)