SaaS的多租戶模式(Multi-tenancy)實踐:.NET Core + Entity Framework Core

企業級軟件訂閲服務 (SaaS) 的開發 (2)


多租戶模式 (Multi tenancy)

我們於上一篇提過關於多租戶模式 (Multi tenancy) 的各種好處,今日我們就來談談如何去實際架構出一個多用戶模式的服務器架構,並能實現客戶獨立穩健、容易維護的特點。


多用戶模式的實現方法可以分爲幾種:

  1. 服務器使用同一個域名 (Domain Name),在每一個請求 (Request) 裏面都加上一個 TenantId 的參數作分辨。
  2. 服務器使用同一個域名,然後以不同的用戶ID 來作區分。
  3. 服務器接受不同域名,每個客戶都給予他一個專屬域名,並以域名去區分

這裏會示範實現第三個方案,其優點有:

  • 可以用不同子域名 (Sub-domain) 去分給不同用戶,例如 tenantA.example.com,tenantB.example.com。如果請求裏面加有用戶的名字的話,能夠讓用戶感覺方案是他們專有的服務。
  • 如果客戶自己公司有想用的域名的話,也可以使用。只要在他們的域名服務器 (Domain Name Server)上,把域名指到我們服務器的地址便可。
  • 對服務器來説,只要小心處理,用哪個方案差別都不大,但是在瀏覽器 (Browser)上差別就很大。方案 1 和 2 都是使用同一個域名進入,即使所有客戶不同,他們在瀏覽器上的 Cookie 或者 Local Storage 的空間都是共通。雖然説在實際環境中,同一臺電腦登入不同客戶的情況非常罕見。但一旦登入不同客戶,就很容易發生資料有衝突。這樣的話,就不容易達到客戶獨立這個條件。相反方案 3的話,每一個客戶都使用不同的域名,在瀏覽器上的的存取空間也會是獨立的,就不會發生以上的問題。

閒話休提,要開始實際示範了。這個示範架構的概念是「靠頂端判斷租戶資料,再於末端去進行分類處理」。這裏我們會利用 .Net Core + Entity Framework Core 來實現一個多租戶模式的例子。這個例子要符合三個要求:

  1. 根據不同請求域名 (Request Host Name),返回對應的客戶資料,如名字;
  2. 根據不同域名,存取不同的數據庫 (Database)
  3. 根據不同域名,上載和下載檔案(File) 進去不同的文件夾 (Folder)

實際操作

1.首先打開 Visual Studio,新增一個 .Net Core 的 Project,這裏我們用 2.2版本。

2.定義 Tenant (租客),裏面存放了該客戶的資料,如客戶名字數據庫的地址 (ConnectionString),存取檔案的文件夾,和這個客戶接受的所有域名。

3.定義TenantContext,用來緩存當前的Scope現在是哪一個租戶。

4.定義TenantResolver,來根據不同的域名去搜尋自己屬於哪一個租戶。這裏爲了方便示範,所有直接加入了兩個客戶的資料在這裏,但正式應該要讀取一個額外儲存所有租戶資料的檔案,或者是連接到一個遠端的租戶管理服務器,來讀取租戶資料。

這裏的例子,如果是經過 localhost:5000 進來的就是客戶A,localhost:5001進來的就是客戶B。

5. 到這裏爲止,就有了最基本的構成了。然後就是如何去判斷請求是屬於哪一個租戶,這裏使用中間件 (Middleware),來定義一個HttpTenantResolveMiddleware 去判斷。

在 HttpContext 裏面能夠找回當前 Request 的 HostName,並通過TenantResolver 裏是屬於哪一個 Tenant,最後放進去 TenantContext 裏面便可。

6.在 startup.cs,把之前定義的 Class 都加進去依賴注入,TenantResolver 用 Singleton,其他的用 Scope。

這裏有一個巧妙的地方,就是把 TenantContext 裏面的 Tenant 再放進Scope裏面,這樣就可以更直接的在拿到當前 Scope 的租戶。

7.加入 HttpTenantResolveMiddleware 進流水綫 (Pipeline)。判斷 Tenant這個流程,越早階段做越好,所以這裏直接放到第一位。

8. 這楊就可以開始簡單的測試一下。首先在 launchSetting 裏面加上兩個Tenant 的 Url。

定義一個測試的 Controller,裏面直接 注入 (inject) Tenant 進去就可以了。

