前言
在上一篇文章中,我們介紹了如何根據不同的租戶進行數據分離,分離的辦法是一個租戶一個數據庫。
也提到了這種模式還是相對比較重,所以本文會介紹一種更加普遍使用的辦法: 按表分離租戶。
這樣做的好處是什么:
在目前的to B的系統中,其實往往會有一個Master數據庫,里面使用的是系統中主要的數據,各個租戶的數據,往往只是對應的訂單、配置、客戶信息。
這就造成了,租戶的數據不會有很多的種類,他的數據表的數量相對Master來說還是比較少的。
所以在單一租戶數據量沒有十分龐大的時候,就沒有必要對單一租戶數據獨立到單一數據庫。多個租戶數據共享使用一個數庫是一個折中的選擇。
下圖就是對應的數據表結構,其中store1和store2使用不同的數據表,但有同一個表名后綴和相同結構。
實施
項目介紹
本文的項目還是沿用上一篇文章的代碼,進行加以修改。所以項目中的依賴項還是那些。
但由於代碼中有很多命名不好的地方我進行了修改。並且,由於代碼結構太簡單,對這個示例實現起來不好,進行了少量的結構優化。
項目中新增的對象有什么:
1. ModelCacheKeyFactory,這個是EF core提供的對象,主要是要來產生ModelCacheKey
2. ModelCacheKey, 這個跟ModelCacheKeyFactory是一對的,如果需要自定義的話一般要同時實現他們倆
3. ConnectionResolverOption,這個是項目自定義的對象,用於配置。因為我們項目中現在需要同時支持多種租戶數據分離的方式
實施步驟
1. 添加 ITenantDbContext 接口,它的作用是要來規定StoreDbContext中,必須可以返回TenantInfo。
1 public interface ITenantDbContext 2 { 3 TenantInfo TenantInfo{get;} 4 }
我們同時也需要修改StoreDbContext去實現 ITenantDbContext 接口,並且在構造函數上添加TenantInfo的注入
其中Products已經不是原來簡單的一個Property,這里使用DbSet來獲取對應的對象,因為表對象還是使用只讀Property會好點。
新增一個方法的重寫OnModelCreating,這個方法的主要規定EF core 的表實體(本文是Product)怎么跟數據庫匹配的,簡單來說就是配置。
可以看到表名的規則是TenantInfo.Name+"_Products"

1 public class StoreDbContext : DbContext,ITenantDbContext 2 { 3 public DbSet<Product> Products => this.Set<Product>(); 4 5 public TenantInfo TenantInfo => tenantInfo; 6 7 private readonly TenantInfo tenantInfo; 8 9 public StoreDbContext(DbContextOptions options, TenantInfo tenantInfo) : base(options) 10 { 11 this.tenantInfo = tenantInfo; 12 } 13 14 protected override void OnModelCreating(ModelBuilder modelBuilder) 15 { 16 modelBuilder.Entity<Product>().ToTable(this.tenantInfo.Name + "_Products"); 17 } 18 }
2. 創建 TenantModelCacheKeyFactory 和 TenantModelCacheKey
TenantModelCacheKeyFactory的作用主要是創建TenantModelCacheKey實例。TenantModelCacheKey的作用是作為一個鍵值,標識dbContext中的OnModelCreating否需要調用。
為什么這樣做呢?因為ef core為了優化效率,避免在dbContext每次實例化的時候,都需要重新構建數據實體模型。
在默認情況下,OnModelCreating只會調用一次就會存在緩存。但由於我們創建了TenantModelCacheKey,使得我們有機會判斷在什么情況下需要重新調用OnModelCreating
這里是本文中最關鍵的改動

1 using System; 2 using Microsoft.EntityFrameworkCore; 3 using Microsoft.EntityFrameworkCore.Infrastructure; 4 5 namespace kiwiho.Course.MultipleTenancy.EFcore.Api.Infrastructure 6 { 7 internal sealed class TenantModelCacheKeyFactory<TContext> : ModelCacheKeyFactory 8 where TContext : DbContext, ITenantDbContext 9 { 10 11 public override object Create(DbContext context) 12 { 13 var dbContext = context as TContext; 14 return new TenantModelCacheKey<TContext>(dbContext, dbContext?.TenantInfo?.Name ?? "no_tenant_identifier"); 15 } 16 17 public TenantModelCacheKeyFactory(ModelCacheKeyFactoryDependencies dependencies) : base(dependencies) 18 { 19 } 20 } 21 22 internal sealed class TenantModelCacheKey<TContext> : ModelCacheKey 23 where TContext : DbContext, ITenantDbContext 24 { 25 private readonly TContext context; 26 private readonly string identifier; 27 public TenantModelCacheKey(TContext context, string identifier) : base(context) 28 { 29 this.context = context; 30 this.identifier = identifier; 31 } 32 33 protected override bool Equals(ModelCacheKey other) 34 { 35 return base.Equals(other) && (other as TenantModelCacheKey<TContext>)?.identifier == identifier; 36 } 37 38 public override int GetHashCode() 39 { 40 var hashCode = base.GetHashCode(); 41 if (identifier != null) 42 { 43 hashCode ^= identifier.GetHashCode(); 44 } 45 46 return hashCode; 47 } 48 } 49 }
3. 添加 ConnectionResolverOption 類和 ConnectionResolverType 枚舉。

1 using System; 2 3 namespace kiwiho.Course.MultipleTenancy.EFcore.Api.Infrastructure 4 { 5 public class ConnectionResolverOption 6 { 7 public string Key { get; set; } = "default"; 8 9 public ConnectionResolverType Type { get; set; } 10 11 public string ConnectinStringName { get; set; } 12 } 13 14 public enum ConnectionResolverType 15 { 16 Default = 0, 17 ByDatabase = 1, 18 ByTabel = 2 19 } 20 }
4. 調整 MultipleTenancyExtension 的代碼結構,並且添加2個擴展函數用於對配置相關的注入。
下面貼出修改過后最主要的3個方法

