了解 Migration 的黑魔法

透過 PostgreSQL 官方提供的 Npgsql EF Core Provider,Entity Framework Core 也能簡單地存取 PostgreSQL。

本文將使用 Code First 方式對 PostgreSQL 建立 database schema,並解釋 Migration 背後運作原理。

Version


macOS High Sierra 10.13.4
Docker for Mac 18.03-ce-mac65 (24312)
.NET Core 2.1
Entity Framework 2.1
PostgreSQL 10.3
Npgsql EF Core Provider 2.1
VS Code 1.24.0
DataGrip 2018.1.4

Definition


Code First

會先在 code 建立 DbContextEntity ,然後透過 Migration 在 database 建立 schema

Q : 為什麼要使用 Code First 與 Migration ?

A : 傳統都會使用視覺化工具建立 database schema,這種方式雖然直覺,但有以下缺點:

  • Schema 建立步驟無法透過 Git 版控
  • 無法很簡單的同步 development / lab / stage / production 各 server 環境的 database schema

EF Core 提供以下解決方案:

  • 透過 Code First,schema 改用 Entity 描述,我們可將 Migration 檔案進行 Git 版控,明確地知道 schema 變化過程
  • 透過 Migration,只要在各 server 執行 dotnet ef database update,就能確保 schema 同步

建立 Console App


1
$ dotnet new console -o EFCoreMigration

使用 dotnet new 建立 .NET Core App。

  • new:建立 project
  • console:建立 console 類型 project
  • -o:以 EFCorePostgres 為專案名稱並建立目錄

post000

以 VS Code 開啟


1
2
$ cd EFCoreMigration
~/EFCorrMigration $ code .

進入專案目錄,呼叫 VS Code 開啟。

post001

post002

安裝 EF Core Package


1
~/EFCoreMigration $ dotnet add package Microsoft.EntityFrameworkCore.Design

將來會在 CLI 執行 migration,而 dotnet ef 必須透過 Microsoft.EntityFrameworkCore.Design 才能存取 Entity 與 DbContext,所以必須另外安裝 package。

post005

  1. 輸入 dotnet add package Microsoft.EntityFrameworkCore.Design 安裝 package

post003

  1. 安裝完 package 會在 .csproj 會增加新的 <PackageReference/>

安裝 PostgreSQL Database Provider


1
~/EFCoreMigration $ dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL

EF Core 預設提供了 MSSQL、SQLite 與 InMemory 3 個 Database Provider,其餘的 provider 則由原廠 vendor 提供。

其中 Npgsql 為 PostgreSQL 所提供的 EFCore Database Provider。

post006

  1. 使用 dotnet add package 安裝 Npgsql.EntityFrameworkCore.PostgreSQL package

post004

  1. 安裝完 package 會在 .csproj 會增加新的 <PackageReference/>

目前在 .csproj 一共會看到 Microsoft.EntityFrameworkCore.DesignNpgsql.EntityFrameworkCore.PostgreSQL 兩個 package

EF Core


在 EF Core,database 在 ORM 中都有相對應的物件:

  • Database:EF Core 的 DbContext
  • Table:EF Core 的 Entity
  • Column:EF Core 的 Property

我們即將在 PostgreSQL 建立 :

  • Database : eflab
  • TableCustomers
  • Column
    • CustomerID : int (PK)
    • Name : string

Code First


建立 Entity

Entity 在 EF Core 中代表 table,我們將建立自己的 Entity。

其中 Customer entity 代表 Customers table。

Entity 名稱為 單數,而 table 名稱為 複數

Customer.cs

1
2
3
4
5
6
7
8
namespace EFCoreMigration
{
public class Customer
{
public int CustomerID { get; set; }
public string Name { get; set; }
}
}

第 3 行

1
public class Customer

Entity 為 單數,所以使用單數的 Customer

第 5 行

1
2
public int CustomerID { get; set; }
public string Name { get; set; }

Column 以 property 呈現。

Q : string 是否能指定長度 ?

A : EF Core 能使用 [StringLength()] 指定 string 的 column 長度,我們將在稍後改變長度,目前先不指定長度,看看不指定長度下的 string,在 PostgreSQL 會如何 ?

第 5 行

1
public int CustomerID { get; set; }

Table 的 PK,EF Core 規定要以 table name + ID 表示,則 migration 時會自動將該欄位建立成 PK,不需要額外 attribute。

migration009

建立 DbContext

DbContext 在 EF Core 中代表 database,我們將繼承 DbContext 建立自己的 database context。

