首页 > 学院 > 开发设计 > 正文

EF为什么向我的数据库再次插入已有对象?(ZT)

2019-11-17 03:11:54
字体:
来源:转载
供稿:网友

EF为什么向我的数据库再次插入已有对象?(ZT)

最近做了个多对多对实体对象,结果发现每次只要增加一个子实体,就会自动添加一个父实体进去,而不管该父实体是否已经存在.

找了好久,终于找到这篇文章,照文章内容来看,应该是断开连接导致的.

原文地址:http://msdn.microsoft.com/zh-cn/magazine/dn166926.aspx

------------------------------------------------------------------------------

在为本期专栏的主题构思的时候,有三位朋友通过 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 所示的表单。

图 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,
发表评论 共有条评论
用户名: 密码:
验证码: 匿名发表