前言
上一篇隨筆我們談到了多租戶模式,通過多租戶模式的演化的例子。大致歸納和總結了幾種模式的表現形式。
並且順帶提到了讀寫分離。
通過好幾次的代碼調整,使得這個庫更加通用。今天我們聊聊怎么通過該類庫快速接入多租戶。
類庫地址:
https://github.com/woailibain/kiwiho.EFcore.MultiTenant
實施
這次實例的代碼,完全引用上面github地址中的 traditional_and_multiple_context 的例子。
從實例的名稱可以知道,我們主要演示典型的多組戶模式,並且在同一個系統中同時支持多個 DbContext
為什么一個系統要同時支持多個 DbContext
其實回答這個問題還是要回到你們系統為什么要多租戶模式上。無非是系統性能瓶頸、數據安全與隔離問題。
1. 系統性能問題,那系統是經過長時間的洗禮的,就是說多租戶是系統結構演化的過程。以前的系統,以單體為主,一個系統一個數據庫。
要演化, 肯定需要一個過程,所以將一個數據庫按業務類型分割成多個數據庫就是順理成章的事情。
2. 數據安全與隔離問題,其實數據安全和隔離,並不需要全部數據都進行隔離。例如,一些公司可能只對自己客戶的資料進行隔離,可能只對敏感數據隔離。
那么我們大可按業務分開好幾個模塊,將敏感的數據使用數據庫分離模式隔離數據,對不敏感數據通過數據表模式進行隔離,節省資源。
項目結構
我們先來看看項目結構。分別有2個項目:一個是Api,另一個DAL。
這里涉及到一個問題,為什么要分開Api和DAL。其實是為了模擬當今項目中主流的項目結構,最起碼數據層和邏輯操作層是分開的。
Api的結構和引用,可以看到Api幾乎引用了MultiTenant的所有包,並且包含DAL。
其實這里的****.MySql ,***.SqlServer和****.Postgre三個包,只需要引用一個即可,由於這個example是同時使用了3個數據庫,才需要同時引用多個。
DAL的結構和引用,DAL的引用就相對簡單了,只需要引用DAL和Model即可
實施詳解
DAL詳解
DAL既然是數據層,那么DbContext和Entity是必須的。這里同時有 CustomerDbContext 和 StoreDbContext 。
我們首先看看 StoreDbContext ,它主要包含 Product 產品表。
里面有幾個要點:
1. StoreDbContext 必須繼承自 TenantBaseDbContext
2. 構造函數中的第一個參數 options ,需要使用泛型 DbContextOptions<> 類型傳入。(如果整個系統只有一個DbContext,那么這里可以使用 DbContextOptions 代替)
3. 重寫 OnModelCreating 方法。這個並不是必要步驟。但由於大部分 DbContext 都需要通過該方法定義數據庫實體結構,所以如果有重寫這個方法,必須要顯式調用 base.OnModelCreating
4. 公開的屬性 Products,代表product表。
1 public class StoreDbContext : TenantBaseDbContext 2 { 3 public DbSet<Product> Products => this.Set<Product>(); 4 5 public StoreDbContext(DbContextOptions<StoreDbContext> options, TenantInfo tenant, IServiceProvider serviceProvider) 6 : base(options, tenant, serviceProvider) 7 { 8 9 } 10 11 protected override void OnModelCreating(ModelBuilder modelBuilder) 12 { 13 base.OnModelCreating(modelBuilder); 14 } 15 }
現在看看 CustomerDbContext ,它主要包含 Instruction 訂單表
這里使用了精簡的DbContext實現方式,只包含了公開的Instructions屬性和構造函數
1 public class CustomerDbContext : TenantBaseDbContext 2 { 3 public DbSet<Instruction> Instructions => this.Set<Instruction>(); 4 public CustomerDbContext(DbContextOptions<CustomerDbContext> options, TenantInfo tenant, IServiceProvider serviceProvider) 5 : base(options, tenant, serviceProvider) 6 { 7 } 8 }
剩下的2個類分別是 Product 和 Instruction 。他們沒有什么特別的,就是普通Entity

