ASP.NET Core 6: Creando una app Multi-tenant (Parte 1)

isaacojeda

Isaac Ojeda

Posted on September 11, 2021

ASP.NET Core 6: Creando una app Multi-tenant (Parte 1)

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:

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; }
}


Enter fullscreen mode Exit fullscreen mode

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();
}


Enter fullscreen mode Exit fullscreen mode

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);
    }
}


Enter fullscreen mode Exit fullscreen mode

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);
}


Enter fullscreen mode Exit fullscreen mode

¿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>


Enter fullscreen mode Exit fullscreen mode

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; }
}


Enter fullscreen mode Exit fullscreen mode


using Microsoft.EntityFrameworkCore;
using MultiTenantSingleDatabase.Models;

public class TenantAdminDbContext : DbContext
{
    public TenantAdminDbContext(DbContextOptions<TenantAdminDbContext> options)
        : base(options) { }

    public DbSet<Tenant> Tenants { get; set; }
}


Enter fullscreen mode Exit fullscreen mode

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;
    }
}


Enter fullscreen mode Exit fullscreen mode

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);
}


Enter fullscreen mode Exit fullscreen mode

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;
    }
}


Enter fullscreen mode Exit fullscreen mode

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>();


Enter fullscreen mode Exit fullscreen mode

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);
    }
}


Enter fullscreen mode Exit fullscreen mode

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>>();
}


Enter fullscreen mode Exit fullscreen mode

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();


Enter fullscreen mode Exit fullscreen mode

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>();
}


Enter fullscreen mode Exit fullscreen mode

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")));


Enter fullscreen mode Exit fullscreen mode

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": "*"
}


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

Esto ya creará la base de datos (dentro de C:\Users\<user>\MultiTenant_Admin.mdf)

Untitled

Untitled 1

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>


Enter fullscreen mode Exit fullscreen mode

Y el resultado.

Untitled 2

Ya que estoy mostrando el nombre del tenant (no el Identifier) se muestra "My localhots Tenant".

Así tengo mi BD.

Untitled 3

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.

Untitled 4

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 💪🏽.

💖 💪 🙅 🚩
isaacojeda
Isaac Ojeda

Posted on September 11, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related