[EF] 如何在 Entity Framework 中以手動方式設定 Code First 的 Migration 作業

Entity Framework (簡稱 EF) 發展到現在, 版本已經進入 6.1.0, 距離我寫的「在 VS2013 以 Code First 方式建立 EF 資料庫」這篇文章已有半年的時間。如果你和我一樣從那時候開始使用 EF Code First, 那麼你對 EF 應該已經有了基本的了解。依我個人的使用經驗, EF 雖然好用, 但是如果一直使用 AutomaticMigrations 的方式維護你的資料庫, 也許會遇到一些麻煩。因為在正常作業環境下, 資料庫的格式不可能永遠不變; 當我們已經開始寫入資料之後, 情況會變得更複雜, 迫使我們不得不去探究更適當、更有彈性的做法...

Entity Framework (簡稱 EF) 發展到現在, 版本已經進入 6.1.0, 距離我寫的「在 VS2013 以 Code First 方式建立 EF 資料庫」這篇文章已有半年的時間。如果你和我一樣從那時候開始使用 EF Code First, 那麼你對 EF 應該已經有了基本的了解。依我個人的使用經驗, EF 雖然好用, 但是如果一直使用 AutomaticMigrations 的方式維護你的資料庫, 也許會遇到一些麻煩。因為在正常作業環境下, 資料庫的格式不可能永遠不變; 當我們已經開始寫入資料之後, 情況會變得更複雜, 迫使我們不得不去探究更適當、更有彈性的做法。

以下, 我將介紹如何捨棄 AutomaticMigrations 而以手動方式做 Migrations 的方法。我覺得我無法以很淺顯易懂的方式用短簡的話語來做講解, 只能帶你一步一步地親自動手做一次, 這樣你才有辦法體會箇中的奧妙。

Step 1

請使用 VS2013 建立一個專案 (我個人使用 Console 專案), 然後依照我在「在 VS2013 以 Code First 方式建立 EF 資料庫」中介紹的方式安裝最新版的 Entity Framework、自訂 Connection String、建立好資料對應的類別, 並且開啟並操作 Package Manager Console 視窗。

如果你還沒有想怎麼訂你的資料庫的話, 你可以使用以下的範例類別。我在本文中會一直使用這個類別。


using System;
using System.Collections.Generic;
using System.Data.Entity;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace EfDemo.Model
{
    public class AddressInfo
    {
        [Key]
        public int AddressId { get; set; }
        public string Address { get; set; }
    }

    public class TaiwanAddress: DbContext
    {
        public TaiwanAddress() : base("TaiwanAddress") { }
        public DbSet<AddressInfo> Addresses { get; set; }
    }
}

同時, 你的 App.config 應該像如下的樣子:


<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <configSections>
    <!-- For more information on Entity Framework configuration, visit http://go.microsoft.com/fwlink/?LinkID=237468 -->
    <section name="entityFramework" type="System.Data.Entity.Internal.ConfigFile.EntityFrameworkSection, EntityFramework, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" requirePermission="false" />
  </configSections>
  <startup>
    <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" />
  </startup>
  <connectionStrings>
    <add name="TaiwanAddress" connectionString="Data Source=你的電腦名稱\SQLEXPRESS;Initial Catalog=TaiwanAddress;Persist Security Info=True;User ID=你的帳號;Password=你的密碼" providerName="System.Data.SqlClient" />
  </connectionStrings>
  <entityFramework>
    <defaultConnectionFactory type="System.Data.Entity.Infrastructure.SqlConnectionFactory, EntityFramework" />
    <providers>
      <provider invariantName="System.Data.SqlClient" type="System.Data.Entity.SqlServer.SqlProviderServices, EntityFramework.SqlServer" />
    </providers>
  </entityFramework>
</configuration>

Step 2

找到套件管理器主控台(檢視、其它視窗、套件管理器主控台; VS2013 之外的版本也許不在這裡), 執行 Enable-Migrations 指令。如果你的方案中有多個專案, 請注意你必須在上方「預設專案」下拉式選單中選取本專案。

