LINQ那些事(9)-解析Table<T>.Attach引发的异常和解决方法

起因主要是因为看到博客园又有朋友开始讨论LINQ2SQL的问题,这次说的是Attach。通过解读Attach,可以发现LINQ2SQL内部是如何维护和跟踪对象实例、如何实现延迟加载,并且还可以引发关于延迟加载和N-Tier Application中LINQ2SQL的应用技巧的讨论。本文所讨论内容适用于.Net Framework 3.5版本的LINQ2SQL,所使用数据库是Northwnd。

对于对象添加和删除操作,LINQ2SQL在Table<T>类定义中直接提供了InsertOnSubmit()/DeleteOnSubmit()。而对于对象的更新,由于LINQ2SQL中采取了对象跟踪的机制(可参考LINQ2SQL对象生命周期管理),所以我们在修改了对象属性后无需显式通知DataContext,当调用DataContext.SubmitChanges()时会自动的把我们所做的修改提交到数据库保存。这种基于上下文的操作是非常方便的,否则在代码中会出现大量的Update调用,但是也存在限制——只有在同一个DataContext对象的作用域内,对象所做的修改才会在SubmitChanges()时得到保存。如:

            using (var context = new Northwnd())
            {
                var customer = context.Customers.First();
                customer.City = "Beijing";
                context.SubmitChanges();
            }

而在Web和N-Tier Application开发时,数据查询和更新同在一个DataContext中往往得不到满足,所以LINQ2SQL在Table<T>类定义了Attach方法,用于把已与查询DataContext上下文断开的对象关联到Table所属的DataContext对象,这样就可以通过新的DataContext执行对象的更新操作。如:

            Customer customer = null;
	
            using (var context1 = new Northwnd())
            {
                customer = context1.Customers.First();
            }

            customer.City = "Beijing";

            using (var context = new Northwnd())
            {
                context.Customers.Attach(customer);
                context.SubmitChanges();
            }

但是问题来了,这段代码执行错误,抛出以下异常:

System.NotSupportedException: An attempt has been made to Attach or Add an entity that is not new, perhaps having been loaded from another DataContext.  This is not supported.

 

这个问题已经不是一个新鲜的问题了,google一下有很多的解决方法,但这看起来很正常的代码为什么会抛出异常呢?其实还是和DataContext的作用域有关的,本文尝试剖析这个问题,然后还会讨论在N-Tier Application中使用LINQ2SQL的一些须知技巧。

都是Association惹的祸?

出错的地方在System.Data.Linq.Table<T>.Attach(TEntity entity, bool asModified),下列条件只要满足其中一个,就会造成Attach调用失败:

1、DataContext为只读,相当于把DataContext.ObjectTrackingEnabled=false。只读的DataContext只能进行数据的查询,其他的操作都会抛出异常。

2、当调用Attach时,DataContext正在执行SubmitChanges()操作。

3、DataContext对象已经被显式调用Dispose()销毁。

4、asModified为true,但TEntity类的属性映射信息(MetaType)中包含UpdateCheck=WhenChanged或UpdateCheck=Always设置。这是因为Attach的对象在当前DataContext中并没有原始值的记录,DataContext无法根据UpdateCheck的设置生成where字句以避免并发冲突。需要说明的是几乎不会用到asModified=true的调用,尤其是在对查询显示-用户修改-提交保存这样的Web应用场景,本文稍后会讨论这样的场景如何操作。如果坚持要用asModified=true的调用,那么可以在TEntity类增加RowVersion属性的定义,LINQ2SQL引入RowVersion就是为了提供除UpateCheck以外的另一个冲突检测的方法,由于RowVersion应该在每一次更新操作后都应该修改,所以一般对应Timestamp类型。

5、尝试Attach一个已属于当前DataContext上下文的对象。

6、尝试Attach的对象包含未载入的Assocation属性,或是未载入的嵌套Association属性。

其中原因(6)属于本文的讨论内容,我们来看看Attach函数中的调用

...
if (trackedObject == null)
    {
        trackedObject = this.context.Services.ChangeTracker.Track(entity, true);
    }
...

Attach函数中调用StandardChangeTracker.Track(TEntity entity, bool recursive)方法,请注意第二个参数表示递归,Attach调用Track(entity, true)会导致entity的所有嵌套Association属性都会被检查。代码就是在StandardChangeTracker.Track中抛出了异常:

