EasySharding.EFCore 如何设计使用一套代码完成的EFCore Migration 构建Saas系统多租户不同业务需求且满足租户自定义分库分表、数据迁移能力?
阅读原文时间:2023年07月08日阅读:1

下面用一篇文章来完成这些事情

多租户系统的设计单纯的来说业务,一套Saas多租户的系统,面临很多业务复杂性,不同的租户存在不同的业务需求,大部分相同的表结构,那么如何使用EFCore来完成这样的设计呢?满足不同需求的数据库结构迁移

这里我准备设计一套中间件来完成大部分分库分表的工作,然后可以通过自定义的Migration 数据库文件来迁移构建不同的租户数据库和表,抛开业务处理不谈,单纯提供给业务处理扩展为前提的设计,姑且把这个中间件命名为:

EasySharding

原理:数据库Migation创建是利用 ModelCacheKeyFactory监控ModelCacheKey的模型是否存在变化来完成Create,并不是每次都需要Create

ModelCacheKeyFactory 通过 ModelCacheKey 的 Equals 方法返回的 Bool值 来确定是否需要Create

所以我们通过自定的类ShardingModelCacheKeyFactory来重写ModelCacheKeyFactory 的Create方法 ,ShardingModelCacheKey来重写ModelCacheKey的Equal方法

public class ShardingModelCacheKeyFactory : ModelCacheKeyFactory where T : DbContext, IShardingDbContext
{

    public ShardingModelCacheKeyFactory(ModelCacheKeyFactoryDependencies dependencies) : base(dependencies)  
    {  
    }  
    /// <summary>  
    /// 重写创建  
    /// </summary>  
    /// <param name="context"></param>  
    /// <returns></returns>  
    public override object Create(DbContext context)  
    {  
        var dbContext = context as T;  
        var key = string.Format("{0}{1}", dbContext?.ShardingInfo?.DatabaseTagName, dbContext?.ShardingInfo?.StufixTableName);  
        var strchangekey = string.IsNullOrEmpty(key) ? "0" : key;  
        return new ShardingModelCacheKey<T>(dbContext, strchangekey);  
    }

}

ShardingModelCacheKeyFactory

internal class ShardingModelCacheKey : ModelCacheKey where T : DbContext, IShardingDbContext
{
private readonly T _context;
///

/// _hashchangedid ///
private readonly string _hashchangedid;
public ShardingModelCacheKey(T context, string hashchangedid) : base(context)
{
this._context = context;
this._hashchangedid = hashchangedid;
}

    public override int GetHashCode()  
    {

        var hashCode = base.GetHashCode();

        if (\_hashchangedid != null)  
        {  
            hashCode ^= \_hashchangedid.GetHashCode();  
        }  
        else  
        {  
            //构成已经默认了 一般不得出发异常  
            throw new Exception("this is no tenantid");  
        }

        return hashCode;  
    }

    /// <summary>  
    /// 判断模型更改缓存是否需要创建Migration  
    /// </summary>  
    /// <param name="other"></param>  
    /// <returns></returns>  
    protected override bool Equals(ModelCacheKey other)  
    {  
        return base.Equals(other) && (other as ShardingModelCacheKey<T>)?.\_hashchangedid == \_hashchangedid;  
    }  
}

ShardingModelCacheKey

设计一个变量值,通过记录并比较 _hashchangedid 一个改变的标识的hashcode值来确定,所以后续只需要 ModelCacheKey Equal 的返回值 来告诉你什么时候应该发生Migration数据迁移创建了,为后续的业务提供支持

做到这一步骤看起来一切都是ok的,然而有很多问题?怎么按照租户去生成库或表,不同租户表结构不同怎么办?Migration迁移文件怎么维护?多个租户Migration交错混乱怎么办?

为了满足不同租户的需求,为此设计了一个ShardingInfo的类对租户提供可改变的数据库上下文对象以及数据库或表区别的类来告诉ModelCacheKey 不同的变化,为了提供更多的场景,这里提供租户 可以分库,可以分表、亦可以区分数据行,都需要结合业务实现,这里不做过多讨论

///

