.NET雲原生應用實踐(六):多租戶初步

dax.net發表於2024-11-24

本章目標

  • 多租戶簡介
  • 實現public租戶下的使用者資料隔離

出於開發進度考慮,本章暫不會完全實現多租戶的整套體系,而是會實現其中的一小部分:基於預設public租戶的資料隔離,並在本章節中會討論多租戶的實現框架結構。在後續的系列文章章節中,我們會完成多租戶的實現。

多租戶(Multi-Tenancy)

如果你對多租戶應用架構非常熟悉,可以直接跳過本章節的閱讀。

雲原生應用不一定需要支援多租戶,但是,多租戶軟體即服務(SaaS)應用一定是雲原生應用。從軟體釋出、更新以及盈利模式的角度來看,傳統軟體通常是透過購買許可證或訂閱服務的方式向客戶提供軟體。釋出更新通常需要使用者手動下載和安裝更新的軟體版本,而盈利模式主要是透過一次性銷售許可證或者定期收取訂閱費用來獲得收入。相比之下,基於多租戶的SaaS(軟體即服務)模式是將軟體部署在雲端,透過網際網路向多個客戶提供服務。在SaaS模式下,軟體的釋出更新是由軟體供應商在雲端進行,客戶無需手動更新軟體版本。盈利模式通常是按照訂閱費用或者按照使用量來收費,客戶根據實際使用情況付費。因此,基於多租戶的SaaS模式相比傳統軟體釋出更新更加便捷和實時,盈利模式更加靈活和可預測。同時,SaaS模式也更加適應雲端計算和移動化時代的需求,能夠更好地滿足客戶的個性化需求。總體上看,與傳統軟體相比,SaaS會有以下這些方面的優勢:

  • 由於軟體系統是部署在雲端,因此是由軟體供應商負責運維,使用者不需要擔心繫統運維的成本和複雜度
  • 由於軟體供應商可以實時更新和維護軟體,客戶無需手動下載和安裝更新,始終使用最新版本的軟體,提高了安全性和效率
  • 軟體供應商可以根據客戶使用情況來識別功能熱點,進而對軟體進行業務功能和技術調整
  • 客戶可以根據實際需求隨時增減使用者數量或調整服務套餐。這種靈活性和可擴充套件性使得SaaS更適應企業的快速發展和變化
  • 使用者可以透過各種裝置和平臺(如PC、手機、平板)隨時隨地訪問和使用軟體,提高了工作的靈活性和便捷性
  • SaaS軟體通常支援API整合,客戶可以方便地將SaaS軟體與其他系統整合,實現定製化需求。此外,SaaS提供商通常會提供一些定製化的功能和服務,幫助客戶更好地滿足自身需求

與此同時,多租戶SaaS應用模式也面臨了一些挑戰:

  • 將資料儲存在雲端可能會引發安全性和隱私問題,客戶可能擔心資料洩露或未經授權的訪問。因此,SaaS提供商需要採取有效的安全措施來保護客戶資料,並在商業合作上,與客戶達成責任共擔的合作模式
  • SaaS軟體需要穩定的網際網路連線才能正常執行,如果網路連線不穩定或中斷,可能會影響使用者的工作效率和體驗
  • 儘管SaaS軟體通常支援API整合和定製化功能,但某些情況下,客戶特定的定製化需求可能會面臨挑戰,需要額外的開發工作和成本。比如有些大客戶希望有自己的一套環境部署,而不僅僅是某個環境下的一個租戶;在某些業務領域,軟體供應商可能會提前實現某些業務功能並提供給某些客戶去試用,這就產生了功能模組定製與面向特定客戶開放的設計挑戰
  • 在SaaS模式下,客戶的資料儲存在提供商的雲端伺服器上,可能會引發資料所有權和遷移問題。如果客戶決定切換到另一個SaaS提供商,可能需要面對資料遷移的複雜性和成本
  • 由於SaaS軟體是部署在提供商的雲端伺服器上,可能會受到伺服器故障、維護升級等因素影響,導致服務不穩定或效能下降。這對於軟體供應商來說,需要有較高水準的系統監控和問題排查能力
  • 客戶在選擇SaaS提供商時需要謹慎,因為他們會完全依賴於提供商提供的服務和支援。如果提供商出現問題或服務中斷,客戶可能會受到影響

