[EF] 如何在 Entity Framework 中以手动方式设定 Code First 的 Migration 作业

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:

201404240923456_SetDateCreatedAgain
201404240917497_SetDateCreatedToNullable
201404240809403_AddDateCreated
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 作业时, 你必须确定两件事:

Migration 动作必须与你的资料模型符合。例如你的 Migration 目的是让 DateCreated 栏位为 NULL, 但是你却忘了把 DateCreated 栏位的型别指定为 "DateTime?", 或者你从资料库中擅自变更了结构。这是不对的。
你要做的 Migration 不能与资料库中的既有资料衝突。例如如果把 DateCreated 设定为 NOT NULL, 而你的资料库中却有资料的 DateCreated 栏位为 NULL, 那么 Update-Database 指令在执行时会发出错误。
结论

这一篇文章是 EF Code First 的 Step by step 教学。写得很长, 而且你不能跳过任何一个步骤和细节, 否则前后可能会无法连贯。如果你在某个步骤中发现问题, 我建议你把旧的资料库删掉, 重建专案, 从 Step 1 重新做过。

对于 EF 或者 Code First 不熟的朋友, 这篇教学可能对你的负担很重。但是不要怕麻烦。因为当你从步骤1做到最后, 甚至重複做过几次之后, 你会发现其实这个范例专案真的十分简单; 等你熟了以后, 你会发现从头做到尾根本花不到半个钟头, 甚至更短。

不过, 话说回来, 由于 EF 还在不停地改版, VS 也会改版, 我也不知道什么时候上面的哪些步骤或指令会突然改变。如果你发现问题, 麻烦向我反映。此外, 我可能还会随时修改这篇文章, 加进一些我还没想到的东西, 就恕我不另行通知了。

原文地址:http://www.dotblogs.com.tw/johnny/archive/2014/04/23/manual-entity-framework-migrations.aspx

原文地址:https://www.cnblogs.com/liaohuolin/p/4539533.html