EntityFramework Core如何對映動態模型?

Jeffcky發表於2021-01-24

前言

本文我們來探討下對映動態模型的幾種方式,相信一部分童鞋專案有這樣的需求,比如每天/每小時等生成一張表,此種動態模型對映非常常見,經我摸索,這裡給出每一步詳細思路,希望能幫助到沒有任何頭緒的童鞋,本文以.NET Core 3.1控制檯,同時以SQL Server資料庫作為示例演示(其他資料庫同理照搬),由於會用到內建APi,因版本不同可能比如建構函式需略微進行調整即可。注:雖為示例程式碼,但我將其作為實際專案皆已進行封裝,基本完全通用。本文略長,請耐心。

動態對映模型引入前提

首先我們給出所需要用到的特性以及對應列舉,看註釋一看便知

public enum CustomTableFormat
{
    /// <summary>
    /// 每天,(yyyyMMdd)
    /// </summary>
    [Description("每天")]
    DAY,
    /// <summary>
    /// 每小時,(yyyyMMddHH)
    /// </summary>
    [Description("每小時")]
    HOUR,
    /// <summary>
    /// 每分鐘(yyyyMMddHHmm)
    /// </summary>
    [Description("每分鐘")]
    MINUTE
}

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class EfEntityAttribute : Attribute
{
    /// <summary>
    /// 是否啟用動態生成表
    /// </summary>
    public bool EnableCustomTable { get; set; } = false;
    /// <summary>
    /// 動態生成表字首
    /// </summary>
    public string Prefix { get; set; }
    /// <summary>
    /// 表生成規則
    /// </summary>
    public CustomTableFormat Format { get; set; } = CustomTableFormat.DAY;

    public override string ToString()
    {
        if (EnableCustomTable)
        {
            return string.IsNullOrEmpty(Prefix) ? Format.FormatToDate() : $"{Prefix}{Format.FormatToDate()}";
        }
        return base.ToString();
    }
}

public static class CustomTableFormatExetension
{
    public static string FormatToDate(this CustomTableFormat tableFormat)
    {
        return tableFormat switch
        {
            CustomTableFormat.DAY => DateTime.Now.ToString("yyyyMMdd"),
            CustomTableFormat.HOUR => DateTime.Now.ToString("yyyyMMddHH"),
            CustomTableFormat.MINUTE => DateTime.Now.ToString("yyyyMMddHHmm"),
            _ => DateTime.Now.ToString("yyyyMMdd"),
        };
    }
}

通過定義特性,主要出發點基於兩點考慮:其一:由外部注入模型而非寫死DbSet屬性訪問、其二:每個模型可定義動態對映表規則

動態對映模型方式(一)

首先我們給出需要用到的上下文,為方便演示我們以每分鐘自動對映模型為例

public class EfDbContext : DbContext
{
    public string Date { get; set; } = CustomTableFormat.MINUTE.FormatToDate();
    public EfDbContext(DbContextOptions<EfDbContext> options) : base(options)
    {

    }
}

動態模型即指表名不同,比如我們實現每天/每小時/每分鐘動態對映模型和生成一張表。在下面介面中我們需要用到每分鐘生成一張表格式,所以在上下文中定義每分鐘屬性。第一種方式則是通過實現IModelCacheKeyFactory介面,此介面將指定上下文下所有模型表名進行了快取,所以我們可以根據所需動態模型表名進行更改即可,如下:

public class CustomModelCacheKeyFactory : IModelCacheKeyFactory
{
    public object Create(DbContext context)
    {
        var efDbContext = context as EfDbContext;
        if (efDbContext != null)
        {
            return (context.GetType(), efDbContext.Date);
        }
        return context.GetType();
    }
}

上述其實現貌似感覺有點看不太懂,主要這是直接實現介面一步到位,底層本質則是額外呼叫例項一個快取鍵類,我們將上述改為如下兩步則一目瞭然

public class CustomModelCacheKeyFactory : ModelCacheKeyFactory
{
    private string _date;
    public CustomModelCacheKeyFactory(ModelCacheKeyFactoryDependencies dependencies)
        : base(dependencies)
    {

    }
    public override object Create(DbContext context)
    {
        if (context is EfDbContext efDbContext)
        {
            _date = efDbContext.Date;
        }

        return new CustomModelCacheKey(_date, context);
    }
}

public class CustomModelCacheKey : ModelCacheKey
{
    private readonly Type _contextType;
    private readonly string _date;
    public CustomModelCacheKey(string date, DbContext context) : base(context)
    {
        _date = date;
        _contextType = context.GetType();
    }

