EntityFramework Core如何映射动态模型?

前言

本文咱们来探讨下映射动态模型的几种方式,相信一部分童鞋项目有这样的需求,好比天天/每小时等生成一张表,此种动态模型映射很是常见,经我摸索,这里给出每一步详细思路,但愿能帮助到没有任何头绪的童鞋,本文以.NET Core 3.1控制台,同时以SQL Server数据库做为示例演示(其余数据库同理照搬),因为会用到内置APi,因版本不一样可能好比构造函数需略微进行调整便可。注:虽为示例代码,但我将其做为实际项目皆已进行封装,基本彻底通用。本文略长,请耐心。git

动态映射模型引入前提

首先咱们给出所须要用到的特性以及对应枚举,看注释一看便知github

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属性访问、其二:每一个模型可定义动态映射表规则web

动态映射模型方式(一)

首先咱们给出须要用到的上下文,为方便演示咱们以每分钟自动映射模型为例数据库

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方法,若咱们在模型上使用注解,则对应也会将其应用app

 

但注解不够灵活,好比要标识联合主键,则只能使用Fluent APi,因此咱们经过在外部实现IEntityTypeConfiguration进行注册,而后EF Core提供针对该接口程序集注册,其底层本质也是扫描程序集,两种方式都支持,不用再担忧外部模型注册问题ide

 

紧接着咱们给出测试模型,表名为当前分钟,表名利用注解则不行(值必须为常量),因此咱们使用以下第二种映射模型函数

[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语句并缓存

相关文章
相关标签/搜索