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
链接
相关讨论
麒麟的帖子引发了不少讨论,挺有趣的:
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对象,不小心反而会造成内存不必要的占用。
All the posts in this blog are provided "AS IS" with no warranties, and confer no rights. Except where otherwise noted, content on this site is licensed under a Creative Commons Attribution 2.5 China Mainland License.