    public virtual bool Equals(CustomModelCacheKey other)
      => _contextType == other._contextType && _date == other._date;

    public override bool Equals(object obj)
      => (obj is CustomModelCacheKey otherAsKey) && Equals(otherAsKey);

    public override int GetHashCode() => _date.GetHashCode();
}

然後在OnModelCreating方法裡面進行掃描特性標識模型進行註冊,如下:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var entityMethod = typeof(ModelBuilder).GetMethod(nameof(modelBuilder.Entity), 
    new Type[] { });
    
    var assembly = Assembly.GetExecutingAssembly();

    //【1】使用Entity方法註冊
    foreach (var type in assembly.ExportedTypes)
    {
        if (!(type.GetCustomAttribute(typeof(EfEntityAttribute)) is EfEntityAttribute attribute))
        {
            continue;
        }

        if (type.IsNotPublic || type.IsAbstract || type.IsSealed
            || type.IsGenericType
            || type.ContainsGenericParameters)
        {
            continue;
        }

        entityMethod.MakeGenericMethod(type)
                .Invoke(modelBuilder, new object[] { });
    }

    //【2】使用IEntityTypeConfiguration<T>註冊
    modelBuilder.ApplyConfigurationsFromAssembly(assembly);
    
    base.OnModelCreating(modelBuilder);
}

上述第一種方式則通過反射將模型註冊,其本質則是呼叫modeBuilder.Entity方法,若我們在模型上使用註解,則對應也會將其應用

 

但註解不夠靈活,比如要標識聯合主鍵,則只能使用Fluent APi,所以我們通過在外部實現IEntityTypeConfiguration進行註冊,然後EF Core提供針對該介面程式集註冊,其底層本質也是掃描程式集,兩種方式都支援,不用再擔心外部模型註冊問題

 

緊接著我們給出測試模型,表名為當前分鐘,表名利用註解則不行(值必須為常量),所以我們使用如下第二種對映模型

[EfEntity(EnableCustomTable = true, Format = CustomTableFormat.MINUTE)]
public class Test
{
    [Table(DateTime.Now.ToString("yyyyMMdd"))]
    public int Id { get; set; }
    public string Name { get; set; }
}

public class TestEntityTypeConfiguration : IEntityTypeConfiguration<Test>
{
    public void Configure(EntityTypeBuilder<Test> builder)
    {
        builder.ToTable(DateTime.Now.ToString("yyyyMMddHHmm"));
    }
}

上述第二種配置未嘗不可,但我們還有更加簡潔一步到位的操作,所以這裡刪除上述第二種方式,因為在OnModelCreating方法裡面,我們反射了呼叫了Entity方法,所以我們直接將反射呼叫Entity方法強制轉換為EntityTypeBuilder,在已有基礎上,程式碼做了重點標識

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var entityMethod = typeof(ModelBuilder).GetMethod(nameof(modelBuilder.Entity), new Type[] { });
    var assembly = Assembly.GetExecutingAssembly();

    //【1】使用Entity方法註冊
    foreach (var type in assembly.ExportedTypes)
    {
        if (!(type.GetCustomAttribute(typeof(EfEntityAttribute)) is EfEntityAttribute attribute))
        {
            continue;
        }

        if (type.IsNotPublic || type.IsAbstract || type.IsSealed
            || type.IsGenericType
            || type.ContainsGenericParameters)
        {
            continue;
        }

        // 強制轉換為EntityTypeBuilder
        var entityBuilder = (EntityTypeBuilder)entityMethod.MakeGenericMethod(type)
               .Invoke(modelBuilder, new object[] { });

        if (attribute.EnableCustomTable)
        {
            entityBuilder.ToTable(attribute.ToString());
        }
    }

    //【2】使用IEntityTypeConfiguration<T>註冊
    modelBuilder.ApplyConfigurationsFromAssembly(assembly);

    base.OnModelCreating(modelBuilder);
}

最後則是注入上下文,這裡我們將內外部容器進行區分(EF Core為何分內部容器,具體原因請參看文章《EntityFramework Core 3.x上下文建構函式可以注入例項呢?》)

 

因在實際專案中上下文可能需要在上下文建構函式中注入其他介面,比如我們就有可能在上下文建構函式中注入介面從而根據具體介面實現來更改表架構或不同表名規則等等