如上圖, 如果你忘記指令的正確拚法, 你可以僅輸入前幾個字元, 然後按下 <TAB> 鍵, 就會出現提示視窗讓你選擇正確的指令。

執行 Enable-Migrations 指令後, 你可能會看到一道錯誤訊息, 說專案中找不到 Migrations 下的 Configuration 型別。這是個錯誤的錯誤指令, 不用理會。你只要確定你的專案中確實已增加了 Migrations 資料夾和其它的 Configuration.cs 這個檔案即可:

Step 3

管理器主控台中執行 Add-Migration CreateDb 指令:

如上圖所示, 我們可以看見它顯示的一道黃色底的說明文字, 意思是說, Add-Migration CreateDb 這個指令會把專案中目前的 Code First Model 儲存成一個叫做 CreateDb (這個名稱是自訂的, 建議你取一個容易記的名稱) 的快照 (Snapshot); 這個快照存在的目的, 是用以記錄截至目前為止你的程式內的資料模型的修改歷程, 並且會被用來做為下一次做 Migrations 的比較基礎。但是如果稍後你又修改了你的資料模型, 但是又想把它包進 CreateDb 這個快照裡面的話, 那麼只需要重新執行一次 Add-Migration CreateDb 即可。

我把上面這一段文字用淺顯的白話重新說明一次。假設你想開車找找家裡附近有沒有加油站, 那麼你可以先往前開一個 block, 把這個過程記起來, 叫它做 Run1 (叫做 "Run1" 的快照)。然後再往左開一個 block, 把這個過程記起來, 叫它做 Run2 (叫做 "Run2" 的快照)。依此類推。把上述各個 Run 加起來, 你就知道你的開車路徑, 從而知道下次該怎麼開到那家加油站了。

但是如果你開到 Run2 (往左一個 block) 的時候, 突然覺得你應該把它納入 Run1 (往前一個 block) 裡面, 而不需要特別標記一個 Run2, 那麼你可以把 Run1 的意義重新定義 (變成往前一個 block, 再往左一個 block)。

執行完之後, 一個名稱為 xxxx_CreateDb.cs 的檔案會自動開啟:


namespace EfDemo.Migrations
{
    using System;
    using System.Data.Entity.Migrations;
    
    public partial class CreateDb : DbMigration
    {
        public override void Up()
        {
            CreateTable(
                "dbo.AddressInfoes",
                c => new
                    {
                        AddressId = c.Int(nullable: false, identity: true),
                        Address = c.String(),
                    })
                .PrimaryKey(t => t.AddressId);            
        }
        
        public override void Down()
        {
            DropTable("dbo.AddressInfoes");
        }
    }
}

這個檔案將在稍後我們執行 Update-Database 時被執行。程式中的 Up() 方法和 Down() 方法是相對的, 基本上 Up() 方法就是即將對資料庫進行的作業, 而 Down() 方法就是 Up() 方法的 Undo 作業。所以未來如果你要 Rollback 這個 Migration, EF 就會去執行 Down() 方法。

如果你對 Up() 或 Down() 方法的內容有意見的話, 你也可以手動予以修改它的程式。例如, 或許你不喜歡 EF 把你的資料表名稱擅自改為 AddressInfoes (AddressInfo 的複數型式), 你可以把它改回 dbo.AddressInfo。不過, 你不能只改 Up() 方法, 你也得記得同時改 Down() 方法。

不過我建議你此時不要亂改 EF 自動產生的 Up() 和 Down() 方法, 畢竟我們在這裡只是練習而已。或許等你對 EF 更熟練時再來做這種動作。同時, 我們原本就可以使用 [Table("AddressInfo")] 這種 attribute 以指定資料表名稱(見「在 VS2013 以 Code First 方式建立 EF 資料庫」文中的介紹), 所以平常以手動方式去更改此程式內容的必要性是很低的。盡量不要對自動產生的程式去做修改, 這是防呆的重要法則之一。

Step 4