1 public class Product 2 { 3 [Key] 4 public int Id { get; set; } 5 6 [StringLength(50), Required] 7 public string Name { get; set; } 8 9 [StringLength(50)] 10 public string Category { get; set; } 11 12 public double? Price { get; set; } 13 }

1 public class Instruction 2 { 3 [Key] 4 public int Id { get; set; } 5 6 public DateTime Date { get; set; } 7 8 public double TotalAmount { get; set; } 9 10 [StringLength(200)] 11 public string Remark { get; set; } 12 13 }
Api詳解
Startup
Startup作為asp.net core的配置入口,我們先看看這里
首先是ConfigureService 方法。這里主要配置需要使用的服務和注冊
1. 我們通過 AddMySqlPerConnection 擴展函數,添加對 StoreDbContext 的使用,使用的利用數據庫分離租戶間數據的模式
里面配置的 ConnectionPerfix,代表配置文件中前綴是 mysql_ 的連接字符串,可以提供給 StoreDbContext 使用。
2. 通過 AddMySqlPerTable 擴展函數,添加對 CustomerDbContext 的使用,使用的是利用表分離租戶間數據的模式。
配置的第一個參數是多租戶的鍵值,這里使用的是customer,注意在多個 DbContext 的情況下,其中一個DbContext必須包含鍵值
配置的第二個參數是鏈接字符串的鍵值,由於多個租戶同時使用一個數據庫,所以這里只需要配置一個鏈接字符串
這里可以注意到,我們默認可以提供2中方式配置多租戶,分別是 委托 和 參數 。
它們2個使用方式有區別,在不同的模式下都同時支持這2種模式
1 public void ConfigureServices(IServiceCollection services) 2 { 3 // MySql 4 services.AddMySqlPerConnection<StoreDbContext>(settings=> 5 { 6 settings.ConnectionPrefix = "mysql_"; 7 }); 8 9 services.AddMySqlPerTable<CustomerDbContext>("customer","mysql_default_customer"); 10 11 services.AddControllers(); 12 }
其次是 Configure 方法。這里主要是配置asp.net core的請求管道
1. 可以看到使用了好幾個asp.net core的中間件,其中 UseRouting 和 UseEndpoint 是必要的。
2. 使用 UserMiddleware 擴展函數引入我們的中間件 TenantInfoMiddleware 。這個中間件是類庫提供的默認支持。
1 public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 2 { 3 if (env.IsDevelopment()) 4 { 5 app.UseDeveloperExceptionPage(); 6 } 7 8 app.UseMiddleware<TenantInfoMiddleware>(); 9 10 11 app.UseRouting(); 12 13 app.UseEndpoints(endpoints => 14 { 15 endpoints.MapControllers(); 16 }); 17 }
appsettings
修改appsettings這個文件,主要是為了在里面添加鏈接字符串
1 { 2 "Logging": { 3 "LogLevel": { 4 "Default": "Information", 5 "Microsoft": "Warning", 6 "Microsoft.Hosting.Lifetime": "Information" 7 } 8 }, 9 "AllowedHosts": "*", 10 "ConnectionStrings":{ 11 "mysql_default":"server=127.0.0.1;port=3306;database=multi_tenant_default;uid=root;password=gh001;charset=utf8mb4", 12 "mysql_store1":"server=127.0.0.1;port=3306;database=multi_tenant_store1;uid=root;password=gh001;charset=utf8mb4", 13 "mysql_store2":"server=127.0.0.1;port=3306;database=multi_tenant_store2;uid=root;password=gh001;charset=utf8mb4", 14 15 "mysql_default_customer":"server=127.0.0.1;port=3306;database=multi_tenant_customer;uid=root;password=gh001;charset=utf8mb4" 16 } 17 }
ProductController 和 InstructionController
productController 和 InstructionController 非常相似,他們的里面主要包含3個方法,分別是:查詢所有、根據Id查詢、添加
里面的代碼就不一一解釋了

1 namespace kiwiho.EFcore.MultiTenant.Example.Api.Controllers 2 { 3 [ApiController] 4 [Route("api/[controller]s")] 5 public class ProductController : ControllerBase 6 { 7 private readonly StoreDbContext storeDbContext; 8 9 public ProductController(StoreDbContext storeDbContext) 10 { 11 this.storeDbContext = storeDbContext; 12 this.storeDbContext.Database.EnsureCreated(); 13 14 // this.storeDbContext.Database.Migrate(); 15 } 16 17 [HttpPost("")] 18 public async Task<ActionResult<Product>> Create(Product product) 19 { 20 var rct = await this.storeDbContext.Products.AddAsync(product); 21 22 await this.storeDbContext.SaveChangesAsync(); 23 24 return rct?.Entity; 25 26 } 27 28 [HttpGet("{id}")] 29 public async Task<ActionResult<Product>> Get([FromRoute] int id) 30 { 31 32 var rct = await this.storeDbContext.Products.FindAsync(id); 33 34 return rct; 35 36 } 37 38 [HttpGet("")] 39 public async Task<ActionResult<List<Product>>> Search() 40 { 41 var rct = await this.storeDbContext.Products.ToListAsync(); 42 return rct; 43 } 44 } 45 }

1 namespace kiwiho.EFcore.MultiTenant.Example.Api.Controllers 2 { 3 [ApiController] 4 [Route("api/[controller]s")] 5 public class InstructionController : ControllerBase 6 { 7 private readonly CustomerDbContext customerDbContext; 8 public InstructionController(CustomerDbContext customerDbContext) 9 { 10 this.customerDbContext = customerDbContext; 11 this.customerDbContext.Database.EnsureCreated(); 12 } 13 14 [HttpPost("")] 15 public async Task<ActionResult<Instruction>> Create(Instruction instruction) 16 { 17 var rct = await this.customerDbContext.Instructions.AddAsync(instruction); 18 19 await this.customerDbContext.SaveChangesAsync(); 20 21 return rct?.Entity; 22 23 } 24 25 [HttpGet("{id}")] 26 public async Task<ActionResult<Instruction>> Get([FromRoute] int id) 27 { 28 29 var rct = await this.customerDbContext.Instructions.FindAsync(id); 30 31 return rct; 32 33 } 34 35 [HttpGet("")] 36 public async Task<ActionResult<List<Instruction>>> Search() 37 { 38 var rct = await this.customerDbContext.Instructions.ToListAsync(); 39 return rct; 40 } 41 } 42 }
實施概括
實施過程中我們總共做了4件事:
1. 定義 DbContext 和對應的 Entity . DbContext必須繼承 TenantBaseDbContext 。
2. 修改 Startup 類,配置多租戶的服務,配置多租戶需要使用的中間件。
3. 按照規則添加字符串。
4. 添加 Controller 。
檢驗結果
檢驗結果之前,我們需要一些原始數據。可以通過數據庫插入或者調用api生成
使用 store1 查詢 Product 的數據
使用 store2 查詢 Product 的數據
使用 store1 查詢 Instruction 的數據
使用 store2 查詢 Instruction 的數據
總結
通過上述步驟,已經可以看出我們能通過簡單的配置,就實施多租戶模式。
這個例子有什么缺陷:
大家應該能發現,實例中Store和Customer都使用了store1和store2來請求數據。但是Customer這個域,很明顯是需要用customer1和customers2等等去請求數據的。
本實例主要為了簡單明了,將他們混為一談的。
但是要解決這個事情,是可以達到的。我們將在日后的文章繼續。
之后的還會有什么例子:
既然上一篇隨筆提到了多租戶的演化和讀寫分離,那么我們將會優先講到這部分內容。
通過查看github源代碼,可能有人疑問,除了MySql,SqlServer和Postgre,是不是就不能支持別的數據庫了。
其實並不是的,類庫里已經做好一定的擴展性,各位可以通過自行使用UseOracle等擴展方法把Oracle集成進來,代碼僅需不到10行。
代碼怎么看:
代碼已經全部更新到github,其中本文事例代碼在example/traditional_and_multiple_context 內
https://github.com/woailibain/kiwiho.EFcore.MultiTenant