...
if (trackedObject.HasDeferredLoaders)
{
	throw Error.CannotAttachAddNonNewEntities();
}
....

 

再看看trackedObject.HasDeferredLoaders做了什么:

internal override bool HasDeferredLoaders
{
    get
    {
        foreach (MetaAssociation association in this.Type.Associations)
        {
            if (this.HasDeferredLoader(association.ThisMember))
            {
                return true;
            }
        }
        foreach (MetaDataMember member in from p in this.Type.PersistentDataMembers
            where p.IsDeferred && !p.IsAssociation
            select p)
        {
            if (this.HasDeferredLoader(member))
            {
                return true;
            }
        }
        return false;
    }
}

很快就要找到关键点了,在看看this.HasDeferredLoader:

private bool HasDeferredLoader(MetaDataMember deferredMember)
{
    if (!deferredMember.IsDeferred)
    {
        return false;
    }
    MetaAccessor storageAccessor = deferredMember.StorageAccessor;
    if (storageAccessor.HasAssignedValue(this.current) || storageAccessor.HasLoadedValue(this.current))
    {
        return false;
    }
    IEnumerable boxedValue = (IEnumerable) deferredMember.DeferredSourceAccessor.GetBoxedValue(this.current);
    return (boxedValue != null);
} 

答案揭晓:storageAccessor.HasAssignedValue检测了Association属性是否被赋值(针对EntityRef),storageAccessor.HasLoadedValue检测了Association属性是否已被加载(针对EntitySet),如果没有任何的赋值或加载,并且由GetBoxedValue获取的延迟源对象(DeferredSource)不为空,则抛出异常。

 

要解释Attach为什么在这种情况下会抛出异常?首先要弄明白延迟源对象,这是LINQ2SQL实现延迟加载的关键。在延迟加载的模式(DataContext.DeferredLoading=true)下,EntitySet和EntityRef属性只有当被访问时,才会产生数据库的查询。以EntitySet为例,当调用GetEnumerator()时:

public IEnumerator<TEntity> GetEnumerator()
{
    this.Load();
    return new Enumerator<TEntity>((EntitySet<TEntity>) this);
}

this.Load中调用了延迟源进行数据的加载:

public void Load()
{
    if (this.HasSource)
    {
        ItemList<TEntity> entities = this.entities;
        this.entities = new ItemList<TEntity>();
        foreach (TEntity local in this.source)
        {
            this.entities.Add(local);
        }
...
    }
}

再进一步就要追溯到System.Data.Linq.CommonDataServices.GetDeferredSourceFactory(MetaDataMember)和System.Data.Linq.Mapping.EntitySetValueAccessor。当DataContext对象初始化模型信息时,会调用GetDeferredSourceFactory为指定属性生成相应的DeferredSourceFactory对象,该工厂对象通过CreateDeferredSource()生成延迟源对象。在执行查询操作时,DataContext将会调用每个对象的EntitySet属性的SetSource方法,为每一个EntitySet绑定延迟源,由延迟源来调用DataContext实现延迟加载,这样就实现了EntitySet和DataContext的解耦,让POCO类也变智能了。对于EntitySet,当执行延迟加载后,延迟源将被清空,并且相应的已加载标志也将设为true。

接下来我们验证一下,为了方便示例我只保留Customer类的Orders作为唯一的Association属性:(文章最后会给出代码下载,有兴趣可以照着验证)

            Customer customer = null;
            
            using (var context = CreateNorthwnd())
            {
                customer = context.Customers.First();
                // forces to load order association
                customer.Orders.Count.Dump();
            }

            customer.City = "Beijing";

            using (var context = CreateNorthwnd())
            {
                context.Customers.Attach(customer);
                context.SubmitChanges();
            }

别急,还是错的!虽然customer.Orders.Count的调用让customer.Orders被加载,但Order对象还包含几个未被加载的Association属性,你把Order对象的Association属性定义去掉就对了!

剖析到这里你明白为什么当存在Association或嵌套Association未被赋值或加载,且延迟源不为空时会抛出异常了么?这是因为和需要Attach的对象一样,延迟源关联的DataContext对象已经被销毁了,延迟源无法在加载数据,所以DataContext拒绝关联这样的对象。