static IServiceProvider Initialize()
{
    var services = new ServiceCollection();

    services.AddEntityFrameworkSqlServer()
        .AddDbContext<EfDbContext>(
            (serviceProvider, options) =>
                options.UseSqlServer("server=.;database=efcore;uid=sa;pwd=sa123;")
                .UseInternalServiceProvider(serviceProvider));

    services.Replace(ServiceDescriptor.Singleton<IModelCacheKeyFactory, CustomModelCacheKeyFactory>());

    return services.BuildServiceProvider();
}

由於我們已區分EF Core內外部容器,所以在替換自定義快取鍵工廠時,不能再像如下直接呼叫ReplaceService方法替換,勢必會丟擲異常

options.UseSqlServer("server=.;database=efcore;uid=sa;pwd=sa123;")
                        .ReplaceService<IModelCacheKeyFactory, CustomModelCacheKeyFactory>()

同時謹記在非Web專案中利用EF Core始終要使用作用域(scope)來釋放上下文,不像Web可基於HTTP請求作為scope,最後我們測試如下

using (var scope1 = ServiceProvider.CreateScope())
{
    var context1 = scope1.ServiceProvider.GetService<EfDbContext>();

    context1.Database.EnsureCreated();

    var type = context1.Model.FindEntityType(typeof(Test));

    Console.WriteLine(type?.GetTableName());

    var tests = context1.Set<Test>().ToList();
}

Thread.Sleep(60000);

using (var scope2 = ServiceProvider.CreateScope())
{
    var context2 = scope2.ServiceProvider.GetService<EfDbContext>();

    context2.Database.EnsureCreated();

    var type = context2.Model.FindEntityType(typeof(Test));

    Console.WriteLine(type?.GetTableName());

    var tests1 = context2.Set<Test>().ToList();
}

為方便看到實際效果,我們構建兩個scope,然後睡眠一分鐘,在介面上列印輸出表名,若兩分鐘後列印表名不一致,說明達到預期

動態對映模型方式(二)

述我們使用每分鐘規則動態對映表,同時可針對不同模型有各自規則(字首,每小時或每天)等等,這是第一種方式

 

如果對第一種方式實現完全看懂了,可能會有所疑惑,因為第一種方式其介面生命週期為單例,若不需要豈不還是會將上下文中所有模型都會進行快取嗎

 

呼叫OnModelCreating方法只是進行模型構建,但我們現直接呼叫內建APi來手動使用所有模型,此時將不再快取,所以不再需要IModelCacheKeyFactory介面

 

對EF Core稍微瞭解一點的話,我們知道OnModelCreating方法僅僅只會呼叫一次,我們通過手動使用和處置所有模型,換言之每次請求都會使用新的模型,說了這麼多,那麼我們到底該如何做呢?

 

如果看過我之前原理分析的話,大概能知道EntityFramework Core對於模型的處理(除卻預設模型快取)分為三步,除卻模型快取:構建模型,使用模型,處置模型。

 

我們將OnModelCreating方法程式碼全部直接複製過來,只是多了上面三步而已,在我們例項化ModelBuilder時,我們需要提供對應資料庫預設約定,然後使用模型、處置模型,結果變成如下這般

 services.AddEntityFrameworkSqlServer()
      .AddDbContext<EfDbContext>(
          (serviceProvider, options) => {
          
            options.UseSqlServer("server=.;database=efcore;uid=sa;pwd=sa123;")
               .UseInternalServiceProvider(serviceProvider);

            var conventionSet = SqlServerConventionSetBuilder.Build();

            var modelBuilder = new ModelBuilder(conventionSet);

            // OnModelCreating方法,程式碼複製

            options.UseModel(modelBuilder.Model);

            modelBuilder.FinalizeModel();               
  )};

執行第一種方式測試程式碼,然後麼有問題

 問題來了,要是有多個資料庫,豈不是都要像上述再來一遍?上述實現本質上是每次構造一個上下文則會構建並重新使用新的模型,所以我們將其統一放到上下文建構函式中去,然後寫個擴充套件方法構建模型,如下:

public static class ModelBuilderExetension
{
    public static ModelBuilder BuildModel(this ModelBuilder modelBuilder)
    {

        var entityMethod = typeof(ModelBuilder).GetMethod(nameof(modelBuilder.Entity), new Type[] { });
        var assembly = Assembly.GetExecutingAssembly();

        //【1】使用Entity方法註冊
        foreach (var type in assembly.ExportedTypes)
        {
            if (!(type.GetCustomAttribute(typeof(EfEntityAttribute)) is EfEntityAttribute attribute))
            {
                continue;
            }

            if (type.IsNotPublic || type.IsAbstract || type.IsSealed
                || type.IsGenericType
                || type.ContainsGenericParameters)
            {
                continue;
            }

            var entityBuilder = (EntityTypeBuilder)entityMethod.MakeGenericMethod(type)
                   .Invoke(modelBuilder, new object[] { });

            if (attribute.EnableCustomTable)
            {
                entityBuilder.ToTable(attribute.ToString());
            }
        }

        //【2】使用IEntityTypeConfiguration<T>註冊
        modelBuilder.ApplyConfigurationsFromAssembly(assembly);

        return modelBuilder;
    }
}

