(譯)創建.NET Core多租戶應用程式-租戶解析
- 2020 年 4 月 7 日
- 筆記
介紹
本系列部落格文章探討了如何在ASP.NET Core Web應用程式中實現多租戶。這裡有很多程式碼段,因此您可以按照自己的示例應用程式進行操作。在此過程的最後,沒有對應的NuGet程式包,但這是一個很好的學習和練習。它涉及到框架的一些「核心」部分。
在本系列的改篇中,我們將解析對租戶的請求,並介紹訪問該租戶資訊的能力。
系列目錄
- 第1部分:租戶解析(本篇)
- 第2部分:租戶containers
- 第3部分:每個租戶的選項配置
- 第4部分:每個租戶的身份驗證
- 附加:升級到.NET Core 3.1(LTS)
什麼是多租戶應用程式?
它是一個單一的程式碼庫,根據訪問它的「租戶」不同而做出不同的響應,您可以使用幾種不同的模式,例如
- 應用程式級別隔離:為每個租戶啟動一個新網站和相關的依存關係
- 多租戶應用都擁有自己的資料庫:租戶使用相同的網站,但是擁有自己的資料庫
- 多租戶應用程式使用多租戶資料庫:租戶使用相同的網站和相同的資料庫(需要注意不要將數據暴露給錯誤的租戶!)
這裡有關於每種模式的非常深入的指南。在本系列中,我們將探討多租戶應用程式選項。https://docs.microsoft.com/zh-cn/azure/sql-database/saas-tenancy-app-design-patterns
多租戶應用程式需要什麼?
多租戶應用程式需要滿足幾個核心要求。
租戶解析
從HTTP請求中,我們將需要能夠確定在哪個租戶上下文中運行請求。這會影響諸如訪問哪個資料庫或使用哪種配置等問題。
租戶應用程式配置
根據載入的租戶上下文,可能會對應用程式進行不同的配置,例如OAuth提供程式的身份驗證密鑰,連接字元串等。
租戶數據隔離
租戶將需要能夠訪問他們的數據,以及僅僅訪問他們自己的數據。這可以通過在單個數據存儲中對數據進行分區或通過使用每個租戶的數據存儲來實現。無論我們使用哪種模式,我們都應該使開發人員在跨租戶場景中難以公開數據以避免編碼錯誤。
租戶解析
對於任何多租戶應用程式,我們都需要能夠識別請求在哪個租戶下運行,但是在我們太興奮之前,我們需要確定查找租戶所需的數據。在此階段,我們實際上只需要一個資訊,即租戶標識符。
/// <summary> /// Tenant information /// </summary> public class Tenant { /// <summary> /// The tenant Id /// </summary> public string Id { get; set; } /// <summary> /// The tenant identifier /// </summary> public string Identifier { get; set; } /// <summary> /// Tenant items /// </summary> public Dictionary<string, object> Items { get; private set; } = new Dictionary<string, object>(); }
我們將Identifier根據解析方案策略使用來匹配租戶(可能是租戶的域名,例如https://{tenant}.myapplication.com)。
我們將使用它Id作為對租戶的持久引用(Identifier可能會更改,例如主機域更改)。
該屬性Items僅用於讓開發人員在請求管道期間向租戶添加其他內容,如果他們需要特定的屬性或方法,他們還可以擴展該類。
常見的租戶解決策略
我們將使用解決方案策略將請求匹配到租戶,該策略不應依賴任何外部數據來使其變得美觀,快速。
主機頭
將根據瀏覽器發送的主機頭來推斷租戶,如果所有租戶都具有不同的域(例如)https://host1.example.com,https://host2.example.com或者https://host3.com您支援自定義域,則這是完美的選擇。
例如,如果主機標頭是,https://host1.example.com我們將Tenant使用Identifier持有值載入host1.example.com。
請求路徑
可以根據路線推斷租戶,例如 https://example.com/host1/…
標頭值
可以根據標頭值來推斷承租人,例如x-tenant: host1,如果所有承租人都可以在核心api上訪問,https://api.example.com並且客戶端可以指定要與特定標頭一起使用的承租人,則這可能很有用。
定義租戶解析策略
為了讓應用程式知道使用哪種策略,我們應該能夠實現ITenantResolutionStrategy將請求解析為租戶標識符的服務。
public interface ITenantResolutionStrategy { Task<string> GetTenantIdentifierAsync(); }
在這篇文章中,我們將實現一個策略,從主機頭那裡解析租戶。
/// <summary> /// Resolve the host to a tenant identifier /// </summary> public class HostResolutionStrategy : ITenantResolutionStrategy { private readonly IHttpContextAccessor _httpContextAccessor; public HostResolutionStrategy(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } /// <summary> /// Get the tenant identifier /// </summary> /// <param name="context"></param> /// <returns></returns> public async Task<string> GetTenantIdentifierAsync() { return await Task.FromResult(_httpContextAccessor.HttpContext.Request.Host.Host); } }
租戶存儲
現在我們知道要載入哪個租戶,該從哪裡獲取?那將需要某種租戶存儲。我們將需要實現一個ITenantStore接受承租人標識符並返回Tenant資訊的。
public interface ITenantStore<T> where T : Tenant { Task<T> GetTenantAsync(string identifier); }
我為什麼要使泛型存儲?萬一我們想在使用我們庫的項目中獲得更多特定於應用程式的租戶資訊,我們可以擴展租戶使其具有應用程式級別所需的任何其他屬性,並適當地配置存儲
如果要針對租戶存儲連接字元串之類的內容,則需要將其放置在安全的地方,並且最好使用每個租戶模式的選項配置,並從諸如Azure Key Vault之類的安全地方載入這些字元串。
在這篇文章中,為了簡單起見,我們將為租戶存儲執行一個硬編碼的記憶體中模擬。
/// <summary> /// In memory store for testing /// </summary> public class InMemoryTenantStore : ITenantStore<Tenant> { /// <summary> /// Get a tenant for a given identifier /// </summary> /// <param name="identifier"></param> /// <returns></returns> public async Task<Tenant> GetTenantAsync(string identifier) { var tenant = new[] { new Tenant{ Id = "80fdb3c0-5888-4295-bf40-ebee0e3cd8f3", Identifier = "localhost" } }.SingleOrDefault(t => t.Identifier == identifier); return await Task.FromResult(tenant); } }
與ASP.NET Core管道集成
有兩個主要組成部分
- 註冊你的服務,以便可以解析它們
- 重新註冊一些中間件,以便您可以HttpContext在請求管道中將租戶資訊添加到當前資訊中,從而使下游消費者可以使用它
註冊服務
現在,我們有一個獲取租戶的策略,以及一個使租戶脫離的位置,我們需要在應用程式容器中註冊這些服務。我們希望該庫易於使用,因此我們將使用構建器模式來提供積極的服務註冊體驗。
首先,我們添加一點擴展以支援.AddMultiTenancy()語法。
/// <summary> /// Nice method to create the tenant builder /// </summary> public static class ServiceCollectionExtensions { /// <summary> /// Add the services (application specific tenant class) /// </summary> /// <param name="services"></param> /// <returns></returns> public static TenantBuilder<T> AddMultiTenancy<T>(this IServiceCollection services) where T : Tenant => new TenantBuilder<T>(services); /// <summary> /// Add the services (default tenant class) /// </summary> /// <param name="services"></param> /// <returns></returns> public static TenantBuilder<Tenant> AddMultiTenancy(this IServiceCollection services) => new TenantBuilder<Tenant>(services); }
然後,我們將讓構建器提供「流暢的」擴展。
/// <summary> /// Configure tenant services /// </summary> public class TenantBuilder<T> where T : Tenant { private readonly IServiceCollection _services; public TenantBuilder(IServiceCollection services) { _services = services; } /// <summary> /// Register the tenant resolver implementation /// </summary> /// <typeparam name="V"></typeparam> /// <param name="lifetime"></param> /// <returns></returns> public TenantBuilder<T> WithResolutionStrategy<V>(ServiceLifetime lifetime = ServiceLifetime.Transient) where V : class, ITenantResolutionStrategy { _services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>(); _services.Add(ServiceDescriptor.Describe(typeof(ITenantResolutionStrategy), typeof(V), lifetime)); return this; } /// <summary> /// Register the tenant store implementation /// </summary> /// <typeparam name="V"></typeparam> /// <param name="lifetime"></param> /// <returns></returns> public TenantBuilder<T> WithStore<V>(ServiceLifetime lifetime = ServiceLifetime.Transient) where V : class, ITenantStore<T> { _services.Add(ServiceDescriptor.Describe(typeof(ITenantStore<T>), typeof(V), lifetime)); return this; } }
現在,在.NET Core Web應用程式ConfigureServices中的StartUp類部分中,您可以添加以下內容。
services.AddMultiTenancy() .WithResolutionStrategy<HostResolutionStrategy>() .WithStore<InMemoryTenantStore>();
這是一個很好的開始但接下來您可能會希望支援傳遞選項,例如,如果不使用整個域,可能會有一個模式從主機中提取tenantId等,但它現在可以完成任務。
此時,您將能夠將存儲或解析方案策略注入到控制器中,但這有點低級。您不想在要訪問租戶的任何地方都必須執行這些解決步驟。接下來,讓我們創建一個服務以允許我們訪問當前的租戶對象。
/// <summary> /// Tenant access service /// </summary> /// <typeparam name="T"></typeparam> public class TenantAccessService<T> where T : Tenant { private readonly ITenantResolutionStrategy _tenantResolutionStrategy; private readonly ITenantStore<T> _tenantStore; public TenantAccessService(ITenantResolutionStrategy tenantResolutionStrategy, ITenantStore<T> tenantStore) { _tenantResolutionStrategy = tenantResolutionStrategy; _tenantStore = tenantStore; } /// <summary> /// Get the current tenant /// </summary> /// <returns></returns> public async Task<T> GetTenantAsync() { var tenantIdentifier = await _tenantResolutionStrategy.GetTenantIdentifierAsync(); return await _tenantStore.GetTenantAsync(tenantIdentifier); } }
並更新構建器以也註冊此服務
public TenantBuilder(IServiceCollection services) { services.AddTransient<TenantAccessService<T>>(); _services = services; }
酷酷酷酷。現在,您可以通過將服務注入控制器來訪問當前租戶
/// <summary> /// A controller that returns a value /// </summary> [Route("api/values")] [ApiController] public class Values : Controller { private readonly TenantAccessService<Tenant> _tenantService; /// <summary> /// Constructor with required services /// </summary> /// <param name="tenantService"></param> public Values(TenantAccessService<Tenant> tenantService) { _tenantService = tenantService; } /// <summary> /// Get the value /// </summary> /// <param name="definitionId"></param> /// <returns></returns> [HttpGet("")] public async Task<string> GetValue(Guid definitionId) { return (await _tenantService.GetTenantAsync()).Id; } }
運行,您應該會看到根據URL返回的租戶ID。

接下來,我們可以添加一些中間件,以將當前的Tenant注入到HttpContext中,這意味著我們可以在可以訪問HttpContext的任何地方獲取Tenant,從而更加方便。這將意味著我們不再需要大量地注入TenantAccessService。
註冊中間件
ASP.NET Core中的中間件使您可以將一些邏輯放入請求處理管道中。在本例中,我們應該在需要訪問Tenant資訊的任何內容(例如MVC中間件)之前註冊中間件。這很可能需要處理請求的控制器中的租戶上下文。
首先讓我們創建我們的中間件類,這將處理請求並將其注入Tenant當前HttpContext-超級簡單。
internal class TenantMiddleware<T> where T : Tenant { private readonly RequestDelegate next; public TenantMiddleware(RequestDelegate next) { this.next = next; } public async Task Invoke(HttpContext context) { if (!context.Items.ContainsKey(Constants.HttpContextTenantKey)) { var tenantService = context.RequestServices.GetService(typeof(TenantAccessService<T>)) as TenantAccessService<T>; context.Items.Add(Constants.HttpContextTenantKey, await tenantService.GetTenantAsync()); } //Continue processing if (next != null) await next(context); } }
接下來,我們創建一個擴展類使用它。
/// <summary> /// Nice method to register our middleware /// </summary> public static class IApplicationBuilderExtensions { /// <summary> /// Use the Teanant Middleware to process the request /// </summary> /// <typeparam name="T"></typeparam> /// <param name="builder"></param> /// <returns></returns> public static IApplicationBuilder UseMultiTenancy<T>(this IApplicationBuilder builder) where T : Tenant => builder.UseMiddleware<TenantMiddleware<T>>(); /// <summary> /// Use the Teanant Middleware to process the request /// </summary> /// <typeparam name="T"></typeparam> /// <param name="builder"></param> /// <returns></returns> public static IApplicationBuilder UseMultiTenancy(this IApplicationBuilder builder) => builder.UseMiddleware<TenantMiddleware<Tenant>>(); }
最後,我們可以註冊我們的中間件,這樣做的最佳位置是在中間件之前,例如MVC可能需要訪問Tenant資訊的地方。
app.UseMultiTenancy(); app.UseMvc()
現在,Tenant它將位於items集合中,但我們並不是真的要強迫開發人員找出將其存儲在哪裡,記住類型,需要對其進行轉換等。因此,我們將創建一個不錯的擴展方法來提取列出當前的租戶資訊。
/// <summary> /// Extensions to HttpContext to make multi-tenancy easier to use /// </summary> public static class HttpContextExtensions { /// <summary> /// Returns the current tenant /// </summary> /// <typeparam name="T"></typeparam> /// <param name="context"></param> /// <returns></returns> public static T GetTenant<T>(this HttpContext context) where T : Tenant { if (!context.Items.ContainsKey(Constants.HttpContextTenantKey)) return null; return context.Items[Constants.HttpContextTenantKey] as T; } /// <summary> /// Returns the current Tenant /// </summary> /// <param name="context"></param> /// <returns></returns> public static Tenant GetTenant(this HttpContext context) { return context.GetTenant<Tenant>(); } }
現在,我們可以修改我們的Values控制器,演示使用當前的HttpContext而不是注入服務。
/// <summary> /// A controller that returns a value /// </summary> [Route("api/values")] [ApiController] public class Values : Controller { /// <summary> /// Get the value /// </summary> /// <param name="definitionId"></param> /// <returns></returns> [HttpGet("")] public async Task<string> GetValue(Guid definitionId) { return await Task.FromResult(HttpContext.GetTenant().Id); } }
如果運行,您將得到相同的結果?

我們的應用程式是「租戶感知」的。這是一個重大的里程碑。
『加個餐』,租戶上下文訪問者
在ASP.NET Core中,可以使用IHttpContextAccessor訪問服務內的HttpContext,為了開發人員提供對租戶資訊的熟悉訪問模式,我們可以創建ITenantAccessor服務。
首先定義一個介面
public interface ITenantAccessor<T> where T : Tenant { T Tenant { get; } }
然後實現
public class TenantAccessor<T> : ITenantAccessor<T> where T : Tenant { private readonly IHttpContextAccessor _httpContextAccessor; public TenantAccessor(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public T Tenant => _httpContextAccessor.HttpContext.GetTenant<T>(); }
現在,如果下游開發人員想要向您的應用程式添加一個需要訪問當前租戶上下文的服務,他們只需以與使用IHttpContextAccessor完全相同的方式注入ITenantAccessor<T>⚡⚡
只需將該TenantAccessService<T>類標記為內部類,這樣就不會在我們的程式集之外錯誤地使用它。
小結
在這篇文章中,我們研究了如何將請求映射到租戶。我們將應用程式容器配置為能夠解析我們的租戶服務,甚至創建了ITenantAccessor服務,以允許在其他服務(如IHttpContextAccessor)內部訪問該租賃者。我們還編寫了自定義中間件,將當前的租戶資訊注入到HttpContext中,以便下游中間件可以輕鬆訪問它,並創建了一個不錯的擴展方法,以便您可以像HttpContext.GetTenant()一樣輕鬆地獲取當前的Tenant。在下一篇文章中,我們將研究按租戶隔離數據訪問。
在本系列的下一篇文章中,我們將介紹如何在每個租戶的基礎上配置服務,以便我們可以根據活動的租戶解析不同的實現。