1. 程式人生 > >EF多租戶例項:演變為讀寫分離

EF多租戶例項:演變為讀寫分離

前言

我又來寫關於多租戶的內容了,這個系列真夠漫長的。

如無意外這篇隨筆是最後一篇了。內容是講關於如何利用我們的多租戶庫簡單實現讀寫分離。

 

分析

對於讀寫分離,其實有很多種實現方式,但是總體可以分以下兩類:

1. 通過不同的連線字串分離讀庫和寫庫

2. 通過有多個連線例項,分別連線到讀或寫庫

他們2種類型都有各自明顯的優缺點。我下面會列舉部分優缺點

第1種,如果一個請求 scope 內只有一個連線例項,那麼就造成同一 scope 內就只能連線讀或寫庫。

由於一個 scope 裡只有一個連線例項,造成讀寫都只能在一個庫,好處是在需要寫的情況,資料一致性很高,但也造成對於一些需要長時間執行的請求,會降低整個讀寫框架的效率。

另一個好處是可以節省連線,一個 scope 只有一個連線,對連線的開銷更加少。

第2種,同一個請求 scope 內有多個連線例項,可以同時對讀和寫庫進行操作。

在同時對讀庫和寫庫操作時,必須要對資料的一致性問題小心處理,由於讀庫寫庫的同步是需要很長時間的(對比一個請求的花費時間)。

在這種情況下,一般我們要對絕大部分的寫操作進行覓等處理,部分只增不改的資料簡單處理就行(例如新增操作記錄)

由於同一個 scope 下同時擁有讀和寫庫的例項,可以非常優雅的自動對 insert,update 等指向寫庫, select 指向讀庫。而不需要在寫程式碼階段顯式標註

 

上面的2種類型我都有在實際專案中使用過,我個人是更加偏向於第1種,因為在第2種類型的專案應用中,資料的一致性問題常常造成各種各樣的問題,越來越多的介面後來都將2個連線例項轉變成讀或寫例項操作。

但不得不說,第2種類型確實比第一種效率上更加高。因為即使在一個需要寫的介面下,可能需要讀4~5次庫,才會進行1次寫操作,所以這不是一個影響效率的小因素。

由於這篇隨筆我只想討論讀寫分離,資料一致性問題不想過多涉及,所以本文會使用第1種類型進行講解。

 

實施

在具體的實施步驟前,我們先看看專案的結構。其中 Entity,DbContext,Controller 都是前文多次提及的,就不再強調他的程式碼實現了,有需要等朋友去github或者前面幾篇文章參考。

 

 

讀寫是靠什麼分離的

在我們的例項中,最大的難題是: 如何區分讀和寫?

對的,這就是我們全文的核心。從程式碼層面可以區分為 人為顯式標明 和 程式碼自動識別資料庫操作 

人為顯式標明很簡單理解,就是我們在實現一個介面的時候,實際上已經知道它是否有需要寫庫。本文的實施方式

程式碼自動識別資料庫,簡單來說通過區分資料庫的操作型別,從而自動指向不同的庫。但由於我們本文的示例不具備很好的結構優勢(上文提到的第1種類型),所以可操作性較低。

 

既然我們選擇認為顯示標明,那麼大家很容易想到的是使用 C# 中備受推崇的註解方式 Attribute 。那麼,我們很簡單按照要求就建立了下面的這個類

這個 Attribute 看起來非常地簡單,甚至連建構函式、屬性和欄位都沒有。

有的只有第1行的 AttributeUsage 註解。這裡的作用是規定他只能在方法上使用,並且不能同時存在多個和在繼承時無效。

可能有朋友會提問為什麼不用 ActionFilterAttribute 作為父類,其實這只是一個標識,沒有任何邏輯在裡面,自然也不需要用到強大的 ActionFilterAttribute 了