在上面的這些描述中,有一些關係到SaaS應用組織結構模型的概念:

  • 客戶(Customer):通常是指購買SaaS應用的個人或者組織。比如某家化工企業,購買了化工製品成分分析的SaaS應用,那麼這家化工企業就是客戶
  • 使用者(User):指某個客戶下的真正使用SaaS應用的個人或者集體。比如真正操作化工製品成分分析應用軟體的工作人員
  • 租戶(Tenant):某個客戶在SaaS應用中建立的子賬戶或者子組織,因此,一個客戶可以有一個或多個租戶,各個租戶之間的資料是嚴格隔離的
  • 系統管理員:指SaaS應用的管理員,一般由軟體供應商方面承擔這一角色,該管理員角色具有建立租戶、管理訂閱等許可權
  • 租戶管理員:指負責管理某個租戶的個人或組織,一般由客戶內部的員工或團隊承擔這一角色
  • 環境:一套環境執行了一個SaaS應用例項,它是一整套SaaS應用服務的獨立部署,大家熟知的比如QA環境和生產環境就是兩套不同的SaaS應用部署。而有些客戶還有可能需要軟體供應商為其運維一套獨立的環境,以保證應用程式的執行效率和更為徹底的資料隔離

可以看到,多租戶SaaS應用中,有一個重要的特徵就是資料隔離(租戶隔離):不同租戶之間的資料是完全隔離的(多租戶資料整合共享的場景除外),有些情況下,租戶資料還會使用不同的加密金鑰進行加密以防止資料洩露,確保資料安全。常見的資料隔離方式有物理隔離和邏輯隔離,物理隔離通常使用獨立的伺服器和資料庫來存放不同租戶的資料,而邏輯隔離則是使用同一個資料庫,只不過透過資料庫的名稱空間或者Schema來達到資料隔離的目的。無論是物理隔離還是邏輯隔離,在一套環境中,只執行一套SaaS應用的部署(也就是執行的前端應用、微服務、API等只有一套)。

軟體架構的魅力就在於,無論你的選擇是什麼,總會有利弊,所以你需要根據實際情況進行權衡。租戶隔離是選擇物理隔離還是邏輯隔離,也需要根據實際需求和成本、運維難度等現狀來決定,兩者在不同的場景下也是各有利弊。有興趣的讀者可以自行搜尋查閱資料,這裡就不贅述了。

回到我們的案例,Stickers採用邏輯隔離的方式,基於PostgreSQL的Schema實現資料隔離。在IdP(Identity Provider)這邊藉助於Keycloak的Realm Client實現租戶隔離,但這有一個弊端,Keycloak中使用者和使用者組是基於Realm的,而不是基於Client的,因此,如果是基於Client的租戶隔離,那麼從Keycloak的角度,相同的賬戶名就會被多個租戶“可見”,並且不同的租戶則不能使用相同的賬戶名。比如:某個使用者名稱為daxnet,這個賬號的郵箱地址為daxnet@example.com,如果這個賬戶是屬於租戶A的,那麼當B租戶希望新增一個名為daxnet的賬戶時,就會發生“賬戶名稱已經存在”的問題,因為daxnet賬戶是跨Client的(Realm級別),但實際中,應該是可以允許同一個賬戶名稱出現在不同的租戶中的。

Stickers案例中的多租戶設計與實現

由於Stickers使用PostgreSQL的Schema來實現資料隔離,所以,在呼叫Stickers微服務所提供的API時,就需要區分當前租戶是什麼,以及當前使用者是誰,從而才可以根據租戶名稱來查詢相應的Schema,並獲取當前登入使用者的資訊。而對於一個登入使用者而言,租戶的資訊和使用者的資訊都是儲存在IdP裡的,比如,如果對Keycloak所頒發的access token進行解碼,就能夠獲取到租戶的名稱:

.NET雲原生應用實踐(六):多租戶初步

這個租戶名稱也就是PostgreSQL中的Schema名稱。除此之外,由於我們在Client中配置了使用者組的Client Scope(usergroup),因此,在access token中也會自帶使用者組的資訊:

.NET雲原生應用實踐(六):多租戶初步