現在我們回頭來看看在 Step 2 中程式建立的 Configuration.cs 程式。如果我們不是以手動方式進行 Migration 而是啟動了 AutomaticMigration 的話, 那麼在這個 Configuration 建構式中就會看到 AutomaticMigrationsEnabled 被設定為 true 而非 false。

但是 Seed() 方法又是什麼呢? 依照它原來的註解 "This method will be called after migrating to the latest version", 字面上是說這個 Seed() 方法會在我們 migrate 到最近的版本時會被呼叫; 實際上就是說, 如果我們已經設定好 Migration (即上一步驟中建立的 "CreateDb" 快照), 當我們執行了 Update-Database 指令時, EF 就會自動去呼叫並執行這個 Seed() 方法。

請把這個檔案修改如下:


namespace EfDemo.Migrations
{
    using System;
    using System.Data.Entity;
    using System.Data.Entity.Migrations;
    using System.Linq;

    internal sealed class Configuration : DbMigrationsConfiguration<efdemo.addressdata>
    {
        public Configuration()
        {
            AutomaticMigrationsEnabled = false;
        }

        protected override void Seed(EfDemo.AddressData context)
        {
            context.Addresses.AddOrUpdate(
                p => p.Address,
                new Model.AddressInfo { Address = "凱達格達大道1號" },
                new Model.AddressInfo { Address = "凱達格達大道2號" }
            );
        }
    }
}

在這裡, 我把 Seed() 方法改寫了, 讓它寫入兩筆資料。其中的 AddOrUpdate() 是一個擴充方法, 有兩種多載型式。如果你把 p => p.Address, 這行指令刪除或者註解掉, 那麼這是另一種多載的語法。但是若使用上述範例的寫法 (即保留 p => p.Address, 這一行), 那兩筆資料只會被寫入一次。若把 p => p.Address, 這行指令刪除或者註解掉, 那麼那兩筆資料稍後可能會被重複寫入資料庫。

Configuration.cs 雖然是 EF 自動產生的, 但是它之後並不會一再被產生; 所以對它進行修改是安全的。況且 Seed() 方法本來就是用來讓開發者修改的。

現在請執行 Update-Database 指令。你會發現資料庫和資料表都被建立起來了, 上面兩筆資料也都被寫入了。

Seed() 方法會在你每次你執行 Update-Database 指令時被呼叫一次。所以你可以試試看再加入一筆 "凱達格達大道3號", 再執行一次 Update-Database 指令, 資料庫中就會多出那一筆。換句話說, 你可以使用同樣的方法一直往資料庫裡加入資料。

話說回來, 或許你必須思考一下, 你會在什麼時候使用 Seed() 方法在資料庫中塞入資料? 一般而言, 我們在程式中套用 EF 絕對不是為了可以使用 Seed() 方法塞入資料。但是我們一定有很多時候會希望在資料庫一建立時就加上一些固定而不容易異動的資料, 例如自己公司的地址, 或者一些測試資料。所以 Seed() 方法的確是開發者的絕佳幫手。

現在, 檢查一下你的資料庫, 你會發現 EF 會同時建立一個 "__MigrationHistory" 資料表, 同時在裡面寫入了一筆記錄。其 MigrationId 就是 EF 在 Migration 資料夾之下建立的 .cs 檔案名稱。

既然資料庫裡已經有資料了, 我們就可以從程式中讀取:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using EfDemo.Model;

namespace EfDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            using (var db = new TaiwanAddress())
            {
                foreach (AddressInfo info in db.Addresses)
                {
                    Console.WriteLine(info.Address);
                }
            }
            Console.ReadKey();
        }
    }
}

按下 F5 以執行應用程式, 即可從資料庫中讀出資料。不過, 經由 EF 讀寫資料庫的方法並不是本文的重點, 在此我就不多著墨了。

Step 5

如果你的專案真的非常簡單, 那麼, 你只需要練習到步驟四, 就可以讓你從此過著幸福的日子。可惜的是, 現實中實在很難找到那種夢幻情境。所以我們還是繼續往下走吧!

現在, 請把你的資料類別加上一個欄位, 例如 DateCreated 欄位:


