EntityFramework Core如何映射动态模型?
前言
本文我们来探讨下映射动态模型的几种方式,相信一部分童鞋项目有这样的需求,比如每天/每小时等生成一张表,此种动态模型映射非常常见,经我摸索,这里给出每一步详细思路,希望能帮助到没有任何头绪的童鞋,本文以.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 }); } }
总结
最后的最后,老规矩,实现动态模型映射有如上两种方式,而后可通过lambda或手动构建SQL语句并缓存创建表,总结如下!
💡 使用IModelCacheKeyFactory
💡 手动构建模型、使用模型、处置模型
💡 兼容不同数据库,手动构建SQL语句并缓存