使用LINQ to SQL更新数据库(上):问题重重
在学习LINQ时,我几乎被一个困难所击倒,这就是你从标题中看到的更新数据库的操作。下面我就一步步带你走入这泥潭,请准备好砖头和口水,Follow me。
从最简单的情况入手
我们以Northwind数据库为例,当需要修改一个产品的ProductName时,可以在客户端直接写下这样的代码:
// List 0
NorthwindDataContext db = new NorthwindDataContext(); Product product = db.Products.Single(p => p.ProductID == 1); product.ProductName = "Chai Changed"; db.SubmitChanges();
测试一下,更新成功。不过我相信,在各位的项目中不会出现这样的代码,因为它简直没法复用。好吧,让我们对其进行重构,提取至一个方法中。参数应该是什么呢?是新的产品名称,以及待更新的产品ID。嗯,好像是这样的。
public void UpdateProduct(int id, string productName) { NorthwindDataContext db = new NorthwindDataContext(); Product product = db.Products.Single(p => p.ProductID == id); product.ProductName = productName; db.SubmitChanges(); }
在实际的项目中,我们不可能仅仅只修改产品名称。Product的其他字段同样也是修改的对象。那么UpdateProduct方法的签名将变成如下的形式:
public void UpdateProduct(int id, string productName, int suplierId, int categoryId, string quantityPerUnit, decimal unitPrice, short unitsInStock, short unitsOnOrder, short reorderLevel)
当然这只是简单的数据库,在实际项目中,二十、三十甚至上百个字段的情况也不少见。谁能忍受这样的方法呢?这样写,还要Product对象干什么呢?
对啊,把Product作为方法的参数,把恼人的赋值操作抛给客户代码吧。同时,我们将获取Product实例的代码提取出来,形成GetProduct方法,并且将与数据库操作相关的方法放到一个专门负责和数据库打交道的ProductRepository类中。哦耶,SRP!
// List 1
// ProductRepository public Product GetProduct(int id) { NorthwindDataContext db = new NorthwindDataContext(); return db.Products.SingleOrDefault(p => p.id == id); } public void UpdateProduct(Product product) { NorthwindDataContext db = new NorthwindDataContext(); db.Products.Attach(product); db.SubmitChanges(); } // Client code ProductRepository repository = new ProductRepository(); Product product = repository.GetProduct(1); product.ProductName = "Chai Changed"; repository.UpdateProduct(product);
在这里我使用了Attach方法,将Product的一个实例附加到其他的DataContext上。对于默认的Northwind数据库来说,这样做的结果就是得到下面的异常:
// Exception 1
NotSupportException: 已尝试Attach或Add实体,该实体不是新实体,可能是从其他DataContext中加载来的。不支持这种操作。 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
查看MSDN我们知道,在将实体序列化到客户端时,这些实体会与其原始DataContext分离。DataContext不再跟踪这些实体的更改或它们与其他对象的关联。这时如果要更新或者删除数据,则必须在调用SubmitChanges之前使用Attach方法将实体附加到新的DataContext中,否则就会抛出上面的异常。
而在Northwind数据库中,Product类包含三个与之相关的类(即外键关联):Order_Detail、Category和Supllier。在上面的例子中,我们虽然把Product进行了Attach,但却没有Attach与其相关联的类,因此抛出NotSupportException。
那么如何关联与Product相关的类呢?这看上去似乎十分复杂,即便简单地如Northwind这样的数据库亦是如此。我们似乎必须先获取与原始Product相关的Order_Detail、Category和Supllier的原始类,然后再分别Attach到当前的DataContext中,但实际上即使这样做也同样会抛出NotSupportException。
那么究竟该如何实现更新操作呢?为了简便起见,我们删除Northwind.dbml中的其他实体类,只保留Product。这样就可以从最简单的情况开始入手分析了。
问题重重
删除其他类之后,我们再次执行List 1中的代码,然而数据库并没有更改产品的名称。通过查看Attach方法的重载版本,我们很容易发现问题所在。
Attach(entity)方法默认调用Attach(entity, false)重载,它将以未修改的状态附加相应实体。如果Product对象没有被修改,那么我们应该调用该重载版本,将Product对象以未修改的状态附加到DataContext,以便后续操作。而此时的Product对象的状态是“已修改”,我们只能调用Attach(entity, true)方法。
于是我们将List 1的相关代码改为Attach(product, true),看看发生了什么?
// Exception 2
InvalidOperationException: 如果实体声明了版本成员或者没有更新检查策略,则只能将它附加为没有原始状态的已修改实体。 An entity can only be attached as modified without original state if it declares a version member or does not have an update check policy.
LINQ to SQL使用RowVersion列来实现默认的乐观式并发检查,否则在以修改状态向DataContext附加实体的时候,就会出现上面的错误。实现RowVersion列的方法有两种,一种是为数据库表定义一个timestamp类型的列,另一种方法是在表主键所对应的实体属性上,定义IsVersion=true特性。注意,不能同时拥有TimeStamp列和IsVersion=true特性,否则将抛出InvalidOprationException:成员“System.Data.Linq.Binary TimeStamp”和“Int32 ProductID”都标记为行版本。在本文中,我们使用timestamp列来举例。
为Products表建立名为TimeStamp、类型为timestamp的列之后,将其重新拖拽到设计器中,然后执行List 1中的代码。谢天谢地,终于成功了。
现在,我们再向设计器中拖入Categories表。这次学乖了,先在Categories表中添加timestamp列。测试一下,居然又是Exception 1中的错误!删除Categories的timestamp列,问题依旧。天哪,可怕的Attach方法里究竟干了什么?
哦,对了,Attach方法还有一个重载版本,我们来试一下吧。
public void UpdateProduct(Product product) { NorthwindDataContext db = new NorthwindDataContext(); Product oldProduct = db.Products.SingleOrDefault(p => p.ProductID == product.ProductID); db.Products.Attach(product, oldProduct); db.SubmitChanges(); }
我就倒!Attach啊Attach,你究竟怎么了?
探索LINQ to SQL源代码
我们使用Reflector的FileDisassembler插件,将System.Data.Linq.dll反编译成cs代码,并生成项目文件,这有助于我们在Visual Studio中进行查找和定位。
什么时候抛出Exception 1?
我们先从System.Data.Linq.resx中找到Exception 1所描述的信息,得到键“CannotAttachAddNonNewEntities”,然后找到System.Data.Linq.Error.CannotAttachAddNonNewEntities()方法,查找该方法的所有引用,发现在两个地方使用了该方法,分别为StandardChangeTracker.Track方法和InitializeDeferredLoader方法。
我们再打开Table.Attach(entity, bool)的代码,不出所料地发现它调用了StandardChangeTracker.Track方法(Attach(entity, entity)方法中也是如此):
trackedObject = this.context.Services.ChangeTracker.Track(entity, true);
在Track方法中,抛出Exception 1的是下面的代码:
if (trackedObject.HasDeferredLoaders) { throw System.Data.Linq.Error.CannotAttachAddNonNewEntities(); }
于是我们将注意力转移到StandardTrackedObject.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; } }
从中我们大致可以推出,只要实体中存在延迟加载的项时,执行Attach操作就会抛出Exception 1。这正好符合我们发生Exception 1的场景——Product类含有延迟加载的项。
那么避免该异常的方法也浮出水面了——移除Product中需要延迟加载的项。如何移除呢?可以使用DataLoadOptions立即加载,也可以将需要延迟加载的项设置为null。但是第一种方法行不通,只好使用第二种方法了。
// List 2 class ProductRepository { public Product GetProduct(int id) { NorthwindDataContext db = new NorthwindDataContext(); return db.Products.SingleOrDefault(p => p.ProductID == id); } public Product GetProductNoDeffered(int id) { NorthwindDataContext db = new NorthwindDataContext(); //DataLoadOptions options = new DataLoadOptions(); //options.LoadWith<Product>(p => p.Category); //db.LoadOptions = options; var product = db.Products.SingleOrDefault(p => p.ProductID == id); product.Category = null; return product; } public void UpdateProduct(Product product) { NorthwindDataContext db = new NorthwindDataContext(); db.Products.Attach(product, true); db.SubmitChanges(); } } // Client code ProductRepository repository = new ProductRepository(); Product product = repository.GetProductNoDeffered(1); product.ProductName = "Chai Changed"; repository.UpdateProduct(product);
什么时候抛出Exception 2?
按照上一节的方法,我们很快找到了抛出Exception 2的代码,幸运的是,整个项目中只有这一处:
if (asModified && ((inheritanceType.VersionMember == null) && inheritanceType.HasUpdateCheck)) { throw System.Data.Linq.Error.CannotAttachAsModifiedWithoutOriginalState(); }
可以看到,当Attach的第二个参数asModified为true、不包含RowVersion列(VersionMember=null)、且含有更新检查的列(HasUpdateCheck)时,会抛出Exception 2。HasUpdateCheck的代码如下:
public override bool HasUpdateCheck { get { foreach (MetaDataMember member in this.PersistentDataMembers) { if (member.UpdateCheck != UpdateCheck.Never) { return true; } } return false; } }
这也符合我们的场景——Products表没有RowVersion列,并且设计器自动生成的代码中,所有字段的UpdateCheck特性均为默认的Always,即HasUpdateCheck属性为true。
避免Exception 2的方法就更简单了,为所有表都添加TimeStamp列或对所有表的主键字段上设置IsVersion=true字段。由于后一种方法要修改自动生成的类,并随时都会被新的设计所覆盖,因此我建议使用前一种方法。
如何使用Attach方法?
经过上面的分析,我们可以找出与Attach方法相关的两个条件:是否有RowVersion列以及是否存在外键关联(即需要延迟加载的项)。我将这两个条件与Attach的几个重载使用的情况总结出了一个表,在看下面这个表时,你需要做好充分的心理准备。
序号 |
Attach方法 |
RowVersion列 |
是否有关联 |
描述 |
1 | Attach(entity) | 否 | 否 | 没有修改 |
2 | Attach(entity) | 否 | 是 | NotSupportException: 已尝试Attach或Add实体,该实体不是新实体,可能是从其他DataContext中加载来的。不支持这种操作。 |
3 | Attach(entity) | 是 | 否 | 没有修改 |
4 | Attach(entity) | 是 | 是 | 没有修改。如果子集没有RowVersion列则与2一样。 |
5 | Attach(entity, true) | 否 | 否 | InvalidOperationException:如果实体声明了版本成员或者没有更新检查策略,则只能将它附加为没有原始状态的已修改实体。 |
6 | Attach(entity, true) | 否 | 是 | NotSupportException: 已尝试Attach或Add实体,该实体不是新实体,可能是从其他DataContext中加载来的。不支持这种操作。 |
7 | Attach(entity, true) | 是 | 否 | 正常修改(强制修改RowVersion列会报错) |
8 | Attach(entity, true) | 是 | 是 | NotSupportException: 已尝试Attach或Add实体,该实体不是新实体,可能是从其他DataContext中加载来的。不支持这种操作。 |
9 | Attach(entity, entity) | 否 | 否 |
DuplicateKeyException:不能添加其键已在使用中的实体。 |
10 | Attach(entity, entity) | 否 | 是 | NotSupportException: 已尝试Attach或Add实体,该实体不是新实体,可能是从其他DataContext中加载来的。不支持这种操作。 |
11 | Attach(entity, entity) | 是 | 否 |
DuplicateKeyException:不能添加其键已在使用中的实体。 |
12 | Attach(entity, entity) | 是 | 是 | NotSupportException: 已尝试Attach或Add实体,该实体不是新实体,可能是从其他DataContext中加载来的。不支持这种操作。 |
Attach居然只能在第7种情况(包含RowVersion列并且无外键关联)时才能正常更新!而这种情况对于一个基于数据库的系统来说,几乎不可能出现!这是一个什么样的API啊?
总结
让我们平静一下心情,开始总结吧。
如果像List 0那样,直接在UI里写LINQ to SQL代码,则什么不幸的事也不会发生。但是如果要抽象出一个单独的数据访问层,灾难就会降临。这是否说明LINQ to SQL不适合多层架构的开发?很多人都说LINQ to SQL适合小型系统的开发,但小型不意味着不分层啊。有没有什么办法避免这么多的异常发生呢?
本文其实已经给出了一些线索,在本系列的下一篇随笔中,我将尝试着提供几种解决方案供大家选择。