2013/10/15

在 VS2013 以 Code First 方式建立 EF 資料庫

當你下載並安裝 Visual Studio 2013 Preview 之後, 要如何建立一個使用 Entity Framework 的專案呢? 在以下文章裡, 我要介紹一個使用 Code First 方式建立的專案。我所將描述的內容可以在以下影片中看到

此外, 你也可以在「Code First 至新的資料庫」這篇文章裡找到相當完整的 Step by step 操作步驟 (中文)。

簡略地講, 上文及影片中提到的步驟有幾下幾項 (這只是 Check List, 細節與補充的部份我會在下面提到) :

  1. 確定你的環境沒問題, 包括 Visual Studio、SQL 等等。我建議你使用 SQL Express 並且把 Management Studio 裝好。
  2. 在 VS2013 下建立專案 (不僅是 ASP.NET, 其它類型專案也是相同的方式)
  3. 從 NuGet 中把最新的 Entity Framework 套件加入
  4. 建立一個資料夾 (例如 "Model") 以存放對應的類別檔案
  5. 參考原文範例以建立類別 (例如 class A)。關聯的類別 (例如 class B) 則請加上修飾詞 virtual; 若是一對多的關聯, 請使用 List<B> B 這種方式來宣告 (也可以使用 ICollection<B> B)。
  6. 建立一個繼承 DbContext 的新類別 (例如 public class MyData : DbContext ), 這個類別的名稱將會被用來取名為資料庫檔案的名稱。如果你想使用不同名稱, 我在後面會講到如何操作。
  7. 在上述類別中加入 DbSet<A> As 屬性 (這裡的 A 指的是上面提到的 class A, 而 As 就是該屬性)。這個屬性的名稱將被用來建立資料表。同樣的, 如果你想使用不同名稱, 我在後面會講到如何操作。
  8. 找到並開啟 Package Manager Console 視窗 (Tools -> Library Package Manager -> Package Manager Console), 執行 Enable-Migrations –EnableAutomaticMigrations命令。啟動自動轉移功能可以幫你省下很多麻煩 (參見下一節說明)。
  9. 參考下方「使用 Connection String」一節的說明以自訂 Connection String; 雖然你可以不需要加入 Connection String 而採用預設的資料庫。
  10. 同上, 執行 Update-Database 指令, EF 會幫你自動把資料庫、索引和關聯欄位等等建立起來。未來, 如果你變動了類別中的屬性, 也是下達這個指令即可自動更新資料庫。但是如果你的資料庫裡面已經有資料, 你可能會看到無法建立的錯誤。那麼最快、最 dirty 的做法, 就是進 Management Studio 中把整個資料庫砍掉 (除非你已經在裡面寫進重要資料)。如果你不想這麼做, 你可以選擇將部份資料表砍掉即可 (原則上就是關聯的表先砍, 被關聯的表後砍), 然後再下一次 Update-Datebase 指令。
  11. 要寫入資料, 你可以在程式中加一個 using (var db = new MyData) {.. } 區段 (這裡的 MyData 就是剛才建立的那個繼承 DbContext 的類別, 也是資料庫的名稱), 然後你就可以使用 db.As 來取得資料表物件 (嚴格地說, 是 DataSet 物件)。使用 db.As.Add(X) 就可以在這個資料表中加入一列 (這裡的 X 是一個類別為 A 的物件實體)。
  12. 將資料寫好之後, 你必須執行 db.SaveChanges() 才能將資料真正地寫進資料庫裡面。
  13. 要從資料庫中取出資料, 你必須同樣在一個 using (var db = new MyData) {.. } 區段中操作, 以 db.As 取得資料表, 使用 var As = from r in db.As.Include("B").Include("C").. 語法把資料表 A 連同關聯的 B、C 等等一併取出來。然後你就可以從 foreach (A a in As) 迴圈裡, 以 A.xx (包括 A.B.xx 和 A.C.xx 等) 取得 A 的資料了。