請注意這個使用者組的資訊包含了一個字串陣列,用以表示該使用者屬於哪些使用者組。上面已經提到,在Keycloak中,使用者和使用者組是Realm級別的,因此,在設計使用者組時,我們將最上層的組以租戶的名稱命名,這樣也就區分了不同租戶下的使用者分組。這也就是為什麼對於上面這個使用者賬戶而言,它會屬於/public/users這個組。

如果你選擇的IdP不是Keycloak,你也可以在IdP的實現中尋求一種合理的方式從而獲得租戶的名稱,比如,可以將租戶資訊以自定義屬性的形式附加在access token上。不管使用何種方式,將使用者的基本資訊包含在access token中,這始終是一個好的設計,這樣可以減少後續微服務透過API呼叫來查詢使用者資訊所帶來的工作負荷,以及由快取機制帶來的複雜度。但也需要注意,不要將大量的客戶化資料放在access token中,以免產生效能損耗。

當我們可以從access token中獲取租戶資訊時,在Stickers微服務的API層面,實現起來就很簡單了,只需要透過UserClaims獲取其中型別為azp的Claim即可。下面的序列圖表述了多租戶模式下API訪問的過程(Authentication flow相關的部分已省略):

.NET雲原生應用實踐(六):多租戶初步

首先修改資料庫,在public schema下的Stickers資料表中增加一列,用來儲存使用者的UserId

ALTER TABLE IF EXISTS public.stickers
    ADD COLUMN "UserId" character varying(128) NOT NULL;

然後,在Sticker業務模型物件中,也增加一個屬性,用來儲存UserId,這個屬性與資料庫中的UserId對應:

[StringLength(128)]
public string UserId { get; set; } = string.Empty;

第三步,修改ISimplifiedDataAccessor介面以及相應的PostgreSqlDataAccessor實現,將租戶Id(TenantId)加入到資料訪問層,從下面的程式碼可以看到,與之前的程式碼相比,每個方法的引數中都多了一個tenantId的引數:

public interface ISimplifiedDataAccessor
{
    Task<int> AddAsync<TEntity>(string tenantId, 
        TEntity entity, 
        CancellationToken cancellationToken = default)
        where TEntity : class, IEntity;

    Task<int> RemoveByIdAsync<TEntity>(string tenantId
        int id,
        CancellationToken cancellationToken = default)
        where TEntity : class, IEntity;

    Task<TEntity?> GetByIdAsync<TEntity>(string tenantId
        int id
        CancellationToken cancellationToken = default)
        where TEntity : class, IEntity;

    Task<int> UpdateAsync<TEntity>(string tenantId
        int id
        TEntity entity
        CancellationToken cancellationToken = default)
        where TEntity : class, IEntity;

    Task<Paginated<TEntity>> GetPaginatedEntitiesAsync<TEntity, TField>(
        string tenantId, 
        Expression<Func<TEntity, TField>> orderByExpression,
        bool sortAscending = true, int pageSize = 25, int pageNumber = 1,
        Expression<Func<TEntity, bool>>? filterExpression = null
        CancellationToken cancellationToken = default)
        where TEntity : class, IEntity;

    Task<bool> ExistsAsync<TEntity>(string tenantId, 
        Expression<Func<TEntity, bool>> filterExpression,
        CancellationToken cancellationToken = default) 
        where TEntity : class, IEntity;
}

簡單分析後不難發現,由於TenantId和UserId引數的引入,使得我們查詢資料庫的時候,就會需要根據當前的TenantId和UserId來過濾資料。TenantId是比較容易解決的,只需將SQL語句中的public(也就是public schema)替換為真正的TenantId就可以了,只不過目前即使替換了,SQL語句中仍然是在查詢public schema。而UserId就會相對複雜一點,例如,在獲取某個貼紙是否存在時,以下現有程式碼:

var exists = await dac.ExistsAsync<Sticker>(
    CurrentTenantName,
    s => s.Title == title);

就需要改為:

var exists = await dac.ExistsAsync<Sticker>(
    CurrentTenantName, 
    s => s.Title == title && s.UserId == CurrentUserName);

也就是,需要同時判斷貼紙的標題和使用者Id是否重複,因為不同的使用者也可以使用相同的貼紙標題。這裡就涉及Lambda表示式的處理,於是,之前在PostgreSqlDataAccessor中實現的BuildSqlWhereClause方法就需要相應的重構:需要在所支援的表示式型別中增加兩個型別:AndAlsoOrElse