其中 EFLabDbContext DbContext 代表 eflab database。

EFLabDbContext.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using Microsoft.EntityFrameworkCore;

namespace EFCoreMigration
{
public class EFLabDbContext: DbContext
{
public DbSet<Customer> Customers { get; set; }
private const string DbConnectionString = "Host=localhost;Port=5432;Database=eflab;Username=admin;Password=12345";

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{

optionsBuilder.UseNpgsql(DbConnectionString);
}
}
}

第 5 行

1
public class EFLabDbContext: DbContext

建立自己的 EFLabDbContext,繼承自 DbContext

第 7 行

1
public DbSet<Customer> Customers { get; set; }

使用 property 宣告 table,其型別為 DbSet<Customer>,這表示其在 EF Core 為 Customer entity,而在 database 為 Customers table。

Entity 名稱為 單數,而 table 名稱為 複數

第 8 行

1
private const string DbConnectionString = "Host=localhost;Port=5432;Database=eflab;Username=admin;Password=12345";

連接 database server 需要基本的資訊,統稱為 database connection string,包含以下資料:

  • Host : 設定 PostgreSQL server 的名稱
  • Port : 設定 PostgreSQL 對外的 port
  • Database : 設定要連接的 database
  • Username : 設定 user name
  • Password : 設定 password

這些資訊在建立 PostgreSQL 的 docker-compose.yml 時,都已經在 .env 建立

第 10 行

1
2
3
4
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{

optionsBuilder.UseNpgsql(DbConnectionString);
}

Override OnConfiguring() 設定 database。

EF Core 會傳入 DbContextOptionBuider,因為我們要連接的是 PostgreSQL,其 database provider 為 Npgsql, 所以將 connection string 傳入 OptionBuilder.UseNpgsql()

migration010

Migration


Migration 分兩個階段:

  • 建立 Migration
  • 執行 Migration

建立 Migration

1
~/EFCoreMigration $ dotnet ef migrations add Migration00

輸入 dotnet ef migrations add 建立 Migration,其中 Migration00 為 Migration 名稱,請自行建立不重複的名稱。

migration007

migration011

  1. 執行完 dotnet ef migrations add Migration00,會發現新增了 Migrations 目錄,並增加了 3 個檔案

Q : 為什麼需要這 3 個 Migration 檔案 ?

A : 稍後在 Migration 工作原理 會一併並解釋。

執行 Migration

1
~/EFCoreMigration $ dotnet ef database update

輸入 dotnet ef database update 執行 Migration。

EF Core 將根據剛剛在 Migrations 所建立的 3 個檔案,與 DbContext.OnConfiguration() 的設定,對 PostgreSQL 進行 Migration

migration008

  1. 執行了 Migration00

確認 Database

migration012

  1. 展開 eflab database
  2. EF Core 的 Migration 在 eflab 建立了 __EFMigrationHistoryCustomers 兩個 table
  3. Customers 則建立 CustomerIDName 兩個 column,並有 PK_Customers

注意 Name 的型別為 text,也就是在 Entity 的 string,預設在 PostgreSQL 為 text,而不是 varchar

若 Migration 沒有建立成功,請確認 Docker 與 PostgreSQL container 已經正常執行

Q : 為什麼會多了 __EFMigrationHistory table 呢 ?

A : 稍後在 Migration 工作原理 會一併並解釋。

Code First


新增 Field

若只是單純將 Entity 建立成 table,還顯不出 Migration 的威力。

實務上因為需求的變動,我們會想在 table 新增 column,我們只要繼續在 Entity 新增 property 即可。

Customer.cs

1
2
3
4
5
6
7
8
9
namespace EFCoreMigration
{
public class Customer
{
public int CustomerID { get; set; }
public string Name { get; set; }
public int Age { get; set; }
}
}

第 7 行

1
public int Age { get; set; }

新增 Age property。

migration013

Migration


建立 Migration

1
~/EFCoreMigration $ dotnet ef migrations add Migration01

輸入 dotnet ef migrations add 建立 Migration,其中 Migration01 為 Migration 名稱,有別於剛剛建立的 Migration00

migration007

migration017

  1. 新增兩個 Migration 檔案

Q : 為什麼第二次 Migration 只新增了兩個檔案 ?

A : 稍後在 Migration 工作原理 會一併並解釋。

執行 Migration

1
~/EFCoreMigration $ dotnet ef database update

輸入 dotnet ef database update 執行 Migration。