以上這個 Check List 看似簡略, 如果你以前從來沒踫過 EF, 那麼你還是需要花一些功夫和練習, 才能熟練。以下, 我來做一點額外的補充。

使用 VS2013

雖然一開始介紹大家看的影片和文章都是使用 VS2012 示範的, 但是我試過最新版的 VS2013, 發現各個步驟基本上並沒有不一樣的地方。

我唯一要為上述影片補充說明的, 在於原文第五點「處理模型變更」小節裡所描述的資料庫更新方式。在這一節中, 作者要你輸入 Enable-Migrations 以啟動資料庫的更新功能, 如此你就可以在修改你的類別欄位之後以手動方式更新資料庫。事實上, 你可以改輸入 "Enable-Migrations -EnableAutomaticMigrations" (不含括號), 然後你就不再需要以手動方式輸入 "Add-Migration ChangeDisplayName" 之類的命令。每次當你修改你的類別欄位之後, 只需要執行一次 "Update-Database" 命令就可以了。

當你如上述教學影片建立一個 BlogContext 類別之後, 請注意, 這個 "BlogContext" 將變成你的資料庫名稱。事實上, 你在程式中所有繼承 DbContext 的類別, 都會被拿來建立為資料庫。例如, 如果你建立了一個 A : DbContext 和 B : DbContext 和 C : DbContext 三個類別, 那麼程式會自動幫你建立三個資料庫檔案, 而不是三個資料表!

因此, 如果你並不是想要建立三個資料庫檔案的話, 那麼你應該建立一個繼承 DbContext 的類別就可以, 例如 MyData : DbContext, 然後在這個類別裡加入不同的 DbSet<XX> 屬性; 它們會被建立為同一個資料庫檔案之下的不同資料表。例如:

public class MyData : DbContext
{
    public DbSet<company> Companies { get; set; }
    public DbSet<employee> Employees { get; set; }
    public DbSet<vendor> Vendors { get; set; }
}

然後, EF 就會幫你在 "MyData" 資料庫之下建立 Companies、Employees 和 Vendors 三個資料表。

自訂資料庫檔案名稱

比較討厭的是, 實際上的資料庫名稱是 "MyNameSpace.MyData"; Entity Framework 會連同你所使用的 Namespace 加入資料庫名稱裡面。如果你不喜歡這種名稱, 那麼你可以在類別中加入一個具參數的基礎建構子 (Base Constructor), 如下例所示:

public class MyData : DbContext
{
    public MyData() : base("MyDb") { }

    public DbSet Companies { get; set; }
    public DbSet Employees { get; set; }
    public DbSet Vendors { get; set; }
}

如此一來, 你的資料庫檔案就會被命名為 dbo.MyDb, 使用方式就和其它普通的資料庫一樣了。

不過, 你還是必須透過下達 Update-DataBase 指令, EF 才會去資料庫中進行修改。而且, 你原有的舊資料庫檔案和資料表並不會被自動刪除, 你必須自行予以移除。

使用 Connection String

那麼, 如果你使用的資料庫並不是預設的 SQL Express, 也不是 LocalDB, 而是本機或其它機器上的 MS SQL, 該怎麼設定呢?

你可以使用和 ASP.NET 相同的 Connection String 設定。例如, 假設你使用的專案是 Console Application, 那麼你可以在 App.config 檔案裡 (若是 ASP.NET, 則是 Web.config) 加上 Connection String, 如下示範:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <configSections>
    <section name="entityFramework" type="System.Data.Entity.Internal.ConfigFile.EntityFrameworkSection, EntityFramework, Version=5.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" requirePermission="false" />
  </configSections>
  <startup>
    <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" />
  </startup>
  <connectionStrings>
    <add name="MyData" connectionString="Data Source=MYPC\SQLEXPRESS; Initial Catalog=MyDb; Integrated Security=True"
      providerName="System.Data.SqlClient" />
  </connectionStrings>
  <!--<entityFramework>
    <defaultConnectionFactory type="System.Data.Entity.Infrastructure.SqlConnectionFactory, EntityFramework" />    
  </entityFramework>-->