1 internal static IServiceCollection AddDatabase<TDbContext>(this IServiceCollection services, 2 ConnectionResolverOption option) 3 where TDbContext : DbContext, ITenantDbContext 4 { 5 services.AddSingleton(option); 6 7 services.AddScoped<TenantInfo>(); 8 services.AddScoped<ISqlConnectionResolver, TenantSqlConnectionResolver>(); 9 services.AddDbContext<TDbContext>((serviceProvider, options) => 10 { 11 var resolver = serviceProvider.GetRequiredService<ISqlConnectionResolver>(); 12 13 var dbOptionBuilder = options.UseMySql(resolver.GetConnection()); 14 if (option.Type == ConnectionResolverType.ByTabel) 15 { 16 dbOptionBuilder.ReplaceService<IModelCacheKeyFactory, TenantModelCacheKeyFactory<TDbContext>>(); 17 } 18 }); 19 20 return services; 21 } 22 23 public static IServiceCollection AddTenantDatabasePerTable<TDbContext>(this IServiceCollection services, 24 string connectionStringName, string key = "default") 25 where TDbContext : DbContext, ITenantDbContext 26 { 27 var option = new ConnectionResolverOption() 28 { 29 Key = key, 30 Type = ConnectionResolverType.ByTabel, 31 ConnectinStringName = connectionStringName 32 }; 33 34 return services.AddTenantDatabasePerTable<TDbContext>(option); 35 } 36 37 public static IServiceCollection AddTenantDatabasePerTable<TDbContext>(this IServiceCollection services, 38 ConnectionResolverOption option) 39 where TDbContext : DbContext, ITenantDbContext 40 { 41 if (option == null) 42 { 43 option = new ConnectionResolverOption() 44 { 45 Key = "default", 46 Type = ConnectionResolverType.ByTabel, 47 ConnectinStringName = "default" 48 }; 49 } 50 51 52 return services.AddDatabase<TDbContext>(option); 53 }
其中有一個關鍵的配置, 需要把上文提到的 TenantModelCacheKeyFactory 配置到dbOptionBuilder
1 if (option.Type == ConnectionResolverType.ByTabel) 2 { 3 dbOptionBuilder.ReplaceService<IModelCacheKeyFactory,TenantModelCacheKeyFactory<TDbContext>>(); 4 }
5. 在 TenantSqlConnectionResolver 的GetConnection方法中修改邏輯,讓它同時支持按表分離數據和前文的按數據庫分離數據
這個類的名字已經改了,前文的命名不合適。 方法中用到的 option 是 ConnectionResolverOption 類型,需要加到構造函數。

1 public string GetConnection() 2 { 3 string connectionString = null; 4 switch (this.option.Type) 5 { 6 case ConnectionResolverType.ByDatabase: 7 connectionString = configuration.GetConnectionString(this.tenantInfo.Name); 8 break; 9 case ConnectionResolverType.ByTabel: 10 connectionString = configuration.GetConnectionString(this.option.ConnectinStringName); 11 break; 12 } 13 14 if (string.IsNullOrEmpty(connectionString)) 15 { 16 throw new NullReferenceException("can not find the connection"); 17 } 18 return connectionString; 19 }
驗證效果
前提條件
在本文中,並沒有使用Code First配置數據庫。所以數據庫和數據表需要自行創建。
這樣做其實更加貼合項目實際,因為具有這種軟件架構的項目,往往需要在新增租戶的時候進行自動化處理,普遍做法是准備好一批sql,在新增租戶的時候自動在對應的數據庫中創建一批表
可能會有人提出疑問,覺得ef core提供的Migration是具有同樣的作用的。這個的確是,但是我們這里的表是動態的,ef core生成的Migration plan其實是需要做手動修改的。
Migration 的修改和自定義話是一個大話題,這個需要開另外的文章談
關於本示例的ef core Migration 實操,請參閱我的另一篇文章
EF core (code first) 通過自定義 Migration History 實現多租戶使用同一數據庫時更新數據庫結構
建表腳本

1 CREATE TABLE `store1_Products` ( 2 `Id` int(11) NOT NULL AUTO_INCREMENT, 3 `Name` varchar(50) CHARACTER SET utf8mb4 NOT NULL, 4 `Category` varchar(50) CHARACTER SET utf8mb4 DEFAULT NULL, 5 `Price` double DEFAULT NULL, 6 PRIMARY KEY (`Id`) 7 ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=latin1; 8 9 CREATE TABLE `store2_Products` ( 10 `Id` int(11) NOT NULL AUTO_INCREMENT, 11 `Name` varchar(50) CHARACTER SET utf8mb4 NOT NULL, 12 `Category` varchar(50) CHARACTER SET utf8mb4 DEFAULT NULL, 13 `Price` double DEFAULT NULL, 14 PRIMARY KEY (`Id`) 15 ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=latin1;
調用接口
我們還是跟前文一樣,分別使用store1和store2仲添加一些數據。
調動查詢所有product接口
store1:
store2:
總結
這個示例已經完成了。跟前文一樣,是一個實操類型的文章。
下一步是什么:
下一次我們談談怎么根據Schema分離數據。但是Mysql是沒有Schema這個概念的,所以我們需要把SqlServer集成進來
但這樣把項目的復雜性又提高的。所以這一次必須把代碼抽象好了。
關於代碼
代碼已經傳上github,請查看part2的分支或查看commit tag是part2的代碼內容。
https://github.com/woailibain/EFCore.MultipleTenancyDemo/tree/part2