【EF Core】使用外部 Model

对于模型的配置,98.757%的情况下,我们使用"数据批注"特性类,或者 Fluent API (重写 DbContext 类的 OnModelCreating 方法)进行配置即可。但在少数情况下,可能会考虑在 DbContext 之外配置模型。比如:

  • 你的实体类和模型,以及 DbContext 派生不在一个程序集中;
  • 你可以想在配置模型时做一些自己特有的扩展;
  • 你希望所有 DbContext 的实例共享一个 Model 实例,这样不必在每次实例化上下文时都配置一次模型。

话又说回来,其实就每次实例都配置一次模型也不用耗什么性能,除非实体很多,或特殊情况导致初始化较慢。

使用外部模型后,不仅可以把 DbContextOptions(选项)对象全局共享,连模型也顺便共享了。老周先介绍一下原理,比吃咸菜还简单。我们一般会使用 DbContextOptionsBuilder 类(不管是不是泛型版本)来构建 Options,其中,有一个 UseModel 方法,可以传递一个实现 IModel 接口的对象实例。对,就是模型对象。

于是,问题就聚焦在这个 IModel 接口上,咱们一般不会花十牛二虎之力自己实现 IModel 接口的,并且,EF Core 内部有实现类,叫 Model。虽然咱们可以访问此类,但从框架的角度看显然人家是不希望咱们在代码中使用它的。应用程序代码通过 ModelBuilder 类构建模型,再访问它的 Model 属性来获得模型实例的引用。你如果自己实现 IModel 接口,意义不大的,而且你还要花很多精力去重新实现 EF Core 的各部功能,才能与框架对接。

由 ModelBuilder 类相关 API 可以展现模型构建过程中的各种细节,即设计时模型。DbContext.Database.EnsureCreated 方法、迁移等功能在创建 / 修改数据库时都使用设计时的模型对象,毕竟其包含的元数据比较完整。

在预置约定的作用下,模型的设计时构建结束后,可以生成运行时模型。EnsureCreated 与迁移功能不使用运行时模型(使用了会报错)。但在数据查询、插入、删除、更新这些常规操作时是可以使用运行时模型的。你可以自己编写运行时模型,做法是继承 RuntimeModel 类(位于 Microsoft.EntityFrameworkCore.Metadata 命名空间)。不......过,这个其实也不用你去写的,dotnet-ef 命令行工具使用 dbcontext optimize 命令就可以帮我们生成代码了。为什么用工具生成而不动手去写呢,因为运行时模型的构造和设计时模型其实是相同的------描述实体的特征是相同的。通常我们通过 ModelBuilder 的API构建了模型,没有必要在 RuntimeModel 上又重复写一遍。所以贴心的微软给咱们准备了 ef 工具,代替我们做重复的工作。比如,迁移(Migration)的代码也是要描述实体到数据表的映射的(表名、列名等),这些咱们在 ModelBuilder 中就能做,也没有必要在 Migration 时又重写一番。

ef 工具生成的运行模型也可以传递给选项类的 UseModel 方法的,但文已提过,运行时模型是不能用来创建数据库和表的,只可用于增删改查。

(以下内容是重写的,可能与老周第一次写的内容有些不同,老周尽量按相同的思路写。因为中途去处理一下别的事情,回来发现电脑从睡眠中唤醒直接蓝屏了,草稿未保存,丢了。铭瑄的主板可能要背锅,以前用的 Acer 不会有这问题)

顺便解释一下设计时模型和运行时模型的不同。设计时模型就是我们常写的配置模型的过程,先由框架执行所有预置的约定,自动识别能识别的东西。随后执行咱们自己写的配置代码,完事后生成只读的模型(不用改了)。之后对数据做查询、更改等操作就用最终生成的模型。运行时模型说简单点,就是把模型的置进行"硬编码",里面有几个实体,哪个类的,类有几个属性。表、列、函数、存储过程映射了哪些。主键是谁,外键是谁,全部用明确的代码写出来。不用执行预置约定,不用自动识别,不用猜测......直接把模型的结构写死了。也就是说,运行时模型执行的代码较少,性能会好一些。注意,这里仅仅指 EF Core 初始化阶段,至于查询数据的过程不受影响(查询也可以用 dotnet-ef 工具生成预编译的查询,原理和生成运行时模型差不多,就是少执行一些代码)。

用 dotnet-ef 工具生成优化代码一般在你发现程序初始化很慢时才考虑,如果不影响性能,可以不优化。

扯远了,回到咱们的主题。由于配置模型过程需要预置约定集合,咱们也没必要自己重写这些功能。同时,预置约定不仅包括 EF Core 部分,各个数据库提供者(如 SQL Server)可能会加入自己特有的约定。所以,我们手动把预置约定添加到集合中也很麻烦的,幸好,贴心的微软又又又为咱们准备了一组静态方法,直接调用就能生成 ModelBuilder 实例返回,非常地方便。

