(译)创建.NET Core多租户应用程序-租户解析

介绍

本系列博客文章探讨了如何在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提供程序的身份验证密钥,连接字符串等。

租户数据隔离

租户将需要能够访问他们的数据,以及仅仅访问他们自己的数据。这可以通过在单个数据存储中对数据进行分区或通过使用每个租户的数据存储来实现。无论我们使用哪种模式,我们都应该使开发人员在跨租户场景中难以公开数据以避免编码错误。

租户解析

对于任何多租户应用程序,我们都需要能够识别请求在哪个租户下运行,但是在我们太兴奋之前,我们需要确定查找租户所需的数据。在此阶段,我们实际上只需要一个信息,即租户标识符。

代码语言:javascript
复制
/// <summary>
/// Tenant information
/// </summary>
public class Tenant
{
    /// <summary>
    /// The tenant Id
    /// </summary>
    public string Id { get; set; }
/// &lt;summary&gt;
/// The tenant identifier
/// &lt;/summary&gt;
public string Identifier { get; set; }

/// &lt;summary&gt;
/// Tenant items
/// &lt;/summary&gt;
public Dictionary&lt;string, object&gt; Items { get; private set; } = new Dictionary&lt;string, object&gt;();

}

我们将Identifier根据解析方案策略使用来匹配租户(可能是租户的域名,例如https://{tenant}.myapplication.com)

我们将使用它Id作为对租户的持久引用(Identifier可能会更改,例如主机域更改)。

该属性Items仅用于让开发人员在请求管道期间向租户添加其他内容,如果他们需要特定的属性或方法,他们还可以扩展该类。

常见的租户解决策略

我们将使用解决方案策略将请求匹配到租户,该策略不应依赖任何外部数据来使其变得美观,快速。

主机头

将根据浏览器发送的主机头来推断租户,如果所有租户都具有不同的域(例如)https://host1.example.comhttps://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将请求解析为租户标识符的服务。

代码语言:javascript
复制
public interface ITenantResolutionStrategy
{
Task<string> GetTenantIdentifierAsync();
}

在这篇文章中,我们将实现一个策略,从主机头那里解析租户。

代码语言:javascript
复制
/// <summary>
/// Resolve the host to a tenant identifier
/// </summary>
public class HostResolutionStrategy : ITenantResolutionStrategy
{
private readonly IHttpContextAccessor _httpContextAccessor;

public HostResolutionStrategy(IHttpContextAccessor httpContextAccessor)
{
    _httpContextAccessor = httpContextAccessor;
}

/// &lt;summary&gt;
/// Get the tenant identifier
/// &lt;/summary&gt;
/// &lt;param name=&#34;context&#34;&gt;&lt;/param&gt;
/// &lt;returns&gt;&lt;/returns&gt;
public async Task&lt;string&gt; GetTenantIdentifierAsync()
{
    return await Task.FromResult(_httpContextAccessor.HttpContext.Request.Host.Host);
}

}

租户存储

现在我们知道要加载哪个租户,该从哪里获取?那将需要某种租户存储。我们将需要实现一个ITenantStore接受承租人标识符并返回Tenant信息的。

代码语言:javascript
复制
public interface ITenantStore<T> where T : Tenant
{
Task<T> GetTenantAsync(string identifier);
}

我为什么要使泛型存储?万一我们想在使用我们库的项目中获得更多特定于应用程序的租户信息,我们可以扩展租户使其具有应用程序级别所需的任何其他属性,并适当地配置存储

如果要针对租户存储连接字符串之类的内容,则需要将其放置在安全的地方,并且最好使用每个租户模式的选项配置,并从诸如Azure Key Vault之类的安全地方加载这些字符串。

在这篇文章中,为了简单起见,我们将为租户存储执行一个硬编码的内存中模拟。

代码语言:javascript
复制
/// <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()语法。

代码语言:javascript
复制
/// <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);

/// &lt;summary&gt;
/// Add the services (default tenant class)
/// &lt;/summary&gt;
/// &lt;param name=&#34;services&#34;&gt;&lt;/param&gt;
/// &lt;returns&gt;&lt;/returns&gt;
public static TenantBuilder&lt;Tenant&gt; AddMultiTenancy(this IServiceCollection services) 
    =&gt; new TenantBuilder&lt;Tenant&gt;(services);

}