migration015

  • 只執行了 Migration01,並沒有執行 Migration00

Q : 為什麼 EF Core 知道只執行新的 Migration,而不是全部 Migration 重跑一次 ?

A : 稍後在 Migration 工作原理 會一併並解釋。

確認 Database

migration016

  1. Customers 只新增了 Age column

Code First


指定 Column 長度

Customer.cs

1
2
3
4
5
6
7
8
9
10
11
12
using System.ComponentModel.DataAnnotations;

namespace EFCoreMigration
{
public class Customer
{
public int CustomerID { get; set; }
[StringLength(20)]
public string Name { get; set; }
public int Age { get; set; }
}
}

實務上也常會有改變欄位長度的需求,目前我們將 Name 加上 StringLength() attribute,指定長度為 20

migration021

  1. Name 加上 StringLength() attribute
  2. 加上 using System.ComponentModel.DataAnnotations; namespace

Migration


建立 Migration

1
~/EFCoreMigration $ dotnet ef migrations add Migration02

由於 Customer entity 被修改,因此必須建立新的 Migration。

migration022

  • 由於我們是將 text 改成 varchar(20),因此有可能會 loss 資料,EF Core 特別提出警告

執行 Migration

1
$ dotnet ef database update

輸入 dotnet ef database update 執行 Migration。

migration023

  • 只執行了 Migration02,並沒有執行其他 Migration

確認 Database

migration024

  1. Nametext 變成 varchar(20)

Migration 工作原理


目前 Migration 已經正常執行,Table 與 Column 也都如預期建立在 PostgreSQL,但我們累積了很多疑問:

  • Q : 為什麼需要這 3 個 Migration 檔案 ?
  • Q : 為什麼會多了 __EFMigrationHistory table 呢 ?
  • Q : 為什麼第二次 Migration 只有兩個檔案 ?
  • Q : 為什麼 EF Core 知道只執行新的 Migration,而不是全部 Migration 重跑一次 ?

這些都是 Migration 的黑魔法,我們必須從 EF Core 的 Migration 工作原理談起。

建立 Migration

1
$ dotnet ef migrations add

migration018

  1. 根據 DbContextEntity 蒐集要建立 Migration 的資訊
  2. 比對 ModelSnapshot.csDbContextEntity 的差異,決定 Migration 檔案要如何建立
  3. MyMigrate.Designer.csMyMigrate.cs 就是實際的 Migration 檔案
  4. 將新增異動的 schema 寫入 ModelSnapshot.cs

ModelSnapshot.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// <auto-generated />
using EFCoreMigration;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;

namespace EFCoreMigration.Migrations
{
[DbContext(typeof(EFLabDbContext))]
partial class EFLabDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{

#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn)
.HasAnnotation("ProductVersion", "2.1.0-rtm-30799")
.HasAnnotation("Relational:MaxIdentifierLength", 63);

modelBuilder.Entity("EFCoreMigration.Customer", b =>
{
b.Property<int>("CustomerID")
.ValueGeneratedOnAdd();

b.Property<int>("Age");

b.Property<string>("Name")
.HasMaxLength(20);

b.HasKey("CustomerID");

b.ToTable("Customers");
});
#pragma warning restore 612, 618
}
}
}

描述了 database schema 該有哪些 table 與 column。

ModelSnapshot.cs 可視為前一次 Migration 所產生 database schema 的 golden sample,因此可用目前的 DbContextEntityModelSnapshot 做比對,產生新的 Migration 檔案

Migration02.Designer.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// <auto-generated />
using EFCoreMigration;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;

namespace EFCoreMigration.Migrations
{
[DbContext(typeof(EFLabDbContext))]
[Migration("20180615035640_Migration03")]
partial class Migration03
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{

#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn)
.HasAnnotation("ProductVersion", "2.1.0-rtm-30799")
.HasAnnotation("Relational:MaxIdentifierLength", 63);

modelBuilder.Entity("EFCoreMigration.Customer", b =>
{
b.Property<int>("CustomerID")
.ValueGeneratedOnAdd();

b.Property<int>("Age");

b.Property<string>("Name")
.HasMaxLength(20);

b.HasKey("CustomerID");

b.ToTable("Customers");
});
#pragma warning restore 612, 618
}
}
}

描述了這次 Migration 建立後最終的 schema。

Migration02.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using Microsoft.EntityFrameworkCore.Migrations;

