EF多租戶例項:如何快速實現和同時支援多個DbContext
前言
上一篇隨筆我們談到了多租戶模式,通過多租戶模式的演化的例子。大致歸納和總結了幾種模式的表現形式。
並且順帶提到了讀寫分離。
通過好幾次的程式碼調整,使得這個庫更加通用。今天我們聊聊怎麼通過該類庫快速接入多租戶。
類庫地址:
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 }Product
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 }Instruction
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 }ProductController
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 }InstructionController
實施概括
實施過程中我們總共做了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
&n