然后,我们将让构建器提供“流畅的”扩展。

代码语言:javascript
复制
/// <summary>
/// Configure tenant services
/// </summary>
public class TenantBuilder<T> where T : Tenant
{
private readonly IServiceCollection _services;

public TenantBuilder(IServiceCollection services)
{
    _services = services;
}

/// &lt;summary&gt;
/// Register the tenant resolver implementation
/// &lt;/summary&gt;
/// &lt;typeparam name=&#34;V&#34;&gt;&lt;/typeparam&gt;
/// &lt;param name=&#34;lifetime&#34;&gt;&lt;/param&gt;
/// &lt;returns&gt;&lt;/returns&gt;
public TenantBuilder&lt;T&gt; WithResolutionStrategy&lt;V&gt;(ServiceLifetime lifetime = ServiceLifetime.Transient) where V : class, ITenantResolutionStrategy
{
    _services.TryAddSingleton&lt;IHttpContextAccessor, HttpContextAccessor&gt;();
    _services.Add(ServiceDescriptor.Describe(typeof(ITenantResolutionStrategy), typeof(V), lifetime));
    return this;
}

/// &lt;summary&gt;
/// Register the tenant store implementation
/// &lt;/summary&gt;
/// &lt;typeparam name=&#34;V&#34;&gt;&lt;/typeparam&gt;
/// &lt;param name=&#34;lifetime&#34;&gt;&lt;/param&gt;
/// &lt;returns&gt;&lt;/returns&gt;
public TenantBuilder&lt;T&gt; WithStore&lt;V&gt;(ServiceLifetime lifetime = ServiceLifetime.Transient) where V : class, ITenantStore&lt;T&gt;
{
    _services.Add(ServiceDescriptor.Describe(typeof(ITenantStore&lt;T&gt;), typeof(V), lifetime));
    return this;
}

}

现在,在.NET Core Web应用程序ConfigureServices中的StartUp类部分中,您可以添加以下内容。

代码语言:javascript
复制
services.AddMultiTenancy()
.WithResolutionStrategy<HostResolutionStrategy>()
.WithStore<InMemoryTenantStore>();

这是一个很好的开始但接下来您可能会希望支持传递选项,例如,如果不使用整个域,可能会有一个模式从主机中提取tenantId等,但它现在可以完成任务。

此时,您将能够将存储或解析方案策略注入到控制器中,但这有点低级。您不想在要访问租户的任何地方都必须执行这些解决步骤。接下来,让我们创建一个服务以允许我们访问当前的租户对象。

代码语言:javascript
复制
/// <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&lt;T&gt; tenantStore)
{
    _tenantResolutionStrategy = tenantResolutionStrategy;
    _tenantStore = tenantStore;
}

/// &lt;summary&gt;
/// Get the current tenant
/// &lt;/summary&gt;
/// &lt;returns&gt;&lt;/returns&gt;
public async Task&lt;T&gt; GetTenantAsync()
{
    var tenantIdentifier = await _tenantResolutionStrategy.GetTenantIdentifierAsync();
    return await _tenantStore.GetTenantAsync(tenantIdentifier);
}

}

并更新构建器以也注册此服务

代码语言:javascript
复制
public TenantBuilder(IServiceCollection services)
{
services.AddTransient<TenantAccessService<T>>();
_services = services;
}

酷酷酷酷。现在,您可以通过将服务注入控制器来访问当前租户