</configuration>

請注意我在最上面加入的 connectionStrings 區段。C# 程式部份則不必修改 (但仍然要記得加入 public MyData() : base("MyDb") { } 這一行)。還有, 要記得讓 <configSections> 保持為第一個(最上面)區段, 否則在編譯時可能會發生錯誤。

這裡的 Connection String 命名為 MyData, 它必須符合符合 Context 類別的名稱 (亦即 public class MyData : DbContext {} 這個類別名稱)。還有, 原來的 <entityFramework> 區段不再需要了, 所以我把它註解掉。

經過這番修改, EF 就會建立/修改基於你在 App.config / Web.config 中設定的 connectionStrings 區段, 而且把資料庫檔案命名為 MyDb 了。

設定主索引鍵

使用 Code First 讓 EF 自動建立資料庫, EF 會要求你的資料表內必須提供一個唯一值的欄位以作為主索引鍵。因此, 如果你的類別中有一個型別為 int 的屬性, 甚至名稱內有 "id" 字樣, 那麼依照慣例, EF 會自動將它設定為主索引鍵 (Primary Key)。因此, 如果你希望自己控制採用哪個屬性作為主索引鍵, 你最好自行指定, 方法是自行加上 [Key] 這個 attribute。如下例:

using System.ComponentModel.DataAnnotations;
...
public class Person
{
    [Key]
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string DisplayName { get; set; }
}

當資料庫建好之後, 你將發現 EF 果然會把 PersonId 這個屬性指定為 PK。

不過, 你也會發現, EF 不但把 PersonId 設定為 PK, 它也自動設定它為自動遞增其值的 Identity (請參考「Code First 慣例」)。在上例中, 如果你所用的這個屬性並不需要自動遞增 (例如你使用公司的員工編號作為其值), 你應該如何告知 EF 別把它設定為 Identity 呢?

方法很簡單, 再加上 DatabaseGenerated(DatabaseGeneratedOption.None) 這個 attribute 就行了。如下例所示:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
...
public class Person
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string DisplayName { get; set; }
}

經此設定之後, PersonId 就不再是 Identity 了。

除了上面提到的幾個 attribute 之外, 我們還可以使用 [Required] 標示某屬性為 NOT NULL。

如果我們使用 [MaxLength(12)] 標示某字串屬性的最大長度, EF 會將該欄位設定為 nvarchar(12) 而不再是 nvarchar(MAX))。

如果有某個屬性, 你既想讓它成為公開, 又不想讓它寫進成為資料表中的欄位, 那麼你可以加上 NotMapped 這個 attribute。

我們也可以使用 Table 這個 attribute 來指定資料表的命名方式。如下例:

[Table("Employee", Schema = "Company")]
public class Person
{
   ...

如此, 這個資料表會被建立為 "Company.Employee", 而不再是 "Person"。

綜合以上幾個補充, 你可以把原來的程式改寫如下:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
...
[Table("Employee", Schema = "Company")]
public class Person
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    public int PersonId { get; set; }
    [MaxLength(8)]
    public string FirstName { get; set; }
    [MaxLength(8)]
    public string LastName { get; set; }
    [NotMapped]
    public string DisplayName { get; set; }
}

如果你的資料表中有複合鍵 (Composite Primary Keys) 時應如何標示? 假設你有兩個主鍵, 如果你只在這兩個欄位上標明為 [Key], 那麼當你下達 Update-Database 指令時, 程式會跳出錯誤, 說它無法判斷其順序。這時候你必須指定這兩個主鍵的順序, 方式是標注 "Column(Order = 0)" 之類的字樣, 例如:

[DatabaseGenerated(DatabaseGeneratedOption.None)]
[Key, Column(Order = 0)]
public string Key1 { get; set; }
[Key, Column(Order = 1)]
[DatabaseGenerated(DatabaseGeneratedOption.None)]
public string Key2 { get; set; }

