ASP.NET Core 6: Creando una app Multi-tenant (Parte 1)
Isaac Ojeda
Posted on September 11, 2021
Introducción
En esta serie de posts estaremos viendo una de las formas que se pueden realizar aplicaciones multi-tenant en ASP.NET Core (Razor Pages en esta ocación).
Utilizaremos distintos estilos de patrones para emplear mecanismos que nos facilitarán el día a día en una aplicación multi-tenant.
Esta serie de posts se dividen en 3 partes:
- ASP.NET Core 6: Creando una app Multi-tenant (Parte 1) (este post)
- ASP.NET Core 6: Multi-tenant Single Database (Parte 2)
- ASP.NET Core 6: Multi-tenant Multi-Database (Parte 3)
Te recomiendo que este tutorial lo veas junto con el código de ejemplo ya que hay muchos snippets y se volverá un poco más extenso con las demás partes.
Si tienes alguna pregunta, no dudes en contactarme por mi twitter @balunatic.
¿Qué es una aplicación multi-tenant?
Es una aplicación que responde diferente dependiendo de cual "tenant" se está accesando, existen distintas formas de crear aplicaciones multi-tenant:
- multi-aplicación: Cada tenant tiene sus propios recursos y dependencias y se ejecuta todo por separado.
- single database: Todos los tenants corren en la misma aplicación y en la misma base de datos. Aquí hay que tener cuidado para nunca exponer información de un tenant en otro, lo veremos en otro post.
- multi database: Todos los tenants tienen su propia base de datos pero utilizan la misma aplicación.
Cada estilo de multi-tenant apps tiene sus beneficios y se deben de considerar distintos factores (como escalabilidad, cantidad de tenants, almacenamiento por tenant, etc)
Este artículo explica muy bien las formas de hacer multi-tenancy y lo que hay que considerar.
¿Qué requerimientos tiene una aplicación multi-tenant?
Hay un un par de requerimientos que deberíamos cumplir para crear una aplicación multi-tenant.
Resolución del Tenant
Según la solicitud HTTP que llegue a nuestro servicio, debemos de determinar que tenant se está accesando y así establecer cadenas de conexión a bases de datos, configuración y entre otras cosas.
Configuración del Tenant
La aplicación podría configurarse diferente según el tenant que se está accediendo, como private keys de servicios externos y entre otras cosas.
Aislamiento del Tenant
Cada tenant debe de poder acceder a su información y solo a su información. Ya sea que utilicemos una sola base de datos o varias bases de datos por tenant, es importante establecer la infraestructura adecuada para hacer más difícil a los developers de que se equivoquen y mostrar información de otro tenant por algún error de código.
Resolver el tenant
Para resolver un tenant primero necesitamos su representación en una clase, aquí podemos agregar lo que más nos sea útil de un tenant. Pero por practicidad podemos utilizar un diccionario y los datos que se quieran, ahí se agregan:
public class Tenant
{
public Tenant(int id, string identifier)
{
Id = id;
Identifier = identifier;
Items = new Dictionary<string, object>();
}
public int Id { get; }
public string Identifier { get; }
public Dictionary<string, object> Items { get; }
}
Utilizaremos el campo Identifier
para poder saber que tenant se está tratando de usar en la solicitud actual (Ejemplo. https://{identifier}.contoso.com
).
La propiedad Id
será nuestro identificador interno (el cual podría ser la llave primaria de la base de datos) y este no cambiará, Identifier
podría cambiar sin problema.
Y por último tenemos el diccionario Items
, que como mencionaba arriba, nos ayudará agregar cualquier propiedad adicional que creamos conveniente.
Formas comunes de resolver un tenant
Utilizaremos una estrategia para resolver el tenant según el request, la estrategia no debe basarse en ningún servicio o dato externo, así lo hacemos mejor estructurado y rápido.
Según el Host
El tenant se determinará según el host que es enviado por el navegador, este para mi es el mejor porque cada cliente (tenant) podrá tener su propio dominio o al menos un subdominio. Ejemplos: https://cliente1.contoso.com
, https://cliente2.contoso.com
.
En este caso, solo está cambiando el subdominio, pero podríamos soportar dominios personalizados para cada tenant.
Según un Header
El tenant podría ser determinado según un valor de algún HTTP Header, por ejemplo X-Tenant: cliente1
. Este es más común cuando la aplicación multi-tenant es una API como https://api.contoso.com
y la aplicación cliente especifica el valor del tenant.
Según el URL
Otro también muy común es por el path del request. Se utiliza un mismo dominio pero según la estructura del path (el url) se puede determinar el tenant que se quiere acceder. Por ejemplo https://contoso.com/cliente1/...
.
Definiendo una estrategia para resolver el tenant
Para permitir que la aplicación sepa que estrategia utilizar, deberíamos de poder implementar un servicio de ITenantResolutionStrategy
el cual según el request, no se regresará el tenant (el identifier).
public interface ITenantResolutionStrategy
{
Task<string> GetTenantIdentifierAsync();
}
En este post, implementaremos la resolución de tenants según el Host.
public class HostResolutionStrategy : ITenantResolutionStrategy
{
private readonly HttpContext? _httpContext;
public HostResolutionStrategy(IHttpContextAccessor httpContext)
{
_httpContext = httpContext.HttpContext;
}
public async Task<string> GetTenantIdentifierAsync()
{
if (_httpContext is null)
{
return string.Empty;
}
return await Task.FromResult(_httpContext.Request.Host.Host);
}
}
Almacenamiento de Tenants
Ahora ya sabemos que tenant debemos resolver, pero ahora la pregunta es ¿De dónde obtenemos los tenants? Para eso necesitamos un repositorio o "store" para consultar los tenants que tenemos disponibles. Para hacerlo independiente a la persistencia, implementaremos un ITenantStore
el cual aceptará el Identifier
del tenant para buscarlo en algún origen de datos.
public interface ITenantStore<T> where T : Tenant
{
Task<T> GetTenantAsync(string identifier);
}
¿Por qué hicimos el store genérico? Realmente estamos diseñando una solución reutilizable, alguien más en nuestra organización podría usar nuestra librería y debemos de permitir que pueda adaptarla a las necesidades del proyecto.
La clase Tenant
puede almacenar cualquier tipo de información. Si tuviéramos muchas bases de datos probablemente vamos a querer guardar cadenas de conexión del tenant en este mismo objeto, pero podría ser algo inseguro ya que estamos trabajando con información sensible y lo recomendable es utilizar el patrón Options por tenant o algún Vault como el de Azure.
En este post vamos a guardar los tenants en una base de datos y en otros posts tendremos otra(s) base de datos para la información propia de los tenants.
Por ahora solo necesitaremos un DbContext
de Entity Framework: TenantAdminDbContext
(el que administra los tenants) y posteriormente crearemos más.
Para trabajar con Entity Framework necesitamos los siguientes paquetes (al día de este post, siguen estando en preview).
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.0-preview.7.21378.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.0-preview.7.21378.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.0-preview.7.21378.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
Y nuestro contexto que administrará los tenants quedará de la siguiente forma:
/// <summary>
/// Entity Tenant (diferente a Infrastructure.Tenant)
/// </summary>
public class Tenant
{
public int TenantId { get; set; }
public string Name { get; set; }
public string Identifier { get; set; }
}
using Microsoft.EntityFrameworkCore;
using MultiTenantSingleDatabase.Models;
public class TenantAdminDbContext : DbContext
{
public TenantAdminDbContext(DbContextOptions<TenantAdminDbContext> options)
: base(options) { }
public DbSet<Tenant> Tenants { get; set; }
}
Aquí estamos definiendo un Entity Tenant que es diferente al Tenant
que encontramos dentro de Infrastructure > Multitenancy (uno es Dto y otro Domain Object).
La propiedad Name es para tener una descripción del tenant (Ejemplo: Contoso Crafts) y el Identifier (Ejemplo: contoso).
Ahora que ya tenemos nuestro origen de datos (Una base de datos con una tabla Tenants) podemos escribir nuestro TenantStore
.
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using MultiTenantSingleDatabase.Persistence;
public class DbContextTenantStore : ITenantStore<Tenant>
{
private readonly TenantAdminDbContext _context;
private readonly IMemoryCache _cache;
public DbContextTenantStore(TenantAdminDbContext context, IMemoryCache cache)
{
_context = context;
_cache = cache;
}
public async Task<Tenant> GetTenantAsync(string identifier)
{
var cacheKey = $"Cache_{identifier}";
var tenant = _cache.Get<Tenant>(cacheKey);
if (tenant is null)
{
var entity = await _context.Tenants
.FirstOrDefaultAsync(q => q.Identifier == identifier)
?? throw new ArgumentException($"identifier no es un tenant válido");
tenant = new Tenant(entity.TenantId, entity.Identifier);
tenant.Items["Name"] = entity.Name;
_cache.Set(cacheKey, tenant);
}
return tenant;
}
}
Esta implementación puede variar a como lo necesites, este es solo un ejemplo práctico. Podemos ver que incluso estamos agregando a caché los Tenants que se van consultando, porque esto se hará en cada request y si siempre consultamos a la BD esto será nada eficiente.
Integración con ASP.NET Core
Apenas vamos a mitad de camino. Ya tenemos lo esencial para resolver los tenants pero ahora falta conectar algunos cables para que esto empiece a funcionar.
Registrando los servicios
Ahora que ya tenemos la forma de diferenciar los tenants y un lugar donde consultarlos, necesitamos registrar estos servicios como dependencias de nuestra aplicación.
Queremos que esto funcione como una librería que se pueda extender, por eso haremos uso de estilos "fluent" y "builders".
Primero, crearemos una extension siguiendo el estilo de registrar servicios de asp.net core con una sintaxis .AddMultiTenancy()
.
public static class ServiceCollectionExtensions
{
/// <summary>
/// Agrega los servicios (con clase específica)
/// </summary>
/// <param name="services"></param>
/// <returns></returns>
public static TenantBuilder<T> AddMultiTenancy<T>(this IServiceCollection services) where T : Tenant
=> new(services);
/// <summary>
/// Agrega los servicios (con clase default)
/// </summary>
/// <param name="services"></param>
/// <returns></returns>
public static TenantBuilder<Tenant> AddMultiTenancy(this IServiceCollection services)
=> new(services);
}
Y ahora el Builder.
public class TenantBuilder<T> where T : Tenant
{
private readonly IServiceCollection _services;
public TenantBuilder(IServiceCollection services)
{
_services = services;
}
/// <summary>
/// Registrar la implementación de Resolución de Tenants
/// </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>
/// Registrar la implementación del Repositorio de Tenants
/// </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;
}
}
Ahora dentro de nuestro Program.cs
registraremos estas dependencias (estamos con .NET 6 por lo que las plantillas default ya no incluyen un Startup
como antes).
builder.Services.AddMultiTenancy()
.WithResolutionStrategy<HostResolutionStrategy>()
.WithStore<DbContextTenantStore>();
Hasta este punto ya "casi" podríamos consultar el tenant según el request, pero aparte de que nos falta configurar la base de datos (y crear unos tenants de ejemplo) sería muy latoso siempre estar usando el ITenantResolutionStrategy
junto con el ITenantStore
para estar consultando el tenant actual.
Por lo que la solución será, un middleware.
Registrando el middleware
Los middlewares son muy útiles cuando queremos que algo se procese en el pipeline de la solicitud HTTP. En este caso, queremos que el tenant esté resuelto antes de que cualquier Controlador o Razor Page quiera usarlo, eso significa que este middleware debe de ir antes de Controllers o Razor Pages.
Primero creamos nuestra clase middleware para que inyecte el Tenant actual en la solicitud Http.
public 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(AppConstants.HttpContextTenantKey))
{
var tenantStore = context.RequestServices.GetService(typeof(ITenantStore<T>)) as ITenantStore<T>;
var resolutionStrategy = context.RequestServices.GetService(typeof(ITenantResolutionStrategy)) as ITenantResolutionStrategy;
var identifier = await resolutionStrategy.GetTenantIdentifierAsync();
var tenant = await tenantStore.GetTenantAsync(identifier));
context.Items.Add(AppConstants.HttpContextTenantKey, tenant);
}
//Continue processing
if (next != null)
await next(context);
}
}
Y ahora para registrarlo al estilo ASP.NET Core, creamos la siguiente extensión.
public static class ApplicationBuilderExtensions
{
/// <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>>();
}
Para terminar, registramos este middleware en el pipeline dentro del Program.cs
.
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseMultiTenancy(); // <--- custom middleware
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.Run();
En este caso estamos usando Razor Pages, pero realmente eso no importa podría ser MVC clásico o una Web API.
Ahora que el Tenant ya se encuentra accessible dentro del HttpContext
podemos escribir la siguiente extensión (y última) para poder acceder a él de una manera más práctica.
/// <summary>
/// Extensiones de HttpContext para hacer multi-tenancy más fácil de usar
/// </summary>
public static class HttpContextExtensions
{
/// <summary>
/// Regresa el Tenant actual
/// </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(AppConstants.HttpContextTenantKey))
return null;
return context.Items[AppConstants.HttpContextTenantKey] as T;
}
/// <summary>
/// Regresa el Tenant actual
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public static Tenant? GetTenant(this HttpContext context) => context.GetTenant<Tenant>();
}
Creando la Base de Datos
Para por fin crear la base de datos, debemos registrar el contexto dentro del Program.cs
.
builder.Services.AddDbContext<TenantAdminDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("TenantAdmin")));
Y podemos utilizar el siguiente connection string (junto con el otro que utilizaremos más adelante).
{
"ConnectionStrings": {
"TenantAdmin": "Server=(localdb)\\mssqllocaldb;Database=MultiTenant_Admin;Trusted_Connection=True;MultipleActiveResultSets=true",
"SingleTenant": "Server=(localdb)\\mssqllocaldb;Database=MultiTenantSingleDb;Trusted_Connection=True;MultipleActiveResultSets=true"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
}
Y para crear la base datos, hacemos una migración inicial y actualizamos la base de datos (solito creará la base de datos ya que esta no existirá inicialmente).
Lo siguiente, lo ejecutamos estando el proyecto principal.
dotnet ef migrations add InitTenantAdmin -o Persistence/Migrations/TenantAdmin
dotnet ef database update
Esto ya creará la base de datos (dentro de C:\Users\<user>\MultiTenant_Admin.mdf
)
Lo estamos organizando de esta manera porque todavía falta otro DbContext
que haremos en otro post.
Finalizando
Para poder probar que todo lo que hicimos funciona, podemos modificar cualquier controlador o Page que tengamos. En mi caso, como estoy usando Razor Pages, pues modificaré el Index.cshtml
.
@page
@model IndexModel
@using MultiTenantSingleDatabase.Infrastructure.Multitenancy
@{
ViewData["Title"] = "Home page";
}
<div class="text-center">
<h1 class="display-4">Welcome @HttpContext.GetTenant()?.Items["Name"] </h1>
</div>
Y el resultado.
Ya que estoy mostrando el nombre del tenant (no el Identifier) se muestra "My localhots Tenant".
Así tengo mi BD.
Para probar el segundo tenant, hay que hacer un pequeño truco para modificar el archivo hosts y poner un host que apunte a 127.0.0.1 (al igual que lo hace localhost). Puedes intentarlo aquí.
En fin, navegando al segundo tenant, me muestra el resultado esperado.
Lo que falta ahora, será crear un DbContext que realice queries de forma dinámica a los Entitites que corresponden a cada quien según el Tenant, pero esto quedará para el siguiente post.
Conclusión
En este post vimos como crear los mecanismos de detección de tenants y su implementación para el escenario de multi-tenant que elegimos.
Gracias a las interfaces se pueden implementar las estrategias de resolución de tenants como se desee y también el repositorio de tenants.
Gracias a las extensiones y middlewares, de una forma muy sencilla (HttpContext
) podemos acceder al Tenant actual según el request.
Existen distintas formas de hacer esto, pero me gustó esta solución que originalmente propone Michal McKenna que en este post explica esta solución en ingles, en la cual me basé principalmente (más del 99% 😅). Thanks Micke!.
Muchos saludos y sigue aprendiendo 💪🏽.
Posted on September 11, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.