代码语言:javascript
复制
/// <summary>
/// A controller that returns a value
/// </summary>
[Route("api/values")]
[ApiController]
public class Values : Controller
{

private readonly TenantAccessService&lt;Tenant&gt; _tenantService;

/// &lt;summary&gt;
/// Constructor with required services
/// &lt;/summary&gt;
/// &lt;param name=&#34;tenantService&#34;&gt;&lt;/param&gt;
public Values(TenantAccessService&lt;Tenant&gt; tenantService)
{
    _tenantService = tenantService;
}

/// &lt;summary&gt;
/// Get the value
/// &lt;/summary&gt;
/// &lt;param name=&#34;definitionId&#34;&gt;&lt;/param&gt;
/// &lt;returns&gt;&lt;/returns&gt;
[HttpGet(&#34;&#34;)]
public async Task&lt;string&gt; GetValue(Guid definitionId)
{
    return (await _tenantService.GetTenantAsync()).Id;
}

}

运行,您应该会看到根据URL返回的租户ID。

接下来,我们可以添加一些中间件,以将当前的Tenant注入到HttpContext中,这意味着我们可以在可以访问HttpContext的任何地方获取Tenant,从而更加方便。这将意味着我们不再需要大量地注入TenantAccessService。

注册中间件

ASP.NET Core中的中间件使您可以将一些逻辑放入请求处理管道中。在本例中,我们应该在需要访问Tenant信息的任何内容(例如MVC中间件)之前注册中间件。这很可能需要处理请求的控制器中的租户上下文。

首先让我们创建我们的中间件类,这将处理请求并将其注入Tenant当前HttpContext-超级简单。

代码语言:javascript
复制
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&lt;T&gt;)) as TenantAccessService&lt;T&gt;;
        context.Items.Add(Constants.HttpContextTenantKey, await tenantService.GetTenantAsync());
    }

    //Continue processing
    if (next != null)
        await next(context);
}

}

接下来,我们创建一个扩展类使用它。

代码语言:javascript
复制
/// <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>>();

/// &lt;summary&gt;
/// Use the Teanant Middleware to process the request
/// &lt;/summary&gt;
/// &lt;typeparam name=&#34;T&#34;&gt;&lt;/typeparam&gt;
/// &lt;param name=&#34;builder&#34;&gt;&lt;/param&gt;
/// &lt;returns&gt;&lt;/returns&gt;
public static IApplicationBuilder UseMultiTenancy(this IApplicationBuilder builder) 
    =&gt; builder.UseMiddleware&lt;TenantMiddleware&lt;Tenant&gt;&gt;();

}

最后,我们可以注册我们的中间件,这样做的最佳位置是在中间件之前,例如MVC可能需要访问Tenant信息的地方。

代码语言:javascript
复制
app.UseMultiTenancy();
app.UseMvc()

现在,Tenant它将位于items集合中,但我们并不是真的要强迫开发人员找出将其存储在哪里,记住类型,需要对其进行转换等。因此,我们将创建一个不错的扩展方法来提取列出当前的租户信息。

代码语言:javascript
复制
/// <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;
}

/// &lt;summary&gt;
/// Returns the current Tenant
/// &lt;/summary&gt;
/// &lt;param name=&#34;context&#34;&gt;&lt;/param&gt;
/// &lt;returns&gt;&lt;/returns&gt;
public static Tenant GetTenant(this HttpContext context)
{
    return context.GetTenant&lt;Tenant&gt;();
}

}

现在,我们可以修改我们的Values控制器,演示使用当前的HttpContext而不是注入服务。

代码语言:javascript
复制
/// <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服务。

首先定义一个接口

代码语言:javascript
复制
public interface ITenantAccessor<T> where T : Tenant
{
T Tenant { get; }
}

然后实现

代码语言:javascript
复制
public class TenantAccessor<T> : ITenantAccessor<T> where T : Tenant
{
private readonly IHttpContextAccessor _httpContextAccessor;

public TenantAccessor(IHttpContextAccessor httpContextAccessor)
{
    _httpContextAccessor = httpContextAccessor;
}

public T Tenant =&gt; _httpContextAccessor.HttpContext.GetTenant&lt;T&gt;();

}

现在,如果下游开发人员想要向您的应用程序添加一个需要访问当前租户上下文的服务,他们只需以与使用IHttpContextAccessor完全相同的方式注入ITenantAccessor<T>⚡⚡

只需将该TenantAccessService<T>类标记为内部类,这样就不会在我们的程序集之外错误地使用它。

小结

在这篇文章中,我们研究了如何将请求映射到租户。我们将应用程序容器配置为能够解析我们的租户服务,甚至创建了ITenantAccessor服务,以允许在其他服务(如IHttpContextAccessor)内部访问该租赁者。我们还编写了自定义中间件,将当前的租户信息注入到HttpContext中,以便下游中间件可以轻松访问它,并创建了一个不错的扩展方法,以便您可以像HttpContext.GetTenant()一样轻松地获取当前的Tenant。在下一篇文章中,我们将研究按租户隔离数据访问。

在本系列的下一篇文章中,我们将介绍如何在每个租户的基础上配置服务,以便我们可以根据活动的租户解析不同的实现。