C# · #csharp#ef-core#orm#database

EF Core Code First 实战:从建模到迁移管理

2018.02.14 C# 3 min 1.3k
// 目录 · contents

从 EF 6 迁移到 EF Core 之后,API 有不少变化,但整体思路是一致的。EF Core 的 Code First 工作流在团队协作和版本管理上比直接写 SQL 更顺畅,这篇文章总结了我在实际项目中的使用经验。

1. 实体建模

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
public class Order
{
public int Id { get; set; }
public string OrderNo { get; set; }
public DateTime CreatedAt { get; set; }
public OrderStatus Status { get; set; }
public decimal TotalAmount { get; set; }

// 外键
public int UserId { get; set; }
public User User { get; set; } // 导航属性

// 一对多
public List<OrderItem> Items { get; set; } = new List<OrderItem>();
}

public class OrderItem
{
public int Id { get; set; }
public int OrderId { get; set; }
public Order Order { get; set; }

public int ProductId { get; set; }
public Product Product { get; set; }

public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}

2. Fluent API 配置

Data Annotation(特性)适合简单场景,Fluent API 更强大且不污染实体类:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
public class AppDbContext : DbContext
{
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<User> Users { get; set; }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 推荐:把每个实体的配置分离到独立类
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
}
}

// 独立的实体配置类
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("orders");
builder.HasKey(o => o.Id);

builder.Property(o => o.OrderNo)
.IsRequired()
.HasMaxLength(32);

builder.Property(o => o.TotalAmount)
.HasColumnType("decimal(18, 2)");

builder.Property(o => o.CreatedAt)
.HasDefaultValueSql("GETUTCDATE()");

// 索引
builder.HasIndex(o => o.OrderNo).IsUnique();
builder.HasIndex(o => new { o.UserId, o.Status, o.CreatedAt });

// 关联
builder.HasOne(o => o.User)
.WithMany(u => u.Orders)
.HasForeignKey(o => o.UserId)
.OnDelete(DeleteBehavior.Restrict);

builder.HasMany(o => o.Items)
.WithOne(i => i.Order)
.HasForeignKey(i => i.OrderId);

// 全局查询过滤器(软删除)
builder.HasQueryFilter(o => !o.IsDeleted);

// 枚举存储为字符串
builder.Property(o => o.Status)
.HasConversion<string>();
}
}

3. 迁移管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 创建迁移
dotnet ef migrations add InitialCreate

# 查看迁移生成的 SQL(不执行)
dotnet ef migrations script

# 应用迁移到数据库
dotnet ef database update

# 回滚到指定迁移
dotnet ef database update PreviousMigrationName

# 删除最后一个迁移(只在尚未应用时)
dotnet ef migrations remove

生成的迁移文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public partial class AddOrderStatusIndex : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_orders_UserId_Status_CreatedAt",
table: "orders",
columns: new[] { "UserId", "Status", "CreatedAt" });
}

protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_orders_UserId_Status_CreatedAt",
table: "orders");
}
}

4. 生产环境迁移策略

不推荐:在应用启动时自动迁移(db.Database.Migrate())——如果有多个实例同时启动,并发迁移可能导致问题。

推荐:生成 SQL 脚本,通过 CI/CD 流水线在部署前应用:

1
2
# 生成从当前版本到最新版本的 SQL
dotnet ef migrations script --idempotent -o migration.sql

--idempotent 参数生成的脚本包含版本检查,可以安全地重复执行。

1
2
3
4
5
6
7
-- EF Core 生成的幂等脚本示例
IF NOT EXISTS(SELECT * FROM [__EFMigrationsHistory] WHERE [MigrationId] = N'20180214_AddOrderStatusIndex')
BEGIN
CREATE INDEX [IX_orders_UserId_Status_CreatedAt] ON [orders] ([UserId], [Status], [CreatedAt]);
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20180214_AddOrderStatusIndex', N'2.0.1');
END;

5. 值对象(Value Object)

DDD 中的值对象(没有独立 ID)在 EF Core 2.0+ 可以用 Owned Entity 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Order
{
public Address ShippingAddress { get; set; }
}

public class Address
{
public string Province { get; set; }
public string City { get; set; }
public string Street { get; set; }
public string ZipCode { get; set; }
}

// 配置
builder.OwnsOne(o => o.ShippingAddress, address =>
{
address.Property(a => a.Province).HasColumnName("shipping_province");
address.Property(a => a.City).HasColumnName("shipping_city");
address.Property(a => a.Street).HasColumnName("shipping_street");
address.Property(a => a.ZipCode).HasColumnName("shipping_zipcode");
});

Address 的字段会被内联到 orders 表中,不会创建独立的表。

踩坑记录

我们有个迁移在本地和测试环境都没问题,在生产环境应用时出错了。原因是生产环境数据库有一张历史遗留表 user_profiles,而这次迁移恰好要创建同名的表(我们重构了模型)。

EF Core 的迁移只知道它自己管理的历史(__EFMigrationsHistory 表),不知道数据库里已有的对象。

解决方案:迁移前先手动备份并 rename 旧表,或者在迁移文件里加 migrationBuilder.Sql("EXEC sp_rename...") 手动处理冲突。

从那以后,我们在 CI 里加了一步:在一个干净的数据库快照上跑迁移,确认没问题后再上生产。

总结

EF Core Code First 最佳实践:

  • Fluent API 放在独立的 Configuration 类里,实体类保持干净
  • 迁移文件提交到版本控制,团队成员 pull 后能重建数据库
  • 生产环境用幂等 SQL 脚本,而不是程序启动时自动迁移
  • HasQueryFilter 实现软删除,全局生效,无需每次手写 WHERE
  • HasConversion 存储枚举为字符串,比存 int 更具可读性
作者 · authorzt
发布 · date2018-02-14
篇幅 · length1.3k 字 · 3 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论