最近做了个多对多对实体对象,结果发现每次只要增加一个子实体,就会自动添加一个父实体进去,而不管该父实体是否已经存在.
找了好久,终于找到这篇文章,照文章内容来看,应该是断开连接导致的.
原文地址: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,
新闻热点
疑难解答