這應該是近期最後一篇 Entity Framework 的相關筆記,明最近在研究 WSL,結果筆記都是 Entity Framework,搞得我好像我近期和 Entity Framework 槓上一樣。
這本來是要拆兩篇寫,但內容相關性較高,我又有點懶,就併到同一篇寫。
Entity Framework 常見的 Exception 有以下三個:
RowVersion
或 ConcurrencyCheck
特性時發生,這些特性用於實現並發控制。當 EF 發現資料庫中的資料已被其他操作修改,而當前操作的資料版本與之不符合時,就會拋出這個 Exception。SaveChanges()
且 Entity 的驗證失敗時,所拋出的 Exception。這個 Exception 通常用於捕捉 Entity 的資料驗證錯誤,例如屬性值不符合資料註釋(如 [Required]
、[MaxLength]
)的要求。已於在 Entity Framework Core 中被移除。之前在處理 Entity Framework 的錯誤訊息時,我發現找不到 DbEntityValidationException
,剛剛查才發現這個例外已經被移除,說實話有點意外。至於被移除的可能原因,可以參考保哥的「EF Core 已經不會在 SaveChanges() 的時候對實體模型進行額外驗證」。
雖然我認為 Model Binding 的驗證和 Entity 的驗證應該要分開來看,但仔細想想,Entity 的驗證的好處是在寫入資料庫前進行檢核,這樣可以減少一些開銷。但在實務上 Model Binding 和 Service Layer 的驗證已經能夠擋掉大部分情境,因此用到 Entity 驗證的機會確實不多。
說一句老實話,每次看到 EF Exception 的訊息都會很困擾,例如可能會看到以下訊息:
DbUpdateException
訊息:An error occurred while saving the entity changes. See the inner exception for details.
DbEntityValidationException
訊息:一個或多個實體的驗證失敗。如需詳細資料,請參閱 'EntityValidationErrors' 屬性。
鬼才知道是什麼原因,變成需要針對這幾個 Exception 做特別處理。 如何處理 Entity Exception,主要取決於發生 Exception(這裡指的是全部的 Exception)時,前端是否會看到 Exception 的錯誤訊息:
InnerException
或 EntityValidationErrors
中提取完整的錯誤訊息並記錄到 Log 中。這樣既能保證 Log 中有詳細的錯誤信息,也能讓前端僅看到原本的攏統訊息。SaveChanges()
方法來捕獲 Entity Exception,並重新拋出一個相同類型的 Exception,將 Message
設為完整的錯誤訊息。這樣 Exception Log 也不需要額外處理,讓錯誤處理和權責劃分更為清楚。在資料處理過程中,通常會藉由資料庫的資料檢核來確保不會寫入異常資料,或依賴預設值來避免因為資料遺漏而導致的錯誤。但以為的想法來說,不應過度依賴資料庫檢核或預設值,因為這可能引發預期外的問題。這小節的內容正是源自於我多年前犯下的一個錯誤經驗。
當時的排程程式使用 ADO.NET 進行資料寫入,開發人員為了省事,在寫入資料前並未檢查是否有重複資料,而是依賴主鍵來阻擋重複資料。當我將這段程式碼翻寫成 Entity Framework 時,也延續了這種處理方式。根據之前的「淺談 Entity Framework 的導覽屬性與外鍵的同步更新」中提到,SaveChanges()
執行失敗時,Entity 狀態會保留。這代表,如果第一次資料的 SaveChanges()
失敗,當嘗試新增第二筆資料並再次呼叫 SaveChanges()
時,產生的 SQL 語句會包含第一次的資料。因此,一旦發生一次失敗,後續的所有變更也將一同失敗。
當然 SaveChanges()
失敗後保留 Entity 狀態時,這在某些情況下會有幫助的,例如因網路不穩定導致的失敗,允許重試 SaveChanges()
。我曾見過一些專案中,當 SaveChanges()
失敗時會自動重試最多三次,直到成功或放棄。但如果在特定情境下不希望失敗的變更被保留,可以考慮覆寫 SaveChanges()
,並在捕捉到 DbUpdateException
時,還原 Entity 的狀態,以忽略該次異動。
在是否應該設定預設值這個問題上,業界有不同的觀點,主要可以分為兩種:
NOT NULL
並且不設定預設值,這樣在資料未正確存值時,程式會立即報錯,幫助開發者及早發現並修正潛在的問題,避免隱藏錯誤的風險。兩種作法的設計思路不同,也不能說誰對誰錯,但如果團隊沒特別要求,我個人傾向於第二種做法。
需注意,SaveChanges()
失敗後還原 Entity State 的方法僅適用於不含外鍵的 Entity 結構。具體原因將在後續說明。
這邊我就偷懶,把兩個小節的程式碼合併寫在一起。由於 EF Core 已經移除了 DbEntityValidationException
,因此這部分我就不再處理了。
Entity State 的處理如以下表格:
State | 狀態說明 | 處理方式 |
---|---|---|
Detached | 未加入追蹤。 | 不需處理。 |
Unchanged | 從資料庫取得且未異動的資料。 | 不需處理。 |
Deleted | 從資料庫取得,且使用 Remove 移除 。 |
把 State 改為 Unchanged 。 |
Modified | 從資料庫取得,且有異動過屬性。 | 把 State 改為 Unchanged ,且使用 entry.CurrentValues.SetValues(entry.OriginalValues) 還原資料。 |
Added | 僅存在本地端的資料。 | 把 State 改為 Detached 。 |
如果透過導覽屬性加入 Entity 時,該 Entity 也會被加入追蹤。所以問題比較有可能出現在移除關聯的情況下。因此,使用以下測試程式來測試移除關聯的情境:
Entity 結構如下:
資料庫現存資料如下: Table1
Id |
---|
1 |
2 |
3 |
Table2
Id |
---|
1 |
2 |
TableRef
Table1Id | Table2Id |
---|---|
1 | 1 |
2 | 2 |
使用以下程式碼進行測試:
執行結果如下:
從結果來看,當從導覽屬性增加或刪除關聯時,並不會影響到 Entity State,但是實際上還是會產生一筆關聯的 EntityEntry
,但是將關聯的 EntityEntry.State
還原時,僅會還原 Add()
的異動,而不會處理 Remove()
的部分。
有關 TestEFContext
的第 50 行處理 EntityState.Deleted
情境,以下針對 TableRef 的 Entity State 的處理方式不同來說明結果差異。
EntityState.Unchanged
:
TableRef 中將有兩筆資料,這與 Remove()
前的狀況相同,這結果才是正確的。EntityState.Detached
:
TableRef 只有一筆,缺少 TableRef (Dictionary<string, object>) {Table1Id: 1, Table2Id: 1} Unchanged FK {Table1Id: 1} FK {Table2Id: 1}
。table11.Table2s.Count
:兩者皆為 0。Table1.Id
為 1
的資料:
EntityState.Unchanged
:
Table2s.Count
依然是 0。推測是和「EF Core DbContext 緩存特性實驗」、「查詢的運作方式。」所提到的 DbContext 快取機制有關。雖然會從資料庫查詢資料,但因為 DbContext 已存在該資料且已追蹤,因此直接回傳 DbContext 裡的 Entity。話說這我怎看都覺得像 Bug…。EntityState.Detached
:Table2s.Count
會是 1,導覽屬性成功從資料庫重新取得資料。雖然使用上來看,設為 EntityState.Detached
結果會一好點,但實際上都有問題,因此不建議在有外鍵的情況下使用 Entity 狀態還原。
使用 DbSet.Add()
加入與已查詢資料具有相同 PK 的 Entity 時,會拋出 InvalidOperationException
。由於 Exception 是在 Add()
時拋出,而非在 SaveChanges()
,因此不會被現有的錯誤處理機制捕獲。
.NET
.NET Core & .NET 5+
Entity Framework
C#