依此類推。

其它相關標示可以參考「Code First 資料註解」。

關聯式資料表

EF 可以很方便地幫我們自動建立好關聯式資料表的對應。實際上的做法也很直覺; 事實上, 在最上方的影片中我們已經可以略為看出端倪了。

以下我以程式做示範:

public class Company
{
    [Key]
    public int CompanyId { get; set; }
    public string Name { get; set; }
    public virtual DetailedInfo DetailedInfo {get; set; }
    public virtual List<Person> Employees { get; set; }
}

這個範例需配合我在上面已列的 Person 類別, 以及另一個 DetailedInfo 類別。在這個 DetailedInfo 類別中必須包含一個 CompanyId 欄位以與 Company 類別對應:

public class DetailedInfo
{
    [Key]
    public int CompanyId { get; set; }
    public string Address { get; set; }
    public DateTime DateFounded {get; set; }
}

如上, 當我們在程式中加上 和 ​DetailedInfo 和 Employees 這兩個屬性之後, 並在 Package Manager Console 中下達 Update-Database 指令, EF 會幫我們把 Company 和 DetailedInfo, 以及 Company 和 Person 這兩組資料表產生關聯, 方式是在 Company 和 Person 中分別加入一個 FK 索引 (Foreign Key)。所以, 你會在 Company 資料表中找到一個 DetailedInfo_CompanyId 和在 Person 資料表中找到一個叫做 Company_Company 的欄位。

如此, 我們說 EF 對 Company 跟 DetailedInfo 建立了一個 One to One 關聯, 並在 Company 跟 Person 建立了一個 One to Many 的關聯。

之後, 你就可以從 Context 中取出這兩個屬性, 如同你從 SQL 中下達 JOIN 指令取得資料:

using (var db = new MyData())
{
    Company c = db.Companies;
    DetailedInfo detailedInfo = c.DetailedInfo;
    List<person> employees = c.Employees;
}

在上例中, 如果你把 c 物件 (亦即 Company 資料表) 傳到外面的話, 你會無法以 c.DetailedInfo 語法取得資料; 在執行期間會出現例外, 顯示 DbContext 已不存在, 無法再予存取。

幸好, 除了我在文章最上面提到過的 Include 方法之外, 我們還可以使用 Reference(..).Load 方法把關聯的資料表一併取出來, 如下例所示:

using (var db = new MyData())
{
    Company c = db.Companies;
    db.Entry(c).Reference(c => c.DetailedInfo).Load();
    return c;
}

如果關聯是一對多的資料表, 那麼請把 Reference 方法改成 Collection 即可:

using (var db = new MyData())
{
    Company c = db.Companies;
    db.Entry(c).Collection(c => c.Employees).Load();
    return c;
}

事實上, EF 提供了三種取出關聯資料的方法, 整理如下。

Lazy Loading

亦即, 關聯資料會在第一次被存取時, EF 才連上資料庫去下載。在上例中, 如果我們的程式放在 using (var db = new MyData()) 迴圈中, 那麼你可以直接使用如下的程式取得所有關聯資料:

using (var db = new MyData())
{
   foreach (Company c in db.Companies)
   {
      foreach (Employee e in c.Employees)
      {
         Label1.Text = e.Name;
      }
   }
}

很可惜的, 如果你跳出那個 using db 迴圈之外, 你就沒辦法再取得任何關聯資料。道理很簡單, 因為 DbContext 已經被關閉了, 怎麼還能存取呢? 除非你在整個 Session 期間都把 DbContext 物件開啟的 (有一些網路上的範例也是都這麼做的), 就不會有這種問題。

Eager Loading

每次當 entity 被取出來的時候, 就連同關聯資料一併取出; 這也就是我在最上面提到的 Include 做法。如下例, 