/// 黎又铭 2021.10.30 ///
public class ShardingInfo
{
/// /// 数据库地址 租户可分库 ///
public string ConStr { get; set; }
/// /// 数据库名称标识,分库特殊意义,唯一,结合StufixTableName来识别Migration文件变化缓存hashcode值 ///
public string DatabaseTagName { get; set; }
/// /// 分表处理 租户可分表 ///
public string StufixTableName { get; set; }
/// /// 租户也可分数据行 ///
public string TenantId { get; set; }
}

设计这个类很有必要,第一为了提供给数据库上下文扩展变化,第二利用它的字段来确定变化,第三后续根据它来完成Migiration的差异变化

接下来就是需要根据ShardingInfo的变化来创建不同的的表结构了,怎么来实现呢?

添加模型类,通过命令生成Migration迁移文件,程序第一次生成不会有错,而当我们的ShardingInfo发生变化出发CreateMigration的时候表就会存在发生二次创建错误,原因是我们记录了创建变化而没有记录Migration文件的变化关联

所以这里还需要处理一个关键类,重写MigrationsAssembly的CreateMigration方法,将ShardingInfo变化告诉Migration文件,所以要做到这一步骤,还需要对每次数据迁移变化的Migration文件进行改造以及CodeFirst中自定义的数据表结构稍微修改下

public ShardingMigrationAssembly(ICurrentDbContext currentContext, IDbContextOptions options, IMigrationsIdGenerator idGenerator, IDiagnosticsLogger logger) : base(currentContext, options, idGenerator, logger)
{
context = currentContext.Context;
}
public override Migration CreateMigration(TypeInfo migrationClass, string activeProvider)
{

        if (activeProvider == null)  
            throw new ArgumentNullException($"{nameof(activeProvider)} argument is null");

        var hasCtorWithSchema = migrationClass  

.GetConstructor(new[] { typeof(ShardingInfo) }) != null;

        if (hasCtorWithSchema && context is IShardingDbContext tenantDbContext)  
        {  
            var instance = (Migration)Activator.CreateInstance(migrationClass.AsType(), tenantDbContext?.ShardingInfo);  
            instance.ActiveProvider = activeProvider;  
            return instance;  
        }  
        return base.CreateMigration(migrationClass, activeProvider);

    }

ShardingMigrationAssembly

将变化告诉Migration迁移文件,好在迁移文件中做对于修改 下面是一个Demo Migrationi迁移文件

public partial class initdata : Migration
{
private readonly ShardingInfo _shardingInfo;
public initdata(ShardingInfo shardingInfo)
{
_shardingInfo = shardingInfo;
}
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySql:CharSet", "utf8mb4");

        migrationBuilder.CreateTable(  
            name: $"OneDayTable{\_shardingInfo.GetName()}" ,  
            columns: table => new  
            {  
                Id = table.Column<int>(type: "int", nullable: false)  
                    .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),  
                Day = table.Column<string>(type: "longtext", nullable: true)  
                    .Annotation("MySql:CharSet", "utf8mb4")  
            },  
            constraints: table =>  
            {  
                table.PrimaryKey("PK\_OneDayTables", x => x.Id);  
            })  
            .Annotation("MySql:CharSet", "utf8mb4");

        migrationBuilder.CreateTable(  
            name: $"Test{\_shardingInfo.GetName()}",  
            columns: table => new  
            {  
                Id = table.Column<int>(type: "int", nullable: false)  
                    .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),  
                TestName = table.Column<string>(type: "longtext", nullable: true)  
                    .Annotation("MySql:CharSet", "utf8mb4")  
            },  
            constraints: table =>  
            {  
                table.PrimaryKey("PK\_Tests", x => x.Id);  
            }  
           // schema: \_shardingInfo.StufixTableName  
            )  
            .Annotation("MySql:CharSet", "utf8mb4");  
    }

    protected override void Down(MigrationBuilder migrationBuilder)  
    {  
        migrationBuilder.DropTable(  
            name: $"OneDayTable{\_shardingInfo.GetName()}");

        migrationBuilder.DropTable(  
            name: $"Test{\_shardingInfo.GetName()}");  
    }  
}

initdata

通过构造函数接受迁移变化类,就可以告诉它不同的变化生成不同的表了

做到这里似乎可以生成了,这里还需要注意Migraion文件迁移表__efmigrationshistory的变化问题,需要为不同的表结构变化生成不同的 __efmigrationshistory 历史记录表,防止同一套系统中不同的表结构迁移被覆盖的情况

需要注意的是这里的记录表需要结合变化类ShardingInfo文件来完成