public class AddressInfo
{
    [Key]
    public int AddressId { get; set; }
    public string Address { get; set; }
    public DateTime DateCreated { get; set; }
}

其它地方維持不變。

可是當你再度按下 F5 執行應用程式時, 卻出現如下的錯誤:

"The model backing the 'TaiwanAddress' context has changed since the database was created. Consider using Code First Migrations to update the database (http://go.microsoft.com/fwlink/?LinkId=238269)."

它的意思是, 由於你的資料類別已經和資料庫中的版本不一樣, 所以 EF 會發出一個 InvalidOperationException。所以, 若換一個角度來想, 每當你看到這個訊息時, 就應該知道是因為你的資料類別和資料庫的結構已經不一致所致。

所以, 每當你異動了你的資料類別時, 你就必須再做一次 Add-Migration 的動作。在此, 請執行 Add-Migration AddDateCreated 指令, 然後再下達 Update-Database 指令。

果然, 資料庫順利地更新了。但是, 你也會同時發現一排紅字: "An error occurred while updating the entries. See the inner exception for details."

這也許是一個 EF 思慮不夠週到之處。其實這個問題之所以出現, 是因為我們在 Configuration.cs 中的 Seed() 方法加入了幾筆起始資料所致。因為我們沒有指定 DateCreated 欄位的值, 而這個欄位又是 NOT NULL, 所以會出現錯誤。但是如果你仔細去看資料庫中 AddressInfoes 的內容, 你會發現其實每一個 DateCreated 欄位都已經填入了一個初始值。既然如此, 這個「錯誤」訊息應該不算是「錯誤」, 而僅僅是「警告」而已。程式內部應該確實有發出 Exception, 但是 EF 已經自動採取補救措施了。

當然, 也有可能是我的 SQL Express 自己採用的捕救措施, 所以它發出的「錯誤」訊息, 也許也是正確的。

不管如何, 如果我們在資料類別中加入了一個 NOT NULL 欄位, 卻在寫入資料庫的過程中忘記做必要的對應修改, 這確實是我們自己的不對! 我們不能因為 EF 或者資料庫幫我們主動做了補救措施而不採取任何動作。所以請把 Seed() 方法修改如下:


protected override void Seed(EfDemo.Model.TaiwanAddress context)
{
    context.Addresses.AddOrUpdate(
        p => p.Address,                
        new Model.AddressInfo { Address = "凱達格達大道1號", DateCreated = DateTime.Now },
        new Model.AddressInfo { Address = "凱達格達大道2號", DateCreated = DateTime.Now }
    );
}

然後再執行一次 Update-Database 指令。如此就不會再看到那一行紅色的錯誤訊息了。

Step 6

等等, 想像一下, 你現在突然想到, 你有些資料必須從外部匯進來, 但是這些外部資料的 DateCreated 有一部份是 NULL! 如果你不想, 或者沒辦法改動這些資料, 該怎麼辦?

或許你會想到一個投機取巧的方法, 就是偷偷把資料庫的 DateCreated 欄位定義中, 將它從 NOT NULL 改成 NULL。不過, 如果你還不是個能獨撐大局的專家, 還是先遵循原本的規矩比較好, 不要動不動就想耍特技、使大絕。

和之前的做法一樣, 我們先把 DateCreated 的型別從 "DateTime" 改成 "DateTime?", 然後執行 Add-Migration SetDateCreatedToNullable 指令, 再執行 Update-Database 指令。接著, 我們就可以看到資料庫中已經把 DateCreated 欄位設定為 NULL 了。

同樣地, 我們也檢查一下資料庫中的 __MigrationHistory 資料表是否也同時新增了一筆 xxx_SetDateCreatedToNullable 記錄。

Step 7

現在, 假設你的外部資料已經匯入完畢, 未來不會再有匯入外部資料的需要, 你又想把 DateCreated 改成 NOT NULL 了。所以, 現在我們把 DateCreated 的型別從 "DateTime?" 重新改回 "DateTime", 然後再執行 Add-Migration SetDateCreatedAgain 指令, 再執行 Update-Database 指令。