Company company = null;
using (var db = new MyData())
{
   var found = from c in db.Companies.Include("Employees")
                             where c.CompanyId == 5
                             select c;
   company = found.FirstOrDefault();
}

如此, 上例中的 company 物件即使在 using 迴圈之後, 仍然可以以 company.Employees 取得資料。

Explicit Loading

這種做法和 Lazy Loading 幾乎是一模一樣的, 只是換一種寫法罷了。以下我把 Lazy Loading 的做法改寫了一下, 讓它只傳回一筆:

Company company = null;
using (var db = new MyData())
{
   company = db.Companies.Find(5);
   db.Entry(company).Collection(c => c.Employees).Load();
}

改寫之後, 我們也能在 using 迴圈之外存取到 company.Employees 了。

事實上, Lazy Loading 和 Explocit Loading 都稱之為 Deffered Loading (延遲載入); 其差別大概只在用法了。

在效能上, 如果你像我在上面的最後兩個範例一樣, 只是找出一筆資料並連同關聯資料回傳的話, 那麼三種做法在效能上的差異應該不大。但如果你是打算把整個 Company 資料表傳回來並附帶上整個 Employee 資料表, 那麼, 當然是 Eager Loading 在效能上佔上風。因為 Eager Loading 只會讀取一次資料庫、一次傳回, 而另外兩種做法則可以必須連上資料庫許多次 (有幾筆就連幾次); 當然會造成效能上的巨大差異。

這是很容易明白的道理。就像我曾經試過使用 EF 做一千筆資料的 Update 動作, 那麼, 如果我每 Update 一筆之後就立刻做 SaveChanges(), 或者等資料都通通 Update 完畢後再一次做 SaveChanges(), 前者耗費的時間差不多至少有幾百倍之多。當然, 這只是舉個例子; 如果只是讀取資料的話, 效能的差異不能這樣算。

不過, 話說回來, 如果採用 Eager Loading, 我們往往取回太多不需要的資料。如果資料量太大、太複雜, 那麼採用 Deffered Loading (尤其是 Explicit Loading) 反而也許能提高效能。總而言之, 看狀況而決定吧!

設定欄位預設值

EF Code First 和資料庫專案不同, 截至 5.0 版為止, 它都不能在資料庫層級上為欄位設定預設值 (例如為 DateTime 欄位設定預設值為 GETDATE())。

窮則變, 變則通; 為欄位設定預設值, 說穿了, 只是圖方便而已, 既不重要, 也不難。不能在資料庫層級辦到, 我們可以在程式層級辦到。

我把上面舉過的 DetailedInfo 類別稍為改了一下 (如下所示)。如果我們要為 DateModified 欄位設定其預設值為現在的時間, 那麼, 我們可以在類別的建構式裡面做:

public class DetailedInfo
{
    [Key]
    public int CompanyId { get; set; }
    public string Address { get; set; }
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public DateTime DateCreated {get; set; }

    // Constructor
    public DetailedInfo() 
    {
        this.DateCreated = DateTime.Now;
    }
}