builder.MigrationsHistoryTable($"__EFMigrationsHistory_{base.ShardingInfo.GetName()}");

可以参见下这里:https://docs.microsoft.com/zh-cn/ef/core/managing-schemas/migrations/history-table

我还不得不为它提供一些方便的扩展方法来更好的完成这个操作,例如 IEntityTypeConfiguration 的处理,可能就是为了少写结构构造函数,尴尬~

public class ShardingEntityTypeConfigurationAbstract : IEntityTypeConfiguration where T : class
{
///

/// 这个字段将提供扩展自定义规则的后缀名称 ///
public string _suffix { get; set; } = string.Empty;

    public ShardingEntityTypeConfigurationAbstract(string suffix)  
    {  
        this.\_suffix = suffix;

    }  
    public ShardingEntityTypeConfigurationAbstract()  
    {  
    }  
    public virtual void Configure(EntityTypeBuilder<T> builder)  
    {

    }  
}

ShardingEntityTypeConfigurationAbstract

public class TestMap : ShardingEntityTypeConfigurationAbstract
{

    public override void Configure(EntityTypeBuilder<Test> builder)  
    {  
        builder.ToTable($"Test"+ \_suffix);

    }  
}

TestMap

最后为了支持EFCore的查询我们还需要处理查询DbSet 属性,为了让我们在调用查询的时候不用去考虑当前分表的是哪一个分表,只需要关注 Tests本身 不用去管变化,扩展了ToTableSharding的方法

public DbSet Tests { get; set; }

结合上诉处理,我在模型创建重载里面这样处理如下:自定义的模型对象关系配置

modelBuilder.ApplyConfiguration(new ShardingEntityTypeConfigurationAbstract(ShardingInfo.GetName()));
modelBuilder.ApplyConfiguration(new ShardingEntityTypeConfigurationAbstract(ShardingInfo.GetName()));
modelBuilder.ApplyConfiguration(new ShardingEntityTypeConfigurationAbstract());

查询对象表关系配置

modelBuilder.Entity().ToTableSharding("Test", ShardingInfo);
modelBuilder.Entity().ToTableSharding("OneDayTable", ShardingInfo);
modelBuilder.Entity().ToTable("TestTwo");

这个时候我们是不是该注入DbContext上下文对象了,这里修改了一些东西,定义业务上下文对象BusinessContext ,这里需要继承分库上下文对象

public class BusinessContext : ShardingDbContext
{

    IConfiguration \_configuration;

    #region Migrations 修改文件

    #endregion  
    public DbSet<Test> Tests { get; set; }  
    public DbSet<OneDayTable> OneDayTables { get; set; }

    public DbSet<TestTwo> TestTwos { get; set; }  
    public BusinessContext(DbContextOptions<BusinessContext> options, ShardingInfo shardingInfo, IConfiguration configuration) : base(options, shardingInfo)  
    {  
        \_configuration = configuration;  
    }

    public BusinessContext(ShardingInfo shardingInfo, IConfiguration configuration) : base(shardingInfo)  
    {  
        \_configuration = configuration;  
    }

}

BusinessContext

这里我提供了一个构造函数来接受创建自定义变化的上下文对象,并且在扩展变化的构造函数ShardingDbContext中执行了一次Migrate来促发更改迁移,这个自定义的上下文对象在创建的时候促发Migrate,然后根据传递的变化文件的hashcode值来确定是否需要CreateMigration操作

为了让调用看起来不会有那么的new BusinessContext(new Sharding{  });这样的操作,何况存在多个数据库上下文对象的情况,这样就不漂亮了,所以稍加修改了下:

public class ShardingConnection where TContext : DbContext
{
public TContext con;
public ShardingConnection(TContext context)
{
con = context;
}
public TContext GetContext()
{
return con;
}
public TContext GetShardingContext(ShardingInfo shardingInfo)
{

        return (TContext)Activator.CreateInstance(typeof(TContext), shardingInfo,null);

    }  
}

ShardingConnection

定义了ShardingConnection泛型类来获取未区分的表结构连接或创建区分的表结构上下文对象,让后DI注入下,这样写起来好像就ok了

下面来实现看看,这里定义了分库 两个库easysharding 和 easysharding1  ,easysharding 按 oranizeA 分了表, easysharding1 按 oranizeB 和当前日期分了表

贴上测试代码:

public class HomeController : Controller
{
ShardingConnection context;
public HomeController(ShardingConnection shardingConnection)
{
context = shardingConnection;

    }  
    \[HttpGet\]  
    public IActionResult Index(string id)  
    {  
        var con = context.GetContext();  
        con.Tests.Add(new MySql.Test { Id = new Random().Next(0, 10000), TestName = "11111111" });  
        con.SaveChanges();  
        var c = con.Tests.AsNoTracking().ToList();

        var con1 = context.GetShardingContext(new ShardingInfo  
        {  
            DatabaseTagName = $"easysharding",  
            StufixTableName = $"oranizeA",  
            ConStr = $"server=192.168.0.208;port=3306;user=root;password=N5\_?MCaE$wDD;database=easysharding;SslMode=none;",  
        });

        con1.Tests.Add(new MySql.Test { Id = new Random().Next(0, 10000), TestName = "oranizeA" });  
        con1.SaveChanges();  
        var c1 = con1.Tests.AsNoTracking().ToList();

        var con2 = context.GetShardingContext(new ShardingInfo  
        {  
            DatabaseTagName = $"easysharding1",  
            StufixTableName = $"oranizeB",  
            ConStr = $"server=192.168.0.208;port=3306;user=root;password=N5\_?MCaE$wDD;database=easysharding1;SslMode=none;",  
        });

        con2.Tests.Add(new MySql.Test { Id = new Random().Next(0, 10000), TestName = "oranizeB" });  
        con2.SaveChanges();  
        var c2 = con2.Tests.AsNoTracking().ToList();

        //针对一些需求,需要每天生成表的  
        var con3 = context.GetShardingContext(new ShardingInfo  
        {  
            DatabaseTagName = $"easysharding1",  
            StufixTableName = $"{string.Format("{0:yyyyMMdd}",DateTime.Now)}",  
            ConStr = $"server=192.168.0.208;port=3306;user=root;password=N5\_?MCaE$wDD;database=easysharding1;SslMode=none;",  
        });

        con3.Tests.Add(new MySql.Test { Id = new Random().Next(0, 10000), TestName = "日期日期日期" });  
        con3.SaveChanges();  
        var c3 = con3.Tests.AsNoTracking().ToList();

        return Ok();  
    }  
}

测试代码

查看结果,添加查询都到了对应的库或表里面,注意前面说到的查询 Tests 始终如一,但是数据却来之不同的库 不同的表了,有点那个么个意思了~

接下来看下数据库迁移情况,忽略我前面测试创建的表

这样看起来似乎就没有问题了吗?其实还是存在问题,其一,没有完成前面说的 不同数据库表结构不同,不同租户表结构不同的要求,其二,如果业务中更新的表是通用的表结构迁移文件,在不同租户访问触发migraion文件改变导致 创建的表结构已经存在的问题,为了解决这2个问题又不得不处理下了,上图中其实细心发现  testtwo这个表结构只在easysharding1库中存在,testtwo可视为对某一个差异结构变化而特殊生成的migraton迁移文件,从而满足上面的要求,定义自己的规则方法MigrateWhenNoSharding来确定这个变化对那些变化执行

if (_shardingInfo.MigrateWhenNoSharding())
{

            migrationBuilder.CreateTable(  
                name: "TestTwo",  
                columns: table => new  
                {  
                    Id = table.Column<int>(type: "int", nullable: false)  
                        .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),  
                    TestName = table.Column<string>(type: "longtext", nullable: true)  
                        .Annotation("MySql:CharSet", "utf8mb4")  
                },  
                constraints: table =>  
                {  
                    table.PrimaryKey("PK\_TestTwo", x => x.Id);  
                })  
                .Annotation("MySql:CharSet", "utf8mb4");  
        }

testtwo

处理好这一步,基本就完成了

参见:

https://docs.microsoft.com/zh-cn/dotnet/api/microsoft.entityframeworkcore.infrastructure.modelcachekey?view=efcore-5.0

https://docs.microsoft.com/zh-cn/dotnet/api/microsoft.entityframeworkcore.infrastructure.modelcachekeyfactory.create?view=efcore-5.0

https://www.thinktecture.com/en/entity-framework-core/changing-db-migration-schema-at-runtime-in-2-1/

项目GitHub 地址:https://github.com/woshilangdanger/easysharding.efcore