最後在上下文建構函式中,簡潔呼叫,如下:

public class EfDbContext : DbContext
{
    public string Date { get; set; } = CustomTableFormat.MINUTE.FormatToDate();
    public EfDbContext(DbContextOptions<EfDbContext> options) : base(options)
    {
        //提供不同資料庫預設約定
        ConventionSet conventionSet = null;

        if (Database.ProviderName == "Microsoft.EntityFrameworkCore.SqlServer")
        {
            conventionSet = SqlServerConventionSetBuilder.Build();
        }
        else if (Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqllite")
        {
            conventionSet = SqliteConventionSetBuilder.Build();
        }
        else if (Database.ProviderName == "Microsoft.EntityFrameworkCore.MySql")
        {
            conventionSet = MySqlConventionSetBuilder.Build();
        }

        var modelBuilder = new ModelBuilder(conventionSet);

        var optionBuilder = new DbContextOptionsBuilder(options);

        //使用模型
        optionBuilder.UseModel(modelBuilder.Model);

        //處置模型
        modelBuilder.FinalizeModel();
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        //構建模型
        modelBuilder.BuildModel();

        base.OnModelCreating(modelBuilder);
    }
}

動態對映模型表生成

看到這裡,細心的你不知道有沒有發現,我寫的列印結果怎麼成功了,居然沒丟擲任何異常,實際情況是必須會丟擲異常,因為我們只做到了模型動態對映,但表自動生成我在此之前將其忽略了,如下:

 

 表如何生成這個也看實際情況分析,比如SQL Server寫個作業每天自動生成表等,若需相容多個資料庫,怕是有點麻煩

 

我沒花太多時間去看原始碼,稍微看了下,碰碰運氣或許能直接找到根據模型來建立表的介面實現,結果好像沒有,即使有也比較麻煩,那麼我們就手動構建SQL語句或者通過lambda構建也可

 

上下文中實現其特性需動態生成的模型我們可以獲取得到,然後搞個定時器每分鐘去執行生成對應表,針對不同資料庫型別,我們可以通過如下屬性獲取得到(和包同名)

// 比如SQL Server:Microsoft.EntityFrameworkCore.SqlServer
context.Database.ProviderName

這裡我以SQL Server資料庫為例,其他資料庫比如MySqL、Sqlite唯一區別則是自增長設定和列型別不同而已,建立表,通過五部分組成:表是否存在,表名,主鍵,所有列,約束。我們定義如下:

internal sealed class CustomTableModel
{
    public CustomEntityType CustomEntityType { get; set; }

    public string TableName { get; set; } = string.Empty;
    public string CheckTable { get; set; } = string.Empty;
    public string PrimaryKey { get; set; } = string.Empty;
    public string Columns { get; set; } = string.Empty;
    public string Constraint { get; set; } = string.Empty;

    public override string ToString()
    {
        var placeHolder = $"{CheckTable} create table {TableName} ({PrimaryKey} {Columns}";

        placeHolder = string.IsNullOrEmpty(Constraint) ? $"{placeHolder.TrimEnd(',')})" : $"{placeHolder}{Constraint})";

        return placeHolder.Replace("@placeholder_table_name", CustomEntityType.ToString());
    }
}

由於每次生成只有表名不同,所以我們將整個表資料結構進行快取,在其內部將表名進行替換就好。整個實現邏輯如下:

public static void Execute()
{
    using var scope = Program.ServiceProvider.CreateScope();
    var context = scope.ServiceProvider.GetService<EfDbContext>();

    context.Database.EnsureCreated();

    var cache = scope.ServiceProvider.GetService<IMemoryCache>();

    var cacheKey = context.GetType().FullName;

    if (!cache.TryGetValue(cacheKey, out List<CustomTableModel> models))
    {
        lock (_syncObject)
        {
            if (!cache.TryGetValue(cacheKey, out models))
            {
                models = CreateModels(context);

                models = cache.Set(cacheKey, models, new MemoryCacheEntryOptions { Size = 100, Priority = CacheItemPriority.High });
            }
        }
    }

    Create(context, models);
}

private static void Create(EfDbContext context, List<CustomTableModel> models)
{
    foreach (var m in models)
    {
        context.Execute(m.ToString());
    }
}

internal static void CreateEntityTypes(CustomEntityType customEntityType)
{
    EntityTypes.Add(customEntityType);
}

上述標紅部分很重要,為什麼呢?讓其先執行OnModelCreating方法,也就是說我們必須保證所有模型已經構建完畢,我們才能在上下文中拿到所有模型後設資料

 

接下來則是在OnModeCreating方法中,在啟動自動對映模型的基礎上,新增如下程式碼(當然也需檢查表名是否存在重複):