说了那么多,是为了让大家能够明白为什么会产生异常,解决的方法很简单,不需要修改实体的定义,同时也是个人认为LINQ2SQL最佳实践之一:

            Customer customer = null;

            using (var context = CreateNorthwnd())
            {
                var option = new DataLoadOptions();
                // disabled the deferred loading
                context.DeferredLoadingEnabled = false;
                // specify the association in needed
                option.LoadWith<Customer>(c => c.Orders);
                context.LoadOptions = option;

                customer = context.Customers.First();
            }

            customer.City = "Beijing";

            using (var context = CreateNorthwnd())
            {
                context.Customers.Attach(customer);
                context.Refresh(RefreshMode.KeepCurrentValues, customer);
                context.SubmitChanges();
            }

首先我们关闭了DataContext的延迟加载,并且通过DataLoadOption显式指定了需要加载的关联数据,这样的做法不但解决Attach的问题,而且还避免了在N-Tier Application中由于延迟加载所可能导致的异常。

LINQ2SQL最佳实践

文章最后罗列一些我自己总结的应用LINQ2SQL的心得。

1、建议通过using来使用DataContext对象,这样当操作完毕立即销毁DataContext对象。默认状态下(ObjectTrackingEnabled=true),DataContext将在内存中保存查询对象的副本,如果长时间保持DataContext对象,会造成内存不必要的占用。

2、对于仅查询的操作,设ObjectTrackingEnabled=false,关闭对象跟踪有助于提高DataContext的查询性能,这也是所谓的只读DataContext。再根据所需数据,设置DataLoadOption.AssociateWith()、DataLoadOption.LoadWith(),可实现高效的查询。

3、当用LINQ2SQL编写N-Tier Application时,建议关闭延迟加载,因为这带来的麻烦远远大于好处。注意当ObjectTrackingEnabled=false时,延迟加载是不可用的,相当于DataContext.DeferredLoading=false。

4、Attach(entity, false) + DataContext.Refresh(RefreshMode.KeepCurrentValues, entity) + SubmitChanges(),实现断开对象的更新,这就避免了DuplicateKey的问题,如上面给出的代码所示。

下载

Demo Code: https://files.cnblogs.com/chwkai/LinqAttach.rar

链接

LINQ那些事(总)

LINQ那些事(6) - 对象生命周期管理

相关讨论

麒麟的帖子引发了不少讨论,挺有趣的:

1、“方法签名中不出现linq to sql的实体,方法代码块中肯定要出现的。我看人家的开源项目都是在访问数据库的时候再将DomainModel转化为linq to sql的Entity,这样使用的linq to sql。”

对于N-Tier Application,DomainModel(领域对象)的应用范围在Presentation Layer和Business Layer之间的层次,而LINQ生成的POCO类属于DataModel,应用范围在整个N-Tier。在概念上DomainModel和DataModel是不同的,但是在大多数的3-tier应用中,DomainModel和DataModel是同一个类——实体类。至于为什么“人家开源项目”会这样做,我想大多数原先并不是用LINQ开发,后来移植过来的吧?

2、“先根据传入product对象的id,查询出原始的product,然后利用反射自动copy新属性”

DataContext专门提供了Refresh()函数,可以读取entity的数据库值,再通过指定的RefreshMode来刷新entity的当前值或原始值。在更新entity前,我们首先需要Attach对象,因为通过DataContext.Refresh(RefreshMode.KeepCurrentValues, entity)可获得entity的原始值,把判断entity是否已更改的工作交给DataContext,所以我们只需要调用Attach(entity)或Attach(entity, false),而不需要调用Attach(entity, true)或Attach(entity, originalEntity),更不需要”copy”了。

3、“楼主的NorthwindDataContext实例化太厉害了,要知道datacontext是个很大的对象,应该避免不停地实例化。最好是一次request只有一个实例,你的问题就迎刃而解了。”

在LINQ2SQL的Design Intent有说过,LINQ2SQL的应用模式是“Unit of work”,即创建-调用-销毁,目的就是为了在调用完毕后快速释放DataContext由于保存对象副本和SQL连接所占用的资源,DataContext提供了足够的机制来保证实例化的消耗在可以接受的范围。但如果在一次http request里keep住DataContext对象,不小心反而会造成内存不必要的占用。

posted @ 2010-01-21 09:08  海南K.K  阅读(6984)  评论(7编辑  收藏  举报