<div> 

前言

我又來寫關於多租戶的內容了,這個系列真夠漫長的。 如無意外這篇隨筆是最後一篇了。內容是講關於如何利用我們的多租戶庫簡單實現讀寫分離。

分析

對於讀寫分離,其實有很多種實現方式,但是總體可以分以下兩類: 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 項目中應用起來。 那優雅的方式,確實是每個程序員都願意使用的。 那麼我們可以期待我們自行實現的 CacheableCachePutCacheEvict 。 這裏的難點是什麼,C# 對比 Java 語法特色上最大區別是 asynchorize 的支持,所以 C# 對這種攔截器最大複雜度,就是在分別處理同步和異步。 有一些已經存在的類似的緩存庫,往往需要使用反射進行對異步封裝或異步解釋,我將用更加優異的方式實現。 關於代碼 請查看github  : https://github.com/woailibain/kiwiho.EFcore.MultiTenant
相關文章