然後就可以看到測試結果。同一條路徑,同一個服務器,但經過不同域名的請求,返回不同租戶的資料。

到這裏,基本的租戶分類就完成了。


數據庫存取  — Entity Framework Core

接著來示範一些對 Db 的實際操作,這裏用 SqlServer 來做示範。

1.要加入三個 Entity Framework Core 需要的 Nuget Project。

2.我們就定義一個簡單的 TenantDbContext 的數據庫,裏面有一個 Key value pair 的表。

3.在 startup.cs 裏面注入 TenantDbContext。平時的 Entity Framework 教學裏,都會在這裏加入數據庫的地址 (connectionString) 加進去,但我們就不在這裏加。

4. 再來一個小竅門,在 TenantDbContext 也注入 Teannt。覆蓋掉原來的 OnConfiguring,在這裏用 Tenant 裏面的 ConnectionString 去指定數據庫路徑。

5.然而,如果現在直接運行 add-migration 去添加 MIgration 的話,就會發生錯誤。因爲在沒有請求的時候,是不會知道現在是屬於哪個租戶,和應該連去哪個 DB。

但其實這個問題很容易解決。只要加一個 Class 去Implement IDesignTimeDbContextFactory<TenantDbContext> 就可以。在運行 add-migration的時候,就會根據這裏去創建 TenantDbContext,只要在這裏給一個假的住戶資料進去,add-migraiton 的時候就可以靠這個Tenant的數據庫路徑,去計算 Migration。

Migration 成功做出來了!

6.然後就到建立的數據庫的時候。在startup.cs configure 的最後階段,對每一個 Tenant 都創造一個新的 Scope,將該 Tenant 放進 TenantContext,再運行 Migration 就可。

運行完後看一看 SSMS,兩個客戶的數據庫都應能成功創造出來。

7.開始實際測試,做一個測試數據庫的 Controller。如果熟悉 Entity Framework的人就會發現,在這裏對 Entity Framework 的用法和平時的用法沒有任何分別

測試開。預先將設了客戶A的 hello=I’m Tenant A, 然後就可以看到不同的域名拿到的結果是不一樣,兩個數據庫是完全分開的。

檔案存取

1.這個做法也很簡單,做一個 TenantFileService,也是一開始就注入Tenant。這裡都用 Tenant 裏面的 FileDirectory 作爲路徑的基礎。

2. 在startup.cs的加到 Dependency Injection 裏面。

3.實際測試,和數據庫的測試一樣,也是 Controller 注入TenantFileService,直接使用便可。

大家可以看到經過不同的域名上載的檔案,會分別放進不同的文件夾裏面。


總結

上一篇裏面提到過,儘管是在多租戶模式(Multi Tenancy)的環境下, 用戶資料的安全和保密還是最重要的考慮。而這個例子的示範的系統架構,就正正是爲了達到這個目的。

裏面提到過,儘管是在多租戶模式(Multi Tenancy)的環境下, 用戶資料的安全和保密還是最重要的考慮。而這個例子的示範的系統架構,就正正是爲了達到這個目的。

當一個系統不斷開發期間,開發者不可能寫每一個功能的時候,都要顧慮到如何去區分現在是屬於哪個一租戶,因爲這樣很容易會發生人爲錯誤,也會令到代碼混亂,難以維護。

而這個結構的好處是,在最頂端的階段已經確立好這個請求是屬於那個一租戶,然後到了最末端的 IO 的部分,根據最頂端的判斷好的租戶資料去決定如何存取,而中間的邏輯部分就完全不用顧慮到租戶的問題,就當普通的服務器去開發功能便可。

當然實際的多租戶模式的不止這麽簡單,還有很多範圍要顧及,如背景工作 (Background Job)緩存 (Cache)WebSocket日誌文件 (Logging) 等等。但只要牢記這這個例子的概念 「靠頂端判斷租戶資料,再於末端去進行分類處理」,那無論功能如何增加擴大,也能做好維護工作,而不會發生用戶資料混亂的事情發生。

大家可以上我們的Github,去下載這個 project 實際來試試看。 https://github.com/ones-software/dotnet-core-multi-tenancy-sample


ONEs software。香港軟件開發團隊,提供專業的軟件開發服務。包括手機應用程序開發,網頁開發和定制軟件開發。 https://ones.software/zh-hk

Leave a Reply

Your email address will not be published. Required fields are marked *