namespace EFCoreMigration.Migrations
{
public partial class Migration03 : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{

migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Customers",
maxLength: 20,
nullable: true,
oldClrType: typeof(string),
oldNullable: true);
}

protected override void Down(MigrationBuilder migrationBuilder)
{

migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Customers",
nullable: true,
oldClrType: typeof(string),
oldMaxLength: 20,
oldNullable: true);
}
}
}

描述了 dotnet ef database updatedotnet ef migrations remove 要執行的動作。

第 7 行

1
2
3
4
5
6
7
8
9
10
11
protected override void Up(MigrationBuilder migrationBuilder)
{

migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Customers",
maxLength: 20,
nullable: true,
oldClrType: typeof(string),
oldNullable: true);

}

當執行 dotnet ef datebase update 時,就會執行 Up(),包含如何建立 schema。

Migration02 為例,這次 migration 的目的就是將 Nametext 改成 varchar(20),因此在 Up 只看到 AlterColumn<T>()

18 行

1
2
3
4
5
6
7
8
9
10
11
protected override void Down(MigrationBuilder migrationBuilder)
{

migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Customers",
nullable: true,
oldClrType: typeof(string),
oldMaxLength: 20,
oldNullable: true);
}
}

當執行 dotnet ef migrations remove 時,就會執行 Down(),包含如何還原此次 Migration 所需要的動作。

由於 Migration02 主要動作就是將 Nametext 改成 varchar(20),因此 Down() 就是反過來由 varchar(20) 改成 text

由於 MyMigrate.Designer.csMyMigrate.cs 都是 C# 檔案,因此可以進入 Git 版控,可藉由 Migration 檔案的變化,得知 database schema 變化的歷程,且若真的 Migration 錯誤,還可以透過 dotnet ef migrations remove 還原 Migration 所變動的 database schema

MyMigrate.Designer.csMyMigrate.cs 雖然是由 CLI 產生,但並非不能修改,一些進階的Migration 功能,就得自行修改才能實現,如想利用 Migration 建立 Stored Procedure,就必須自行修改 MyMigrate.csUp()

ModelSnapshot.cs 則不應該修改,應該交由 Migration 去維護,否則會發生錯亂

這解釋了 :

  1. Q : 為什麼需要這 3 個 Migration 檔案 ?
  2. Q : 為什麼第二次 Migration 只有兩個檔案 ?

執行 Migration

1
$ dotnet ef database update

migration019

  1. 檢查是否有 __EFMigrationHistory table,若沒有,表示是第一次執行 Migration
  2. 若是第一次執行 Migration,會建立 __EFMigrationHistory table
  3. 若已經有 __EFMigrationHistory,表示已經執行過 Migration,因此在 __EFMigrationHistory 會有 Migration 紀錄,可查詢已經執行過哪些 Migration
  4. 執行尚未執行過的 Migration
  5. 將執行過的 Migration 名稱 寫進 __EFMigrationHistory

migration020

__EFMigrationHistory 記載了執行過的 Migration 名稱。

透過 __EFMigrationHistorydotnet ef database update 就知道每次該執行哪些沒執行過的 Migration,且在不同環境下,如 development / lab / stage / production,因為各自有各自的 __EFMigrationHistory,也能根據不同環境執行不同的 Migration。

這解釋了 :

  1. Q : 為什麼會多了 __EFMigrationHistory table 呢 ?
  2. Q : 為什麼 EF Core 知道只執行新的 Migration,而不是全部 Migration 重跑一次 ?

Conclusion


  • Code First 與 Migration 解決了 database schema 無法 Git 版控與 schema 同步問題,且完全使用開發端技術,而不是資料庫端技術
  • Migration 另外一個優點就是用在 整合測試,當配合 Docker 時,每個測試案例在執行前先 docker-compose up 建立 PostgreSQL container,接著跑 Migration 建立 schema,然後跑整合測試讀寫 PostgreSQL, 測試完畢後再 docker-compose down 刪除 PostgreSQL container,所有在 PostgreSQL 的測試資料也跟著刪除,下一個測試案例再重新 docker-compose up 建立新的 container,重新跑 Migration …,如此能確保每個整合測試都是全新乾淨的 database,完全沒有任何 side effect
  • 因為不了解 Migration,所以很多人在實務上不敢用 Migration,事實上對於新的技術,只要能充分了解其背後原理與機制,就不再是黑魔法了

Sample Code


完整的範例可以在我的 GitHub 上找到

Reference


Npgsql, Entity Framework Core
John P Smith, Entity Framework Core in Action

2018-06-13