 if (attribute.EnableCustomTable)
  {
      entityBuilder.ToTable(attribute.ToString());

      var customType = new CustomEntityType()
      {
          ClrType = type,
          Prefix = attribute.Prefix,
          Format = attribute.Format
      };

      var existTable = CreateCustomTable.EntityTypes.FirstOrDefault(c => c.ToString() == customType.ToString());

      if (existTable != null)
      {
          throw new ArgumentNullException($"Cannot use table '{customType}' for entity type '{type.Name}' since it is being used for entity type '{existTable.ClrType.Name}' ");
      }

      CreateCustomTable.CreateEntityTypes(customType);
  }

相信構建SQL語句這塊都不在話下,就不再給出了,真的有需要的童鞋,可私信我,人比較多的話,我會將相容不同資料庫的SQL語句構建都會放到github上去,控制檯入口方法呼叫如下:

private const int TIME_INTERVAL_IN_MILLISECONDS = 60000;
private static Timer _timer { get; set; }
public static IServiceProvider ServiceProvider { get; set; }
static void Main(string[] args)
{
    ServiceProvider = Initialize();

    //初始化時檢查一次
    CreateCustomTable.Execute();

    //定時檢查
    _timer = new Timer(TimerCallback, null, TIME_INTERVAL_IN_MILLISECONDS, Timeout.Infinite);

    using (var scope1 = ServiceProvider.CreateScope())
    {
        var context1 = scope1.ServiceProvider.GetService<EfDbContext>();

        context1.Database.EnsureCreated();

        var type = context1.Model.FindEntityType(typeof(Test1));

        Console.WriteLine(type?.GetTableName());

        var tests = context1.Set<Test1>().ToList();
    }

    Thread.Sleep(60000);

    using (var scope2 = ServiceProvider.CreateScope())
    {
        var context2 = scope2.ServiceProvider.GetService<EfDbContext>();

        context2.Database.EnsureCreated();

        var type = context2.Model.FindEntityType(typeof(Test2));

        Console.WriteLine(type?.GetTableName());

        var tests1 = context2.Set<Test2>().ToList();
    }

    Console.ReadKey();

}

接下來則是通過定義上述定時器,回撥呼叫上述Execute方法,如下:

static void TimerCallback(object state)
{
      var watch = new Stopwatch();

      watch.Start();

      CreateCustomTable.Execute();

      _timer.Change(Math.Max(0, TIME_INTERVAL_IN_MILLISECONDS - watch.ElapsedMilliseconds), Timeout.Infinite);
 }

最後我們來兩個模型測試下實際效果

[EfEntity(EnableCustomTable = true, Prefix = "test1", Format = CustomTableFormat.MINUTE)]
public class Test1
{
    public int Id { get; set; }
    public int UserId { get; set; }
    public string Name { get; set; }
}

public class Test1EntityTypeConfiguration : IEntityTypeConfiguration<Test1>
{
    public void Configure(EntityTypeBuilder<Test1> builder)
    {
        builder.HasKey(k => new { k.Id, k.UserId });
    }
}


[EfEntity(EnableCustomTable = true, Prefix = "test2", Format = CustomTableFormat.MINUTE)]
public class Test2
{
    public int Id { get; set; }
    public int UserId { get; set; }
    public string Name { get; set; }
}

public class Test2EntityTypeConfiguration : IEntityTypeConfiguration<Test2>
{
    public void Configure(EntityTypeBuilder<Test2> builder)
    {
        builder.HasKey(k => new { k.Id, k.UserId });
    }
}

總結

最後的最後,老規矩,實現動態對映模型有如上兩種方式,通過手動構建SQL語句並快取,總結如下!

?  使用IModelCacheKeyFactory

 

? 手動使用模型、處置模型

 

  ? 相容不同資料庫,手動構建SQL語句並快取

相關文章