1 [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
2 public class IsWriteAttribute : Attribute
3 {
4 }

 

連線例項初始化

較為熟悉 asp.net core 的朋友或者有留意系列文章的朋友,應該不難發現 EF core 的連線例項 DbContext 是通過控制反轉自動初始化的,在 Controller 產生之前,DbContext 已經初始化完成了。

那麼我們是如何在 Controller 構造之前就標明這個DbContext 使用的是寫庫的連線還是讀庫的連線呢?

在這種情況下,我們就需要利用 asp.net core 的路由了,因為沒有 asp.net core 的 Endpoint,我們是無法知道這個請求是到達哪一個 Controller 和方法的,這樣就造成我們前文提到使用 Middleware 已經不再適用了。

通過苦苦地閱讀了部分關於 Endpoint 的原始碼之後,我分析有2個較為合適的物件,分別是:IActionInvokerProvider 和 IControllerActivator。

最終我選定使用 IActionInvokerProvider ,理由暫不敘述,如果有機會我們展開原始碼討論的時候再談。

下面貼出 ReadWriteActionInvokerProvider 的程式碼。 OnProviderExecuted 就是執行後,OnProviderExecuting 就是執行前,這個很好理解。

第14行就是讀出當前即將執行的介面方法有沒有上文提到的使用 IsWriteAttribute 進行標註

剩下的程式碼的作用,主要就是對當前請求 scope 的 tenantInfo 進行賦值,用於區分當前請求是讀還是寫。

 1 public class ReadWriteActionInvokerProvider : IActionInvokerProvider
 2 {
 3     public int Order => 10;
 4 
 5     public void OnProvidersExecuted(ActionInvokerProviderContext context)
 6     {
 7     }
 8 
 9     public void OnProvidersExecuting(ActionInvokerProviderContext context)
10     {
11         if (context.ActionContext.ActionDescriptor is ControllerActionDescriptor descriptor)
12         {
13             var serviceProvider = context.ActionContext.HttpContext.RequestServices;
14             var isWrite = descriptor.MethodInfo.GetCustomAttributes(typeof(IsWriteAttribute), false)?.Length > 0;
15 
16             var tenantInfo = serviceProvider.GetService(typeof(TenantInfo)) as TenantInfo;
17             tenantInfo.Name = isWrite ? "WRITE" : "READ";
18             (tenantInfo as dynamic).IsWrite = isWrite;
19         }
20     }
21 }

 

獲取連線字串

連線字串這部分,由於我們已經跳出了多租戶庫規定的範疇了,所以我們需要自己實現一個可用於讀寫分離的 ConnectionGenerator

其中 TenantKey 屬性和 MatchTenantKey 方法是 IConnectionGenerator 中必須的,主要是用來這個 Generator 是否匹配當前 DbContext

GetConection 中的邏輯,主要是通過 IsWrite 來判斷是否是寫庫,從而獲得唯一的寫庫連線字串。其他的任何情況都通過隨機數的取模,從2個讀庫的連線字串中取一個。

 1 public class ReadWriteConnectionGenerator : IConnectionGenerator
 2 {
 3 
 4     static Lazy<Random> random = new Lazy<Random>();
 5     private readonly IConfiguration configuration;
 6     public string TenantKey => "";
 7 
 8     public ReadWriteConnectionGenerator(IConfiguration configuration)
 9     {
10         this.configuration = configuration;
11     }
12 
13 
14     public string GetConnection(TenantOption option, TenantInfo tenantInfo)
15     {
16         dynamic info = tenantInfo;
17         if (info?.IsWrite == true)
18         {
19             return configuration.GetConnectionString($"{option.ConnectionPrefix}write");
20         }
21         else
22         {
23             var mod = random.Value.Next(1000) % 2;
24             return configuration.GetConnectionString($"{option.ConnectionPrefix}read{(mod + 1)}");
25         }
26     }
27 
28     public bool MatchTenantKey(string tenantKey)
29     {
30         return true;
31     }
32 }

 

注入配置

來到 asp.net core 的世界,怎麼能缺少注入配置和管道配置呢。

首先是配置我們自定義的 IActionInvokerProvider 和 IConnectionGernerator .

然後是配置多租戶。 這裡利用 AddTenantedDatabase 這個基礎方法,主要是為了表名它並不需要前文提到的mysql,sqlserver等的眾多實現庫。

 1 public class Startup
 2 {
 3     public Startup(IConfiguration configuration)
 4     {
 5         Configuration = configuration;
 6     }
 7 
 8     public IConfiguration Configuration { get; }
 9 
10     // This method gets called by the runtime. Use this method to add services to the container.
11     public void ConfigureServices(IServiceCollection services)
12     {
13         services.AddSingleton<IActionInvokerProvider, ReadWriteActionInvokerProvider>();
14         services.AddScoped<IConnectionGenerator, ReadWriteConnectionGenerator>();
15         services.AddTenantedDatabase<StoreDbContext>(null, setupDb);
16 
17         services.AddControllers();
18     }
19 
20     void setupDb(TenantSettings<StoreDbContext> settings)
21     {
22         settings.ConnectionPrefix = "mysql_";
23         settings.DbContextSetup = (serviceProvider, connectionString, optionsBuilder) =>
24         {
25             var tenant = serviceProvider.GetService<TenantInfo>();
26             optionsBuilder.UseMySql(connectionString, builder =>
27             {
28                 // not necessary, if you are not using the table or schema 
29                 builder.TenantBuilderSetup(serviceProvider, settings, tenant);
30             });
31         };
32     }
33 
34     // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
35     public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
36     {
37         if (env.IsDevelopment())
38         {
39             app.UseDeveloperExceptionPage();
40         }
41 
42         // app.UseHttpsRedirection();
43 
44         app.UseRouting();
45 
46         // app.UseAuthorization();
47 
48         app.UseEndpoints(endpoints =>
49         {
50             endpoints.MapControllers();
51         });
52     }
53 }

 

其他

通過了上面的好幾個關鍵步驟,我們已經將最關鍵的幾個部分說明了。

剩下的是還有 StoreDbContext, Controller, Product, appsettings 等,請參考原始碼或者。

ProductionController 中有一個方法可以貼出來做為一個示例,標明我們怎麼使用 IsWriteAttribute

 1 [HttpPost("")]
 2 [IsWriteAttribute]
 3 public async Task<ActionResult<Product>> Create(Product product)
 4 {
 5     var rct = await this.storeDbContext.Products.AddAsync(product);
 6 
 7     await this.storeDbContext.SaveChangesAsync();
 8 
 9     return rct?.Entity;
10 
11 }

 

檢驗結果

其實這裡我提供的例子,並不能從介面的響應如何區分是自動指向了讀庫或寫庫,所以效果就不截圖了。

 

最後

這個系列終於要完成了。整整持續了2個月,主要是最近太忙了,即使在家辦公,工作還是多得做不完。所以文章的產出非常的慢。

 

接下來做什麼

這個系列的文章雖然完成了,但是開源的程式碼還是在繼續的,我會開始完成github的Readme,以求讓大家通過閱讀github的介紹就能快速上手。

可能有朋友會有EF migration有需求,那請參閱我之前寫的文章,其實套路都一樣,沒什麼難度的。

 

之後會介紹什麼知識點

其實我在寫這個系列文章之前,就打算寫快取。可能有朋友會覺得快取有什麼可說的,不就是讀一下,有就拿出來,沒有就先寫進去。

確實這是快取的最基礎操作,但是有沒有一種優雅的方式,另我們不用不停重複寫if else去讀寫快取呢?

是有的,自從我讀了Spring boot的部分原始碼,裡面的快取使用方式實在令我眼前一亮,後來我也在 asp.net core 專案中應用起來。

那優雅的方式,確實是每個程式設計師都願意使用的。

那麼我們可以期待我們自行實現的 Cacheable,CachePut,CacheEvict。

 

這裡的難點是什麼,C# 對比 Java 語法特色上最大區別是 asynchorize 的支援,所以 C# 對這種攔截器最大複雜度,就是在分別處理同步和非同步。

有一些已經存在的類似的快取庫,往往需要使用反射進行對非同步封裝或非同步解釋,我將用更加優異的方式實現。

 

關於程式碼

請檢視github  : https://github.com/woailibain/kiwiho.EFcore.MultiTenant

&n