Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
September 11, 2021 10:46 pm GMT

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

Introduccin

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 ocacin).

Utilizaremos distintos estilos de patrones para emplear mecanismos que nos facilitarn el da a da en una aplicacin 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) (proximamente)
  • ASP.NET Core 6: Multi-tenant Multi-Database (Parte 3) (proximamente)

Te recomiendo que este tutorial lo veas junto con el cdigo de ejemplo ya que hay muchos snippets y se volver un poco ms extenso con las dems partes.

Si tienes alguna pregunta, no dudes en contactarme por mi twitter @balunatic.

Qu es una aplicacin multi-tenant?

Es una aplicacin que responde diferente dependiendo de cual "tenant" se est accesando, existen distintas formas de crear aplicaciones multi-tenant:

  • multi-aplicacin: Cada tenant tiene sus propios recursos y dependencias y se ejecuta todo por separado.
  • single database: Todos los tenants corren en la misma aplicacin y en la misma base de datos. Aqu hay que tener cuidado para nunca exponer informacin de un tenant en otro, lo veremos en este post.
  • multi database: Todos los tenants tienen su propia base de datos pero utilizan la misma aplicacin.

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 artculo explica muy bien las formas de hacer multi-tenancy y lo que hay que considerar.

Qu requerimientos tiene una aplicacin multi-tenant?

Hay un un par de requerimientos que deberamos cumplir para crear una aplicacin multi-tenant.

Resolucin del Tenant

Segn la solicitud HTTP que llegue a nuestro servicio, debemos de determinar que tenant se est accesando y as establecer cadenas de conexin a bases de datos, configuracin y entre otras cosas.

Configuracin del Tenant

La aplicacin podra configurarse diferente segn 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 informacin y solo a su informacin. Ya sea que utilicemos una sola base de datos o varias bases de datos por tenant, es importante establecer la infraestructura adecuada para hacer ms difcil a los developers de que se equivoquen y mostrar informacin de otro tenant por algn error de cdigo.

Resolver el tenant

Para resolver un tenant primero necesitamos su representacin en una clase, aqu podemos agregar lo que ms 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 podra ser la llave primaria de la base de datos) y este no cambiar, Identifier podra 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 segn el request, la estrategia no debe basarse en ningn servicio o dato externo, as lo hacemos mejor estructurado y rpido.

Segn el Host

El tenant se determinar segn 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 podramos soportar dominios personalizados para cada tenant.

Segn un Header

El tenant podra ser determinado segn un valor de algn HTTP Header, por ejemplo X-Tenant: cliente1. Este es ms comn cuando la aplicacin multi-tenant es una API como https://api.contoso.com y la aplicacin cliente especifica el valor del tenant.

Segn el URL

Otro tambin muy comn es por el path del request. Se utiliza un mismo dominio pero segn 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 aplicacin sepa que estrategia utilizar, deberamos de poder implementar un servicio de ITenantResolutionStrategy el cual segn el request, no se regresar el tenant (el identifier).

public interface ITenantResolutionStrategy{    Task<string> GetTenantIdentifierAsync();}

En este post, implementaremos la resolucin de tenants segn 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 dnde 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 algn origen de datos.

public interface ITenantStore<T> where T : Tenant{    Task<T> GetTenantAsync(string identifier);}

Por qu hicimos el store genrico? Realmente estamos diseando una solucin reutilizable, alguien ms en nuestra organizacin podra usar nuestra librera y debemos de permitir que pueda adaptarla a las necesidades del proyecto.

La clase Tenant puede almacenar cualquier tipo de informacin. Si tuviramos muchas bases de datos probablemente vamos a querer guardar cadenas de conexin del tenant en este mismo objeto, pero podra ser algo inseguro ya que estamos trabajando con informacin sensible y lo recomendable es utilizar el patrn Options por tenant o algn 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 informacin propia de los tenants.

Por ahora solo necesitaremos un DbContext de Entity Framework: TenantAdminDbContext (el que administra los tenants) y posteriormente crearemos ms.

Para trabajar con Entity Framework necesitamos los siguientes paquetes (al da 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 descripcin 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 vlido");            tenant = new Tenant(entity.TenantId, entity.Identifier);            tenant.Items["Name"] = entity.Name;            _cache.Set(cacheKey, tenant);        }        return tenant;    }}

Esta implementacin puede variar a como lo necesites, este es solo un ejemplo prctico. 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.

Integracin 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 aplicacin.

Queremos que esto funcione como una librera 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 especfica)    /// </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 implementacin de Resolucin 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 implementacin 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" podramos consultar el tenant segn el request, pero aparte de que nos falta configurar la base de datos (y crear unos tenants de ejemplo) sera muy latoso siempre estar usando el ITenantResolutionStrategy junto con el ITenantStore para estar consultando el tenant actual.

Por lo que la solucin 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 extensin.

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 middlewareapp.UseRouting();app.UseAuthorization();app.MapRazorPages();app.Run();

En este caso estamos usando Razor Pages, pero realmente eso no importa podra ser MVC clsico o una Web API.

Ahora que el Tenant ya se encuentra accessible dentro del HttpContext podemos escribir la siguiente extensin (y ltima) para poder acceder a l de una manera ms prctica.

/// <summary>/// Extensiones de HttpContext para hacer multi-tenancy ms fcil 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 ms 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 migracin 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/TenantAdmindotnet ef database update

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 todava 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.

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 pequeo 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 dinmica a los Entitites que corresponden a cada quien segn el Tenant, pero esto quedar para el siguiente post.

Conclusin

En este post vimos como crear los mecanismos de deteccin de tenants y su implementacin para el escenario de multi-tenant que elegimos.

Gracias a las interfaces se pueden implementar las estrategias de resolucin de tenants como se desee y tambin el repositorio de tenants.

Gracias a las extensiones y middlewares, de una forma muy sencilla (HttpContext) podemos acceder al Tenant actual segn el request.

Existen distintas formas de hacer esto, pero me gust esta solucin que originalmente propone Michal McKenna que en este post explica esta solucin en ingles, en la cual me bas principalmente (ms del 99% ). Thanks Micke!.

Muchos saludos y sigue aprendiendo .


Original Link: https://dev.to/isaacojeda/asp-net-core-6-creando-una-app-multi-tenant-parte-1-3df5

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To