var oper = binaryExpression.NodeType switch
{
    ExpressionType.Equal => "=",
    ExpressionType.NotEqual => "<>",
    ExpressionType.GreaterThan => ">",
    ExpressionType.GreaterThanOrEqual => ">=",
    ExpressionType.LessThan => "<",
    ExpressionType.LessThanOrEqual => "<=",
    ExpressionType.AndAlso => " AND ",
    ExpressionType.OrElse => " OR ",
    _ => null
};

並對這兩種新增的表示式型別進行相應的處理,也就是針對AndAlsoOrElse的左右兩邊運算子分別遞迴呼叫BuildSqlWhereClause方法,並將獲得的SQL語句拼接起來:

if (string.Equals(oper, " AND ") || string.Equals(oper, " OR "))
{
    var leftStr = BuildSqlWhereClause(binaryExpression.Left);
    var rightStr = BuildSqlWhereClause(binaryExpression.Right);
    return string.Concat(leftStr, oper, rightStr);
}

最後,就是在StickersController上,透過當前登入使用者的access token中的azppreferred_username這兩個Claim來獲得登入使用者所在的租戶Id和使用者Id:

private string CurrentTenantName
{
    get
    {
        if (User.Identity is ClaimsIdentity { IsAuthenticated: true } identity)
            return identity.Claims.FirstOrDefault(c => c.Type == "azp")?.Value ??
                   throw new AuthenticationException(
                       "Get current tenant name failed: Claim \"azp\" doesn't exist on the user identity.");
        throw new AuthenticationException("Can't get the current tenant name: User is not authenticated.");
    }
}
private string CurrentUserName
{
    get
    {
        if (User.Identity is ClaimsIdentity { IsAuthenticated: true } identity)
            return identity.Claims.FirstOrDefault(c =>
                       c.Type == (configuration["keycloak:nameClaimType"] ?? "preferred_username"))?.Value ??
                   throw new AuthenticationException(
                       "Get current user name failed: Claim \"preferred_username\" doesn't exist on the user identity.");
        throw new AuthenticationException("Can't get the current user name: User is not authenticated.");
    }
}

因此,在呼叫Stickers API的時候,只要是已經登入的認證使用者,API就會自動獲得TenantId和UserId,不需要客戶端呼叫方進行指定。

實現效果

啟動整個應用程式,以daxnet使用者登入,所看到的貼紙如下所示:

.NET雲原生應用實踐(六):多租戶初步

退出登入,然後以super使用者登入,所看到的貼紙如下所示:

.NET雲原生應用實踐(六):多租戶初步

總結

本文雖然沒有完整實現多租戶的整個流程,但已經從public這個特殊的租戶層面,對使用者資料進行了隔離,也就是修復了在前一篇文章中最後所提到的那個Bug。基本上從API層面,已經有了支援多租戶的技術基礎,更進一步的實現就需要配合反向代理和域名解析,以及Blazor WebAssembly對OIDC動態ClientID的支援等這些技術細節來共同完成。目前我們的案例已經基本完成單個租戶所能支援的主體功能,接下來我們會要開始探索容器化、持續整合與持續部署等等與雲原生相關的話題,之後在完成這些主題的討論研究後,回過頭來再來解決多租戶問題,同時還會考慮擴充套件現有的業務功能。下一講將介紹Stickers應用程式的容器化,以及如何在本地docker compose中執行一整套應用。

原始碼

【本章原始碼已更新到最新的 .NET 9】本章原始碼在chapter_6這個分支中:https://gitee.com/daxnet/stickers/tree/chapter_6/

下載原始碼前,請先刪除已有的stickers-pgsql:devstickers-keycloak:dev兩個容器映象,並刪除docker_stickers_postgres_data資料卷。

下載原始碼後,進入docker目錄,然後編譯並啟動容器:

$ docker compose -f docker-compose.dev.yaml build
$ docker compose -f docker-compose.dev.yaml up

現在就可以直接用Visual Studio 2022或者JetBrains Rider開啟stickers.sln解決方案檔案,然後同時啟動Stickers.WebApiStickers.Web兩個專案進行除錯執行了。

相關文章