附加分离的 POCO 到 EF DbContext - 简单快速
一个简单的通用扩展方法,可以在不重新加载的情况下将分离的 POCO 附加到 DbContext
引言
最近,我一直在研究Entity Framework (EF),并评估它是否适合某些项目。我花了很长时间才弄清楚如何以快速且可靠的方式将分离的对象图附加到DBContext
。在这里,我分享一个简单的AttachByIdValue()
方法实现,它可以为您做到这一点。如果您对问题的完整解释不感兴趣,请直接跳转到方法实现并开始附加您的对象。
问题
假设我们在 Web 应用程序中使用 EF 来实现管理Order
和OrderLines
的页面。所以我们有父子关系 (Order
和 OrderLines
) 以及一些显示但不更新的参考数据 (Customer
和 Products
)。
我们通常会使用 EF 从数据库 (DB) 查询上述对象图,并将其发送到客户端(浏览器)。当客户端将此对象图发回服务器时,我们希望持久化它,为了做到这一点,我们必须首先将其附加到DbContext
。
问题是如何在不从数据库重新加载和应用更改的情况下附加这个分离的图。从数据库重新加载会影响性能并且具有侵入性。如果我不能在不重新加载的情况下做到这一点,我会放弃 EF,因为这是一个非常基本的任务,我期望我的 ORM 能够轻松解决。幸运的是,经过大量的研究,我找到了解决方案。
Add() 或 Attach()
有两种方法可以附加分离的对象,Add()
和 Attach()
,它们接收图根对象 (Order
)。Add()
方法附加图中的所有对象并将它们标记为 Added,而 Attach()
也附加图中的所有对象但将它们标记为 Unchanged。
由于我们的对象组通常会有新的、修改的和未更改的数据,因此我们唯一的选择是使用这两种方法之一来附加完整的图,然后遍历该图并更正每个条目的状态。
那么我们应该选择哪种方法呢?
实际上,Attach
不是一个选项,因为 attach
可能会因同一object
类型的重复键值而导致键冲突。如果我们有 Order
并且有两个新的 OrderLines
,这些 OrderLines
可能会有 Id = 0
。使用 Attach
方法附加此 Order
将会失败,因为 Attach
会将这两个 OrderLines
标记为 Unchanged
,并且 EF 坚持所有现有实体都应该有唯一的 主键。这就是为什么我们将使用 Add 方法进行附加。
通过 Id 值解决新增和修改的数据
问题是我们如何知道图中每个对象的状态(New/Modified/Unchanged/Deleted)?由于分离的对象没有被跟踪,唯一可靠的方法是从数据库重新加载对象图,正如我之前所说,我不想这样做,因为性能问题。
我们可以使用简单的约定。如果 Id > 0
对象被修改,如果 Id = 0
则对象是新的。这是一个非常简单的约定,但存在缺点
- 我们无法检测未更改的对象,因此我们会将未更改的数据保存到数据库中。
好的一面是,这些对象图不应该那么大,所以这应该不是一个性能问题。 - 删除对象必须使用自定义逻辑处理。
例如,拥有类似Order.DeletedOrderLines
集合的东西。
为了在附加对象时读取 Id
值,所有实体都将实现 IEntity
接口。
public interface IEntity
{
long Id { get; }
}
忽略参考数据
每个对象图都可以包含参考(只读)数据。在我们的例子中,当我们保存 Order
时,我们可能在图中有 Products
和 Customer
对象,但我们知道我们不想将它们保存在数据库中。我们知道我们应该只保存 Order
和 OrderLines
。另一方面,EF 不知道这一点。这就是 AttachByIdValue
接受 Child 类型数组的方式,这些类型应该与 Order
一起附加以进行保存。图中所有不是根类型或不是 Child 类型的对象都将被附加到上下文中,但将被标记为 Unchanged
,因此它们不会被保存到数据库中。
要仅保存 Order
(没有 OrderLines
),我们应该调用
myContext.AttachByIdValue(Order, null);
myContext.SaveChanges();
因此,要保存 Order
和 OrderLines
,我们应该调用
myContext.AttachByIdValue(Order, new HashSet<Type>() { typeof(OrderLine) });
myContext.SaveChanges();
当然,上面的 HashSet<Type>
可以缓存在 static
字段中,以避免在每次附加对象时调用 typeof
。
private static readonly HashSet<Type> OrderChildTypes = new HashSet<Type>() { typeof(OrderLine) };
...
myContext.AttachByIdValue(Order, OrderChildTypes);
myContext.SaveChanges();
最终解决方案
/// <summary>
/// Attaches entity graph to context using entity id to determinate if entity is new or modified.
/// If Id is zero, then entity is treated as NEW and otherwise, it is treated as modified.
/// If we want to save more than just root entity, then child types must be supplied.
/// If entity in graph is not root nor of child type it will be attached but not saved
/// (it will be treated as unchanged).
/// </summary>
/// <param name="context">The context.</param>
/// <param name="rootEntity">The root entity.</param>
/// <param name="childTypes">The child types that should be saved with root entity.</param>
public static void AttachByIdValue<TEntity>(this DbContext context,
TEntity rootEntity, HashSet<Type> childTypes)
where TEntity : class, IEntity
{
// mark root entity as added
// this action adds whole graph and marks each entity in it as added
context.Set<TEntity>().Add(rootEntity);
// in case root entity has id value mark it as modified (otherwise it stays added)
if (rootEntity.Id != 0)
{
context.Entry(rootEntity).State = EntityState.Modified;
}
// traverse all entities in context (hopefully they are all part of graph we just attached)
foreach (var entry in context.ChangeTracker.Entries<IEntity>())
{
// we are only interested in graph we have just attached
// and we know they are all marked as Added
// and we will ignore root entity because it is already resolved correctly
if (entry.State == EntityState.Added && entry.Entity != rootEntity)
{
// if no child types are defined for saving then just mark all entities as unchanged)
if (childTypes == null || childTypes.Count == 0)
{
entry.State = EntityState.Unchanged;
}
else
{
// request object type from context
// because we might have got reference to dynamic proxy
// and we wouldn't want to handle Type of dynamic proxy
Type entityType = ObjectContext.GetObjectType(entry.Entity.GetType());
// if type is not child type than it should not be saved so mark it as unchanged
if (!childTypes.Contains(entityType))
{
entry.State = EntityState.Unchanged;
}
else if (entry.Entity.Id != 0)
{
// if entity should be saved with root entity
// then if it has id mark it as modified
// else leave it marked as added
entry.State = EntityState.Modified;
}
}
}
}
}
一个小陷阱
正如我之前解释的,EF 坚持所有现有实体都应该有唯一的主键,这就是为什么你不能将两个具有相同 Id 的相同类型的未更改对象附加到 DbContext
。通常情况下不应该出现这种情况,但我发现了一个可能发生的极端情况。假设我们正在加载 Order
、OrderLines
和 Products
,并且我们有两个不同的 Order Lines 指向同一个 Product。通常,EF 会将对同一个 Product
对象的引用设置到这些 OrderLines
,除非你使用 AsNoTracking加载你的数据以获得更好的性能,在这种情况下,每个 OrderLine
都会获得对单独的 Product
对象的引用,该对象在所有值上都是相等的。我没有在任何地方找到这种行为的文档,当我努力将对象附加到 DBContext
时,我偶然发现了它。