我為 DateCreated 加上 [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 註記, 是因為如此一來, 這個欄位的值只會在這筆資料被建立時被存入, 而不會在每次儲存時都被存入。

像這類簡單且用不到複雜的 SQL 指令的預設值設定工作, 採用如上所示範的方法即可。但是如果你真的有特殊的需要, 那麼你可以參考「Entity Framework Code First & Default Column Values」這篇文章, 除上述的同樣做法, 它還提供了另一種做法, 可以讓 EF 在應用程式起始時執行 SQL 指令, 而這道 SQL 指令則會把某個欄位的預設值予以設定。不過, 除非你一定要用到 SQL 指令 (例如 GETDATE() 或者自訂函數), 否則使用我在上面所提到方法, 還是比較直覺而且簡單的。

建立 Stored Procedures

Code First 並沒有直接支援在 C#/VB 程式中對資料庫加上 Stored Procedure 的做法。它倒是有「執行」Stored Procedure 的做法, 但是如果你想要憑空去建立, 是沒辦法的。至少在 EF6 以及之前的版本, 通通不行。

怎麼辦呢? 在上面提到的「Entity Framework Code First & Default Column Values」這篇文章中, 它示範了一個如何在 global.asax 程式的 Application_Start() 方法執行初始化動作以建立欄位預設值的做法。透過 DbContext.Database.ExecuteSqlCommand 指令, 你可以指定某些資料表的某些欄位的初始值。換句話說, 如果我們可以從這裡執行 SQL 指令, 那麼還有什麼是辦不到的? 如果你勤勞一點, 那麼, 你也可以完全跳過 EF, 從這裡建立起整個資料庫。

那麼, 要建立 Stored Procedure、Function、Trigger 等等, 也就一點都不難了。

在那篇文章裡, 是以 ASP.NET 為範例, 所以你可以從 global.asax 著手。如果是是其它類型專案, 例如 Console 專案, 那麼你可以把程式寫在 EF 套件幫你建立的 Configuration.cs 程式裡面; 兩種專案的原理是一樣的。範例如下:

protected override void Seed(DbManager.BoData context)
{
    foreach (string file in SpFiles())
    {
        string sql = System.IO.File.ReadAllText(file);
        context.Database.ExecuteSqlCommand(sql);
    }
}

private string[] SpFiles()
{
    string appPath = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().GetName().CodeBase).Replace(@"file:\", "");
    return new string[] { path + "A.sql", path + "B.sql" };
}

在這個程式中, Seed 方法會在你每次下達 Update-Database 時被呼叫執行。你的 Stored Procedure 可以寫在如我的範例中的 A.sql 和 B.sql 等等。在我的專案中, 這兩個檔案的 Copy to Output Directory 屬性都必須為 Copy Always, 如此程式執行時才能讀得到。如果你把這兩個檔案放在某個專案子目錄下面 (例如 MySp\ 裡面), 那麼你必須把檔案路徑改成 path + @"\MySp\" + "A.sql" (依此類推)。由於這個程式無法設定中斷點, 也無法以 Debug.Write 輸出訊息, 而錯誤訊息也不會出現在 VS 的 Error List 裡面, 而且程式組件的路徑也不在專案的 bin 子目錄下面, 所以很難除錯, 如果出了問題, 只能暗中摸索。

採用這種方法有一個明顯的缺點, 那就是如果你下 Update-Database 的目的是更新資料庫而不是建立, 那麼上述步驟會因為 Stored Procedure 已經存在而發生錯誤。EF 會在發生第一個錯誤時即停止執行, 這會大大影響我們的任何補救措施。

解決的辦法, 就是我們非得另外下達一個 SQL 指令去判斷那個 Stored Procedure 存在或不存在。如果已經存在, 我們就不建立。反之, 我們才建立。我把改良後的程式列在下面:

protected override void Seed(DbManager.BoData context)
{
    string ext = ".sql";
    foreach (string file in SpFiles())
    {
        string sql = System.IO.File.ReadAllText(file);
        IEnumerable<int?> resultSet = context.Database.SqlQuery<int?>(
            string.Format("SELECT object_id('[dbo].[{0}]', 'P')", file.Name.Replace(ext, string.Empty)));
        if (resultSet.First() == null)
            context.Database.ExecuteSqlCommand(sql);
    }
}

至於 SpFiles() 程式的部份則不必修改。

如上, 我又另外下了一個 DataContext.Database.SqlQuery 指令, 並採用 SELECT object_id 指令去判斷目標 Stored Procedure 是否存在。不管存在不存在, 程式中的 resultSet 集合只會有一個元素, 但如果 Stored Procedure 不存在, 這個元素會是 null。這就是我用來判斷 Stored Procedure 是否存在的關鍵。

不過這個方法仍然不是最佳解法。設想, 如果你修改了你的 Stored Procedure, 上述方法並不會幫你自動修改資料庫。因此, 如果要做到更完美的解決方案, 這個程式還是有改善空間。我想, 這個工作就交給讀者自己去做了!

還有一點必須注意: 你要用來建立 Stored Procedure 的 SQL 指令, 必須以 CREATE 或者 ALTER 做為第一行。如果你的指令最上方加入了什麼 USE MyData GO 之類的指令, 請把它們全數刪除, 如此才不會出問題。還有, 是的, IF 指令也無法被接受, 這讓我們很多東西都只能在程式裡判斷, 增加了許多難度。

「初始化字串規格不相符」的問題與解法

如果你和我一樣, 把 Data Model 專案放在另一個專案中撰寫, 而不是全部擺在 ASP.NET 專案裡面, 那麼你就有可能會遇到「初始化字串的格式和開始於索引 0 的規格不相符」("Format of the initialization string does not conform to specification starting at index 0.") 這個煩人的錯誤。

不過, 這個錯誤並不是隨便出現的。在你的開發過程中, 你很可能永遠也不會遇到這個問題, 唯有當你終於把網站布署到伺服器之後, 才會出現這個問題。

首先來看看這個問題的出現方式。基本上, 非常簡單, 你只要執行到任何透過位於 Data Model 專案中的任何 Entity Framework 功能, 這個問題就會出現。換句話說, 很有可能你的網站根本不能用, 也什麼都看不到。

坦白地說, 直到我找到解法之前, 我也曾遇到同樣的問題, 而且幾乎令我萬念俱灰。因為我冒著很大的勇氣把整個網站改用 EF6 Code First 技術, 而且許多功能都已經寫好了, 卻發生這種連問題出在哪裡都看不出來的錯誤, 只能憑著短短的幾行狀況堆疊來暗中摸索, 讓人無言。但也幸好有這幾行狀況堆疊, 讓我很確定問題一定出在 EF 的 Connection String, 因此才能順利地找到解法。

我個人認為這是 ASP.NET 搭配 EF 所產生的 bug。不過解法倒是出奇的容易。

先來說明原因。很簡單, 當在 ASP.NET 環境下執行時, EF 不會去取得原來的 Connection 設定 (我是寫在該專案的 App.config 裡), 而是會到 ASP.NET 的 Web.config 裡去撈出一個布署程式自動產生的 Connection String 出來用。而問題就出在於布署程式寫的這個 Connection 字串是錯的!

舉例說明, 如果 EF 程式會去取得 MyDb 這個資料庫, 那麼它就會去 Web.config 裡找出一個 name="MyDb" 的 Connection String 設定 (不要管在你的原來專案裡它叫做什麼名字)。ASP.NET 的布署程式會在布署時自動新增一個叫做 "MyDb" 的連線字串, 放在原來的連線字串之下。

現在, 麻煩你自己去看這個新增的連線字串, 我相信你馬上就會看到錯誤。你只需要把這個連線字串的 connectionString= 之後改成正確的, 問題就可以迎刃而解。

同樣的, 如果你的網站是布署到 Azure 上面, 你可以經由 FTP 把網站的 Connection String 改成正確的就行了。

在微軟修正這個問題之前, 就暫時使用上述的解法吧! 

可用的 Power Shell 命令

EF 可在 Visual Studio 的套件管理器主控台中使用的指令有以下四個:

  1. Enable-Migrations - 啟動某一專案的 Code First Migrations
  2. Add-Migration - 加上某個修改過的 Migration
  3. Update-Database - 將已變更的欄位套用到資料庫中
  4. Get-Migrations - 列出資料庫中已套用過的 migrations 記錄

這四個命令定義在 EF 原始檔中 EntityFramework.psm1 這個 PowerShell 檔案裡。

有人把這四個命令的文法整理如下:

★ Enable-Migrations

Enable-Migrations [-EnableAutomaticMigrations] [[-ProjectName] <String>]
  [-Force] [<CommonParameters>]

★ Add-Migration

Add-Migration [-Name] <String> [-Force]
  [-ProjectName <String>] [-StartUpProjectName <String>]
  [-ConfigurationTypeName <String>] [-ConnectionStringName <String>]
  [-IgnoreChanges] [<CommonParameters>]
 
Add-Migration [-Name] <String> [-Force]
  [-ProjectName <String>] [-StartUpProjectName <String>]
  [-ConfigurationTypeName <String>] -ConnectionString <String>
  -ConnectionProviderName <String> [-IgnoreChanges] [<Common Parameters>]

★ Update-Database

Update-Database [-SourceMigration <String>]
  [-TargetMigration <String>] [-Script] [-Force] [-ProjectName <String>]
  [-StartUpProjectName <String>] [-ConfigurationTypeName <String>]
  [-ConnectionStringName <String>] [<CommonParameters>]
 
Update-Database [-SourceMigration <String>] [-TargetMigration <String>]
  [-Script] [-Force] [-ProjectName <String>] [-StartUpProjectName <String>]
  [-ConfigurationTypeName <String>] -ConnectionString <String>
  -ConnectionProviderName <String> [<CommonParameters>]

★ Get-Migrations

Get-Migrations [-ProjectName <String>] [-StartUpProjectName <String>]
  [-ConfigurationTypeName <String>] [-ConnectionStringName <String>]
  [<CommonParameters>]
 
Get-Migrations [-ProjectName <String>] [-StartUpProjectName <String>]
  [-ConfigurationTypeName <String>] -ConnectionString <String>
  -ConnectionProviderName <String> [<CommonParameters>]

詳細的說明可以參考「EF Migrations Command Reference」這篇文章。

Entity Framework 除錯技巧

在 Visual Studio 裡為 Entity Framework 除錯是一件十分令人頭痛的問題。如果你在 Debug 時遇到 Entity Framework 發出來的 Exception, 它多數是從 SaveChanges() 發出來的。但是等你看到時, 你卻看不到詳細的 Exception 資料。你幾乎不必期望能看到任何有意義的錯誤訊息; 而只能在下列幾種可能的錯誤中猜測:

  1. 違反 Primary Key 約束
  2. 違反 Foreign Key 約束
  3. 違反其它資料庫中的約束

至於結構上的問題倒是比較容易解決, 因為它們在最上層的錯誤訊息就能夠看得出來, 例如資料類別和資料庫結構不符合之類的。

至於包在 SaveChanges() 中的 Exception 之所以無助於偵錯, 是因為我們從最上層中看到的錯誤訊息非常簡單, 它根本不會告訴你錯誤在哪裡。如果你的資料已經相當複雜, 你很可能把程式重新寫過一遍, 仍然找不到引起錯誤的原因到底在哪裡。

其實 Entity Framework 提供了比你以為的還要多的錯誤資訊, 足以解決大多數常見的問題, 尤其是能夠幫你找出「到底是哪個欄位出問題了」這類的資訊, 可以幫你省下很多很多錯誤嘗試的時間。我們唯一要做的, 只剩下「如何把這種錯誤訊息翻出來」而已。

以下我要教你一個技巧, 一點也不難。請在你定義資料庫的最上層類別 (就是繼承了 DbContext 的那個類別), 覆寫 base.SaveChanges() 方法:

public override int SaveChanges()
{
    try
    {
        return base.SaveChanges();
    }
    catch (Exception ex)
    {                
        throw ex;
    }            
}

也許你覺得這個覆寫是個廢物, 它根本沒有做什麼事。的確! 但是如果這麼寫的話, 下次一旦 Entity Framework 發出 Exception, 在 VS 中錯誤會停留在 throw ex 這一行, 然後你可以把 ex 物件加入監看式 (不是快速監看)。接著, 你就可以從監看式視窗中拉出包括 InnerException、Data 和其它更有意義的資訊項目, 如此你就可以看到更多、更詳細、對你的除錯更有意義的資訊了!

參考資料:

 

沒有留言:

張貼留言