如何設計使用一套程式碼完成的EFCore Migration 構建Saas系統多租戶不同業務需求且滿足租戶自定義分庫分表、資料遷移能力?
下面用一篇文章來完成這些事情
多租戶系統的設計單純的來說業務,一套Saas多租戶的系統,面臨很多業務複雜性,不同的租戶存在不同的業務需求,大部分相同的表結構,那麼如何使用EFCore來完成這樣的設計呢?滿足不同需求的資料庫結構遷移
這裡我準備設計一套中介軟體來完成大部分分庫分表的工作,然後可以通過自定義的Migration 資料庫檔案來遷移構建不同的租戶資料庫和表,拋開業務處理不談,單純提供給業務處理擴充套件為前提的設計,姑且把這個中介軟體命名為:
EasySharding
原理:資料庫Migation建立是利用ModelCacheKeyFactory監控ModelCacheKey的模型是否存在變化來完成Create,並不是每次都需要Create
ModelCacheKeyFactory 通過ModelCacheKey 的 Equals 方法返回的 Bool值 來確定是否需要Create
所以我們通過自定的類ShardingModelCacheKeyFactory來重寫ModelCacheKeyFactory 的Create方法 ,ShardingModelCacheKey來重寫ModelCacheKey的Equal方法
public class ShardingModelCacheKeyFactory<T> : ModelCacheKeyFactory where T : DbContext, IShardingDbContext {ShardingModelCacheKeyFactorypublic ShardingModelCacheKeyFactory(ModelCacheKeyFactoryDependencies dependencies) : base(dependencies) { } /// <summary> /// 重寫建立 /// </summary> /// <param name="context"></param> /// <returns></returns> public overrideobject 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); } }
internal class ShardingModelCacheKey<T> : ModelCacheKey where T : DbContext, IShardingDbContext { private readonly T _context; /// <summary> /// _hashchangedid /// </summary> 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 不同的變化,為了提供更多的場景,這裡提供租戶 可以分庫,可以分表、亦可以區分資料行,都需要結合業務實現,這裡不做過多討論
/// <summary> /// 黎又銘 2021.10.30 /// </summary> public class ShardingInfo { /// <summary> /// 資料庫地址 租戶可分庫 /// </summary> public string ConStr { get; set; } /// <summary> /// 資料庫名稱標識,分庫特殊意義,唯一,結合StufixTableName來識別Migration檔案變化快取hashcode值 /// </summary> public string DatabaseTagName { get; set; } /// <summary> /// 分表處理 租戶可分表 /// </summary> public string StufixTableName { get; set; } /// <summary> /// 租戶也可分資料行 /// </summary> public string TenantId { get; set; } }View Code
設計這個類很有必要,第一為了提供給資料庫上下文擴充套件變化,第二利用它的欄位來確定變化,第三後續根據它來完成Migiration的差異變化
接下來就是需要根據ShardingInfo的變化來建立不同的的表結構了,怎麼來實現呢?
新增模型類,通過命令生成Migration遷移檔案,程式第一次生成不會有錯,而當我們的ShardingInfo發生變化出發CreateMigration的時候表就會存在發生二次建立錯誤,原因是我們記錄了建立變化而沒有記錄Migration檔案的變化關聯
所以這裡還需要處理一個關鍵類,重寫MigrationsAssembly的CreateMigration方法,將ShardingInfo變化告訴Migration檔案,所以要做到這一步驟,還需要對每次資料遷移變化的Migration檔案進行改造以及CodeFirst中自定義的資料表結構稍微修改下
public ShardingMigrationAssembly(ICurrentDbContext currentContext, IDbContextOptions options, IMigrationsIdGenerator idGenerator, IDiagnosticsLogger<DbLoggerCategory.Migrations> 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<T> : IEntityTypeConfiguration<T> where T : class { /// <summary> /// 這個欄位將提供擴充套件自定義規則的字尾名稱 /// </summary> 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<Test> { public override void Configure(EntityTypeBuilder<Test> builder) { builder.ToTable($"Test"+ _suffix); } }TestMap
最後為了支援EFCore的查詢我們還需要處理查詢DbSet<Test> 屬性,為了讓我們在呼叫查詢的時候不用去考慮當前分表的是哪一個分表,只需要關注 Tests本身 不用去管變化,擴充套件了ToTableSharding的方法
public DbSet<Test> Tests { get; set; }
結合上訴處理,我在模型建立過載裡面這樣處理如下:自定義的模型物件關係配置
modelBuilder.ApplyConfiguration(new ShardingEntityTypeConfigurationAbstract<Test>(ShardingInfo.GetName())); modelBuilder.ApplyConfiguration(new ShardingEntityTypeConfigurationAbstract<OneDayTable>(ShardingInfo.GetName())); modelBuilder.ApplyConfiguration(new ShardingEntityTypeConfigurationAbstract<TestTwo>());
查詢物件表關係配置
modelBuilder.Entity<Test>().ToTableSharding("Test", ShardingInfo); modelBuilder.Entity<OneDayTable>().ToTableSharding("OneDayTable", ShardingInfo); modelBuilder.Entity<TestTwo>().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操作
為了讓呼叫看起來不會有那麼的newBusinessContext(new Sharding{ });這樣的操作,何況存在多個數據庫上下文物件的情況,這樣就不漂亮了,所以稍加修改了下:
public class ShardingConnection<TContext> 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<BusinessContext> context; public HomeController(ShardingConnection<BusinessContext> 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
處理好這一步,基本就完成了
參見:
如果您覺得閱讀本文對您有幫助,請點一下“推薦”按鈕,您的“推薦”將是我最大的寫作動力!
本文版權歸作者和部落格園共有,來源網址:http://www.cnblogs.com/liyouming歡迎各位轉載,但是未經作者本人同意,轉載文章之後必須在文章頁面明顯位置給出作者和原文連線。