这些静态方法是按数据库提供者分组的:

|------------|------------------------------------------------------------|-------------------------------|--------------------|
| 数据库 | 命名空间 | | 静态方法 |
| SQL Server | Microsoft.EntityFrameworkCore.Metadata.Conventions | SqlServerConventionSetBuilder | CreateModelBuilder |
| SQLite | Microsoft.EntityFrameworkCore.Metadata.Conventions | SqliteConventionSetBuilder | CreateModelBuilder |
| PostgreSQL | Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Conventions | NpgsqlConventionSetBuilder | CreateModelBuilder |

这样一来,咱们配置外部模型就跟在 OnModelCreating 方法中一样了。

下面老周用一个示例让大伙伴们掌握使用方法。

第一步,写实体。

复制代码
public class Ultraman
{
    /// <summary>
    /// 标识
    /// </summary>
    public int Uid { get; set; }
    /// <summary>
    /// 称号
    /// </summary>
    public required string Nick { get; set; }
    /// <summary>
    /// 年龄
    /// </summary>
    public int Age { get; set; }
    /// <summary>
    /// 特征
    /// </summary>
    public Speciality Spec { get; set; } = new();
}

/// <summary>
/// 特性
/// </summary>
public class Speciality
{
    public int Id { get; set; }
    /// <summary>
    /// 身高
    /// </summary>
    public decimal Height { get; set; }
    /// <summary>
    /// 体重
    /// </summary>
    public decimal Weight { get; set; }
    /// <summary>
    /// 飞行速度
    /// </summary>
    public decimal FlightSpeed { get; set; }
}

Ultraman 表示超人,Speciality 表示超人的某些特征,如身高、体重、飞行速度。

第二步,派生 DbContext 类。

复制代码
public class DemoDbContext : DbContext
{
    public DemoDbContext(DbContextOptions<DemoDbContext> options)
        : base(options)
    {

    }

