在學習LINQ時,我幾乎被一個困難所擊倒,這就是你從標題中看到的更新資料庫的操作。下面我就一步步帶你走入這泥潭,請準備好磚頭和口水,Follow me。
從最簡單的情況入手我們以Northwind資料庫為例,當需要修改一個產品的ProductName時,可以在客戶端直接寫下這樣的程式碼:
// List 0NorthwindDataContext 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();
}還是Exception 1的錯誤!
我就倒! 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適合小型系統的開發,但小型不代表不分層。有沒有辦法避免這麼多的異常發生呢?
本文其實已經給了一些線索,在本系列的下一篇隨筆中,我將嘗試著提供幾個解決方案供大家選擇。