當然, 你不能忘記在資料庫中檢查是否有資料的 DateCreated 欄位是 NULL。如果有的話, 要替它們填上一個預設值。如果你不這樣做的話, 剛才在執行 Update-Database 指令時就會發生錯誤。

同樣地, 我們再檢查一下資料庫中的 __MigrationHistory 資料表是否也同時新增了一筆 xxx_SetDateCreatedAgain 記錄。

Step 8

不幸地, 現在 PM 跑來告訴你, 說他覺得 DateCreated 還是維持 NULL 比較好, 因為他不敢保證未來不會再有外部資料會匯進來; 如果讓這些資料的 DateCreated 欄位的值保持為 NULL, 那麼就能判斷那些資料是否為本來就不知道何時建立的。

話都是他在講的。沒關係, 我們有辦法。不過, 你需要再次建立一個 Migration 嗎? 不需要。因為在之前建立過的 Migration 中, 已經有一個是讓 DateCreated 欄位的值為 NULL 的了, 我們不需要再建立一個一模一樣的 Migration。Update-Database 指令可以讓我們在歷次的 Migration 中進行挑選。

首先, 你可以從專案中的 Migration 資料夾看到歷來的 Migration, 但是你也可以直接下 Get-Migrations 指令, 把歷次的所有 Migration 列出來:

從這個列表中可以看到, 從步驟1到步驟7, 我們總共已經建立了四個 Migration:

  1. 201404240923456_SetDateCreatedAgain
  2. 201404240917497_SetDateCreatedToNullable
  3. 201404240809403_AddDateCreated
  4. 201404240554516_CreateDb

其中的第一項 (201404240923456_SetDateCreatedAgain) 就是我們在步驟7建立的 Migration。我們現在要做的, 是復原到我們在步驟6建立的 Migration (即 201404240917497_SetDateCreatedToNullable)。指令如下:

Update-Database -TargetMigration 201404240917497_SetDateCreatedToNullable

這道指令的意思, 就是指定 Update-Database 的執行對象, 同時把在它之後的 Migration 復原。指令執行完畢之後, EF 會把 SetDateCreatedAgain 這個 Migration 復原到 SetDateCreatedToNullable 這個 Migration。如果你再執行一次 Get-Migrations 指令, 你會發現它只剩下三項。此外, 如果你進資料庫去看 __MigrationHistory 資料表, 裡面也只剩下三個記錄了。

當然, 你在進行各種 Migration 作業時, 你必須確定兩件事:

  1. Migration 動作必須與你的資料模型符合。例如你的 Migration 目的是讓 DateCreated 欄位為 NULL, 但是你卻忘了把 DateCreated 欄位的型別指定為 "DateTime?", 或者你從資料庫中擅自變更了結構。這是不對的。
  2. 你要做的 Migration 不能與資料庫中的既有資料衝突。例如如果把 DateCreated 設定為 NOT NULL, 而你的資料庫中卻有資料的 DateCreated 欄位為 NULL, 那麼 Update-Database 指令在執行時會發出錯誤。

結論

這一篇文章是 EF Code First 的 Step by step 教學。寫得很長, 而且你不能跳過任何一個步驟和細節, 否則前後可能會無法連貫。如果你在某個步驟中發現問題, 我建議你把舊的資料庫刪掉, 重建專案, 從 Step 1 重新做過。

對於 EF 或者 Code First 不熟的朋友, 這篇教學可能對你的負擔很重。但是不要怕麻煩。因為當你從步驟1做到最後, 甚至重複做過幾次之後, 你會發現其實這個範例專案真的十分簡單; 等你熟了以後, 你會發現從頭做到尾根本花不到半個鐘頭, 甚至更短。

不過, 話說回來, 由於 EF 還在不停地改版, VS 也會改版, 我也不知道什麼時候上面的哪些步驟或指令會突然改變。如果你發現問題, 麻煩向我反映。此外, 我可能還會隨時修改這篇文章, 加進一些我還沒想到的東西, 就恕我不另行通知了。


Dev 2Share @ 點部落