    public DbSet<Ultraman> Ultramen { get; setpublic DbSet<Speciality> SpecialSet { get; set; }
}

第三步,老周用一个 ModelHelper 类,公开静态的 BuildModel 方法。配置好模型后直接返回。

复制代码
public static class ModelHelper
{
    public static IModel BuildModel()
    {
        ModelBuilder builder=SqliteConventionSetBuilder.CreateModelBuilder();
        builder.Entity<Ultraman>(et =>
        {
            // 主键
            et.HasKey(x => x.Uid).HasName("PK_ultra_id");
            // 长度约束
            et.Property(x => x.Nick).HasMaxLength(25);
            // 表映射
            et.ToTable("tb_ultras", tb =>
            {
                tb.Property(d => d.Uid).HasColumnName("ultr_id");
                tb.Property(d => d.Nick).HasColumnName("ultr_nick");
                tb.Property(x => x.Age).HasColumnName("ultr_age");
            });
            // 关系:一对一
            et.HasOne(x => x.Spec)
                .WithOne()
                .HasForeignKey<Ultraman>("spec_id")
                .HasPrincipalKey<Speciality>(s => s.Id)
                .HasConstraintName("FK_ultra_spec");
        });
        builder.Entity<Speciality>(et =>
        {
            et.HasKey(c => c.Id).HasName("PK_spid");
            // 表/列映射
            et.ToTable("tb_spec", tb =>
            {
                tb.Property(q => q.Id).HasColumnName("sp_id");
                tb.Property(q => q.Height).HasColumnName("sp_height");
                tb.Property(q => q.FlightSpeed).HasColumnName("sp_flightspeed");
            });
            // 精度控制
            et.Property(k => k.FlightSpeed).HasPrecision(7, 2);
            et.Property(m => m.Height).HasPrecision(5, 2);
            et.Property(o => o.Weight).HasPrecision(3, 1);
        });
        // 返回模型
        returnbuilder.Model.FinalizeModel();
    }
}

配置模型的过程相信大伙们都很熟了。上面代码两处关键:

1、调用 SqliteConventionSetBuilder.CreateModelBuilder 方法生成 ModelBuilder 实例。老周这次用的是 SQLite 数据库;

2、模型配置完后,通过 ModelBuilder 实例的 Model 属性来获取模型实例的引用。按照约定,应该调用模型的 FinalizeModel 方法,返回模型的最终形态(只读或 RuntimeModel)。

第四步,通过 Options 来配置数据库与模型相关参数,再传给 DbContext 的子类构造函数,就能达到全局共享选项和模型的目的。

复制代码
// 生成外部模型
IModel extModel = ModelHelper.BuildModel();// 打印一下模型结构
Console.WriteLine(extModel.ToDebugString());
Console.WriteLine("------------------------------------------------");
// 选项
var options = new DbContextOptionsBuilder<DemoDbContext>()
            .UseSqlite("data source=test.db")   // 连接字符串
            .UseModel(extModel)                 // 重要:使用外部模型
            // .LogTo(log => Console.WriteLine(log))    // 日志
            .Options;                           // 获取构建的选项实例

首先当然是调用咱们刚写好的静态方法生成模型实例,应用外部模型很TM简单的,只要调用 UseModel 方法,把模型实例传递进去就好了。

ToDebugString 方法可以在生成模型中各实体的详细信息,就像这样:

复制代码
Model: 
  EntityType: Speciality
    Properties: 
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      FlightSpeed (decimal) Required
      Height (decimal) Required
      Weight (decimal) Required
    Keys: 
      Id PK
  EntityType: Ultraman
    Properties: 
      Uid (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Age (int) Required
      Nick (string) Required MaxLength(25)
      spec_id (no field, int) Shadow Required FK Index
    Navigations:
      Spec (Speciality) ToPrincipal Speciality
    Keys:
      Uid PK
    Foreign keys:
      Ultraman {'spec_id'} -> Speciality {'Id'} Unique Required Cascade ToPrincipal: Spec
    Indexes:
      spec_id Unique

在创建数据库前,在调试阶段,我们可以打印这信息来检查一下由实体构建的模型(Code First)是否正确。

第五步,用上面的选项类初始化上下文对象,先创建数据库,并写入几条数据记录。

复制代码
using (var c = new DemoDbContext(options))
{
    bool res = c.Database.EnsureCreated();
    if (res)
    {
        c.Ultramen.Add(new()
        {
            Nick = "赛文",
            Age = 17000,
            Spec = new()
            {
                Height = 40.0M,         // 米
                Weight = 35000.0M,      // 吨
                FlightSpeed = 7.0M      // 马赫
            }
        });
        c.Ultramen.Add(new()
        {
            Nick = "爱迪",
            Age = 8000,
            Spec = new()
            {
                Height = 50.0M,
                Weight = 44000.0M,
                FlightSpeed = 9.0M
            }
        });
        c.Ultramen.Add(new()
        {
            Nick = "戴拿",
            Age = 22,       // 飞鸟信年龄
            Spec = new()
            {
                Height = 55.0M,
                Weight = 45000.0M,
                FlightSpeed = 8.0M
            }
        });
        c.Ultramen.Add(new()
        {
            Nick = "盖亚",
            Age = 20,       // 高山我梦年龄
            Spec = new()
            {
                Height = 50.0M,
                Weight = 42000.0M,
                FlightSpeed = 20.0M
            }
        });
        // 保存
        c.SaveChanges();
    }
}

第六步,把上面插入的记录查询出来。

复制代码
using (var c = new DemoDbContext(options))
{
    Console.WriteLine("{0,-5}{1,-7}{2,-7}{3,-7}{4,-10}", "名称", "年龄", "身高(米)", "体重(吨)", "飞行速度(马赫)");
    Console.WriteLine("-------------------------------------------------------");
    var q = c.Ultramen.Include(x =>x.Spec).ToList();
    foreach (Ultraman um in q)
    {
        Console.WriteLine($"{um.Nick,-5}{um.Age,-10}{um.Spec.Height,-12}{um.Spec.Weight,-13}{um.Spec.FlightSpeed,-10}");
    }
}

这里要高度注意:c.Ultramen 访问 Ultraman 集合时,与 Ultraman 一对一的 Speciality 实体并没有加载。此处我们需要查询整个关系的数据,所以得调用 Include 方法把 Speciality 集合的数据也 SELECT 出来。ToList 方法真正触发 SQL 语句的生成和发送到数据库执行(与 LINQ 一样的原理)。

如果你嫌调用 Include 方法麻烦,可以在配置模型时让其默认预加载。

复制代码
builder.Entity<Ultraman>(et =>
{
   ......
    // 关系:一对一
    et.HasOne(x => x.Spec)
        .WithOne()
        .HasForeignKey<Ultraman>("spec_id")
        .HasPrincipalKey<Speciality>(s => s.Id)
        .HasConstraintName("FK_ultra_spec");
    et.Navigation(k=>k.Spec).AutoInclude();
});

如果导航属性是个集合,引用的记录比较多,还是不要自动 Include 了,手动的好一些。

示例查询结果如下:

复制代码
名称   年龄      身高(米)    体重(吨)    飞行速度(马赫)  
-------------------------------------------------------
赛文   17000     40.0        35000.0      7.0       
爱迪   8000      50.0        44000.0      9.0       
戴拿   22        55.0        45000.0      8.0
盖亚   20        50.0        42000.0      20.0

使用 dotnet ef dbcontext optimize 命令生成的运行时模型也可以通过 UseModel 方法引用的,道理一样。但要注意,运行时的模型不能用来创建数据库的。

好了,今天就水到这里了。